import * as fs from "node:fs"; import * as path from "node:path"; import { REGISTRY_DIR, STALE_MS } from "./constants"; import { pidAlive } from "./util"; export interface AgentRef { id: string; name: string; cwd: string; socket?: string; } export interface RegistryEntry { id: string; name: string; pid: number; cwd: string; branch?: string; sessionFile: string | null; socket: string; busy: boolean; startedAt: string; heartbeat: string; } export function readRegistry(dir: string = REGISTRY_DIR): RegistryEntry[] { let files: string[] = []; try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { return []; } const out: RegistryEntry[] = []; for (const f of files) { try { out.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"))); } catch {} } return out; } export function isLive(e: RegistryEntry, now = Date.now()): boolean { const fresh = now - Date.parse(e.heartbeat) < STALE_MS; return (fresh || pidAlive(e.pid)) && fs.existsSync(e.socket); } /** Live peers (excluding self); reaps stale entries as a side effect. */ export function listPeers(selfId: string, dir: string = REGISTRY_DIR): { live: RegistryEntry[]; stale: string[] } { const live: RegistryEntry[] = []; const stale: string[] = []; for (const e of readRegistry(dir)) { if (isLive(e)) { if (e.id !== selfId) live.push(e); } else { stale.push(e.id); try { fs.unlinkSync(path.join(dir, `${e.id}.json`)); fs.rmSync(e.socket, { force: true }); } catch {} } } return { live, stale }; } /** Resolve an agent's CURRENT socket by stable id (survives their restart). */ export function resolveSocketById(id: string, dir: string = REGISTRY_DIR): string | null { for (const e of readRegistry(dir)) { if (e.id === id && isLive(e)) return e.socket; } return null; } export type MatchResult = | { kind: "found"; peer: RegistryEntry } | { kind: "ambiguous"; candidates: RegistryEntry[] } | { kind: "none" }; /** * Resolve a target string to a live peer. Ids are stable; names are mutable display * labels (auto-naming renames sessions mid-flight), so matching is id-first: * 1. exact id, or unambiguous id prefix (>=4 chars) * 2. exact name * 3. the #suffix of a (possibly stale) name — it is the first 4 chars of the id, * so an out-of-date name still identifies the agent * 4. name prefix */ export function matchPeer(live: RegistryEntry[], target: string): MatchResult { const t = target.trim(); const pick = (arr: RegistryEntry[]): MatchResult => arr.length === 1 ? { kind: "found", peer: arr[0] } : arr.length > 1 ? { kind: "ambiguous", candidates: arr } : { kind: "none" }; const exactId = live.filter((p) => p.id === t); if (exactId.length) return pick(exactId); if (/^[0-9a-f]{4,12}$/.test(t)) { const byIdPrefix = live.filter((p) => p.id.startsWith(t)); if (byIdPrefix.length) return pick(byIdPrefix); } const exactName = live.filter((p) => p.name === t); if (exactName.length) return pick(exactName); const hash = t.lastIndexOf("#"); if (hash >= 0 && hash < t.length - 1) { const suffix = t.slice(hash + 1); const bySuffix = live.filter((p) => p.id.startsWith(suffix)); if (bySuffix.length) return pick(bySuffix); } const byNamePrefix = live.filter((p) => p.name.startsWith(t)); return pick(byNamePrefix); }