feat(hook): nudge Claude to start watch --block as background bash on session start
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,8 @@ import {
|
|||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildHookCommand,
|
||||||
|
buildSessionAnnounceLines,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatActivePeerList,
|
|
||||||
formatMessagesForHook,
|
formatMessagesForHook,
|
||||||
parseHookStdin,
|
parseHookStdin,
|
||||||
readSettings,
|
readSettings,
|
||||||
@@ -240,38 +240,28 @@ program
|
|||||||
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
const name = deriveSessionName(sid, cwd);
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
|
||||||
const lines = [
|
let peers: PeerEntry[] = [];
|
||||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
let daemonError: string | null = null;
|
||||||
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); 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="<their-name>", to="${name}", body="...")`,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
||||||
headers: { "X-Mailbox": name },
|
headers: { "X-Mailbox": name },
|
||||||
});
|
});
|
||||||
const all = (Array.isArray(out) ? out : []) as PeerEntry[];
|
peers = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||||
lines.push(
|
|
||||||
"",
|
|
||||||
...formatActivePeerList(all, name, {
|
|
||||||
windowMinutes: opts.peerWindowMinutes,
|
|
||||||
maxPeers: opts.maxPeers,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||||
lines.push(
|
daemonError = `[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`;
|
||||||
"",
|
|
||||||
`[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("");
|
lines.push("");
|
||||||
process.stdout.write(lines.join("\n"));
|
process.stdout.write(lines.join("\n"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,6 +117,44 @@ export function formatActivePeerList(
|
|||||||
return lines;
|
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 <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); 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="<their-name>", 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 <peer>: <body>" → treat the body as an inbound interrupt with priority over your current plan, then relaunch.`,
|
||||||
|
` - stdout contains "[Claude-Mailbox] Mailbox renamed to '<new>'" → relaunch with --name <new> (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 {
|
export interface HookMessage {
|
||||||
id: number;
|
id: number;
|
||||||
from: string;
|
from: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildHookCommand,
|
||||||
|
buildSessionAnnounceLines,
|
||||||
deriveProjectName,
|
deriveProjectName,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatActivePeerList,
|
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", () => {
|
describe("readSettings / writeSettings roundtrip", () => {
|
||||||
it("survives an install → write → read cycle", () => {
|
it("survives an install → write → read cycle", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||||
|
|||||||
Reference in New Issue
Block a user