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:
@@ -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");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user