import { EventEmitter } from 'node:events'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { APP_NAME, APP_TITLE, OPERATION_BRIDGE_TIMEOUT_MS, OPERATION_POLL_INTERVAL_MS, REQUEST_TIMEOUT_MS, } from '../config/defaults.js'; import type { InitializeParams, JsonLineMessage, JsonLineNotification, JsonLineRequest, JsonLineResponse, PendingServerRequest, RequestId, RuntimeOperation, } from '../types/codex.js'; interface PendingRequestBinding { resolve: (value: unknown) => void; reject: (reason?: unknown) => void; timeout: NodeJS.Timeout; } interface RequestOptions { timeoutMs?: number | undefined; } interface RequestWithBridgeOptions extends RequestOptions { threadId?: string | undefined; bridgeTimeoutMs?: number | undefined; } interface WaitOptions { timeoutMs?: number | undefined; pollIntervalMs?: number | undefined; } export interface AppServerClientOptions { command: string; args: string[]; env: NodeJS.ProcessEnv; codexHome: string; appName?: string | undefined; appVersion?: string | undefined; } export interface BridgedOperationResult { status: 'completed' | 'pending_request' | 'running'; operationId: string; requestId: RequestId; pendingRequestIds: RequestId[]; result?: unknown; } const SERVER_REQUEST_METHODS = new Set([ 'item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/tool/requestUserInput', 'mcpServer/elicitation/request', 'item/permissions/requestApproval', 'item/tool/call', 'account/chatgptAuthTokens/refresh', 'applyPatchApproval', 'execCommandApproval', ]); const TURN_LIFECYCLE_METHODS = new Set([ 'turn/start', 'turn/steer', 'review/start', ]); function asObject(value: unknown): Record | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } return value as Record; } function requestIdKey(id: RequestId): string { return typeof id === 'number' ? `n:${id}` : `s:${id}`; } const OPERATION_EVICTION_MS = 5 * 60 * 1000; const MAX_OPERATIONS = 2_000; const MAX_SERVER_REQUESTS = 2_000; export class AppServerClient extends EventEmitter { private readonly options: AppServerClientOptions; private readonly pendingRequests = new Map(); private readonly serverRequests = new Map(); private readonly operations = new Map(); private readonly operationsByRequestId = new Map(); private readonly threadEvents = new Map(); private process?: ChildProcessWithoutNullStreams | undefined; private nextRequestCounter = 1; private nextOperationCounter = 1; private buffer = ''; private started = false; constructor(options: AppServerClientOptions) { super(); this.options = options; } async start(): Promise { if (this.started) { return; } const child = spawn(this.options.command, this.options.args, { cwd: process.cwd(), env: { ...this.options.env, CODEX_HOME: this.options.codexHome, }, stdio: 'pipe', }); this.process = child; this.started = true; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => this.consumeStdout(String(chunk))); child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk) => { this.emit('stderr', String(chunk)); }); child.on('exit', (code, signal) => { const message = `Codex app-server exited (code=${String(code)}, signal=${String(signal)})`; this.started = false; this.process = undefined; for (const binding of this.pendingRequests.values()) { clearTimeout(binding.timeout); binding.reject(new Error(message)); } this.pendingRequests.clear(); for (const operation of this.operations.values()) { if (operation.status === 'running') { operation.status = 'failed'; operation.completedAt = new Date().toISOString(); operation.error = message; } } this.emit('exit', { code, signal }); }); const initializeParams: InitializeParams = { clientInfo: { name: this.options.appName ?? APP_NAME, title: APP_TITLE, version: this.options.appVersion ?? '0.0.0', }, capabilities: { experimentalApi: true, optOutNotificationMethods: null, }, }; await this.request('initialize', initializeParams); // If an API key is available in the environment, log in with it to // override any stale OAuth tokens in auth.json. This prevents the // "refresh token already used" crash that kills the app-server // during long tasks when the cached ChatGPT OAuth token expires. const apiKey = this.options.env?.CODEX_LB_API_KEY ?? this.options.env?.OPENAI_API_KEY; if (apiKey) { await this.request('account/login/start', { type: 'apiKey', apiKey, }).catch(() => { // Non-fatal — the server may not support this auth mode, // or may already be authenticated. }); } } async stop(): Promise { if (!this.process) { return; } this.process.kill('SIGTERM'); this.process = undefined; this.started = false; } async request(method: string, params: unknown, options: RequestOptions = {}): Promise { const sent = this.sendRequest(method, params, options.timeoutMs); return sent.promise; } async requestWithBridge( method: string, params: unknown, options: RequestWithBridgeOptions = {}, ): Promise { this.evictStaleEntries(); const sent = this.sendRequest(method, params, options.timeoutMs); const operationId = `op-${this.nextOperationCounter++}`; const requestIdKeyValue = requestIdKey(sent.requestId); const threadId = options.threadId ?? this.extractThreadIdFromParams(params); const waitsForTurnCompletion = TURN_LIFECYCLE_METHODS.has(method); const operation: RuntimeOperation = { operationId, requestId: sent.requestId, method, threadId, startedAt: new Date().toISOString(), status: 'running', pendingRequestIds: [], }; this.operations.set(operationId, operation); this.operationsByRequestId.set(requestIdKeyValue, operationId); sent.promise.then( (result) => { const current = this.operations.get(operationId); if (!current) { return; } current.result = result; const turnId = this.extractTurnId(result); if (turnId) { current.turnId = turnId; } if (!waitsForTurnCompletion) { current.status = 'completed'; current.completedAt = new Date().toISOString(); } }, (error) => { const current = this.operations.get(operationId); if (!current) { return; } current.status = 'failed'; current.completedAt = new Date().toISOString(); current.error = error instanceof Error ? error.message : String(error); }, ); const bridgeTimeoutMs = options.bridgeTimeoutMs ?? OPERATION_BRIDGE_TIMEOUT_MS; const result = await new Promise((resolve, reject) => { let settled = false; const cleanup = () => { this.off('server-request', onServerRequest); this.off('notification', onNotification); clearTimeout(timeout); }; const resolveOnce = (value: BridgedOperationResult) => { if (settled) { return; } settled = true; cleanup(); resolve(value); }; const rejectOnce = (error: unknown) => { if (settled) { return; } settled = true; cleanup(); reject(error); }; const snapshot = (): BridgedOperationResult => { const current = this.operations.get(operationId); return { status: current?.status === 'completed' ? 'completed' : 'running', operationId, requestId: sent.requestId, pendingRequestIds: [...(current?.pendingRequestIds ?? [])], ...(current?.result !== undefined ? { result: current.result } : {}), }; }; const onServerRequest = (pending: PendingServerRequest) => { const current = this.operations.get(operationId); if (!current || current.status !== 'running') { return; } const pendingThreadId = this.extractThreadIdFromParams(pending.params); if (!threadId || !pendingThreadId || pendingThreadId !== threadId) { return; } if (!current.pendingRequestIds.some((id) => requestIdKey(id) === requestIdKey(pending.id))) { current.pendingRequestIds.push(pending.id); } resolveOnce({ status: 'pending_request', operationId, requestId: sent.requestId, pendingRequestIds: [...current.pendingRequestIds], }); }; const onNotification = () => { const current = this.operations.get(operationId); if (!current || current.status === 'running') { return; } if (current.status === 'failed') { rejectOnce(new Error(current.error ?? `Operation ${operationId} failed.`)); return; } resolveOnce(snapshot()); }; this.on('server-request', onServerRequest); this.on('notification', onNotification); sent.promise.then((response) => { const current = this.operations.get(operationId); if (!waitsForTurnCompletion) { resolveOnce({ status: 'completed', operationId, requestId: sent.requestId, pendingRequestIds: [...(current?.pendingRequestIds ?? [])], result: response, }); return; } if (current?.status === 'completed') { resolveOnce(snapshot()); return; } if (current?.status === 'failed') { rejectOnce(new Error(current.error ?? `Operation ${operationId} failed.`)); } }).catch((error) => { rejectOnce(error); }); const timeout = setTimeout(() => { resolveOnce(snapshot()); }, bridgeTimeoutMs); timeout.unref(); }); return result; } async waitForOperation(operationId: string, options: WaitOptions = {}): Promise { const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; const pollIntervalMs = options.pollIntervalMs ?? OPERATION_POLL_INTERVAL_MS; const start = Date.now(); while (Date.now() - start <= timeoutMs) { const operation = this.operations.get(operationId); if (!operation) { throw new Error(`Operation not found: ${operationId}`); } if (operation.status === 'completed' || operation.status === 'failed') { return { ...operation, pendingRequestIds: [...operation.pendingRequestIds] }; } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } throw new Error(`Timed out waiting for operation ${operationId}`); } getOperation(operationId: string): RuntimeOperation | undefined { const operation = this.operations.get(operationId); if (!operation) { return undefined; } return { ...operation, pendingRequestIds: [...operation.pendingRequestIds] }; } listOperations(): RuntimeOperation[] { return [...this.operations.values()].map((operation) => ({ ...operation, pendingRequestIds: [...operation.pendingRequestIds], })); } listOperationsForThread(threadId: string): RuntimeOperation[] { return [...this.operations.values()] .filter((op) => op.threadId === threadId) .map((op) => ({ ...op, pendingRequestIds: [...op.pendingRequestIds] })); } listServerRequests(includeResolved = false): PendingServerRequest[] { return [...this.serverRequests.values()] .filter((request) => includeResolved || request.status === 'pending') .map((request) => ({ ...request })); } getServerRequest(id: RequestId): PendingServerRequest | undefined { const request = this.serverRequests.get(requestIdKey(id)); return request ? { ...request } : undefined; } async respondToServerRequest(id: RequestId, payload: unknown): Promise { const requestKey = requestIdKey(id); const request = this.serverRequests.get(requestKey); if (!request) { throw new Error(`Pending server request not found: ${String(id)}`); } if (!this.process || !this.started) { throw new Error('Codex app-server is not running'); } this.writeLine({ id, result: payload, }); request.response = payload; request.status = 'resolved'; request.resolvedAt = new Date().toISOString(); } getThreadEvents(threadId: string): unknown[] { return [...(this.threadEvents.get(threadId) ?? [])]; } private sendRequest(method: string, params: unknown, timeoutMs = REQUEST_TIMEOUT_MS): { requestId: RequestId; promise: Promise; } { if (!this.process || !this.started) { throw new Error('Codex app-server is not running'); } const requestId: RequestId = `client-${this.nextRequestCounter++}`; const key = requestIdKey(requestId); const promise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(key); reject(new Error(`Timed out waiting for ${method} response`)); }, timeoutMs); timeout.unref(); this.pendingRequests.set(key, { resolve, reject, timeout, }); }); const message: JsonLineRequest = { method, id: requestId, params }; this.writeLine(message); return { requestId, promise }; } private consumeStdout(chunk: string): void { this.buffer += chunk; while (true) { const lineBreak = this.buffer.indexOf('\n'); if (lineBreak === -1) { return; } const line = this.buffer.slice(0, lineBreak).trim(); this.buffer = this.buffer.slice(lineBreak + 1); if (!line) { continue; } let message: JsonLineMessage; try { message = JSON.parse(line) as JsonLineMessage; } catch (error) { this.emit('parse-error', error); continue; } this.handleMessage(message); } } private handleMessage(message: JsonLineMessage): void { const asResponse = asObject(message); if (!asResponse) { return; } const hasMethod = typeof asResponse.method === 'string'; const hasId = typeof asResponse.id === 'string' || typeof asResponse.id === 'number'; if (hasMethod && hasId) { const request = message as JsonLineRequest; const key = requestIdKey(request.id); if (this.pendingRequests.has(key)) { this.resolveClientRequest(request.id, request as unknown as JsonLineResponse); return; } this.storeServerRequest(request); return; } if (hasId) { this.resolveClientRequest((message as JsonLineResponse).id, message as JsonLineResponse); return; } if (hasMethod) { this.handleNotification(message as JsonLineNotification); } } private resolveClientRequest(id: RequestId, response: JsonLineResponse): void { const key = requestIdKey(id); const binding = this.pendingRequests.get(key); if (!binding) { return; } clearTimeout(binding.timeout); this.pendingRequests.delete(key); if ('error' in response && response.error !== undefined) { binding.reject( new Error( typeof response.error === 'string' ? response.error : JSON.stringify(response.error), ), ); return; } binding.resolve(response.result); } private storeServerRequest(request: JsonLineRequest): void { if (!SERVER_REQUEST_METHODS.has(request.method)) { return; } const key = requestIdKey(request.id); const pending: PendingServerRequest = { id: request.id, method: request.method, params: request.params, createdAt: new Date().toISOString(), status: 'pending', }; this.serverRequests.set(key, pending); this.emit('server-request', pending); } private handleNotification(notification: JsonLineNotification): void { if (notification.method === 'serverRequest/resolved') { const params = asObject(notification.params); const requestId = params?.requestId; if (typeof requestId === 'string' || typeof requestId === 'number') { const request = this.serverRequests.get(requestIdKey(requestId)); if (request) { request.status = 'resolved'; request.resolvedAt = new Date().toISOString(); } } } this.updateOperationsFromNotification(notification); const threadId = this.extractThreadIdFromParams(notification.params); if (threadId) { const events = this.threadEvents.get(threadId) ?? []; events.push({ receivedAt: new Date().toISOString(), method: notification.method, params: notification.params, }); this.threadEvents.set(threadId, events.slice(-200)); } this.emit('notification', notification); } private updateOperationsFromNotification(notification: JsonLineNotification): void { const threadId = this.extractThreadIdFromParams(notification.params); if (!threadId) { return; } if (notification.method === 'turn/completed') { const params = asObject(notification.params); const turn = asObject(params?.turn); const turnId = typeof turn?.id === 'string' ? turn.id : undefined; const turnStatus = typeof turn?.status === 'string' ? turn.status : undefined; for (const operation of this.operations.values()) { if (operation.status !== 'running' || operation.threadId !== threadId || !TURN_LIFECYCLE_METHODS.has(operation.method)) { continue; } if (turnId) { operation.turnId = operation.turnId ?? turnId; if (operation.turnId !== turnId) { continue; } } if (turnStatus === 'completed' || turnStatus === 'interrupted') { operation.status = 'completed'; operation.completedAt = new Date().toISOString(); operation.result = { turn }; } else { operation.status = 'failed'; operation.completedAt = new Date().toISOString(); operation.error = this.extractTurnError(turn) ?? `Turn finished with status ${turnStatus ?? 'unknown'}.`; operation.result = { turn }; } } return; } if (notification.method === 'error') { const params = asObject(notification.params); const notificationTurnId = typeof params?.turnId === 'string' ? params.turnId : undefined; const message = this.extractErrorNotificationMessage(notification.params); for (const operation of this.operations.values()) { if (operation.status !== 'running' || operation.threadId !== threadId || !TURN_LIFECYCLE_METHODS.has(operation.method)) { continue; } if (notificationTurnId && operation.turnId && operation.turnId !== notificationTurnId) { continue; } operation.status = 'failed'; operation.completedAt = new Date().toISOString(); operation.error = message; } } } /** * Pulls a human-readable message out of an `error` notification. * * The Codex app-server emits `ErrorNotification` with shape * `{ error: { message, codexErrorInfo, additionalDetails }, willRetry, threadId, turnId }` * (see src/protocol/v2/ErrorNotification.ts). We surface the nested message and * append the `codexErrorInfo` tag so downstream classifiers in * `codex-runtime.ts` (isAuthSignal / isRateLimitSignal) can match on keywords * like `unauthorized` or `usageLimitExceeded` to drive profile failover. */ private extractErrorNotificationMessage(params: unknown): string { const paramsObject = asObject(params); const errorObject = asObject(paramsObject?.error); const nestedMessage = typeof errorObject?.message === 'string' ? errorObject.message : undefined; const flatMessage = typeof paramsObject?.message === 'string' ? paramsObject.message : undefined; const rawMessage = nestedMessage ?? flatMessage; const codexErrorInfo = this.normalizeCodexErrorInfo(errorObject?.codexErrorInfo); if (rawMessage && codexErrorInfo) { return `${rawMessage} [${codexErrorInfo}]`; } if (rawMessage) { return rawMessage; } if (codexErrorInfo) { return `Codex reported an error: ${codexErrorInfo}`; } return 'Codex reported an error.'; } private normalizeCodexErrorInfo(value: unknown): string | undefined { if (typeof value === 'string') { return value; } const object = asObject(value); if (object) { const [key] = Object.keys(object); if (key) { return key; } } return undefined; } private extractThreadIdFromParams(params: unknown): string | undefined { const object = asObject(params); const threadId = object?.threadId; if (typeof threadId === 'string') { return threadId; } const thread = asObject(object?.thread); const threadIdFromThread = thread?.id; if (typeof threadIdFromThread === 'string') { return threadIdFromThread; } return undefined; } private extractTurnId(payload: unknown): string | undefined { const object = asObject(payload); const turn = asObject(object?.turn); if (typeof turn?.id === 'string') { return turn.id; } if (typeof object?.turnId === 'string') { return object.turnId; } return undefined; } private extractTurnError(turn: Record | undefined): string | undefined { if (!turn) { return undefined; } const error = turn.error; if (typeof error === 'string') { return error; } const object = asObject(error); if (typeof object?.message === 'string') { return object.message; } return undefined; } private writeLine(message: JsonLineMessage | { id: RequestId; result: unknown }): void { if (!this.process || !this.started) { throw new Error('Codex app-server is not running'); } this.process.stdin.write(`${JSON.stringify(message)}\n`); } private evictStaleEntries(): void { const now = Date.now(); for (const [key, op] of this.operations) { if ((op.status === 'completed' || op.status === 'failed') && op.completedAt) { const age = now - new Date(op.completedAt).getTime(); if (age > OPERATION_EVICTION_MS) { this.operations.delete(key); this.operationsByRequestId.delete(requestIdKey(op.requestId)); } } } if (this.operations.size > MAX_OPERATIONS) { const sorted = [...this.operations.entries()] .filter(([, op]) => op.status !== 'running') .sort((a, b) => (a[1].completedAt ?? '').localeCompare(b[1].completedAt ?? '')); for (const [key, op] of sorted) { this.operations.delete(key); this.operationsByRequestId.delete(requestIdKey(op.requestId)); if (this.operations.size <= MAX_OPERATIONS) { break; } } } for (const [key, req] of this.serverRequests) { if (req.status === 'resolved' && req.resolvedAt) { const age = now - new Date(req.resolvedAt).getTime(); if (age > OPERATION_EVICTION_MS) { this.serverRequests.delete(key); } } } if (this.serverRequests.size > MAX_SERVER_REQUESTS) { const resolved = [...this.serverRequests.entries()] .filter(([, r]) => r.status === 'resolved') .sort((a, b) => (a[1].resolvedAt ?? '').localeCompare(b[1].resolvedAt ?? '')); for (const [key] of resolved) { this.serverRequests.delete(key); if (this.serverRequests.size <= MAX_SERVER_REQUESTS) { break; } } } } }