AppImage exports ARGV0 into the process environment. zsh treats that as argv[0] for every external command, which broke Python venv detection in the integrated terminal and managed OpenCode sessions. Clear ARGV0 in Electron before login-shell probing, refuse to re-apply it from shell snapshots, and strip it from terminal PTY and managed OpenCode launch environments. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
1112 lines
37 KiB
JavaScript
1112 lines
37 KiB
JavaScript
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,
|
|
};
|
|
};
|