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:
@@ -259,7 +259,6 @@ program
|
|||||||
peers,
|
peers,
|
||||||
windowMinutes: opts.peerWindowMinutes,
|
windowMinutes: opts.peerWindowMinutes,
|
||||||
maxPeers: opts.maxPeers,
|
maxPeers: opts.maxPeers,
|
||||||
watcherCommand: `claude-mailbox watch --block --name ${name}`,
|
|
||||||
daemonError: daemonError ?? undefined,
|
daemonError: daemonError ?? undefined,
|
||||||
});
|
});
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|||||||
@@ -122,12 +122,11 @@ export interface SessionAnnounceOptions {
|
|||||||
peers: PeerEntry[];
|
peers: PeerEntry[];
|
||||||
windowMinutes: number;
|
windowMinutes: number;
|
||||||
maxPeers: number;
|
maxPeers: number;
|
||||||
watcherCommand?: string;
|
|
||||||
daemonError?: string;
|
daemonError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] {
|
export function buildSessionAnnounceLines(opts: SessionAnnounceOptions): string[] {
|
||||||
const { name, peers, windowMinutes, maxPeers, watcherCommand, daemonError } = opts;
|
const { name, peers, windowMinutes, maxPeers, daemonError } = opts;
|
||||||
const lines = [
|
const lines = [
|
||||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
`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.`,
|
`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__peek_inbox: name="${name}"`,
|
||||||
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
||||||
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
||||||
|
"",
|
||||||
|
`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 (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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (daemonError) {
|
if (daemonError) {
|
||||||
lines.push("", daemonError);
|
lines.push("", daemonError);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -372,27 +372,7 @@ describe("buildSessionAnnounceLines", () => {
|
|||||||
expect(out).toContain("mcp__mailbox__send");
|
expect(out).toContain("mcp__mailbox__send");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes the watcher bootstrap instruction when watcherCommand is set", () => {
|
it("never auto-bootstraps the watcher — push delivery must be opt-in", () => {
|
||||||
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({
|
const out = buildSessionAnnounceLines({
|
||||||
name: "alice-abc12345",
|
name: "alice-abc12345",
|
||||||
peers: [],
|
peers: [],
|
||||||
@@ -401,6 +381,20 @@ describe("buildSessionAnnounceLines", () => {
|
|||||||
}).join("\n");
|
}).join("\n");
|
||||||
expect(out).not.toContain("watch --block");
|
expect(out).not.toContain("watch --block");
|
||||||
expect(out).not.toContain("run_in_background");
|
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", () => {
|
it("replaces the peer list with the daemonError hint when daemon is unreachable", () => {
|
||||||
|
|||||||
5
plugin/commands/collaborate.md
Normal file
5
plugin/commands/collaborate.md
Normal 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.
|
||||||
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal file
37
plugin/skills/mailbox-collaborate/SKILL.md
Normal 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.
|
||||||
Reference in New Issue
Block a user