Compare commits
3 Commits
v1.5.3
...
840a3e32c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840a3e32c8 | ||
|
|
1f7585152e | ||
|
|
7b58db771a |
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kuns/claude-mailbox",
|
"name": "@kuns/claude-mailbox",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -265,6 +265,27 @@ program
|
|||||||
process.stdout.write(lines.join("\n"));
|
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
|
program
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List known mailboxes.")
|
.description("List known mailboxes.")
|
||||||
|
|||||||
@@ -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 } {
|
pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } {
|
||||||
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
|
||||||
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
|
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function readVersion(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
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> {
|
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -49,7 +50,9 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.mailboxName = name;
|
req.mailboxName = name;
|
||||||
store.upsertMailbox(name);
|
if (!SKIP_UPSERT_PATHS.has(url)) {
|
||||||
|
store.upsertMailbox(name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", async () => ({
|
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);
|
await registerMcp(app, store, cfg.hideAfterMinutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -210,4 +210,43 @@ describe("REST surface", () => {
|
|||||||
const peek = await call("GET", "/v1/peek?name=bob");
|
const peek = await call("GET", "/v1/peek?name=bob");
|
||||||
expect(peek.status).toBe(200);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mailbox",
|
"name": "claude-mailbox",
|
||||||
"version": "1.5.3",
|
"version": "1.5.5",
|
||||||
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mika Kuns"
|
"name": "Mika Kuns"
|
||||||
|
|||||||
@@ -2,4 +2,35 @@
|
|||||||
description: Enter Claude-Mailbox collaboration mode — start the push-delivery watch loop so peers can wake Claude mid-task.
|
description: Enter Claude-Mailbox collaboration mode — start the push-delivery watch loop so peers can wake Claude mid-task.
|
||||||
---
|
---
|
||||||
|
|
||||||
Invoke the `mailbox-collaborate` skill now to enter collaboration mode and start the watcher relaunch loop.
|
You are now in **mailbox collaboration mode**. Peers can wake you mid-task by sending mail to your mailbox.
|
||||||
|
|
||||||
|
## Launch the watcher
|
||||||
|
|
||||||
|
Your mailbox name was announced at SessionStart (format: `<project>-<short-session-id>`). Use it in place of `<NAME>` below.
|
||||||
|
|
||||||
|
Launch the watcher as a background bash task immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bash(command="claude-mailbox watch --block --name <NAME>", run_in_background=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher is short-lived by design — it exits after exactly one event (delivered message, 25-second timeout, rename, or daemon error). Claude Code's BashOutput notifications fire only on task completion, so every exit MUST be followed by a fresh background relaunch with the same command. Gaps in the loop = missed pushes.
|
||||||
|
|
||||||
|
## Handling watcher completions
|
||||||
|
|
||||||
|
When a watcher task completes, inspect its stdout and exit code:
|
||||||
|
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mail from <peer>: <body>`** → treat the body as an inbound interrupt with priority over your current plan. Address it, then relaunch the watcher.
|
||||||
|
- **stdout contains `[Claude-Mailbox] Mailbox renamed to '<new>'`** → relaunch with `--name <new>`, and use `<new>` for all future `mcp__mailbox__*` calls (update your identity).
|
||||||
|
- **exit code 3 with no stdout** → silent timeout, just relaunch.
|
||||||
|
- **exit code 2** → daemon unreachable; wait ~5 seconds, then relaunch.
|
||||||
|
- **any other exit code** → report it to the user, then relaunch.
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
Keep the loop running until the user says "stop watching", "stop collaborating", "end collaboration", or similar. When they do:
|
||||||
|
|
||||||
|
- Stop relaunching after the next completion.
|
||||||
|
- If a watcher is currently mid-poll and the user wants it killed immediately, use `TaskStop` on its task id.
|
||||||
|
|
||||||
|
Do not re-enter collaboration mode on your own after stopping — wait for the user to invoke this command again.
|
||||||
|
|||||||
@@ -29,6 +29,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-end"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user