6 Commits
v1.5.3 ... main

Author SHA1 Message Date
Mika Kuns
debc6287e2 chore(release): 1.5.6
All checks were successful
CI (Node) / build-test (push) Successful in 13s
Release (Node) / release (push) Successful in 16s
2026-05-27 13:16:51 +02:00
Mika Kuns
fed74dc8f0 docs: document TaskCompleted hook in CLAUDE.md 2026-05-27 13:16:19 +02:00
Mika Kuns
3ebf54e75d feat(hook): add TaskCompleted drain + multi-event install-hook
Wire a fifth pull hook so peer messages also surface between todo
items, not only at user prompts and subagent stops. While here,
extend the manual `install-hook` CLI so it patches the full plugin
hook set (SessionStart/UserPromptSubmit/SubagentStop/TaskCompleted/
SessionEnd) instead of only UserPromptSubmit, mirroring what the
plugin's hooks.json registers. Mailbox name is auto-derived from
stdin, so --name is no longer required.

Also corrects stale docs that claimed SessionStart auto-bootstraps
the watcher — push delivery has been opt-in since the
mailbox-collaborate skill landed.
2026-05-27 11:23:19 +02:00
mika kuns
840a3e32c8 chore(release): 1.5.5
All checks were successful
CI (Node) / build-test (push) Successful in 11s
Release (Node) / release (push) Successful in 13s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:39:29 +02:00
mika kuns
1f7585152e 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>
2026-05-22 09:39:29 +02:00
mika kuns
7b58db771a chore(release): 1.5.4
All checks were successful
CI (Node) / build-test (push) Successful in 12s
Release (Node) / release (push) Successful in 15s
Make /collaborate slash command self-contained so it works without a registered mailbox-collaborate skill in the session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:23:08 +02:00
16 changed files with 482 additions and 78 deletions

109
CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repo layout (important)
The active codebase lives entirely in **`node/`** (TypeScript, Node 24+). The top-level `src/ClaudeMailbox/` and `tests/ClaudeMailbox.Tests/` directories are stale .NET build artifacts (`bin/`, `obj/` only) left over from an abandoned C# prototype — ignore them, don't build them, don't grep them.
Other top-level dirs:
- `plugin/` — the Claude Code plugin (`hooks/hooks.json`, slash `commands/`, the `mailbox-collaborate` skill). Loaded via `.claude-plugin/marketplace.json`.
- `homebrew/`, `install.ps1`, `install.sh` — distribution shims for the published npm package `@kuns/claude-mailbox`.
- `docs/superpowers/` — design specs and plans (history, not runtime).
## Development commands
All commands run from `node/`:
```sh
cd node
npm install
npm run build # tsc → dist/
npm test # pretest builds, then vitest run
npm run test:watch # vitest in watch mode
npx vitest run tests/cli-watch.test.ts # single test file
npx vitest run -t "rename" # by test name pattern
npm start # node dist/cli.js serve (run daemon in foreground)
```
`npm test` runs `pretest` (build) first — vitest executes against `dist/`, not via a TS transformer, so always rebuild after editing `src/` if running vitest directly.
Vitest config: `tests/**/*.test.ts`, `pool: "forks"`, 15 s timeout. Tests spin up real Fastify servers and real SQLite files in temp dirs — they are integration tests, not unit tests with mocks.
## Runtime CLI surface (after `npm run build`)
`dist/cli.js` is the single entry point (bin name `claude-mailbox`):
```
serve | send | peek | check [--hook] | watch --block | list | status
session-announce # SessionStart hook helper, reads stdin JSON
session-end # SessionEnd hook helper, deletes mailbox if empty
install-hook / uninstall-hook # patch settings.json
install-autostart / uninstall-autostart [--service] # OS autostart registration
mcp-stdio # stdio MCP wrapper that proxies to the HTTP daemon
```
All subcommands accept `--url <url>`; `CLAUDE_MAILBOX_URL` env overrides the default `http://127.0.0.1:37849`.
## Architecture
One long-running daemon, single SQLite file, multiple thin clients.
```
clients (Claude sessions, CLI, scripts)
│ HTTP loopback
claude-mailbox serve (Fastify, port 37849)
├─ /mcp MCP Streamable HTTP transport (registerMcp)
├─ /v1/send /peek /check-inbox /watch /list REST
└─ /health
~/.claude-mailbox/mailbox.db (SQLite WAL, two tables: mailboxes, messages)
```
Module map under `node/src/`:
| File | Responsibility |
|---|---|
| `cli.ts` | Commander CLI; dispatches to every other module. Single entry. |
| `server.ts` | Fastify app, request hook that enforces `X-Mailbox` header on non-anonymous paths, REST routes. |
| `mcp.ts` | Registers MCP tools (`send`, `check_inbox`, `peek_inbox`, `list_mailboxes`, `rename`) on the same Fastify app at `/mcp`. |
| `mcp-stdio.ts` | Stdio MCP wrapper used by the plugin's `.mcp.json` — proxies tool calls to the HTTP daemon (workaround for Claude Code not yet supporting env-var substitution in HTTP MCP URLs). |
| `db.ts` | `MailboxStore` — all SQL via `node:sqlite` (no ORM). Owns DDL idempotency, atomic `check_inbox`, rename-with-message-transfer, long-poll `wait` for push delivery. |
| `hook.ts` | Helpers shared by `session-announce` / `check --hook` / `install-hook`: stdin parsing, identity derivation (`<project>-<8hex>` from `session_id`), settings.json patching, peer formatting. |
| `config.ts` | Config precedence: CLI flag > `mailbox.json` > defaults. Looks in `~/.claude-mailbox/mailbox.json` and on Windows also `%ProgramData%\ClaudeMailbox\mailbox.json`. |
| `autostart/{windows,darwin,linux}.ts` | Per-OS autostart: Scheduled Task / HKCU Run / `node-windows` service / launchd LaunchAgent / systemd `--user`. Selected via `autostart/index.ts`. |
### Identity derivation
`hook.ts::deriveSessionName` builds the mailbox name from the SessionStart hook's stdin JSON (`session_id`, `cwd`): `<sanitized-project>-<first-8-hex-of-session-id>`. Project name is the git repo basename if inside a repo, else the cwd basename, sanitized (lowercased, non-alphanumerics → `-`, capped at 40 chars). Without a cwd it degrades to `claude-<8hex>`. This is how two parallel sessions in the same project stay distinct.
### Push delivery (`watch --block`)
Long-poll on `GET /v1/watch?name=…&timeout=…`. The daemon parks the request on an in-memory waiter list keyed by mailbox; `send` wakes the first waiter atomically (FIFO winner across concurrent watchers). Exit codes: `0` delivered (or `Mailbox renamed to '<new>'` on stdout), `2` daemon unreachable, `3` timeout. Push is **opt-in** — the plugin's `SessionStart` hook does *not* launch the watcher automatically; users invoke the `mailbox-collaborate` skill (or `/collaborate`) to enter collaboration mode. The pull path via `UserPromptSubmit` / `SubagentStop` hooks remains the always-on fallback.
### Plugin hooks (what runs without you doing anything)
`plugin/hooks/hooks.json` wires five hooks to the installed `claude-mailbox` binary:
- `SessionStart``claude-mailbox session-announce` (prints identity + active peers into context, registers session with daemon)
- `UserPromptSubmit``claude-mailbox check --hook` (drains inbox, injects messages)
- `SubagentStop``claude-mailbox check --hook` (same, when a subagent finishes)
- `TaskCompleted``claude-mailbox check --hook` (same, when Claude marks a TaskCreate task completed — gives mid-run sync points between todo items)
- `SessionEnd``claude-mailbox session-end` (deletes the session's mailbox if no pending messages — same semantics as the sweeper, just immediate; renamed mailboxes are preserved because the auto-derived name no longer exists)
`install-hook` / `uninstall-hook` patch the same five events into `settings.json` for users not using the plugin. The manual installer is multi-event aware — adding/removing a hook in `plugin/hooks/hooks.json` should be mirrored by `MANAGED_HOOK_EVENTS` and `buildPluginHookCommands` in `hook.ts`.
## Conventions worth knowing
- ES modules with `NodeNext` resolution — relative imports must use the `.js` extension even in `.ts` source (e.g. `import { … } from "./db.js"`).
- `tsconfig.json` is strict including `noUnusedLocals` / `noUnusedParameters` — dead identifiers fail the build.
- `node:sqlite` is used directly (no `better-sqlite3`) — Node 24+ requirement comes from this.
- The daemon **never authenticates**: loopback bind + filesystem perms are the trust boundary. `X-Mailbox` header is identity, not auth — anything on loopback can claim any name. Don't add code that assumes otherwise.
- `*.db`, `*.db-shm`, `*.db-wal` are gitignored — never commit a real mailbox DB.
## Release
`node/package.json` is the published artifact (`@kuns/claude-mailbox`, registry `https://git.kuns.dev/api/packages/releases/npm/`). Version bumps land as `chore(release): X.Y.Z` commits (see git log). The plugin and the npm package version-lockstep — `mailbox-doctor` and `mailbox-update` slash commands run `npm install -g @kuns/claude-mailbox` against that registry.

View File

@@ -191,7 +191,7 @@ claude-mailbox watch --block --name <mailbox> [--timeout 25]
claude-mailbox list
claude-mailbox status
claude-mailbox session-announce # hook helper, reads stdin JSON
claude-mailbox install-hook --name <mailbox> [--user|--project]
claude-mailbox install-hook [--user|--project] [--url <url>]
claude-mailbox uninstall-hook [--user|--project]
```

View File

@@ -19,25 +19,35 @@ claude-mailbox install-autostart # registers per-OS autostart, no admin needed
See the [repository README](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.
## Claude Code hook (auto-check inbox)
## Claude Code hooks (auto-check inbox)
Register a `UserPromptSubmit` hook so Claude pulls pending mailbox messages before every prompt:
Register the full plugin-equivalent hook set so Claude pulls pending mailbox messages at every natural sync point:
```sh
claude-mailbox install-hook --name alice # patches ~/.claude/settings.json
claude-mailbox install-hook --name alice --project # patches <cwd>/.claude/settings.json
claude-mailbox install-hook # patches ~/.claude/settings.json
claude-mailbox install-hook --project # patches <cwd>/.claude/settings.json
claude-mailbox uninstall-hook # remove again
```
The hook is idempotent (running `install-hook` twice does nothing the second time) and only touches the `UserPromptSubmit` block — other hooks and settings are preserved.
This installs five hooks:
Under the hood the hook runs `claude-mailbox check --name <mailbox> --hook`, which:
| Event | Command | When it fires |
|---|---|---|
| `SessionStart` | `session-announce` | Announces this session's mailbox identity + active peers. |
| `UserPromptSubmit` | `check --hook` | Before each user prompt. |
| `SubagentStop` | `check --hook` | When a subagent finishes. |
| `TaskCompleted` | `check --hook` | When Claude marks a `TaskCreate` task completed — gives mid-run sync points. |
| `SessionEnd` | `session-end` | Cleans up the auto-derived mailbox if empty. |
The mailbox name is auto-derived from the session-id stdin payload — no `--name` required. Install is idempotent and only touches our own commands; other hooks and settings are preserved.
`check --hook`:
- prints unread messages in a Claude-friendly format,
- silently exits 0 if the inbox is empty or the daemon is unreachable (no context noise),
- marks the messages delivered so they aren't injected again next prompt.
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows).
Cost: one local HTTP round-trip plus Node coldstart per fire (~100ms on Windows).
## Push delivery (watch)

View File

@@ -1,12 +1,12 @@
{
"name": "@kuns/claude-mailbox",
"version": "1.5.3",
"version": "1.5.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@kuns/claude-mailbox",
"version": "1.5.3",
"version": "1.5.6",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@kuns/claude-mailbox",
"version": "1.5.3",
"version": "1.5.6",
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
"type": "module",
"bin": {

View File

@@ -9,7 +9,7 @@ import { runStdioMcp } from "./mcp-stdio.js";
import {
applyInstall,
applyUninstall,
buildHookCommand,
buildPluginHookCommands,
buildSessionAnnounceLines,
deriveSessionName,
formatMessagesForHook,
@@ -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.")
@@ -344,13 +365,12 @@ program
program
.command("install-hook")
.description(
"Install a Claude Code UserPromptSubmit hook that checks the mailbox on every prompt. Idempotent.",
"Install the full set of Claude Code hooks that mirror the plugin's hooks.json: SessionStart announces identity, UserPromptSubmit/SubagentStop/TaskCompleted drain the inbox, SessionEnd cleans up. Mailbox name is auto-derived from session stdin. Idempotent.",
)
.requiredOption("--name <name>", "Mailbox name to check")
.option("--user", "Patch ~/.claude/settings.json (default)")
.option("--project", "Patch <cwd>/.claude/settings.json")
.option("--url <url>", "Daemon base URL to embed in the hook command")
.action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => {
.option("--url <url>", "Daemon base URL to embed in each hook command")
.action(async (opts: { user?: boolean; project?: boolean; url?: string }) => {
if (opts.user && opts.project) {
console.error("Pick either --user or --project, not both.");
process.exit(1);
@@ -358,20 +378,34 @@ program
const scope: HookScope = opts.project ? "project" : "user";
const path = settingsPathFor(scope);
const settings = readSettings(path);
const command = buildHookCommand(opts.name, opts.url);
const result = applyInstall(settings, command);
if (result.changed) {
const hooks = buildPluginHookCommands(opts.url);
const added: string[] = [];
const alreadyPresent: string[] = [];
for (const h of hooks) {
const r = applyInstall(settings, h.event, h.command);
if (r.changed) added.push(h.event);
else alreadyPresent.push(h.event);
}
if (added.length > 0) {
writeSettings(path, settings);
console.log(`Hook installed in ${path}`);
console.log(`Command: ${command}`);
console.log(`Hooks installed in ${path}:`);
for (const event of added) console.log(` + ${event}`);
if (alreadyPresent.length > 0) {
console.log("Already present:");
for (const event of alreadyPresent) console.log(` · ${event}`);
}
} else {
console.log(`Hook already present in ${path}; nothing to do.`);
console.log(`All hooks already present in ${path}; nothing to do.`);
}
});
program
.command("uninstall-hook")
.description("Remove the claude-mailbox UserPromptSubmit hook from Claude Code settings.")
.description(
"Remove all claude-mailbox hooks (SessionStart, UserPromptSubmit, SubagentStop, TaskCompleted, SessionEnd) from Claude Code settings.",
)
.option("--user", "Patch ~/.claude/settings.json (default)")
.option("--project", "Patch <cwd>/.claude/settings.json")
.action(async (opts: { user?: boolean; project?: boolean }) => {

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

@@ -194,12 +194,31 @@ interface ClaudeSettings {
[key: string]: unknown;
}
const HOOK_EVENT = "UserPromptSubmit";
export interface ManagedHook {
event: string;
command: string;
}
export function buildHookCommand(name: string, url?: string): string {
const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"];
if (url) parts.push("--url", quoteIfNeeded(url));
return parts.join(" ");
export const MANAGED_HOOK_EVENTS = [
"SessionStart",
"UserPromptSubmit",
"SubagentStop",
"TaskCompleted",
"SessionEnd",
] as const;
export function buildPluginHookCommands(url?: string): ManagedHook[] {
const urlSuffix = url ? ` --url ${quoteIfNeeded(url)}` : "";
const check = `claude-mailbox check --hook${urlSuffix}`;
const announce = `claude-mailbox session-announce${urlSuffix}`;
const end = `claude-mailbox session-end${urlSuffix}`;
return [
{ event: "SessionStart", command: announce },
{ event: "UserPromptSubmit", command: check },
{ event: "SubagentStop", command: check },
{ event: "TaskCompleted", command: check },
{ event: "SessionEnd", command: end },
];
}
function quoteIfNeeded(value: string): string {
@@ -209,7 +228,10 @@ function quoteIfNeeded(value: string): string {
function isOurHookCommand(command: string): boolean {
const c = command.trim();
return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c);
if (/(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c)) return true;
if (/(^|\W)claude-mailbox\s+session-announce(\s|$)/.test(c)) return true;
if (/(^|\W)claude-mailbox\s+session-end(\s|$)/.test(c)) return true;
return false;
}
export function readSettings(path: string): ClaudeSettings {
@@ -229,10 +251,14 @@ export interface PatchResult {
reason: "added" | "already-present" | "removed" | "not-present";
}
export function applyInstall(settings: ClaudeSettings, command: string): PatchResult {
export function applyInstall(
settings: ClaudeSettings,
event: string,
command: string,
): PatchResult {
settings.hooks ??= {};
settings.hooks[HOOK_EVENT] ??= [];
const groups = settings.hooks[HOOK_EVENT];
settings.hooks[event] ??= [];
const groups = settings.hooks[event];
for (const group of groups) {
for (const hook of group.hooks) {
@@ -252,21 +278,26 @@ export function applyInstall(settings: ClaudeSettings, command: string): PatchRe
}
export function applyUninstall(settings: ClaudeSettings): PatchResult {
const groups = settings.hooks?.[HOOK_EVENT];
if (!groups || groups.length === 0) return { changed: false, reason: "not-present" };
if (!settings.hooks) return { changed: false, reason: "not-present" };
let removed = false;
for (const event of MANAGED_HOOK_EVENTS) {
const groups = settings.hooks[event];
if (!groups || groups.length === 0) continue;
for (const group of groups) {
const before = group.hooks.length;
group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command));
if (group.hooks.length !== before) removed = true;
}
settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0);
if (settings.hooks![HOOK_EVENT].length === 0) {
delete settings.hooks![HOOK_EVENT];
settings.hooks[event] = groups.filter((g) => g.hooks.length > 0);
if (settings.hooks[event].length === 0) {
delete settings.hooks[event];
}
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
}
if (Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}

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

@@ -6,12 +6,13 @@ import { execFileSync } from "node:child_process";
import {
applyInstall,
applyUninstall,
buildHookCommand,
buildPluginHookCommands,
buildSessionAnnounceLines,
deriveProjectName,
deriveSessionName,
formatActivePeerList,
formatMessagesForHook,
MANAGED_HOOK_EVENTS,
parseHookStdin,
readSettings,
sanitizeProjectName,
@@ -53,34 +54,49 @@ describe("formatMessagesForHook", () => {
});
});
describe("buildHookCommand", () => {
it("builds a basic command", () => {
expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook");
describe("buildPluginHookCommands", () => {
it("returns one entry per managed event", () => {
const hooks = buildPluginHookCommands();
expect(hooks.map((h) => h.event)).toEqual([...MANAGED_HOOK_EVENTS]);
});
it("appends --url when provided", () => {
expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe(
"claude-mailbox check --name alice --hook --url http://127.0.0.1:9000",
);
it("uses session-announce for SessionStart and session-end for SessionEnd", () => {
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
expect(map.get("SessionStart")).toBe("claude-mailbox session-announce");
expect(map.get("SessionEnd")).toBe("claude-mailbox session-end");
});
it("quotes names with spaces", () => {
const cmd = buildHookCommand("my mailbox");
expect(cmd).toContain('--name "my mailbox"');
it("uses check --hook for the three drain events", () => {
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
expect(map.get("UserPromptSubmit")).toBe("claude-mailbox check --hook");
expect(map.get("SubagentStop")).toBe("claude-mailbox check --hook");
expect(map.get("TaskCompleted")).toBe("claude-mailbox check --hook");
});
it("appends --url to every command when provided", () => {
for (const h of buildPluginHookCommands("http://127.0.0.1:9000")) {
expect(h.command).toContain("--url http://127.0.0.1:9000");
}
});
it("quotes URLs that need it", () => {
for (const h of buildPluginHookCommands("http://has space/")) {
expect(h.command).toContain('--url "http://has space/"');
}
});
});
describe("applyInstall", () => {
it("creates hooks structure from empty settings", () => {
const s: Record<string, unknown> = {};
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
expect(r).toEqual({ changed: true, reason: "added" });
expect(s).toMatchObject({
hooks: {
UserPromptSubmit: [
{
matcher: "",
hooks: [{ type: "command", command: "claude-mailbox check --name bob --hook" }],
hooks: [{ type: "command", command: "claude-mailbox check --hook" }],
},
],
},
@@ -89,13 +105,20 @@ describe("applyInstall", () => {
it("is idempotent — does not duplicate the same command", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "claude-mailbox check --name bob --hook");
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
expect(r).toEqual({ changed: false, reason: "already-present" });
const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit;
expect(groups[0]!.hooks).toHaveLength(1);
});
it("installs into the target event, not always UserPromptSubmit", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "TaskCompleted", "claude-mailbox check --hook");
expect((s.hooks as Record<string, unknown>).TaskCompleted).toBeDefined();
expect((s.hooks as Record<string, unknown>).UserPromptSubmit).toBeUndefined();
});
it("preserves existing unrelated hooks", () => {
const s: Record<string, unknown> = {
hooks: {
@@ -108,13 +131,11 @@ describe("applyInstall", () => {
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }],
},
};
applyInstall(s, "claude-mailbox check --name bob --hook");
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2);
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe(
"claude-mailbox check --name bob --hook",
);
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe("claude-mailbox check --hook");
expect((s.hooks as Record<string, unknown>).PostToolUse).toBeDefined();
});
@@ -126,7 +147,7 @@ describe("applyInstall", () => {
],
},
};
applyInstall(s, "claude-mailbox check --name bob --hook");
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit;
expect(groups).toHaveLength(2);
expect(groups[1]!.matcher).toBe("");
@@ -134,14 +155,40 @@ describe("applyInstall", () => {
});
describe("applyUninstall", () => {
it("removes the hook and cleans up empty structures", () => {
it("removes a single-event install and cleans up empty structures", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "claude-mailbox check --name bob --hook");
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
const r = applyUninstall(s);
expect(r).toEqual({ changed: true, reason: "removed" });
expect(s.hooks).toBeUndefined();
});
it("removes hooks across all managed events in one pass", () => {
const s: Record<string, unknown> = {};
for (const h of buildPluginHookCommands()) {
applyInstall(s, h.event, h.command);
}
const r = applyUninstall(s);
expect(r).toEqual({ changed: true, reason: "removed" });
expect(s.hooks).toBeUndefined();
});
it("recognizes legacy hooks with --name and removes them too", () => {
const s: Record<string, unknown> = {
hooks: {
UserPromptSubmit: [
{
matcher: "",
hooks: [{ type: "command", command: "claude-mailbox check --name alice --hook" }],
},
],
},
};
const r = applyUninstall(s);
expect(r.changed).toBe(true);
expect(s.hooks).toBeUndefined();
});
it("preserves unrelated hooks in the same group", () => {
const s: Record<string, unknown> = {
hooks: {
@@ -150,7 +197,7 @@ describe("applyUninstall", () => {
matcher: "",
hooks: [
{ type: "command", command: "echo something-else" },
{ type: "command", command: "claude-mailbox check --name bob --hook" },
{ type: "command", command: "claude-mailbox check --hook" },
],
},
],
@@ -170,9 +217,11 @@ describe("applyUninstall", () => {
expect(r).toEqual({ changed: false, reason: "not-present" });
});
it("removes hooks installed with --url arg", () => {
it("removes hooks installed with --url arg across every event", () => {
const s: Record<string, unknown> = {};
applyInstall(s, "claude-mailbox check --name bob --hook --url http://x");
for (const h of buildPluginHookCommands("http://x")) {
applyInstall(s, h.event, h.command);
}
applyUninstall(s);
expect(s.hooks).toBeUndefined();
});
@@ -419,11 +468,11 @@ describe("readSettings / writeSettings roundtrip", () => {
const path = join(dir, "settings.json");
const s = readSettings(path);
expect(s).toEqual({});
applyInstall(s, "claude-mailbox check --name bob --hook");
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
writeSettings(path, s);
const reloaded = readSettings(path);
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
"claude-mailbox check --name bob --hook",
"claude-mailbox check --hook",
);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -451,7 +500,7 @@ describe("readSettings / writeSettings roundtrip", () => {
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
);
const s = readSettings(path);
applyInstall(s, "claude-mailbox check --name bob --hook");
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
writeSettings(path, s);
const reloaded = readSettings(path) as {
model?: string;

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

View File

@@ -1,6 +1,6 @@
{
"name": "claude-mailbox",
"version": "1.5.3",
"version": "1.5.6",
"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": {
"name": "Mika Kuns"

View File

@@ -41,11 +41,13 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
|---|---|---|
| `SessionStart` | `claude-mailbox session-announce` | Registers the session with the daemon, then prints (a) this session's mailbox name, (b) the exact `from` / `name` args to pass to MCP tools, and (c) a list of other mailboxes active in the last hour — so Claude knows who's around without needing to call `list_mailboxes` first. |
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
| `SubagentStop` | `claude-mailbox check --hook` | Same as `UserPromptSubmit`, but fires when a subagent finishes (Task tool). Lets the parent see peer messages that arrived during a long-running subagent run, instead of waiting until the next user prompt. |
| `SubagentStop` | `claude-mailbox check --hook` | Same as `UserPromptSubmit`, but fires when a subagent finishes. Lets the parent see peer messages that arrived during a long-running subagent run, instead of waiting until the next user prompt. |
| `TaskCompleted` | `claude-mailbox check --hook` | Fires whenever Claude marks a `TaskCreate` task completed — gives peers mid-run injection points between todo items without needing the opt-in watcher. |
| `SessionEnd` | `claude-mailbox session-end` | Deletes this session's auto-derived mailbox if it's empty (no pending messages). Renamed mailboxes are preserved. |
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
Cost: one local HTTP round-trip per fire + Node coldstart (~100ms on Windows).
The SessionStart announcement also instructs Claude to start `claude-mailbox watch --block --name <derived-name>` as a background bash task on its first turn. While that watcher is alive, peers can `mcp__mailbox__send(...)` and Claude reacts mid-turn — no user prompt needed. After processing each completion (delivery, timeout, rename, or daemon-down), Claude relaunches the watcher in the background. The pull hook (`UserPromptSubmit`) remains as a fallback for any messages that arrive while no watcher is running.
Push delivery (real-time mid-turn wakeup via `claude-mailbox watch --block`) is **opt-in**. Invoke the `mailbox-collaborate` skill or the `/collaborate` slash command when you want peers to wake the session mid-task. Without it, the four pull hooks above are the always-on delivery path.
## MCP tools

View File

@@ -2,4 +2,35 @@
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.

View File

@@ -29,6 +29,26 @@
}
]
}
],
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": "claude-mailbox check --hook"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "claude-mailbox session-end"
}
]
}
]
}
}