feat(naming)!: auto-derive mailbox name from project + runtime rename

Mailbox names are now built as <project>-<session-short>, where <project>
is the sanitized git-repo basename (or cwd basename) — no more env-var
prefix step. Sessions can re-tag themselves at runtime via the new
mcp__mailbox__rename tool (POST /v1/rename), which transfers all
pending messages to the new name in a single transaction. Peers using
the old name re-discover via list_mailboxes.

BREAKING: \$CLAUDE_MAILBOX_NAME is no longer read. Existing setups that
relied on the env-var prefix should remove it from .claude/settings.json;
the prefix now comes from the working directory automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-05-20 13:14:15 +02:00
parent 8832eab6c7
commit b10ac36ed0
14 changed files with 441 additions and 95 deletions

View File

@@ -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("");
});

View File

@@ -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);

View File

@@ -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");
describe("sanitizeProjectName", () => {
it("lowercases and replaces non-alnum with dashes", () => {
expect(sanitizeProjectName("My Project!")).toBe("my-project");
});
it("prepends base prefix when given", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "backend")).toBe(
"backend-abc12345",
);
it("collapses runs of separators", () => {
expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar");
});
it("treats whitespace-only base as no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", " ")).toBe(
"claude-abc12345",
);
it("trims leading/trailing dashes", () => {
expect(sanitizeProjectName("--foo--")).toBe("foo");
});
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("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);
});
});

View File

@@ -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" },