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:
Mika Kuns
2026-05-27 11:23:19 +02:00
parent 840a3e32c8
commit 3ebf54e75d
7 changed files with 187 additions and 72 deletions

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;