From efdc752890c03ccc2e459943f3e86eec15b8cd8c Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 20 May 2026 16:37:40 +0200 Subject: [PATCH] feat(hook): nudge Claude to start watch --block as background bash on session start Co-Authored-By: Claude Opus 4.7 (1M context) --- node/src/cli.ts | 38 +++++++++++++--------------------- node/src/hook.ts | 38 ++++++++++++++++++++++++++++++++++ node/tests/hook.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/node/src/cli.ts b/node/src/cli.ts index e86166f..0068a4c 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -10,8 +10,8 @@ import { applyInstall, applyUninstall, buildHookCommand, + buildSessionAnnounceLines, deriveSessionName, - formatActivePeerList, formatMessagesForHook, parseHookStdin, readSettings, @@ -240,38 +240,28 @@ program const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd(); const name = deriveSessionName(sid, cwd); - const lines = [ - `Claude-Mailbox: your mailbox name this session is \`${name}\`.`, - `The name is auto-derived as -. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="--"); after that, use the new name everywhere.`, - `When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`, - ` - mcp__mailbox__send: from="${name}"`, - ` - mcp__mailbox__check_inbox: name="${name}"`, - ` - mcp__mailbox__peek_inbox: name="${name}"`, - ` - mcp__mailbox__list_mailboxes: name="${name}"`, - `Peers reach you with: mcp__mailbox__send(from="", to="${name}", body="...")`, - ]; - + let peers: PeerEntry[] = []; + let daemonError: string | null = null; try { const out = await callJson("GET", `${opts.url}/v1/list`, { headers: { "X-Mailbox": name }, }); - const all = (Array.isArray(out) ? out : []) as PeerEntry[]; - lines.push( - "", - ...formatActivePeerList(all, name, { - windowMinutes: opts.peerWindowMinutes, - maxPeers: opts.maxPeers, - }), - ); + peers = (Array.isArray(out) ? out : []) as PeerEntry[]; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) { - lines.push( - "", - `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`, - ); + daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`; } } + + const lines = buildSessionAnnounceLines({ + name, + peers, + windowMinutes: opts.peerWindowMinutes, + maxPeers: opts.maxPeers, + watcherCommand: `claude-mailbox watch --block --name ${name}`, + }); + if (daemonError) lines.push("", daemonError); lines.push(""); process.stdout.write(lines.join("\n")); }); diff --git a/node/src/hook.ts b/node/src/hook.ts index 20be841..a399ba9 100644 --- a/node/src/hook.ts +++ b/node/src/hook.ts @@ -117,6 +117,44 @@ export function formatActivePeerList( return lines; } +export interface SessionAnnounceOptions { + name: string; + peers: PeerEntry[]; + windowMinutes: number; + maxPeers: number; + watcherCommand?: string; +} + +export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] { + const { name, peers, windowMinutes, maxPeers, watcherCommand } = opts; + const lines = [ + `Claude-Mailbox: your mailbox name this session is \`${name}\`.`, + `The name is auto-derived as -. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="--"); after that, use the new name everywhere.`, + `When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`, + ` - mcp__mailbox__send: from="${name}"`, + ` - mcp__mailbox__check_inbox: name="${name}"`, + ` - mcp__mailbox__peek_inbox: name="${name}"`, + ` - mcp__mailbox__list_mailboxes: name="${name}"`, + `Peers reach you with: mcp__mailbox__send(from="", to="${name}", body="...")`, + ]; + if (watcherCommand) { + lines.push( + "", + "PUSH DELIVERY — REQUIRED FIRST ACTION, then maintain the relaunch loop forever:", + ` Before responding to anything else on your first turn, you MUST launch the mailbox watcher as a background bash task. This is the only thing that lets peers wake you mid-session without the user prompting:`, + ` Bash(command="${watcherCommand}", run_in_background=true)`, + ` The watcher is short-lived by design — it exits after exactly one event (a delivered message, a 25s timeout, a rename, or a daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop are missed pushes.`, + ` When a watcher completion surfaces:`, + ` - stdout contains "[Claude-Mailbox] Mail from : " → treat the body as an inbound interrupt with priority over your current plan, then relaunch.`, + ` - stdout contains "[Claude-Mailbox] Mailbox renamed to ''" → relaunch with --name (also update your own identity for mcp__mailbox__* calls).`, + ` - exit code 3 with no stdout → silent timeout, just relaunch.`, + ` - exit code 2 → daemon unreachable; wait ~5 s, then relaunch.`, + ); + } + lines.push("", ...formatActivePeerList(peers, name, { windowMinutes, maxPeers })); + return lines; +} + export interface HookMessage { id: number; from: string; diff --git a/node/tests/hook.test.ts b/node/tests/hook.test.ts index 508f166..285a087 100644 --- a/node/tests/hook.test.ts +++ b/node/tests/hook.test.ts @@ -7,6 +7,7 @@ import { applyInstall, applyUninstall, buildHookCommand, + buildSessionAnnounceLines, deriveProjectName, deriveSessionName, formatActivePeerList, @@ -359,6 +360,50 @@ describe("formatActivePeerList", () => { }); }); +describe("buildSessionAnnounceLines", () => { + it("includes the identity announcement and tool-call examples", () => { + const out = buildSessionAnnounceLines({ + name: "alice-abc12345", + peers: [], + windowMinutes: 60, + maxPeers: 10, + }).join("\n"); + expect(out).toContain("alice-abc12345"); + expect(out).toContain("mcp__mailbox__send"); + }); + + it("includes the watcher bootstrap instruction when watcherCommand is set", () => { + const out = buildSessionAnnounceLines({ + name: "alice-abc12345", + peers: [], + windowMinutes: 60, + maxPeers: 10, + watcherCommand: "claude-mailbox watch --block --name alice-abc12345", + }).join("\n"); + expect(out).toContain("watch --block --name alice-abc12345"); + expect(out).toContain("run_in_background=true"); + expect(out).toMatch(/\[Claude-Mailbox\] Mail from/); + // Strong-directive framing: must be a REQUIRED FIRST ACTION, not a soft tip. + expect(out).toMatch(/REQUIRED FIRST ACTION/); + expect(out).toMatch(/MUST launch/); + // Relaunch protocol must cover every exit code Claude will see. + expect(out).toMatch(/exit code 3/); + expect(out).toMatch(/exit code 2/); + expect(out).toMatch(/renamed to/); + }); + + it("omits the watcher instruction when watcherCommand is unset", () => { + const out = buildSessionAnnounceLines({ + name: "alice-abc12345", + peers: [], + windowMinutes: 60, + maxPeers: 10, + }).join("\n"); + expect(out).not.toContain("watch --block"); + expect(out).not.toContain("run_in_background"); + }); +}); + describe("readSettings / writeSettings roundtrip", () => { it("survives an install → write → read cycle", () => { const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));