import { getRuntimeUrlResolver } from './runtime-url'; import { subscribeRuntimeEndpointChanged } from './runtime-switch'; type ScheduledTaskRanEvent = { type: 'scheduled-task-ran'; projectId: string; taskId: string; ranAt: number; status: 'running' | 'success' | 'error'; sessionId?: string; }; type SessionCreatedEvent = { type: 'session-created'; sessionId: string; directory: string; projectId?: string; createdAt: number; promptDispatched: boolean; dispatchedAsCommand: boolean; }; /** * One in-app browser action requested by the agent tool. Broadcast to every * connected client; only the one owning a browser view answers. */ type BrowserControlRequestEvent = { type: 'browser-control-request'; requestId: string; action: string; parameters: Record; }; type OpenChamberEvent = ScheduledTaskRanEvent | SessionCreatedEvent | BrowserControlRequestEvent; type Listener = (event: OpenChamberEvent) => void; let eventSource: EventSource | null = null; let reconnectTimer: ReturnType | null = null; let heartbeatTimer: ReturnType | null = null; let reconnectAttempt = 0; let runtimeChangeUnsubscribe: (() => void) | null = null; const listeners = new Set(); const MAX_RECONNECT_DELAY_MS = 30_000; const HEARTBEAT_TIMEOUT_MS = 45_000; const clearHeartbeatTimer = () => { if (!heartbeatTimer) { return; } clearTimeout(heartbeatTimer); heartbeatTimer = null; }; const scheduleReconnect = () => { if (reconnectTimer || listeners.size === 0) { return; } const delay = Math.min(1_000 * Math.pow(2, Math.min(reconnectAttempt, 5)), MAX_RECONNECT_DELAY_MS); reconnectTimer = setTimeout(() => { reconnectTimer = null; reconnectAttempt += 1; connect(); }, delay); }; const cleanupSource = () => { clearHeartbeatTimer(); if (eventSource) { eventSource.close(); } eventSource = null; }; const resetHeartbeatTimer = () => { clearHeartbeatTimer(); if (listeners.size === 0) { return; } heartbeatTimer = setTimeout(() => { cleanupSource(); scheduleReconnect(); }, HEARTBEAT_TIMEOUT_MS); }; const parseEnvelope = (raw: string): { type: string; properties: unknown } | null => { if (!raw || raw.trim().length === 0) { return null; } try { const parsed = JSON.parse(raw); const type = typeof parsed?.type === 'string' ? parsed.type : ''; const properties = parsed?.properties; if (!type) { return null; } return { type, properties }; } catch { return null; } }; const getEventProperties = (properties: unknown): Record | null => { if (!properties || typeof properties !== 'object') { return null; } return properties as Record; }; const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) => { if (envelope.type === 'openchamber:event-stream-ready') { reconnectAttempt = 0; return; } if (envelope.type === 'openchamber:heartbeat') { return; } if (envelope.type === 'openchamber:session-created') { const properties = getEventProperties(envelope.properties); const sessionId = typeof properties?.sessionId === 'string' ? properties.sessionId : ''; const directory = typeof properties?.directory === 'string' ? properties.directory : ''; if (!sessionId || !directory) { return; } const nextEvent: SessionCreatedEvent = { type: 'session-created', sessionId, directory, createdAt: typeof properties?.createdAt === 'number' ? properties.createdAt : Date.now(), promptDispatched: properties?.promptDispatched === true, dispatchedAsCommand: properties?.dispatchedAsCommand === true, ...(typeof properties?.projectId === 'string' && properties.projectId.length > 0 ? { projectId: properties.projectId } : {}), }; for (const listener of listeners) { listener(nextEvent); } return; } if (envelope.type === 'openchamber:browser-control-request') { const properties = getEventProperties(envelope.properties); const requestId = typeof properties?.requestId === 'string' ? properties.requestId : ''; const action = typeof properties?.action === 'string' ? properties.action : ''; if (!requestId || !action) { return; } const rawParameters = properties?.parameters; const nextEvent: BrowserControlRequestEvent = { type: 'browser-control-request', requestId, action, parameters: rawParameters && typeof rawParameters === 'object' && !Array.isArray(rawParameters) ? rawParameters as Record : {}, }; for (const listener of listeners) { listener(nextEvent); } return; } if (envelope.type !== 'openchamber:scheduled-task-ran') { return; } const properties = getEventProperties(envelope.properties); const projectId = typeof properties?.projectId === 'string' ? properties.projectId : ''; const taskId = typeof properties?.taskId === 'string' ? properties.taskId : ''; const ranAt = typeof properties?.ranAt === 'number' ? properties.ranAt : Date.now(); const rawStatus = properties?.status; const status = rawStatus === 'running' || rawStatus === 'error' ? rawStatus : 'success'; if (!projectId || !taskId) { return; } const nextEvent: ScheduledTaskRanEvent = { type: 'scheduled-task-ran', projectId, taskId, ranAt, status, ...(typeof properties?.sessionId === 'string' && properties.sessionId.length > 0 ? { sessionId: properties.sessionId } : {}), }; for (const listener of listeners) { listener(nextEvent); } }; const connect = () => { if (typeof window === 'undefined' || listeners.size === 0) { return; } if (typeof EventSource !== 'function') { return; } if (eventSource && eventSource.readyState !== EventSource.CLOSED) { return; } cleanupSource(); // Tell the server what this client can do while the connection lasts. Only a // Chromium host can drive a page; a browser tab can display one but not be // driven, and the agent tool needs to know which it is talking to without a // setting anyone has to remember to change. const canControlBrowser = typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__); const source = new EventSource(getRuntimeUrlResolver().sse( '/api/openchamber/events', canControlBrowser ? { browser: '1' } : undefined, )); source.onopen = () => { resetHeartbeatTimer(); }; source.onmessage = (event) => { resetHeartbeatTimer(); const envelope = parseEnvelope(event.data); if (!envelope) { return; } dispatchFromEnvelope(envelope); }; source.onerror = () => { cleanupSource(); scheduleReconnect(); }; eventSource = source; }; const ensureRuntimeChangeSubscription = () => { if (runtimeChangeUnsubscribe || typeof window === 'undefined') return; runtimeChangeUnsubscribe = subscribeRuntimeEndpointChanged(() => { cleanupSource(); reconnectAttempt = 0; connect(); }); }; const cleanupRuntimeChangeSubscription = () => { runtimeChangeUnsubscribe?.(); runtimeChangeUnsubscribe = null; }; export const subscribeOpenchamberEvents = (listener: Listener): (() => void) => { listeners.add(listener); ensureRuntimeChangeSubscription(); connect(); return () => { listeners.delete(listener); if (listeners.size === 0) { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectAttempt = 0; cleanupSource(); cleanupRuntimeChangeSubscription(); } }; };