feat(plugin): SessionStart hook discovers and announces active peers
session-announce now calls /v1/list with the session's X-Mailbox header, which both registers the session with the daemon and returns all known mailboxes in one round-trip. The output appends an "Active peers" block listing mailboxes seen within the last hour (configurable via --peer-window-minutes), capped at 10 entries by default. Self is filtered out; the list is sorted most-recent-first. So when the user says "I started a second session, coordinate with it", Claude already has the peer's mailbox name in context — no manual list_mailboxes call needed. The peer-formatting logic is extracted into formatActivePeerList for unit testing; CLI tests now pin --url to an unreachable port to keep assertions stable on machines that have a real daemon running.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
deriveSessionName,
|
||||
formatActivePeerList,
|
||||
formatMessagesForHook,
|
||||
parseHookStdin,
|
||||
readSettings,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
writeSettings,
|
||||
type HookMessage,
|
||||
type HookScope,
|
||||
type PeerEntry,
|
||||
} from "./hook.js";
|
||||
|
||||
function readVersion(): string {
|
||||
@@ -193,14 +195,28 @@ program
|
||||
program
|
||||
.command("session-announce")
|
||||
.description(
|
||||
"SessionStart-hook helper: reads the hook stdin JSON, derives the session's mailbox name from session_id, and prints a one-line announcement to stdout so Claude sees its mailbox identity.",
|
||||
"SessionStart-hook helper: derives the session's mailbox name from stdin session_id, registers it with the daemon, and announces the identity + currently active peers to context.",
|
||||
)
|
||||
.action(() => {
|
||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||
.option(
|
||||
"--peer-window-minutes <minutes>",
|
||||
"Only show peers seen within this many minutes (default 60)",
|
||||
(v) => parseInt(v, 10),
|
||||
60,
|
||||
)
|
||||
.option(
|
||||
"--max-peers <n>",
|
||||
"Maximum number of peers to list (default 10)",
|
||||
(v) => parseInt(v, 10),
|
||||
10,
|
||||
)
|
||||
.action(async (opts: { url: string; peerWindowMinutes: number; maxPeers: number }) => {
|
||||
const stdin = parseHookStdin(readStdinIfPiped());
|
||||
const sid = stdin?.session_id?.trim();
|
||||
if (!sid) return;
|
||||
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
|
||||
const name = deriveSessionName(sid, base);
|
||||
|
||||
const lines = [
|
||||
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||
`When using mcp__mailbox__* tools, ALWAYS pass this name explicitly:`,
|
||||
@@ -209,8 +225,30 @@ program
|
||||
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
||||
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
||||
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
||||
``,
|
||||
];
|
||||
|
||||
try {
|
||||
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
||||
headers: { "X-Mailbox": name },
|
||||
});
|
||||
const all = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||
lines.push(
|
||||
"",
|
||||
...formatActivePeerList(all, name, {
|
||||
windowMinutes: opts.peerWindowMinutes,
|
||||
maxPeers: opts.maxPeers,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||
lines.push(
|
||||
"",
|
||||
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
process.stdout.write(lines.join("\n"));
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,41 @@ export function deriveSessionName(sessionId: string, base?: string | null): stri
|
||||
return `claude-${short}`;
|
||||
}
|
||||
|
||||
export interface PeerEntry {
|
||||
name: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export function formatActivePeerList(
|
||||
peers: PeerEntry[],
|
||||
selfName: string,
|
||||
options: { windowMinutes: number; maxPeers: number; now?: number },
|
||||
): string[] {
|
||||
const others = peers.filter((p) => p.name !== selfName);
|
||||
const cutoff = (options.now ?? Date.now()) - options.windowMinutes * 60_000;
|
||||
const active = others
|
||||
.filter((p) => {
|
||||
const t = new Date(p.lastSeenAt).getTime();
|
||||
return Number.isFinite(t) && t >= cutoff;
|
||||
})
|
||||
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
|
||||
.slice(0, options.maxPeers);
|
||||
|
||||
if (active.length === 0) {
|
||||
return [
|
||||
`No other mailboxes seen within the last ${options.windowMinutes} minutes (${others.length} total registered).`,
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Active peers (seen within last ${options.windowMinutes} min, ${active.length} of ${others.length} total):`,
|
||||
];
|
||||
for (const p of active) {
|
||||
lines.push(` - ${p.name} (last seen ${p.lastSeenAt})`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export interface HookMessage {
|
||||
id: number;
|
||||
from: string;
|
||||
|
||||
@@ -79,6 +79,8 @@ describe("`check --hook` CLI behavior", () => {
|
||||
});
|
||||
|
||||
describe("`session-announce` CLI behavior", () => {
|
||||
const UNREACHABLE = "http://127.0.0.1:1";
|
||||
|
||||
beforeAll(() => {
|
||||
if (!existsSync(cliPath)) {
|
||||
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
|
||||
@@ -86,7 +88,7 @@ describe("`session-announce` CLI behavior", () => {
|
||||
});
|
||||
|
||||
it("prints the derived mailbox name from a SessionStart payload", () => {
|
||||
const r = runCli(["session-announce"], {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
@@ -97,7 +99,7 @@ describe("`session-announce` CLI behavior", () => {
|
||||
});
|
||||
|
||||
it("uses base prefix when set", () => {
|
||||
const r = runCli(["session-announce"], {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: "backend" },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
@@ -105,8 +107,17 @@ describe("`session-announce` CLI behavior", () => {
|
||||
expect(r.stdout).toContain("`backend-abc12345`");
|
||||
});
|
||||
|
||||
it("emits daemon-not-reachable hint when daemon is down", () => {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
stdin: HOOK_STDIN,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||
});
|
||||
|
||||
it("stays silent when no session_id in stdin", () => {
|
||||
const r = runCli(["session-announce"], {
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
|
||||
});
|
||||
@@ -115,7 +126,9 @@ describe("`session-announce` CLI behavior", () => {
|
||||
});
|
||||
|
||||
it("stays silent when no stdin at all", () => {
|
||||
const r = runCli(["session-announce"], { env: { CLAUDE_MAILBOX_NAME: undefined } });
|
||||
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||
env: { CLAUDE_MAILBOX_NAME: undefined },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe("");
|
||||
});
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
applyUninstall,
|
||||
buildHookCommand,
|
||||
deriveSessionName,
|
||||
formatActivePeerList,
|
||||
formatMessagesForHook,
|
||||
parseHookStdin,
|
||||
readSettings,
|
||||
shortSessionId,
|
||||
writeSettings,
|
||||
type PeerEntry,
|
||||
} from "../src/hook.js";
|
||||
|
||||
describe("formatMessagesForHook", () => {
|
||||
@@ -239,6 +241,74 @@ describe("shortSessionId / deriveSessionName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("readSettings / writeSettings roundtrip", () => {
|
||||
it("survives an install → write → read cycle", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||
|
||||
Reference in New Issue
Block a user