diff --git a/packages/codex-native-api/src/codex_app_client.ts b/packages/codex-native-api/src/codex_app_client.ts index 216daae..a543453 100644 --- a/packages/codex-native-api/src/codex_app_client.ts +++ b/packages/codex-native-api/src/codex_app_client.ts @@ -342,6 +342,8 @@ export class CodexAppClient extends EventEmitter { socket: WebSocket | null; + stdioLineBuffer: string; + pending: Map; pendingApprovals: Map; @@ -398,6 +400,7 @@ export class CodexAppClient extends EventEmitter { this.child = null; this.socket = null; + this.stdioLineBuffer = ''; this.pending = new Map(); this.pendingApprovals = new Map(); this.approvedExecutions = new Map(); @@ -1019,21 +1022,22 @@ export class CodexAppClient extends EventEmitter { } this.childStartError = null; this.childStderrTail = []; - this.port = await reservePort(); + this.stdioLineBuffer = ''; + this.port = null; const featureArgs = this.enabledFeatures.flatMap((feature) => ['--enable', feature]); const launchSpec = createCodexAppServerLaunchSpec({ command: this.codexCliBin, - args: [...this.codexCliArgs, 'app-server', ...featureArgs, '--listen', `ws://127.0.0.1:${this.port}`], + args: [...this.codexCliArgs, 'app-server', ...featureArgs, '--listen', 'stdio://'], platform: this.platform, }); try { this.child = launchSpec.args ? this.spawnImpl(launchSpec.command, launchSpec.args, { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], ...launchSpec.options, }) : this.spawnImpl(launchSpec.command, { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], ...launchSpec.options, }); } catch (error) { @@ -1053,6 +1057,7 @@ export class CodexAppClient extends EventEmitter { autolaunch: this.autolaunch, launchCommand: this.launchCommand, }); + this.child.stdout?.on('data', (chunk) => this.handleStdioData(chunk)); this.child.stderr?.on('data', (chunk) => { const text = String(chunk).trim(); if (text) { @@ -1071,10 +1076,36 @@ export class CodexAppClient extends EventEmitter { this.connected = false; this.socket = null; }); - await this.connectWebSocket(); + this.connected = true; + await new Promise((resolve) => setImmediate(resolve)); + if (this.childStartError) { + throw this.childStartError; + } + if (this.child && this.child.exitCode !== null) { + throw createCodexAppServerExitedError({ + command: this.codexCliBin, + exitCode: this.child.exitCode, + stderrTail: this.childStderrTail, + }); + } await this.initialize(); } + handleStdioData(chunk: unknown): void { + this.stdioLineBuffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk ?? ''); + for (;;) { + const newlineIndex = this.stdioLineBuffer.indexOf('\n'); + if (newlineIndex < 0) { + break; + } + const line = this.stdioLineBuffer.slice(0, newlineIndex).trim(); + this.stdioLineBuffer = this.stdioLineBuffer.slice(newlineIndex + 1); + if (line) { + this.handleMessage(line); + } + } + } + async connectWebSocket(): Promise { const url = `ws://127.0.0.1:${this.port}`; const started = Date.now(); @@ -1140,7 +1171,7 @@ export class CodexAppClient extends EventEmitter { } async request(method: string, params: any, { timeoutMs = 30_000 }: { timeoutMs?: number } = {}): Promise { - if (!this.socket || !this.connected) { + if (!this.child || !this.connected) { await this.start(); } const id = String(++this.requestId); @@ -1191,10 +1222,10 @@ export class CodexAppClient extends EventEmitter { } send(payload: any): void { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - throw new Error('Codex app-server socket is not open'); + if (!this.child?.stdin?.writable) { + throw new Error('Codex app-server stdio is not open'); } - this.socket.send(JSON.stringify(payload)); + this.child.stdin.write(`${JSON.stringify(payload)}\n`); } handleMessage(raw: string): void { diff --git a/src/providers/codex/app_client.ts b/src/providers/codex/app_client.ts index 78d11b5..5349f84 100644 --- a/src/providers/codex/app_client.ts +++ b/src/providers/codex/app_client.ts @@ -1066,7 +1066,7 @@ export class CodexAppClient extends EventEmitter { const featureArgs = this.enabledFeatures.flatMap((feature) => ['--enable', feature]); const appServerArgs = transportKind === 'websocket' ? [...this.codexCliArgs, 'app-server', ...featureArgs, '--listen', `ws://127.0.0.1:${this.port}`] - : [...this.codexCliArgs, 'app-server', ...featureArgs]; + : [...this.codexCliArgs, 'app-server', ...featureArgs, '--listen', 'stdio://']; const launchSpec = createCodexAppServerLaunchSpec({ command: this.codexCliBin, args: appServerArgs, @@ -1125,6 +1125,17 @@ export class CodexAppClient extends EventEmitter { if (transportKind === 'stdio') { this.transportKind = 'stdio'; this.connected = true; + await new Promise((resolve) => setImmediate(resolve)); + if (this.childStartError) { + throw this.childStartError; + } + if (this.child && this.child.exitCode !== null) { + throw createCodexAppServerExitedError({ + command: this.codexCliBin, + exitCode: this.child.exitCode, + stderrTail: this.childStderrTail, + }); + } } else { await this.connectWebSocket(); } @@ -1132,10 +1143,10 @@ export class CodexAppClient extends EventEmitter { } resolveAppServerTransportKind(): 'websocket' | 'stdio' { - if (this.appServerTransport === 'stdio') { - return 'stdio'; + if (this.appServerTransport === 'websocket') { + return 'websocket'; } - return 'websocket'; + return 'stdio'; } handleStdioData(chunk: unknown): void { diff --git a/test/providers/codex/app_client.test.ts b/test/providers/codex/app_client.test.ts index 605bfeb..2d8452d 100644 --- a/test/providers/codex/app_client.test.ts +++ b/test/providers/codex/app_client.test.ts @@ -618,7 +618,7 @@ test('CodexAppClient startServer inherits the default Codex feature config when exitCode: number | null; }; child.stderr = new EventEmitter(); - child.exitCode = 0; + child.exitCode = null; const client = new CodexAppClient({ codexCliBin: 'codex', @@ -638,8 +638,7 @@ test('CodexAppClient startServer inherits the default Codex feature config when assert.equal(calls.length, 1); assert.equal(calls[0]?.command, 'codex'); assert.equal(calls[0]?.args?.[0], 'app-server'); - assert.deepEqual(calls[0]?.args?.slice(1, 2), ['--listen']); - assert.match(String(calls[0]?.args?.[2]), /^ws:\/\/127\.0\.0\.1:\d+$/); + assert.deepEqual(calls[0]?.args?.slice(1, 3), ['--listen', 'stdio://']); }); test('CodexAppClient startServer prepends configured Codex CLI args', async () => { @@ -649,7 +648,7 @@ test('CodexAppClient startServer prepends configured Codex CLI args', async () = exitCode: number | null; }; child.stderr = new EventEmitter(); - child.exitCode = 0; + child.exitCode = null; const client = new CodexAppClient({ codexCliBin: 'codex', @@ -677,7 +676,7 @@ test('CodexAppClient startServer wraps Windows cmd launchers through cmd.exe', a exitCode: number | null; }; child.stderr = new EventEmitter(); - child.exitCode = 0; + child.exitCode = null; const client = new CodexAppClient({ codexCliBin: 'C:\\Program Files\\Codex\\codex.cmd', @@ -703,7 +702,7 @@ test('CodexAppClient startServer wraps Windows cmd launchers through cmd.exe', a assert.equal(calls[0]?.args, null); assert.equal(calls[0]?.options?.shell, true); assert.equal(calls[0]?.options?.windowsHide, true); - assert.match(String(calls[0]?.command), /^"C:\\Program Files\\Codex\\codex\.cmd" app-server --listen ws:\/\/127\.0\.0\.1:\d+$/); + assert.match(String(calls[0]?.command), /^"C:\\Program Files\\Codex\\codex\.cmd" app-server --listen stdio:\/\/$/); }); test('CodexAppClient startServer surfaces a helpful Windows Codex ENOENT error', async () => {