// agent-wrapper — long-running bridge process between rnx desktop and a // local coding agent (codex / claude). // // shipped as an internal rnx CLI subcommand so it survives `bun build // --compile` bundling. callers spawn it as: // // rnx agent-wrapper \ // --session-id --project-id --provider codex \ // --cwd --prompt-in --events-out \ // [--transcript ] // // protocol: // prompt.in — newline-delimited prompt text from electron / the CLI. // events.out — newline-delimited JSON events (see `sootsim/agent-events`). // transcript — append-only raw stdout/stderr of the spawned agent turn. // // stage 7 scope (codex): replaced the per-turn `codex exec` spawn with a // persistent `codex app-server` JSON-RPC child. one thread/start per session // (or thread/resume if we find a prior thread for the cwd), one turn/start // per prompt. notifications are translated into structured AgentEvents with // real filesTouched, file-diff deltas, and plan updates. // // claude still uses the stage 3 per-turn spawn via `claude -p`; stage 8 // replaces it with `--output-format stream-json --verbose --resume`. import { execFile, spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { constants as fsConstants, createReadStream, createWriteStream, existsSync, openSync, } from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import readline from 'node:readline' import { promisify } from 'node:util' import { decodeAgentPromptEnvelope } from '../../src/agent-prompt' import { spawnCodexClient, type CodexClient } from '../../src/codex-client' import type { AgentEvent } from '../../src/agent-events' const CODEX_APPROVAL_POLICY = 'never' const CODEX_THREAD_SANDBOX = 'danger-full-access' const CODEX_TURN_SANDBOX_POLICY = { type: 'dangerFullAccess' } as const const CODEX_MODEL = 'gpt-5.6-luna' const CODEX_SERVICE_TIER = 'fast' as const // prompt-bar turns are small targeted edits, so both providers think briefly. // long-horizon callers (the one-take film) raise it through the env; the // take's stack launch sets SOOTSIM_AGENT_EFFORT, never the repo. const AGENT_REASONING_EFFORT = process.env.SOOTSIM_AGENT_EFFORT?.trim() || ('low' as const) const CODEX_READY_TIMEOUT_MS = 5_000 const CODEX_THREAD_SETUP_TIMEOUT_MS = 10_000 const execFileP = promisify(execFile) async function resolveAgentBinary( provider: 'codex' | 'claude', override: string | undefined, ): Promise<{ ok: true; path: string } | { ok: false; message: string }> { const bin = override || provider // absolute or relative path — check directly if (bin.includes('/') || bin.includes('\\')) { if (existsSync(bin)) return { ok: true, path: bin } return { ok: false, message: `agent binary not found at path: ${bin}` } } try { const { stdout } = await execFileP('which', [bin], { timeout: 1500 }) const resolved = stdout.trim() if (resolved) return { ok: true, path: resolved } return { ok: false, message: `${bin} not found on PATH` } } catch { return { ok: false, message: `${bin} not found on PATH. install the ${provider} CLI and retry, or pass ` + `--${provider}-bin /path/to/${provider} to agent start.`, } } } interface WrapperArgs { sessionId?: string projectId?: string provider?: string cwd?: string promptIn?: string eventsOut?: string transcript?: string codexBin?: string claudeBin?: string claudeSessionUuid?: string freshThread?: boolean } interface KillableChildProcess { kill(signal?: 'SIGTERM'): boolean } interface AgentTranscriptStream { write(chunk: string): unknown end(): unknown } function parseWrapperArgs(argv: string[]): WrapperArgs { const map: Record = { '--session-id': 'sessionId', '--project-id': 'projectId', '--provider': 'provider', '--cwd': 'cwd', '--prompt-in': 'promptIn', '--events-out': 'eventsOut', '--transcript': 'transcript', '--codex-bin': 'codexBin', '--claude-bin': 'claudeBin', '--claude-session-uuid': 'claudeSessionUuid', } const out: WrapperArgs = {} for (let i = 0; i < argv.length; i++) { const flag = argv[i] if (flag === '--fresh-thread') { out.freshThread = true continue } const key = map[flag] if (!key) continue out[key] = argv[i + 1] as never i++ } return out } /** drives one prompt end-to-end. emits turn-started on entry and exactly one * terminal event (turn-completed OR error) before resolving. never throws. */ type TurnRunner = (text: string) => Promise export async function runAgentWrapper(argv: string[]): Promise { const args = parseWrapperArgs(argv) if ( !args.sessionId || !args.projectId || !args.provider || !args.cwd || !args.promptIn || !args.eventsOut ) { process.stderr.write( 'usage: rnx agent-wrapper --session-id --project-id \n' + ' --provider codex|claude --cwd \n' + ' --prompt-in --events-out \n' + ' [--transcript ]\n' + ' [--codex-bin ] [--claude-bin ]\n', ) return 2 } if (args.provider !== 'codex' && args.provider !== 'claude') { process.stderr.write(`unknown provider: ${args.provider}\n`) return 2 } const { sessionId, projectId, cwd, promptIn, eventsOut } = args const provider = args.provider as 'codex' | 'claude' // keep the events.out write end open for the whole process lifetime. opening // and closing per event would SIGPIPE briefly between writers. // // IMPORTANT: open with O_RDWR (not O_WRONLY/'a') so the wrapper itself // holds a reader fd on the FIFO. without this, the bootstrap reader // (startSession's waitForFirstEvent) attaches briefly to consume `ready`, // then closes — leaving the wrapper's O_WRONLY fd as the only handle on // a reader-less FIFO. the next write (prompt-received / turn-started / // turn-message …) triggers SIGPIPE / EPIPE and the wrapper dies silently // before any client ws subscription lands. O_RDWR makes the FIFO appear // to always have a reader, so writes buffer in the kernel until the // daemon attaches to drain. if (!existsSync(eventsOut)) { process.stderr.write(`events-out FIFO missing: ${eventsOut}\n`) return 3 } if (!existsSync(promptIn)) { process.stderr.write(`prompt-in FIFO missing: ${promptIn}\n`) return 3 } const eventsFd = openSync(eventsOut, fsConstants.O_RDWR | fsConstants.O_APPEND) const eventsStream = createWriteStream(eventsOut, { fd: eventsFd, flags: 'a' }) function emit(event: Partial & { type: AgentEvent['type'] }): void { const line = JSON.stringify({ ts: Date.now(), ...event }) + '\n' eventsStream.write(line) } // transcript is optional; append mode so multiple turns accumulate. let transcriptStream: AgentTranscriptStream | null = null if (args.transcript) { try { await fs.mkdir(path.dirname(args.transcript), { recursive: true }) transcriptStream = createWriteStream(args.transcript, { flags: 'a' }) } catch (err) { emit({ type: 'error', message: `transcript open failed: ${(err as Error).message}`, } as AgentEvent) } } // defensive: an EPIPE from a stale reader would otherwise bubble to // uncaughtException and tear the wrapper down mid-turn. the O_RDWR open // above prevents the common case, but a rogue close on the read side // can still briefly surface here. log it and move on — the FIFO // self-heals on the next reader attach. eventsStream.on('error', (err) => { const message = `[events-out] stream error: ${(err as Error).message}\n` try { if (transcriptStream) transcriptStream.write(message) else process.stderr.write(message) } catch {} }) // preflight: resolve the agent binary once up front. emitting ready without // this check means the first `agent prompt` silently completes with an empty // turn-completed — users think "the agent said nothing" instead of "codex // isn't installed." const bin = await resolveAgentBinary( provider, provider === 'codex' ? args.codexBin : args.claudeBin, ) if (!bin.ok) { emit({ type: 'error', message: bin.message } as AgentEvent) // give the reader a moment to drain before we exit await new Promise((r) => setTimeout(r, 50)) eventsStream.end() return 4 } const resolvedAgentBin = bin.path // for codex: boot the persistent app-server now. any startup failure is a // clean error event before we advertise ready. let codexClient: CodexClient | null = null let codexThreadId: string | null = null if (provider === 'codex') { try { codexClient = spawnCodexClient({ bin: resolvedAgentBin, cwd, env: { RNX_CLI_IDENTITY: sessionId, SOOTSIM_PROJECT_ID: projectId, }, }) // surface stderr into the transcript without cluttering events. codexClient.on('__stderr__', (p) => { const text = (p as { text?: string })?.text if (text) transcriptStream?.write(`[codex-stderr] ${text}`) }) // capture async errors so they don't kill the wrapper silently. void codexClient.exited.then(({ code, signal }) => { if (!shuttingDown) { emit({ type: 'error', message: `codex app-server exited (code=${code}, signal=${signal ?? ''})`, } as AgentEvent) shutdown(5) } }) // only app-server initialization gates ready. thread lookup can take // several seconds on a healthy codex install, so the first prompt owns // that work below instead of making session startup wait for it. await withTimeout( codexClient.request('initialize', { clientInfo: { name: 'sootsim', title: null, version: '0.1.0' }, capabilities: null, }), CODEX_READY_TIMEOUT_MS, `codex app-server initialize timed out after ${CODEX_READY_TIMEOUT_MS}ms`, ) } catch (err) { const message = err instanceof Error ? err.message : String(err) emit({ type: 'error', message: `codex app-server init failed: ${message}`, } as AgentEvent) try { codexClient?.kill() } catch {} eventsStream.end() return 4 } } // serialize turn execution per-session — queue prompts while one is in // flight. both codex and claude serialize per-session internally, but // gating here keeps wrapper event ordering honest. let turnInFlight = false let currentClaudeChild: KillableChildProcess | null = null const pendingPrompts: string[] = [] const { promise: exitPromise, resolve: resolveExit } = createDeferred() let shuttingDown = false function shutdown(code: number, emitExit = true): void { if (shuttingDown) return shuttingDown = true clearInterval(orphanWatchdog) try { currentClaudeChild?.kill('SIGTERM') } catch {} try { void codexClient?.shutdown(800) } catch {} if (emitExit) emit({ type: 'exited', code } as AgentEvent) eventsStream.end(() => { try { transcriptStream?.end() } catch {} resolveExit(code) }) ;(setTimeout(() => resolveExit(code), 1500) as unknown as NodeJS.Timeout).unref() } // orphan watchdog: if our session dir / FIFOs are deleted out from under // us (test cleanup did `rm -rf tmpdir` without calling endSession, the // daemon crashed, the user force-nuked app-data) we can't be reached by // anyone anyway — stop holding fds and spinning. poll every 10s to keep // cost negligible; tests override via SOOTSIM_WRAPPER_WATCHDOG_MS. const watchdogMs = (() => { const raw = Number(process.env.SOOTSIM_WRAPPER_WATCHDOG_MS) return Number.isFinite(raw) && raw > 0 ? raw : 10_000 })() const orphanWatchdog = setInterval(() => { if (shuttingDown) return const eventsOutExists = existsSync(eventsOut) const promptInExists = existsSync(promptIn) if (!eventsOutExists || !promptInExists) { process.stderr.write( `[wrapper] session fifos gone (eventsOut=${eventsOutExists} promptIn=${promptInExists}), exiting\n`, ) // once events.out is gone there is no recipient for an exit event. a // final write only introduces a broken-pipe race into clean orphan // shutdown; normal exits and prompt-only loss still report over the wire. shutdown(0, eventsOutExists) } }, watchdogMs) ;(orphanWatchdog as unknown as NodeJS.Timeout).unref() process.on('SIGTERM', () => shutdown(143)) process.on('SIGINT', () => shutdown(130)) // detached: true takes us out of the parent's process group, but SIGHUP // can still propagate in edge cases (e.g. crashed parent terminal). exit // cleanly instead of being killed uncleanly with no `exited` event. process.on('SIGHUP', () => shutdown(129)) process.on('uncaughtException', (err) => { emit({ type: 'error', message: `uncaught: ${(err as Error).message}`, } as AgentEvent) shutdown(1) }) // build the per-turn runner for whichever provider this is. const runTurn: TurnRunner = provider === 'codex' && codexClient ? buildCodexTurnRunner({ client: codexClient, resolveThreadId: async () => { if (codexThreadId) return codexThreadId codexThreadId = await withTimeout( resolveCodexThread(codexClient, cwd, { fresh: args.freshThread === true, }), CODEX_THREAD_SETUP_TIMEOUT_MS, `codex thread setup timed out after ${CODEX_THREAD_SETUP_TIMEOUT_MS}ms`, ) return codexThreadId }, emit, transcript: transcriptStream, onTurnSettled: () => { turnInFlight = false const next = pendingPrompts.shift() if (next) void invokeTurn(next) }, }) : buildClaudeTurnRunner({ resolvedBin: resolvedAgentBin, sessionId, projectId, // prefer the uuid persisted on the AgentSession (passed in by // startSession so `~/.claude/projects//.jsonl` is // reused across wrapper restarts). fall back to a fresh uuid for // standalone invocations (tests, ad-hoc debugging). claudeSessionUuid: args.claudeSessionUuid ?? randomUUID(), cwd, emit, transcript: transcriptStream, setChild: (c) => (currentClaudeChild = c), onTurnSettled: () => { turnInFlight = false const next = pendingPrompts.shift() if (next) void invokeTurn(next) }, }) async function invokeTurn(text: string): Promise { turnInFlight = true try { await runTurn(text) } catch (err) { emit({ type: 'error', message: `turn failed: ${(err as Error).message}`, } as AgentEvent) turnInFlight = false const next = pendingPrompts.shift() if (next) void invokeTurn(next) } } // open prompt.in with O_RDWR so our own write end keeps the FIFO alive // across external writer open/close cycles. `rnx agent prompt` opens, // writes one line, closes — without a self-writer the reader would EOF // after the first prompt and subsequent prompts would be lost. const promptFd = openSync(promptIn, fsConstants.O_RDWR) const promptStream = createReadStream('', { fd: promptFd, autoClose: false }) const rl = readline.createInterface({ input: promptStream, crlfDelay: Infinity }) rl.on('line', (rawLine) => { const prompt = decodeAgentPromptEnvelope(rawLine) if (!prompt) return const text = prompt.text emit({ type: 'prompt-received', text: prompt.displayText ?? text, ...(prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {}), ...(prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {}), } as AgentEvent) if (turnInFlight) { pendingPrompts.push(text) return } void invokeTurn(text) }) promptStream.on('error', (err) => { emit({ type: 'error', message: `prompt-in read failed: ${err.message}`, } as AgentEvent) }) // readiness means the prompt channel is open and orphan handling is armed. // callers may act on this event immediately, including deleting the session. emit({ type: 'ready', sessionId, projectId, provider, cwd, } as AgentEvent) return exitPromise } // --- codex app-server integration --- export function extractCodexThreadId( started: { threadId?: string; thread?: { id?: string } } | undefined, ): string | null { return started?.threadId ?? started?.thread?.id ?? null } export function extractCodexThreadListId(response: unknown): string | null { if (typeof response !== 'object' || response === null) return null const data = Reflect.get(response, 'data') if (!Array.isArray(data)) return null const first = data[0] if (typeof first !== 'object' || first === null) return null const id = Reflect.get(first, 'id') return typeof id === 'string' && id.length > 0 ? id : null } async function resolveCodexThread( client: CodexClient, cwd: string, opts: { fresh?: boolean } = {}, ): Promise { // try to reuse the most recent active thread for this cwd. if anything // goes wrong, or the caller requested a fresh thread, use thread/start. if (!opts.fresh) { try { const res = await client.request('thread/list', { cwd, limit: 1, archived: false, }) const existing = extractCodexThreadListId(res) if (existing) { try { await client.request('thread/resume', { threadId: existing, // codex 0.121 rejects `persistExtendedHistory: true` with "requires // experimentalApi capability". we don't need extended rollouts for // v1 — thread history on resume is already sufficient. persistExtendedHistory: false, approvalPolicy: CODEX_APPROVAL_POLICY, sandbox: CODEX_THREAD_SANDBOX, model: CODEX_MODEL, serviceTier: CODEX_SERVICE_TIER, }) return existing } catch { // fall through to start a fresh thread } } } catch { // thread/list not available in this codex build; just start a new one. } } const started = (await client.request('thread/start', { cwd, experimentalRawEvents: false, // codex 0.121 rejects `persistExtendedHistory: true` without the // `experimentalApi` capability (which we don't opt into). extended // rollouts aren't needed for v1 — the default short history is fine. persistExtendedHistory: false, approvalPolicy: CODEX_APPROVAL_POLICY, sandbox: CODEX_THREAD_SANDBOX, model: CODEX_MODEL, serviceTier: CODEX_SERVICE_TIER, })) as { threadId?: string; thread?: { id?: string } } | undefined const threadId = extractCodexThreadId(started) if (!threadId) { throw new Error('thread/start returned no threadId') } return threadId } interface CodexTurnDeps { client: CodexClient resolveThreadId: () => Promise emit: (event: Partial & { type: AgentEvent['type'] }) => void transcript: AgentTranscriptStream | null onTurnSettled: () => void } function buildCodexTurnRunner(deps: CodexTurnDeps): TurnRunner { const { client, emit, transcript } = deps // install notification handlers once; they feed the per-turn state via a // module-local map keyed by turnId. a new turn clears its bucket on entry. interface TurnState { started: number resolve: () => void filesTouched: Set pendingEmit: string } const turns = new Map() let latestTurnId: string | null = null client.on('turn/started', (params) => { const p = params as { threadId?: string; turn?: { id?: string; startedAt?: number } } const turnId = p?.turn?.id if (!turnId) return latestTurnId = turnId const state = turns.get(turnId) ?? { started: Date.now(), resolve: () => {}, filesTouched: new Set(), pendingEmit: '', } if (!turns.has(turnId)) turns.set(turnId, state) emit({ type: 'turn-started', turnId } as AgentEvent) }) client.on('item/agentMessage/delta', (params) => { const p = params as { delta?: string } if (!p?.delta) return transcript?.write(p.delta) emit({ type: 'turn-message', delta: p.delta } as AgentEvent) }) client.on('item/reasoning/textDelta', (params) => { const p = params as { delta?: string } if (!p?.delta) return emit({ type: 'turn-reasoning', delta: p.delta } as AgentEvent) }) client.on('item/reasoning/summaryTextDelta', (params) => { const p = params as { delta?: string } if (!p?.delta) return emit({ type: 'turn-reasoning', delta: p.delta } as AgentEvent) }) // intentionally NOT subscribing to `turn/diff/updated` — the notification // carries an aggregated unified diff for the whole turn with no per-file // attribution, which would force consumers (HMR halo) to key on an empty // path. `item/completed` with type=fileChange delivers the same information // per-file, so we rely on that instead. client.on('turn/plan/updated', (params) => { const p = params as { plan?: Array<{ step: string; status: string }> turnId?: string } if (!Array.isArray(p?.plan)) return emit({ type: 'turn-plan', steps: p.plan.map((s, i) => ({ id: String(i), title: s.step, status: s.status, })), } as AgentEvent) }) client.on('item/started', (params) => { const p = params as { item?: { type?: string; command?: string; tool?: string } turnId?: string } const item = p?.item if (!item) return if (item.type === 'commandExecution' && item.command) { emit({ type: 'tool-call', name: 'commandExecution', args: { command: item.command }, } as AgentEvent) } else if (item.type === 'mcpToolCall' || item.type === 'dynamicToolCall') { emit({ type: 'tool-call', name: item.tool ?? item.type, args: {}, } as AgentEvent) } }) client.on('item/completed', (params) => { const p = params as { item?: { type?: string status?: string changes?: Array<{ path: string kind: { type?: string; move_path?: string | null } diff?: string }> } turnId?: string } const item = p?.item if (!item) return if (item.type !== 'fileChange') return if (item.status !== 'completed') return if (!Array.isArray(item.changes)) return const turnId = p?.turnId const state = turnId ? turns.get(turnId) : null for (const change of item.changes) { const kindType = change.kind?.type const kind: 'add' | 'modify' | 'delete' = kindType === 'add' ? 'add' : kindType === 'delete' ? 'delete' : 'modify' emit({ type: 'file-edited', path: change.path, kind, diff: change.diff, } as AgentEvent) state?.filesTouched.add(change.path) // a rename (`update` with move_path) touches two files — track both so // HMR halo can highlight the new path too. const movePath = change.kind?.move_path if (movePath && movePath !== change.path) { state?.filesTouched.add(movePath) } } }) client.on('turn/completed', (params) => { const p = params as { threadId?: string turn?: { id?: string status?: string durationMs?: number | null startedAt?: number | null } } const turnId = p?.turn?.id if (!turnId) return const state = turns.get(turnId) const filesTouched = state ? Array.from(state.filesTouched) : [] const durationMs = p.turn?.durationMs ?? (state ? Date.now() - state.started : 0) emit({ type: 'turn-completed', turnId, filesTouched, durationMs, } as AgentEvent) state?.resolve() turns.delete(turnId) }) client.on('error', (params) => { const p = params as { error?: { message?: string } turnId?: string } const msg = p?.error?.message ?? 'codex reported an error' emit({ type: 'error', message: msg } as AgentEvent) if (p.turnId) { const state = turns.get(p.turnId) state?.resolve() turns.delete(p.turnId) } }) return async function codexRunTurn(text: string): Promise { let threadId: string try { threadId = await deps.resolveThreadId() } catch (err) { const message = err instanceof Error ? err.message : String(err) emit({ type: 'error', message: `codex thread setup failed: ${message}`, } as AgentEvent) emit({ type: 'turn-completed', filesTouched: [] as string[], durationMs: 0, }) deps.onTurnSettled() return } const { promise, resolve } = createDeferred() // we don't know the turnId until turn/started arrives. register a // placeholder and patch it up in the notification handler. const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` turns.set(pendingId, { started: Date.now(), resolve, filesTouched: new Set(), pendingEmit: '', }) try { const res = (await client.request('turn/start', { threadId, input: [{ type: 'text', text, text_elements: [] }], approvalPolicy: CODEX_APPROVAL_POLICY, sandboxPolicy: CODEX_TURN_SANDBOX_POLICY, model: CODEX_MODEL, serviceTier: CODEX_SERVICE_TIER, effort: AGENT_REASONING_EFFORT, })) as { turn?: { id?: string } } | undefined const actualTurnId = res?.turn?.id ?? latestTurnId if (actualTurnId && actualTurnId !== pendingId) { // hoist the placeholder bucket onto the real turnId so future // notifications accumulate into it. const placeholder = turns.get(pendingId) if (placeholder) { turns.delete(pendingId) const existing = turns.get(actualTurnId) if (existing) { for (const f of placeholder.filesTouched) existing.filesTouched.add(f) existing.resolve = placeholder.resolve } else { turns.set(actualTurnId, placeholder) } } } } catch (err) { turns.delete(pendingId) const message = err instanceof Error ? err.message : String(err) emit({ type: 'error', message: `turn/start failed: ${message}` } as AgentEvent) emit({ type: 'turn-completed', filesTouched: [] as string[], durationMs: 0, }) deps.onTurnSettled() return } // safety timeout so a lost turn/completed notification doesn't wedge // the wrapper forever. 10min is far beyond normal codex turn duration. const timeout = ( setTimeout(() => resolve(), 10 * 60 * 1000) as unknown as NodeJS.Timeout ).unref() try { await promise } finally { clearTimeout(timeout) deps.onTurnSettled() } } } // --- claude per-turn spawn via --output-format stream-json --- // // each prompt spawns a fresh `claude --print --output-format stream-json // --verbose --permission-mode bypassPermissions --effort low --session-id ` // process. // stdout is a newline-delimited stream of well-typed objects; we translate // them into our AgentEvent wire format. the deterministic session uuid means // successive spawns all land in the same `~/.claude/projects//.jsonl` // file, so turn-over-turn state is preserved without a long-lived child. interface ClaudeStreamEvent { type?: string subtype?: string message?: { content?: Array<{ type?: string text?: string name?: string id?: string input?: { file_path?: string; path?: string; [k: string]: unknown } }> } duration_ms?: number total_cost_usd?: number result?: string is_error?: boolean permission_denials?: Array<{ tool_name?: string; tool_input?: unknown }> session_id?: string } const CLAUDE_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']) interface ClaudeTurnDeps { resolvedBin: string sessionId: string projectId: string claudeSessionUuid: string cwd: string emit: (event: Partial & { type: AgentEvent['type'] }) => void transcript: AgentTranscriptStream | null setChild: (child: KillableChildProcess | null) => void onTurnSettled: () => void } function buildClaudeTurnRunner(deps: ClaudeTurnDeps): TurnRunner { return async function claudeRunTurn(text: string): Promise { const { resolvedBin, sessionId, projectId, claudeSessionUuid, cwd, emit, transcript, setChild, } = deps const startedAt = Date.now() emit({ type: 'turn-started' } as AgentEvent) // the agent runs as the user, on the user's claude login. the host app's // own api key must not become the agent's credential. const { ANTHROPIC_API_KEY: _hostApiKey, ...claudeEnv } = process.env // `--session-id` creates the transcript and refuses a uuid that already // has one, so every turn after the first (including the first turn of a // restarted wrapper) resumes it instead. const transcriptPath = path.join( os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'), `${claudeSessionUuid}.jsonl`, ) const sessionFlag = existsSync(transcriptPath) ? '--resume' : '--session-id' const child = spawn( resolvedBin, [ '--print', '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions', '--effort', AGENT_REASONING_EFFORT, sessionFlag, claudeSessionUuid, text, ], { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...claudeEnv, RNX_CLI_IDENTITY: sessionId, SOOTSIM_PROJECT_ID: projectId, }, }, ) setChild(child) const filesTouched = new Set() let completedEmitted = false function processEvent(ev: ClaudeStreamEvent): void { const type = ev.type if (!type) return if (type === 'assistant' && Array.isArray(ev.message?.content)) { for (const item of ev.message!.content!) { if (item.type === 'text' && item.text) { transcript?.write(item.text) emit({ type: 'turn-message', delta: item.text } as AgentEvent) } else if (item.type === 'tool_use' && item.name) { emit({ type: 'tool-call', name: item.name, args: item.input ?? {}, } as AgentEvent) const filePath = item.input?.file_path ?? item.input?.path if (typeof filePath === 'string' && CLAUDE_FILE_TOOLS.has(item.name)) { filesTouched.add(filePath) emit({ type: 'file-edited', path: filePath, kind: 'modify', } as AgentEvent) } } } } else if (type === 'result') { completedEmitted = true if (Array.isArray(ev.permission_denials)) { for (const denial of ev.permission_denials) { emit({ type: 'approval-needed', kind: denial.tool_name ?? 'unknown', detail: denial.tool_input ?? null, } as AgentEvent) } } if (ev.is_error && typeof ev.result === 'string') { emit({ type: 'error', message: ev.result } as AgentEvent) } emit({ type: 'turn-completed', filesTouched: Array.from(filesTouched), durationMs: ev.duration_ms ?? Date.now() - startedAt, // suppress cost on failed turns so the 7-day rolling history in // `recordTurnTelemetry` reflects work the agent actually did, not // rate-limited / user-aborted attempts. costUsd: ev.is_error ? undefined : ev.total_cost_usd, } as AgentEvent) } } // parse stdout line-by-line; claude emits one JSON object per line in // stream-json mode, but chunks can split mid-line so we buffer. let leftover = '' child.stdout?.setEncoding('utf8') child.stdout?.on('data', (chunk: string) => { leftover += chunk let idx: number while ((idx = leftover.indexOf('\n')) >= 0) { const line = leftover.slice(0, idx) leftover = leftover.slice(idx + 1) const trimmed = line.trim() if (!trimmed) continue let parsed: ClaudeStreamEvent try { parsed = JSON.parse(trimmed) as ClaudeStreamEvent } catch { // non-JSON noise (e.g. pre-init stderr crossover) — skip. continue } try { processEvent(parsed) } catch (err) { emit({ type: 'error', message: `claude event handler failed: ${(err as Error).message}`, } as AgentEvent) } } }) child.stderr?.setEncoding('utf8') child.stderr?.on('data', (chunk: string) => { transcript?.write(`[claude-stderr] ${chunk}`) }) child.on('error', (err) => { emit({ type: 'error', message: `spawn failed: ${err.message}`, } as AgentEvent) }) const { promise, resolve } = createDeferred() child.on('close', (code) => { if (!completedEmitted) { // claude exited without emitting a `result` event — usually a crash, // rate-limit, or SIGTERM. emit a terminal event so the queue drains. emit({ type: 'turn-completed', filesTouched: Array.from(filesTouched), durationMs: Date.now() - startedAt, } as AgentEvent) if (code !== 0 && code !== null) { emit({ type: 'error', message: `claude exited with code ${code}`, } as AgentEvent) } } setChild(null) resolve() }) try { await promise } finally { deps.onTurnSettled() } } } function createDeferred(): { promise: Promise resolve: (value: T) => void } { let resolve!: (value: T) => void const promise = new Promise((res) => { resolve = res }) return { promise, resolve } } function withTimeout(p: Promise, ms: number, message: string): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(message)), ms) p.then( (v) => { clearTimeout(t) resolve(v) }, (err: unknown) => { clearTimeout(t) reject(err instanceof Error ? err : new Error(String(err))) }, ) }) }