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.")

View File

@@ -2,6 +2,45 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface HookStdinPayload {
session_id?: string;
hook_event_name?: string;
[key: string]: unknown;
}
export function parseHookStdin(raw: string | null | undefined): HookStdinPayload | null {
if (!raw || !raw.trim()) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object") return parsed as HookStdinPayload;
return null;
} catch {
return null;
}
}
export function readStdinIfPiped(): string | null {
if (process.stdin.isTTY) return null;
try {
return readFileSync(0, "utf8");
} catch {
return null;
}
}
export function shortSessionId(sessionId: string): string {
const hex = sessionId.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
if (hex.length >= 8) return hex.slice(0, 8);
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
}
export function deriveSessionName(sessionId: string, base?: string | null): string {
const short = shortSessionId(sessionId);
const trimmed = (base ?? "").trim();
if (trimmed) return `${trimmed}-${short}`;
return `claude-${short}`;
}
export interface HookMessage {
id: number;
from: string;

View File

@@ -5,14 +5,14 @@ import { resolve } from "node:path";
const cliPath = resolve(__dirname, "..", "dist", "cli.js");
function runCli(args: string[], env: Record<string, string | undefined> = {}): {
status: number;
stdout: string;
stderr: string;
} {
function runCli(
args: string[],
opts: { env?: Record<string, string | undefined>; stdin?: string } = {},
): { status: number; stdout: string; stderr: string } {
const r = spawnSync(process.execPath, [cliPath, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
env: { ...process.env, ...(opts.env ?? {}) },
input: opts.stdin,
});
return {
status: r.status ?? -1,
@@ -21,6 +21,13 @@ function runCli(args: string[], env: Record<string, string | undefined> = {}): {
};
}
const HOOK_STDIN = JSON.stringify({
session_id: "abc12345-de67-89f0-1234-567890abcdef",
hook_event_name: "UserPromptSubmit",
cwd: "/tmp",
prompt: "test",
});
describe("`check --hook` CLI behavior", () => {
beforeAll(() => {
if (!existsSync(cliPath)) {
@@ -28,36 +35,87 @@ describe("`check --hook` CLI behavior", () => {
}
});
it("exits 0 silently when no name resolved (no --name, no env)", () => {
const r = runCli(["check", "--hook"], { CLAUDE_MAILBOX_NAME: undefined });
it("exits 0 silently when no stdin, no --name, no env", () => {
const r = runCli(["check", "--hook"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
expect(r.stderr).toBe("");
});
it("emits daemon-not-reachable hint when name is set but daemon is down", () => {
const r = runCli(
["check", "--hook", "--url", "http://127.0.0.1:1"],
{ CLAUDE_MAILBOX_NAME: "alice" },
);
it("derives session-id-based name from stdin and emits daemon hint when down", () => {
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
expect(r.stdout).toContain("http://127.0.0.1:1");
expect(r.stdout).toContain("claude-mailbox install-autostart");
});
it("`--name` arg wins over CLAUDE_MAILBOX_NAME env (visible via hint URL/contents)", () => {
it("uses base prefix from CLAUDE_MAILBOX_NAME when both env and stdin present", () => {
// We can't directly assert the name from --hook output (it's only in the unreachable hint URL).
// The hint always contains the URL we passed, so this just confirms the path runs without error.
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
env: { CLAUDE_MAILBOX_NAME: "backend" },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("explicit --name overrides session-id derivation", () => {
const r = runCli(
["check", "--hook", "--name", "bob", "--url", "http://127.0.0.1:1"],
{ CLAUDE_MAILBOX_NAME: "alice" },
["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
{ env: { CLAUDE_MAILBOX_NAME: "ignored" }, stdin: HOOK_STDIN },
);
expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
});
it("non-hook mode errors out when no name resolved", () => {
const r = runCli(["check"], { CLAUDE_MAILBOX_NAME: undefined });
const r = runCli(["check"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("CLAUDE_MAILBOX_NAME");
});
});
describe("`session-announce` CLI behavior", () => {
beforeAll(() => {
if (!existsSync(cliPath)) {
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
}
});
it("prints the derived mailbox name from a SessionStart payload", () => {
const r = runCli(["session-announce"], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("`claude-abc12345`");
expect(r.stdout).toContain("claude-mailbox send");
});
it("uses base prefix when set", () => {
const r = runCli(["session-announce"], {
env: { CLAUDE_MAILBOX_NAME: "backend" },
stdin: HOOK_STDIN,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("`backend-abc12345`");
});
it("stays silent when no session_id in stdin", () => {
const r = runCli(["session-announce"], {
env: { CLAUDE_MAILBOX_NAME: undefined },
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
});
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
});
it("stays silent when no stdin at all", () => {
const r = runCli(["session-announce"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
expect(r.status).toBe(0);
expect(r.stdout).toBe("");
});
});

View File

@@ -6,8 +6,11 @@ import {
applyInstall,
applyUninstall,
buildHookCommand,
deriveSessionName,
formatMessagesForHook,
parseHookStdin,
readSettings,
shortSessionId,
writeSettings,
} from "../src/hook.js";
@@ -169,6 +172,73 @@ describe("applyUninstall", () => {
});
});
describe("parseHookStdin", () => {
it("returns null for empty or whitespace input", () => {
expect(parseHookStdin(null)).toBeNull();
expect(parseHookStdin("")).toBeNull();
expect(parseHookStdin(" \n ")).toBeNull();
});
it("returns null for non-JSON input", () => {
expect(parseHookStdin("not json")).toBeNull();
expect(parseHookStdin("{")).toBeNull();
});
it("returns null for JSON primitives (only objects allowed)", () => {
expect(parseHookStdin("42")).toBeNull();
expect(parseHookStdin("\"foo\"")).toBeNull();
expect(parseHookStdin("null")).toBeNull();
});
it("parses a hook payload", () => {
const out = parseHookStdin(
JSON.stringify({
session_id: "abc12345-de67-89f0-1234-567890abcdef",
hook_event_name: "UserPromptSubmit",
prompt: "hi",
}),
);
expect(out?.session_id).toBe("abc12345-de67-89f0-1234-567890abcdef");
expect(out?.hook_event_name).toBe("UserPromptSubmit");
});
});
describe("shortSessionId / deriveSessionName", () => {
it("takes first 8 hex chars from a UUID", () => {
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
});
it("normalizes case and ignores hyphens", () => {
expect(shortSessionId("ABC12345-DE67-89F0-1234-567890ABCDEF")).toBe("abc12345");
});
it("falls back to a sanitized prefix for non-hex ids", () => {
expect(shortSessionId("session-Test123")).toBe("sessiont");
});
it("derives anonymous name when no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef")).toBe("claude-abc12345");
});
it("prepends base prefix when given", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "backend")).toBe(
"backend-abc12345",
);
});
it("treats whitespace-only base as no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", " ")).toBe(
"claude-abc12345",
);
});
it("derives different names for different sessions with the same base", () => {
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "shared");
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "shared");
expect(a).not.toBe(b);
});
});
describe("readSettings / writeSettings roundtrip", () => {
it("survives an install → write → read cycle", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));