import * as plugins from './plugins.js'; import { openCodeExpectedVersion, openCodeUsername, type IOpenCodeConnectionConfig, type IOpenCodeHealth, type IOpenCodeLogEntry, type IOpenCodeProcessGroupController, type IOpenCodeSupervisorOptions, type IOpenCodeSupervisorStatus, } from './interfaces.opencode.js'; import { controllerMcpCallerCredentialEnvironmentVariable } from '../ts_interfaces/index.js'; import { createSanitizedRuntimeEnvironment } from './functions.runtimeenvironment.js'; import { linuxOpenCodeProcessHasExited, openCodeServeArguments, OpenCodeOrphanRecovery, } from './classes.opencodeorphanrecovery.js'; const defaultStartupTimeoutMs = 20_000; const defaultHealthRequestTimeoutMs = 1_500; const defaultHealthPollIntervalMs = 150; const defaultStopTimeoutMs = 5_000; const defaultFinalStopTimeoutMs = 2_000; const defaultLogEntryLimit = 200; const defaultLogLineByteLimit = 8 * 1024; const maxHealthResponseBytes = 32 * 1024; const logTruncationSuffix = '… [truncated]'; const macOsLsofPath = '/usr/sbin/lsof'; interface IChildLifecycleOutcome { type: 'exit' | 'error'; code?: number | null; signal?: NodeJS.Signals | null; error?: Error; } interface ILogAccumulator { decoder: TextDecoder; partial: string; droppingUntilNewline: boolean; } type TStartupOperationOutcome = | { type: 'value'; value: T } | { type: 'operationError'; error: unknown } | { type: 'lifecycle'; outcome: IChildLifecycleOutcome } | { type: 'abort'; reason: unknown }; class OpenCodeVersionMismatchError extends Error {} class OpenCodeListenerOwnershipError extends Error {} const validateIntegerOption = ( value: number | undefined, fallback: number, name: string, minimum: number, maximum: number ): number => { const selected = value ?? fallback; if (!Number.isInteger(selected) || selected < minimum || selected > maximum) { throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); } return selected; }; const waitForPauseOrLifecycle = async ( milliseconds: number, lifecycle: Promise, signal?: AbortSignal ): Promise => { let timeout: NodeJS.Timeout | undefined; let abortListener: (() => void) | undefined; try { const outcomes: Array< Promise > = [ new Promise((resolve) => { timeout = setTimeout(() => resolve(undefined), milliseconds); }), lifecycle, ]; if (signal) { outcomes.push( new Promise<{ type: 'abort'; reason: unknown }>((resolve) => { if (signal.aborted) { resolve({ type: 'abort', reason: signal.reason }); return; } abortListener = () => resolve({ type: 'abort', reason: signal.reason }); signal.addEventListener('abort', abortListener, { once: true }); }) ); } return await Promise.race(outcomes); } finally { if (timeout) { clearTimeout(timeout); } if (signal && abortListener) { signal.removeEventListener('abort', abortListener); } } }; const waitForStartupOperation = async ( operation: Promise, lifecycle: Promise, signal?: AbortSignal ): Promise> => { let abortListener: (() => void) | undefined; try { const outcomes: Array>> = [ operation.then( (value) => ({ type: 'value' as const, value }), (error: unknown) => ({ type: 'operationError' as const, error }) ), lifecycle.then((outcome) => ({ type: 'lifecycle' as const, outcome })), ]; if (signal) { outcomes.push( new Promise<{ type: 'abort'; reason: unknown }>((resolve) => { if (signal.aborted) { resolve({ type: 'abort', reason: signal.reason }); return; } abortListener = () => resolve({ type: 'abort', reason: signal.reason }); signal.addEventListener('abort', abortListener, { once: true }); }) ); } return await Promise.race(outcomes); } finally { if (signal && abortListener) { signal.removeEventListener('abort', abortListener); } } }; const startupAbortError = (reason: unknown): Error => reason instanceof Error ? reason : new Error('OpenCode startup was aborted.', { cause: reason }); const waitBounded = async (promise: Promise, timeoutMs: number): Promise => { let timeout: NodeJS.Timeout | undefined; try { return await Promise.race([ promise.then(() => true), new Promise((resolve) => { timeout = setTimeout(() => resolve(false), timeoutMs); }), ]); } finally { if (timeout) { clearTimeout(timeout); } } }; const isMissingProcessError = (errorArg: unknown): boolean => typeof errorArg === 'object' && errorArg !== null && 'code' in errorArg && errorArg.code === 'ESRCH'; const defaultProcessGroupController: IOpenCodeProcessGroupController = { isAlive: (processGroupIdArg) => { try { process.kill(-processGroupIdArg, 0); return true; } catch (errorArg) { if (isMissingProcessError(errorArg)) return false; throw errorArg; } }, signal: (processGroupIdArg, signalArg) => { try { process.kill(-processGroupIdArg, signalArg); } catch (errorArg) { if (!isMissingProcessError(errorArg)) throw errorArg; } }, }; const waitForProcessGroupExit = async ( processGroupControllerArg: IOpenCodeProcessGroupController, processGroupIdArg: number, timeoutMsArg: number, ): Promise => { const deadline = Date.now() + timeoutMsArg; while (await processGroupControllerArg.isAlive(processGroupIdArg)) { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return false; await new Promise((resolve) => { setTimeout(resolve, Math.min(20, remainingMs)); }); } return true; }; const truncateLogLine = (value: string, byteLimit: number): { line: string; truncated: boolean } => { if (Buffer.byteLength(value, 'utf8') <= byteLimit) { return { line: value, truncated: false }; } const suffixBytes = Buffer.byteLength(logTruncationSuffix, 'utf8'); const contentBytes = Math.max(0, byteLimit - suffixBytes); let line = Buffer.from(value, 'utf8').subarray(0, contentBytes).toString('utf8'); line = line.replace(/\uFFFD$/u, ''); return { line: `${line}${logTruncationSuffix}`, truncated: true, }; }; const readBoundedResponseText = async (response: Response): Promise => { if (!response.body) { return ''; } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let byteLength = 0; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } byteLength += value.byteLength; if (byteLength > maxHealthResponseBytes) { await reader.cancel('OpenCode health response exceeded its size limit.'); throw new Error('OpenCode health response exceeded its size limit.'); } chunks.push(value); } } finally { reader.releaseLock(); } const body = new Uint8Array(byteLength); let offset = 0; for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(body); }; const createOpenCodeEnvironment = ( password: string, callerCredential?: string, ): NodeJS.ProcessEnv => { const environment = createSanitizedRuntimeEnvironment(); environment.OPENCODE_SERVER_USERNAME = openCodeUsername; environment.OPENCODE_SERVER_PASSWORD = password; // Written explicitly and never inheritable, so no ambient value can impersonate a runtime. delete environment[controllerMcpCallerCredentialEnvironmentVariable]; if (callerCredential !== undefined) { environment[controllerMcpCallerCredentialEnvironmentVariable] = callerCredential; } return environment; }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const detectLinuxLibc = (): 'glibc' | 'musl' => { const report = process.report?.getReport(); if (isRecord(report)) { const header = isRecord(report.header) ? report.header : undefined; if ( typeof header?.glibcVersionRuntime === 'string' && header.glibcVersionRuntime.length > 0 ) { return 'glibc'; } if ( Array.isArray(report.sharedObjects) && report.sharedObjects.some( (entry) => typeof entry === 'string' && (entry.includes('/ld-musl-') || entry.includes('/libc.musl-')) ) ) { return 'musl'; } } if (plugins.fs.existsSync('/etc/alpine-release')) { return 'musl'; } throw new Error('Unable to determine whether this Linux runtime uses glibc or musl.'); }; const resolvePlatformPackage = (): { packageName: string; binaryName: string } => { if (process.platform === 'linux') { const libcSuffix = detectLinuxLibc() === 'musl' ? '-musl' : ''; if (process.arch === 'x64') { return { packageName: `opencode-linux-x64-baseline${libcSuffix}`, binaryName: 'opencode', }; } if (process.arch === 'arm64') { return { packageName: `opencode-linux-arm64${libcSuffix}`, binaryName: 'opencode', }; } } else if (process.platform === 'darwin') { if (process.arch === 'x64') { return { packageName: 'opencode-darwin-x64-baseline', binaryName: 'opencode', }; } if (process.arch === 'arm64') { return { packageName: 'opencode-darwin-arm64', binaryName: 'opencode', }; } } else if (process.platform === 'win32') { if (process.arch === 'x64') { return { packageName: 'opencode-windows-x64-baseline', binaryName: 'opencode.exe', }; } if (process.arch === 'arm64') { return { packageName: 'opencode-windows-arm64', binaryName: 'opencode.exe', }; } } throw new Error( `OpenCode does not provide a supported package for ${process.platform}/${process.arch}.` ); }; const collectLinuxLoopbackListenerInodes = (port: number): Set => { const portHex = port.toString(16).toUpperCase().padStart(4, '0'); const expectedAddress = `0100007F:${portHex}`; const inodes = new Set(); const table = plugins.fs.readFileSync('/proc/net/tcp', 'utf8'); for (const line of table.split('\n').slice(1)) { const fields = line.trim().split(/\s+/u); if (fields.length >= 10 && fields[1] === expectedAddress && fields[3] === '0A') { inodes.add(fields[9]); } } return inodes; }; const verifyLinuxListenerOwnership = (processId: number, port: number): boolean => { const listenerInodes = collectLinuxLoopbackListenerInodes(port); if (listenerInodes.size === 0) { return false; } const descriptorDirectory = `/proc/${processId}/fd`; let descriptorNames: string[]; try { descriptorNames = plugins.fs.readdirSync(descriptorDirectory); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return false; } if ((error as NodeJS.ErrnoException).code === 'EACCES' && linuxOpenCodeProcessHasExited(processId)) { return false; } throw new Error(`Unable to inspect OpenCode process ${processId} file descriptors.`, { cause: error, }); } for (const descriptorName of descriptorNames) { let target: string; try { target = plugins.fs.readlinkSync( plugins.path.join(descriptorDirectory, descriptorName) ); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { continue; } if ((error as NodeJS.ErrnoException).code === 'EACCES' && linuxOpenCodeProcessHasExited(processId)) { return false; } throw new Error(`Unable to inspect OpenCode process ${processId} socket ownership.`, { cause: error, }); } const match = /^socket:\[(\d+)\]$/u.exec(target); if (match?.[1] && listenerInodes.has(match[1])) { return true; } } return false; }; const verifyMacOsListenerOwnership = (processId: number, port: number): boolean => { const result = plugins.childProcess.spawnSync( macOsLsofPath, [ '-nP', '-a', '-p', String(processId), `-iTCP@127.0.0.1:${port}`, '-sTCP:LISTEN', '-Fpn', ], { encoding: 'utf8', maxBuffer: 64 * 1024, timeout: 1_500, windowsHide: true, } ); if (result.error) { throw new Error('Unable to inspect OpenCode listener ownership with lsof.', { cause: result.error, }); } if (result.status === 1) { return false; } if (result.status !== 0) { throw new Error(`lsof failed while inspecting OpenCode with status ${result.status}.`); } const lines = result.stdout.split(/\r?\n/u); return ( lines.includes(`p${processId}`) && lines.some((line) => line === `n127.0.0.1:${port}`) ); }; const verifyWindowsListenerOwnership = (processId: number, port: number): boolean => { const systemRoot = process.env.SystemRoot; if (!systemRoot || !plugins.path.isAbsolute(systemRoot)) { throw new Error('SystemRoot is unavailable for Windows listener ownership verification.'); } const resolvedSystemRoot = plugins.fs.realpathSync.native(systemRoot); const netstatPath = plugins.fs.realpathSync.native( plugins.path.join(resolvedSystemRoot, 'System32', 'netstat.exe') ); const relativeNetstatPath = plugins.path.relative(resolvedSystemRoot, netstatPath); if ( relativeNetstatPath.startsWith(`..${plugins.path.sep}`) || relativeNetstatPath === '..' || plugins.path.isAbsolute(relativeNetstatPath) ) { throw new Error('The Windows netstat executable escaped SystemRoot.'); } const result = plugins.childProcess.spawnSync(netstatPath, ['-ano', '-p', 'tcp'], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, timeout: 1_500, windowsHide: true, }); if (result.error) { throw new Error('Unable to inspect OpenCode listener ownership with netstat.', { cause: result.error, }); } if (result.status !== 0) { throw new Error(`netstat failed while inspecting OpenCode with status ${result.status}.`); } const expectedEndpoint = `127.0.0.1:${port}`; return result.stdout.split(/\r?\n/u).some((line) => { const fields = line.trim().split(/\s+/u); return ( fields.length >= 5 && fields[0]?.toUpperCase() === 'TCP' && fields[1] === expectedEndpoint && fields[3]?.toUpperCase() === 'LISTENING' && fields[4] === String(processId) ); }); }; export const verifyOpenCodeListenerOwnership = ( processId: number, port: number ): boolean => { if (!Number.isSafeInteger(processId) || processId <= 0) { return false; } if (process.platform === 'linux') { return verifyLinuxListenerOwnership(processId, port); } if (process.platform === 'darwin') { return verifyMacOsListenerOwnership(processId, port); } if (process.platform === 'win32') { return verifyWindowsListenerOwnership(processId, port); } throw new Error(`Listener ownership verification is unsupported on ${process.platform}.`); }; export class OpenCodeSupervisor { public readonly directory: string; public readonly port: number; public readonly baseUrl: string; private readonly executablePath: string; private readonly password: string; private readonly startupTimeoutMs: number; private readonly healthRequestTimeoutMs: number; private readonly healthPollIntervalMs: number; private readonly stopTimeoutMs: number; private readonly finalStopTimeoutMs: number; private readonly logEntryLimit: number; private readonly logLineByteLimit: number; private readonly spawnFactory: typeof plugins.childProcess.spawn; private readonly fetchImplementation: typeof globalThis.fetch; private readonly listenerOwnershipVerifier: NonNullable< IOpenCodeSupervisorOptions['listenerOwnershipVerifier'] >; private readonly processGroupController?: IOpenCodeProcessGroupController; private readonly onChildExitObserved?: () => void; private readonly mintCallerCredential?: () => string | undefined; private readonly onChildExit?: () => void; private state: IOpenCodeSupervisorStatus['state'] = 'stopped'; private healthy = false; private version?: string; private startedAt?: number; private child?: plugins.childProcess.ChildProcess; private childLifecycle?: Promise; private ownedProcessGroupId?: number; private orphanedProcessGroupId?: number; private orphanReapTask?: Promise; private childExitCleanupPending = false; private startPromise?: Promise; private stopPromise?: Promise; private readonly logs: IOpenCodeLogEntry[] = []; private readonly stdoutAccumulator: ILogAccumulator = { decoder: new TextDecoder(), partial: '', droppingUntilNewline: false, }; private readonly stderrAccumulator: ILogAccumulator = { decoder: new TextDecoder(), partial: '', droppingUntilNewline: false, }; constructor(options: IOpenCodeSupervisorOptions) { this.directory = plugins.fs.realpathSync.native(options.directory); const directoryStat = plugins.fs.statSync(this.directory); if (!directoryStat.isDirectory()) { throw new Error('The configured OpenCode working directory is not a directory.'); } this.port = validateIntegerOption(options.port, options.port, 'OpenCode port', 1, 65_535); this.baseUrl = `http://127.0.0.1:${this.port}`; this.startupTimeoutMs = validateIntegerOption( options.startupTimeoutMs, defaultStartupTimeoutMs, 'OpenCode startup timeout', 1, 300_000 ); this.healthRequestTimeoutMs = validateIntegerOption( options.healthRequestTimeoutMs, defaultHealthRequestTimeoutMs, 'OpenCode health request timeout', 1, 60_000 ); this.healthPollIntervalMs = validateIntegerOption( options.healthPollIntervalMs, defaultHealthPollIntervalMs, 'OpenCode health poll interval', 1, 60_000 ); this.stopTimeoutMs = validateIntegerOption( options.stopTimeoutMs, defaultStopTimeoutMs, 'OpenCode stop timeout', 1, 300_000 ); this.finalStopTimeoutMs = validateIntegerOption( options.finalStopTimeoutMs, defaultFinalStopTimeoutMs, 'OpenCode final stop timeout', 1, 60_000 ); this.logEntryLimit = validateIntegerOption( options.logEntryLimit, defaultLogEntryLimit, 'OpenCode log entry limit', 1, 10_000 ); this.logLineByteLimit = validateIntegerOption( options.logLineByteLimit, defaultLogLineByteLimit, 'OpenCode log line byte limit', 16, 1024 * 1024 ); this.spawnFactory = options.spawnFactory ?? plugins.childProcess.spawn; this.fetchImplementation = options.fetchImplementation ?? globalThis.fetch; this.listenerOwnershipVerifier = options.listenerOwnershipVerifier ?? verifyOpenCodeListenerOwnership; this.processGroupController = process.platform !== 'win32' && (options.spawnFactory === undefined || options.processGroupController !== undefined) ? (options.processGroupController ?? defaultProcessGroupController) : undefined; this.onChildExitObserved = options.onChildExitObserved; this.mintCallerCredential = options.mintCallerCredential; this.onChildExit = options.onChildExit; this.password = plugins.crypto.randomBytes(32).toString('base64url'); this.executablePath = this.resolveExecutablePath(); } public getStatus(): IOpenCodeSupervisorStatus { return { state: this.state, healthy: this.healthy, ...(this.child?.pid ? { pid: this.child.pid } : {}), ...(this.version ? { version: this.version } : {}), ...(this.startedAt ? { startedAt: this.startedAt } : {}), }; } public getConnectionConfig(): IOpenCodeConnectionConfig { return { baseUrl: this.baseUrl, directory: this.directory, username: openCodeUsername, password: this.password, }; } public getLogSnapshot(): IOpenCodeLogEntry[] { return this.logs.map((entry) => ({ ...entry })); } public async start(signal?: AbortSignal): Promise { if (this.state === 'ready' && this.child) { return this.getStatus(); } if (this.startPromise) { return this.startPromise; } if (this.state === 'stopping') { throw new Error('OpenCode cannot start while it is stopping.'); } const startPromise = this.performStart(signal); this.startPromise = startPromise; try { return await startPromise; } finally { if (this.startPromise === startPromise) { this.startPromise = undefined; } } } public async checkHealth(): Promise { try { const health = await this.fetchHealth(); this.healthy = true; this.version = health.version; return health; } catch (error) { this.healthy = false; throw error; } } public async stop(): Promise { if (this.stopPromise) { return this.stopPromise; } const child = this.child; const lifecycle = this.childLifecycle; if (!child || !lifecycle) { if (this.ownedProcessGroupId !== undefined) { throw new Error('OpenCode process-group ownership outlived child lifecycle tracking.'); } await this.drainOrphanedProcessGroup(); this.child = undefined; this.childLifecycle = undefined; this.healthy = false; this.state = 'stopped'; this.startedAt = undefined; return; } const stopPromise = this.performStop(child, lifecycle); this.stopPromise = stopPromise; try { await stopPromise; } finally { if (this.stopPromise === stopPromise) { this.stopPromise = undefined; } } } private resolveExecutablePath(): string { const require = plugins.createRequire(import.meta.url); const platformPackage = resolvePlatformPackage(); const platformPackageJsonPath = plugins.fs.realpathSync.native( require.resolve(`${platformPackage.packageName}/package.json`) ); const installedPackageJson = JSON.parse( plugins.fs.readFileSync(platformPackageJsonPath, 'utf8') ) as unknown; if ( !isRecord(installedPackageJson) || installedPackageJson.name !== platformPackage.packageName || installedPackageJson.version !== openCodeExpectedVersion ) { throw new Error( `Expected ${platformPackage.packageName} ${openCodeExpectedVersion}.` ); } const packageDirectory = plugins.fs.realpathSync.native( plugins.path.dirname(platformPackageJsonPath) ); const executablePath = plugins.fs.realpathSync.native( plugins.path.join(packageDirectory, 'bin', platformPackage.binaryName) ); const relativeExecutablePath = plugins.path.relative(packageDirectory, executablePath); if ( relativeExecutablePath.startsWith(`..${plugins.path.sep}`) || relativeExecutablePath === '..' || plugins.path.isAbsolute(relativeExecutablePath) ) { throw new Error('The package-owned OpenCode executable escaped its package directory.'); } const executableStat = plugins.fs.statSync(executablePath); if ( !executableStat.isFile() || (process.platform !== 'win32' && (executableStat.mode & 0o111) === 0) ) { throw new Error('The package-owned OpenCode executable is not a file.'); } return executablePath; } private async performStart(signal?: AbortSignal): Promise { this.state = 'starting'; this.healthy = false; this.version = undefined; this.startedAt = undefined; try { signal?.throwIfAborted(); if (this.spawnFactory === plugins.childProcess.spawn) { await new OpenCodeOrphanRecovery({ directory: this.directory, executablePath: this.executablePath, port: this.port, listenerExists: () => collectLinuxLoopbackListenerInodes(this.port).size > 0, ownsListener: verifyOpenCodeListenerOwnership, stopTimeoutMs: this.stopTimeoutMs, finalStopTimeoutMs: this.finalStopTimeoutMs, }).recover(signal); } signal?.throwIfAborted(); const child = this.spawnFactory( this.executablePath, openCodeServeArguments(this.port), { cwd: this.directory, detached: this.processGroupController !== undefined, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: createOpenCodeEnvironment(this.password, this.mintCallerCredential?.()), } ); this.child = child; this.childLifecycle = this.observeChild(child); if (!child.pid) { throw new Error('OpenCode did not provide a process ID after spawn.'); } if (this.processGroupController) { this.ownedProcessGroupId = child.pid; } const deadline = Date.now() + this.startupTimeoutMs; let lastHealthError: unknown; while (Date.now() < deadline) { const lifecycle = this.childLifecycle; if (!lifecycle) { throw new Error('OpenCode child lifecycle tracking was lost.'); } const ownershipOutcome = await waitForStartupOperation( Promise.resolve().then(() => this.listenerOwnershipVerifier(child.pid!, this.port)), lifecycle, signal ); if (ownershipOutcome.type === 'abort') { throw startupAbortError(ownershipOutcome.reason); } if (ownershipOutcome.type === 'lifecycle') { throw this.lifecycleError( ownershipOutcome.outcome, 'OpenCode exited during startup.' ); } if (ownershipOutcome.type === 'operationError') { throw new OpenCodeListenerOwnershipError( 'OpenCode listener ownership could not be verified.', { cause: ownershipOutcome.error } ); } const listenerOwned = ownershipOutcome.value; if (!listenerOwned) { lastHealthError = new OpenCodeListenerOwnershipError( 'The loopback listener is not owned by the spawned OpenCode process.' ); const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { break; } const pauseOutcome = await waitForPauseOrLifecycle( Math.min(this.healthPollIntervalMs, remainingMs), lifecycle, signal ); if (pauseOutcome) { if (pauseOutcome.type === 'abort') { throw startupAbortError(pauseOutcome.reason); } throw this.lifecycleError(pauseOutcome, 'OpenCode exited during startup.'); } continue; } const healthOutcome = await waitForStartupOperation( this.fetchHealth(signal), lifecycle, signal ); if (healthOutcome.type === 'abort') { throw startupAbortError(healthOutcome.reason); } if (healthOutcome.type === 'lifecycle') { throw this.lifecycleError(healthOutcome.outcome, 'OpenCode exited during startup.'); } if (healthOutcome.type === 'value') { const secondOwnershipOutcome = await waitForStartupOperation( Promise.resolve().then(() => this.listenerOwnershipVerifier(child.pid!, this.port) ), lifecycle, signal ); if (secondOwnershipOutcome.type === 'abort') { throw startupAbortError(secondOwnershipOutcome.reason); } if (secondOwnershipOutcome.type === 'lifecycle') { throw this.lifecycleError( secondOwnershipOutcome.outcome, 'OpenCode exited during startup.' ); } if (secondOwnershipOutcome.type === 'operationError') { throw new OpenCodeListenerOwnershipError( 'OpenCode listener ownership could not be reverified.', { cause: secondOwnershipOutcome.error } ); } const stillOwned = secondOwnershipOutcome.value; if (!stillOwned) { throw new OpenCodeListenerOwnershipError( 'OpenCode lost ownership of its loopback listener during startup.' ); } if (child.exitCode !== null || child.signalCode !== null || this.child !== child) { throw new Error('OpenCode exited while completing its health check.'); } this.healthy = true; this.version = healthOutcome.value.version; this.startedAt = Date.now(); this.state = 'ready'; return this.getStatus(); } lastHealthError = healthOutcome.error; if (lastHealthError instanceof OpenCodeVersionMismatchError) { throw lastHealthError; } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { break; } const pauseOutcome = await waitForPauseOrLifecycle( Math.min(this.healthPollIntervalMs, remainingMs), lifecycle, signal ); if (pauseOutcome) { if (pauseOutcome.type === 'abort') { throw startupAbortError(pauseOutcome.reason); } throw this.lifecycleError(pauseOutcome, 'OpenCode exited during startup.'); } } throw new Error(`OpenCode did not become healthy within ${this.startupTimeoutMs}ms.`, { cause: lastHealthError, }); } catch (error) { let cleanupError: unknown; try { await this.stop(); } catch (caughtCleanupError) { cleanupError = caughtCleanupError; } this.state = 'failed'; this.healthy = false; this.startedAt = undefined; if (cleanupError) { throw new AggregateError( [error, cleanupError], 'OpenCode startup failed and its child process could not be cleaned up.' ); } throw new Error('OpenCode startup failed.', { cause: error }); } } /** * best-effort reaping of group members that survived their leader's exit. * Runs at exit time because that is when the pgid is still provably ours: * living members keep it reserved, and an already-empty group answers ESRCH. */ private async reapOrphanedGroup(processGroupIdArg: number): Promise { if (!this.processGroupController) return; if (!(await this.processGroupController.isAlive(processGroupIdArg))) return; await this.processGroupController.signal(processGroupIdArg, 'SIGTERM'); let exited = await waitForProcessGroupExit( this.processGroupController, processGroupIdArg, this.stopTimeoutMs, ); if (!exited) { await this.processGroupController.signal(processGroupIdArg, 'SIGKILL'); exited = await waitForProcessGroupExit( this.processGroupController, processGroupIdArg, this.finalStopTimeoutMs, ); } if (!exited) throw new Error('An owned OpenCode process-group member survived SIGKILL.'); } private notifyChildExitCleanup(): void { if (!this.childExitCleanupPending) return; this.childExitCleanupPending = false; try { this.onChildExit?.(); } catch { // Child lifecycle cleanup must not prevent lifecycle settlement. } } private async drainOrphanedProcessGroup(): Promise { const orphanReapTask = this.orphanReapTask; if (orphanReapTask) await orphanReapTask; const processGroupId = this.orphanedProcessGroupId; if (processGroupId !== undefined) { await this.reapOrphanedGroup(processGroupId); if (this.orphanedProcessGroupId === processGroupId) { this.orphanedProcessGroupId = undefined; } } this.notifyChildExitCleanup(); } private observeChild(child: plugins.childProcess.ChildProcess): Promise { child.stdout?.on('data', (chunk: unknown) => { this.consumeLogChunk('stdout', chunk, this.stdoutAccumulator); }); child.stderr?.on('data', (chunk: unknown) => { this.consumeLogChunk('stderr', chunk, this.stderrAccumulator); }); return new Promise((resolve) => { let settled = false; const finish = (outcome: IChildLifecycleOutcome): void => { if (settled) { return; } settled = true; this.flushLogAccumulator('stdout', this.stdoutAccumulator); this.flushLogAccumulator('stderr', this.stderrAccumulator); this.healthy = false; if (this.state === 'stopping') { this.state = 'stopped'; } else if (this.state === 'starting' || this.state === 'ready') { this.state = 'failed'; } try { this.onChildExitObserved?.(); } catch { // Immediate authority fencing must not prevent lifecycle settlement. } // once the group leader is gone the kernel may recycle its pid, so a // LATER stop() must never signal this group — but surviving group // descendants must still be reaped NOW, while living members keep the // pgid reserved and therefore safely targetable const orphanedGroupId = this.ownedProcessGroupId; this.ownedProcessGroupId = undefined; this.childExitCleanupPending = true; if (orphanedGroupId !== undefined && this.processGroupController) { this.orphanedProcessGroupId = orphanedGroupId; let reap!: Promise; reap = this.reapOrphanedGroup(orphanedGroupId).then(() => { if (this.orphanedProcessGroupId === orphanedGroupId) { this.orphanedProcessGroupId = undefined; } this.notifyChildExitCleanup(); }).finally(() => { if (this.orphanReapTask === reap) this.orphanReapTask = undefined; }); this.orphanReapTask = reap; void reap.catch(() => undefined); } else { this.notifyChildExitCleanup(); } resolve(outcome); }; child.once('exit', (code, signal) => { finish({ type: 'exit', code, signal }); }); child.once('error', (error) => { this.addLogEntry('stderr', `OpenCode process error: ${error.message}`, false); if (!child.pid) { finish({ type: 'error', error }); } }); }); } private async fetchHealth(callerSignal?: AbortSignal): Promise { callerSignal?.throwIfAborted(); const requestAbortController = new AbortController(); const abortFromCaller = (): void => { requestAbortController.abort(callerSignal?.reason); }; callerSignal?.addEventListener('abort', abortFromCaller, { once: true }); if (callerSignal?.aborted) { abortFromCaller(); } const timeout = setTimeout(() => { requestAbortController.abort( new Error(`OpenCode health request exceeded ${this.healthRequestTimeoutMs}ms.`) ); }, this.healthRequestTimeoutMs); timeout.unref(); const authorization = Buffer.from(`${openCodeUsername}:${this.password}`, 'utf8').toString( 'base64' ); try { const response = await this.fetchImplementation(`${this.baseUrl}/global/health`, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Basic ${authorization}`, }, redirect: 'error', signal: requestAbortController.signal, }); if (!response.ok) { throw new Error(`OpenCode health check returned HTTP ${response.status}.`); } const body = await readBoundedResponseText(response); let parsed: unknown; try { parsed = JSON.parse(body); } catch (error) { throw new Error('OpenCode health check returned invalid JSON.', { cause: error }); } if ( typeof parsed !== 'object' || parsed === null || !('healthy' in parsed) || !('version' in parsed) || parsed.healthy !== true || typeof parsed.version !== 'string' ) { throw new Error('OpenCode health check returned an invalid payload.'); } if (parsed.version !== openCodeExpectedVersion) { throw new OpenCodeVersionMismatchError( `OpenCode reported version ${parsed.version}; expected ${openCodeExpectedVersion}.` ); } return { healthy: true, version: openCodeExpectedVersion, }; } finally { clearTimeout(timeout); callerSignal?.removeEventListener('abort', abortFromCaller); } } private async performStop( child: plugins.childProcess.ChildProcess, lifecycle: Promise ): Promise { this.state = 'stopping'; this.healthy = false; // a leader that already exited cleared ownedProcessGroupId in observeChild // and handed surviving members to reapOrphanedGroup, so this branch only // ever signals a group whose leader is still ours const ownedProcessGroupId = this.ownedProcessGroupId; if (this.processGroupController && ownedProcessGroupId !== undefined) { let exited = !(await this.processGroupController.isAlive(ownedProcessGroupId)); if (!exited) { await this.processGroupController.signal(ownedProcessGroupId, 'SIGTERM'); exited = await waitForProcessGroupExit( this.processGroupController, ownedProcessGroupId, this.stopTimeoutMs, ); } if (!exited) { await this.processGroupController.signal(ownedProcessGroupId, 'SIGKILL'); exited = await waitForProcessGroupExit( this.processGroupController, ownedProcessGroupId, this.finalStopTimeoutMs, ); } if (!exited) { this.state = 'failed'; throw new Error('The owned OpenCode process group did not exit after SIGKILL.'); } const lifecycleSettled = await waitBounded(lifecycle, this.finalStopTimeoutMs); if (!lifecycleSettled) { this.state = 'failed'; throw new Error('OpenCode process-group exit was not confirmed by the child lifecycle.'); } this.ownedProcessGroupId = undefined; } else { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGTERM'); } catch (error) { if (child.exitCode === null && child.signalCode === null) { throw new Error('Failed to send SIGTERM to OpenCode.', { cause: error }); } } } let exited = await waitBounded(lifecycle, this.stopTimeoutMs); if (!exited && child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch (error) { if (child.exitCode === null && child.signalCode === null) { throw new Error('Failed to send SIGKILL to OpenCode.', { cause: error }); } } exited = await waitBounded(lifecycle, this.finalStopTimeoutMs); } if (!exited && child.exitCode === null && child.signalCode === null) { this.state = 'failed'; throw new Error('OpenCode did not exit after SIGKILL.'); } } // a leader that exited on its own may have left a reap task running; stop() // must not return while owned group members are still being signalled await this.drainOrphanedProcessGroup(); if (this.child === child) { this.child = undefined; } if (this.childLifecycle === lifecycle) { this.childLifecycle = undefined; } this.state = 'stopped'; this.healthy = false; this.startedAt = undefined; } private lifecycleError(outcome: IChildLifecycleOutcome, message: string): Error { if (outcome.type === 'error') { return new Error(message, { cause: outcome.error }); } const detail = outcome.signal ? `signal ${outcome.signal}` : `exit code ${String(outcome.code)}`; return new Error(`${message} (${detail})`); } private consumeLogChunk( stream: IOpenCodeLogEntry['stream'], chunk: unknown, accumulator: ILogAccumulator ): void { let bytes: Uint8Array; if (typeof chunk === 'string') { bytes = Buffer.from(chunk, 'utf8'); } else if (chunk instanceof Uint8Array) { bytes = chunk; } else { return; } let text = accumulator.decoder.decode(bytes, { stream: true }); while (text.length > 0) { if (accumulator.droppingUntilNewline) { const newlineIndex = text.indexOf('\n'); if (newlineIndex === -1) { return; } accumulator.droppingUntilNewline = false; text = text.slice(newlineIndex + 1); continue; } const newlineIndex = text.indexOf('\n'); if (newlineIndex !== -1) { const completeLine = `${accumulator.partial}${text.slice(0, newlineIndex)}`.replace( /\r$/u, '' ); accumulator.partial = ''; const bounded = truncateLogLine(completeLine, this.logLineByteLimit); this.addLogEntry(stream, bounded.line, bounded.truncated); text = text.slice(newlineIndex + 1); continue; } const combined = `${accumulator.partial}${text}`; if (Buffer.byteLength(combined, 'utf8') > this.logLineByteLimit) { const bounded = truncateLogLine(combined, this.logLineByteLimit); this.addLogEntry(stream, bounded.line, true); accumulator.partial = ''; accumulator.droppingUntilNewline = true; } else { accumulator.partial = combined; } return; } } private flushLogAccumulator( stream: IOpenCodeLogEntry['stream'], accumulator: ILogAccumulator ): void { const finalText = accumulator.decoder.decode(); if (finalText) { this.consumeLogChunk(stream, finalText, accumulator); } if (accumulator.partial && !accumulator.droppingUntilNewline) { const bounded = truncateLogLine(accumulator.partial, this.logLineByteLimit); this.addLogEntry(stream, bounded.line, bounded.truncated); } accumulator.partial = ''; accumulator.droppingUntilNewline = false; } private addLogEntry( stream: IOpenCodeLogEntry['stream'], line: string, truncated: boolean ): void { this.logs.push({ timestamp: Date.now(), stream, line, truncated, }); const excessEntries = this.logs.length - this.logEntryLimit; if (excessEntries > 0) { this.logs.splice(0, excessEntries); } } }