Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- **An OpenCode v2 resume note reaches the model instead of being silently dropped (T700).** OpenCode 2.x drives the plugin's default `setup(ctx)` path. It marks a turn at `session.execution.started`, before the first tool, so the busy-turn guard treated every tool as mid-turn and the revisit notice could only fall back; at the turn's end the plugin admitted it with `resume:false`, whose `msg_` receipt can arrive without any `session_message` (T544), and then discarded the pending copy. The first tool of a turn now places the note even after the v2 start event, and a kept note is only dropped after it actually lands in a tool result: a stop no longer acknowledges a receipt as delivery, and `resume:true` is not used because starting a turn on an idle TUI is not a resume note's job. A fresh conversation's start note keeps its queued admission, which the host materialises as the conversation begins.

## 0.5.48 (2026-10-17)

- **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.
Expand Down
38 changes: 25 additions & 13 deletions dist/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -1504,9 +1504,12 @@ const watchWake = (ctx: NoteCtx, sid: string): void => {
/** T642: a session is mid-turn from its first tool call (or execution start) until its turn ends (idle / execution succeeded).
* Delivery "queue" holds a note or a wake until that end, so the post-tool notice rides the tool result instead. */
const turnSince = new Map<string, number>();
/** T700: tools this turn has already run. The first tool of a turn may place a resume note even on the v2 path,
* where session.execution.started marks the turn before any tool runs; later tools in that same turn are mid-turn. */
const turnTools = new Map<string, number>();
const TURN_STALE_MS = 30 * 60_000;
const markTurn = (sid: string): void => { if (!turnSince.has(sid)) turnSince.set(sid, Date.now()); };
const endTurn = (sid: string): void => { turnSince.delete(sid); };
const markTurn = (sid: string): void => { if (!turnSince.has(sid)) { turnSince.set(sid, Date.now()); turnTools.set(sid, 0); } };
const endTurn = (sid: string): void => { turnSince.delete(sid); turnTools.delete(sid); };
/** T675: the turn ended. Tell the daemon (receipt id "idle"), so its next normal pass may wake this session for mail still
* unhandled instead of waiting out the busy backoff. It admits nothing. True only when the daemon took it, which means the
* wake path owns the post-turn alert for this session; false keeps the stop hook as the fallback. */
Expand Down Expand Up @@ -1582,16 +1585,17 @@ const postToolSteady = (sid: string): boolean => {
/** One hook call: the JSON payload goes in on stdin; stdout is captured; a reason we recognize is
* admitted through the hosting serve's session API. A failure never surfaces. The same call starts
* the standalone wake poll once the session id is known. */
const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<string, unknown>, pending: Map<string, string>, sending: Set<string>,
const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<string, unknown>, pending: Map<string, string>,
resumeTold: Set<string>, place?: (text: string) => boolean): Promise<void> => {
try {
const sid = typeof payload.session_id === "string" ? payload.session_id : null;
// Captured before this hook marks or ends the turn, so the first tool of a turn can still admit
// and a turn that is already running cannot.
// Captured before this hook marks or ends the turn. T700: the v2 event loop marks the turn at
// session.execution.started, before its first tool, so "turn open" is not the same as "a tool already ran".
const since = sid ? turnSince.get(sid) : undefined;
const turnOpen = since !== undefined && Date.now() - since < TURN_STALE_MS;
const firstTool = event === "post-tool" && (turnTools.get(sid ?? "") ?? 0) === 0;
if (sid) watchWake(ctx, sid);
if (sid && event === "post-tool") markTurn(sid);
if (sid && event === "post-tool") { markTurn(sid); turnTools.set(sid, (turnTools.get(sid) ?? 0) + 1); }
if (sid && event === "stop") endTurn(sid);
if (sid && (event === "post-tool" || event === "stop")) await releaseStartNote(ctx, sid, event, place);
// T187: retry an unadmitted notice on an existing hook, without bypassing the zero-spawn fast path.
Expand All @@ -1603,17 +1607,25 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<
const line = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged.";
if (!(place && place(line))) logWake("resume", session, "not admitted; mail unchanged");
};
let placed: string | null = null;
const deliver = async () => {
if (!sid || sending.has(sid)) return;
if (!sid) return;
const text = pending.get(sid);
if (!text) return;
// T700: the first tool of a turn is where a resume notice belongs, even when the v2 path marked the turn
// at execution.started. A successful placement is the only proof that the note reached the model, so only
// then is the pending copy dropped.
if (text === placed) { pending.delete(sid); return; }
if (event === "post-tool" && firstTool && place && place(text)) { placed = text; if (pending.get(sid) === text) pending.delete(sid); return; }
if (turnOpen && event !== "stop") { sayResumeFallback(sid, text); return; }
if (event === "post-tool" && place && place(text)) { if (pending.get(sid) === text) pending.delete(sid); return; }
// T700: a note kept across a turn is never acknowledged-and-dropped on a receipt. resume:false can return a
// msg_ id without writing a session_message (T544), and resume:true would start a turn on an idle TUI, so the
// next turn's first tool places it. A fresh conversation's start note keeps its queued admission, which the
// host materialises as the conversation begins; only that event may admit without a tool result.
if (event !== "session-start") return;
const api = typeof ctx?.session?.synthetic === "function" || typeof ctx?.session?.prompt === "function";
if (!api) { sayResumeFallback(sid, text); return; }
sending.add(sid);
try { if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); }
finally { sending.delete(sid); }
if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid);
};
await deliver();
// T675: with this session's wake poll live, the daemon's wake is the one post-turn alert: it starts a turn and offers only
Expand All @@ -1638,8 +1650,8 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<
};

const runFor = (ctx: NoteCtx, bin: string[], cwd: string | undefined) => {
const pending = new Map<string, string>(), sending = new Set<string>(), resumeTold = new Set<string>();
return (event: string, payload: Record<string, unknown>, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, resumeTold, place);
const pending = new Map<string, string>(), resumeTold = new Set<string>();
return (event: string, payload: Record<string, unknown>, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, resumeTold, place);
};

const sidOf = (v: unknown): string | undefined => {
Expand Down
38 changes: 25 additions & 13 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1337,9 +1337,12 @@ const watchWake = (ctx: NoteCtx, sid: string): void => {
/** T642: a session is mid-turn from its first tool call (or execution start) until its turn ends (idle / execution succeeded).
* Delivery "queue" holds a note or a wake until that end, so the post-tool notice rides the tool result instead. */
const turnSince = new Map<string, number>();
/** T700: tools this turn has already run. The first tool of a turn may place a resume note even on the v2 path,
* where session.execution.started marks the turn before any tool runs; later tools in that same turn are mid-turn. */
const turnTools = new Map<string, number>();
const TURN_STALE_MS = 30 * 60_000;
const markTurn = (sid: string): void => { if (!turnSince.has(sid)) turnSince.set(sid, Date.now()); };
const endTurn = (sid: string): void => { turnSince.delete(sid); };
const markTurn = (sid: string): void => { if (!turnSince.has(sid)) { turnSince.set(sid, Date.now()); turnTools.set(sid, 0); } };
const endTurn = (sid: string): void => { turnSince.delete(sid); turnTools.delete(sid); };
/** T675: the turn ended. Tell the daemon (receipt id "idle"), so its next normal pass may wake this session for mail still
* unhandled instead of waiting out the busy backoff. It admits nothing. True only when the daemon took it, which means the
* wake path owns the post-turn alert for this session; false keeps the stop hook as the fallback. */
Expand Down Expand Up @@ -1415,16 +1418,17 @@ const postToolSteady = (sid: string): boolean => {
/** One hook call: the JSON payload goes in on stdin; stdout is captured; a reason we recognize is
* admitted through the hosting serve's session API. A failure never surfaces. The same call starts
* the standalone wake poll once the session id is known. */
const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<string, unknown>, pending: Map<string, string>, sending: Set<string>,
const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<string, unknown>, pending: Map<string, string>,
resumeTold: Set<string>, place?: (text: string) => boolean): Promise<void> => {
try {
const sid = typeof payload.session_id === "string" ? payload.session_id : null;
// Captured before this hook marks or ends the turn, so the first tool of a turn can still admit
// and a turn that is already running cannot.
// Captured before this hook marks or ends the turn. T700: the v2 event loop marks the turn at
// session.execution.started, before its first tool, so "turn open" is not the same as "a tool already ran".
const since = sid ? turnSince.get(sid) : undefined;
const turnOpen = since !== undefined && Date.now() - since < TURN_STALE_MS;
const firstTool = event === "post-tool" && (turnTools.get(sid ?? "") ?? 0) === 0;
if (sid) watchWake(ctx, sid);
if (sid && event === "post-tool") markTurn(sid);
if (sid && event === "post-tool") { markTurn(sid); turnTools.set(sid, (turnTools.get(sid) ?? 0) + 1); }
if (sid && event === "stop") endTurn(sid);
if (sid && (event === "post-tool" || event === "stop")) await releaseStartNote(ctx, sid, event, place);
// T187: retry an unadmitted notice on an existing hook, without bypassing the zero-spawn fast path.
Expand All @@ -1436,17 +1440,25 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<
const line = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged.";
if (!(place && place(line))) logWake("resume", session, "not admitted; mail unchanged");
};
let placed: string | null = null;
const deliver = async () => {
if (!sid || sending.has(sid)) return;
if (!sid) return;
const text = pending.get(sid);
if (!text) return;
// T700: the first tool of a turn is where a resume notice belongs, even when the v2 path marked the turn
// at execution.started. A successful placement is the only proof that the note reached the model, so only
// then is the pending copy dropped.
if (text === placed) { pending.delete(sid); return; }
if (event === "post-tool" && firstTool && place && place(text)) { placed = text; if (pending.get(sid) === text) pending.delete(sid); return; }
if (turnOpen && event !== "stop") { sayResumeFallback(sid, text); return; }
if (event === "post-tool" && place && place(text)) { if (pending.get(sid) === text) pending.delete(sid); return; }
// T700: a note kept across a turn is never acknowledged-and-dropped on a receipt. resume:false can return a
// msg_ id without writing a session_message (T544), and resume:true would start a turn on an idle TUI, so the
// next turn's first tool places it. A fresh conversation's start note keeps its queued admission, which the
// host materialises as the conversation begins; only that event may admit without a tool result.
if (event !== "session-start") return;
const api = typeof ctx?.session?.synthetic === "function" || typeof ctx?.session?.prompt === "function";
if (!api) { sayResumeFallback(sid, text); return; }
sending.add(sid);
try { if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); }
finally { sending.delete(sid); }
if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid);
};
await deliver();
// T675: with this session's wake poll live, the daemon's wake is the one post-turn alert: it starts a turn and offers only
Expand All @@ -1471,8 +1483,8 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record<
};

const runFor = (ctx: NoteCtx, bin: string[], cwd: string | undefined) => {
const pending = new Map<string, string>(), sending = new Set<string>(), resumeTold = new Set<string>();
return (event: string, payload: Record<string, unknown>, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, resumeTold, place);
const pending = new Map<string, string>(), resumeTold = new Set<string>();
return (event: string, payload: Record<string, unknown>, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, resumeTold, place);
};

const sidOf = (v: unknown): string | undefined => {
Expand Down
29 changes: 18 additions & 11 deletions test/opencode-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,19 @@ test("T187: failed admission retries on the next hook without another node spawn
try {
const hooks = await mod.AgentMBXHooks({ directory: "/work", session: { synthetic: async (body: Note) => {
notes.push(body); if (!ready) throw new Error("not admitted"); return body;
} } }) as { event: (event: unknown) => Promise<void>; "tool.execute.after": (event: unknown) => Promise<void> };
} } }) as { event: (event: unknown) => Promise<void>; "tool.execute.after": (event: unknown, output?: { output?: string }) => Promise<void> };
await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_retry" } } } });
assert.equal(notes.length, 1); ready = true;
await hooks["tool.execute.after"]({ sessionID: "ses_sibling" });
assert.equal(notes.length, 1, "sibling never receives or drains another entry");
await hooks["tool.execute.after"]({ sessionID: "ses_retry" });
assert.equal(notes.length, 2); assert.equal(notes[0].id, notes[1].id);
assert.ok(notes.every(note => note.sessionID === "ses_retry" && note.resume === false));
await hooks["tool.execute.after"]({ sessionID: "ses_retry" });
assert.equal(notes.length, 2, "admitted entry is removed from pending");
// T700: a failed admission is not retried through the session API. The kept note rides the next turn's first
// tool result, which is the only admission the model can be proven to have seen.
const first = { output: "tool ok" };
await hooks["tool.execute.after"]({ sessionID: "ses_retry" }, first);
assert.match(first.output, /worker@alpha: mbx_catchup/);
assert.equal(notes.length, 1, "the tool result is the admission; no second synthetic attempt");
await hooks["tool.execute.after"]({ sessionID: "ses_retry" }, { output: "next" });
assert.equal(notes.length, 1, "a placed entry is removed from pending");
assert.equal(calls.filter(c => c.args[1] === "session-start").length, 1);
assert.equal(calls.filter(c => c.args[1] === "post-tool" && c.input.session_id === "ses_retry").length, 0, "steady retries don't spawn node");
} finally {
Expand Down Expand Up @@ -286,12 +289,16 @@ test("T187: a resolved native call with no receipt remains pending instead of cl
try {
const hooks = await mod.AgentMBXHooks({ directory: "/work", session: { synthetic: async (body: Note) => {
notes.push(body); return ++attempts === 1 ? {} : { data: { id: body.id } };
} } }) as { event: (event: unknown) => Promise<void>; "tool.execute.after": (event: unknown) => Promise<void> };
} } }) as { event: (event: unknown) => Promise<void>; "tool.execute.after": (event: unknown, output?: { output?: string }) => Promise<void> };
await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_no_receipt" } } } });
await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" });
assert.equal(notes.length, 2); assert.equal(notes[0].id, notes[1].id);
await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" });
assert.equal(notes.length, 2, "recognized data.id closes only the pending admission, not work completion");
// T700: the no-receipt call did not prove admission, so the note stays pending; the next turn's first tool
// result carries it, and no further synthetic attempt is made.
const first = { output: "tool ok" };
await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" }, first);
assert.match(first.output, /worker@alpha: mbx_catchup/);
assert.equal(notes.length, 1, "the session API was not asked again");
await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" }, { output: "next" });
assert.equal(notes.length, 1, "a placed entry is removed from pending");
} finally { restore(); rmSync(dir, { recursive: true, force: true }); }
});

Expand Down
Loading
Loading