// node-side client for the daemon's agent:* ws protocol. // // used by electron main (translates IPC → daemon) and by the CLI // `sootsim agent …` subcommands. the browser shell has its own shim that // uses window.WebSocket; this file is node-only. // // single long-lived connection per client. if the daemon goes away, the // client surfaces the disconnect to callers via pending rejections and // `onDisconnect`, and the caller can rebuild. auto-reconnect is not built // in — the two callers (electron main, CLI) each have their own // lifecycle and decide when to reconnect. import { spawn } from 'node:child_process' import net from 'node:net' import { WebSocket } from 'ws' import { resolveSootsimInvocation, type Provider } from './agent-sessions.ts' import { DEFAULT_SOOTSIM_BRIDGE_PORT } from './bridge-constants.ts' import { shouldSkipPersistentDaemon } from './home-paths.ts' import type { AgentEvent } from './agent-events.ts' import type { AgentPromptEnvelope } from './agent-prompt.ts' import type { AgentSession, AttachedProject } from './attached-projects.ts' export interface AgentDaemonClientOptions { port?: number commandTimeoutMs?: number /** label forwarded in logs; helps identify the daemon caller. */ clientLabel?: string } export interface AgentDaemonPaths { userDataDir: string storeFile: string sessionsDir: string transcriptsDir: string } export interface AgentStartSessionInput { projectId: string provider?: Provider codexBin?: string claudeBin?: string freshThread?: boolean } export interface AgentStartSessionResult { session: AgentSession wrapperPid: number } export interface AgentUpsertProjectInput { cwd: string name?: string preferredProvider?: Provider sourceRoots?: string[] knownBundleUrls?: string[] framework?: 'expo' | 'one' | 'rock' | 'unknown' bundleId?: string } export interface AgentAutoAttachInput { bundleUrl: string provider?: Provider } export class AgentDaemonError extends Error { code?: string constructor(message: string, code?: string) { super(message) this.name = 'AgentDaemonError' this.code = code } } type PendingEntry = { resolve: (value: unknown) => void reject: (err: Error) => void timer: ReturnType } type EventCallback = (payload: { sessionId: string; event: AgentEvent }) => void type StatusCallback = (session: AgentSession) => void export class AgentDaemonClient { private ws: WebSocket private port: number private commandTimeoutMs: number private ready: Promise private closed = false private nextId = 1 private pending = new Map() private eventListeners = new Set() private statusListeners = new Set() private disconnectListeners = new Set<() => void>() constructor(opts: AgentDaemonClientOptions = {}) { this.port = opts.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT this.commandTimeoutMs = opts.commandTimeoutMs ?? 15_000 this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`) this.ready = new Promise((resolve, reject) => { const onOpen = () => { this.ws.off('error', onError) resolve() } const onError = (err: Error) => { this.ws.off('open', onOpen) reject( new AgentDaemonError( `could not connect to rnx daemon on port ${this.port}: ${err.message}`, 'NO_DAEMON', ), ) } this.ws.once('open', onOpen) this.ws.once('error', onError) }) this.ws.on('message', (data) => this.handleMessage(data)) this.ws.on('close', () => this.handleClose()) // ws emits 'error' for late socket faults (peer dropped, write EIO after // sleep/wake, etc). without a listener, node's EventEmitter rethrows as // uncaughtException. 'close' fires right after and runs the cleanup. this.ws.on('error', () => {}) } async waitReady(): Promise { return this.ready } // --- public api --- async listProjects(): Promise { return this.send('agent:list-projects') } async upsertProject(input: AgentUpsertProjectInput): Promise { return this.send('agent:upsert-project', { input }) } async deleteProject(projectId: string): Promise<{ ok: true }> { return this.send<{ ok: true }>('agent:delete-project', { projectId }) } async autoAttachForUrl( input: AgentAutoAttachInput, ): Promise<{ project: AttachedProject | null }> { return this.send<{ project: AttachedProject | null }>('agent:auto-attach-for-url', { input, }) } async listSessions(projectId?: string): Promise { return this.send('agent:list-sessions', { projectId }) } async startSession(input: AgentStartSessionInput): Promise { return this.send('agent:start-session', { input }) } async sendPrompt( sessionId: string, prompt: AgentPromptEnvelope, ): Promise<{ ok: true }> { return this.send<{ ok: true }>('agent:send-prompt', { sessionId, prompt, }) } async sendClaimedPrompt( simId: string, prompt: AgentPromptEnvelope, ): Promise<{ ok: true; routed: 'team-machine'; sessionId: string }> { return this.send<{ ok: true; routed: 'team-machine'; sessionId: string }>( 'agent:send-claimed-prompt', { simId, prompt, }, ) } async endSession(sessionId: string): Promise<{ ok: true }> { return this.send<{ ok: true }>('agent:end-session', { sessionId }) } async getTranscript( sessionId: string, ): Promise { return this.send('agent:get-transcript', { sessionId, }) } async getPaths(): Promise { return this.send('agent:get-paths') } async subscribeEvents(sessionId: string): Promise<{ ok: true; refCount: number }> { return this.send<{ ok: true; refCount: number }>('agent:subscribe-events', { sessionId, }) } async unsubscribeEvents(sessionId: string): Promise<{ ok: true; refCount: number }> { return this.send<{ ok: true; refCount: number }>('agent:unsubscribe-events', { sessionId, }) } onAgentEvent(cb: EventCallback): () => void { this.eventListeners.add(cb) return () => this.eventListeners.delete(cb) } onSessionStatusChange(cb: StatusCallback): () => void { this.statusListeners.add(cb) return () => this.statusListeners.delete(cb) } onDisconnect(cb: () => void): () => void { this.disconnectListeners.add(cb) return () => this.disconnectListeners.delete(cb) } close(): void { if (this.closed) return this.closed = true try { this.ws.close() } catch {} } // --- internals --- private async send(type: string, payload: Record = {}): Promise { await this.ready if (this.closed || this.ws.readyState !== WebSocket.OPEN) { throw new AgentDaemonError('daemon connection is closed', 'NO_DAEMON') } const id = this.nextId++ return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id) reject( new AgentDaemonError( `${type} timed out after ${Math.round(this.commandTimeoutMs / 1000)}s`, 'TIMEOUT', ), ) }, this.commandTimeoutMs) this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer, }) try { this.ws.send(JSON.stringify({ id, type, ...payload })) } catch (err) { clearTimeout(timer) this.pending.delete(id) reject(err instanceof Error ? err : new Error(String(err))) } }) } private handleMessage(data: unknown): void { let msg: any try { msg = JSON.parse(String(data)) } catch { return } if (!msg || typeof msg !== 'object') return if (msg.type === 'agent:event') { for (const cb of this.eventListeners) { try { cb({ sessionId: msg.sessionId, event: msg.event }) } catch {} } return } if (msg.type === 'agent:session-status') { for (const cb of this.statusListeners) { try { cb(msg.session) } catch {} } return } // non-agent server pushes (bridge:welcome, bridge:client-state, …) are // harmless — we just ignore them. we only opened this socket for the // agent protocol. if (typeof msg.id !== 'number') return const entry = this.pending.get(msg.id) if (!entry) return this.pending.delete(msg.id) clearTimeout(entry.timer) if (msg.error) { entry.reject(new AgentDaemonError(msg.error, msg.code)) } else { entry.resolve(msg.result) } } private handleClose(): void { if (this.closed) return this.closed = true for (const [, entry] of this.pending) { clearTimeout(entry.timer) entry.reject(new AgentDaemonError('daemon disconnected', 'DISCONNECT')) } this.pending.clear() for (const cb of this.disconnectListeners) { try { cb() } catch {} } } } // --- daemon lifecycle helpers --- /** raw TCP probe — matches the shape electron main.ts already uses. */ export function isBridgeUp( port: number = DEFAULT_SOOTSIM_BRIDGE_PORT, timeoutMs = 400, ): Promise { return new Promise((resolve) => { const socket = new net.Socket() let settled = false const done = (ok: boolean) => { if (settled) return settled = true socket.destroy() resolve(ok) } socket.setTimeout(timeoutMs) socket.once('connect', () => done(true)) socket.once('timeout', () => done(false)) socket.once('error', () => done(false)) socket.connect(port, '127.0.0.1') }) } export interface EnsureDaemonOptions { port?: number /** how long to wait for the spawned daemon to start listening before * giving up. defaults to 5s, which is generous for a local process * spawn even on cold hw. */ startupTimeoutMs?: number } /** returns { alreadyRunning: true } if something was already bound to the * port, or { alreadyRunning: false, pid } if we spawned a daemon. throws * if a spawn attempt fails to reach a ready state in time. */ export async function ensureDaemonRunning( opts: EnsureDaemonOptions = {}, ): Promise<{ alreadyRunning: boolean; pid?: number }> { const port = opts.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT if (await isBridgeUp(port)) { return { alreadyRunning: true } } if (shouldSkipPersistentDaemon()) { throw new AgentDaemonError( `no rnx bridge on port ${port}. run \`rnx daemon install\` to enable the faster background service, or \`rnx serve\` in another shell.`, 'DEV_HOST_REFUSED', ) } const { executable, prefixArgs } = resolveSootsimInvocation() const args = [...prefixArgs, 'serve', '--quiet'] if (port !== DEFAULT_SOOTSIM_BRIDGE_PORT) { args.push('--port', String(port)) } const child = spawn(executable, args, { detached: true, stdio: 'ignore', env: process.env, cwd: process.cwd(), }) child.unref() // poll with a modest budget. the daemon binds synchronously once its // event loop is alive, so first-connection usually lands well under 1s. const deadline = Date.now() + (opts.startupTimeoutMs ?? 5_000) while (Date.now() < deadline) { if (await isBridgeUp(port)) return { alreadyRunning: false, pid: child.pid } await new Promise((r) => setTimeout(r, 100)) } throw new AgentDaemonError( `spawned rnx daemon on port ${port} but it did not come up in time. ` + `run \`rnx serve\` manually to diagnose.`, 'SPAWN_TIMEOUT', ) } /** convenience: ensureDaemon + open a client + wait until ready. */ export async function connectToDaemon( opts: AgentDaemonClientOptions & EnsureDaemonOptions = {}, ): Promise { await ensureDaemonRunning({ port: opts.port, startupTimeoutMs: opts.startupTimeoutMs, }) const client = new AgentDaemonClient(opts) try { await client.waitReady() } catch (err) { client.close() throw err } return client }