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:
Mika Kuns
2026-05-19 11:59:31 +02:00
parent 9fd321043f
commit 48b6ba6452
5 changed files with 166 additions and 10 deletions

View File

@@ -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"));
});

View File

@@ -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;

View File

@@ -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("");
});

View File

@@ -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-"));

View File

@@ -37,7 +37,7 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
| Hook | Command | Effect |
|---|---|---|
| `SessionStart` | `claude-mailbox session-announce` | Tells Claude its mailbox name for this session and instructs it on the `from` / `name` args to pass to MCP tools. |
| `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. |
Cost: one local HTTP round-trip per prompt + Node coldstart (~100ms on Windows).
@@ -66,8 +66,8 @@ The SessionStart announcement spells out the exact args to pass, so Claude picks
## Coordinating two Claude Code sessions
1. Open two Claude Code sessions in the same (or different) project.
2. Each session's SessionStart hook announces its mailbox name in context.
3. In session A you can say: *"I have a second session running. Use `mcp__mailbox__list_mailboxes` to find it and send `<msg>`."* Claude will discover the peer's mailbox and send via `mcp__mailbox__send`.
2. Each session's SessionStart hook registers itself with the daemon and prints both its own mailbox name and the **list of currently active peers** into context.
3. In session A you can simply say: *"I started a second session, coordinate with it."* Because the peer's mailbox name is already in context, Claude can call `mcp__mailbox__send(from="<my-name>", to="<peer-name>", body="...")` straight away — no manual `list_mailboxes` step needed.
4. Session B's `UserPromptSubmit` hook pulls the message on its next prompt and injects it as context.
You can also send from any shell: