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

@@ -106,7 +106,7 @@ The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header
## Use from Claude Code (plugin) ## Use from Claude Code (plugin)
Easiest path — everything happens inside Claude Code: Easiest path — three prompts, all inside Claude Code:
``` ```
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox /plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
@@ -114,11 +114,16 @@ Easiest path — everything happens inside Claude Code:
/claude-mailbox:mailbox-doctor /claude-mailbox:mailbox-doctor
``` ```
The doctor command checks for the `claude-mailbox` binary, installs it if missing (via npm), runs `install-autostart` if the daemon isn't registered, prompts you for a mailbox name and writes it to `.claude/settings.json`, and finishes with a self → self smoke test. The doctor command auto-installs the daemon binary via npm (asks first), registers autostart, optionally takes a base prefix (e.g. `backend`), and runs a smoke test. Subsequent slash commands:
After that, unread messages appear in context before every prompt. If the daemon is unreachable later, the hook emits a one-line setup hint instead of staying silent — missing setup is loud, not invisible. - `/claude-mailbox:mailbox-status` — read-only health check
- `/claude-mailbox:mailbox-update` — pull the latest daemon version and restart
See [`plugin/README.md`](./plugin/README.md) for details, including why each Claude session needs its own mailbox name. **Each Claude session gets its own mailbox identity** derived from the session's UUID — `claude-a8b3c1d2` by default, or `<base>-a8b3c1d2` if you set a prefix. Parallel sessions in the same project automatically get distinct names. The `SessionStart` hook announces the current session's name in context so Claude knows its own identity. Peers discover each other via `mcp__mailbox__list_mailboxes`.
If the daemon goes down later, the hook emits a one-line setup hint instead of staying silent.
See [`plugin/README.md`](./plugin/README.md) for the full walkthrough.
## Use from a Claude session ## Use from a Claude session

View File

@@ -10,8 +10,11 @@ import {
applyInstall, applyInstall,
applyUninstall, applyUninstall,
buildHookCommand, buildHookCommand,
deriveSessionName,
formatMessagesForHook, formatMessagesForHook,
parseHookStdin,
readSettings, readSettings,
readStdinIfPiped,
settingsPathFor, settingsPathFor,
writeSettings, writeSettings,
type HookMessage, 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 program
.command("check") .command("check")
.description( .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("--url <url>", "Daemon base URL", DEFAULT_URL)
.option( .option(
"--hook", "--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 }) => { .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 (!name) {
if (opts.hook) return; if (opts.hook) return;
console.error("Missing --name (or set CLAUDE_MAILBOX_NAME)."); 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 program
.command("list") .command("list")
.description("List known mailboxes.") .description("List known mailboxes.")

View File

@@ -2,6 +2,45 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, join } from "node:path"; 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 { export interface HookMessage {
id: number; id: number;
from: string; from: string;

View File

@@ -5,14 +5,14 @@ import { resolve } from "node:path";
const cliPath = resolve(__dirname, "..", "dist", "cli.js"); const cliPath = resolve(__dirname, "..", "dist", "cli.js");
function runCli(args: string[], env: Record<string, string | undefined> = {}): { function runCli(
status: number; args: string[],
stdout: string; opts: { env?: Record<string, string | undefined>; stdin?: string } = {},
stderr: string; ): { status: number; stdout: string; stderr: string } {
} {
const r = spawnSync(process.execPath, [cliPath, ...args], { const r = spawnSync(process.execPath, [cliPath, ...args], {
encoding: "utf8", encoding: "utf8",
env: { ...process.env, ...env }, env: { ...process.env, ...(opts.env ?? {}) },
input: opts.stdin,
}); });
return { return {
status: r.status ?? -1, 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", () => { describe("`check --hook` CLI behavior", () => {
beforeAll(() => { beforeAll(() => {
if (!existsSync(cliPath)) { if (!existsSync(cliPath)) {
@@ -28,36 +35,87 @@ describe("`check --hook` CLI behavior", () => {
} }
}); });
it("exits 0 silently when no name resolved (no --name, no env)", () => { it("exits 0 silently when no stdin, no --name, no env", () => {
const r = runCli(["check", "--hook"], { CLAUDE_MAILBOX_NAME: undefined }); const r = runCli(["check", "--hook"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
expect(r.status).toBe(0); expect(r.status).toBe(0);
expect(r.stdout).toBe(""); expect(r.stdout).toBe("");
expect(r.stderr).toBe(""); expect(r.stderr).toBe("");
}); });
it("emits daemon-not-reachable hint when name is set but daemon is down", () => { it("derives session-id-based name from stdin and emits daemon hint when down", () => {
const r = runCli( const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
["check", "--hook", "--url", "http://127.0.0.1:1"], env: { CLAUDE_MAILBOX_NAME: undefined },
{ CLAUDE_MAILBOX_NAME: "alice" }, stdin: HOOK_STDIN,
); });
expect(r.status).toBe(0); expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable"); 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( const r = runCli(
["check", "--hook", "--name", "bob", "--url", "http://127.0.0.1:1"], ["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
{ CLAUDE_MAILBOX_NAME: "alice" }, { env: { CLAUDE_MAILBOX_NAME: "ignored" }, stdin: HOOK_STDIN },
); );
expect(r.status).toBe(0); expect(r.status).toBe(0);
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable"); expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
}); });
it("non-hook mode errors out when no name resolved", () => { 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.status).not.toBe(0);
expect(r.stderr).toContain("CLAUDE_MAILBOX_NAME"); 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, applyInstall,
applyUninstall, applyUninstall,
buildHookCommand, buildHookCommand,
deriveSessionName,
formatMessagesForHook, formatMessagesForHook,
parseHookStdin,
readSettings, readSettings,
shortSessionId,
writeSettings, writeSettings,
} from "../src/hook.js"; } 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", () => { 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-"));

View File

@@ -1,8 +1,8 @@
# claude-mailbox plugin # claude-mailbox plugin
Lets Claude Code pull unread messages from a local `claude-mailbox` daemon before every prompt and inject them into the conversation context. Lets Claude Code pull unread messages from a local `claude-mailbox` daemon before every prompt and inject them into the conversation context. Each Claude session gets a **unique mailbox identity** auto-derived from its session id, so two sessions in the same project never collide.
## Setup (two steps, all inside Claude Code) ## Setup (three prompts, all inside Claude Code)
``` ```
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox /plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox
@@ -10,50 +10,59 @@ Lets Claude Code pull unread messages from a local `claude-mailbox` daemon befor
/claude-mailbox:mailbox-doctor /claude-mailbox:mailbox-doctor
``` ```
The doctor command walks the rest: The doctor walks the rest:
1. checks whether the `claude-mailbox` binary is on `PATH` — installs it (`npm install -g @kuns/claude-mailbox`) if missing, asks before doing anything that might need elevation 1. installs the `claude-mailbox` binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
2. checks the daemon status — runs `install-autostart` and/or `start` until it reports `Running` 2. registers the daemon for autostart and starts it if needed
3. ensures `CLAUDE_MAILBOX_NAME` is set in `.claude/settings.json` env — prompts for a name if not, writes it idempotently 3. health-probes `http://127.0.0.1:47822/health`
4. runs a self → self smoke test to verify the round-trip works 4. optionally lets you set a **base prefix** (e.g., `backend`) — without one, mailbox names are anonymous (`claude-XXXXXXXX`)
5. runs a self → self smoke test
Restart Claude Code after the doctor finishes (only needed if the mailbox name was newly written). Unread messages will then appear in context before every prompt. Restart Claude Code only if step 4 wrote a new prefix. After that, every prompt auto-pulls unread messages.
## Why a mailbox name? ## Mailbox identity (the important bit)
Each Claude session has an identity used to address peer sessions — like an email address. If you run a `backend` session and a `frontend` session in parallel, they need different names so they can send messages to each other. Each Claude Code session gets its own mailbox name, derived from the session's UUID:
For a single Claude Code instance just wanting notifications, any stable kebab-case name works. The name lives in **per-project** `.claude/settings.json` env, so different worktrees / projects automatically get different mailboxes. | Configuration | Resulting mailbox name |
|---|---|
| No `CLAUDE_MAILBOX_NAME` set | `claude-a8b3c1d2` (first 8 hex chars of session_id) |
| `CLAUDE_MAILBOX_NAME=backend` in `.claude/settings.json` env | `backend-a8b3c1d2` |
## What the hook actually does So if you open two Claude Code sessions in the same project, they'll be e.g. `backend-a8b3c1d2` and `backend-d4e5f6a7` — distinct, addressable, no manual setup.
Before every prompt the plugin runs `claude-mailbox check --hook`, which: The `SessionStart` hook announces the current session's mailbox name in the conversation context on startup. Peers discover each other via `claude-mailbox list` or the `mcp__mailbox__list_mailboxes` MCP tool.
- prints unread mailbox messages in a Claude-friendly format and marks them delivered, ## What the hooks do
- stays **silent** when the inbox is empty or `CLAUDE_MAILBOX_NAME` is not set,
- emits a one-line setup hint when the daemon is unreachable, so a missing daemon is loud, not invisible.
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows). | Hook | Command | Effect |
|---|---|---|
| `SessionStart` | `claude-mailbox session-announce` | Prints `"Claude-Mailbox: this session is mailbox \`X\`"` so Claude knows its own identity. |
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
## Commands Cost: one local HTTP round-trip per prompt + Node coldstart (~100ms on Windows).
## Slash commands
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `/claude-mailbox:mailbox-doctor` | Diagnose + auto-fix the full setup. | | `/claude-mailbox:mailbox-doctor` | Diagnose + auto-fix the full setup. |
| `/claude-mailbox:mailbox-status` | Read-only health check. No changes. | | `/claude-mailbox:mailbox-status` | Read-only health check. No changes. |
| `/claude-mailbox:mailbox-update` | Update the daemon to the latest npm version and restart it. |
## Smoke test (manually, after doctor finishes) ## Sending a message to a peer session
From inside Claude Code, use the MCP tool (the daemon already exposes `mcp__mailbox__*`). From any shell:
```sh ```sh
claude-mailbox send --from probe --to <your-mailbox-name> --body "hello" claude-mailbox list # find the recipient's mailbox name
claude-mailbox send --from probe --to backend-a8b3c1d2 --body "hi"
``` ```
Then start a new Claude Code prompt — the message should appear in context before Claude's first reply.
## Uninstall ## Uninstall
``` ```
/plugin uninstall claude-mailbox@claude-mailbox /plugin uninstall claude-mailbox@claude-mailbox
npm uninstall -g @kuns/claude-mailbox npm uninstall -g @kuns/claude-mailbox
claude-mailbox uninstall-autostart # if you used it claude-mailbox uninstall-autostart # if you registered it
``` ```

View File

@@ -1,85 +1,92 @@
--- ---
description: Diagnose and auto-fix the Claude-Mailbox setup (binary install, daemon autostart, mailbox name, smoke test). description: Diagnose and auto-fix the Claude-Mailbox setup (binary install, daemon autostart, smoke test, optional base-prefix).
allowed-tools: Bash, Read, Edit, Write allowed-tools: Bash, Read, Edit, Write
--- ---
You are running the **Claude-Mailbox doctor**. Walk through these checks in order. After each check, print a one-line status (✓ / ✗) and the action you took. At the very end, print a final summary block. You are running the **Claude-Mailbox doctor**. Walk through these checks in order. After each step, print a one-line `✓` / `✗` with the action you took. End with a summary block.
Throughout, prefer the dedicated tools (`Read`, `Edit`, `Write`) for files. Use `Bash` only for `claude-mailbox` subcommands, `npm`, `where`/`which`, and `cat /etc/os-release` style lookups. Never run `sudo` automatically — if elevation is needed, stop and ask the user how to proceed. Use `Bash` only for `claude-mailbox` subcommands, `npm`, `where`/`which`, and HTTP probes. Use `Read`/`Edit`/`Write` for `.claude/settings.json`. Never run `sudo` automatically — if elevation is needed, stop and ask.
---
## Step 1 — daemon binary on PATH ## Step 1 — daemon binary on PATH
Run: `claude-mailbox --version` Run: `claude-mailbox --version`
- **Exit 0** → binary present. Record the version string. ✓ continue. - **Exit 0** → ✓ record the version. Continue.
- **Command not found / non-zero exit** → binary missing. Tell the user the install command for their platform and ask before running it: - **Command not found** → binary missing. Install path:
| Platform | Install command | | Platform | Command |
|---|---| |---|---|
| Windows | `npm install -g @kuns/claude-mailbox` (no admin) | | Windows | `npm install -g @kuns/claude-mailbox` (no admin) |
| macOS / Linux | `npm install -g @kuns/claude-mailbox` (may need `sudo` depending on Node setup; ask the user if `npm install` fails with EACCES) | | macOS / Linux | `npm install -g @kuns/claude-mailbox` (may fail with EACCES — never run sudo automatically; ask the user) |
Prerequisite: `npm config get @kuns:registry` should return `https://git.kuns.dev/api/packages/releases/npm/`. If it doesn't, set it first: Prerequisite: `npm config get @kuns:registry` must point at `https://git.kuns.dev/api/packages/releases/npm/`. If not:
``` ```
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/ npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
``` ```
After install, re-run `claude-mailbox --version`. If it still fails, stop and report the error. After install, re-run `claude-mailbox --version`. If it still fails, stop and report.
## Step 2 — daemon autostart and running state ## Step 2 — daemon autostart and running state
Run: `claude-mailbox status` Run: `claude-mailbox status`
- **`Running`** → ✓ continue. - `Running` → ✓ continue.
- **`Stopped`** → run `claude-mailbox start`. Re-check status. - `Stopped` `claude-mailbox start`, re-check.
- **`NotInstalled`** → run `claude-mailbox install-autostart`, then `claude-mailbox start`. Re-check status. - `NotInstalled` `claude-mailbox install-autostart`, then `claude-mailbox start`, re-check.
If status doesn't become `Running` after the fix, stop and report what `status` and `start` printed. If status doesn't reach `Running`, stop and report.
Sanity check: `curl -sf http://127.0.0.1:47822/health` (or use Bash to fetch it). Expect a JSON body with `"status":"ok"`. ## Step 3 — health probe
## Step 3 — mailbox name in project settings Hit `http://127.0.0.1:47822/health`. Expect a JSON body with `"status":"ok"`. If unreachable, stop and report — the daemon claims it's running but isn't accepting connections.
The hook reads the mailbox name from `$CLAUDE_MAILBOX_NAME`. Claude Code injects env vars from `.claude/settings.json` into hook commands, so the cleanest place to set it is per-project. ## Step 4 — mailbox identity
1. Read `.claude/settings.json` in the current working directory (it may not exist yet — that's fine). **No prompt by default.** Each Claude Code session now gets a unique mailbox name auto-derived from its `session_id` (e.g., `claude-a8b3c1d2`), so two parallel sessions can never collide.
2. Check if `env.CLAUDE_MAILBOX_NAME` is set.
3. If **set**: ✓ continue, record the name.
4. If **not set**:
- Ask the user for a mailbox name. Suggest a default based on the cwd basename (e.g., for `C:\Private\Claude-Mailbox` suggest `claude-mailbox`). Names should be short, kebab-case-ish, unique among parallel Claude sessions.
- Read existing `.claude/settings.json` if present, otherwise start with `{}`.
- Set/merge `env.CLAUDE_MAILBOX_NAME` to the chosen name. Preserve any other existing settings.
- Write back with 2-space indentation.
- Tell the user they need to **restart this Claude Code session** for the env to take effect in the hook — but the smoke test below can still run because we'll pass `--name` explicitly.
## Step 4 — smoke test Read `.claude/settings.json` in the current working directory and look for `env.CLAUDE_MAILBOX_NAME`.
Use the resolved name from step 3 (either pre-existing or just chosen). Run: - If set → ✓ this is a **base prefix**. The real name will be `<base>-<short_session_id>`. Tell the user "Mailbox prefix is set to `X`."
- If unset → ✓ tell the user "Mailbox name will be auto-derived (`claude-<short_session_id>`)."
Then **ask** the user (one question, not a deep prompt):
> "Want to flavor your mailbox names with a memorable prefix like `backend` or `frontend`? (yes / no / change to `<x>`)"
If they say yes or give a value:
1. Read `.claude/settings.json` (empty `{}` if missing).
2. Merge `env.CLAUDE_MAILBOX_NAME` = chosen value, preserving anything else.
3. Write back with 2-space indentation.
4. Mark this as `restart_needed = true`.
If they say no or skip → leave as-is.
## Step 5 — smoke test
Use two ephemeral names (`doctor-probe-a` / `doctor-probe-b`) — we don't need the real session name here, we just need to prove the daemon round-trips:
``` ```
claude-mailbox send --from doctor-probe --to <name> --body "ping from /claude-mailbox:mailbox-doctor" claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor"
claude-mailbox check --name <name> claude-mailbox check --name doctor-probe-b
``` ```
- The `check` output should be a JSON array containing exactly one message with `"from": "doctor-probe"` and that body. The `check` output must be a JSON array with one message: `from: doctor-probe-a`, body matches. If yes ✓. If no ✗ and report what came back.
- If yes: ✓ smoke test passed.
- If no (empty array, error, or wrong message): ✗ report what was returned.
## Step 5 final summary ## Step 6 — summary
Print a compact block with these fields, one per line:
``` ```
Claude-Mailbox doctor Claude-Mailbox doctor
binary: <version> binary: <version>
daemon: Running | Stopped | NotInstalled (and what you did) daemon: Running (and what you did, if anything)
health: ok | unreachable health: ok
mailbox name: <name> (source: existing | newly written to .claude/settings.json) base prefix: <name from settings, or "auto-derived (anonymous)">
smoke test: passed | failed smoke test: passed | failed
restart hint: <yes if name was newly written, otherwise no> restart hint: yes if restart_needed, otherwise no
``` ```
If everything is ✓ and `restart hint: yes`, end with: "Restart Claude Code (or open a new session) so the UserPromptSubmit hook picks up `CLAUDE_MAILBOX_NAME`." If `restart hint: no`, end with: "You're good to go — unread messages will appear before your next prompt." End with one of:
- All ✓ and no restart needed → "Setup is healthy. Your mailbox name this session will be revealed by the SessionStart hook on next session start (or run `claude-mailbox list` to see active mailboxes)."
- All ✓ and restart needed → "Restart Claude Code (or open a new session) so the SessionStart hook picks up the new base prefix."
- Anything ✗ → "Setup incomplete: <first failure>."

View File

@@ -0,0 +1,56 @@
---
description: Update the Claude-Mailbox daemon to the latest published npm version and restart it.
allowed-tools: Bash
---
You are running the **Claude-Mailbox update** command. Update the `@kuns/claude-mailbox` npm package and restart the daemon. Never run `sudo` automatically — if elevation is needed, stop and ask.
## Step 1 — current version
Run: `claude-mailbox --version`
- Exit 0 → record the version string as `CURRENT`.
- Non-zero → tell the user the daemon binary is not installed yet. Suggest `/claude-mailbox:mailbox-doctor` to do a full setup and stop.
## Step 2 — latest published version
Run: `npm view @kuns/claude-mailbox version`
If the npm registry config is missing, the call may fail with a 404. Fall back to:
```
npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
```
Record the result as `LATEST`.
## Step 3 — compare
- If `CURRENT === LATEST`: print "Already up to date (vX.Y.Z)." and stop. Do not run any further steps.
- Otherwise: tell the user `CURRENT``LATEST` and ask for confirmation before proceeding.
## Step 4 — perform the update
On user confirmation, run these in order. Stop on the first failure and report it:
1. `claude-mailbox stop`
2. `npm install -g @kuns/claude-mailbox@latest`
- On Linux/macOS this may fail with EACCES. **Do not run sudo automatically.** Ask the user how they want to proceed (e.g., `sudo npm install -g …`, or switch to a user-scoped Node setup with nvm/fnm).
3. `claude-mailbox start`
4. `claude-mailbox --version` to verify the upgrade landed.
5. `claude-mailbox status` to verify the daemon is `Running`.
## Step 5 — summary
Print exactly this block:
```
Claude-Mailbox update
previous version: <CURRENT>
new version: <whatever --version now reports>
daemon: Running | Stopped | NotInstalled
pending messages survived: <count of messages still in inbox via `claude-mailbox list`, if applicable>
```
If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete."
Otherwise, end with the first thing that went wrong.

View File

@@ -1,5 +1,15 @@
{ {
"hooks": { "hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "claude-mailbox session-announce"
}
]
}
],
"UserPromptSubmit": [ "UserPromptSubmit": [
{ {
"hooks": [ "hooks": [