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.
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
applyInstall,
|
|
applyUninstall,
|
|
buildPluginHookCommands,
|
|
buildSessionAnnounceLines,
|
|
deriveProjectName,
|
|
deriveSessionName,
|
|
formatActivePeerList,
|
|
formatMessagesForHook,
|
|
MANAGED_HOOK_EVENTS,
|
|
parseHookStdin,
|
|
readSettings,
|
|
sanitizeProjectName,
|
|
shortSessionId,
|
|
writeSettings,
|
|
type PeerEntry,
|
|
} from "../src/hook.js";
|
|
|
|
describe("formatMessagesForHook", () => {
|
|
it("returns empty string for empty inbox", () => {
|
|
expect(formatMessagesForHook("bob", [])).toBe("");
|
|
});
|
|
|
|
it("formats a single message", () => {
|
|
const out = formatMessagesForHook("bob", [
|
|
{ id: 1, from: "alice", body: "hi bob", sentAt: "2026-05-19T10:00:00.000Z" },
|
|
]);
|
|
expect(out).toContain("1 new mailbox message");
|
|
expect(out).toContain("[#1] from alice (2026-05-19T10:00:00.000Z):");
|
|
expect(out).toContain(" hi bob");
|
|
});
|
|
|
|
it("formats multiple messages with plural header", () => {
|
|
const out = formatMessagesForHook("bob", [
|
|
{ id: 1, from: "alice", body: "one", sentAt: "2026-05-19T10:00:00.000Z" },
|
|
{ id: 2, from: "carol", body: "two", sentAt: "2026-05-19T10:01:00.000Z" },
|
|
]);
|
|
expect(out).toContain("2 new mailbox messages");
|
|
expect(out).toContain("[#1] from alice");
|
|
expect(out).toContain("[#2] from carol");
|
|
});
|
|
|
|
it("preserves multi-line bodies with indentation", () => {
|
|
const out = formatMessagesForHook("bob", [
|
|
{ id: 1, from: "alice", body: "line1\nline2", sentAt: "2026-05-19T10:00:00.000Z" },
|
|
]);
|
|
expect(out).toContain(" line1");
|
|
expect(out).toContain(" line2");
|
|
});
|
|
});
|
|
|
|
describe("buildPluginHookCommands", () => {
|
|
it("returns one entry per managed event", () => {
|
|
const hooks = buildPluginHookCommands();
|
|
expect(hooks.map((h) => h.event)).toEqual([...MANAGED_HOOK_EVENTS]);
|
|
});
|
|
|
|
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("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, "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 --hook" }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("is idempotent — does not duplicate the same command", () => {
|
|
const s: Record<string, unknown> = {};
|
|
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: {
|
|
UserPromptSubmit: [
|
|
{
|
|
matcher: "",
|
|
hooks: [{ type: "command", command: "echo something-else" }],
|
|
},
|
|
],
|
|
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }],
|
|
},
|
|
};
|
|
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 --hook");
|
|
expect((s.hooks as Record<string, unknown>).PostToolUse).toBeDefined();
|
|
});
|
|
|
|
it("adds a new empty-matcher group when only non-empty matchers exist", () => {
|
|
const s: Record<string, unknown> = {
|
|
hooks: {
|
|
UserPromptSubmit: [
|
|
{ matcher: "Bash", hooks: [{ type: "command", command: "echo bash-only" }] },
|
|
],
|
|
},
|
|
};
|
|
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("");
|
|
});
|
|
});
|
|
|
|
describe("applyUninstall", () => {
|
|
it("removes a single-event install and cleans up empty structures", () => {
|
|
const s: Record<string, unknown> = {};
|
|
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: {
|
|
UserPromptSubmit: [
|
|
{
|
|
matcher: "",
|
|
hooks: [
|
|
{ type: "command", command: "echo something-else" },
|
|
{ type: "command", command: "claude-mailbox check --hook" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
applyUninstall(s);
|
|
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
|
|
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(1);
|
|
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
|
|
});
|
|
|
|
it("returns not-present when there is nothing to remove", () => {
|
|
const s: Record<string, unknown> = {
|
|
hooks: { UserPromptSubmit: [{ matcher: "", hooks: [{ type: "command", command: "x" }] }] },
|
|
};
|
|
const r = applyUninstall(s);
|
|
expect(r).toEqual({ changed: false, reason: "not-present" });
|
|
});
|
|
|
|
it("removes hooks installed with --url arg across every event", () => {
|
|
const s: Record<string, unknown> = {};
|
|
for (const h of buildPluginHookCommands("http://x")) {
|
|
applyInstall(s, h.event, h.command);
|
|
}
|
|
applyUninstall(s);
|
|
expect(s.hooks).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("parseHookStdin", () => {
|
|
it("returns null for empty or whitespace input", () => {
|
|
expect(parseHookStdin(null)).toBeNull();
|
|
expect(parseHookStdin("")).toBeNull();
|
|
expect(parseHookStdin(" \n ")).toBeNull();
|
|
});
|
|
|
|
it("returns null for non-JSON input", () => {
|
|
expect(parseHookStdin("not json")).toBeNull();
|
|
expect(parseHookStdin("{")).toBeNull();
|
|
});
|
|
|
|
it("returns null for JSON primitives (only objects allowed)", () => {
|
|
expect(parseHookStdin("42")).toBeNull();
|
|
expect(parseHookStdin("\"foo\"")).toBeNull();
|
|
expect(parseHookStdin("null")).toBeNull();
|
|
});
|
|
|
|
it("parses a hook payload", () => {
|
|
const out = parseHookStdin(
|
|
JSON.stringify({
|
|
session_id: "abc12345-de67-89f0-1234-567890abcdef",
|
|
hook_event_name: "UserPromptSubmit",
|
|
prompt: "hi",
|
|
}),
|
|
);
|
|
expect(out?.session_id).toBe("abc12345-de67-89f0-1234-567890abcdef");
|
|
expect(out?.hook_event_name).toBe("UserPromptSubmit");
|
|
});
|
|
});
|
|
|
|
describe("shortSessionId", () => {
|
|
it("takes first 8 hex chars from a UUID", () => {
|
|
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
|
|
});
|
|
|
|
it("normalizes case and ignores hyphens", () => {
|
|
expect(shortSessionId("ABC12345-DE67-89F0-1234-567890ABCDEF")).toBe("abc12345");
|
|
});
|
|
|
|
it("falls back to a sanitized prefix for non-hex ids", () => {
|
|
expect(shortSessionId("session-Test123")).toBe("sessiont");
|
|
});
|
|
});
|
|
|
|
describe("sanitizeProjectName", () => {
|
|
it("lowercases and replaces non-alnum with dashes", () => {
|
|
expect(sanitizeProjectName("My Project!")).toBe("my-project");
|
|
});
|
|
|
|
it("collapses runs of separators", () => {
|
|
expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar");
|
|
});
|
|
|
|
it("trims leading/trailing dashes", () => {
|
|
expect(sanitizeProjectName("--foo--")).toBe("foo");
|
|
});
|
|
|
|
it("returns empty for purely non-alnum input", () => {
|
|
expect(sanitizeProjectName("---")).toBe("");
|
|
expect(sanitizeProjectName("")).toBe("");
|
|
expect(sanitizeProjectName(null)).toBe("");
|
|
expect(sanitizeProjectName(undefined)).toBe("");
|
|
});
|
|
|
|
it("caps long names", () => {
|
|
const out = sanitizeProjectName("a".repeat(120));
|
|
expect(out.length).toBeLessThanOrEqual(40);
|
|
});
|
|
});
|
|
|
|
describe("deriveProjectName", () => {
|
|
it("uses cwd basename when not in a git repo", () => {
|
|
// tmpdir is virtually never inside a git repo; basename is platform-dependent.
|
|
const got = deriveProjectName(tmpdir());
|
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
|
});
|
|
|
|
it("falls back to 'claude' when cwd is empty", () => {
|
|
expect(deriveProjectName("")).toBe("claude");
|
|
expect(deriveProjectName(null)).toBe("claude");
|
|
expect(deriveProjectName(undefined)).toBe("claude");
|
|
});
|
|
|
|
it("uses git toplevel basename when called from inside a repo", () => {
|
|
// The test harness itself runs inside the claude-mailbox checkout.
|
|
let inRepo = false;
|
|
try {
|
|
execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
|
|
inRepo = true;
|
|
} catch {
|
|
inRepo = false;
|
|
}
|
|
if (!inRepo) return; // CI without git in PATH — skip.
|
|
const got = deriveProjectName(process.cwd());
|
|
// Anywhere in the repo, we should resolve to the repo's basename — sanitized.
|
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
|
expect(got.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("deriveSessionName", () => {
|
|
it("composes <project>-<short>", () => {
|
|
const got = deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "");
|
|
expect(got).toBe("claude-abc12345");
|
|
});
|
|
|
|
it("derives different names for different sessions in the same project", () => {
|
|
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "");
|
|
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "");
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
|
|
describe("formatActivePeerList", () => {
|
|
const NOW = new Date("2026-05-19T12:00:00.000Z").getTime();
|
|
|
|
const peer = (name: string, isoOffsetMinutes: number): PeerEntry => ({
|
|
name,
|
|
lastSeenAt: new Date(NOW - isoOffsetMinutes * 60_000).toISOString(),
|
|
});
|
|
|
|
it("excludes self from the list", () => {
|
|
const out = formatActivePeerList(
|
|
[peer("self", 1), peer("alice", 1)],
|
|
"self",
|
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
|
);
|
|
const joined = out.join("\n");
|
|
expect(joined).not.toContain("self");
|
|
expect(joined).toContain("alice");
|
|
});
|
|
|
|
it("filters out peers older than the window", () => {
|
|
const out = formatActivePeerList(
|
|
[peer("recent", 5), peer("stale", 120)],
|
|
"self",
|
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
|
);
|
|
const joined = out.join("\n");
|
|
expect(joined).toContain("recent");
|
|
expect(joined).not.toContain("stale");
|
|
expect(out[0]).toContain("1 of 2 total");
|
|
});
|
|
|
|
it("returns a no-peers message when nothing is active", () => {
|
|
const out = formatActivePeerList(
|
|
[peer("ancient", 9999)],
|
|
"self",
|
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
|
);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatch(/No other mailboxes seen within the last 60 minutes/);
|
|
expect(out[0]).toContain("1 total registered");
|
|
});
|
|
|
|
it("caps at maxPeers and sorts most-recent first", () => {
|
|
const out = formatActivePeerList(
|
|
[peer("p1", 30), peer("p2", 20), peer("p3", 10)],
|
|
"self",
|
|
{ windowMinutes: 60, maxPeers: 2, now: NOW },
|
|
);
|
|
const joined = out.join("\n");
|
|
expect(joined).toContain("p3");
|
|
expect(joined).toContain("p2");
|
|
expect(joined).not.toContain("p1");
|
|
expect(out[0]).toContain("2 of 3 total");
|
|
expect(joined.indexOf("p3")).toBeLessThan(joined.indexOf("p2"));
|
|
});
|
|
|
|
it("ignores peers with invalid lastSeenAt", () => {
|
|
const out = formatActivePeerList(
|
|
[{ name: "garbage", lastSeenAt: "not-a-date" }, peer("ok", 5)],
|
|
"self",
|
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
|
);
|
|
const joined = out.join("\n");
|
|
expect(joined).toContain("ok");
|
|
expect(joined).not.toContain("garbage");
|
|
});
|
|
});
|
|
|
|
describe("buildSessionAnnounceLines", () => {
|
|
it("includes the identity announcement and tool-call examples", () => {
|
|
const out = buildSessionAnnounceLines({
|
|
name: "alice-abc12345",
|
|
peers: [],
|
|
windowMinutes: 60,
|
|
maxPeers: 10,
|
|
}).join("\n");
|
|
expect(out).toContain("alice-abc12345");
|
|
expect(out).toContain("mcp__mailbox__send");
|
|
});
|
|
|
|
it("never auto-bootstraps the watcher — push delivery must be opt-in", () => {
|
|
const out = buildSessionAnnounceLines({
|
|
name: "alice-abc12345",
|
|
peers: [],
|
|
windowMinutes: 60,
|
|
maxPeers: 10,
|
|
}).join("\n");
|
|
expect(out).not.toContain("watch --block");
|
|
expect(out).not.toContain("run_in_background");
|
|
expect(out).not.toMatch(/REQUIRED FIRST ACTION/);
|
|
expect(out).not.toMatch(/MUST launch/);
|
|
});
|
|
|
|
it("points the user to the opt-in collaborate skill / slash command", () => {
|
|
const out = buildSessionAnnounceLines({
|
|
name: "alice-abc12345",
|
|
peers: [],
|
|
windowMinutes: 60,
|
|
maxPeers: 10,
|
|
}).join("\n");
|
|
expect(out).toMatch(/mailbox-collaborate/);
|
|
expect(out).toMatch(/\/collaborate/);
|
|
expect(out).toMatch(/OPT-IN/);
|
|
});
|
|
|
|
it("replaces the peer list with the daemonError hint when daemon is unreachable", () => {
|
|
const out = buildSessionAnnounceLines({
|
|
name: "alice-abc12345",
|
|
peers: [],
|
|
windowMinutes: 60,
|
|
maxPeers: 10,
|
|
daemonError: "[Claude-Mailbox] Daemon not reachable at http://127.0.0.1:1.",
|
|
}).join("\n");
|
|
expect(out).toContain("Daemon not reachable");
|
|
// The misleading "no peers" line must NOT appear when the daemon is down.
|
|
expect(out).not.toMatch(/No other mailboxes seen/);
|
|
expect(out).not.toMatch(/Active peers/);
|
|
});
|
|
});
|
|
|
|
describe("readSettings / writeSettings roundtrip", () => {
|
|
it("survives an install → write → read cycle", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
|
try {
|
|
const path = join(dir, "settings.json");
|
|
const s = readSettings(path);
|
|
expect(s).toEqual({});
|
|
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 --hook",
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("creates parent .claude directory when missing", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
|
try {
|
|
const path = join(dir, "nested", ".claude", "settings.json");
|
|
writeSettings(path, { hooks: {} });
|
|
expect(readFileSync(path, "utf8")).toContain('"hooks"');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves non-hook settings keys", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
|
try {
|
|
const path = join(dir, "settings.json");
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(
|
|
path,
|
|
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
|
|
);
|
|
const s = readSettings(path);
|
|
applyInstall(s, "UserPromptSubmit", "claude-mailbox check --hook");
|
|
writeSettings(path, s);
|
|
const reloaded = readSettings(path) as {
|
|
model?: string;
|
|
permissions?: { allow?: string[] };
|
|
hooks?: unknown;
|
|
};
|
|
expect(reloaded.model).toBe("sonnet");
|
|
expect(reloaded.permissions?.allow).toEqual(["Bash"]);
|
|
expect(reloaded.hooks).toBeDefined();
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|