diff --git a/README.md b/README.md index 27598cc..df77aff 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header ## 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 @@ -114,11 +114,16 @@ Easiest path — everything happens inside Claude Code: /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 `-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 diff --git a/node/src/cli.ts b/node/src/cli.ts index f2135d6..0a6177f 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -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 ", + "Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.", ) - .option("--name ", "Mailbox name (also sent as X-Mailbox). Falls back to $CLAUDE_MAILBOX_NAME.") .option("--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 --to ${name} --body "..."\n`, + ); + }); + program .command("list") .description("List known mailboxes.") diff --git a/node/src/hook.ts b/node/src/hook.ts index 7d3cc91..520cb1d 100644 --- a/node/src/hook.ts +++ b/node/src/hook.ts @@ -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; diff --git a/node/tests/cli-hook.test.ts b/node/tests/cli-hook.test.ts index 23ce365..e0f6f2e 100644 --- a/node/tests/cli-hook.test.ts +++ b/node/tests/cli-hook.test.ts @@ -5,14 +5,14 @@ import { resolve } from "node:path"; const cliPath = resolve(__dirname, "..", "dist", "cli.js"); -function runCli(args: string[], env: Record = {}): { - status: number; - stdout: string; - stderr: string; -} { +function runCli( + args: string[], + opts: { env?: Record; 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 = {}): { }; } +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(""); + }); +}); diff --git a/node/tests/hook.test.ts b/node/tests/hook.test.ts index de46166..1ed6b97 100644 --- a/node/tests/hook.test.ts +++ b/node/tests/hook.test.ts @@ -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-")); diff --git a/plugin/README.md b/plugin/README.md index 8f2e6a1..901c57d 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,8 +1,8 @@ # 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 @@ -10,50 +10,59 @@ Lets Claude Code pull unread messages from a local `claude-mailbox` daemon befor /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 -2. checks the daemon status — runs `install-autostart` and/or `start` until it reports `Running` -3. ensures `CLAUDE_MAILBOX_NAME` is set in `.claude/settings.json` env — prompts for a name if not, writes it idempotently -4. runs a self → self smoke test to verify the round-trip works +1. installs the `claude-mailbox` binary via `npm install -g @kuns/claude-mailbox` if missing (asks first) +2. registers the daemon for autostart and starts it if needed +3. health-probes `http://127.0.0.1:47822/health` +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, -- 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. +## What the hooks do -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 | |---|---| | `/claude-mailbox:mailbox-doctor` | Diagnose + auto-fix the full setup. | | `/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 -claude-mailbox send --from probe --to --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 ``` /plugin uninstall claude-mailbox@claude-mailbox npm uninstall -g @kuns/claude-mailbox -claude-mailbox uninstall-autostart # if you used it +claude-mailbox uninstall-autostart # if you registered it ``` diff --git a/plugin/commands/mailbox-doctor.md b/plugin/commands/mailbox-doctor.md index 78c9edc..c84fdbc 100644 --- a/plugin/commands/mailbox-doctor.md +++ b/plugin/commands/mailbox-doctor.md @@ -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 --- -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 Run: `claude-mailbox --version` -- **Exit 0** → binary present. Record the version string. ✓ continue. -- **Command not found / non-zero exit** → binary missing. Tell the user the install command for their platform and ask before running it: +- **Exit 0** → ✓ record the version. Continue. +- **Command not found** → binary missing. Install path: - | Platform | Install command | + | Platform | Command | |---|---| | 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/ ``` - 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 Run: `claude-mailbox status` -- **`Running`** → ✓ continue. -- **`Stopped`** → run `claude-mailbox start`. Re-check status. -- **`NotInstalled`** → run `claude-mailbox install-autostart`, then `claude-mailbox start`. Re-check status. +- `Running` → ✓ continue. +- `Stopped` → `claude-mailbox start`, re-check. +- `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). -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. +**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. -## 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 `-`. Tell the user "Mailbox prefix is set to `X`." +- If unset → ✓ tell the user "Mailbox name will be auto-derived (`claude-`)." + +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 ``)" + +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 --body "ping from /claude-mailbox:mailbox-doctor" -claude-mailbox check --name +claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor" +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. -- If yes: ✓ smoke test passed. -- If no (empty array, error, or wrong message): ✗ report what was returned. +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. -## Step 5 — final summary - -Print a compact block with these fields, one per line: +## Step 6 — summary ``` Claude-Mailbox doctor - binary: - daemon: Running | Stopped | NotInstalled (and what you did) - health: ok | unreachable - mailbox name: (source: existing | newly written to .claude/settings.json) - smoke test: passed | failed - restart hint: + binary: + daemon: Running (and what you did, if anything) + health: ok + base prefix: + smoke test: passed | failed + 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: ." diff --git a/plugin/commands/mailbox-update.md b/plugin/commands/mailbox-update.md new file mode 100644 index 0000000..f4f22cc --- /dev/null +++ b/plugin/commands/mailbox-update.md @@ -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: + new version: + daemon: Running | Stopped | NotInstalled + pending messages survived: +``` + +If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete." +Otherwise, end with the first thing that went wrong. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 68c2d1d..45fbcdb 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -1,5 +1,15 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "claude-mailbox session-announce" + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [