// minimal client for `codex app-server` — a long-lived child process that // speaks line-delimited JSON-RPC 2.0 over stdio. // // scope: just the subset sootsim needs for the attached-projects agent // wrapper. each connection owns exactly one spawned app-server child; caller // is responsible for lifecycle. bidirectional: client can issue requests + // receive server-initiated notifications. we treat every method we don't // recognize as an observable event that we pass straight to the handler map. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import readline from 'node:readline' export interface CodexClientOptions { /** resolved path to the codex binary (via `which codex` or user override). */ bin: string /** working directory for the spawned process. defaults to process.cwd(). */ cwd?: string /** extra environment overrides forwarded to the child. */ env?: Record } export type CodexNotificationHandler = (params: unknown) => void export interface CodexClient { /** promise that resolves when the child exits. use to await shutdown. */ readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }> /** register a handler for a server-sent notification method. returns * a dispose fn. multiple handlers per method are allowed. */ on(method: string, handler: CodexNotificationHandler): () => void /** send a JSON-RPC request and await its response. rejects on error * response or child death. */ request(method: string, params?: unknown): Promise /** send a JSON-RPC notification (no id, no response). */ notify(method: string, params?: unknown): void /** close stdin (graceful shutdown), then SIGTERM after timeout. */ shutdown(timeoutMs?: number): Promise /** force-kill immediately. */ kill(signal?: NodeJS.Signals): void } interface JsonRpcResponse { jsonrpc?: '2.0' id?: number | string | null result?: unknown error?: { code: number; message: string; data?: unknown } method?: string params?: unknown } export class CodexRpcError extends Error { code: number data: unknown constructor(message: string, code: number, data?: unknown) { super(message) this.name = 'CodexRpcError' this.code = code this.data = data } } export function spawnCodexClient(options: CodexClientOptions): CodexClient { const child: ChildProcessWithoutNullStreams = spawn(options.bin, ['app-server'], { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: ['pipe', 'pipe', 'pipe'], }) const pending = new Map< number, { resolve: (value: unknown) => void reject: (err: unknown) => void method: string } >() const handlers = new Map>() let nextId = 1 let closed = false // the one channel child output reaches a caller on. carries the // app-server's own stderr and any pipe failure against it. function reportChildText(text: string): void { const set = handlers.get('__stderr__') if (!set) return for (const h of set) { try { h({ text }) } catch {} } } // an 'error' event with no listener is an uncaught exception, so a child // that fails to spawn or a write against a dead app-server would take the // hosting process down with exit 1 instead of letting it shut down. a // failed spawn is a closed client: requests reject with the real reason // rather than hanging until their timeout. child.on('error', (err) => { closed = true reportChildText(`[codex-spawn] ${err.message}\n`) }) child.stdin.on('error', (err) => { reportChildText(`[codex-stdin] ${err.message}\n`) }) const exited = new Promise<{ code: number | null signal: NodeJS.Signals | null }>((resolve) => { child.on('close', (code, signal) => { closed = true const err = new Error( `codex app-server exited (code=${code}, signal=${signal ?? ''})`, ) for (const { reject } of pending.values()) reject(err) pending.clear() resolve({ code, signal }) }) }) // stdout carries JSON-RPC messages, one per line. const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }) rl.on('line', (line) => { const trimmed = line.trim() if (!trimmed) return let msg: JsonRpcResponse try { msg = JSON.parse(trimmed) as JsonRpcResponse } catch { // app-server occasionally emits non-JSON warnings; ignore. return } if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) { const idNum = typeof msg.id === 'string' ? Number(msg.id) : msg.id const entry = idNum != null ? pending.get(idNum) : undefined if (!entry) return pending.delete(idNum!) if (msg.error) { entry.reject(new CodexRpcError(msg.error.message, msg.error.code, msg.error.data)) } else { entry.resolve(msg.result) } return } if (msg.method) { const set = handlers.get(msg.method) if (!set) return for (const h of set) { try { h(msg.params) } catch (err) { // a buggy handler must not kill the bridge, but it MUST be visible — // silent swallowing turned a notification-handler typo into a // "nothing happens" bug once already. console.error( `[codex-client] handler for "${msg.method}" threw:`, err instanceof Error ? (err.stack ?? err.message) : err, ) } } } }) // stderr is diagnostic only; surfaced via the "stderr" notification channel // so callers can forward it into transcripts if they want. child.stderr.setEncoding('utf8') child.stderr.on('data', (chunk: string) => { reportChildText(chunk) }) function write(msg: unknown): void { if (closed) return try { child.stdin.write(JSON.stringify(msg) + '\n') } catch { // if stdin has been closed, subsequent writes will throw; ignore. } } return { exited, on(method, handler) { let set = handlers.get(method) if (!set) { set = new Set() handlers.set(method, set) } set.add(handler) return () => { set?.delete(handler) } }, request(method: string, params?: unknown): Promise { if (closed) { return Promise.reject(new Error(`codex app-server closed; cannot call ${method}`)) } const id = nextId++ return new Promise((resolve, reject) => { pending.set(id, { resolve: (v) => resolve(v as TResult), reject, method, }) write({ jsonrpc: '2.0', id, method, params }) }) }, notify(method, params) { write({ jsonrpc: '2.0', method, params }) }, async shutdown(timeoutMs = 1500) { if (closed) return try { child.stdin.end() } catch {} const t = setTimeout(() => { if (!closed) { try { child.kill('SIGTERM') } catch {} } }, timeoutMs) try { await exited } finally { clearTimeout(t) } }, kill(signal = 'SIGTERM') { if (closed) return try { child.kill(signal) } catch {} }, } }