Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1751637
fix(T696): align skill relay depth with policy limits
kryptobaseddev Oct 17, 2026
c346276
docs(T697): update OpenCode wake and eviction guidance
kryptobaseddev Oct 17, 2026
2501021
feat(T583): show harness last activity when the mailbox is quiet
kryptobaseddev Oct 17, 2026
ee1e8ba
fix(T677): keep silent open Codex turns active
kryptobaseddev Oct 17, 2026
5c929ce
feat(T187): keep an OpenCode resume note when the turn is busy
kryptobaseddev Oct 17, 2026
79fbbd4
Merge fix/t696-skill-relay-depth into integration/0.5.48 (T698)
kryptobaseddev Oct 17, 2026
f2efe63
Merge docs/t697-readme-opencode into integration/0.5.48 (T698)
kryptobaseddev Oct 17, 2026
c56cef6
Merge fix/t583-liveness into integration/0.5.48 (T698)
kryptobaseddev Oct 17, 2026
77ee9a7
Merge fix/t677-open-turn-activity into integration/0.5.48 (T698)
kryptobaseddev Oct 17, 2026
50d6619
Merge feat/t187-opencode-resume into integration/0.5.48 (T698)
kryptobaseddev Oct 17, 2026
0424b22
chore(T698): keep integrated entries under Unreleased
kryptobaseddev Oct 17, 2026
6f38782
fix(T667): requeue a watcher-exit wake dropped mid-turn instead of st…
kryptobaseddev Oct 17, 2026
9bc6e5f
test(T667): mid-turn wake-drop reproduction, re-drive once, no double…
kryptobaseddev Oct 17, 2026
0720eb3
docs(T667): changelog entry for the mid-turn wake requeue
kryptobaseddev Oct 17, 2026
dd53607
Merge fix/t667-watch-midturn into integration/0.5.48 (T698)
kryptobaseddev Oct 17, 2026
c01a36d
chore(T698): keep integrated entries under Unreleased
kryptobaseddev Oct 17, 2026
6653958
test(T185): lock Claude SessionStart fork and busy fixtures
kryptobaseddev Oct 17, 2026
ee8f88b
fix(T185): Claude fork skips bind and a live holder is named without …
kryptobaseddev Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## Unreleased

- **Claude SessionStart fork keeps the parent row, and a live holder is named without a takeover suggestion (T185).** A `source: fork` start skips bind and the parent-row delete and prints only the mailbox-context fallback. A session whose name is held live by another session prints that `held by live` reason and does not suggest takeover.

- **Skill relay-depth guidance matches policy enforcement (T696).** The skill now says ask allows 6 hops and collaborate, autonomous and yolo have no limit. A regression test checks the documented limits against `LEVEL_MAX_HOP`.

- **OpenCode wake documentation matches the shipped adapters (T697).** README and portability guidance describe standalone `resume: true` wakes, one shared service per machine for long-lived agents, and standalone/service-hosted recovery after MCP eviction (T659/T688).

- **Liveness follows the harness when the mailbox has gone quiet (T583).** `mbx_agents` and `agentmbx agents` timed a live holder by its last mbx call, so a session that was busy in its harness looked stale. A Codex lease now also uses that thread's rollout mtime (file metadata only, the same one-file rule as T677). A Grok lease uses `active_sessions.json`: a live row is a heartbeat, and a dead or reused pid is not. When that stamp is the newer one the reason says `last activity N min ago (codex rollout)` or `(grok session)`; otherwise it still says `last mbx activity`. A shared conversation becomes claimable only once both are quiet.

- **Silent Codex turns keep their mailbox identity (T677).** The existing MCP heartbeat records activity while the exact thread's rollout has an unmatched turn start, even when it has no new writes or mbx calls. Matching completion or turn-end records stop that protection; missing, unreadable or rotated evidence falls back to rollout mtime. Lease token, thread and process-birth fences remain in force. An abandoned open turn can conservatively retain a live holder until recovery.

- **OpenCode resume notes wait when the turn is busy or the session API is missing (T187).** A fresh `session.created`, and the first tool result of a quiet turn, still show the bounded `[mbx]` resume note: who the session is, what it has not caught up on, and `mbx_inbox`. A turn that is already running keeps that note and adds one fallback line (`mbx_whoami`, then `mbx_inbox`); the note is admitted when the turn ends. A host with no `synthetic` or `prompt` does the same, and a later tool result can still show the note. This path does not read or ack mail. The plugin template changed: run `agentmbx setup --only opencode` and reopen OpenCode tabs to load it.

- **A watcher-exit wake dropped mid-turn is not lost with its hint (T667).** A terminal Kimi session wakes itself: `agentmbx watch` exits when mail that wants it arrives, and the harness turns that background-task completion into the next turn. A completion that lands while a turn is running is dropped, not queued, and mail the watcher already reported sat `notified` and invisible to the dispatcher — after an exit-2 Stop continuation (Kimi runs one Stop per turn) nothing surfaced it until the next owner prompt. The exiting watcher now records which message ids its hint named, and the Stop hook reconciles that marker for mail the turn surfaces, so the two never double-fire. A marked delivery still unread once the batching window has passed is requeued to `delivered` exactly once — no requeue while a live watcher, a mute, or the brake would handle it — so it rides the next watcher report, prompt notice or notice fallback instead of stranding. Mail read or acked before the turn ends is never re-driven. Sessions whose hooks do not dispatch at all (T611) keep their documented MCP fallback.

## 0.5.47 (2026-10-16)

- **Service-hosted OpenCode sessions stay wakeable after MCP eviction (T688).** OpenCode stops an idle session's MCP about hourly, which releases its lease. T659 kept standalone sessions wakeable through their plugin's poll; a session hosted by the shared `opencode serve --service` is now a wake target too while that service process (same pid and birth) lives, and the wake posts to the service with `resume: true` and a verified receipt. A lease held by another session of the agent still blocks it. docs/OPENCODE.md recommends plain `opencode` (one service per machine, every project) for long-lived agents.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,20 @@ AgentMBX gives every agent the same small set of mailbox tools. It delivers mess
| CLI | Wake path | Status |
|---|---|---|
| Codex | `codex app-server` experimental `thread/queue/add`, `thread/queue/list`, and `thread/queue/delete`; AgentMBX tracks its owned notice and withdraws it after read/ack, release, or handover while still queued, preserving unowned items | requires a Codex build with the experimental queue API; historical live receipts |
| OpenCode | service-hosted session: the local service's session API (`/synthetic`). `--standalone` session: the plugin inside that session's own serve, over a loopback queue (see [OPENCODE.md](docs/OPENCODE.md)) | historical service-path live receipts; standalone wakes are admitted in-process, whether one starts a turn on an idle session is unproven (T544) |
| OpenCode | service-hosted session: the local service's session API (`/synthetic`, `resume: true`). `--standalone` session: its own plugin/loopback queue, also `resume: true` (T544; see [OPENCODE.md](docs/OPENCODE.md)) | recorded service-path live receipts and an idle standalone turn (T544); admission alone does not prove model execution |
| Claude Code | pushed by the session's own mbx MCP server through Claude Code's per-session inbox socket (`CLAUDE_CODE_MESSAGING_SOCKET`), so a plainly started `claude` wakes on mail with no flag, setting or cron; `agentmbx claude [args]` adds the research-preview mbx channel instead | historical macOS/Fedora live receipts |
| Kimi | desktop app: its local control socket (setup installs an AgentMBX plugin into the app). `kimi web`: the local server's prompts API. Terminal: a background `agentmbx watch` task the session keeps running (Kimi starts a turn when it exits) | historical terminal/desktop/web live receipts |
| Hermes | one background `agentmbx watch` task with completion notification; shell hooks bind the session and add inbox context before model calls | watcher requires re-arming after each wake; shared gateway hosts aren't covered |
| Grok | a background `agentmbx watch --cli grok --session <id>` task; exact session hooks | watcher requires re-arming after each wake |
| anything else | desktop notification | no automatic model turn implied |

For long-lived OpenCode agents, run plain `opencode` against the shared service, started once with
`opencode service start`: one service per machine, with each session keeping its own project folder.
After OpenCode evicts an idle session's MCP, standalone wakes remain available while that serve's plugin
polls the local daemon (T659); service-hosted wakes remain available while the recorded service process
is alive (T688). Both paths require the recorded PID and process birth, and a lease held by a different
session blocks the old binding. See [the run-mode and eviction guide](docs/OPENCODE.md#after-opencode-evicts-the-sessions-mcp).

- **Across machines:** a small daemon per host. Hosts pair with one command each: `agentmbx pair` prints a one-time token, `agentmbx join <host> <token>` on the other machine finishes it (or compare a 6-digit code instead). Hosts find each other on the LAN over mDNS. Every hop is signed. Messages to a sleeping machine wait in an outbox and retry for 72 h, and each is stored exactly once.
- **Wake batching:** one atomic 30-second window per agent, plus explicit wake mute. Status messages never wake anyone. The published runtime has no hourly per-thread or daily per-agent hard cap; loop detection is observational, so batching alone doesn't bound an automated conversation's token use.
- **Full-text search** (SQLite FTS5) and an audit log. `mbx:<id>@<host>` references can be cited from tickets and notes.
Expand Down
51 changes: 47 additions & 4 deletions dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ import { periodicUpdateCheck, updateAvailable, updateCommand } from "./update.js
import { installKind, version } from "./version.js";
import { installService, serviceLabel, uninstallService } from "./service.js";
import { CLIS, defaultHostName, defaultWhich, formatRows, ownerStep, resolveCommand, runSetup, selfHealSkill, shJoin } from "./setup.js";
import { dispatchWakes, hasWakeAuthority, humanPromptKey, inboxCommand, isHumanPrompt, macNotifierPath, muteWakes, notifyDesktop, opencodeService, liveWatcher, wakeMutedUntil, wakeText, watcherKey, which } from "./wake.js";
import { dispatchWakes, hasWakeAuthority, humanPromptKey, inboxCommand, isHumanPrompt, macNotifierPath, muteWakes, notifyDesktop, opencodeService, liveWatcher, wakeMutedUntil, wakeText, watcherKey, which, noteWatchReported, reconcileWatchReported } from "./wake.js";
import { kimiMultiHost } from "./kimi-web.js";
import { bindInstruction, issueBindTicket } from "./bind-ticket.js";
import { activityKey } from "./identity-availability.js";
import { activityKey, identityAvailability, parseActivity } from "./identity-availability.js";
import { fillCleoPresence } from "./cleo-presence.js";
import { identityLeaseStatus, inspectLeaseProcess } from "./identity-leases.js";
import { AUTO_NAME_RE, identityProjects, linkedKey, projectOf, recordSessionHint, registeredIdentity, removeStaleProjectBindings, staleProjectBindings } from "./registry.js";
Expand Down Expand Up @@ -1936,6 +1936,15 @@ async function hook(node, event, cli) {
const at = sessionPid(session);
return !!at?.pid && takeResumeEntry(node.store, cli, at.pid, at.pid_start, session);
};
// T185: a Claude fork is a different conversation. Skip bindSession and the parent-row delete.
// Record the entry on the parent row's pid so the fork's first prompt does not add a catch-up hint.
if (event === "session-start" && cli === "claude" && sid && input.source === "fork") {
const parent = node.store.db.prepare("SELECT pid,pid_start FROM sessions WHERE cli=? AND pid=? AND session_id NOT GLOB 'mcp-*'").get(cli, process.ppid);
if (parent?.pid)
takeResumeEntry(node.store, cli, parent.pid, parent.pid_start, sid);
emit(cli, "SessionStart", "[mbx] Mailbox context could not be restored. Call mbx_whoami, then mbx_catchup.");
return;
}
// Inspect provider capabilities and processes before the lease transaction. No directory-based session discovery.
const host = cli === "claude" ? detectHost(process.ppid) : null;
const watch = event === "session-start" && noPush(cli, !!host && (host.channel || host.socket), cli === "kimi" && kimiMultiHost(process.ppid));
Expand Down Expand Up @@ -2003,8 +2012,8 @@ async function hook(node, event, cli) {
}
if (event === "session-start") {
// T185: a bound Claude SessionStart is itself the entry (startup, resume, clear, compact; T184 spec C1): it carries the
// resume notice even on an unchanged id, because /clear and /compact do not re-send the MCP instructions. A fork only
// records the entry (D4/C2), so no notice is deferred to its first prompt either. T186: a bound Codex SessionStart is
// resume notice even on an unchanged id, because /clear and /compact do not re-send the MCP instructions. A fork
// returned before this bind. T186: a bound Codex SessionStart is
// the entry too (a missing source counts as startup); its payload has no event id, so clear/compact re-inject on the
// same thread id and enteredNow only stops the following prompt from adding a second copy.
const hookSource = typeof input.source === "string" && input.source ? input.source : "startup";
Expand Down Expand Up @@ -2173,6 +2182,9 @@ async function hook(node, event, cli) {
const digest = stopDigest();
const fresh = node.inbox(agent, { limit: 50 })
.filter(m => m.state !== "read" && m.received_at > seen && m.from_addr !== `${agent}@${node.host}` && node.wantsWake(agent, m) && hasWakeAuthority(node, agent, m) && (!digest || WAKE_KINDS.has(m.kind)));
// T667: this turn ended. The mail this Stop surfaces rides the continuation, and read/acked mail is
// handled: both leave the dropped-exit marker so the daemon's one re-drive never double-fires.
reconcileWatchReported(node, agent, fresh.map(m => m.id));
// T435: mail stays first. After it is consumed, one continuation re-arms a down watcher.
// Mail that arrives after the watcher exits and before the next tool or Stop still only
// gets a desktop notice — compaction does not fire Stop, and this gate cannot close that gap.
Expand Down Expand Up @@ -2261,6 +2273,33 @@ async function hook(node, event, cli) {
emit(cli, "PostToolUse", stillResuming);
return;
}
// T185: a name held live by another session is a conflict, not an invitation to claim. Same-session and released
// leases fall through so T573 resuming and T503 disconnect stay as they are. Nothing here writes.
const busyHeld = () => {
if (cli !== "claude" || !sid || (event !== "session-start" && event !== "prompt"))
return null;
const name = node.store.get(`name:${cli}:${sid}`);
if (!name || !NAME_RE.test(name) || node.store.get(`identity-conflict:${name}`) !== undefined)
return null;
const lease = node.store.db.prepare("SELECT * FROM identity_leases WHERE name=?").get(name);
if (!lease || lease.released_at !== null || (lease.cli === cli && lease.session_id === sid))
return null;
const availability = identityAvailability({
lease, evidence: inspectLeaseProcess(lease.holder_pid),
activity: parseActivity(node.store.get(activityKey(name))), now: Date.now(),
});
if (availability.claimable || availability.takeover || availability.coUse || !availability.reason.startsWith("held by live "))
return null;
return `[mbx] ${availability.reason}.`;
};
try {
const busy = busyHeld();
if (busy) {
emit(cli, event === "session-start" ? "SessionStart" : "UserPromptSubmit", busy);
return;
}
}
catch { /* a lookup failure keeps the existing unbound guidance */ }
const disconnected = grokUnboundPost ? null : hookDisconnectedNote(node, cli, sid, process.ppid);
const multi = cli === "kimi" && kimiMultiHost(process.ppid);
// A conversation in a multi-conversation Kimi host can't be matched to its mbx server from here: hand it a bind
Expand Down Expand Up @@ -2457,6 +2496,10 @@ async function watch(node, selection) {
: mailbox === agent ? "" : ` This is your linked mailbox: use agentmbx inbox --as ${mailbox} and agentmbx ack --as ${mailbox} <id>.`;
if (!reservation.brake)
lines.push(wakeText(subject, wanted, box ? projectLabel(box) : undefined) + linked);
// T667: the printed hint may be dropped mid-turn (the harness does not queue the completion).
// Mark the ids so the daemon requeues the mail once if nothing ever surfaces it.
if (!reservation.brake)
noteWatchReported(node.store, mailbox, wanted.map(r => r.id));
}
for (const r of rows) {
node.setDelivery(r.id, mailbox, "notified");
Expand Down
Loading
Loading