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