/** * Process-wide registry mapping a session id to its active {@link AskAnswerSource}. * * Decouples the `ask` tool (which reads the source via `AgentSession`) from the * notifications extension (which registers one), without threading a new method * through the extension/runner/controller wiring. A session may have multiple * sources. A "protocol" source always takes precedence over an "interactive" * source regardless of registration order; within the same kind, the most * recently registered source wins. Registering returns a disposer. */ /** Error code a remote ask source uses to signal that its own timeout fired. */ export const GJC_ASK_TIMEOUT_CODE = "gjc.ask.timeout"; import { logger } from "@gajae-code/utils"; import type { WorkflowGateEmitter } from "../modes/shared/agent-wire/workflow-gate-broker"; import type { AskAnswerSource } from "./index"; export type AskAnswerSourceKind = "protocol" | "interactive"; interface RegisteredAskAnswerSource { readonly source: AskAnswerSource; readonly kind: AskAnswerSourceKind; } const sources = new Map(); const workflowGateEmitters = new Map(); const workflowGateListeners = new Map void>>(); /** Register `source` for `sessionId`. Returns a disposer that restores the prior source. */ export function registerAskAnswerSource( sessionId: string, source: AskAnswerSource, kind: AskAnswerSourceKind = "interactive", ): () => void { const stack = sources.get(sessionId) ?? []; const entry: RegisteredAskAnswerSource = { source, kind }; stack.push(entry); sources.set(sessionId, stack); return () => { const current = sources.get(sessionId); if (!current) return; const index = current.lastIndexOf(entry); if (index >= 0) current.splice(index, 1); if (current.length === 0) sources.delete(sessionId); }; } /** The highest-priority answer source for `sessionId`, if one is registered. */ export function getAskAnswerSource(sessionId: string): AskAnswerSource | undefined { const entries = sources.get(sessionId); const protocolEntry = entries?.findLast(entry => entry.kind === "protocol"); return protocolEntry?.source ?? entries?.at(-1)?.source; } /** Publish a session's current workflow-gate emitter after mode initialization. */ export function notifyWorkflowGateEmitterChanged(sessionId: string, emitter: WorkflowGateEmitter | undefined): void { if (emitter) workflowGateEmitters.set(sessionId, emitter); else workflowGateEmitters.delete(sessionId); for (const listener of workflowGateListeners.get(sessionId) ?? []) { // Isolate each listener: a throwing observer must never escape into an // emitter suspend/bind/restore step, which runs inside session-transition // transactions (e.g. handoff) whose rollback/commit correctness depends on // this notification being no-throw. try { listener(emitter); } catch (error) { logger.warn("Workflow-gate emitter listener threw during notification", { sessionId, error: error instanceof Error ? error.message : String(error), }); } } } /** Observe workflow-gate emitter installation even when it occurs after session_start. */ export function registerWorkflowGateEmitterListener( sessionId: string, listener: (emitter: WorkflowGateEmitter | undefined) => void, ): () => void { const listeners = workflowGateListeners.get(sessionId) ?? new Set(); listeners.add(listener); workflowGateListeners.set(sessionId, listeners); listener(workflowGateEmitters.get(sessionId)); return () => { listeners.delete(listener); if (listeners.size === 0) workflowGateListeners.delete(sessionId); }; }