/** * coms — Peer-to-peer messaging between Pi agents on the same machine * * Each agent listens on a single endpoint (unix socket on POSIX, named pipe on * Windows) and discovers peers through per-project registry files under * ~/.pi/coms/projects//agents/.json. * * Phase A (foundation): identity resolution, registry I/O, transport bind/send, * connection handlers. Phase B: tools (coms_list/send/get/await), agent_end * response capture. Phase C: live pool widget, ping + keepalive cycles, /coms * slash command, clean shutdown lifecycle. * * Usage: pi -e extensions/coms.ts */ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { Text, Container, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; import type { AutocompleteItem } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import * as net from "node:net"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import * as crypto from "node:crypto"; import { formatPeerStatus, HerdrAgentWatch, HerdrPresence, herdrPaneId, herdrPresenceAvailable, parsePeerName, type HerdrAgentInfo, } from "../lib/herdr-presence.ts"; // ━━ Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const COMS_DIR = process.env.PI_COMS_DIR || path.join(os.homedir(), ".pi", "coms"); const MAX_HOPS = Number(process.env.PI_COMS_MAX_HOPS) || 5; const TIMEOUT_MS = Number(process.env.PI_COMS_TIMEOUT_MS) || 1_800_000; const PING_INTERVAL_MS = Number(process.env.PI_COMS_PING_INTERVAL_MS) || 10_000; const KEEPALIVE_INTERVAL_MS = 30_000; const LINE_CAP_BYTES = 64 * 1024; const FALLBACK_PALETTE = [ "#72F1B8", "#36F9F6", "#FF7EDB", "#FEDE5D", "#C792EA", "#FF8B39", "#4D9DE0", "#FFAA8B", ]; // ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ type EnvelopeType = "prompt" | "response" | "ping"; interface Envelope { type: EnvelopeType; msg_id: string; sender_session: string; sender_endpoint: string; hops: number; timestamp: string; } interface PromptEnvelope extends Envelope { type: "prompt"; prompt: string; sender_name: string; sender_cwd: string; conversation_id?: string | null; response_schema?: object | null; } interface ResponseEnvelope extends Envelope { type: "response"; response: any; error?: string | null; } interface PingEnvelope extends Envelope { type: "ping"; } interface AgentCard { name: string; purpose: string; model: string; color: string; context_used_pct: number; queue_depth: number; } interface Pong { type: "pong"; msg_id: string; agent_card: AgentCard; } interface RegistryEntry { session_id: string; name: string; purpose: string; model: string; color: string; pid: number; endpoint: string; cwd: string; started_at: string; explicit: boolean; version: number; // Live status snapshot — refreshed every KEEPALIVE_INTERVAL_MS by the heartbeat. // Optional so older entries (pre-heartbeat-refresh) still parse cleanly. context_used_pct?: number; queue_depth?: number; heartbeat_at?: string; } interface PendingReply { resolve: (value: any) => void; reject: (err: Error) => void; timer: NodeJS.Timeout | null; promise: Promise<{ response?: any; error?: string | null }>; result?: { response?: any; error?: string | null }; target_name?: string; created_at: string; } interface InboundContext { msg_id: string; hops: number; sender_endpoint: string; sender_session: string; response_schema?: object | null; fulfilled: boolean; } // ━━ Helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; function ulid(): string { const time = Date.now(); const rand = crypto.randomBytes(10); let timeStr = ""; let t = time; for (let i = 9; i >= 0; i--) { timeStr = CROCKFORD[t % 32] + timeStr; t = Math.floor(t / 32); } let randStr = ""; let bits = 0; let value = 0; for (const byte of rand) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; randStr += CROCKFORD[(value >> bits) & 31]; } } return (timeStr + randStr).slice(0, 26); } function hexFg(hex: string, s: string): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`; } function isValidHex(hex: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(hex); } function fallbackColor(sessionId: string): string { const h = crypto.createHash("sha256").update(sessionId).digest("hex").slice(0, 8); return FALLBACK_PALETTE[Number(BigInt("0x" + h)) % FALLBACK_PALETTE.length]; } function parseFrontmatter(raw: string): { name?: string; description?: string; color?: string; body: string } { const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { body: raw }; const frontmatter: Record = {}; for (const line of match[1].split("\n")) { const idx = line.indexOf(":"); if (idx > 0) { const key = line.slice(0, idx).trim(); let val = line.slice(idx + 1).trim(); // strip surrounding quotes for values like color: "#36F9F6" if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } frontmatter[key] = val; } } return { name: frontmatter.name, description: frontmatter.description, color: frontmatter.color, body: match[2], }; } function makeEndpoint(sessionId: string): string { if (process.platform === "win32") { return `\\\\.\\pipe\\pi-coms-${sessionId}`; } return path.join(COMS_DIR, "sockets", `${sessionId}.sock`); } function nowIso(): string { return new Date().toISOString(); } function abbreviateModel(model: string): string { let m = model || ""; if (m.startsWith("claude-")) m = m.slice("claude-".length); if (m.length > 14) m = m.slice(0, 14); return m; } // ━━ CLI flag shape (read via pi.registerFlag/pi.getFlag) ━━━━━━━━━━━━━━━━━━━ interface CliFlags { name?: string; purpose?: string; project?: string; color?: string; explicit?: boolean; } function readCliFlags(pi: ExtensionAPI): CliFlags { // Identity flags are declared via pi.registerFlag at extension load time so // pi's CLI parser accepts them; here we just read them back. const name = pi.getFlag("name") as string | undefined; const purpose = pi.getFlag("purpose") as string | undefined; const project = pi.getFlag("project") as string | undefined; const color = pi.getFlag("color") as string | undefined; const explicit = pi.getFlag("explicit") as boolean | undefined; return { name: name && name.length > 0 ? name : undefined, purpose: purpose && purpose.length > 0 ? purpose : undefined, project: project && project.length > 0 ? project : undefined, color: color && color.length > 0 ? color : undefined, explicit: explicit === true, }; } // ━━ Registry I/O ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ function projectAgentsDir(project: string): string { return path.join(COMS_DIR, "projects", project, "agents"); } function registryFilePath(project: string, name: string): string { return path.join(projectAgentsDir(project), `${name}.json`); } function writeRegistryAtomic(entry: RegistryEntry, project: string): string { const dir = projectAgentsDir(project); fs.mkdirSync(dir, { recursive: true }); const final = registryFilePath(project, entry.name); const tmp = `${final}.tmp`; fs.writeFileSync(tmp, JSON.stringify(entry, null, 2)); fs.renameSync(tmp, final); return final; } function readAllRegistryEntries(project: string): RegistryEntry[] { const dir = projectAgentsDir(project); if (!fs.existsSync(dir)) return []; const out: RegistryEntry[] = []; let files: string[]; try { files = fs.readdirSync(dir); } catch { return []; } for (const f of files) { if (!f.endsWith(".json")) continue; try { const raw = fs.readFileSync(path.join(dir, f), "utf-8"); const parsed = JSON.parse(raw) as RegistryEntry; if (parsed && typeof parsed.session_id === "string") { out.push(parsed); } } catch { // skip malformed } } return out; } function readAllRegistryEntriesAcrossProjects(): RegistryEntry[] { const root = path.join(COMS_DIR, "projects"); let projects: string[]; try { projects = fs.readdirSync(root); } catch { return []; } const out: RegistryEntry[] = []; for (const p of projects) { try { if (!fs.statSync(path.join(root, p)).isDirectory()) continue; } catch { continue; } out.push(...readAllRegistryEntries(p)); } return out; } function removeRegistryEntry(project: string, name: string): void { try { fs.unlinkSync(registryFilePath(project, name)); } catch { // best-effort } } function pruneDeadEntries(project: string): RegistryEntry[] { const entries = readAllRegistryEntries(project); const live: RegistryEntry[] = []; for (const entry of entries) { try { process.kill(entry.pid, 0); live.push(entry); } catch (e: any) { if (e && e.code === "ESRCH") { removeRegistryEntry(project, entry.name); } else { // EPERM means the process exists but we can't signal it — treat as live. live.push(entry); } } } return live; } function resolveUniqueName(project: string, desiredName: string): string { // Returns a name that doesn't collide with any LIVE registered agent. // pruneDeadEntries auto-removes ESRCH entries; we only care about live ones. const liveEntries = pruneDeadEntries(project); const liveNames = new Set(liveEntries.map(e => e.name)); if (!liveNames.has(desiredName)) return desiredName; let n = 2; while (liveNames.has(`${desiredName}${n}`)) n++; return `${desiredName}${n}`; } function pruneDeadEntriesAllProjects(): RegistryEntry[] { const root = path.join(COMS_DIR, "projects"); let projects: string[]; try { projects = fs.readdirSync(root); } catch { return []; } const out: RegistryEntry[] = []; for (const p of projects) { try { if (!fs.statSync(path.join(root, p)).isDirectory()) continue; } catch { continue; } out.push(...pruneDeadEntries(p)); } return out; } function keepaliveTouch(file: string): void { try { const now = new Date(); fs.utimesSync(file, now, now); } catch { // best-effort } } // ━━ Transport ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ function probeStaleSocket(endpoint: string): Promise<"in_use" | "stale"> { return new Promise((resolve) => { const sock = net.createConnection({ path: endpoint }); let settled = false; const finish = (verdict: "in_use" | "stale") => { if (settled) return; settled = true; try { sock.destroy(); } catch { /* ignore */ } resolve(verdict); }; const timer = setTimeout(() => finish("stale"), 250); sock.once("connect", () => { clearTimeout(timer); finish("in_use"); }); sock.once("error", (err: any) => { clearTimeout(timer); if (err && err.code === "ECONNREFUSED") { finish("stale"); } else { // ENOENT or other — treat as stale (file may be gone or unusable) finish("stale"); } }); }); } async function bindEndpoint( endpoint: string, connHandler: (socket: net.Socket) => void, ): Promise { if (process.platform !== "win32" && fs.existsSync(endpoint)) { const verdict = await probeStaleSocket(endpoint); if (verdict === "in_use") { throw new Error(`coms: endpoint already in use (${endpoint})`); } try { fs.unlinkSync(endpoint); } catch { // best-effort } } return await new Promise((resolve, reject) => { const server = net.createServer(connHandler); server.once("error", reject); server.listen(endpoint, () => { server.removeListener("error", reject); resolve(server); }); }); } function readOneLine(socket: net.Socket): Promise { return new Promise((resolve, reject) => { let buf = ""; let settled = false; const onData = (chunk: Buffer) => { buf += chunk.toString("utf-8"); if (buf.length > LINE_CAP_BYTES) { if (settled) return; settled = true; socket.removeListener("data", onData); reject(new Error("line too large")); return; } const nl = buf.indexOf("\n"); if (nl >= 0) { if (settled) return; settled = true; socket.removeListener("data", onData); resolve(buf.slice(0, nl)); } }; socket.on("data", onData); socket.once("error", (err) => { if (settled) return; settled = true; reject(err); }); socket.once("close", () => { if (settled) return; settled = true; reject(new Error("connection closed before line received")); }); }); } function sendEnvelope(endpoint: string, envelope: Envelope | Pong | { type: string; msg_id?: string; [k: string]: any }): Promise { return new Promise((resolve, reject) => { const sock = net.createConnection({ path: endpoint }); let settled = false; const fail = (err: Error) => { if (settled) return; settled = true; try { sock.destroy(); } catch { /* ignore */ } reject(err); }; sock.once("error", fail); sock.once("connect", async () => { try { sock.write(JSON.stringify(envelope) + "\n"); const line = await readOneLine(sock); const parsed = JSON.parse(line); try { sock.end(); } catch { /* ignore */ } if (settled) return; settled = true; if (parsed && parsed.type === "nack") { reject(new Error(parsed.error || "nack")); } else { resolve(parsed); } } catch (err) { fail(err instanceof Error ? err : new Error(String(err))); } }); }); } // ━━ System-prompt frontmatter scan ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ function findSystemPromptPath(argv: string[]): string | null { // Prefer --system-prompt (overwrite). Fall back to --append-system-prompt. // These flags are pi-builtin (not extension-registered) so we still scan // argv directly. First match wins per preference order. const scan = (flag: string): string | null => { for (let i = 0; i < argv.length; i++) { if (argv[i] === flag && i + 1 < argv.length) { const candidate = argv[i + 1]; if (candidate.endsWith(".md")) { try { if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { return candidate; } } catch { // fall through } } } } return null; }; return scan("--system-prompt") ?? scan("--append-system-prompt"); } function readFrontmatterFromArgv(argv: string[]): { name?: string; description?: string; color?: string } { const p = findSystemPromptPath(argv); if (!p) return {}; try { const raw = fs.readFileSync(p, "utf-8"); const { name, description, color } = parseFrontmatter(raw); return { name, description, color }; } catch { return {}; } } // ━━ Default export ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ export default function (pi: ExtensionAPI) { // ━━ Register identity CLI flags so pi's parser accepts them. ━━━━━━━━━ // Without these, pi 0.73+ rejects the invocation with "Unknown options: // --name, --project, ..." before this extension's hooks ever fire. pi.registerFlag("name", { description: "Override agent name (otherwise from frontmatter or auto-generated)", type: "string", default: undefined, }); pi.registerFlag("purpose", { description: "Override agent purpose (otherwise from frontmatter description)", type: "string", default: undefined, }); pi.registerFlag("project", { description: "Project namespace for peer discovery", type: "string", default: "default", }); pi.registerFlag("color", { description: "Hex color #RRGGBB (otherwise from frontmatter or palette fallback)", type: "string", default: undefined, }); pi.registerFlag("explicit", { description: "Hide this agent from auto-discovery; only addressable by exact name", type: "boolean", default: false, }); // State containers — shared across all hooks for this extension instance. let identity: { session_id: string; name: string; purpose: string; color: string; project: string; explicit: boolean; cwd: string; model: string; endpoint: string; registryFile: string; } | null = null; const peerCards: Map = new Map(); const pendingReplies: Map = new Map(); const inboundQueue: Map = new Map(); let server: net.Server | null = null; let pingTimer: NodeJS.Timeout | null = null; let keepaliveTimer: NodeJS.Timeout | null = null; // herdr presence backend (active when this session runs inside a herdr // pane and the server answers ping). The envelope transport and the file // registry are untouched — herdr only replaces the presence/ping layer. let herdrPresence: HerdrPresence | null = null; let herdrWatch: HerdrAgentWatch | null = null; let turnState: "idle" | "working" = "idle"; let includeExplicit = false; let displayProject: string | null = null; let currentCtx: ExtensionContext | null = null; let currentInbound: InboundContext | null = null; // Phase A stub handlers — each just acks valid envelopes. Phase B replaces these. function ackOk(socket: net.Socket, msg_id: string): void { try { socket.write(JSON.stringify({ type: "ack", msg_id }) + "\n"); } catch { // ignore } try { socket.end(); } catch { /* ignore */ } } function nack(socket: net.Socket, msg_id: string, error: string): void { try { socket.write(JSON.stringify({ type: "nack", msg_id, error }) + "\n"); } catch { // ignore } try { socket.end(); } catch { /* ignore */ } } function handlePrompt(socket: net.Socket, env: PromptEnvelope): void { // 1. Hop limit check if (typeof env.hops !== "number" || env.hops >= MAX_HOPS) { nack(socket, env.msg_id, "hops exceeded"); return; } // 2. Insert into inbound queue const inbound: InboundContext = { msg_id: env.msg_id, hops: env.hops, sender_endpoint: env.sender_endpoint, sender_session: env.sender_session, response_schema: env.response_schema ?? null, fulfilled: false, }; inboundQueue.set(env.msg_id, inbound); // 3. Track the current inbound so that any coms_send issued during the // resulting LLM turn inherits the right hop count. currentInbound = inbound; // 4. Inject as a follow-up message into the receiver's next turn. try { pi.sendMessage( { customType: "coms-inbound", content: `[from ${env.sender_name} @ ${env.sender_cwd}]\n\n${env.prompt}`, display: true, details: { msg_id: env.msg_id, sender_session: env.sender_session, response_schema: env.response_schema ?? null, }, }, { deliverAs: "followUp", triggerTurn: true }, ); } catch (err) { // If sendMessage fails, drop the inbound and nack. inboundQueue.delete(env.msg_id); currentInbound = null; nack(socket, env.msg_id, "internal error"); return; } // 5. Ack + audit log ackOk(socket, env.msg_id); try { pi.appendEntry("coms-log", { event: "inbound_prompt", msg_id: env.msg_id, sender: env.sender_session, hops: env.hops, }); } catch { // best-effort } } function handleResponse(socket: net.Socket, env: ResponseEnvelope): void { const pending = pendingReplies.get(env.msg_id); if (pending) { if (pending.timer) { try { clearTimeout(pending.timer); } catch { /* ignore */ } pending.timer = null; } pending.result = { response: env.response, error: env.error ?? null }; try { pending.resolve(pending.result); } catch { // ignore } // Note: do NOT delete the entry here — coms_get poll may still want it. } else { try { pi.appendEntry("coms-log", { event: "orphan_response", msg_id: env.msg_id }); } catch { // best-effort } } ackOk(socket, env.msg_id); } function handlePing(socket: net.Socket, env: PingEnvelope): void { const ctx = currentCtx; const ident = identity; const pct = ctx ? Math.round(ctx.getContextUsage()?.percent ?? 0) : 0; const card: AgentCard = { name: ident?.name ?? "unknown", purpose: ident?.purpose ?? "", model: ctx?.model?.id ?? ident?.model ?? "unknown", color: ident?.color ?? "#36F9F6", context_used_pct: pct, queue_depth: inboundQueue.size, }; const pong: Pong = { type: "pong", msg_id: env.msg_id, agent_card: card }; try { socket.write(JSON.stringify(pong) + "\n"); } catch { // ignore } try { socket.end(); } catch { /* ignore */ } } function isValidEnvelope(obj: any): obj is Envelope { return ( obj && typeof obj === "object" && typeof obj.type === "string" && typeof obj.msg_id === "string" && typeof obj.sender_session === "string" && typeof obj.sender_endpoint === "string" ); } function connHandler(socket: net.Socket): void { let buf = ""; let handled = false; const onData = (chunk: Buffer) => { if (handled) return; buf += chunk.toString("utf-8"); if (buf.length > LINE_CAP_BYTES) { handled = true; socket.removeListener("data", onData); nack(socket, "", "malformed envelope"); return; } const nl = buf.indexOf("\n"); if (nl < 0) return; handled = true; socket.removeListener("data", onData); const line = buf.slice(0, nl); let parsed: any; try { parsed = JSON.parse(line); } catch { nack(socket, "", "malformed envelope"); return; } if (!isValidEnvelope(parsed)) { const mid = parsed && typeof parsed.msg_id === "string" ? parsed.msg_id : ""; nack(socket, mid, "malformed envelope"); return; } try { if (parsed.type === "prompt") { handlePrompt(socket, parsed as PromptEnvelope); } else if (parsed.type === "response") { handleResponse(socket, parsed as ResponseEnvelope); } else if (parsed.type === "ping") { handlePing(socket, parsed as PingEnvelope); } else { nack(socket, parsed.msg_id, "unknown type"); } } catch { nack(socket, parsed.msg_id, "internal error"); } }; socket.on("data", onData); socket.once("error", () => { // connection failures during handshake — drop quietly try { socket.destroy(); } catch { /* ignore */ } }); } // ━━ session_start ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ pi.on("session_start", async (_event, ctx) => { currentCtx = ctx; // 1. Resolve identity from CLI flags > frontmatter > defaults. const flags = readCliFlags(pi); const fm = readFrontmatterFromArgv(process.argv); const project = flags.project || "default"; const explicit = flags.explicit === true; const session_id = ulid(); const defaultName = `agent-${session_id.slice(-6)}`; // pi's built-in `--name` (session display name) shadows the `--name` flag this // extension registers, so `flags.name` (getFlag) stays empty when a user passes // `--name`. Recover it via getSessionName() — it ranks like the CLI flag (above // frontmatter), so `--name foo` is honored as the coms identity. const sessionName = typeof pi.getSessionName === "function" ? (pi.getSessionName() || undefined) : undefined; const desiredName = flags.name || sessionName || fm.name || defaultName; const name = resolveUniqueName(project, desiredName); if (name !== desiredName) { try { pi.appendEntry("coms-log", { event: "name_collision", desired: desiredName, assigned: name, project }); } catch { // best-effort } } const purpose = flags.purpose || fm.description || ""; // Color: validate at every level; fall through invalid hex to next. // Order: --color CLI flag > frontmatter color > deterministic fallback. let color = fallbackColor(session_id); if (fm.color && isValidHex(fm.color)) { color = fm.color; } if (flags.color && isValidHex(flags.color)) { color = flags.color; } const endpoint = makeEndpoint(session_id); const cwd = ctx.cwd || process.cwd(); const model = ctx.model?.id ?? "unknown"; // 2. Ensure storage dirs exist. try { fs.mkdirSync(path.join(COMS_DIR, "projects", project, "agents"), { recursive: true }); if (process.platform !== "win32") { fs.mkdirSync(path.join(COMS_DIR, "sockets"), { recursive: true }); try { fs.chmodSync(COMS_DIR, 0o700); } catch { /* best-effort */ } } } catch (err) { ctx.ui?.notify?.(`📡 coms: failed to create dirs — ${err instanceof Error ? err.message : String(err)}`, "error"); return; } // 3. Bind the endpoint. try { server = await bindEndpoint(endpoint, connHandler); } catch (err) { ctx.ui?.notify?.(`📡 coms: bind failed — ${err instanceof Error ? err.message : String(err)}`, "error"); return; } // 4. Build + write registry entry atomically. const entry: RegistryEntry = { session_id, name, purpose, model, color, pid: process.pid, endpoint, cwd, started_at: nowIso(), explicit, version: 1, }; let registryFile: string; try { registryFile = writeRegistryAtomic(entry, project); } catch (err) { ctx.ui?.notify?.(`📡 coms: registry write failed — ${err instanceof Error ? err.message : String(err)}`, "error"); try { server?.close(); } catch { /* ignore */ } return; } identity = { session_id, name, purpose, color, project, explicit, cwd, model, endpoint, registryFile, }; includeExplicit = false; displayProject = project; // 5. Audit log: boot. try { pi.appendEntry("coms-log", { event: "boot", session_id, name, project }); } catch { // best-effort } // 6. Surface presence in the UI + install the live pool widget. try { ctx.ui.setStatus("coms", `📡 ${name}@${project}`); installPoolWidget(ctx); ctx.ui.notify( `📡 coms ready · ${name}@${project} · ${displayProject ?? project} pool`, "info", ); } catch { // hasUI may be false in some contexts — non-fatal. } // 7. Presence backend: herdr (push events, no polling) when this // session runs inside a herdr pane with a live server; the files ping // loop otherwise. The registry keepalive below runs in BOTH backends — // it carries the full agent card (context %, queue depth) that herdr's // 32-char custom_status cannot, and keeps us discoverable to peers // running outside herdr panes. const useHerdr = await herdrPresenceAvailable(); if (useHerdr) { const paneId = herdrPaneId()!; herdrPresence = new HerdrPresence({ paneId, source: `coms:${session_id}` }); void herdrPresence.report("idle", formatPeerStatus(name, 0, 0)); herdrWatch = new HerdrAgentWatch({ ownPaneId: paneId, onChange: (agents) => herdrSyncPeerCards(agents), }); void herdrWatch.start(); try { pi.appendEntry("coms-log", { event: "presence_backend", backend: "herdr", pane: paneId }); } catch { /* best-effort */ } } else { pingTimer = setInterval(() => { refreshPool().catch(() => {}); }, PING_INTERVAL_MS); try { (pingTimer as any).unref?.(); } catch { /* ignore */ } } keepaliveTimer = setInterval(() => { if (!identity) return; try { const ctx = currentCtx; // Detect missing-registry BEFORE writing so the self_heal audit only // fires when something actually went wrong (file unlinked under us). const missingBeforeWrite = !fs.existsSync(identity.registryFile); const live: RegistryEntry = { session_id: identity.session_id, name: identity.name, purpose: identity.purpose, model: ctx?.model?.id ?? identity.model, color: identity.color, pid: process.pid, endpoint: identity.endpoint, cwd: identity.cwd, started_at: nowIso(), explicit: identity.explicit, version: 1, context_used_pct: Math.round(ctx?.getContextUsage()?.percent ?? 0), queue_depth: inboundQueue.size, heartbeat_at: nowIso(), }; // Unconditional atomic write: handles BOTH the live-status refresh // (file present → overwrite with fresh values) AND self-heal (file // missing → re-create entry). The atomic write also bumps mtime, so // keepaliveTouch is now redundant. writeRegistryAtomic(live, identity.project); if (missingBeforeWrite) { pi.appendEntry("coms-log", { event: "self_heal", session_id: identity.session_id, reason: "registry file missing" }); // Edge case: if the file was unlinked again between our write and // this check, re-write once to be safe. if (!fs.existsSync(identity.registryFile)) { writeRegistryAtomic(live, identity.project); } } } catch { /* best-effort */ } // herdr backend: refresh our sidebar entry (context % / queue move // without a state change) and re-join peer cards against the // registry heartbeats just written by the other peers. if (herdrPresence && identity) { const pct = Math.round(currentCtx?.getContextUsage()?.percent ?? 0); void herdrPresence.report(turnState, formatPeerStatus(identity.name, pct, inboundQueue.size)); } if (herdrWatch) herdrSyncPeerCards(herdrWatch.current()); }, KEEPALIVE_INTERVAL_MS); try { (keepaliveTimer as any).unref?.(); } catch { /* ignore */ } // Kick one cycle immediately so the widget populates fast (the herdr // backend populates from its initial agent.list snapshot instead). if (!useHerdr) refreshPool().catch(() => {}); }); // ━━ herdr presence: turn-state reporting + peer-card sync ━━━━━━━━━━━━━ // The herdr sidebar mirrors turn state (idle ↔ working) push-style; // context % rides the keepalive refresh above. pi.on("before_agent_start", async () => { turnState = "working"; if (herdrPresence && identity) { const pct = Math.round(currentCtx?.getContextUsage()?.percent ?? 0); void herdrPresence.report("working", formatPeerStatus(identity.name, pct, inboundQueue.size)); } }); pi.on("agent_end", async () => { turnState = "idle"; if (herdrPresence && identity) { const pct = Math.round(currentCtx?.getContextUsage()?.percent ?? 0); void herdrPresence.report("idle", formatPeerStatus(identity.name, pct, inboundQueue.size)); } }); // Join herdr's live pane states back to the coms registry: a herdr agent // whose custom_status leads with a peer name maps to that peer's registry // entry (which carries the full card the 32-char custom_status cannot). // Pool-scope semantics are preserved — only entries from peersInScope() // become cards; peers outside herdr panes stay visible as registry-only // "pending" rows exactly like an unpinged peer in the files backend. function herdrSyncPeerCards(agents: HerdrAgentInfo[]): void { if (!identity) return; const liveNames = new Set(); for (const a of agents) { const peerName = parsePeerName(a.custom_status as string | undefined); if (peerName) liveNames.add(peerName); } let changed = false; const liveSessions = new Set(); for (const entry of peersInScope()) { if (!liveNames.has(entry.name)) continue; liveSessions.add(entry.session_id); const next = { name: entry.name, purpose: entry.purpose, model: entry.model, color: entry.color, context_used_pct: entry.context_used_pct ?? 0, queue_depth: entry.queue_depth ?? 0, staleCount: 0, }; const prev = peerCards.get(entry.session_id); if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) { peerCards.set(entry.session_id, next); changed = true; } } for (const sid of [...peerCards.keys()]) { if (sid === identity.session_id) continue; if (!liveSessions.has(sid)) { peerCards.delete(sid); changed = true; } } if (changed && currentCtx?.hasUI) { installPoolWidget(currentCtx); } } // ━━ Helpers used by tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ async function pingPeer(endpoint: string): Promise { if (!identity) return null; const env: PingEnvelope = { type: "ping", msg_id: ulid(), sender_session: identity.session_id, sender_endpoint: identity.endpoint, hops: 0, timestamp: nowIso(), }; try { const resp = await sendEnvelope(endpoint, env); if (resp && resp.type === "pong" && resp.agent_card) { return resp.agent_card as AgentCard; } } catch { // ignore — peer unreachable } return null; } // ━━ Pool widget rendering ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ function renderPool(width: number, theme: Theme): string[] { const projectFilter = displayProject ?? identity?.project ?? "default"; const registryEntries = projectFilter === "*" ? readAllRegistryEntriesAcrossProjects() : readAllRegistryEntries(projectFilter); interface Row { name: string; model: string; color: string; purpose: string; pct: number | null; pending: boolean; stale: boolean; } const rows: Row[] = []; const seenSessions = new Set(); for (const [sid, card] of peerCards.entries()) { if (identity && sid === identity.session_id) continue; seenSessions.add(sid); rows.push({ name: card.name, model: card.model, color: card.color, purpose: card.purpose, pct: card.context_used_pct, pending: false, stale: (card.staleCount ?? 0) >= 3, }); } // Registry-only entries that aren't yet in peerCards → pending const seenNames = new Set(rows.map((r) => r.name)); for (const entry of registryEntries) { if (identity && entry.session_id === identity.session_id) continue; if (!includeExplicit && entry.explicit) continue; if (seenSessions.has(entry.session_id)) continue; if (seenNames.has(entry.name)) continue; rows.push({ name: entry.name, model: entry.model, color: entry.color, purpose: entry.purpose, pct: null, pending: true, stale: false, }); } // Border helpers — sandwich the body with single-line box-drawing rules // so the widget reads as its own block. The top border carries a branded // ` coms ` tag; bottom border stays a plain rule for minimalism. const safeWidth = Math.max(0, width); let topBorder: string; let bottomBorder: string; if (safeWidth < 12) { topBorder = theme.fg("dim", "━".repeat(safeWidth)); bottomBorder = theme.fg("dim", "━".repeat(safeWidth)); } else { const left = theme.fg("dim", "┏━") + theme.fg("border", " coms "); const leftFill = theme.fg("dim", "━"); const nameLen = identity ? identity.name.length : 0; const rightTagVisLen = identity ? nameLen + 4 : 0; const remaining = safeWidth - 9 /* "┏━ coms ━" */ - rightTagVisLen - 1 /* "┓" */; if (identity && remaining >= 1) { const rightTag = theme.fg("dim", " ") + hexFg(identity.color, identity.name) + theme.fg("dim", " ━"); const middle = theme.fg("dim", "━".repeat(remaining)); const right = theme.fg("dim", "┓"); topBorder = left + leftFill + middle + rightTag + right; } else { const fallbackRemaining = Math.max(0, safeWidth - 2 /* "┏━" */ - 6 /* " coms " */ - 1 /* "┓" */); const right = theme.fg("dim", "━".repeat(fallbackRemaining) + "┓"); topBorder = left + right; } bottomBorder = theme.fg("dim", "┗" + "━".repeat(safeWidth - 2) + "┛"); } if (rows.length === 0) { const emptyMsg = theme.fg("muted", "no peers connected"); return [ topBorder, truncateToWidth(theme.fg("dim", " ") + emptyMsg, width), bottomBorder, ]; } rows.sort((a, b) => a.name.localeCompare(b.name)); const out: string[] = [topBorder]; for (const r of rows) { const pctNum = r.pct ?? 0; const filled = Math.max(0, Math.min(15, Math.round((pctNum / 100) * 15))); const empty = 15 - filled; const pctLabel = r.pct == null ? "--%" : `${r.pct}%`; if (r.stale) { const dimRow = `✗ ${r.name.padEnd(12)} ${abbreviateModel(r.model).padEnd(14)} [${"-".repeat(15)}] ${pctLabel.padStart(4)} — ${r.purpose || ""}`; out.push(truncateToWidth(" " + theme.fg("dim", dimRow), width)); continue; } const swatch = r.pending ? theme.fg("dim", "●") : hexFg(r.color, "●"); const namePart = theme.fg("accent", r.name.padEnd(12)); const modelPart = theme.fg("dim", abbreviateModel(r.model).padEnd(14)); const barFill = r.pending ? theme.fg("dim", "-".repeat(15)) : hexFg(r.color, "#".repeat(filled)) + theme.fg("dim", "-".repeat(empty)); const bar = theme.fg("warning", "[") + barFill + theme.fg("warning", "]"); const pctPart = " " + theme.fg("accent", pctLabel.padStart(4)); const sep = theme.fg("dim", " — "); const purposePart = theme.fg("muted", r.purpose || ""); const line = " " + swatch + " " + namePart + " " + modelPart + " " + bar + pctPart + sep + purposePart; out.push(truncateToWidth(line, width)); } out.push(bottomBorder); return out; } function installPoolWidget(ctx: ExtensionContext): void { if (!ctx.hasUI) return; try { ctx.ui.setWidget("coms-pool", (_tui, theme) => ({ invalidate() {}, render(width: number): string[] { return renderPool(width, theme); }, }), { placement: "belowEditor" }); } catch { // non-fatal } } // ━━ Ping cycle ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ async function refreshPool(): Promise { if (!identity) return; const peers = peersInScope(); const results = await Promise.allSettled(peers.map(async (peer) => { const pingEnv: PingEnvelope = { type: "ping", msg_id: ulid(), sender_session: identity!.session_id, sender_endpoint: identity!.endpoint, hops: 0, timestamp: nowIso(), }; const reply = await sendEnvelope(peer.endpoint, pingEnv); return { peer, pong: reply as Pong }; })); const seenSessions = new Set(); let changed = false; for (const r of results) { if (r.status === "fulfilled" && r.value.pong && r.value.pong.agent_card) { const { peer, pong } = r.value; seenSessions.add(peer.session_id); const prev = peerCards.get(peer.session_id); const next = { ...pong.agent_card, staleCount: 0 }; if (!prev || JSON.stringify({ ...prev, staleCount: 0 }) !== JSON.stringify(next)) { peerCards.set(peer.session_id, next); changed = true; } } } for (const [sid, card] of peerCards.entries()) { if (identity && sid === identity.session_id) continue; if (!seenSessions.has(sid)) { card.staleCount = (card.staleCount ?? 0) + 1; if (card.staleCount > 6) { peerCards.delete(sid); } changed = true; } } if (changed && currentCtx?.hasUI) { installPoolWidget(currentCtx); } } function listProjects(): string[] { const root = path.join(COMS_DIR, "projects"); try { return fs.readdirSync(root).filter((d) => { try { return fs.statSync(path.join(root, d)).isDirectory(); } catch { return false; } }); } catch { return []; } } function peersInScope(): RegistryEntry[] { if (!identity) return []; const filter = displayProject ?? identity.project; const live = filter === "*" ? pruneDeadEntriesAllProjects() : pruneDeadEntries(filter); return live.filter( (e) => e.session_id !== identity!.session_id && (includeExplicit || !e.explicit), ); } function resolveTarget(target: string): RegistryEntry | null { // Scoped to the connected pool only (peersInScope): you can reach exactly the // peers the widget shows. Match by name first (preferred, human-facing), then by // session_id. A peer outside the current scope is intentionally NOT resolved — the // human must widen scope via /coms --project / --all first. This closes the old // cross-project leak where a name match fell through to scanning every project. const scope = peersInScope(); const byName = scope.find((e) => e.name === target); if (byName) return byName; return scope.find((e) => e.session_id === target) ?? null; } // ━━ Tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ pi.registerTool({ name: "coms_list", label: "Coms List", description: "List the peer agents in your current coms pool — the ones shown in the pool widget. Returns " + "names, models, and live context-window usage. Discovery is scoped to what the human displays " + "via /coms; you CANNOT widen it to other projects or reveal --explicit peers yourself.", parameters: Type.Object({ project: Type.Optional(Type.String({ description: "Narrow to a project WITHIN the current pool scope. Cannot widen beyond what /coms displays — a widening request is ignored." })), include_explicit: Type.Optional(Type.Boolean({ description: "Only narrows: pass false to hide explicit peers. Cannot reveal them unless the human ran /coms --all." })), }), async execute(_callId, params) { if (!identity) { return { content: [{ type: "text" as const, text: "coms not initialised." }], details: { agents: [], project: null }, }; } // Clamp discovery to the human-set pool scope (displayProject + includeExplicit, // driven by /coms). The LLM's project/include_explicit may NARROW within that // scope but can never widen it — cross-project or explicit discovery requires a // deliberate /coms --project / --all from the human. So coms_list returns exactly // the pool, the same boundary coms_send enforces. const scopeProject = displayProject ?? identity.project; let projects: string[]; let widened = false; if (scopeProject === "*") { projects = params.project && params.project !== "*" ? [params.project] : listProjects(); } else { projects = [scopeProject]; if (params.project && params.project !== scopeProject) widened = true; } // include_explicit may only narrow (turn OFF); it cannot reveal explicit peers // unless the human already did via /coms --all. const includeExp = includeExplicit && params.include_explicit !== false; if (params.include_explicit === true && !includeExplicit) widened = true; const collected: { entry: RegistryEntry; project: string }[] = []; for (const proj of projects) { for (const entry of pruneDeadEntries(proj)) { if (entry.explicit && !includeExp) continue; if (entry.session_id === identity.session_id) continue; collected.push({ entry, project: proj }); } } // Ping each peer in parallel for live context usage. const pongs = await Promise.allSettled(collected.map((c) => pingPeer(c.entry.endpoint))); const agents = collected.map((c, i) => { const r = pongs[i]; const pong = r.status === "fulfilled" ? r.value : null; return { name: c.entry.name, session_id: c.entry.session_id, purpose: c.entry.purpose, model: c.entry.model, cwd: c.entry.cwd, project: c.project, alive: pong != null, context_used_pct: pong ? pong.context_used_pct : null, color: c.entry.color, }; }); const notice = widened ? `\n\n(Discovery is scoped to "${scopeProject}"${includeExplicit ? "" : ", explicit peers hidden"}. ` + `Widening to other projects or revealing --explicit peers is a human action via ` + `/coms --project or /coms --all.)` : ""; const lines = agents.length === 0 ? "No peer agents in your pool." : agents.map((a) => { const ctxStr = a.context_used_pct != null ? ` ${a.context_used_pct}%` : " ?%"; const live = a.alive ? "●" : "✗"; return `${live} ${a.name} (${a.model})${ctxStr}${a.purpose ? ` — ${a.purpose}` : ""}`; }).join("\n"); return { content: [{ type: "text" as const, text: `${agents.length} peer(s) in pool (project ${scopeProject}):\n${lines}${notice}` }], details: { agents, project: scopeProject, scoped: true, widenRequested: widened }, }; }, renderCall(args, theme) { const proj = (args as any).project; const filter = proj ? ` ${proj}` : ""; return new Text( theme.fg("toolTitle", theme.bold("coms_list")) + theme.fg("dim", filter), 0, 0, ); }, renderResult(result, options, theme) { const details = result.details as any; const agents: any[] = details?.agents ?? []; const header = theme.fg("accent", `📡 ${agents.length} peer(s)`); if (!options.expanded || agents.length === 0) { return new Text(header, 0, 0); } const rows = agents.map((a) => { const dot = a.alive ? theme.fg("success", "●") : theme.fg("error", "✗"); const pct = a.context_used_pct != null ? `${a.context_used_pct}%` : "?%"; return `${dot} ${theme.fg("accent", a.name)} ${theme.fg("dim", a.model)} ${theme.fg("warning", pct)}`; }).join("\n"); return new Text(header + "\n" + rows, 0, 0); }, }); pi.registerTool({ name: "coms_send", label: "Coms Send", description: "Send a prompt to a peer agent. Returns synchronously with a msg_id once the receiver acks. " + "Use coms_get (non-blocking) or coms_await (blocking) with the msg_id to retrieve the response. " + "Throws if the receiver is unreachable or rejects the envelope.", parameters: Type.Object({ target: Type.String({ description: "Peer name (preferred) or session_id — must be a peer currently in your coms pool (shown in the widget). Out-of-pool targets are refused; ask the human to widen scope with /coms --project or /coms --all." }), prompt: Type.String({ description: "The prompt to send." }), conversation_id: Type.Optional(Type.String()), response_schema: Type.Optional(Type.Any({ description: "Optional JSON Schema describing the expected response shape." })), }), async execute(_callId, params) { if (!identity) { throw new Error("coms not initialised"); } const target = resolveTarget(params.target); if (!target) { // Refuse without confirming whether the peer exists outside the pool — that // existence is itself cross-project metadata. Point at the human-controlled // widening path instead. const scope = displayProject ?? identity.project; throw new Error( `coms: no connected peer "${params.target}" in your pool (project ${scope}). ` + `Only peers shown in the coms pool are reachable. If you expected this peer, ask the ` + `human to widen scope with /coms --project or /coms --all, then retry.`, ); } const hops = currentInbound ? currentInbound.hops + 1 : 0; if (hops >= MAX_HOPS) { throw new Error(`coms: hop limit reached (${hops} >= ${MAX_HOPS})`); } const msg_id = ulid(); const env: PromptEnvelope = { type: "prompt", msg_id, sender_session: identity.session_id, sender_endpoint: identity.endpoint, sender_name: identity.name, sender_cwd: identity.cwd, hops, timestamp: nowIso(), prompt: params.prompt, conversation_id: params.conversation_id ?? null, response_schema: (params.response_schema as object | undefined) ?? null, }; // Send the envelope synchronously and wait for the receiver's ack. await sendEnvelope(target.endpoint, env); // Register a pending entry whose promise the receiver-side handleResponse // (or the timeout below) will settle. let resolveFn!: (v: { response?: any; error?: string | null }) => void; let rejectFn!: (e: Error) => void; const promise = new Promise<{ response?: any; error?: string | null }>((res, rej) => { resolveFn = res; rejectFn = rej; }); const entry: PendingReply = { resolve: resolveFn, reject: rejectFn, timer: null, promise, target_name: target.name, created_at: nowIso(), }; entry.timer = setTimeout(() => { if (entry.result) return; entry.result = { error: "timeout" }; try { entry.resolve(entry.result); } catch { /* ignore */ } }, TIMEOUT_MS); // Don't keep the event loop alive solely for this timer. try { (entry.timer as any).unref?.(); } catch { /* ignore */ } pendingReplies.set(msg_id, entry); try { pi.appendEntry("coms-log", { event: "outbound_prompt", msg_id, target: target.name, hops, }); } catch { // best-effort } return { content: [{ type: "text" as const, text: `coms_send → ${target.name}\nmsg_id ${msg_id}\nhops ${hops}` }], details: { msg_id, target: target.name, target_session: target.session_id, hops }, }; }, renderCall(args, theme) { const tgt = (args as any).target ?? "?"; const prompt = (args as any).prompt ?? ""; const preview = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt; return new Text( theme.fg("toolTitle", theme.bold("coms_send ")) + theme.fg("accent", tgt) + theme.fg("dim", " — ") + theme.fg("muted", preview), 0, 0, ); }, renderResult(result, _options, theme) { const d = result.details as any; if (!d) { const t = result.content[0]; return new Text(t?.type === "text" ? t.text : "", 0, 0); } return new Text( theme.fg("success", "→ ") + theme.fg("accent", d.target) + theme.fg("dim", ` msg_id `) + theme.fg("warning", d.msg_id), 0, 0, ); }, }); pi.registerTool({ name: "coms_get", label: "Coms Get", description: "Non-blocking poll of a pending coms_send reply. Returns status pending|complete|error and (when complete) the response.", parameters: Type.Object({ msg_id: Type.String({ description: "msg_id returned by coms_send." }), }), async execute(_callId, params) { const entry = pendingReplies.get(params.msg_id); if (!entry) { return { content: [{ type: "text" as const, text: `coms_get: unknown msg_id ${params.msg_id}` }], details: { status: "error", error: "unknown msg_id" }, }; } if (entry.result) { const r = entry.result; const text = r.error ? `coms_get: error — ${r.error}` : `coms_get: complete\n${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`; return { content: [{ type: "text" as const, text }], details: { status: "complete", response: r.response, error: r.error ?? null }, }; } return { content: [{ type: "text" as const, text: `coms_get: pending` }], details: { status: "pending" }, }; }, renderCall(args, theme) { const id = (args as any).msg_id ?? "?"; return new Text( theme.fg("toolTitle", theme.bold("coms_get ")) + theme.fg("warning", id), 0, 0, ); }, renderResult(result, _options, theme) { const d = result.details as any; const status = d?.status ?? "?"; const color = status === "complete" ? "success" : status === "pending" ? "warning" : "error"; return new Text(theme.fg(color, status), 0, 0); }, }); pi.registerTool({ name: "coms_await", label: "Coms Await", description: "Block until a pending coms_send reply lands or the timeout fires. Default timeout 30 minutes (PI_COMS_TIMEOUT_MS).", parameters: Type.Object({ msg_id: Type.String({ description: "msg_id returned by coms_send." }), timeout_ms: Type.Optional(Type.Number({ description: "Override the default timeout (ms)." })), }), async execute(_callId, params) { const entry = pendingReplies.get(params.msg_id); if (!entry) { return { content: [{ type: "text" as const, text: `coms_await: unknown msg_id ${params.msg_id}` }], details: { error: "unknown msg_id" }, }; } const timeoutMs = typeof params.timeout_ms === "number" && params.timeout_ms > 0 ? params.timeout_ms : TIMEOUT_MS; const timed = new Promise<{ error: string }>((resolve) => { const t = setTimeout(() => resolve({ error: "timeout" }), timeoutMs); try { (t as any).unref?.(); } catch { /* ignore */ } }); const winner = await Promise.race([entry.promise, timed]); if ((winner as any).error) { return { content: [{ type: "text" as const, text: `coms_await: error — ${(winner as any).error}` }], details: { error: (winner as any).error }, }; } const resp = (winner as any).response; return { content: [{ type: "text" as const, text: typeof resp === "string" ? resp : JSON.stringify(resp, null, 2) }], details: { response: resp }, }; }, renderCall(args, theme) { const id = (args as any).msg_id ?? "?"; return new Text( theme.fg("toolTitle", theme.bold("coms_await ")) + theme.fg("warning", id), 0, 0, ); }, renderResult(result, _options, theme) { const d = result.details as any; if (d?.error) return new Text(theme.fg("error", `✗ ${d.error}`), 0, 0); return new Text(theme.fg("success", "✓ response received"), 0, 0); }, }); // ━━ agent_end: capture turn output and dispatch response back ━━━━━━━━ pi.on("agent_end", async (_event, ctx) => { const inbound = [...inboundQueue.values()].reverse().find((i) => !i.fulfilled); if (!inbound || !identity) return; // Walk the session branch for the most recent assistant message text. let lastAssistantText = ""; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") { const m = entry.message as any; if (typeof m.content === "string") { lastAssistantText = m.content; } else if (Array.isArray(m.content)) { lastAssistantText = m.content .filter((b: any) => b && b.type === "text") .map((b: any) => b.text) .join("\n"); } } } let payload: any = lastAssistantText; let error: string | null = null; if (inbound.response_schema && typeof inbound.response_schema === "object") { try { payload = JSON.parse(lastAssistantText); } catch { error = "response not valid JSON"; payload = null; } } const respEnv: ResponseEnvelope = { type: "response", msg_id: inbound.msg_id, sender_session: identity.session_id, sender_endpoint: identity.endpoint, hops: 0, timestamp: nowIso(), response: payload, error, }; try { await sendEnvelope(inbound.sender_endpoint, respEnv); try { pi.appendEntry("coms-log", { event: "outbound_response", msg_id: inbound.msg_id, error, }); } catch { // best-effort } } catch (e: any) { try { pi.appendEntry("coms-log", { event: "outbound_response_failed", msg_id: inbound.msg_id, reason: e?.message ?? String(e), }); } catch { // best-effort } } inbound.fulfilled = true; inboundQueue.delete(inbound.msg_id); if (currentInbound && currentInbound.msg_id === inbound.msg_id) { currentInbound = null; } }); // ━━ /coms slash command ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ pi.registerCommand("coms", { description: "Force-refresh the coms pool widget (or filter with --all / --project )", handler: async (args, ctx) => { const trimmed = (args ?? "").trim(); if (trimmed.includes("--all")) { includeExplicit = !includeExplicit; try { ctx.ui.notify(`coms: include_explicit = ${includeExplicit}`, "info"); } catch { /* ignore */ } } const projectMatch = trimmed.match(/--project\s+(\S+)/); if (projectMatch) { displayProject = projectMatch[1]; try { ctx.ui.notify(`coms: displaying project ${displayProject}`, "info"); } catch { /* ignore */ } } if (herdrWatch) { await herdrWatch.start(); // re-list + resubscribe + sync cards } else { await refreshPool(); } }, }); // ━━ Clean shutdown ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ let shuttingDown = false; async function cleanShutdown(): Promise { if (shuttingDown) return; shuttingDown = true; if (pingTimer) { try { clearInterval(pingTimer); } catch { /* ignore */ } pingTimer = null; } if (keepaliveTimer) { try { clearInterval(keepaliveTimer); } catch { /* ignore */ } keepaliveTimer = null; } if (herdrWatch) { try { herdrWatch.stop(); } catch { /* ignore */ } herdrWatch = null; } if (herdrPresence) { try { void herdrPresence.release(); } catch { /* ignore */ } herdrPresence = null; } if (server) { try { server.close(); } catch { /* ignore */ } server = null; } if (identity) { if (process.platform !== "win32") { try { fs.unlinkSync(identity.endpoint); } catch { /* ignore */ } } try { removeRegistryEntry(identity.project, identity.name); } catch { /* ignore */ } try { pi.appendEntry("coms-log", { event: "shutdown", session_id: identity.session_id }); } catch { /* best-effort */ } } if (currentCtx?.hasUI) { try { currentCtx.ui.setWidget("coms-pool", undefined); } catch { /* ignore */ } } } pi.on("session_shutdown", async () => { await cleanShutdown(); }); process.on("SIGINT", () => { void cleanShutdown(); }); process.on("SIGTERM", () => { void cleanShutdown(); }); }