import { spawn, spawnSync } from 'node:child_process'; import net from 'node:net'; import { stripAppImageArgv0Leak } from '../inherited-env.js'; import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; import { recordStartupPerformance } from './startup-performance.js'; const parsePositiveInt = (value, fallback) => { const parsed = Number.parseInt(String(value ?? ''), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; }; const HEALTH_CHECK_TIMEOUT_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_TIMEOUT_MS, 5000); const HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES = parsePositiveInt( process.env.OPENCHAMBER_OPENCODE_HEALTH_CONSECUTIVE_FAILURES, 20 ); const HEALTH_CHECK_INTERVAL_OVERRIDE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_INTERVAL_MS, 0); const HEALTH_CHECK_RESULT_CACHE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_CACHE_MS, 750); const OPENCODE_HEALTH_PATH = '/global/health'; // Last-used directory plus the three most recently opened projects — deeper // tails are unlikely to be the user's first click and just add background work. const WARMUP_DIRECTORY_LIMIT = 4; const WARMUP_REQUEST_TIMEOUT_MS = 30000; export const createOpenCodeLifecycleRuntime = (deps) => { const { state, env, syncToHmrState, syncFromHmrState, getOpenCodeAuthHeaders, buildOpenCodeUrl, waitForReady, normalizeApiPrefix, applyOpencodeBinaryFromSettings, ensureOpencodeCliEnv, ensureLocalOpenCodeServerPassword, resolveManagedOpenCodeLaunchSpec, setOpenCodePort, setDetectedOpenCodeApiPrefix, setupProxy, ensureOpenCodeApiPrefix, clearResolvedOpenCodeBinary, buildAugmentedPath, buildManagedOpenCodePath, getManagedOpenCodeShellEnvSnapshot, getManagedOpenCodeEnv = async () => ({}), getActiveSessionCount = () => 0, reapManagedOrphanedProcesses = reapOrphanedProcesses, getWarmupDirectories = async () => [], now = Date.now, } = deps; const killProcessOnPort = (port) => { if (!port || process.platform === 'win32') 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 hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null; const isManagedOpenCodeProcessAlive = () => { const child = state.openCodeProcess; if (!child || hasChildProcessExited(child)) return false; if (!child.pid) return true; try { process.kill(child.pid, 0); return true; } catch { return false; } }; const waitForChildProcessClose = (child, timeoutMs) => new Promise((resolve) => { if (!child || hasChildProcessExited(child)) { resolve(true); return; } let done = false; const finish = (closed) => { if (done) return; done = true; clearTimeout(timer); child.off('close', onClose); child.off('error', onError); resolve(closed); }; const onClose = () => finish(true); const onError = () => finish(hasChildProcessExited(child)); const timer = setTimeout(() => finish(hasChildProcessExited(child)), timeoutMs); child.once('close', onClose); child.once('error', onError); }); const waitForPortRelease = (port, timeoutMs, hostname = env.ENV_CONFIGURED_OPENCODE_HOSTNAME) => { if (!port) { return Promise.resolve(true); } const probeHost = !hostname || hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]' ? '127.0.0.1' : hostname; const deadline = Date.now() + timeoutMs; return new Promise((resolve) => { const attempt = () => { const socket = net.connect({ port, host: probeHost }); let settled = false; const finish = (released) => { if (settled) return; settled = true; socket.removeAllListeners(); socket.destroy(); if (released || Date.now() >= deadline) { resolve(released); return; } setTimeout(attempt, 150); }; socket.once('connect', () => finish(false)); socket.once('timeout', () => finish(true)); socket.once('error', (error) => { if (error && typeof error === 'object' && (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH')) { finish(true); return; } finish(false); }); socket.setTimeout(500); }; attempt(); }); }; const terminateChildProcess = async (child) => { if (!child) { return; } const pid = child.pid; if (!pid || hasChildProcessExited(child)) { await waitForChildProcessClose(child, 250); return; } const signalProcessTree = (signal) => { if (process.platform !== 'win32') { try { process.kill(-pid, signal); } catch { } } try { child.kill(signal); } catch { } }; if (process.platform === 'win32') { try { child.kill(); } catch { } if (await waitForChildProcessClose(child, 800)) { return; } try { spawnSync('taskkill', ['/pid', String(pid), '/t'], { stdio: 'ignore', timeout: 3000, windowsHide: true, }); } catch { } if (await waitForChildProcessClose(child, 1500)) { return; } try { spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], { stdio: 'ignore', timeout: 5000, windowsHide: true, }); } catch { } await waitForChildProcessClose(child, 3000); return; } signalProcessTree('SIGTERM'); if (await waitForChildProcessClose(child, 2500)) { return; } signalProcessTree('SIGKILL'); await waitForChildProcessClose(child, 1000); }; const closeManagedOpenCodeChild = async (child) => { const pid = child?.pid; try { await terminateChildProcess(child); } finally { // Drop it from the registry only once it has actually exited, so a child // that survived teardown stays eligible for the next run's reaper. if (Number.isInteger(pid) && hasChildProcessExited(child)) { unregisterManagedProcess(pid); } } }; const formatCapturedOutput = ({ stdout, stderr }) => { const parts = []; if (stdout.trim()) { parts.push(`stdout:\n${stdout.trim()}`); } if (stderr.trim()) { parts.push(`stderr:\n${stderr.trim()}`); } return parts.length > 0 ? parts.join('\n\n') : 'No stdout/stderr captured'; }; const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => { let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; const sourceBinary = binary; let args = ['serve', '--hostname', hostname, '--port', String(port)]; let launchWrapperType = null; if (process.platform === 'win32' && state.useWslForOpencode) { throw new Error('Launching OpenCode through WSL is no longer supported. Install OpenCode natively on Windows and configure opencode.cmd or opencode.exe.'); } if (process.platform === 'win32' && !state.useWslForOpencode) { const launchSpec = resolveManagedOpenCodeLaunchSpec(binary); if (launchSpec?.binary) { if (launchSpec.wrapperType) { console.log(`Launching OpenCode via ${launchSpec.wrapperType}: ${launchSpec.binary}`); } launchWrapperType = launchSpec.wrapperType || null; binary = launchSpec.binary; args = [...(Array.isArray(launchSpec.args) ? launchSpec.args : []), ...args]; } } const pathValue = typeof processEnv?.PATH === 'string' ? processEnv.PATH : ''; const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0; state.lastOpenCodeLaunchDiagnostics = { launchedAt: new Date().toISOString(), sourceBinary, binary, args, cwd, hostname, port, wrapperType: launchWrapperType, pathEntryCount, hasShellEnv: shellEnvKeysCount > 0, shellEnvKeysCount, }; console.log('[OpenCode] Launching managed server', state.lastOpenCodeLaunchDiagnostics); const child = spawn(binary, args, { cwd, env: processEnv, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); const url = await new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; 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) => { stdout += chunk.toString(); const lines = stdout.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) => { stderr += chunk.toString(); }; const onExit = (code, signal) => { const reason = signal ? `signal ${signal}` : `code ${code}`; const appBundleHint = process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(binary) ? ' The configured binary appears to point at the macOS desktop app bundle; OpenChamber needs the standalone opencode CLI.' : ''; finish(reject, new Error(`OpenCode process exited before serving with ${reason}. Binary used: ${binary}.${appBundleHint} ${formatCapturedOutput({ stdout, stderr })}`)); }; 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); }); // Record this child so a future run can reap it if we crash before teardown. // The web-server lifecycle runs in-process inside multiple hosts, so tag the // actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone // web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a // hardcoded label, matching the server's existing runtimeName convention. registerManagedProcess({ pid: child.pid, ownerPid: process.pid, port, binary, runtime: process.env.OPENCHAMBER_RUNTIME || 'web', }); return { url, pid: child.pid || null, async close() { await closeManagedOpenCodeChild(child); }, }; }; 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(OPENCODE_HEALTH_PATH, ''), { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders(), }, signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), }); if (!response.ok) return false; const body = await response.json().catch(() => null); return body?.healthy === true; } 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}${OPENCODE_HEALTH_PATH}`, { 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 START_OPEN_CODE_MAX_ATTEMPTS = 2; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const startOpenCodeOnce = async (attempt) => { const attemptStartedAt = performance.now(); let phaseStartedAt = attemptStartedAt; recordStartupPerformance('opencode.attempt.start', { attempt }); 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({ strict: true }); ensureOpencodeCliEnv(); recordStartupPerformance('opencode.binary.ready', { attempt, durationMs: performance.now() - phaseStartedAt, totalDurationMs: performance.now() - attemptStartedAt, }); phaseStartedAt = performance.now(); const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true }); let envPath = process.env.PATH; if (typeof buildManagedOpenCodePath === 'function') { envPath = buildManagedOpenCodePath(); } else if (typeof buildAugmentedPath === 'function') { envPath = buildAugmentedPath(); } const shellEnv = typeof getManagedOpenCodeShellEnvSnapshot === 'function' ? getManagedOpenCodeShellEnvSnapshot() || {} : {}; const managedOpenCodeEnv = await getManagedOpenCodeEnv(); recordStartupPerformance('opencode.environment.ready', { attempt, durationMs: performance.now() - phaseStartedAt, totalDurationMs: performance.now() - attemptStartedAt, }); phaseStartedAt = performance.now(); try { const serverInstance = await createManagedOpenCodeServerProcess({ hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME, port: spawnPort, timeout: 30000, cwd: state.openCodeWorkingDirectory, shellEnvKeysCount: Object.keys(shellEnv).length, env: stripAppImageArgv0Leak({ ...shellEnv, ...process.env, ...managedOpenCodeEnv, PATH: envPath, OPENCODE_SERVER_PASSWORD: openCodePassword, }), }); if (!serverInstance || !serverInstance.url) { throw new Error('OpenCode server started but URL is missing'); } recordStartupPerformance('opencode.process.ready', { attempt, durationMs: performance.now() - phaseStartedAt, totalDurationMs: performance.now() - attemptStartedAt, }); phaseStartedAt = performance.now(); 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; recordStartupPerformance('opencode.health.ready', { attempt, durationMs: performance.now() - phaseStartedAt, totalDurationMs: performance.now() - attemptStartedAt, outcome: 'ready', }); return serverInstance; } try { await 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(); recordStartupPerformance('opencode.attempt.error', { attempt, totalDurationMs: performance.now() - attemptStartedAt, outcome: 'error', }); console.error(`Failed to start OpenCode: ${message}`); throw error; } }; const startOpenCode = async () => { let lastError = null; for (let attempt = 1; attempt <= START_OPEN_CODE_MAX_ATTEMPTS; attempt += 1) { try { return await startOpenCodeOnce(attempt); } catch (error) { lastError = error; if (error?.code === 'OPENCODE_BINARY_INVALID') { break; } if (attempt >= START_OPEN_CODE_MAX_ATTEMPTS) { break; } const message = error instanceof Error ? error.message : String(error); console.warn(`[OpenCode] Managed server startup failed on attempt ${attempt}/${START_OPEN_CODE_MAX_ATTEMPTS}; retrying: ${message}`); state.openCodePort = null; state.isOpenCodeReady = false; state.openCodeNotReadySince = Date.now(); syncToHmrState(); await delay(750 * attempt); } } throw lastError; }; 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 { await state.openCodeProcess.close(); } catch (error) { console.warn('Error closing OpenCode process:', error); } state.openCodeProcess = null; syncToHmrState(); } killProcessOnPort(portToKill); if (!(await waitForPortRelease(portToKill, 5000))) { console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released`); } 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) { let timeout = null; try { const controller = new AbortController(); timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); const response = await fetch(buildOpenCodeUrl(OPENCODE_HEALTH_PATH, ''), { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, signal: controller.signal, }); clearTimeout(timeout); timeout = null; if (!response.ok) { lastError = new Error(`OpenCode health endpoint responded with status ${response.status}`); await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } const body = await response.json().catch(() => null); if (body?.healthy !== true) { lastError = new Error('OpenCode health endpoint returned unhealthy response'); await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } state.isOpenCodeReady = true; state.lastOpenCodeError = null; return; } catch (error) { lastError = error; } finally { if (timeout) { clearTimeout(timeout); } } 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(); // A managed OpenCode process is restarted (and thus re-reads config from // disk) by restartOpenCode(). An external OpenCode server is NOT owned by // OpenChamber: restartOpenCode() only re-probes its health, so the freshly // written config is on disk but the running server keeps serving its old, // startup-cached config until the user restarts it themselves. Report this // honestly so callers don't claim the change is live. const external = state.isExternalOpenCode === true; try { await waitForOpenCodeReady(); state.isOpenCodeReady = true; state.openCodeNotReadySince = 0; // Waiting for the agent to appear only makes sense when we actually // reloaded config. An external server will never surface it here. if (agentName && !external) { 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; } return { reloaded: !external, external }; }; const bootstrapOpenCodeAtStartup = async () => { const bootstrapStartedAt = performance.now(); let bootstrapError = null; recordStartupPerformance('opencode.bootstrap.start'); try { // Before doing anything, reap any OpenCode process WE spawned in a prior // run that was orphaned by a crash/hard-exit. Verified + scoped to our own // pids, so it never touches a live instance's or the user's own server. try { const orphanReapStartedAt = performance.now(); const { reaped } = await reapManagedOrphanedProcesses({ log: (msg) => console.log(msg) }); recordStartupPerformance('opencode.orphan-reap.ready', { durationMs: performance.now() - orphanReapStartedAt, totalDurationMs: performance.now() - bootstrapStartedAt, }); if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`); } catch (error) { console.warn('[lifecycle] orphan reap failed:', error?.message ?? error); } 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 { // We never auto-attach to an arbitrary pre-existing OpenCode instance. // Attaching to an external server requires explicit opt-in via env // (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START), handled by the // branches above. Without that opt-in we always start our OWN managed // instance on a freshly-allocated port. A blind probe of the default // port 4096 used to hijack a user's separately-running OpenCode (e.g. // the OpenCode desktop app), coupling our lifecycle to theirs and // breaking init against an unexpected server version/config. 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) { bootstrapError = error; console.error(`OpenCode readiness check failed: ${error.message}`); } } catch (error) { bootstrapError = error; console.error(`Failed to start OpenCode: ${error.message}`); console.log('Continuing without OpenCode integration...'); state.lastOpenCodeError = error.message; } recordStartupPerformance( bootstrapError ? 'opencode.bootstrap.error' : 'opencode.bootstrap.ready', { totalDurationMs: performance.now() - bootstrapStartedAt, outcome: bootstrapError ? 'error' : 'ready', }, ); if (!bootstrapError) { void warmOpenCodeDirectories(); } }; // OpenCode initializes each project directory lazily on its first // directory-scoped request, and that initialization takes seconds on large // session stores. Without warming, the user's first session open pays it // interactively (the chat waits on the message fetch until the directory // finishes initializing). Warm the most recently used directories right // after readiness so the work overlaps UI startup instead. Sequential and // best-effort: a failed or slow directory never blocks the others for long, // and a restart invalidates the pass via the port/readiness guard. const warmOpenCodeDirectories = async () => { let directories = []; try { directories = await getWarmupDirectories(); } catch { return; } if (!Array.isArray(directories) || directories.length === 0) return; const warmedPort = state.openCodePort; for (const directory of directories.slice(0, WARMUP_DIRECTORY_LIMIT)) { if (typeof directory !== 'string' || !directory) continue; if (!state.isOpenCodeReady || state.openCodePort !== warmedPort) return; let timeout = null; try { const controller = new AbortController(); timeout = setTimeout(() => controller.abort(), WARMUP_REQUEST_TIMEOUT_MS); const url = `${buildOpenCodeUrl('/session/status', '')}?directory=${encodeURIComponent(directory)}`; await fetch(url, { method: 'GET', headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, signal: controller.signal, }); } catch { // Best-effort — the directory stays lazy and the UI's own request warms it. } finally { if (timeout) clearTimeout(timeout); } } }; /** * Perform an immediate (one-shot) health check and restart OpenCode if it's * not healthy. Callers on the SSE / WS proxy path use this to trigger * recovery without waiting for the next periodic interval (up to 15 s). * * Skips restart when sessions are actively busy — a busy server under * concurrent load can fail the health check timeout without actually * being dead (the health endpoint competes with LLM work). * Forces restart if sessions stay "busy" and the server stays unhealthy * for over 2 minutes (staleness guard against stuck session state). */ const STALE_BUSY_GRACE_MS = 2 * 60 * 1000; let lastUnhealthyWithBusySessionsAt = 0; let consecutiveHealthFailures = 0; let lastCountedHealthFailureAt = 0; let healthProbePromise = null; let healthCheckCyclePromise = null; let lastHealthProbeResult = null; let healthFailureCountIntervalMs = 15_000; const resetHealthFailureState = () => { consecutiveHealthFailures = 0; lastUnhealthyWithBusySessionsAt = 0; lastCountedHealthFailureAt = 0; }; const probeOpenCodeHealth = async () => { const checkedAt = now(); if (lastHealthProbeResult && checkedAt - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) { return lastHealthProbeResult.healthy; } if (healthProbePromise) { return healthProbePromise; } healthProbePromise = isOpenCodeProcessHealthy() .then((healthy) => { lastHealthProbeResult = { at: now(), healthy }; return healthy; }) .finally(() => { healthProbePromise = null; }); return healthProbePromise; }; const shouldSkipRestartForBusySessions = () => { const activeCount = getActiveSessionCount(); if (activeCount === 0) { lastUnhealthyWithBusySessionsAt = 0; return false; } const checkedAt = now(); if (!lastUnhealthyWithBusySessionsAt) { lastUnhealthyWithBusySessionsAt = checkedAt; return true; } if (checkedAt - lastUnhealthyWithBusySessionsAt >= STALE_BUSY_GRACE_MS) { console.warn( `[lifecycle] OpenCode unhealthy with ${activeCount} busy session(s) for > 2 min — forcing restart` ); lastUnhealthyWithBusySessionsAt = 0; return false; } return true; }; const runHealthCheckCycle = async (source) => { if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return; if (healthCheckCyclePromise) return healthCheckCyclePromise; healthCheckCyclePromise = (async () => { const healthy = await probeOpenCodeHealth(); if (!healthy) { if (!isManagedOpenCodeProcessAlive()) { console.log(`[lifecycle] ${source} health check: OpenCode process exited, restarting...`); consecutiveHealthFailures = 0; lastHealthProbeResult = null; await restartOpenCode(); return; } const checkedAt = now(); if (lastCountedHealthFailureAt && checkedAt - lastCountedHealthFailureAt < healthFailureCountIntervalMs) { return; } lastCountedHealthFailureAt = checkedAt; consecutiveHealthFailures += 1; console.warn( `[lifecycle] ${source} health check failed (${consecutiveHealthFailures}/${HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES})` ); if (consecutiveHealthFailures < HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES) return; if (shouldSkipRestartForBusySessions()) return; console.log(`[lifecycle] ${source} health check failure threshold reached, restarting OpenCode...`); consecutiveHealthFailures = 0; lastHealthProbeResult = null; await restartOpenCode(); } else { resetHealthFailureState(); } })().finally(() => { healthCheckCyclePromise = null; }); return healthCheckCyclePromise; }; const triggerHealthCheck = async () => { try { await runHealthCheckCycle('immediate'); } catch (error) { console.error(`[lifecycle] immediate health check error: ${error.message}`); } }; const startHealthMonitoring = (healthCheckIntervalMs) => { if (state.healthCheckInterval) { clearInterval(state.healthCheckInterval); } const effectiveIntervalMs = HEALTH_CHECK_INTERVAL_OVERRIDE_MS || healthCheckIntervalMs; healthFailureCountIntervalMs = effectiveIntervalMs; state.healthCheckInterval = setInterval(async () => { try { await runHealthCheckCycle('periodic'); } catch (error) { console.error(`Health check error: ${error.message}`); } }, effectiveIntervalMs); }; return { killProcessOnPort, startOpenCode, restartOpenCode, waitForOpenCodeReady, waitForAgentPresence, refreshOpenCodeAfterConfigChange, bootstrapOpenCodeAtStartup, startHealthMonitoring, triggerHealthCheck, waitForPortRelease, }; };