import * as plugins from './plugins.js'; import { commitinfo } from './00_commitinfo_data.js'; import { createSanitizedRuntimeEnvironment } from './functions.runtimeenvironment.js'; import { codexDiagnostic } from './functions.codexdiagnostics.js'; import { findCodexLocalSocket } from './functions.codexlocalsocket.js'; import { readControllerProcessIdentity, readProcessGroupMemberPids, type IControllerProcessIdentity } from './classes.processinspection.js'; import type { ICodexCreationRuntime, ICodexOwnedProcessIdentity } from './classes.codexcreationmodels.js'; import type { IControllerCodexDiagnostic, TControllerHarnessLifecycleState } from '../ts_interfaces/index.js'; export interface ICodexSupervisorOptions { directory: string; executable?: string; /** Explicit remote app-server attachment; the server remains externally owned. */ serverUrl?: string; token?: string; onNotification: (notificationArg: plugins.crossharness.ICodexAppServerNotification) => void; onServerRequest: (requestArg: plugins.crossharness.ICodexAppServerRequest) => void; onUnavailable: (errorArg: Error) => void; } const waitBounded = async (promiseArg: Promise, timeoutMsArg: number): Promise => { let timer: ReturnType | undefined; try { return await Promise.race([promiseArg.then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMsArg); })]); } finally { if (timer) clearTimeout(timer); } }; /** Owns a local stdio app-server or only a connection to an explicitly supplied remote server. */ export class CodexSupervisor { public readonly generation = plugins.crypto.randomBytes(32).toString('base64url'); private readonly abortController = new AbortController(); private state: TControllerHarnessLifecycleState = 'stopped'; private client?: plugins.crossharness.CodexAppServerClient; private child?: plugins.childProcess.ChildProcess; private processIdentity?: ICodexOwnedProcessIdentity; private childExited?: Promise; private startPromise?: Promise; private stopPromise?: Promise; private readonly observedMembers = new Map(); private diagnostic?: IControllerCodexDiagnostic; private sharedLocal = false; constructor(private readonly options: ICodexSupervisorOptions) {} public get supportsLocalAttachments(): boolean { return !this.options.serverUrl; } public get signal(): AbortSignal { return this.abortController.signal; } public get runtime(): ICodexCreationRuntime { return { generation: this.generation, ...(this.processIdentity ? { process: { ...this.processIdentity } } : {}) }; } public getStatus(): { state: TControllerHarnessLifecycleState; healthy: boolean; pid?: number; version?: string; connectionMode: 'local' | 'shared' | 'remote'; diagnostic?: IControllerCodexDiagnostic } { return { state: this.state, healthy: this.state === 'ready' && this.client?.state === 'connected' && !this.signal.aborted, connectionMode: this.options.serverUrl ? 'remote' : this.sharedLocal ? 'shared' : 'local', ...(this.diagnostic ? { diagnostic: { ...this.diagnostic } } : {}), ...(this.processIdentity ? { pid: this.processIdentity.pid } : {}), ...(this.client?.serverVersion ? { version: this.client.serverVersion } : {}), }; } public requireClient(): plugins.crossharness.CodexAppServerClient { this.signal.throwIfAborted(); if (this.state !== 'ready' || this.client?.state !== 'connected') throw new Error('Codex app-server is unavailable.'); return this.client; } public async start(): Promise { this.signal.throwIfAborted(); if (this.startPromise) return this.startPromise; if (this.state !== 'stopped' || this.signal.aborted) throw new Error('A Codex supervisor generation can only start once.'); this.state = 'starting'; this.startPromise = this.performStart(); return this.startPromise; } private async performStart(): Promise { try { let transport: plugins.crossharness.TCodexAppServerTransport; if (this.options.serverUrl) { transport = { type: 'websocket', url: this.options.serverUrl, token: this.options.token }; } else { if (process.platform === 'win32') throw new Error('Local Codex supervision requires a POSIX process group; use an explicit app-server endpoint on Windows.'); const socketPath = await findCodexLocalSocket(process.env.CODEX_HOME ?? plugins.path.join(plugins.os.homedir(), '.codex')); this.signal.throwIfAborted(); if (socketPath) { this.sharedLocal = true; transport = { type: 'unix', socketPath }; } else { const environment = createSanitizedRuntimeEnvironment(); if (process.env.CODEX_HOME !== undefined) environment.CODEX_HOME = process.env.CODEX_HOME; const child = plugins.childProcess.spawn(this.options.executable ?? 'codex', ['app-server', '--listen', 'stdio://'], { cwd: this.options.directory, env: environment, shell: false, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'], }); this.child = child; this.childExited = new Promise((resolve) => { child.once('error', (error) => { this.invalidate(error); resolve(); }); child.once('exit', () => { this.invalidate(new Error('The owned Codex app-server exited.')); resolve(); }); }); if (!child.pid || !child.stdin || !child.stdout) throw new Error('Codex app-server did not provide its owned stdio process.'); const identity = await readControllerProcessIdentity(child.pid); if (!identity || !identity.processGroupLeader) throw new Error('Codex app-server process ownership could not be verified.'); this.processIdentity = { pid: identity.pid, processGroupId: identity.processGroupId, fingerprint: identity.fingerprint }; this.observedMembers.set(identity.pid, identity); this.signal.throwIfAborted(); transport = { type: 'stdio', readable: child.stdout, writable: child.stdin }; } } const client = new plugins.crossharness.CodexAppServerClient({ transport, clientInfo: { name: 'agl', title: 'AGL', version: commitinfo.version }, experimentalApi: true, handshakeTimeoutMs: 30_000, initializeTimeoutMs: 30_000, maxMessageBytes: 64 * 1024 * 1024, onNotification: this.options.onNotification, onServerRequest: this.options.onServerRequest, onClose: (error) => this.invalidate(error), }); this.client = client; await client.connect(); this.signal.throwIfAborted(); const version = client.serverVersion?.match(/^(\d+)\.(\d+)\.(\d+)/); if (!version || (Number(version[1]) === 0 && (Number(version[2]) < 153 || (Number(version[2]) === 153 && Number(version[3]) < 3)))) { throw new Error('Native Codex support requires Codex app-server 0.153.3 or newer.'); } await this.observeOwnedMembers(); this.state = 'ready'; } catch (error) { this.invalidate(error instanceof Error ? error : new Error(String(error))); try { await this.stop(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Codex startup failed and owned-process cleanup is incomplete.'); } throw error; } } public async checkHealth(): Promise { const result = await this.requireClient().request('thread/loaded/list', {}, 5000, this.signal); if (!result || typeof result !== 'object' || !('data' in result) || !Array.isArray(result.data)) { throw new Error('Codex app-server returned an invalid health response.'); } await this.observeOwnedMembers(); } /** Capture group membership while the exact leader is known, before admitting a mutation. */ public async observeOwnedMembers(): Promise { const owner = this.processIdentity; if (!owner) return; const leader = await readControllerProcessIdentity(owner.pid); if (!leader || leader.fingerprint !== owner.fingerprint || leader.processGroupId !== owner.processGroupId) { throw new Error('The owned Codex runtime generation is no longer present.'); } for (const pid of await readProcessGroupMemberPids(owner.processGroupId)) { const member = await readControllerProcessIdentity(pid); if (member?.processGroupId === owner.processGroupId) this.observedMembers.set(pid, member); } } public async stop(): Promise { if (this.stopPromise) return this.stopPromise; this.state = 'stopping'; this.invalidate(new Error('Codex app-server is stopping.')); this.stopPromise = this.performStop(); try { await this.stopPromise; } catch (error) { this.state = 'failed'; this.diagnostic = codexDiagnostic(error, 'cleanup'); this.stopPromise = undefined; throw error; } } private async performStop(): Promise { if (this.processIdentity && this.child?.exitCode === null && this.child.signalCode === null) { const current = await readControllerProcessIdentity(this.processIdentity.pid); if (current?.fingerprint === this.processIdentity.fingerprint) await this.observeOwnedMembers(); } this.client?.close(); if (this.processIdentity) { await this.signalOwnedGroup('SIGTERM'); if (!await this.waitForOwnedGroupExit(5000)) { await this.signalOwnedGroup('SIGKILL'); if (!await this.waitForOwnedGroupExit(2000)) throw new Error('The owned Codex process group did not terminate.'); } } else if (this.child && this.child.exitCode === null && this.child.signalCode === null) { this.child.kill('SIGTERM'); if (this.childExited && !await waitBounded(this.childExited, 5000)) this.child.kill('SIGKILL'); } if (this.childExited && !await waitBounded(this.childExited, 2000)) throw new Error('Codex child termination was not confirmed.'); this.state = 'stopped'; } private async signalOwnedGroup(signalArg: NodeJS.Signals): Promise { const owner = this.processIdentity; if (!owner) return; const members = await readProcessGroupMemberPids(owner.processGroupId); if (members.length === 0) return; let ownershipConfirmed = false; let liveMembers = 0; for (const pid of members) { const current = await readControllerProcessIdentity(pid); const expected = this.observedMembers.get(pid); if (current?.processGroupId === owner.processGroupId) liveMembers += 1; if (current && expected && current.fingerprint === expected.fingerprint && current.processGroupId === owner.processGroupId) ownershipConfirmed = true; } if (liveMembers === 0) return; if (!ownershipConfirmed) throw new Error('Codex process-group ownership cannot be verified; refusing to signal it.'); try { process.kill(-owner.processGroupId, signalArg); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; } } private async waitForOwnedGroupExit(timeoutMsArg: number): Promise { const owner = this.processIdentity; if (!owner) return true; const deadline = Date.now() + timeoutMsArg; do { if ((await readProcessGroupMemberPids(owner.processGroupId)).length === 0) return true; await new Promise((resolve) => setTimeout(resolve, 50)); } while (Date.now() < deadline); return false; } private invalidate(errorArg: Error): void { if (this.signal.aborted) return; if (this.state !== 'stopping') this.diagnostic = codexDiagnostic(errorArg, this.state === 'starting' ? 'startup' : 'connection'); this.abortController.abort(errorArg); if (this.state !== 'stopping') this.state = 'failed'; this.options.onUnavailable(errorArg); } public static async assertRuntimeTerminated(runtimeArg: ICodexCreationRuntime): Promise { if (!runtimeArg.process) throw new Error('A remote Codex disconnect does not prove server termination.'); const current = await readControllerProcessIdentity(runtimeArg.process.pid); if (current?.fingerprint === runtimeArg.process.fingerprint) throw new Error('The dispatched Codex process is still alive.'); if ((await readProcessGroupMemberPids(runtimeArg.process.processGroupId)).length > 0) throw new Error('The dispatched Codex process group has not conclusively ended.'); } }