import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import net from 'node:net'; import path from 'node:path'; export const createOpenCodeLifecycleRuntime = (deps) => { const { state, env, syncToHmrState, syncFromHmrState, getOpenCodeAuthHeaders, buildOpenCodeUrl, waitForReady, normalizeApiPrefix, applyOpencodeBinaryFromSettings, ensureOpencodeCliEnv, ensureLocalOpenCodeServerPassword, buildWslExecArgs, resolveWslExecutablePath, opencodeShimInterpreter, setOpenCodePort, setDetectedOpenCodeApiPrefix, setupProxy, ensureOpenCodeApiPrefix, clearResolvedOpenCodeBinary, } = deps; const killProcessOnPort = (port) => { if (!port) return; try { const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); const output = result.stdout || ''; const myPid = process.pid; for (const pidStr of output.split(/\s+/)) { const pid = parseInt(pidStr.trim(), 10); if (pid && pid !== myPid) { try { spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 }); } catch { } } } } catch { } }; const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => { let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; let args = ['serve', '--hostname', hostname, '--port', String(port)]; if (process.platform === 'win32' && state.useWslForOpencode) { const wslBinary = state.resolvedWslBinary || resolveWslExecutablePath(); if (!wslBinary) { throw new Error('WSL executable not found while attempting to launch OpenCode from WSL'); } const wslOpencode = state.resolvedWslOpencodePath && state.resolvedWslOpencodePath.trim().length > 0 ? state.resolvedWslOpencodePath.trim() : 'opencode'; const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname; binary = wslBinary; args = buildWslExecArgs([ wslOpencode, 'serve', '--hostname', serveHost, '--port', String(port), ], state.resolvedWslDistro); } if (process.platform === 'win32' && !state.useWslForOpencode) { const interpreter = opencodeShimInterpreter(binary); if (interpreter) { args.unshift(binary); binary = interpreter; } else { try { const shimContent = fs.readFileSync(binary, 'utf8'); const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); if (jsMatch) { const candidate = path.resolve(path.dirname(binary), jsMatch[0]); if (fs.existsSync(candidate)) { const realInterp = opencodeShimInterpreter(candidate); if (realInterp) { args.unshift(candidate); binary = realInterp; } } } } catch { } } } const child = spawn(binary, args, { cwd, env: processEnv, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); const url = await new Promise((resolve, reject) => { let output = ''; let done = false; const finish = (handler, value) => { if (done) return; done = true; clearTimeout(timer); child.stdout?.off('data', onStdout); child.stderr?.off('data', onStderr); child.off('exit', onExit); child.off('error', onError); handler(value); }; const onStdout = (chunk) => { output += chunk.toString(); const lines = output.split('\n'); for (const line of lines) { if (!line.startsWith('opencode server listening')) continue; const match = line.match(/on\s+(https?:\/\/[^\s]+)/); if (!match) { finish(reject, new Error(`Failed to parse server url from output: ${line}`)); return; } finish(resolve, match[1]); return; } }; const onStderr = (chunk) => { output += chunk.toString(); }; const onExit = (code) => { finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); }; const onError = (error) => { finish(reject, error); }; const timer = setTimeout(() => { finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`)); }, timeout); child.stdout?.on('data', onStdout); child.stderr?.on('data', onStderr); child.on('exit', onExit); child.on('error', onError); }); return { url, close() { try { child.kill('SIGTERM'); } catch { } }, }; }; const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => { if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { return requestedPort; } return await new Promise((resolve, reject) => { const server = net.createServer(); const cleanup = () => { server.removeAllListeners('error'); server.removeAllListeners('listening'); }; server.once('error', (error) => { cleanup(); reject(error); }); server.once('listening', () => { const address = server.address(); const port = address && typeof address === 'object' ? address.port : 0; server.close(() => { cleanup(); if (port > 0) { resolve(port); return; } reject(new Error('Failed to allocate OpenCode port')); }); }); server.listen(0, hostname); }); }; const isOpenCodeProcessHealthy = async () => { if (!state.openCodeProcess || !state.openCodePort) { return false; } try { const response = await fetch(buildOpenCodeUrl('/session', ''), { method: 'GET', headers: getOpenCodeAuthHeaders(), signal: AbortSignal.timeout(2000), }); return response.ok; } catch { return false; } }; const probeExternalOpenCode = async (port, origin) => { if (!port || port <= 0) { return false; } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); const base = origin ?? `http://127.0.0.1:${port}`; const response = await fetch(`${base}/global/health`, { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders(), }, signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) return false; const body = await response.json().catch(() => null); return body?.healthy === true; } catch { return false; } }; const waitForOpenCodePort = async (timeoutMs = 15000) => { if (state.openCodePort !== null) { return state.openCodePort; } const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)); if (state.openCodePort !== null) { return state.openCodePort; } } throw new Error('Timed out waiting for OpenCode port'); }; const startOpenCode = async () => { const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0; const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME); console.log( desiredPort > 0 ? `Starting OpenCode on requested port ${desiredPort}...` : `Starting OpenCode on allocated port ${spawnPort}...` ); await applyOpencodeBinaryFromSettings(); ensureOpencodeCliEnv(); const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true }); try { const serverInstance = await createManagedOpenCodeServerProcess({ hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME, port: spawnPort, timeout: 30000, cwd: state.openCodeWorkingDirectory, env: { ...process.env, OPENCODE_SERVER_PASSWORD: openCodePassword, }, }); if (!serverInstance || !serverInstance.url) { throw new Error('OpenCode server started but URL is missing'); } const url = new URL(serverInstance.url); const port = parseInt(url.port, 10); const prefix = normalizeApiPrefix(url.pathname); if (await waitForReady(serverInstance.url, 10000)) { setOpenCodePort(port); setDetectedOpenCodeApiPrefix(prefix); state.isOpenCodeReady = true; state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; return serverInstance; } try { serverInstance.close(); } catch { } throw new Error('Server started but health check failed (timeout)'); } catch (error) { const message = error instanceof Error ? error.message : String(error); state.lastOpenCodeError = message; state.openCodePort = null; syncToHmrState(); console.error(`Failed to start OpenCode: ${message}`); throw error; } }; const restartOpenCode = async () => { if (state.isShuttingDown) return; if (state.currentRestartPromise) { await state.currentRestartPromise; return; } state.currentRestartPromise = (async () => { state.isRestartingOpenCode = true; state.isOpenCodeReady = false; state.openCodeNotReadySince = Date.now(); console.log('Restarting OpenCode process...'); if (state.isExternalOpenCode) { console.log('Re-probing external OpenCode server...'); const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096; const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin; const healthy = await probeExternalOpenCode(probePort, probeOrigin); if (healthy) { console.log(`External OpenCode server on port ${probePort} is healthy`); setOpenCodePort(probePort); state.isOpenCodeReady = true; state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; syncToHmrState(); } else { state.lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`; console.error(state.lastOpenCodeError); throw new Error(state.lastOpenCodeError); } if (state.expressApp) { setupProxy(state.expressApp); ensureOpenCodeApiPrefix(); } return; } const portToKill = state.openCodePort; if (state.openCodeProcess) { console.log('Stopping existing OpenCode process...'); try { state.openCodeProcess.close(); } catch (error) { console.warn('Error closing OpenCode process:', error); } state.openCodeProcess = null; syncToHmrState(); } killProcessOnPort(portToKill); await new Promise((resolve) => setTimeout(resolve, 250)); if (env.ENV_CONFIGURED_OPENCODE_PORT) { console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`); setOpenCodePort(env.ENV_CONFIGURED_OPENCODE_PORT); } else { state.openCodePort = null; syncToHmrState(); } state.openCodeApiPrefixDetected = true; state.openCodeApiPrefix = ''; if (state.openCodeApiDetectionTimer) { clearTimeout(state.openCodeApiDetectionTimer); state.openCodeApiDetectionTimer = null; } state.lastOpenCodeError = null; state.openCodeProcess = await startOpenCode(); syncToHmrState(); if (state.expressApp) { setupProxy(state.expressApp); ensureOpenCodeApiPrefix(); } })(); try { await state.currentRestartPromise; } catch (error) { console.error(`Failed to restart OpenCode: ${error.message}`); state.lastOpenCodeError = error.message; if (!env.ENV_CONFIGURED_OPENCODE_PORT) { state.openCodePort = null; syncToHmrState(); } state.openCodeApiPrefixDetected = true; state.openCodeApiPrefix = ''; throw error; } finally { state.currentRestartPromise = null; state.isRestartingOpenCode = false; } }; const waitForOpenCodeReady = async (timeoutMs = 20000, intervalMs = 400) => { if (!state.openCodePort) { throw new Error('OpenCode port is not available'); } const deadline = Date.now() + timeoutMs; let lastError = null; while (Date.now() < deadline) { try { const [configResult, agentResult] = await Promise.all([ fetch(buildOpenCodeUrl('/config', ''), { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, }).catch((error) => error), fetch(buildOpenCodeUrl('/agent', ''), { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, }).catch((error) => error), ]); if (configResult instanceof Error) { lastError = configResult; await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } if (!configResult.ok) { lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`); await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } await configResult.json().catch(() => null); if (agentResult instanceof Error) { lastError = agentResult; await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } if (!agentResult.ok) { lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`); await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } await agentResult.json().catch(() => []); state.isOpenCodeReady = true; state.lastOpenCodeError = null; return; } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } if (lastError) { state.lastOpenCodeError = lastError.message || String(lastError); throw lastError; } const timeoutError = new Error('Timed out waiting for OpenCode to become ready'); state.lastOpenCodeError = timeoutError.message; throw timeoutError; }; const waitForAgentPresence = async (agentName, timeoutMs = 15000, intervalMs = 300) => { if (!state.openCodePort) { throw new Error('OpenCode port is not available'); } const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await fetch(buildOpenCodeUrl('/agent'), { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, }); if (response.ok) { const agents = await response.json(); if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) { return; } } } catch { } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Agent "${agentName}" not available after OpenCode restart`); }; const refreshOpenCodeAfterConfigChange = async (reason, options = {}) => { const { agentName } = options; console.log(`Refreshing OpenCode after ${reason}`); clearResolvedOpenCodeBinary(); await applyOpencodeBinaryFromSettings(); await restartOpenCode(); try { await waitForOpenCodeReady(); state.isOpenCodeReady = true; state.openCodeNotReadySince = 0; if (agentName) { await waitForAgentPresence(agentName); } state.isOpenCodeReady = true; state.openCodeNotReadySince = 0; } catch (error) { state.isOpenCodeReady = false; state.openCodeNotReadySince = Date.now(); console.error(`Failed to refresh OpenCode after ${reason}:`, error.message); throw error; } }; const bootstrapOpenCodeAtStartup = async () => { try { syncFromHmrState(); if (await isOpenCodeProcessHealthy()) { console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`); } else if (env.ENV_SKIP_OPENCODE_START && env.ENV_EFFECTIVE_PORT) { const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`; console.log(`Using external OpenCode server at ${label} (skip-start mode)`); state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; setOpenCodePort(env.ENV_EFFECTIVE_PORT); state.isOpenCodeReady = true; state.isExternalOpenCode = true; state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; syncToHmrState(); } else if (env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(env.ENV_EFFECTIVE_PORT, env.ENV_CONFIGURED_OPENCODE_HOST?.origin)) { const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`; console.log(`Auto-detected existing OpenCode server at ${label}`); state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; setOpenCodePort(env.ENV_EFFECTIVE_PORT); state.isOpenCodeReady = true; state.isExternalOpenCode = true; state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; syncToHmrState(); } else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { console.log('Auto-detected existing OpenCode server on default port 4096'); setOpenCodePort(4096); state.isOpenCodeReady = true; state.isExternalOpenCode = true; state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; syncToHmrState(); } else { if (env.ENV_EFFECTIVE_PORT) { console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`); setOpenCodePort(env.ENV_EFFECTIVE_PORT); } else { state.openCodePort = null; syncToHmrState(); } state.lastOpenCodeError = null; state.openCodeProcess = await startOpenCode(); syncToHmrState(); } await waitForOpenCodePort(); try { await waitForOpenCodeReady(); } catch (error) { console.error(`OpenCode readiness check failed: ${error.message}`); } } catch (error) { console.error(`Failed to start OpenCode: ${error.message}`); console.log('Continuing without OpenCode integration...'); state.lastOpenCodeError = error.message; } }; const startHealthMonitoring = (healthCheckIntervalMs) => { if (state.healthCheckInterval) { clearInterval(state.healthCheckInterval); } state.healthCheckInterval = setInterval(async () => { if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return; try { const healthy = await isOpenCodeProcessHealthy(); if (!healthy) { console.log('OpenCode process not running, restarting...'); await restartOpenCode(); } } catch (error) { console.error(`Health check error: ${error.message}`); } }, healthCheckIntervalMs); }; return { killProcessOnPort, startOpenCode, restartOpenCode, waitForOpenCodeReady, waitForAgentPresence, refreshOpenCodeAfterConfigChange, bootstrapOpenCodeAtStartup, startHealthMonitoring, }; };