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;