Compare commits
2 Commits
8832eab6c7
...
7b65545600
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b65545600 | ||
|
|
b10ac36ed0 |
19
README.md
19
README.md
@@ -104,15 +104,26 @@ claude-mailbox uninstall-service [--purge]
|
||||
|
||||
## How identity works
|
||||
|
||||
Every Claude Code session gets a unique mailbox name derived from its UUID:
|
||||
Every Claude Code session gets a unique mailbox name automatically derived as `<project>-<8-hex-of-session-id>`:
|
||||
|
||||
| Setup | Resulting mailbox name |
|
||||
|---|---|
|
||||
| Default | `claude-<8-hex-of-session-id>` |
|
||||
| `CLAUDE_MAILBOX_NAME=backend` (in `.claude/settings.json` env) | `backend-<8-hex>` |
|
||||
| Inside a git repo | `<repo-basename>-<8-hex>` (e.g. `claude-mailbox-a3f91b2c`) |
|
||||
| Outside a git repo | `<cwd-basename>-<8-hex>` |
|
||||
| No cwd available (rare) | `claude-<8-hex>` |
|
||||
| Manual `.mcp.json` with `X-Mailbox: backend` header (no plugin) | `backend` (legacy mode) |
|
||||
|
||||
The plugin's `SessionStart` hook prints the session's identity and the list of peers active in the last hour into the conversation context, so Claude knows who it is and who's around without needing to call any tools first.
|
||||
Project names are sanitized (lowercased, non-alphanumerics → dashes, capped at 40 chars). The plugin's `SessionStart` hook prints the session's identity and the list of peers active in the last hour into the conversation context, so Claude knows who it is and who's around without needing to call any tools first.
|
||||
|
||||
### Renaming at runtime
|
||||
|
||||
Claude can refine its own mailbox name during the session — useful when a session focuses on a specific area (e.g. only frontend work):
|
||||
|
||||
```
|
||||
mcp__mailbox__rename(current_name="claude-mailbox-a3f91b2c", new_name="claude-mailbox-frontend-a3f91b2c")
|
||||
```
|
||||
|
||||
Pending messages are transferred to the new name in a single transaction. The old name is removed — peers using it must re-discover via `list_mailboxes`. The endpoint returns `409` if the target name is already in use.
|
||||
|
||||
---
|
||||
|
||||
|
||||
34
node/package-lock.json
generated
34
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
@@ -361,9 +361,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -381,9 +378,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -401,9 +395,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -421,9 +412,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -441,9 +429,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -461,9 +446,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1877,9 +1859,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1901,9 +1880,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1925,9 +1901,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1949,9 +1922,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -137,22 +137,19 @@ 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;
|
||||
if (!sid) return null;
|
||||
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||
return deriveSessionName(sid, cwd);
|
||||
}
|
||||
|
||||
program
|
||||
.command("check")
|
||||
.description(
|
||||
"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.",
|
||||
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin: <project>-<session-short>, where <project> is the git-repo or cwd basename from stdin.",
|
||||
)
|
||||
.option(
|
||||
"--name <name>",
|
||||
"Explicit mailbox name. Overrides hook stdin and $CLAUDE_MAILBOX_NAME.",
|
||||
"Explicit mailbox name. Overrides hook stdin auto-derivation.",
|
||||
)
|
||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||
.option(
|
||||
@@ -162,10 +159,10 @@ program
|
||||
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
|
||||
const name = opts.hook
|
||||
? resolveHookMailboxName(opts.name)
|
||||
: (opts.name ?? process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
|
||||
: (opts.name ?? "").trim() || null;
|
||||
if (!name) {
|
||||
if (opts.hook) return;
|
||||
console.error("Missing --name (or set CLAUDE_MAILBOX_NAME).");
|
||||
console.error("Missing --name.");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
@@ -217,12 +214,13 @@ program
|
||||
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);
|
||||
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||
const name = deriveSessionName(sid, cwd);
|
||||
|
||||
const lines = [
|
||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||
`When using mcp__mailbox__* tools, ALWAYS pass this name explicitly:`,
|
||||
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); after that, use the new name everywhere.`,
|
||||
`When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`,
|
||||
` - mcp__mailbox__send: from="${name}"`,
|
||||
` - mcp__mailbox__check_inbox: name="${name}"`,
|
||||
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
||||
|
||||
@@ -50,6 +50,15 @@ function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
||||
|
||||
export class RenameError extends Error {
|
||||
constructor(message: string, public readonly reason: RenameFailure) {
|
||||
super(message);
|
||||
this.name = "RenameError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseDate(s: string | null | undefined): Date | null {
|
||||
if (!s) return null;
|
||||
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
|
||||
@@ -166,6 +175,35 @@ export class MailboxStore {
|
||||
});
|
||||
}
|
||||
|
||||
rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
|
||||
const oldName = from.trim();
|
||||
const newName = to.trim();
|
||||
if (!oldName) throw new RenameError("from is required", "invalid");
|
||||
if (!newName) throw new RenameError("to is required", "invalid");
|
||||
if (oldName === newName) {
|
||||
this.upsertMailbox(oldName);
|
||||
return { from: oldName, to: newName, messagesTransferred: 0 };
|
||||
}
|
||||
|
||||
return runInTransaction(this.db, () => {
|
||||
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
|
||||
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
||||
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
|
||||
if (target) throw new RenameError(`Mailbox '${newName}' already exists.`, "target-exists");
|
||||
|
||||
const now = nowIso();
|
||||
this.stmts.insertMailbox.run(newName, source.created_at, now);
|
||||
const movedTo = this.db
|
||||
.prepare("UPDATE messages SET to_mailbox = ? WHERE to_mailbox = ?")
|
||||
.run(newName, oldName);
|
||||
this.db
|
||||
.prepare("UPDATE messages SET from_mailbox = ? WHERE from_mailbox = ?")
|
||||
.run(newName, oldName);
|
||||
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
|
||||
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
||||
});
|
||||
}
|
||||
|
||||
listMailboxes(forName?: string): MailboxInfo[] {
|
||||
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
|
||||
const pendingMap = new Map<string, number>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
export interface HookStdinPayload {
|
||||
session_id?: string;
|
||||
@@ -34,11 +35,51 @@ export function shortSessionId(sessionId: string): string {
|
||||
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
|
||||
}
|
||||
|
||||
export function deriveSessionName(sessionId: string, base?: string | null): string {
|
||||
const MAX_PROJECT_NAME_LENGTH = 40;
|
||||
|
||||
export function sanitizeProjectName(raw: string | null | undefined): string {
|
||||
if (!raw) return "";
|
||||
const cleaned = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return cleaned.slice(0, MAX_PROJECT_NAME_LENGTH).replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
export function deriveProjectName(cwd?: string | null): string {
|
||||
const dir = (cwd ?? "").trim();
|
||||
if (dir) {
|
||||
const gitTop = gitToplevel(dir);
|
||||
if (gitTop) {
|
||||
const sanitized = sanitizeProjectName(basename(gitTop));
|
||||
if (sanitized) return sanitized;
|
||||
}
|
||||
const sanitized = sanitizeProjectName(basename(dir));
|
||||
if (sanitized) return sanitized;
|
||||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
function gitToplevel(cwd: string): string | null {
|
||||
try {
|
||||
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
});
|
||||
if (r.status !== 0) return null;
|
||||
const out = (r.stdout ?? "").trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveSessionName(sessionId: string, cwd?: string | null): string {
|
||||
const short = shortSessionId(sessionId);
|
||||
const trimmed = (base ?? "").trim();
|
||||
if (trimmed) return `${trimmed}-${short}`;
|
||||
return `claude-${short}`;
|
||||
const project = deriveProjectName(cwd);
|
||||
return `${project}-${short}`;
|
||||
}
|
||||
|
||||
export interface PeerEntry {
|
||||
|
||||
@@ -155,6 +155,36 @@ export function buildStdioMcpServer(daemonUrl: string = resolveDaemonUrl()): Mcp
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rename",
|
||||
{
|
||||
title: "Rename your mailbox",
|
||||
description:
|
||||
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||
inputSchema: {
|
||||
current_name: z
|
||||
.string()
|
||||
.describe(
|
||||
"Your current mailbox name (from the SessionStart announcement or last rename).",
|
||||
),
|
||||
new_name: z
|
||||
.string()
|
||||
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||
},
|
||||
},
|
||||
async ({ current_name, new_name }) => {
|
||||
const from = requireIdentity(current_name, "name");
|
||||
const out = (await rest("POST", `${daemonUrl}/v1/rename`, {
|
||||
headers: { "X-Mailbox": from },
|
||||
body: { to: new_name },
|
||||
})) as { from: string; to: string; messagesTransferred: number };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||
structuredContent: out,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { MailboxStore, rowToMessage } from "./db.js";
|
||||
import { MailboxStore, RenameError, rowToMessage } from "./db.js";
|
||||
import { HEADER_NAME } from "./server.js";
|
||||
|
||||
function headerFallback(extra: unknown): string {
|
||||
@@ -141,6 +141,42 @@ function buildMcpServer(store: MailboxStore): McpServer {
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rename",
|
||||
{
|
||||
title: "Rename your mailbox",
|
||||
description:
|
||||
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||
inputSchema: {
|
||||
current_name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Your current mailbox name (the one to rename away from). Required unless X-Mailbox is set in .mcp.json.",
|
||||
),
|
||||
new_name: z
|
||||
.string()
|
||||
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||
},
|
||||
},
|
||||
async ({ current_name, new_name }, extra) => {
|
||||
const from = resolveIdentity(current_name, extra, "name");
|
||||
try {
|
||||
const r = store.rename(from, new_name);
|
||||
const out = { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||
structuredContent: out,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof RenameError) {
|
||||
throw new Error(`${err.message} (${err.reason})`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest }
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MailboxStore, rowToMessage } from "./db.js";
|
||||
import { MailboxStore, RenameError, rowToMessage } from "./db.js";
|
||||
import type { DaemonConfig } from "./config.js";
|
||||
import { registerMcp } from "./mcp.js";
|
||||
|
||||
@@ -100,6 +100,25 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
||||
}));
|
||||
});
|
||||
|
||||
app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => {
|
||||
const from = req.mailboxName!;
|
||||
const to = (req.body?.to ?? "").trim();
|
||||
if (!to) {
|
||||
reply.code(400);
|
||||
return { error: "to is required" };
|
||||
}
|
||||
try {
|
||||
const r = store.rename(from, to);
|
||||
return { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||
} catch (err) {
|
||||
if (err instanceof RenameError) {
|
||||
reply.code(err.reason === "target-exists" ? 409 : 400);
|
||||
return { error: err.message, reason: err.reason };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
await registerMcp(app, store);
|
||||
|
||||
return app;
|
||||
|
||||
@@ -35,8 +35,8 @@ describe("`check --hook` CLI behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("exits 0 silently when no stdin, no --name, no env", () => {
|
||||
const r = runCli(["check", "--hook"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
|
||||
it("exits 0 silently when no stdin and no --name", () => {
|
||||
const r = runCli(["check", "--hook"]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("");
|
||||
expect(r.stderr).toBe("");
|
||||
@@ -44,18 +44,6 @@ describe("`check --hook` CLI behavior", () => {
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -65,7 +53,7 @@ describe("`check --hook` CLI behavior", () => {
|
||||
it("explicit --name overrides session-id derivation", () => {
|
||||
const r = runCli(
|
||||
["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
|
||||
{ env: { CLAUDE_MAILBOX_NAME: "ignored" }, stdin: HOOK_STDIN },
|
||||
{ stdin: HOOK_STDIN },
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||
@@ -73,7 +61,7 @@ describe("`check --hook` CLI behavior", () => {
|
||||
|
||||
it("uses CLAUDE_MAILBOX_URL env as default base URL when --url is not given", () => {
|
||||
const r = runCli(["check", "--hook"], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined, CLAUDE_MAILBOX_URL: "http://127.0.0.1:1" },
|
||||
env: { CLAUDE_MAILBOX_URL: "http://127.0.0.1:1" },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
@@ -81,9 +69,9 @@ describe("`check --hook` CLI behavior", () => {
|
||||
});
|
||||
|
||||
it("non-hook mode errors out when no name resolved", () => {
|
||||
const r = runCli(["check"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
|
||||
const r = runCli(["check"]);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain("CLAUDE_MAILBOX_NAME");
|
||||
expect(r.stderr).toContain("Missing --name");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,38 +84,33 @@ describe("`session-announce` CLI behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prints the derived mailbox name from a SessionStart payload", () => {
|
||||
it("prints the derived mailbox name from a SessionStart payload (project-prefixed)", () => {
|
||||
// cwd "/tmp" is not a git repo → basename "tmp" → project prefix "tmp".
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("`claude-abc12345`");
|
||||
// The exact prefix depends on the runtime cwd if git resolves; the deterministic
|
||||
// assertion is the session-short suffix and the announcement structure.
|
||||
expect(r.stdout).toMatch(/`[a-z0-9-]+-abc12345`/);
|
||||
expect(r.stdout).toContain("mcp__mailbox__send");
|
||||
expect(r.stdout).toContain(`from="claude-abc12345"`);
|
||||
expect(r.stdout).toMatch(/from="[a-z0-9-]+-abc12345"/);
|
||||
});
|
||||
|
||||
it("uses base prefix when set", () => {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: "backend" },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
it("includes a hint about the rename tool", () => {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("`backend-abc12345`");
|
||||
expect(r.stdout).toContain("mcp__mailbox__rename");
|
||||
});
|
||||
|
||||
it("emits daemon-not-reachable hint when daemon is down", () => {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||
});
|
||||
|
||||
it("stays silent when no session_id in stdin", () => {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
@@ -135,9 +118,7 @@ describe("`session-announce` CLI behavior", () => {
|
||||
});
|
||||
|
||||
it("stays silent when no stdin at all", () => {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
});
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("");
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
import { MailboxStore, RenameError } from "../src/db.js";
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
@@ -75,6 +75,93 @@ describe("send / peek / check round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename", () => {
|
||||
it("renames a mailbox and transfers undelivered messages", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
store.send("alice", "bob-old", "hi");
|
||||
store.send("alice", "bob-old", "again");
|
||||
|
||||
const r = store.rename("bob-old", "bob-new");
|
||||
expect(r.from).toBe("bob-old");
|
||||
expect(r.to).toBe("bob-new");
|
||||
expect(r.messagesTransferred).toBe(2);
|
||||
|
||||
// Old name is gone.
|
||||
const list = store.listMailboxes().map((m) => m.name);
|
||||
expect(list).toContain("bob-new");
|
||||
expect(list).not.toContain("bob-old");
|
||||
|
||||
// Messages still pending under the new name.
|
||||
const peek = store.peek("bob-new");
|
||||
expect(peek.pending).toBe(2);
|
||||
|
||||
// checkInbox under the new name yields the original bodies and the original from.
|
||||
const pulled = store.checkInbox("bob-new");
|
||||
expect(pulled.map((m) => m.body)).toEqual(["hi", "again"]);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("also rewrites the from-side when the renamed mailbox was a sender", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
store.send("sender-old", "bob", "msg-1");
|
||||
store.rename("sender-old", "sender-new");
|
||||
const pulled = store.checkInbox("bob");
|
||||
expect(pulled).toHaveLength(1);
|
||||
expect(pulled[0]!.from_mailbox).toBe("sender-new");
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats rename-to-same-name as a no-op touch", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
store.upsertMailbox("alice");
|
||||
const r = store.rename("alice", "alice");
|
||||
expect(r.messagesTransferred).toBe(0);
|
||||
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when target already exists", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
store.upsertMailbox("alice");
|
||||
store.upsertMailbox("bob");
|
||||
expect(() => store.rename("alice", "bob")).toThrow(RenameError);
|
||||
try {
|
||||
store.rename("alice", "bob");
|
||||
} catch (e) {
|
||||
expect((e as RenameError).reason).toBe("target-exists");
|
||||
}
|
||||
// Source still present after the failed attempt.
|
||||
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice", "bob"]);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when source is missing", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
try {
|
||||
store.rename("nope", "fresh");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RenameError);
|
||||
expect((e as RenameError).reason).toBe("source-missing");
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMailboxes", () => {
|
||||
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
|
||||
@@ -2,15 +2,18 @@ import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
applyInstall,
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
deriveProjectName,
|
||||
deriveSessionName,
|
||||
formatActivePeerList,
|
||||
formatMessagesForHook,
|
||||
parseHookStdin,
|
||||
readSettings,
|
||||
sanitizeProjectName,
|
||||
shortSessionId,
|
||||
writeSettings,
|
||||
type PeerEntry,
|
||||
@@ -205,7 +208,7 @@ describe("parseHookStdin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortSessionId / deriveSessionName", () => {
|
||||
describe("shortSessionId", () => {
|
||||
it("takes first 8 hex chars from a UUID", () => {
|
||||
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
|
||||
});
|
||||
@@ -217,26 +220,73 @@ describe("shortSessionId / deriveSessionName", () => {
|
||||
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",
|
||||
);
|
||||
describe("sanitizeProjectName", () => {
|
||||
it("lowercases and replaces non-alnum with dashes", () => {
|
||||
expect(sanitizeProjectName("My Project!")).toBe("my-project");
|
||||
});
|
||||
|
||||
it("treats whitespace-only base as no base", () => {
|
||||
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", " ")).toBe(
|
||||
"claude-abc12345",
|
||||
);
|
||||
it("collapses runs of separators", () => {
|
||||
expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar");
|
||||
});
|
||||
|
||||
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");
|
||||
it("trims leading/trailing dashes", () => {
|
||||
expect(sanitizeProjectName("--foo--")).toBe("foo");
|
||||
});
|
||||
|
||||
it("returns empty for purely non-alnum input", () => {
|
||||
expect(sanitizeProjectName("---")).toBe("");
|
||||
expect(sanitizeProjectName("")).toBe("");
|
||||
expect(sanitizeProjectName(null)).toBe("");
|
||||
expect(sanitizeProjectName(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("caps long names", () => {
|
||||
const out = sanitizeProjectName("a".repeat(120));
|
||||
expect(out.length).toBeLessThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveProjectName", () => {
|
||||
it("uses cwd basename when not in a git repo", () => {
|
||||
// tmpdir is virtually never inside a git repo; basename is platform-dependent.
|
||||
const got = deriveProjectName(tmpdir());
|
||||
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||
});
|
||||
|
||||
it("falls back to 'claude' when cwd is empty", () => {
|
||||
expect(deriveProjectName("")).toBe("claude");
|
||||
expect(deriveProjectName(null)).toBe("claude");
|
||||
expect(deriveProjectName(undefined)).toBe("claude");
|
||||
});
|
||||
|
||||
it("uses git toplevel basename when called from inside a repo", () => {
|
||||
// The test harness itself runs inside the claude-mailbox checkout.
|
||||
let inRepo = false;
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
|
||||
inRepo = true;
|
||||
} catch {
|
||||
inRepo = false;
|
||||
}
|
||||
if (!inRepo) return; // CI without git in PATH — skip.
|
||||
const got = deriveProjectName(process.cwd());
|
||||
// Anywhere in the repo, we should resolve to the repo's basename — sanitized.
|
||||
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(got.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSessionName", () => {
|
||||
it("composes <project>-<short>", () => {
|
||||
const got = deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "");
|
||||
expect(got).toBe("claude-abc12345");
|
||||
});
|
||||
|
||||
it("derives different names for different sessions in the same project", () => {
|
||||
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "");
|
||||
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,63 @@ describe("REST surface", () => {
|
||||
expect(wrong.status).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /v1/rename transfers pending messages and exposes the new name", async () => {
|
||||
// alice sends to bob-old.
|
||||
await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
body: { to: "bob-old", body: "hi old bob" },
|
||||
});
|
||||
|
||||
const rename = await call("POST", "/v1/rename", {
|
||||
headers: { "X-Mailbox": "bob-old" },
|
||||
body: { to: "bob-new" },
|
||||
});
|
||||
expect(rename.status).toBe(200);
|
||||
expect(rename.body).toMatchObject({
|
||||
from: "bob-old",
|
||||
to: "bob-new",
|
||||
messagesTransferred: 1,
|
||||
});
|
||||
|
||||
// Peek under new name shows the pending msg; old name is empty.
|
||||
const peekNew = await call("GET", "/v1/peek?name=bob-new");
|
||||
expect(peekNew.body).toMatchObject({ pending: 1 });
|
||||
const peekOld = await call("GET", "/v1/peek?name=bob-old");
|
||||
expect(peekOld.body).toMatchObject({ pending: 0 });
|
||||
|
||||
// check-inbox under new name pulls the message.
|
||||
const check = await call("POST", "/v1/check-inbox?name=bob-new", {
|
||||
headers: { "X-Mailbox": "bob-new" },
|
||||
});
|
||||
const arr = check.body as Array<{ from: string; body: string }>;
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0]!.body).toBe("hi old bob");
|
||||
});
|
||||
|
||||
it("POST /v1/rename returns 409 when target name is taken", async () => {
|
||||
await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
body: { to: "bob", body: "x" },
|
||||
});
|
||||
// 'taken' already exists thanks to upsert on X-Mailbox.
|
||||
const r = await call("POST", "/v1/rename", {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
body: { to: "alice" },
|
||||
});
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.body).toMatchObject({ reason: "target-exists" });
|
||||
});
|
||||
|
||||
it("POST /v1/rename requires X-Mailbox and body.to", async () => {
|
||||
const missingHeader = await call("POST", "/v1/rename", { body: { to: "x" } });
|
||||
expect(missingHeader.status).toBe(400);
|
||||
const missingTo = await call("POST", "/v1/rename", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
body: {},
|
||||
});
|
||||
expect(missingTo.status).toBe(400);
|
||||
});
|
||||
|
||||
it("/v1/list and /v1/peek are anonymous", async () => {
|
||||
await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mailbox",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
||||
"author": {
|
||||
"name": "Mika Kuns"
|
||||
|
||||
@@ -15,21 +15,23 @@ The doctor walks the rest:
|
||||
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:37849/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
|
||||
4. runs a self → self smoke test
|
||||
|
||||
Restart Claude Code only if step 4 wrote a new prefix. After that, every prompt auto-pulls unread messages.
|
||||
After that, every prompt auto-pulls unread messages.
|
||||
|
||||
## Mailbox identity (the important bit)
|
||||
|
||||
Each Claude Code session gets its own mailbox name, derived from the session's UUID:
|
||||
Each Claude Code session gets its own mailbox name, automatically derived as `<project>-<session-short>`:
|
||||
|
||||
| Configuration | Resulting mailbox name |
|
||||
| Where the session runs | 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` |
|
||||
| Inside a git repo | `<repo-basename>-a8b3c1d2` (e.g. `claude-mailbox-a8b3c1d2`) |
|
||||
| Outside a git repo | `<cwd-basename>-a8b3c1d2` |
|
||||
| No cwd in stdin (rare) | `claude-a8b3c1d2` |
|
||||
|
||||
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.
|
||||
So if you open two Claude Code sessions in the same project, they'll share the project prefix but differ in the session-short — e.g. `claude-mailbox-a8b3c1d2` and `claude-mailbox-d4e5f6a7`. No env-var, no manual prefix step.
|
||||
|
||||
If a session focuses on a sub-area (frontend, backend, …), Claude can call `mcp__mailbox__rename(current_name="…", new_name="claude-mailbox-frontend-a8b3c1d2")` to tag itself; pending messages are transferred. Peers using the old name re-discover via `list_mailboxes`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -55,6 +57,7 @@ Each MCP tool takes the caller's mailbox name as an explicit argument (from the
|
||||
| `mcp__mailbox__check_inbox` | `name` | Pull all undelivered messages for your mailbox (marks delivered). |
|
||||
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming count of pending messages. |
|
||||
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes and `pendingForYou` counts. |
|
||||
| `mcp__mailbox__rename` | `current_name`, `new_name` | Rename your own mailbox (e.g. add an area tag). Pending messages are transferred. Use the new name afterward. |
|
||||
|
||||
The SessionStart announcement spells out the exact args to pass, so Claude picks them up automatically.
|
||||
|
||||
|
||||
@@ -81,18 +81,13 @@ If `install-autostart` still fails after both attempts (very rare — would mean
|
||||
|
||||
Hit `http://127.0.0.1:<port>/health` (use the configured port, not necessarily 37849). Expect a JSON body with `"status":"ok"` AND a `version` matching `claude-mailbox --version`. If unreachable or version mismatch, stop and report.
|
||||
|
||||
## Step 6 — mailbox identity (base prefix)
|
||||
## Step 6 — mailbox identity
|
||||
|
||||
**No prompt by default.** Each Claude Code session gets a unique mailbox name auto-derived from its `session_id` (e.g., `claude-a8b3c1d2`).
|
||||
**No prompt.** Each Claude Code session gets a unique mailbox name auto-derived as `<project>-<short_session_id>`, where `<project>` is the git-repo basename of the session's `cwd` (or the cwd basename if not a git repo). Example: `claude-mailbox-a8b3c1d2`.
|
||||
|
||||
Read `.claude/settings.json` and look for `env.CLAUDE_MAILBOX_NAME`.
|
||||
✓ "Mailbox name will be auto-derived as `<project>-<short_session_id>`."
|
||||
|
||||
- If set → ✓ "Mailbox prefix is `<X>`." (real name will be `<X>-<short_session_id>`).
|
||||
- If unset → ✓ "Mailbox name will be auto-derived (`claude-<short_session_id>`)."
|
||||
|
||||
Ask once: *"Want to flavor your mailbox names with a memorable prefix (e.g., `backend`, `frontend`)? (yes / no / `<name>`)"*
|
||||
|
||||
On yes/explicit name: merge `env.CLAUDE_MAILBOX_NAME = <name>` into `.claude/settings.json`, preserving other keys. Mark `restart_needed = true`.
|
||||
Sessions can also rename themselves at runtime via the `mcp__mailbox__rename` MCP tool — e.g. to add an area tag like `claude-mailbox-frontend-a8b3c1d2`. No config involved.
|
||||
|
||||
## Step 7 — smoke test
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@ Claude-Mailbox status
|
||||
binary: <output of `claude-mailbox --version`, or "not installed">
|
||||
daemon: <output of `claude-mailbox status`>
|
||||
health: <"ok" if GET http://127.0.0.1:37849/health returns 200, else "unreachable">
|
||||
mailbox name: <value of env.CLAUDE_MAILBOX_NAME in ./.claude/settings.json, or "unset"; also note if ~/.claude/settings.json has a value>
|
||||
pending: <integer count from `claude-mailbox peek --name <resolved-name>` if name is set, else "n/a">
|
||||
mailbox name: auto-derived per session as <project>-<short-session-id> (see SessionStart announcement)
|
||||
pending: n/a (the session's mailbox name isn't known until SessionStart runs in this session's context)
|
||||
```
|
||||
|
||||
End with one line:
|
||||
|
||||
- All good → `Status: OK`
|
||||
- Missing daemon or unset name → `Status: Setup incomplete. Run /claude-mailbox:mailbox-doctor to fix.`
|
||||
- Missing daemon → `Status: Setup incomplete. Run /claude-mailbox:mailbox-doctor to fix.`
|
||||
- Daemon installed but stopped → `Status: Daemon is not running. Try \`claude-mailbox start\` or run /claude-mailbox:mailbox-doctor.`
|
||||
|
||||
Reference in New Issue
Block a user