From 48b6ba645213a38ebe440382e120a5d7ca642760 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 19 May 2026 11:59:31 +0200 Subject: [PATCH] feat(plugin): SessionStart hook discovers and announces active peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- node/src/cli.ts | 44 +++++++++++++++++++++-- node/src/hook.ts | 35 +++++++++++++++++++ node/tests/cli-hook.test.ts | 21 ++++++++--- node/tests/hook.test.ts | 70 +++++++++++++++++++++++++++++++++++++ plugin/README.md | 6 ++-- 5 files changed, 166 insertions(+), 10 deletions(-) diff --git a/node/src/cli.ts b/node/src/cli.ts index 52f9b18..e7341b1 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -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 ", "Daemon base URL", DEFAULT_URL) + .option( + "--peer-window-minutes ", + "Only show peers seen within this many minutes (default 60)", + (v) => parseInt(v, 10), + 60, + ) + .option( + "--max-peers ", + "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="", 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")); }); diff --git a/node/src/hook.ts b/node/src/hook.ts index 520cb1d..25ed373 100644 --- a/node/src/hook.ts +++ b/node/src/hook.ts @@ -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; diff --git a/node/tests/cli-hook.test.ts b/node/tests/cli-hook.test.ts index fda3eb1..16ac64f 100644 --- a/node/tests/cli-hook.test.ts +++ b/node/tests/cli-hook.test.ts @@ -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(""); }); diff --git a/node/tests/hook.test.ts b/node/tests/hook.test.ts index 1ed6b97..87c6046 100644 --- a/node/tests/hook.test.ts +++ b/node/tests/hook.test.ts @@ -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-")); diff --git a/plugin/README.md b/plugin/README.md index 5ed5ed3..86b20fd 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -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 ``."* 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="", to="", 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: