import { spawn, type ChildProcess } from 'child_process'; import readline from 'readline'; import { findCodexCliPath } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; import type { AppServerModelResponse, AppServerAccountResponse, AppServerRateLimitsResponse, JsonRpcRequest, } from '@automaker/types'; const logger = createLogger('CodexAppServer'); /** * CodexAppServerService * * Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol. * Handles process spawning, JSON-RPC messaging, and cleanup. * * Connection strategy: Spawn on-demand (new process for each method call) */ export class CodexAppServerService { private cachedCliPath: string | null = null; /** * Check if Codex CLI is available on the system */ async isAvailable(): Promise { this.cachedCliPath = await findCodexCliPath(); return Boolean(this.cachedCliPath); } /** * Fetch available models from app-server */ async getModels(): Promise { const result = await this.executeJsonRpc((sendRequest) => { return sendRequest('model/list', {}); }); if (result) { logger.info(`[getModels] ✓ Fetched ${result.data.length} models`); } return result; } /** * Fetch account information from app-server */ async getAccount(): Promise { return this.executeJsonRpc((sendRequest) => { return sendRequest('account/read', { refreshToken: false }); }); } /** * Fetch rate limits from app-server */ async getRateLimits(): Promise { return this.executeJsonRpc((sendRequest) => { return sendRequest('account/rateLimits/read', {}); }); } /** * Execute JSON-RPC requests via Codex app-server * * This method: * 1. Spawns a new `codex app-server` process * 2. Handles JSON-RPC initialization handshake * 3. Executes user-provided requests * 4. Cleans up the process * * @param requestFn - Function that receives sendRequest helper and returns a promise * @returns Result of the JSON-RPC request or null on failure */ private async executeJsonRpc( requestFn: (sendRequest: (method: string, params?: unknown) => Promise) => Promise ): Promise { let childProcess: ChildProcess | null = null; try { const cliPath = this.cachedCliPath || (await findCodexCliPath()); if (!cliPath) { return null; } // On Windows, .cmd files must be run through shell const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd'); childProcess = spawn(cliPath, ['app-server'], { cwd: process.cwd(), env: { ...process.env, TERM: 'dumb', }, stdio: ['pipe', 'pipe', 'pipe'], shell: needsShell, }); if (!childProcess.stdin || !childProcess.stdout) { throw new Error('Failed to create stdio pipes'); } // Setup readline for reading JSONL responses const rl = readline.createInterface({ input: childProcess.stdout, crlfDelay: Infinity, }); // Message ID counter for JSON-RPC let messageId = 0; const pendingRequests = new Map< number, { resolve: (value: unknown) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; } >(); // Process incoming messages rl.on('line', (line) => { if (!line.trim()) return; try { const message = JSON.parse(line); // Handle response to our request if ('id' in message && message.id !== undefined) { const pending = pendingRequests.get(message.id); if (pending) { clearTimeout(pending.timeout); pendingRequests.delete(message.id); if (message.error) { pending.reject(new Error(message.error.message || 'Unknown error')); } else { pending.resolve(message.result); } } } // Ignore notifications (no id field) } catch { // Ignore parse errors for non-JSON lines } }); // Helper to send JSON-RPC request and wait for response const sendRequest = (method: string, params?: unknown): Promise => { return new Promise((resolve, reject) => { const id = ++messageId; const request: JsonRpcRequest = { method, id, params: params ?? {}, }; // Set timeout for request (10 seconds) const timeout = setTimeout(() => { pendingRequests.delete(id); reject(new Error(`Request timeout: ${method}`)); }, 10000); pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout, }); childProcess!.stdin!.write(JSON.stringify(request) + '\n'); }); }; // Helper to send notification (no response expected) const sendNotification = (method: string, params?: unknown): void => { const notification = params ? { method, params } : { method }; childProcess!.stdin!.write(JSON.stringify(notification) + '\n'); }; // 1. Initialize the app-server await sendRequest('initialize', { clientInfo: { name: 'automaker', title: 'AutoMaker', version: '1.0.0', }, }); // 2. Send initialized notification sendNotification('initialized'); // 3. Execute user-provided requests const result = await requestFn(sendRequest); // Clean up rl.close(); childProcess.kill('SIGTERM'); return result; } catch (error) { logger.error('[executeJsonRpc] Failed:', error); return null; } finally { // Ensure process is killed if (childProcess && !childProcess.killed) { childProcess.kill('SIGTERM'); } } } }