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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user