import { EventEmitter } from 'node:events'; import { mkdirSync } from 'node:fs'; import { createServer } from 'node:net'; import { join } from 'node:path'; import { execFile, spawn as nodeSpawn, type ChildProcess, type SpawnOptionsWithoutStdio, } from 'node:child_process'; import { ensureBundledAgentBrowserPlugin, ensureBundledCourseSkills, ensureBundledSuperpowersPlugin, getManagedOpencodeConfigDir, } from './superpowers'; import { logger } from '../utils/logger'; import { prependManagedRuntimesToPath, type PythonRuntime, type UvRuntime, } from '../utils/python-runtime'; import { promisify } from 'node:util'; export type OpencodeLifecycleState = 'stopped' | 'starting' | 'running' | 'error'; export interface OpencodeStatus { state: OpencodeLifecycleState; port: number; url?: string; pid?: number; error?: string; startedAt?: number; } type SpawnFn = ( command: string, args: string[], options: SpawnOptionsWithoutStdio, ) => ChildProcess; type ConfigProvider = () => Promise> | Record; type RuntimeConfigProvider = () => | Promise<{ config: Record; env?: Record }> | { config: Record; env?: Record }; type RuntimeConfig = { config: Record; env: Record }; type SpawnCommand = { command: string; args: string[]; env: Record; shell?: boolean; }; type PortOwner = { pid: number; executablePath?: string; commandLine?: string; }; type FindPortOwner = (port: number) => Promise; type KillProcess = (pid: number) => boolean; type RuntimeProviderDiagnostics = { id: string; name?: string; npm?: string; baseURL?: string; modelIds: string[]; hasApiKey: boolean; apiKeyEnv?: string; headerNames: string[]; }; type RuntimeMcpServerDiagnostics = { id: string; type?: string; enabled?: boolean; command: string[]; envKeys: string[]; }; export function summarizeRuntimeConfigForDiagnostics( config: Record, env: Record, ): { model: string | null; smallModel: string | null; providerIds: string[]; enabledProviderIds: string[]; envKeys: string[]; providers: RuntimeProviderDiagnostics[]; mcpServerIds: string[]; mcpServers: RuntimeMcpServerDiagnostics[]; } { const providerRecord = config.provider && typeof config.provider === 'object' && !Array.isArray(config.provider) ? config.provider as Record : {}; const providerIds = Object.keys(providerRecord); const enabledProviderIds = Array.isArray(config.enabled_providers) ? config.enabled_providers.filter((item): item is string => typeof item === 'string') : providerIds; const providers = providerIds.map((providerId) => { const rawProvider = providerRecord[providerId]; const provider = rawProvider && typeof rawProvider === 'object' && !Array.isArray(rawProvider) ? rawProvider as Record : {}; const options = provider.options && typeof provider.options === 'object' && !Array.isArray(provider.options) ? provider.options as Record : {}; const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models) ? provider.models as Record : {}; const headers = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers) ? options.headers as Record : {}; const apiKey = typeof options.apiKey === 'string' ? options.apiKey : undefined; const apiKeyEnv = apiKey?.match(/^\{env:([^}]+)\}$/)?.[1]; return { id: providerId, ...(typeof provider.name === 'string' ? { name: provider.name } : {}), ...(typeof provider.npm === 'string' ? { npm: provider.npm } : {}), ...(typeof options.baseURL === 'string' ? { baseURL: options.baseURL } : {}), modelIds: Object.keys(models), hasApiKey: Boolean(apiKey), ...(apiKeyEnv ? { apiKeyEnv } : {}), headerNames: Object.keys(headers), }; }); const mcpRecord = config.mcp && typeof config.mcp === 'object' && !Array.isArray(config.mcp) ? config.mcp as Record : {}; const mcpServerIds = Object.keys(mcpRecord); const mcpServers = mcpServerIds.map((serverId) => { const rawServer = mcpRecord[serverId]; const server = rawServer && typeof rawServer === 'object' && !Array.isArray(rawServer) ? rawServer as Record : {}; const command = Array.isArray(server.command) ? server.command.filter((item): item is string => typeof item === 'string') : []; const serverEnv = server.env && typeof server.env === 'object' && !Array.isArray(server.env) ? server.env as Record : {}; return { id: serverId, ...(typeof server.type === 'string' ? { type: server.type } : {}), ...(typeof server.enabled === 'boolean' ? { enabled: server.enabled } : {}), command, envKeys: Object.keys(serverEnv).sort(), }; }); return { model: typeof config.model === 'string' ? config.model : null, smallModel: typeof config.small_model === 'string' ? config.small_model : null, providerIds, enabledProviderIds, envKeys: Object.keys(env).sort(), providers, mcpServerIds, mcpServers, }; } export interface OpencodeManagerOptions { port: number; binPath: string; userDataDir?: string; bundledSuperpowersDir?: string; bundledCourseSkillsDir?: string; bundledAgentBrowserPluginPath?: string; pythonRuntime?: PythonRuntime; uvRuntime?: UvRuntime; spawn?: SpawnFn; configProvider?: ConfigProvider; runtimeConfigProvider?: RuntimeConfigProvider; startupTimeoutMs?: number; preflightPreferredPort?: boolean; findPortOwner?: FindPortOwner; killProcess?: KillProcess; } const execFileAsync = promisify(execFile); const ATTACHED_SERVER_STOP_TIMEOUT_MS = 2_000; const ATTACHED_SERVER_STOP_POLL_MS = 100; class OpencodePortReleaseError extends Error { constructor(readonly port: number) { super(`Timed out waiting for opencode port ${port} to be released`); this.name = 'OpencodePortReleaseError'; } } class OpencodeStartCancelledError extends Error { constructor() { super('opencode startup was stopped'); this.name = 'OpencodeStartCancelledError'; } } type RuntimeStartAttempt = { generation: number; requestSequence: number; controller: AbortController; }; function waitForAbortable(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(new OpencodeStartCancelledError()); return new Promise((resolve, reject) => { const onAbort = () => reject(new OpencodeStartCancelledError()); signal.addEventListener('abort', onAbort, { once: true }); void promise.then( (value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); }, ); }); } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function isLoopbackPortAvailable(port: number): Promise { if (!Number.isInteger(port) || port <= 0) return Promise.resolve(true); return new Promise((resolve) => { const server = createServer(); let settled = false; const finish = (available: boolean) => { if (settled) return; settled = true; if (server.listening) { server.close(() => resolve(available)); } else { resolve(available); } }; server.once('error', () => finish(false)); server.listen({ host: '127.0.0.1', port, exclusive: true }, () => finish(true)); }); } function parseRuntimePort(url: string): number | null { try { const port = Number(new URL(url).port); return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null; } catch { return null; } } function mergeProcessEnvironment( overrides: Record, platform: NodeJS.Platform = process.platform, ): Record { const environment: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (typeof value === 'string') environment[key] = value; } if (platform === 'win32') { const inheritedPath = Object.entries(environment).find(([key]) => key.toLowerCase() === 'path'); for (const key of Object.keys(environment)) { if (key.toLowerCase() === 'path') delete environment[key]; } if (inheritedPath) environment[inheritedPath[0]] = inheritedPath[1]; } for (const [key, value] of Object.entries(overrides)) { if (platform === 'win32' && key.toLowerCase() === 'path') { for (const existingKey of Object.keys(environment)) { if (existingKey.toLowerCase() === 'path') delete environment[existingKey]; } } environment[key] = value; } return environment; } function normalizeComparablePath(value: string | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) return null; return trimmed.replace(/^"+|"+$/g, '').replace(/\\/g, '/').toLowerCase(); } function isManagedOpencodePortOwner(owner: PortOwner, binPath: string): boolean { const expectedPath = normalizeComparablePath(binPath); if (!expectedPath) return false; const executablePath = normalizeComparablePath(owner.executablePath); if (executablePath && executablePath === expectedPath) return true; const commandLine = normalizeComparablePath(owner.commandLine); return Boolean(commandLine?.includes(expectedPath)); } function normalizePortOwner(input: unknown): PortOwner | null { if (!input || typeof input !== 'object' || Array.isArray(input)) return null; const record = input as Record; const rawPid = record.pid ?? record.ProcessId ?? record.processId; const pid = typeof rawPid === 'number' ? rawPid : typeof rawPid === 'string' ? Number(rawPid) : NaN; if (!Number.isInteger(pid) || pid <= 0) return null; return { pid, ...(typeof record.executablePath === 'string' ? { executablePath: record.executablePath } : {}), ...(typeof record.ExecutablePath === 'string' ? { executablePath: record.ExecutablePath } : {}), ...(typeof record.commandLine === 'string' ? { commandLine: record.commandLine } : {}), ...(typeof record.CommandLine === 'string' ? { commandLine: record.CommandLine } : {}), }; } async function findWindowsPortOwner(port: number): Promise { const script = [ `$connection = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1`, 'if (-not $connection) { exit 0 }', '$process = Get-CimInstance Win32_Process -Filter "ProcessId=$($connection.OwningProcess)" -ErrorAction SilentlyContinue', 'if (-not $process) { [pscustomobject]@{ pid = [int]$connection.OwningProcess } | ConvertTo-Json -Compress; exit 0 }', '[pscustomobject]@{ pid = [int]$process.ProcessId; executablePath = $process.ExecutablePath; commandLine = $process.CommandLine } | ConvertTo-Json -Compress', ].join('\n'); const { stdout } = await execFileAsync( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { encoding: 'utf8', windowsHide: true }, ); const trimmed = stdout.trim(); if (!trimmed) return null; return normalizePortOwner(JSON.parse(trimmed) as unknown); } async function findUnixPortOwner(port: number): Promise { const { stdout } = await execFileAsync( 'lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'], { encoding: 'utf8' }, ); const lines = stdout.split(/\r?\n/); let pid: number | null = null; for (const line of lines) { if (line.startsWith('p')) { const nextPid = Number(line.slice(1)); if (Number.isInteger(nextPid) && nextPid > 0) { pid = nextPid; break; } } } if (!pid) return null; try { const { stdout: commandOutput } = await execFileAsync( 'ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }, ); const commandLine = commandOutput.trim(); return commandLine ? { pid, commandLine } : { pid }; } catch { return { pid }; } } async function findListeningPortOwner(port: number): Promise { try { return process.platform === 'win32' ? await findWindowsPortOwner(port) : await findUnixPortOwner(port); } catch { return null; } } function killProcessByPid(pid: number): boolean { if (pid === process.pid) return false; try { return process.kill(pid); } catch { return false; } } export class OpencodeManager extends EventEmitter { private readonly spawnProcess: SpawnFn; private readonly configProvider?: ConfigProvider; private readonly runtimeConfigProvider?: RuntimeConfigProvider; private readonly findPortOwner: FindPortOwner; private readonly killProcess: KillProcess; private readonly startupTimeoutMs: number; private proc: ChildProcess | null = null; private lifecycleSequence = 0; private cancelStartsBeforeSequence = 0; private runtimeGeneration = 0; private activeRuntimeGeneration: number | null = null; private activeStartAttempt: RuntimeStartAttempt | null = null; private readonly trackedUnreleasedPorts = new Set(); private lifecycleBusy = false; private readonly lifecycleQueue: Array<() => void> = []; private exitCleanupPromise: Promise | null = null; private readonly ignoredExitProcesses = new WeakSet(); private status: OpencodeStatus; constructor(private readonly options: OpencodeManagerOptions) { super(); this.spawnProcess = options.spawn ?? nodeSpawn; this.configProvider = options.configProvider; this.runtimeConfigProvider = options.runtimeConfigProvider; this.findPortOwner = options.findPortOwner ?? findListeningPortOwner; this.killProcess = options.killProcess ?? killProcessByPid; this.startupTimeoutMs = options.startupTimeoutMs ?? 10_000; this.status = { state: 'stopped', port: options.port }; } getStatus(): OpencodeStatus { return { ...this.status }; } getManagedConfigDir(): string | null { const userDataDir = this.options.userDataDir?.trim(); return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null; } private enqueueLifecycle(operation: () => Promise): Promise { return new Promise((resolve, reject) => { const run = () => { this.lifecycleBusy = true; const finish = () => { this.lifecycleBusy = false; this.lifecycleQueue.shift()?.(); }; let result: Promise; try { result = operation(); } catch (error) { reject(error); finish(); return; } void result.then( (value) => { resolve(value); finish(); }, (error) => { reject(error); finish(); }, ); }; if (this.lifecycleBusy) { this.lifecycleQueue.push(run); } else { run(); } }); } async start(): Promise { const requestSequence = ++this.lifecycleSequence; return await this.enqueueLifecycle(async () => { this.assertStartAllowed(requestSequence); if (this.status.state === 'running') return this.getStatus(); if (this.proc) await this.stopRuntime(); return await this.startRuntime(requestSequence); }); } async stop(): Promise { const requestSequence = ++this.lifecycleSequence; this.cancelEarlierStarts(requestSequence); await this.enqueueLifecycle(async () => { await this.stopRuntime(); }); } private async stopRuntime(): Promise { const proc = this.proc; const generation = this.activeRuntimeGeneration; const port = this.status.port > 0 ? this.status.port : this.options.port; try { if (proc) { const exitPromise = this.waitForProcessExit(proc); this.ignoredExitProcesses.add(proc); if (!proc.kill()) { this.ignoredExitProcesses.delete(proc); throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`); } if (typeof proc.pid === 'number') { const [exited, released] = await Promise.all([ exitPromise, this.waitForPortToBeReleased(port), ]); this.recordPortReleaseOutcome(port, exited, released); if (!exited) { throw new Error(`Timed out waiting for opencode process ${proc.pid} to exit`); } if (!released) { throw new OpencodePortReleaseError(port); } } } else { await this.stopAttachedServerIfManaged(port); } await this.verifyTrackedPortsReleased(); if (this.proc === proc) { this.proc = null; } if (this.activeRuntimeGeneration === generation) { this.activeRuntimeGeneration = null; } this.setStatus({ state: 'stopped', port: this.options.port }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const errorPort = error instanceof OpencodePortReleaseError ? error.port : port; this.setStatus({ state: 'error', port: errorPort, ...(typeof proc?.pid === 'number' ? { pid: proc.pid } : {}), error: message, }); throw error; } } async restart(): Promise { const requestSequence = ++this.lifecycleSequence; this.cancelEarlierStarts(requestSequence); return await this.enqueueLifecycle(async () => { try { await this.stopRuntime(); } catch (error) { if (!(error instanceof OpencodePortReleaseError)) throw error; logger.warn('[opencode-runtime] Restarting on another loopback port after release timeout', { port: error.port, }); } this.assertStartAllowed(requestSequence); return await this.startRuntime(requestSequence); }); } async checkHealth(): Promise<{ ok: boolean; status: OpencodeStatus }> { return { ok: this.status.state === 'running', status: this.getStatus(), }; } private async startRuntime( requestSequence: number, requestedPort?: number, allowPortFallback = true, ): Promise { const attempt = this.beginStartAttempt(requestSequence); try { let runtimePort = requestedPort ?? this.options.port; if ( requestedPort === undefined && (this.options.preflightPreferredPort || this.options.findPortOwner) ) { const preferredPort = await waitForAbortable( this.inspectPreferredPort(), attempt.controller.signal, ); if (preferredPort.occupied && !preferredPort.existingServer) { runtimePort = 0; this.logPortFallback(); } } this.setStatus({ state: 'starting', port: runtimePort }); const providedRuntimeConfig = this.resolveRuntimeConfig(); const runtimeConfig = providedRuntimeConfig && typeof (providedRuntimeConfig as Promise).then === 'function' ? await waitForAbortable(providedRuntimeConfig, attempt.controller.signal) : providedRuntimeConfig; this.assertStartAllowed(requestSequence); if (attempt.controller.signal.aborted) throw new OpencodeStartCancelledError(); const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${runtimePort}`]; const runtimeEnv = this.resolveRuntimeEnv(runtimeConfig.env); const spawnCommand = this.resolveSpawnCommand(runtimeArgs, runtimeEnv); logger.info('[opencode-runtime] Starting runtime with provider config', { port: runtimePort, command: spawnCommand.command, args: spawnCommand.args, ...summarizeRuntimeConfigForDiagnostics(runtimeConfig.config, runtimeConfig.env), }); const childEnv = { ...spawnCommand.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(runtimeConfig.config), }; // Meowa credentials belong to Electron Main and must never be inherited by // the model runtime or exposed through the Agent's shell environment. delete childEnv.MEOWART_API_KEY; delete childEnv.MEOWART_DEV_KEY; const proc = this.spawnProcess(spawnCommand.command, spawnCommand.args, { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: spawnCommand.shell, }); this.proc = proc; return await new Promise((resolve, reject) => { let settled = false; let stderr = ''; let stdout = ''; let abortStartup = () => undefined; const appendStderr = (message: string) => { stderr = `${stderr}${message}`; if (stderr.length > 8_000) { stderr = stderr.slice(-8_000); } }; const formatStartupError = (message: string) => { const detail = stderr.trim(); return detail ? `${message}\n${detail}` : message; }; const claimSettlement = (): boolean => { if (settled) return false; settled = true; clearTimeout(timer); attempt.controller.signal.removeEventListener('abort', abortStartup); return true; }; const finish = (callback: () => void) => { if (!claimSettlement()) return; callback(); }; const terminateStartup = async () => { this.ignoredExitProcesses.add(proc); const exitPromise = this.waitForProcessExit(proc); proc.kill(); const [exited, released] = await Promise.all([ typeof proc.pid === 'number' ? exitPromise : Promise.resolve(true), this.waitForPortToBeReleased(runtimePort), ]); if (exited && this.proc === proc) this.proc = null; if (this.isActiveGeneration(attempt.generation)) { this.recordPortReleaseOutcome(runtimePort, exited, released); } }; const timer = setTimeout(() => { if (!claimSettlement()) return; const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`); if (this.isActiveGeneration(attempt.generation)) { this.setStatus({ state: 'error', port: runtimePort, error }); } void terminateStartup().then( () => reject(new Error(error)), reject, ); }, this.startupTimeoutMs); abortStartup = () => { if (!claimSettlement()) return; void terminateStartup().then( () => reject(new OpencodeStartCancelledError()), reject, ); }; attempt.controller.signal.addEventListener('abort', abortStartup, { once: true }); if (attempt.controller.signal.aborted) abortStartup(); proc.stdout?.on('data', (chunk: Buffer) => { if (!this.isActiveGeneration(attempt.generation) || this.proc !== proc) return; stdout = `${stdout}${chunk.toString()}`; if (stdout.length > 8_000) { stdout = stdout.slice(-8_000); } const match = stdout.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/); if (!match) return; const listeningPort = parseRuntimePort(match[1]); if (!listeningPort) return; finish(() => { this.setStatus({ state: 'running', port: listeningPort, url: match[1], pid: proc.pid, startedAt: Date.now(), }); resolve(this.getStatus()); }); }); proc.stderr?.on('data', (chunk: Buffer) => { if (!this.isActiveGeneration(attempt.generation)) return; const message = chunk.toString(); appendStderr(message); this.emit('stderr', message); }); proc.on('error', (error) => { finish(() => { if (this.proc === proc) this.proc = null; this.ignoredExitProcesses.add(proc); if (this.isActiveGeneration(attempt.generation)) { this.setStatus({ state: 'error', port: runtimePort, error: error.message }); } reject(error); }); }); proc.on('exit', (code) => { if (this.proc === proc) this.proc = null; if (!settled) { clearTimeout(timer); if (this.ignoredExitProcesses.has(proc)) { finish(() => reject(new Error('opencode startup was stopped'))); return; } void this.inspectRuntimePort(runtimePort, true).then((portState) => { if (!this.isActiveGeneration(attempt.generation)) { finish(() => reject(new OpencodeStartCancelledError())); return; } const existingServer = portState.existingServer; if (existingServer) { finish(() => { logger.warn( '[opencode-runtime] Attached to an existing server; newly generated provider config may not be active', { port: runtimePort, url: existingServer.url, }, ); this.setStatus(existingServer); resolve(this.getStatus()); }); return; } if ( allowPortFallback && runtimePort === this.options.port && portState.occupied ) { if (settled) return; settled = true; clearTimeout(timer); this.logPortFallback(); void this.startRuntime(requestSequence, 0, false).then(resolve, reject); return; } finish(() => { const error = formatStartupError(code == null ? 'opencode process exited' : `Exited with code ${code}`); if (this.isActiveGeneration(attempt.generation)) { this.setStatus({ state: 'error', port: runtimePort, error }); } reject(new Error(error)); }); }); return; } if (this.ignoredExitProcesses.has(proc)) return; this.beginUnexpectedExitCleanup( proc, code, this.isActiveGeneration(attempt.generation) ? this.status.port : runtimePort, attempt.generation, ); }); }); } catch (error) { if ( !(error instanceof OpencodeStartCancelledError) && this.isActiveGeneration(attempt.generation) && this.status.state === 'starting' ) { this.setStatus({ state: 'error', port: this.status.port, error: error instanceof Error ? error.message : String(error), }); } throw error; } finally { if (this.activeStartAttempt === attempt) { this.activeStartAttempt = null; } } } private beginStartAttempt(requestSequence: number): RuntimeStartAttempt { this.assertStartAllowed(requestSequence); const attempt = { generation: ++this.runtimeGeneration, requestSequence, controller: new AbortController(), }; this.activeRuntimeGeneration = attempt.generation; this.activeStartAttempt = attempt; return attempt; } private cancelEarlierStarts(requestSequence: number): void { this.cancelStartsBeforeSequence = Math.max( this.cancelStartsBeforeSequence, requestSequence, ); if ( this.activeStartAttempt && this.activeStartAttempt.requestSequence < requestSequence ) { this.activeStartAttempt.controller.abort(); } } private assertStartAllowed(requestSequence: number): void { if (requestSequence < this.cancelStartsBeforeSequence) { throw new OpencodeStartCancelledError(); } } private isActiveGeneration(generation: number): boolean { return this.activeRuntimeGeneration === generation; } private async inspectPreferredPort(): Promise<{ occupied: boolean; existingServer: OpencodeStatus | null; }> { return await this.inspectRuntimePort(this.options.port); } private async inspectRuntimePort(port: number, checkHealthFirst = false): Promise<{ occupied: boolean; existingServer: OpencodeStatus | null; }> { if (port <= 0) return { occupied: false, existingServer: null }; if (checkHealthFirst) { const existingServer = await this.findExistingServer(port); if (existingServer) return { occupied: true, existingServer }; } if (!this.options.findPortOwner) { const available = await isLoopbackPortAvailable(port); if (available) return { occupied: false, existingServer: null }; return { occupied: true, existingServer: await this.findExistingServer(port), }; } if (!checkHealthFirst) { const existingServer = await this.findExistingServer(port); if (existingServer) return { occupied: true, existingServer }; } return { occupied: Boolean(await this.findPortOwner(port)), existingServer: null, }; } private async findExistingServer(port: number): Promise { if (typeof globalThis.fetch !== 'function') return null; const url = `http://127.0.0.1:${port}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), Math.min(this.startupTimeoutMs, 1_000)); try { const response = await globalThis.fetch(`${url}/global/health`, { signal: controller.signal, }); if (!response.ok) return null; const body = await response.json().catch(() => null); if (!body || typeof body !== 'object') return null; if ((body as { healthy?: unknown }).healthy !== true) return null; return { state: 'running', port, url, startedAt: Date.now(), }; } catch { return null; } finally { clearTimeout(timeout); } } private async stopAttachedServerIfManaged(port: number): Promise { const owner = await this.findPortOwner(port); if (!owner || !isManagedOpencodePortOwner(owner, this.options.binPath)) { return; } if (!this.killProcess(owner.pid)) { this.trackedUnreleasedPorts.add(port); throw new Error(`Failed to stop attached opencode process ${owner.pid}`); } logger.warn('[opencode-runtime] Stopped attached bundled opencode server on managed port', { port, pid: owner.pid, }); const released = await this.waitForPortToBeReleased(port); if (!released) { this.trackedUnreleasedPorts.add(port); throw new OpencodePortReleaseError(port); } this.trackedUnreleasedPorts.delete(port); } private async waitForPortToBeReleased(port: number): Promise { if (port <= 0) return true; const deadline = Date.now() + ATTACHED_SERVER_STOP_TIMEOUT_MS; while (Date.now() < deadline) { const released = await this.isPortReleased(port); if (released) return true; await sleep(ATTACHED_SERVER_STOP_POLL_MS); } return false; } private async isPortReleased(port: number): Promise { if (port <= 0) return true; return this.options.findPortOwner ? !await this.findPortOwner(port) : await isLoopbackPortAvailable(port); } private async verifyTrackedPortsReleased(): Promise { for (const port of this.trackedUnreleasedPorts) { if (await this.isPortReleased(port)) { this.trackedUnreleasedPorts.delete(port); continue; } throw new OpencodePortReleaseError(port); } } private recordPortReleaseOutcome( port: number, processExited: boolean, portReleased: boolean, ): void { if (port <= 0) return; if (processExited && portReleased) { this.trackedUnreleasedPorts.delete(port); return; } this.trackedUnreleasedPorts.add(port); } private waitForProcessExit(proc: ChildProcess): Promise { if (typeof proc.exitCode === 'number' || proc.signalCode != null) { return Promise.resolve(true); } return new Promise((resolve) => { const onExit = () => { clearTimeout(timer); resolve(true); }; const timer = setTimeout(() => { proc.off('exit', onExit); resolve(false); }, ATTACHED_SERVER_STOP_TIMEOUT_MS); proc.once('exit', onExit); }); } private beginUnexpectedExitCleanup( proc: ChildProcess, code: number | null, port: number, generation: number, ): void { const cleanupPromise = this.enqueueLifecycle(async () => { const released = typeof proc.pid !== 'number' || await this.waitForPortToBeReleased(port); if (!this.isActiveGeneration(generation)) { if (!released && port > 0) this.trackedUnreleasedPorts.add(port); return; } this.recordPortReleaseOutcome(port, true, released); if (released) { this.activeRuntimeGeneration = null; this.setStatus({ state: 'stopped', port: this.options.port, error: code == null ? undefined : `Exited with code ${code}`, }); return; } const error = new OpencodePortReleaseError(port).message; this.setStatus({ state: 'error', port, ...(typeof proc.pid === 'number' ? { pid: proc.pid } : {}), error, }); }); this.exitCleanupPromise = cleanupPromise; void cleanupPromise.finally(() => { if (this.exitCleanupPromise === cleanupPromise) { this.exitCleanupPromise = null; } }); } private logPortFallback(): void { logger.warn('[opencode-runtime] Preferred port is occupied by an unhealthy listener; using an ephemeral port', { preferredPort: this.options.port, }); } private resolveRuntimeEnv(runtimeEnv: Record): Record { const inheritedEnvironment = mergeProcessEnvironment(runtimeEnv); const userDataDir = this.options.userDataDir?.trim(); if (!userDataDir) { return prependManagedRuntimesToPath( inheritedEnvironment, this.options.pythonRuntime, this.options.uvRuntime, ); } const baseDir = join(userDataDir, 'opencode'); const configHome = join(baseDir, 'config'); const dataHome = join(baseDir, 'data'); const cacheHome = join(baseDir, 'cache'); const managedConfigDir = this.getManagedConfigDir() ?? getManagedOpencodeConfigDir(userDataDir); mkdirSync(configHome, { recursive: true }); mkdirSync(dataHome, { recursive: true }); mkdirSync(cacheHome, { recursive: true }); mkdirSync(managedConfigDir, { recursive: true }); ensureBundledSuperpowersPlugin({ managedConfigDir, sourceDir: this.options.bundledSuperpowersDir, }); ensureBundledCourseSkills({ managedConfigDir, sourceDir: this.options.bundledCourseSkillsDir, }); ensureBundledAgentBrowserPlugin({ managedConfigDir, sourcePath: this.options.bundledAgentBrowserPluginPath, }); const environment = { ...inheritedEnvironment, OPENCODE_CONFIG_DIR: managedConfigDir, // Makelore exposes its managed and project-owned Skills explicitly. Avoid // rescanning unrelated user-wide ~/.claude and ~/.agents Skill libraries // during every project's cold bootstrap. OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', NIANCODE_GAME_ASSETS_CLI: join(managedConfigDir, 'skills', 'game-assets', 'meowart_api.py'), XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: dataHome, XDG_CACHE_HOME: cacheHome, }; return prependManagedRuntimesToPath( environment, this.options.pythonRuntime, this.options.uvRuntime, ); } private setStatus(next: OpencodeStatus): void { this.status = next; this.emit('status', this.getStatus()); } private resolveSpawnCommand(runtimeArgs: string[], runtimeEnv: Record): SpawnCommand { const binPath = this.options.binPath; if (process.platform !== 'win32') { return { command: binPath, args: runtimeArgs, env: runtimeEnv, }; } if (/\.(?:exe|cmd|bat)$/i.test(binPath)) { return { command: binPath, args: runtimeArgs, env: runtimeEnv, shell: /\.(?:cmd|bat)$/i.test(binPath), }; } return { command: process.execPath, args: [binPath, ...runtimeArgs], env: { ...runtimeEnv, ELECTRON_RUN_AS_NODE: '1', }, }; } private resolveRuntimeConfig(): RuntimeConfig | Promise { if (this.runtimeConfigProvider) { const providedRuntimeConfig = this.runtimeConfigProvider(); if (providedRuntimeConfig && typeof (providedRuntimeConfig as Promise).then === 'function') { return providedRuntimeConfig.then((runtimeConfig) => ({ config: runtimeConfig.config, env: runtimeConfig.env ?? {}, })); } return { config: providedRuntimeConfig.config, env: providedRuntimeConfig.env ?? {}, }; } const providedConfig = this.configProvider ? this.configProvider() : {}; if (providedConfig && typeof (providedConfig as Promise).then === 'function') { return providedConfig.then((config) => ({ config, env: {} })); } return { config: providedConfig, env: {} }; } }