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

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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