import { EventEmitter } from 'node:events'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { execFile, spawn as nodeSpawn, type ChildProcess, type SpawnOptionsWithoutStdio, } from 'node:child_process'; import { ensureBundledCourseSkills, ensureBundledSuperpowersPlugin, getManagedOpencodeConfigDir, } from './superpowers'; import { logger } from '../utils/logger'; import { prependPythonToPath, type PythonRuntime, } 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; pythonRuntime?: PythonRuntime; spawn?: SpawnFn; configProvider?: ConfigProvider; runtimeConfigProvider?: RuntimeConfigProvider; startupTimeoutMs?: number; findPortOwner?: FindPortOwner; killProcess?: KillProcess; } const execFileAsync = promisify(execFile); const ATTACHED_SERVER_STOP_TIMEOUT_MS = 2_000; const ATTACHED_SERVER_STOP_POLL_MS = 100; function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } 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 startPromise: Promise | null = null; 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; } async start(): Promise { if (this.status.state === 'running') return this.getStatus(); if (this.startPromise) return await this.startPromise; this.startPromise = this.startRuntime(); try { return await this.startPromise; } finally { this.startPromise = null; } } async stop(): Promise { const proc = this.proc; if (proc) { if (!proc.kill()) { throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`); } if (typeof proc.pid === 'number') { const released = await this.waitForPortOwnerToRelease(proc.pid); if (!released) { throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`); } } } else { await this.stopAttachedServerIfManaged(); } this.proc = null; this.setStatus({ state: 'stopped', port: this.options.port }); } async restart(): Promise { await this.stop(); return await this.start(); } async checkHealth(): Promise<{ ok: boolean; status: OpencodeStatus }> { return { ok: this.status.state === 'running', status: this.getStatus(), }; } private async startRuntime(): Promise { this.setStatus({ state: 'starting', port: this.options.port }); const providedRuntimeConfig = this.resolveRuntimeConfig(); const runtimeConfig = providedRuntimeConfig && typeof (providedRuntimeConfig as Promise).then === 'function' ? await providedRuntimeConfig : providedRuntimeConfig; const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${this.options.port}`]; const runtimeEnv = this.resolveRuntimeEnv(runtimeConfig.env); const spawnCommand = this.resolveSpawnCommand(runtimeArgs, runtimeEnv); logger.info('[opencode-runtime] Starting runtime with provider config', { port: this.options.port, command: spawnCommand.command, args: spawnCommand.args, ...summarizeRuntimeConfigForDiagnostics(runtimeConfig.config, runtimeConfig.env), }); const childEnv = { ...process.env, ...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 = ''; 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 finish = (callback: () => void) => { if (settled) return; settled = true; clearTimeout(timer); callback(); }; const timer = setTimeout(() => { finish(() => { const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`); this.setStatus({ state: 'error', port: this.options.port, error }); proc.kill(); reject(new Error(error)); }); }, this.startupTimeoutMs); proc.stdout?.on('data', (chunk: Buffer) => { const text = chunk.toString(); const match = text.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/); if (!match) return; finish(() => { this.setStatus({ state: 'running', port: this.options.port, url: match[1], pid: proc.pid, startedAt: Date.now(), }); resolve(this.getStatus()); }); }); proc.stderr?.on('data', (chunk: Buffer) => { const message = chunk.toString(); appendStderr(message); this.emit('stderr', message); }); proc.on('error', (error) => { finish(() => { this.proc = null; this.setStatus({ state: 'error', port: this.options.port, error: error.message }); reject(error); }); }); proc.on('exit', (code) => { this.proc = null; if (!settled) { clearTimeout(timer); void this.findExistingServer().then((existingServer) => { if (existingServer) { finish(() => { logger.warn( '[opencode-runtime] Attached to an existing server; newly generated provider config may not be active', { port: this.options.port, url: existingServer.url, }, ); this.setStatus(existingServer); resolve(this.getStatus()); }); return; } finish(() => { const error = formatStartupError(code == null ? 'opencode process exited' : `Exited with code ${code}`); this.setStatus({ state: 'error', port: this.options.port, error }); reject(new Error(error)); }); }); return; } if (this.status.state !== 'stopped') { this.setStatus({ state: 'stopped', port: this.options.port, error: code == null ? undefined : `Exited with code ${code}`, }); } }); }); } private async findExistingServer(): Promise { if (typeof globalThis.fetch !== 'function') return null; const url = `http://127.0.0.1:${this.options.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: this.options.port, url, startedAt: Date.now(), }; } catch { return null; } finally { clearTimeout(timeout); } } private async stopAttachedServerIfManaged(): Promise { const owner = await this.findPortOwner(this.options.port); if (!owner || !isManagedOpencodePortOwner(owner, this.options.binPath)) { return; } if (!this.killProcess(owner.pid)) { throw new Error(`Failed to stop attached opencode process ${owner.pid}`); } logger.warn('[opencode-runtime] Stopped attached bundled opencode server on managed port', { port: this.options.port, pid: owner.pid, }); const released = await this.waitForPortOwnerToRelease(owner.pid); if (!released) { throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`); } } private async waitForPortOwnerToRelease(pid: number): Promise { const deadline = Date.now() + ATTACHED_SERVER_STOP_TIMEOUT_MS; while (Date.now() < deadline) { const owner = await this.findPortOwner(this.options.port); if (!owner || owner.pid !== pid) return true; await sleep(ATTACHED_SERVER_STOP_POLL_MS); } return false; } private resolveRuntimeEnv(runtimeEnv: Record): Record { const userDataDir = this.options.userDataDir?.trim(); if (!userDataDir) { return this.options.pythonRuntime ? prependPythonToPath(runtimeEnv, this.options.pythonRuntime) : runtimeEnv; } 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, }); const environment = { ...runtimeEnv, 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 this.options.pythonRuntime ? prependPythonToPath(environment, this.options.pythonRuntime) : environment; } 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: {} }; } }