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:
13
README.md
13
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)
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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-"));
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>."
|
||||||
|
|||||||
56
plugin/commands/mailbox-update.md
Normal file
56
plugin/commands/mailbox-update.md
Normal 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.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-announce"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"UserPromptSubmit": [
|
"UserPromptSubmit": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
Reference in New Issue
Block a user