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.
This commit is contained in:
@@ -191,7 +191,7 @@ claude-mailbox watch --block --name <mailbox> [--timeout 25]
|
|||||||
claude-mailbox list
|
claude-mailbox list
|
||||||
claude-mailbox status
|
claude-mailbox status
|
||||||
claude-mailbox session-announce # hook helper, reads stdin JSON
|
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]
|
claude-mailbox uninstall-hook [--user|--project]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
```sh
|
||||||
claude-mailbox install-hook --name alice # patches ~/.claude/settings.json
|
claude-mailbox install-hook # patches ~/.claude/settings.json
|
||||||
claude-mailbox install-hook --name alice --project # patches <cwd>/.claude/settings.json
|
claude-mailbox install-hook --project # patches <cwd>/.claude/settings.json
|
||||||
claude-mailbox uninstall-hook # remove again
|
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,
|
- prints unread messages in a Claude-friendly format,
|
||||||
- silently exits 0 if the inbox is empty or the daemon is unreachable (no context noise),
|
- 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.
|
- 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)
|
## Push delivery (watch)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { runStdioMcp } from "./mcp-stdio.js";
|
|||||||
import {
|
import {
|
||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildPluginHookCommands,
|
||||||
buildSessionAnnounceLines,
|
buildSessionAnnounceLines,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatMessagesForHook,
|
formatMessagesForHook,
|
||||||
@@ -365,13 +365,12 @@ program
|
|||||||
program
|
program
|
||||||
.command("install-hook")
|
.command("install-hook")
|
||||||
.description(
|
.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("--user", "Patch ~/.claude/settings.json (default)")
|
||||||
.option("--project", "Patch <cwd>/.claude/settings.json")
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
||||||
.option("--url <url>", "Daemon base URL to embed in the hook command")
|
.option("--url <url>", "Daemon base URL to embed in each hook command")
|
||||||
.action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => {
|
.action(async (opts: { user?: boolean; project?: boolean; url?: string }) => {
|
||||||
if (opts.user && opts.project) {
|
if (opts.user && opts.project) {
|
||||||
console.error("Pick either --user or --project, not both.");
|
console.error("Pick either --user or --project, not both.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -379,20 +378,34 @@ program
|
|||||||
const scope: HookScope = opts.project ? "project" : "user";
|
const scope: HookScope = opts.project ? "project" : "user";
|
||||||
const path = settingsPathFor(scope);
|
const path = settingsPathFor(scope);
|
||||||
const settings = readSettings(path);
|
const settings = readSettings(path);
|
||||||
const command = buildHookCommand(opts.name, opts.url);
|
const hooks = buildPluginHookCommands(opts.url);
|
||||||
const result = applyInstall(settings, command);
|
|
||||||
if (result.changed) {
|
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);
|
writeSettings(path, settings);
|
||||||
console.log(`Hook installed in ${path}`);
|
console.log(`Hooks installed in ${path}:`);
|
||||||
console.log(`Command: ${command}`);
|
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 {
|
} else {
|
||||||
console.log(`Hook already present in ${path}; nothing to do.`);
|
console.log(`All hooks already present in ${path}; nothing to do.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("uninstall-hook")
|
.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("--user", "Patch ~/.claude/settings.json (default)")
|
||||||
.option("--project", "Patch <cwd>/.claude/settings.json")
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
||||||
.action(async (opts: { user?: boolean; project?: boolean }) => {
|
.action(async (opts: { user?: boolean; project?: boolean }) => {
|
||||||
|
|||||||
@@ -194,12 +194,31 @@ interface ClaudeSettings {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOOK_EVENT = "UserPromptSubmit";
|
export interface ManagedHook {
|
||||||
|
event: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHookCommand(name: string, url?: string): string {
|
export const MANAGED_HOOK_EVENTS = [
|
||||||
const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"];
|
"SessionStart",
|
||||||
if (url) parts.push("--url", quoteIfNeeded(url));
|
"UserPromptSubmit",
|
||||||
return parts.join(" ");
|
"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 {
|
function quoteIfNeeded(value: string): string {
|
||||||
@@ -209,7 +228,10 @@ function quoteIfNeeded(value: string): string {
|
|||||||
|
|
||||||
function isOurHookCommand(command: string): boolean {
|
function isOurHookCommand(command: string): boolean {
|
||||||
const c = command.trim();
|
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 {
|
export function readSettings(path: string): ClaudeSettings {
|
||||||
@@ -229,10 +251,14 @@ export interface PatchResult {
|
|||||||
reason: "added" | "already-present" | "removed" | "not-present";
|
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 ??= {};
|
||||||
settings.hooks[HOOK_EVENT] ??= [];
|
settings.hooks[event] ??= [];
|
||||||
const groups = settings.hooks[HOOK_EVENT];
|
const groups = settings.hooks[event];
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
for (const hook of group.hooks) {
|
for (const hook of group.hooks) {
|
||||||
@@ -252,21 +278,26 @@ export function applyInstall(settings: ClaudeSettings, command: string): PatchRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applyUninstall(settings: ClaudeSettings): PatchResult {
|
export function applyUninstall(settings: ClaudeSettings): PatchResult {
|
||||||
const groups = settings.hooks?.[HOOK_EVENT];
|
if (!settings.hooks) return { changed: false, reason: "not-present" };
|
||||||
if (!groups || groups.length === 0) return { changed: false, reason: "not-present" };
|
|
||||||
|
|
||||||
let removed = false;
|
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) {
|
for (const group of groups) {
|
||||||
const before = group.hooks.length;
|
const before = group.hooks.length;
|
||||||
group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command));
|
group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command));
|
||||||
if (group.hooks.length !== before) removed = true;
|
if (group.hooks.length !== before) removed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0);
|
settings.hooks[event] = groups.filter((g) => g.hooks.length > 0);
|
||||||
if (settings.hooks![HOOK_EVENT].length === 0) {
|
if (settings.hooks[event].length === 0) {
|
||||||
delete settings.hooks![HOOK_EVENT];
|
delete settings.hooks[event];
|
||||||
}
|
}
|
||||||
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
}
|
||||||
|
|
||||||
|
if (Object.keys(settings.hooks).length === 0) {
|
||||||
delete settings.hooks;
|
delete settings.hooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { execFileSync } from "node:child_process";
|
|||||||
import {
|
import {
|
||||||
applyInstall,
|
applyInstall,
|
||||||
applyUninstall,
|
applyUninstall,
|
||||||
buildHookCommand,
|
buildPluginHookCommands,
|
||||||
buildSessionAnnounceLines,
|
buildSessionAnnounceLines,
|
||||||
deriveProjectName,
|
deriveProjectName,
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatActivePeerList,
|
formatActivePeerList,
|
||||||
formatMessagesForHook,
|
formatMessagesForHook,
|
||||||
|
MANAGED_HOOK_EVENTS,
|
||||||
parseHookStdin,
|
parseHookStdin,
|
||||||
readSettings,
|
readSettings,
|
||||||
sanitizeProjectName,
|
sanitizeProjectName,
|
||||||
@@ -53,34 +54,49 @@ describe("formatMessagesForHook", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildHookCommand", () => {
|
describe("buildPluginHookCommands", () => {
|
||||||
it("builds a basic command", () => {
|
it("returns one entry per managed event", () => {
|
||||||
expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook");
|
const hooks = buildPluginHookCommands();
|
||||||
|
expect(hooks.map((h) => h.event)).toEqual([...MANAGED_HOOK_EVENTS]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends --url when provided", () => {
|
it("uses session-announce for SessionStart and session-end for SessionEnd", () => {
|
||||||
expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe(
|
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
|
||||||
"claude-mailbox check --name alice --hook --url http://127.0.0.1:9000",
|
expect(map.get("SessionStart")).toBe("claude-mailbox session-announce");
|
||||||
);
|
expect(map.get("SessionEnd")).toBe("claude-mailbox session-end");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("quotes names with spaces", () => {
|
it("uses check --hook for the three drain events", () => {
|
||||||
const cmd = buildHookCommand("my mailbox");
|
const map = new Map(buildPluginHookCommands().map((h) => [h.event, h.command]));
|
||||||
expect(cmd).toContain('--name "my mailbox"');
|
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", () => {
|
describe("applyInstall", () => {
|
||||||
it("creates hooks structure from empty settings", () => {
|
it("creates hooks structure from empty settings", () => {
|
||||||
const s: Record<string, unknown> = {};
|
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(r).toEqual({ changed: true, reason: "added" });
|
||||||
expect(s).toMatchObject({
|
expect(s).toMatchObject({
|
||||||
hooks: {
|
hooks: {
|
||||||
UserPromptSubmit: [
|
UserPromptSubmit: [
|
||||||
{
|
{
|
||||||
matcher: "",
|
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", () => {
|
it("is idempotent — does not duplicate the same command", () => {
|
||||||
const s: Record<string, unknown> = {};
|
const s: Record<string, unknown> = {};
|
||||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
|
const r = applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
expect(r).toEqual({ changed: false, reason: "already-present" });
|
expect(r).toEqual({ changed: false, reason: "already-present" });
|
||||||
const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit;
|
const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit;
|
||||||
expect(groups[0]!.hooks).toHaveLength(1);
|
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", () => {
|
it("preserves existing unrelated hooks", () => {
|
||||||
const s: Record<string, unknown> = {
|
const s: Record<string, unknown> = {
|
||||||
hooks: {
|
hooks: {
|
||||||
@@ -108,13 +131,11 @@ describe("applyInstall", () => {
|
|||||||
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }],
|
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 }[] }[] };
|
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
|
||||||
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2);
|
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2);
|
||||||
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
|
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
|
||||||
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe(
|
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe("claude-mailbox check --hook");
|
||||||
"claude-mailbox check --name bob --hook",
|
|
||||||
);
|
|
||||||
expect((s.hooks as Record<string, unknown>).PostToolUse).toBeDefined();
|
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;
|
const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit;
|
||||||
expect(groups).toHaveLength(2);
|
expect(groups).toHaveLength(2);
|
||||||
expect(groups[1]!.matcher).toBe("");
|
expect(groups[1]!.matcher).toBe("");
|
||||||
@@ -134,14 +155,40 @@ describe("applyInstall", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("applyUninstall", () => {
|
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> = {};
|
const s: Record<string, unknown> = {};
|
||||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
const r = applyUninstall(s);
|
const r = applyUninstall(s);
|
||||||
expect(r).toEqual({ changed: true, reason: "removed" });
|
expect(r).toEqual({ changed: true, reason: "removed" });
|
||||||
expect(s.hooks).toBeUndefined();
|
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", () => {
|
it("preserves unrelated hooks in the same group", () => {
|
||||||
const s: Record<string, unknown> = {
|
const s: Record<string, unknown> = {
|
||||||
hooks: {
|
hooks: {
|
||||||
@@ -150,7 +197,7 @@ describe("applyUninstall", () => {
|
|||||||
matcher: "",
|
matcher: "",
|
||||||
hooks: [
|
hooks: [
|
||||||
{ type: "command", command: "echo something-else" },
|
{ 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" });
|
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> = {};
|
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);
|
applyUninstall(s);
|
||||||
expect(s.hooks).toBeUndefined();
|
expect(s.hooks).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -419,11 +468,11 @@ describe("readSettings / writeSettings roundtrip", () => {
|
|||||||
const path = join(dir, "settings.json");
|
const path = join(dir, "settings.json");
|
||||||
const s = readSettings(path);
|
const s = readSettings(path);
|
||||||
expect(s).toEqual({});
|
expect(s).toEqual({});
|
||||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
writeSettings(path, s);
|
writeSettings(path, s);
|
||||||
const reloaded = readSettings(path);
|
const reloaded = readSettings(path);
|
||||||
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
|
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
|
||||||
"claude-mailbox check --name bob --hook",
|
"claude-mailbox check --hook",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
@@ -451,7 +500,7 @@ describe("readSettings / writeSettings roundtrip", () => {
|
|||||||
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
|
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
|
||||||
);
|
);
|
||||||
const s = readSettings(path);
|
const s = readSettings(path);
|
||||||
applyInstall(s, "claude-mailbox check --name bob --hook");
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
||||||
writeSettings(path, s);
|
writeSettings(path, s);
|
||||||
const reloaded = readSettings(path) as {
|
const reloaded = readSettings(path) as {
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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
|
## MCP tools
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,16 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"TaskCompleted": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"SessionEnd": [
|
"SessionEnd": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
Reference in New Issue
Block a user