import { createContext } from "react"; import { io, Socket } from "socket.io-client"; import type { ChatSession, IWorkOrderCompleteStats } from "./api"; /** * Web Worker for heartbeat timing — avoids browser tab throttling. * Chrome throttles setInterval in background tabs to ~1/min, which * would cause heartbeat timeouts against the drone's 120s timer. */ let HeartbeatWorker: typeof Worker | null = null; try { // Vite supports `new Worker` with `?worker` import syntax HeartbeatWorker = Worker; } catch { // Web Workers not available (extremely rare in modern browsers) } const SOCKET_URL = ""; export interface ServerToClientEvents { thinking: (content: string) => void; response: (content: string) => void; toolCall: ( callId: string, name: string, params: string, response: string, ) => void; workOrderComplete: ( turnId: string, success: boolean, message?: string, stats?: IWorkOrderCompleteStats, ) => void; log: ( timestamp: string, component: { name: string; slug: string }, level: string, message: string, metadata?: unknown, ) => void; } export interface ClientToServerEvents { submitPrompt: (content: string) => void; abortWorkOrder: (cb: (success: boolean, message?: string) => void) => void; requestSessionLock: ( registration: any, project: any, chatSession: any, cb: (success: boolean, chatSessionId: string) => void, ) => void; requestWorkspaceMode: ( registration: any, project: any, chatSession: any, mode: string, cb: (success: boolean, mode: string) => void, ) => void; releaseSessionLock: ( registration: any, project: any, chatSession: any, cb: (success: boolean) => void, ) => void; sessionHeartbeat: (cb: (ack: boolean) => void) => void; fileTreeRequest: ( args: { path?: string; recursive?: boolean; maxDepth?: number }, cb: (success: boolean, data: { entries?: any[]; error?: string }) => void, ) => void; fileReadRequest: ( args: { path: string }, cb: (success: boolean, data: { content?: string; error?: string }) => void, ) => void; fileWriteRequest: ( args: { path: string; content: string }, cb: (success: boolean, data: { success?: boolean; error?: string }) => void, ) => void; } export interface SocketEvents { thinking: (content: string) => void; response: (content: string) => void; toolCall: ( callId: string, name: string, params: string, response: string, ) => void; workOrderComplete: ( turnId: string, success: boolean, message?: string, stats?: IWorkOrderCompleteStats, ) => void; "agent:thinking": (data: { agentId: string; thinking: string }) => void; "agent:response": (data: { agentId: string; chunk: string }) => void; "agent:tool-call": (data: { agentId: string; tool: string; args: unknown; }) => void; "agent:tool-result": (data: { agentId: string; tool: string; result: unknown; }) => void; "agent:complete": (data: { agentId: string; response?: string; subagent?: Record; stats?: { toolCallCount: number; inputTokens: number; thinkingTokenCount: number; responseTokens: number; durationMs: number; durationLabel: string; }; }) => void; "log:entry": (data: { level: string; component: string; message: string; metadata?: unknown; timestamp: number; }) => void; "chat:message": (data: { agentId: string; message: string; role: "user" | "assistant" | "system"; }) => void; workspaceModeChanged: (mode: string) => void; sessionUpdated: (updates: Partial) => void; tabLockDenied: (data: { message: string }) => void; fileTreeResponse: (path: string, entries: any[], error?: string) => void; fileReadResponse: (path: string, content: string | null, error?: string) => void; fileWriteResponse: (path: string, success: boolean, error?: string) => void; status: (content: string) => void; "drone:log": (data: { timestamp: string; level: string; component: string; message: string; metadata?: unknown; }) => void; "drone:status": (data: { timestamp: string; message: string; }) => void; reconnect_attempt: (attempt: number) => void; reconnect_failed: () => void; reconnect: (attempt: number) => void; connect: () => void; disconnect: (reason: string) => void; error: (error: Error) => void; } class SocketClient { private _socket: Socket | null = null; private eventListeners: Map void>> = new Map(); private reconnectAttempts = 0; private maxReconnectAttempts = 5; private jwt: string | null = null; /** * Web Worker for heartbeat timing. Runs the 19-second interval in a * worker thread to avoid browser tab throttling, which would cause * heartbeat timeouts against the drone's 120-second timer. */ private heartbeatWorker: Worker | null = null; /** * Fallback setInterval for environments where Web Workers are unavailable. * This is subject to browser tab throttling but is better than nothing. */ private heartbeatFallbackInterval: ReturnType | null = null; /** * Bound visibility handler so we can remove it on disconnect. */ private boundVisibilityHandler: (() => void) | null = null; get connected(): boolean { return this._socket?.connected ?? false; } get socket(): Socket | null { return this._socket; } connect(token: string): void { if (this._socket?.connected) { return; } this.jwt = token; this._socket = io(SOCKET_URL, { auth: { token: this.jwt, }, transports: ["websocket", "polling"], reconnection: true, reconnectionAttempts: this.maxReconnectAttempts, reconnectionDelay: 1000, reconnectionDelayMax: 5000, }); if (!this.socket) { return; } // Forward server events to our event listeners this.socket.on("thinking", (content: string) => { this.emit("thinking", content); }); this.socket.on("response", (content: string) => { this.emit("response", content); }); this.socket.on( "toolCall", (callId: string, name: string, params: string, response: string) => { this.emit("toolCall", callId, name, params, response); }, ); this.socket.on( "workOrderComplete", (turnId: string, success: boolean, message?: string, stats?: IWorkOrderCompleteStats) => { this.emit("workOrderComplete", turnId, success, message, stats); }, ); this.socket.on("workspaceModeChanged", (mode: string) => { this.emit("workspaceModeChanged", mode); }); this.socket.on("sessionUpdated", (updates: unknown) => { this.emit("sessionUpdated", updates as Partial); }); this.socket.on("fileTreeResponse", (path: string, entries: unknown[], error?: string) => { this.emit("fileTreeResponse", path, entries as any[], error); }); this.socket.on("fileReadResponse", (path: string, content: string | null, error?: string) => { this.emit("fileReadResponse", path, content, error); }); this.socket.on("fileWriteResponse", (path: string, success: boolean, error?: string) => { this.emit("fileWriteResponse", path, success, error); }); this.socket.on( "log", ( timestamp: string, component: { name: string; slug: string }, level: string, message: string, metadata?: unknown, ) => { this.emit("log:entry", { level, component: component.name, message, metadata, timestamp: new Date(timestamp).getTime(), }); }, ); this.socket.on("agent:thinking", (data: unknown) => { this.emit("agent:thinking", data as { agentId: string; thinking: string }); }); this.socket.on("agent:response", (data: unknown) => { this.emit("agent:response", data as { agentId: string; chunk: string }); }); this.socket.on("agent:tool-call", (data: unknown) => { this.emit("agent:tool-call", data as { agentId: string; tool: string; args: unknown }); }); this.socket.on("agent:tool-result", (data: unknown) => { this.emit("agent:tool-result", data as { agentId: string; tool: string; result: unknown }); }); this.socket.on("agent:complete", (data: unknown) => { this.emit("agent:complete", data as SocketEvents["agent:complete"] extends (data: infer T) => void ? T : never); }); this._socket.on("connect", () => { this.reconnectAttempts = 0; this.emit("connect"); }); this._socket.on("disconnect", (reason) => { this.emit("disconnect", reason); }); this._socket.on("connect_error", (error) => { this.reconnectAttempts++; this.emit("error", error); }); this._socket.on("tabLockDenied", (data: { message: string }) => { this.emit("tabLockDenied", data); }); this.socket.on("status", (content: string) => { this.emit("status", content); }); this.socket.on("drone:log", (data: unknown) => { this.emit("drone:log", data as SocketEvents["drone:log"] extends (data: infer T) => void ? T : never); }); this.socket.on("drone:status", (data: unknown) => { this.emit("drone:status", data as SocketEvents["drone:status"] extends (data: infer T) => void ? T : never); }); this._socket.on("reconnect_attempt", (attempt: number) => { this.emit("reconnect_attempt", attempt); }); this._socket.on("reconnect_failed", () => { this.emit("reconnect_failed"); }); this._socket.on("reconnect", (attempt: number) => { this.emit("reconnect", attempt); }); } disconnect(): void { this.stopSessionHeartbeat(); this.removeVisibilityHandler(); if (this._socket) { this._socket.disconnect(); this._socket = null; this.jwt = null; } } on(event: K, callback: SocketEvents[K]): void { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners .get(event)! .add(callback as (...args: unknown[]) => void); } off(event: K, callback: SocketEvents[K]): void { const listeners = this.eventListeners.get(event); if (listeners) { listeners.delete(callback as (...args: unknown[]) => void); } } emit( event: K, ...args: Parameters ): void { const listeners = this.eventListeners.get(event); if (listeners) { listeners.forEach((callback) => callback(...args)); } } send( event: K, ...args: Parameters ): void { if (this._socket?.connected) { this._socket.emit(event, ...args); } } emitServer( event: K, ...args: Parameters ): void { if (this._socket?.connected) { this._socket.emit(event, ...args); } } submitPrompt( prompt: string, cb: (success: boolean, data: { turnId?: string; message?: string }) => void, ): void { if (this._socket?.connected) { this._socket.emit('submitPrompt', prompt, cb); } else { cb(false, { message: 'Socket not connected' }); } } abortWorkOrder(cb?: (success: boolean, message?: string) => void): void { if (this._socket?.connected) { this._socket.emit('abortWorkOrder', (success: boolean, message?: string) => { cb?.(success, message); }); } else { cb?.(false, 'Socket not connected'); } } requestSessionLock( registration: any, project: any, chatSession: any, ): Promise { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "requestSessionLock", registration, project, chatSession, resolve, ); } else { resolve(false); } }); } requestWorkspaceMode( registration: any, project: any, chatSession: any, mode: string, ): Promise<{ success: boolean; mode: string; reason?: string }> { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "requestWorkspaceMode", registration, project, chatSession, mode, (success: boolean, mode: string, reason?: string) => resolve({ success, mode, reason }), ); } else { resolve({ success: false, mode: "" }); } }); } requestFileTree( args: { path?: string; recursive?: boolean; maxDepth?: number } = {}, ): Promise<{ success: boolean; entries?: any[]; error?: string }> { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "fileTreeRequest", args, (success: boolean, data: { entries?: any[]; error?: string }) => resolve({ success, ...data }), ); } else { resolve({ success: false, error: "Socket not connected" }); } }); } requestFileRead( args: { path: string }, ): Promise<{ success: boolean; content?: string; error?: string }> { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "fileReadRequest", args, (success: boolean, data: { content?: string; error?: string }) => resolve({ success, ...data }), ); } else { resolve({ success: false, error: "Socket not connected" }); } }); } requestFileWrite( args: { path: string; content: string }, ): Promise<{ success: boolean; error?: string }> { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "fileWriteRequest", args, (success: boolean, data: { success?: boolean; error?: string }) => resolve({ success: success, ...data }), ); } else { resolve({ success: false, error: "Socket not connected" }); } }); } releaseSessionLock( registration: any, project: any, chatSession: any, ): Promise { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "releaseSessionLock", registration, project, chatSession, resolve, ); } else { resolve(false); } }); } requestProcessStats( registrationId: string, ): Promise<{ success: boolean; processes?: { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: string; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string }[]; message?: string; }> { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit( "requestProcessStats", registrationId, (success: boolean, data?: { processes?: any[]; message?: string }) => { resolve({ success, processes: data?.processes, message: data?.message }); }, ); } else { resolve({ success: false, message: "Socket not connected" }); } }); } subscribeDrone(registrationId: string): Promise { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit("subscribeDrone", registrationId, resolve); } else { resolve(false); } }); } unsubscribeDrone(registrationId: string): Promise { return new Promise((resolve) => { if (this._socket?.connected) { this._socket.emit("unsubscribeDrone", registrationId, resolve); } else { resolve(false); } }); } /** * Sends a single sessionHeartbeat event to the server (which relays * it to the drone). The drone resets its 120-second timeout timer * on each heartbeat. */ private sendHeartbeat(): void { if (this._socket?.connected) { this._socket.emit("sessionHeartbeat", (ack: boolean) => { if (!ack) { console.warn("sessionHeartbeat: drone did not acknowledge"); } }); } } startSessionHeartbeat(): void { // Already running? if (this.heartbeatWorker || this.heartbeatFallbackInterval) return; if (HeartbeatWorker) { // Use a Web Worker for the heartbeat interval — not subject to // browser tab throttling, which can delay setInterval to 1/min // in background tabs and cause drone heartbeat timeouts. this.heartbeatWorker = new HeartbeatWorker( new URL("../workers/heartbeat.worker.ts", import.meta.url), { type: "module" }, ); this.heartbeatWorker.onmessage = (event: MessageEvent) => { if (event.data?.type === "tick") { this.sendHeartbeat(); } }; this.heartbeatWorker.onerror = (err) => { console.error("heartbeat worker error, falling back to setInterval", err); this.stopSessionHeartbeat(); this.startFallbackHeartbeat(); }; this.heartbeatWorker.postMessage({ type: "start" }); } else { // Fallback: use setInterval (subject to tab throttling) this.startFallbackHeartbeat(); } // Install visibility handler: send an immediate heartbeat when // the tab becomes visible again, to reset the drone's timer // after any throttling that may have occurred. this.installVisibilityHandler(); } private startFallbackHeartbeat(): void { if (this.heartbeatFallbackInterval) return; this.heartbeatFallbackInterval = setInterval(() => { this.sendHeartbeat(); }, 19000); } stopSessionHeartbeat(): void { if (this.heartbeatWorker) { this.heartbeatWorker.postMessage({ type: "stop" }); this.heartbeatWorker.terminate(); this.heartbeatWorker = null; } if (this.heartbeatFallbackInterval) { clearInterval(this.heartbeatFallbackInterval); this.heartbeatFallbackInterval = null; } } private installVisibilityHandler(): void { if (this.boundVisibilityHandler) return; // already installed this.boundVisibilityHandler = () => { if (document.visibilityState === "visible") { // Tab is now visible — send an immediate heartbeat to reset // the drone's timeout timer, which may have been starved // while the tab was backgrounded and timers were throttled. this.sendHeartbeat(); } }; document.addEventListener("visibilitychange", this.boundVisibilityHandler); } private removeVisibilityHandler(): void { if (this.boundVisibilityHandler) { document.removeEventListener("visibilitychange", this.boundVisibilityHandler); this.boundVisibilityHandler = null; } } /** * Update the stored JWT used for socket authentication. * Called after a successful token renewal so that future socket * reconnections use the new token instead of the expired one. */ updateJwt(newToken: string): void { this.jwt = newToken; if (this._socket) { this._socket.auth = { ...this._socket.auth, token: newToken }; } } } export const socketClient = new SocketClient(); export const SocketContext = createContext(null);