feat(plugin): per-session mailbox identity + mailbox-update command

The hook now derives a unique mailbox name from the session_id supplied
on hook stdin, so two parallel Claude Code sessions in the same project
get distinct mailboxes (e.g. `claude-a8b3c1d2`, `claude-d4e5f6a7`)
instead of colliding on a shared env value. An optional
CLAUDE_MAILBOX_NAME base prefix flavors the names as `<base>-<sid>`.

Adds:
- `claude-mailbox session-announce` subcommand for the new SessionStart
  hook, which prints the current session's mailbox name to context
- `/claude-mailbox:mailbox-update` slash command for `npm update` +
  daemon restart
- stdin parsing helpers (parseHookStdin, deriveSessionName) with unit
  tests; the doctor no longer needs a mandatory name prompt
This commit is contained in:
Mika Kuns
2026-05-19 11:39:14 +02:00
parent c231f8c18c
commit 462d6561e1
9 changed files with 385 additions and 94 deletions

View File

@@ -10,8 +10,11 @@ import {
applyInstall,
applyUninstall,
buildHookCommand,
deriveSessionName,
formatMessagesForHook,
parseHookStdin,
readSettings,
readStdinIfPiped,
settingsPathFor,
writeSettings,
type HookMessage,
@@ -125,19 +128,36 @@ program
}
});
function resolveHookMailboxName(explicit: string | undefined): string | null {
if (explicit && explicit.trim()) return explicit.trim();
const stdin = parseHookStdin(readStdinIfPiped());
const sid = stdin?.session_id?.trim();
if (sid) {
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
return deriveSessionName(sid, base);
}
const envName = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim();
return envName || null;
}
program
.command("check")
.description(
"Pull pending messages and mark delivered. In --hook mode the name can come from CLAUDE_MAILBOX_NAME.",
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin (session_id), optionally flavored by $CLAUDE_MAILBOX_NAME.",
)
.option(
"--name <name>",
"Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.",
)
.option("--name <name>", "Mailbox name (also sent as X-Mailbox). Falls back to $CLAUDE_MAILBOX_NAME.")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.option(
"--hook",
"Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable (exit 0). Emits a one-line setup hint when name is set but daemon is unreachable.",
"Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable. Emits a one-line setup hint when name resolves but daemon is unreachable.",
)
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
const name = (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim();
const name = opts.hook
? resolveHookMailboxName(opts.name)
: (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
if (!name) {
if (opts.hook) return;
console.error("Missing --name (or set CLAUDE_MAILBOX_NAME).");
@@ -170,6 +190,23 @@ program
}
});
program
.command("session-announce")
.description(
"SessionStart-hook helper: reads the hook stdin JSON, derives the session's mailbox name from session_id, and prints a one-line announcement to stdout so Claude sees its mailbox identity.",
)
.action(() => {
const stdin = parseHookStdin(readStdinIfPiped());
const sid = stdin?.session_id?.trim();
if (!sid) return;
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
const name = deriveSessionName(sid, base);
process.stdout.write(
`Claude-Mailbox: this session is mailbox \`${name}\`. ` +
`Peers can send to it with: claude-mailbox send --from <peer> --to ${name} --body "..."\n`,
);
});
program
.command("list")
.description("List known mailboxes.")