Files
openchamber/packages/web/server/lib/opencode/lifecycle.js
T
Bohdan Triapitsyn e908db637b feat: agent and CLI control plane for sessions, worktrees, and scheduled tasks (#2408)
Add a shared OpenChamber control service with two thin adapters — a native
`openchamber` tool injected into managed OpenCode, and new CLI commands — so
users can manage parallel sessions, worktrees, and scheduled tasks
conversationally through agents or from the terminal.

Control plane:
- New openchamber-control service owning a fixed action contract:
  projects.list, models.list, session list/create/send/fork/status/messages,
  and schedule list/create/run/delete/toggle. Session and worktree deletion
  and project registration are deliberately not exposed.
- New openchamber-sessions module owning create/worktree/prompt orchestration,
  Goal Mode dispatch, wait semantics (initial idle never counts as completion;
  timeout and cancellation are failures), and explicit partial-failure results.
- Scheduled-task logic extracted into a service shared by routes, CLI, and the
  agent tool.

Agent tool:
- Managed OpenCode gets a materialized plugin registering one typed tool with
  a loopback-only callback, per-child ephemeral bearer (timing-safe, never
  persisted or logged), and abort propagation into the service.
- The ~1.5k-token schema applies progressive disclosure: short descriptions,
  server-side validation returning actionable usage errors, and intent
  guardrails — created sessions/tasks are user-facing work (not age
  self-delegation); worktree/goal/agent/variant/wait are omit-by-default;
  dispatches produce no completion notification, and later result r
  to session.messages, which now returns the authoritative sessionStatus.
- session.create without a user-named model picks from favorites/re
  send/fork omit the selection and the service reuses the target session's
  last user-message model, agent, and variant before falling back t
- An "Agent control tool" setting (default on, Save + Reload to apply)
  disables plugin injection entirely.

CLI:
- New `openchamber session`, `schedule`, `projects`, and `models` commands
  with automatic instance targeting, --wait/--timeout/--last-assist
  worktree flags, and Goal Mode, preserving interactive, non-TTY, --quiet,
  and --json contracts. The control HTTP timeout derives from the w
  instead of the 4-second default.

UI:
- New built-in "Schedule a Task" starter (/schedule-task) running a
  dialogue that defines a task and offers to create it via the tool after
  explicit confirmation; Craft a Goal and Feature Planning gain the
  handoff offer, and guided starters reserve the question tool for concrete
  option choices. Localized in all 10 locales, migrated into custom
  starter lists, hidden on VS Code.
- Sidebar shows CLI/agent-created sessions live via the control eve
- openchamber tool calls render with per-action titles and metadata.
2026-07-24 21:54:28 +03:00

1000 lines
32 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.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';
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,
} = 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';
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(),
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 () => {
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();
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();
try {
const serverInstance = await createManagedOpenCodeServerProcess({
hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
port: spawnPort,
timeout: 30000,
cwd: state.openCodeWorkingDirectory,
shellEnvKeysCount: Object.keys(shellEnv).length,
env: {
...shellEnv,
...process.env,
...managedOpenCodeEnv,
PATH: envPath,
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 {
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();
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();
} 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 () => {
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 { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) });
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) {
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;
}
};
/**
* 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 healthProbePromise = null;
let healthCheckCyclePromise = null;
let lastHealthProbeResult = null;
const resetHealthFailureState = () => {
consecutiveHealthFailures = 0;
lastUnhealthyWithBusySessionsAt = 0;
};
const probeOpenCodeHealth = async () => {
const now = Date.now();
if (lastHealthProbeResult && now - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) {
return lastHealthProbeResult.healthy;
}
if (healthProbePromise) {
return healthProbePromise;
}
healthProbePromise = isOpenCodeProcessHealthy()
.then((healthy) => {
lastHealthProbeResult = { at: Date.now(), healthy };
return healthy;
})
.finally(() => {
healthProbePromise = null;
});
return healthProbePromise;
};
const shouldSkipRestartForBusySessions = () => {
const activeCount = getActiveSessionCount();
if (activeCount === 0) {
lastUnhealthyWithBusySessionsAt = 0;
return false;
}
const now = Date.now();
if (!lastUnhealthyWithBusySessionsAt) {
lastUnhealthyWithBusySessionsAt = now;
return true;
}
if (now - 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;
}
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;
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,
};
};