Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions packages/codex-native-api/src/codex_app_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ export class CodexAppClient extends EventEmitter {

socket: WebSocket | null;

stdioLineBuffer: string;

pending: Map<string, PendingRequest>;

pendingApprovals: Map<string, PendingApproval>;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -1071,10 +1076,36 @@ export class CodexAppClient extends EventEmitter {
this.connected = false;
this.socket = null;
});
await this.connectWebSocket();
this.connected = true;
await new Promise<void>((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<void> {
const url = `ws://127.0.0.1:${this.port}`;
const started = Date.now();
Expand Down Expand Up @@ -1140,7 +1171,7 @@ export class CodexAppClient extends EventEmitter {
}

async request(method: string, params: any, { timeoutMs = 30_000 }: { timeoutMs?: number } = {}): Promise<any> {
if (!this.socket || !this.connected) {
if (!this.child || !this.connected) {
await this.start();
}
const id = String(++this.requestId);
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 15 additions & 4 deletions src/providers/codex/app_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1125,17 +1125,28 @@ export class CodexAppClient extends EventEmitter {
if (transportKind === 'stdio') {
this.transportKind = 'stdio';
this.connected = true;
await new Promise<void>((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();
}
await this.initialize();
}

resolveAppServerTransportKind(): 'websocket' | 'stdio' {
if (this.appServerTransport === 'stdio') {
return 'stdio';
if (this.appServerTransport === 'websocket') {
return 'websocket';
}
return 'websocket';
return 'stdio';
}

handleStdioData(chunk: unknown): void {
Expand Down
11 changes: 5 additions & 6 deletions test/providers/codex/app_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down