/** * AgentRegistry - Process-global registry of live AgentSession instances. * * Tracks every alive agent (the main session plus every subagent) so the * `irc` tool can address peers by id. Sessions are registered explicitly at * creation and removed when the owner releases them. */ import type { AgentSession } from "../session/agent-session"; export const MAIN_AGENT_ID = "0-Main"; export type AgentStatus = "running" | "idle" | "completed" | "aborted"; export type AgentKind = "main" | "sub"; export interface AgentRef { id: string; displayName: string; kind: AgentKind; parentId?: string; status: AgentStatus; session: AgentSession | null; sessionFile: string | null; createdAt: number; lastActivity: number; } export type RegistryEvent = | { type: "registered"; ref: AgentRef } | { type: "status_changed"; ref: AgentRef } | { type: "removed"; ref: AgentRef }; type RegistryListener = (event: RegistryEvent) => void; export interface RegisterInput { id: string; displayName: string; kind: AgentKind; parentId?: string; session: AgentSession | null; sessionFile?: string | null; status?: AgentStatus; } export class AgentRegistry { static #global: AgentRegistry | undefined; static global(): AgentRegistry { if (!AgentRegistry.#global) { AgentRegistry.#global = new AgentRegistry(); } return AgentRegistry.#global; } /** Reset the global registry. Test-only. */ static resetGlobalForTests(): void { AgentRegistry.#global = new AgentRegistry(); } readonly #refs = new Map(); readonly #listeners = new Set(); register(input: RegisterInput): AgentRef { const now = Date.now(); const ref: AgentRef = { id: input.id, displayName: input.displayName, kind: input.kind, parentId: input.parentId, status: input.status ?? "running", session: input.session, sessionFile: input.sessionFile ?? null, createdAt: now, lastActivity: now, }; this.#refs.set(ref.id, ref); this.#emit({ type: "registered", ref }); return ref; } setStatus(id: string, status: AgentStatus): void { const ref = this.#refs.get(id); if (!ref || ref.status === status) return; ref.status = status; ref.lastActivity = Date.now(); this.#emit({ type: "status_changed", ref }); } attachSession(id: string, session: AgentSession, sessionFile?: string | null): void { const ref = this.#refs.get(id); if (!ref) return; ref.session = session; if (sessionFile !== undefined) ref.sessionFile = sessionFile; ref.lastActivity = Date.now(); } detachSession(id: string): void { const ref = this.#refs.get(id); if (!ref) return; ref.session = null; } unregister(id: string): void { const ref = this.#refs.get(id); if (!ref) return; this.#refs.delete(id); this.#emit({ type: "removed", ref }); } get(id: string): AgentRef | undefined { return this.#refs.get(id); } list(): AgentRef[] { return [...this.#refs.values()]; } /** * Returns every alive agent (running | idle) except the caller. * Flat namespace: every agent can see every other agent. */ listVisibleTo(id: string): AgentRef[] { return this.list().filter(ref => ref.id !== id && (ref.status === "running" || ref.status === "idle")); } onChange(listener: RegistryListener): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } #emit(event: RegistryEvent): void { for (const listener of this.#listeners) { try { listener(event); } catch { // listeners must not break the dispatch loop } } } }