feat(hook): make push delivery opt-in via mailbox-collaborate skill

The SessionStart announce no longer forces a watch-loop bootstrap on every session — it now emits a short pointer instructing Claude to invoke the new mailbox-collaborate skill (or /collaborate slash command) when the user wants peers to wake them mid-task. Messages still surface on the next user prompt via the UserPromptSubmit hook even without the watcher, so nothing is lost; idle sessions just stop burning relaunch tokens.

The watch-loop protocol (exit codes, rename handling, mail handling) moves from the hook prose into the new skill body, where it only loads when actually needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-21 09:24:41 +02:00
parent 951fb4f021
commit 22824bd35f
5 changed files with 60 additions and 38 deletions

View File

@@ -259,7 +259,6 @@ program
peers,
windowMinutes: opts.peerWindowMinutes,
maxPeers: opts.maxPeers,
watcherCommand: `claude-mailbox watch --block --name ${name}`,
daemonError: daemonError ?? undefined,
});
lines.push("");

View File

@@ -122,12 +122,11 @@ export interface SessionAnnounceOptions {
peers: PeerEntry[];
windowMinutes: number;
maxPeers: number;
watcherCommand?: string;
daemonError?: string;
}
export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] {
const { name, peers, windowMinutes, maxPeers, watcherCommand, daemonError } = opts;
const { name, peers, windowMinutes, maxPeers, daemonError } = 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.`,
@@ -137,21 +136,9 @@ export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[
` - 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.`,
);
}
`Push delivery is OPT-IN. Do NOT launch the watcher on your own. When the user wants peers to wake you mid-task, invoke the \`mailbox-collaborate\` skill (or the /collaborate slash command) to enter collaboration mode. Without it, peers can still leave messages — you'll see them on your next user prompt via the existing UserPromptSubmit hook.`,
];
if (daemonError) {
lines.push("", daemonError);
} else {

View File

@@ -372,27 +372,7 @@ describe("buildSessionAnnounceLines", () => {
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", () => {
it("never auto-bootstraps the watcher — push delivery must be opt-in", () => {
const out = buildSessionAnnounceLines({
name: "alice-abc12345",
peers: [],
@@ -401,6 +381,20 @@ describe("buildSessionAnnounceLines", () => {
}).join("\n");
expect(out).not.toContain("watch --block");
expect(out).not.toContain("run_in_background");
expect(out).not.toMatch(/REQUIRED FIRST ACTION/);
expect(out).not.toMatch(/MUST launch/);
});
it("points the user to the opt-in collaborate skill / slash command", () => {
const out = buildSessionAnnounceLines({
name: "alice-abc12345",
peers: [],
windowMinutes: 60,
maxPeers: 10,
}).join("\n");
expect(out).toMatch(/mailbox-collaborate/);
expect(out).toMatch(/\/collaborate/);
expect(out).toMatch(/OPT-IN/);
});
it("replaces the peer list with the daemonError hint when daemon is unreachable", () => {

View File

@@ -0,0 +1,5 @@
---
description: Enter Claude-Mailbox collaboration mode — start the push-delivery watch loop so peers can wake Claude mid-task.
---
Invoke the `mailbox-collaborate` skill now to enter collaboration mode and start the watcher relaunch loop.

View File

@@ -0,0 +1,37 @@
---
name: mailbox-collaborate
description: Start the Claude-Mailbox push-delivery watch loop for this session so peers can wake Claude mid-task. Use when the user says "let's collaborate", "wait for mail", "watch the mailbox", "listen for peers", "enter collaboration mode", or invokes /collaborate. Without this skill the watcher is OFF — peers can still leave messages but Claude only sees them on the next user prompt. Do NOT launch the watcher without this opt-in.
---
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
## Launch the watcher
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
Launch the watcher as a background bash task immediately:
```
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
```
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or 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 = missed pushes.
## Handling watcher completions
When a watcher task completes, inspect its stdout and exit code:
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
- **exit code 3 with no stdout** → silent timeout, just relaunch.
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
- **any other exit code** → report it to the user, then relaunch.
## Stopping
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
- Stop relaunching after the next completion.
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this skill again.