feat(hook): close mailbox on SessionEnd

New SessionEnd plugin hook runs `claude-mailbox session-end`, which derives the session's auto-name from stdin and asks the daemon to delete the mailbox if it has no pending messages either direction. Renamed mailboxes are preserved (the auto-name no longer exists, so DELETE is a no-op). The daemon-side `SKIP_UPSERT_PATHS` prevents the request from auto-recreating the mailbox. Sweeper remains the safety net for sessions that exit ungracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-22 09:39:29 +02:00
parent 7b58db771a
commit 1f7585152e
6 changed files with 150 additions and 1 deletions

View File

@@ -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 <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.")

View File

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

View File

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

View File

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

View File

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