diff --git a/node/src/cli.ts b/node/src/cli.ts index 0339458..1699463 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -265,6 +265,27 @@ program process.stdout.write(lines.join("\n")); }); +program + .command("session-end") + .description( + "SessionEnd-hook helper: derives the session's mailbox name from stdin session_id and asks the daemon to delete it if empty (no pending messages either direction). Silent on all errors — the daemon sweeper is the safety net.", + ) + .option("--url ", "Daemon base URL", DEFAULT_URL) + .action(async (opts: { url: string }) => { + const stdin = parseHookStdin(readStdinIfPiped()); + const sid = stdin?.session_id?.trim(); + if (!sid) return; + const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd(); + const name = deriveSessionName(sid, cwd); + try { + await callJson("POST", `${opts.url}/v1/session-end`, { + headers: { "X-Mailbox": name }, + }); + } catch { + // Daemon unreachable or other error — sweeper will clean up later. + } + }); + program .command("list") .description("List known mailboxes.") diff --git a/node/src/db.ts b/node/src/db.ts index 0dbe158..56d3168 100644 --- a/node/src/db.ts +++ b/node/src/db.ts @@ -366,6 +366,25 @@ export class MailboxStore { })); } + deleteIfEmpty(name: string): { deleted: boolean; reason: "deleted" | "not-found" | "has-pending" } { + return runInTransaction(this.db, () => { + const row = this.stmts.findMailbox.get(name) as MailboxRow | undefined; + if (!row) return { deleted: false, reason: "not-found" as const }; + const pendingIn = (this.stmts.countPending.get(name) as { n: number } | undefined)?.n ?? 0; + if (pendingIn > 0) return { deleted: false, reason: "has-pending" as const }; + const pendingOutRow = this.db + .prepare( + "SELECT 1 FROM messages WHERE from_mailbox = ? AND delivered_at IS NULL LIMIT 1", + ) + .get(name); + if (pendingOutRow) return { deleted: false, reason: "has-pending" as const }; + const namesJson = JSON.stringify([name]); + this.stmts.deleteMessagesForNames.run(namesJson, namesJson); + this.stmts.deleteMailboxesByNames.run(namesJson); + return { deleted: true, reason: "deleted" as const }; + }); + } + pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } { if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 }; const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString(); diff --git a/node/src/server.ts b/node/src/server.ts index c0996bc..8b1ed7a 100644 --- a/node/src/server.ts +++ b/node/src/server.ts @@ -27,6 +27,7 @@ function readVersion(): string { } const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]); +const SKIP_UPSERT_PATHS = new Set(["/v1/session-end"]); export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise { const app = Fastify({ @@ -49,7 +50,9 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi } req.mailboxName = name; - store.upsertMailbox(name); + if (!SKIP_UPSERT_PATHS.has(url)) { + store.upsertMailbox(name); + } }); app.get("/health", async () => ({ @@ -175,6 +178,12 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi }, ); + app.post("/v1/session-end", async (req) => { + const name = req.mailboxName!; + const result = store.deleteIfEmpty(name); + return { name, ...result }; + }); + await registerMcp(app, store, cfg.hideAfterMinutes); return app; diff --git a/node/tests/db.test.ts b/node/tests/db.test.ts index 1aa9ff1..d3c2657 100644 --- a/node/tests/db.test.ts +++ b/node/tests/db.test.ts @@ -310,3 +310,54 @@ describe("pruneStale", () => { } }); }); + +describe("deleteIfEmpty", () => { + it("deletes a fresh mailbox with no pending messages and wipes its delivered history", () => { + const store = new MailboxStore(dbPath); + try { + store.send("alice", "bob", "old"); + store.checkInbox("bob"); + const r = store.deleteIfEmpty("bob"); + expect(r).toEqual({ deleted: true, reason: "deleted" }); + expect(store.listMailboxes().map((m) => m.name)).not.toContain("bob"); + } finally { + store.close(); + } + }); + + it("refuses to delete when the mailbox has undelivered incoming mail", () => { + const store = new MailboxStore(dbPath); + try { + store.send("alice", "bob", "still pending"); + const r = store.deleteIfEmpty("bob"); + expect(r).toEqual({ deleted: false, reason: "has-pending" }); + expect(store.peek("bob").pending).toBe(1); + } finally { + store.close(); + } + }); + + it("refuses to delete when the mailbox has undelivered outgoing mail", () => { + const store = new MailboxStore(dbPath); + try { + store.send("alice", "bob", "from alice"); + const r = store.deleteIfEmpty("alice"); + expect(r).toEqual({ deleted: false, reason: "has-pending" }); + expect(store.listMailboxes().map((m) => m.name)).toContain("alice"); + } finally { + store.close(); + } + }); + + it("is a no-op for an unknown name (e.g. renamed mailbox)", () => { + const store = new MailboxStore(dbPath); + try { + store.upsertMailbox("alice"); + const r = store.deleteIfEmpty("nope"); + expect(r).toEqual({ deleted: false, reason: "not-found" }); + expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]); + } finally { + store.close(); + } + }); +}); diff --git a/node/tests/server.test.ts b/node/tests/server.test.ts index e8bf915..850c370 100644 --- a/node/tests/server.test.ts +++ b/node/tests/server.test.ts @@ -210,4 +210,43 @@ describe("REST surface", () => { const peek = await call("GET", "/v1/peek?name=bob"); expect(peek.status).toBe(200); }); + + it("POST /v1/session-end deletes an empty mailbox and does not auto-recreate it", async () => { + await call("POST", "/v1/send", { + headers: { "X-Mailbox": "alice" }, + body: { to: "bob", body: "x" }, + }); + await call("POST", "/v1/check-inbox?name=bob", { headers: { "X-Mailbox": "bob" } }); + + const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } }); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ name: "bob", deleted: true, reason: "deleted" }); + + const list = await call("GET", "/v1/list"); + const names = (list.body as Array<{ name: string }>).map((m) => m.name); + expect(names).not.toContain("bob"); + }); + + it("POST /v1/session-end refuses to delete a mailbox with pending messages", async () => { + await call("POST", "/v1/send", { + headers: { "X-Mailbox": "alice" }, + body: { to: "bob", body: "still pending" }, + }); + const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "bob" } }); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ name: "bob", deleted: false, reason: "has-pending" }); + + const peek = await call("GET", "/v1/peek?name=bob"); + expect(peek.body).toMatchObject({ pending: 1 }); + }); + + it("POST /v1/session-end is a no-op for an unknown (e.g. renamed) name", async () => { + const r = await call("POST", "/v1/session-end", { headers: { "X-Mailbox": "renamed-away" } }); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ name: "renamed-away", deleted: false, reason: "not-found" }); + + const list = await call("GET", "/v1/list"); + const names = (list.body as Array<{ name: string }>).map((m) => m.name); + expect(names).not.toContain("renamed-away"); + }); }); diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index b238873..04d4bf9 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -29,6 +29,16 @@ } ] } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "claude-mailbox session-end" + } + ] + } ] } }