feat(codex): share sessions across terminal and web

Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
weishu
2026-09-12 10:59:17 +08:00
parent 25af3f8e13
commit 0c4abcb3d1
111 changed files with 4684 additions and 960 deletions
+93 -4
View File
@@ -1,5 +1,6 @@
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { existsSync } from 'node:fs';
import WebSocket from 'ws';
import { logger } from '@/ui/logger';
import { JsonLineParser } from '@/utils/jsonLineParser';
import { killProcessByChildProcess } from '@/utils/process';
@@ -82,6 +83,9 @@ export function isIndeterminateError(error: unknown): boolean {
type CodexAppServerClientOptions = {
cwd?: string;
/** An independently owned shared server. Disconnect only detaches this client. */
endpoint?: string;
token?: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -133,7 +137,7 @@ function compareVersion(a: number[] | null, b: number[] | null): number {
return 0;
}
function resolveCodexAppServerCommand(): string {
export function resolveCodexAppServerCommand(): string {
if (process.env.HAPI_CODEX_APP_SERVER_BIN) {
return process.env.HAPI_CODEX_APP_SERVER_BIN;
}
@@ -176,6 +180,9 @@ function resolveCodexAppServerCommand(): string {
}
export class CodexAppServerClient extends JsonLineParser {
private connecting: Promise<void> | null = null;
private socket: WebSocket | null = null;
private serverRequestHandler: ((request: { id: string | number; method: string; params: unknown }) => void) | null = null;
private process: ChildProcessWithoutNullStreams | null = null;
private connected = false;
private initialized = false;
@@ -208,10 +215,46 @@ export class CodexAppServerClient extends JsonLineParser {
}
async connect(): Promise<void> {
if (this.connecting) return this.connecting;
this.connecting = this.connectTransport();
try { await this.connecting; } finally { this.connecting = null; }
}
private async connectTransport(): Promise<void> {
if (this.connected) {
return;
}
if (this.options.endpoint) {
const endpoint = this.options.endpoint;
const url = endpoint.startsWith('unix://') ? `ws+unix://${endpoint.slice(7)}:/` : endpoint;
const socket = new WebSocket(url, {
headers: { Host: 'localhost', ...(this.options.token ? { Authorization: `Bearer ${this.options.token}` } : {}) },
maxPayload: 64 * 1024 * 1024,
perMessageDeflate: false,
handshakeTimeout: 10_000
});
this.socket = socket;
socket.on('message', data => {
if (this.socket === socket) this.handleLine(data.toString());
});
socket.on('error', error => logger.debug('[CodexAppServer] WebSocket error', error));
socket.on('close', () => {
if (this.socket !== socket) return;
this.socket = null;
this.connected = this.initialized = false;
this.rejectAllPending(new Error('Shared Codex connection closed'));
this.resetParserState();
this.transportAbandonedHandler?.();
});
await new Promise<void>((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
});
this.connected = true;
return;
}
const codexCommand = resolveCodexAppServerCommand();
logger.debug(`[CodexAppServer] Starting ${codexCommand} app-server`);
const child = spawn(codexCommand, ['app-server'], {
@@ -289,6 +332,18 @@ export class CodexAppServerClient extends JsonLineParser {
this.requestHandlers.set(method, handler);
}
setServerRequestHandler(handler: typeof this.serverRequestHandler): void {
this.serverRequestHandler = handler;
}
respond(id: string | number, result: unknown): void {
this.writePayload({ id, result });
}
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
return await this.sendRequest(method, params, { timeoutMs: 20_000 }) as T;
}
async initialize(params: InitializeParams): Promise<InitializeResponse> {
const response = await this.sendRequest('initialize', params, { timeoutMs: 30_000 });
this.sendNotification('initialized');
@@ -363,11 +418,12 @@ export class CodexAppServerClient extends JsonLineParser {
return response as ThreadForkResponse;
}
async supportsMethod(method: 'thread/fork' | 'thread/rollback' | 'thread/settings/update'): Promise<boolean> {
async supportsMethod(method: string): Promise<boolean> {
try {
await this.sendRequest(method, { threadId: '__hapi_capability_probe__' }, { timeoutMs: 30_000 });
return true;
} catch (error) {
if (this.options.endpoint && isIndeterminateError(error)) throw error;
return !/method not found|unknown method|unsupported/i.test(
error instanceof Error ? error.message : String(error)
);
@@ -472,6 +528,15 @@ export class CodexAppServerClient extends JsonLineParser {
}
async disconnect(): Promise<void> {
if (this.socket) {
const socket = this.socket;
this.socket = null;
this.connected = this.initialized = false;
socket.close();
this.rejectAllPending(new Error('Shared Codex client detached'));
this.resetParserState();
return;
}
if (!this.connected) {
return;
}
@@ -520,8 +585,14 @@ export class CodexAppServerClient extends JsonLineParser {
throw createAbortError();
}
if (!this.connected) {
if (this.options.endpoint && method !== 'initialize') {
throw new Error('Shared Codex client disconnected; reconnect and initialize before sending');
}
await this.connect();
}
if (this.options.endpoint && !this.initialized && method !== 'initialize') {
throw new Error('Shared Codex client is not initialized');
}
if (options?.signal?.aborted) {
throw createAbortError();
}
@@ -561,6 +632,8 @@ export class CodexAppServerClient extends JsonLineParser {
};
const abandonUnconfirmedDispatch = (error: Error) => {
this.socket?.terminate();
this.socket = null;
const child = this.process;
this.process = null;
this.connected = false;
@@ -646,7 +719,7 @@ export class CodexAppServerClient extends JsonLineParser {
try {
const serialized = JSON.stringify(payload);
this.process?.stdin.write(`${serialized}\n`, (error) => {
const onWrite = (error?: Error | null) => {
if (error) {
const writeError = error instanceof Error ? error : new Error(String(error));
failRequest(this.markIndeterminate(writeError), !dispatchSettled);
@@ -656,7 +729,10 @@ export class CodexAppServerClient extends JsonLineParser {
dispatchSettled = true;
resolveDispatched();
}
});
};
if (this.socket) this.socket.send(serialized, onWrite);
else if (this.process) this.process.stdin.write(`${serialized}\n`, onWrite);
else failRequest(this.markIndeterminate(new Error('Codex transport is unavailable')));
} catch (error) {
const writeError = error instanceof Error ? error : new Error(String(error));
failRequest(writeError);
@@ -689,6 +765,7 @@ export class CodexAppServerClient extends JsonLineParser {
logger.debug('[CodexAppServer] Failed to parse JSON line', { line, error });
this.rejectAllPending(protocolError);
this.process?.stdin.end();
this.socket?.close(1007, 'Invalid JSON-RPC');
return;
}
@@ -698,6 +775,14 @@ export class CodexAppServerClient extends JsonLineParser {
if ('id' in message && message.id !== undefined) {
const requestId = message.id;
if (this.options.endpoint) {
// A side client must not consume requests it does not own, even
// with a JSON-RPC error: the first response wins upstream.
if (typeof requestId === 'string' || typeof requestId === 'number') {
this.serverRequestHandler?.({ id: requestId, method, params });
}
return;
}
const sourceProcess = this.process;
void this.handleIncomingRequest({
id: requestId,
@@ -795,6 +880,10 @@ export class CodexAppServerClient extends JsonLineParser {
payload: JsonRpcLiteRequest | JsonRpcLiteNotification | JsonRpcLiteResponse,
targetProcess: ChildProcessWithoutNullStreams | null = this.process
): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
return;
}
if (!targetProcess || targetProcess !== this.process) {
return;
}