mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user