diff --git a/README.md b/README.md index d2827d3..0c55318 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ claude-mailbox watch --block --name [--timeout 25] claude-mailbox list claude-mailbox status claude-mailbox session-announce # hook helper, reads stdin JSON -claude-mailbox install-hook --name [--user|--project] +claude-mailbox install-hook [--user|--project] [--url ] claude-mailbox uninstall-hook [--user|--project] ``` diff --git a/node/README.md b/node/README.md index 82efe3f..66cd91f 100644 --- a/node/README.md +++ b/node/README.md @@ -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 /.claude/settings.json -claude-mailbox uninstall-hook # remove again +claude-mailbox install-hook # patches ~/.claude/settings.json +claude-mailbox install-hook --project # patches /.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 --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) diff --git a/node/src/cli.ts b/node/src/cli.ts index 1699463..784268d 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -9,7 +9,7 @@ import { runStdioMcp } from "./mcp-stdio.js"; import { applyInstall, applyUninstall, - buildHookCommand, + buildPluginHookCommands, buildSessionAnnounceLines, deriveSessionName, formatMessagesForHook, @@ -365,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 ", "Mailbox name to check") .option("--user", "Patch ~/.claude/settings.json (default)") .option("--project", "Patch /.claude/settings.json") - .option("--url ", "Daemon base URL to embed in the hook command") - .action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => { + .option("--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); @@ -379,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 /.claude/settings.json") .action(async (opts: { user?: boolean; project?: boolean }) => { diff --git a/node/src/hook.ts b/node/src/hook.ts index b380650..5b657ae 100644 --- a/node/src/hook.ts +++ b/node/src/hook.ts @@ -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 group of groups) { - const before = group.hooks.length; - group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command)); - if (group.hooks.length !== before) removed = true; + 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[event] = groups.filter((g) => g.hooks.length > 0); + if (settings.hooks[event].length === 0) { + delete settings.hooks[event]; + } } - settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0); - if (settings.hooks![HOOK_EVENT].length === 0) { - delete settings.hooks![HOOK_EVENT]; - } - if (settings.hooks && Object.keys(settings.hooks).length === 0) { + if (Object.keys(settings.hooks).length === 0) { delete settings.hooks; } diff --git a/node/tests/hook.test.ts b/node/tests/hook.test.ts index aa1ade7..acbd492 100644 --- a/node/tests/hook.test.ts +++ b/node/tests/hook.test.ts @@ -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 = {}; - 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 = {}; - 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 = {}; + applyInstall(s, "TaskCompleted", "claude-mailbox check --hook"); + expect((s.hooks as Record).TaskCompleted).toBeDefined(); + expect((s.hooks as Record).UserPromptSubmit).toBeUndefined(); + }); + it("preserves existing unrelated hooks", () => { const s: Record = { 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).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 = {}; - 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 = {}; + 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 = { + 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 = { 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 = {}; - 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; diff --git a/plugin/README.md b/plugin/README.md index 4f1bb3e..3a16f39 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -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 ` 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 diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 04d4bf9..0dc3c7a 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -30,6 +30,16 @@ ] } ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "claude-mailbox check --hook" + } + ] + } + ], "SessionEnd": [ { "hooks": [