1140 lines
37 KiB
TypeScript
1140 lines
37 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { createServer } from 'node:net';
|
|
import { join } from 'node:path';
|
|
import {
|
|
execFile,
|
|
spawn as nodeSpawn,
|
|
type ChildProcess,
|
|
type SpawnOptionsWithoutStdio,
|
|
} from 'node:child_process';
|
|
import {
|
|
ensureBundledAgentBrowserPlugin,
|
|
ensureBundledCourseSkills,
|
|
ensureBundledSuperpowersPlugin,
|
|
getManagedOpencodeConfigDir,
|
|
} from './superpowers';
|
|
import { logger } from '../utils/logger';
|
|
import {
|
|
prependManagedRuntimesToPath,
|
|
type PythonRuntime,
|
|
type UvRuntime,
|
|
} from '../utils/python-runtime';
|
|
import { promisify } from 'node:util';
|
|
|
|
export type OpencodeLifecycleState = 'stopped' | 'starting' | 'running' | 'error';
|
|
|
|
export interface OpencodeStatus {
|
|
state: OpencodeLifecycleState;
|
|
port: number;
|
|
url?: string;
|
|
pid?: number;
|
|
error?: string;
|
|
startedAt?: number;
|
|
}
|
|
|
|
type SpawnFn = (
|
|
command: string,
|
|
args: string[],
|
|
options: SpawnOptionsWithoutStdio,
|
|
) => ChildProcess;
|
|
|
|
type ConfigProvider = () => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
type RuntimeConfigProvider = () =>
|
|
| Promise<{ config: Record<string, unknown>; env?: Record<string, string> }>
|
|
| { config: Record<string, unknown>; env?: Record<string, string> };
|
|
type RuntimeConfig = { config: Record<string, unknown>; env: Record<string, string> };
|
|
type SpawnCommand = {
|
|
command: string;
|
|
args: string[];
|
|
env: Record<string, string>;
|
|
shell?: boolean;
|
|
};
|
|
type PortOwner = {
|
|
pid: number;
|
|
executablePath?: string;
|
|
commandLine?: string;
|
|
};
|
|
type FindPortOwner = (port: number) => Promise<PortOwner | null>;
|
|
type KillProcess = (pid: number) => boolean;
|
|
|
|
type RuntimeProviderDiagnostics = {
|
|
id: string;
|
|
name?: string;
|
|
npm?: string;
|
|
baseURL?: string;
|
|
modelIds: string[];
|
|
hasApiKey: boolean;
|
|
apiKeyEnv?: string;
|
|
headerNames: string[];
|
|
};
|
|
|
|
type RuntimeMcpServerDiagnostics = {
|
|
id: string;
|
|
type?: string;
|
|
enabled?: boolean;
|
|
command: string[];
|
|
envKeys: string[];
|
|
};
|
|
|
|
export function summarizeRuntimeConfigForDiagnostics(
|
|
config: Record<string, unknown>,
|
|
env: Record<string, string>,
|
|
): {
|
|
model: string | null;
|
|
smallModel: string | null;
|
|
providerIds: string[];
|
|
enabledProviderIds: string[];
|
|
envKeys: string[];
|
|
providers: RuntimeProviderDiagnostics[];
|
|
mcpServerIds: string[];
|
|
mcpServers: RuntimeMcpServerDiagnostics[];
|
|
} {
|
|
const providerRecord = config.provider && typeof config.provider === 'object' && !Array.isArray(config.provider)
|
|
? config.provider as Record<string, unknown>
|
|
: {};
|
|
const providerIds = Object.keys(providerRecord);
|
|
const enabledProviderIds = Array.isArray(config.enabled_providers)
|
|
? config.enabled_providers.filter((item): item is string => typeof item === 'string')
|
|
: providerIds;
|
|
const providers = providerIds.map((providerId) => {
|
|
const rawProvider = providerRecord[providerId];
|
|
const provider = rawProvider && typeof rawProvider === 'object' && !Array.isArray(rawProvider)
|
|
? rawProvider as Record<string, unknown>
|
|
: {};
|
|
const options = provider.options && typeof provider.options === 'object' && !Array.isArray(provider.options)
|
|
? provider.options as Record<string, unknown>
|
|
: {};
|
|
const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models)
|
|
? provider.models as Record<string, unknown>
|
|
: {};
|
|
const headers = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)
|
|
? options.headers as Record<string, unknown>
|
|
: {};
|
|
const apiKey = typeof options.apiKey === 'string' ? options.apiKey : undefined;
|
|
const apiKeyEnv = apiKey?.match(/^\{env:([^}]+)\}$/)?.[1];
|
|
return {
|
|
id: providerId,
|
|
...(typeof provider.name === 'string' ? { name: provider.name } : {}),
|
|
...(typeof provider.npm === 'string' ? { npm: provider.npm } : {}),
|
|
...(typeof options.baseURL === 'string' ? { baseURL: options.baseURL } : {}),
|
|
modelIds: Object.keys(models),
|
|
hasApiKey: Boolean(apiKey),
|
|
...(apiKeyEnv ? { apiKeyEnv } : {}),
|
|
headerNames: Object.keys(headers),
|
|
};
|
|
});
|
|
const mcpRecord = config.mcp && typeof config.mcp === 'object' && !Array.isArray(config.mcp)
|
|
? config.mcp as Record<string, unknown>
|
|
: {};
|
|
const mcpServerIds = Object.keys(mcpRecord);
|
|
const mcpServers = mcpServerIds.map((serverId) => {
|
|
const rawServer = mcpRecord[serverId];
|
|
const server = rawServer && typeof rawServer === 'object' && !Array.isArray(rawServer)
|
|
? rawServer as Record<string, unknown>
|
|
: {};
|
|
const command = Array.isArray(server.command)
|
|
? server.command.filter((item): item is string => typeof item === 'string')
|
|
: [];
|
|
const serverEnv = server.env && typeof server.env === 'object' && !Array.isArray(server.env)
|
|
? server.env as Record<string, unknown>
|
|
: {};
|
|
return {
|
|
id: serverId,
|
|
...(typeof server.type === 'string' ? { type: server.type } : {}),
|
|
...(typeof server.enabled === 'boolean' ? { enabled: server.enabled } : {}),
|
|
command,
|
|
envKeys: Object.keys(serverEnv).sort(),
|
|
};
|
|
});
|
|
|
|
return {
|
|
model: typeof config.model === 'string' ? config.model : null,
|
|
smallModel: typeof config.small_model === 'string' ? config.small_model : null,
|
|
providerIds,
|
|
enabledProviderIds,
|
|
envKeys: Object.keys(env).sort(),
|
|
providers,
|
|
mcpServerIds,
|
|
mcpServers,
|
|
};
|
|
}
|
|
|
|
export interface OpencodeManagerOptions {
|
|
port: number;
|
|
binPath: string;
|
|
userDataDir?: string;
|
|
bundledSuperpowersDir?: string;
|
|
bundledCourseSkillsDir?: string;
|
|
bundledAgentBrowserPluginPath?: string;
|
|
pythonRuntime?: PythonRuntime;
|
|
uvRuntime?: UvRuntime;
|
|
spawn?: SpawnFn;
|
|
configProvider?: ConfigProvider;
|
|
runtimeConfigProvider?: RuntimeConfigProvider;
|
|
startupTimeoutMs?: number;
|
|
preflightPreferredPort?: boolean;
|
|
findPortOwner?: FindPortOwner;
|
|
killProcess?: KillProcess;
|
|
}
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const ATTACHED_SERVER_STOP_TIMEOUT_MS = 2_000;
|
|
const ATTACHED_SERVER_STOP_POLL_MS = 100;
|
|
|
|
class OpencodePortReleaseError extends Error {
|
|
constructor(readonly port: number) {
|
|
super(`Timed out waiting for opencode port ${port} to be released`);
|
|
this.name = 'OpencodePortReleaseError';
|
|
}
|
|
}
|
|
|
|
class OpencodeStartCancelledError extends Error {
|
|
constructor() {
|
|
super('opencode startup was stopped');
|
|
this.name = 'OpencodeStartCancelledError';
|
|
}
|
|
}
|
|
|
|
type RuntimeStartAttempt = {
|
|
generation: number;
|
|
requestSequence: number;
|
|
controller: AbortController;
|
|
};
|
|
|
|
function waitForAbortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
if (signal.aborted) return Promise.reject(new OpencodeStartCancelledError());
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
const onAbort = () => reject(new OpencodeStartCancelledError());
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
void promise.then(
|
|
(value) => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
resolve(value);
|
|
},
|
|
(error) => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function isLoopbackPortAvailable(port: number): Promise<boolean> {
|
|
if (!Number.isInteger(port) || port <= 0) return Promise.resolve(true);
|
|
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
let settled = false;
|
|
const finish = (available: boolean) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (server.listening) {
|
|
server.close(() => resolve(available));
|
|
} else {
|
|
resolve(available);
|
|
}
|
|
};
|
|
server.once('error', () => finish(false));
|
|
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => finish(true));
|
|
});
|
|
}
|
|
|
|
function parseRuntimePort(url: string): number | null {
|
|
try {
|
|
const port = Number(new URL(url).port);
|
|
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function mergeProcessEnvironment(
|
|
overrides: Record<string, string>,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): Record<string, string> {
|
|
const environment: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (typeof value === 'string') environment[key] = value;
|
|
}
|
|
|
|
if (platform === 'win32') {
|
|
const inheritedPath = Object.entries(environment).find(([key]) => key.toLowerCase() === 'path');
|
|
for (const key of Object.keys(environment)) {
|
|
if (key.toLowerCase() === 'path') delete environment[key];
|
|
}
|
|
if (inheritedPath) environment[inheritedPath[0]] = inheritedPath[1];
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
if (platform === 'win32' && key.toLowerCase() === 'path') {
|
|
for (const existingKey of Object.keys(environment)) {
|
|
if (existingKey.toLowerCase() === 'path') delete environment[existingKey];
|
|
}
|
|
}
|
|
environment[key] = value;
|
|
}
|
|
return environment;
|
|
}
|
|
|
|
function normalizeComparablePath(value: string | undefined): string | null {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return null;
|
|
return trimmed.replace(/^"+|"+$/g, '').replace(/\\/g, '/').toLowerCase();
|
|
}
|
|
|
|
function isManagedOpencodePortOwner(owner: PortOwner, binPath: string): boolean {
|
|
const expectedPath = normalizeComparablePath(binPath);
|
|
if (!expectedPath) return false;
|
|
|
|
const executablePath = normalizeComparablePath(owner.executablePath);
|
|
if (executablePath && executablePath === expectedPath) return true;
|
|
|
|
const commandLine = normalizeComparablePath(owner.commandLine);
|
|
return Boolean(commandLine?.includes(expectedPath));
|
|
}
|
|
|
|
function normalizePortOwner(input: unknown): PortOwner | null {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
const record = input as Record<string, unknown>;
|
|
const rawPid = record.pid ?? record.ProcessId ?? record.processId;
|
|
const pid = typeof rawPid === 'number'
|
|
? rawPid
|
|
: typeof rawPid === 'string'
|
|
? Number(rawPid)
|
|
: NaN;
|
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
return {
|
|
pid,
|
|
...(typeof record.executablePath === 'string' ? { executablePath: record.executablePath } : {}),
|
|
...(typeof record.ExecutablePath === 'string' ? { executablePath: record.ExecutablePath } : {}),
|
|
...(typeof record.commandLine === 'string' ? { commandLine: record.commandLine } : {}),
|
|
...(typeof record.CommandLine === 'string' ? { commandLine: record.CommandLine } : {}),
|
|
};
|
|
}
|
|
|
|
async function findWindowsPortOwner(port: number): Promise<PortOwner | null> {
|
|
const script = [
|
|
`$connection = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1`,
|
|
'if (-not $connection) { exit 0 }',
|
|
'$process = Get-CimInstance Win32_Process -Filter "ProcessId=$($connection.OwningProcess)" -ErrorAction SilentlyContinue',
|
|
'if (-not $process) { [pscustomobject]@{ pid = [int]$connection.OwningProcess } | ConvertTo-Json -Compress; exit 0 }',
|
|
'[pscustomobject]@{ pid = [int]$process.ProcessId; executablePath = $process.ExecutablePath; commandLine = $process.CommandLine } | ConvertTo-Json -Compress',
|
|
].join('\n');
|
|
const { stdout } = await execFileAsync(
|
|
'powershell.exe',
|
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
{ encoding: 'utf8', windowsHide: true },
|
|
);
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) return null;
|
|
return normalizePortOwner(JSON.parse(trimmed) as unknown);
|
|
}
|
|
|
|
async function findUnixPortOwner(port: number): Promise<PortOwner | null> {
|
|
const { stdout } = await execFileAsync(
|
|
'lsof',
|
|
['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
const lines = stdout.split(/\r?\n/);
|
|
let pid: number | null = null;
|
|
for (const line of lines) {
|
|
if (line.startsWith('p')) {
|
|
const nextPid = Number(line.slice(1));
|
|
if (Number.isInteger(nextPid) && nextPid > 0) {
|
|
pid = nextPid;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!pid) return null;
|
|
|
|
try {
|
|
const { stdout: commandOutput } = await execFileAsync(
|
|
'ps',
|
|
['-p', String(pid), '-o', 'command='],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
const commandLine = commandOutput.trim();
|
|
return commandLine ? { pid, commandLine } : { pid };
|
|
} catch {
|
|
return { pid };
|
|
}
|
|
}
|
|
|
|
async function findListeningPortOwner(port: number): Promise<PortOwner | null> {
|
|
try {
|
|
return process.platform === 'win32'
|
|
? await findWindowsPortOwner(port)
|
|
: await findUnixPortOwner(port);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function killProcessByPid(pid: number): boolean {
|
|
if (pid === process.pid) return false;
|
|
try {
|
|
return process.kill(pid);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export class OpencodeManager extends EventEmitter {
|
|
private readonly spawnProcess: SpawnFn;
|
|
private readonly configProvider?: ConfigProvider;
|
|
private readonly runtimeConfigProvider?: RuntimeConfigProvider;
|
|
private readonly findPortOwner: FindPortOwner;
|
|
private readonly killProcess: KillProcess;
|
|
private readonly startupTimeoutMs: number;
|
|
private proc: ChildProcess | null = null;
|
|
private lifecycleSequence = 0;
|
|
private cancelStartsBeforeSequence = 0;
|
|
private runtimeGeneration = 0;
|
|
private activeRuntimeGeneration: number | null = null;
|
|
private activeStartAttempt: RuntimeStartAttempt | null = null;
|
|
private readonly trackedUnreleasedPorts = new Set<number>();
|
|
private lifecycleBusy = false;
|
|
private readonly lifecycleQueue: Array<() => void> = [];
|
|
private exitCleanupPromise: Promise<void> | null = null;
|
|
private readonly ignoredExitProcesses = new WeakSet<ChildProcess>();
|
|
private status: OpencodeStatus;
|
|
|
|
constructor(private readonly options: OpencodeManagerOptions) {
|
|
super();
|
|
this.spawnProcess = options.spawn ?? nodeSpawn;
|
|
this.configProvider = options.configProvider;
|
|
this.runtimeConfigProvider = options.runtimeConfigProvider;
|
|
this.findPortOwner = options.findPortOwner ?? findListeningPortOwner;
|
|
this.killProcess = options.killProcess ?? killProcessByPid;
|
|
this.startupTimeoutMs = options.startupTimeoutMs ?? 10_000;
|
|
this.status = { state: 'stopped', port: options.port };
|
|
}
|
|
|
|
getStatus(): OpencodeStatus {
|
|
return { ...this.status };
|
|
}
|
|
|
|
getManagedConfigDir(): string | null {
|
|
const userDataDir = this.options.userDataDir?.trim();
|
|
return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null;
|
|
}
|
|
|
|
private enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const run = () => {
|
|
this.lifecycleBusy = true;
|
|
const finish = () => {
|
|
this.lifecycleBusy = false;
|
|
this.lifecycleQueue.shift()?.();
|
|
};
|
|
let result: Promise<T>;
|
|
try {
|
|
result = operation();
|
|
} catch (error) {
|
|
reject(error);
|
|
finish();
|
|
return;
|
|
}
|
|
void result.then(
|
|
(value) => {
|
|
resolve(value);
|
|
finish();
|
|
},
|
|
(error) => {
|
|
reject(error);
|
|
finish();
|
|
},
|
|
);
|
|
};
|
|
|
|
if (this.lifecycleBusy) {
|
|
this.lifecycleQueue.push(run);
|
|
} else {
|
|
run();
|
|
}
|
|
});
|
|
}
|
|
|
|
async start(): Promise<OpencodeStatus> {
|
|
const requestSequence = ++this.lifecycleSequence;
|
|
return await this.enqueueLifecycle(async () => {
|
|
this.assertStartAllowed(requestSequence);
|
|
if (this.status.state === 'running') return this.getStatus();
|
|
if (this.proc) await this.stopRuntime();
|
|
return await this.startRuntime(requestSequence);
|
|
});
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
const requestSequence = ++this.lifecycleSequence;
|
|
this.cancelEarlierStarts(requestSequence);
|
|
await this.enqueueLifecycle(async () => {
|
|
await this.stopRuntime();
|
|
});
|
|
}
|
|
|
|
private async stopRuntime(): Promise<void> {
|
|
const proc = this.proc;
|
|
const generation = this.activeRuntimeGeneration;
|
|
const port = this.status.port > 0 ? this.status.port : this.options.port;
|
|
try {
|
|
if (proc) {
|
|
const exitPromise = this.waitForProcessExit(proc);
|
|
this.ignoredExitProcesses.add(proc);
|
|
if (!proc.kill()) {
|
|
this.ignoredExitProcesses.delete(proc);
|
|
throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`);
|
|
}
|
|
if (typeof proc.pid === 'number') {
|
|
const [exited, released] = await Promise.all([
|
|
exitPromise,
|
|
this.waitForPortToBeReleased(port),
|
|
]);
|
|
this.recordPortReleaseOutcome(port, exited, released);
|
|
if (!exited) {
|
|
throw new Error(`Timed out waiting for opencode process ${proc.pid} to exit`);
|
|
}
|
|
if (!released) {
|
|
throw new OpencodePortReleaseError(port);
|
|
}
|
|
}
|
|
} else {
|
|
await this.stopAttachedServerIfManaged(port);
|
|
}
|
|
|
|
await this.verifyTrackedPortsReleased();
|
|
|
|
if (this.proc === proc) {
|
|
this.proc = null;
|
|
}
|
|
if (this.activeRuntimeGeneration === generation) {
|
|
this.activeRuntimeGeneration = null;
|
|
}
|
|
this.setStatus({ state: 'stopped', port: this.options.port });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const errorPort = error instanceof OpencodePortReleaseError ? error.port : port;
|
|
this.setStatus({
|
|
state: 'error',
|
|
port: errorPort,
|
|
...(typeof proc?.pid === 'number' ? { pid: proc.pid } : {}),
|
|
error: message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async restart(): Promise<OpencodeStatus> {
|
|
const requestSequence = ++this.lifecycleSequence;
|
|
this.cancelEarlierStarts(requestSequence);
|
|
return await this.enqueueLifecycle(async () => {
|
|
try {
|
|
await this.stopRuntime();
|
|
} catch (error) {
|
|
if (!(error instanceof OpencodePortReleaseError)) throw error;
|
|
logger.warn('[opencode-runtime] Restarting on another loopback port after release timeout', {
|
|
port: error.port,
|
|
});
|
|
}
|
|
this.assertStartAllowed(requestSequence);
|
|
return await this.startRuntime(requestSequence);
|
|
});
|
|
}
|
|
|
|
async checkHealth(): Promise<{ ok: boolean; status: OpencodeStatus }> {
|
|
return {
|
|
ok: this.status.state === 'running',
|
|
status: this.getStatus(),
|
|
};
|
|
}
|
|
|
|
private async startRuntime(
|
|
requestSequence: number,
|
|
requestedPort?: number,
|
|
allowPortFallback = true,
|
|
): Promise<OpencodeStatus> {
|
|
const attempt = this.beginStartAttempt(requestSequence);
|
|
try {
|
|
let runtimePort = requestedPort ?? this.options.port;
|
|
if (
|
|
requestedPort === undefined
|
|
&& (this.options.preflightPreferredPort || this.options.findPortOwner)
|
|
) {
|
|
const preferredPort = await waitForAbortable(
|
|
this.inspectPreferredPort(),
|
|
attempt.controller.signal,
|
|
);
|
|
if (preferredPort.occupied && !preferredPort.existingServer) {
|
|
runtimePort = 0;
|
|
this.logPortFallback();
|
|
}
|
|
}
|
|
this.setStatus({ state: 'starting', port: runtimePort });
|
|
|
|
const providedRuntimeConfig = this.resolveRuntimeConfig();
|
|
const runtimeConfig = providedRuntimeConfig && typeof (providedRuntimeConfig as Promise<unknown>).then === 'function'
|
|
? await waitForAbortable(providedRuntimeConfig, attempt.controller.signal)
|
|
: providedRuntimeConfig;
|
|
this.assertStartAllowed(requestSequence);
|
|
if (attempt.controller.signal.aborted) throw new OpencodeStartCancelledError();
|
|
const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${runtimePort}`];
|
|
const runtimeEnv = this.resolveRuntimeEnv(runtimeConfig.env);
|
|
const spawnCommand = this.resolveSpawnCommand(runtimeArgs, runtimeEnv);
|
|
logger.info('[opencode-runtime] Starting runtime with provider config', {
|
|
port: runtimePort,
|
|
command: spawnCommand.command,
|
|
args: spawnCommand.args,
|
|
...summarizeRuntimeConfigForDiagnostics(runtimeConfig.config, runtimeConfig.env),
|
|
});
|
|
const childEnv = {
|
|
...spawnCommand.env,
|
|
OPENCODE_CONFIG_CONTENT: JSON.stringify(runtimeConfig.config),
|
|
};
|
|
// Meowa credentials belong to Electron Main and must never be inherited by
|
|
// the model runtime or exposed through the Agent's shell environment.
|
|
delete childEnv.MEOWART_API_KEY;
|
|
delete childEnv.MEOWART_DEV_KEY;
|
|
const proc = this.spawnProcess(spawnCommand.command, spawnCommand.args, {
|
|
env: childEnv,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
shell: spawnCommand.shell,
|
|
});
|
|
this.proc = proc;
|
|
|
|
return await new Promise<OpencodeStatus>((resolve, reject) => {
|
|
let settled = false;
|
|
let stderr = '';
|
|
let stdout = '';
|
|
let abortStartup = () => undefined;
|
|
const appendStderr = (message: string) => {
|
|
stderr = `${stderr}${message}`;
|
|
if (stderr.length > 8_000) {
|
|
stderr = stderr.slice(-8_000);
|
|
}
|
|
};
|
|
const formatStartupError = (message: string) => {
|
|
const detail = stderr.trim();
|
|
return detail ? `${message}\n${detail}` : message;
|
|
};
|
|
const claimSettlement = (): boolean => {
|
|
if (settled) return false;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
attempt.controller.signal.removeEventListener('abort', abortStartup);
|
|
return true;
|
|
};
|
|
const finish = (callback: () => void) => {
|
|
if (!claimSettlement()) return;
|
|
callback();
|
|
};
|
|
const terminateStartup = async () => {
|
|
this.ignoredExitProcesses.add(proc);
|
|
const exitPromise = this.waitForProcessExit(proc);
|
|
proc.kill();
|
|
const [exited, released] = await Promise.all([
|
|
typeof proc.pid === 'number' ? exitPromise : Promise.resolve(true),
|
|
this.waitForPortToBeReleased(runtimePort),
|
|
]);
|
|
if (exited && this.proc === proc) this.proc = null;
|
|
if (this.isActiveGeneration(attempt.generation)) {
|
|
this.recordPortReleaseOutcome(runtimePort, exited, released);
|
|
}
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
if (!claimSettlement()) return;
|
|
const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`);
|
|
if (this.isActiveGeneration(attempt.generation)) {
|
|
this.setStatus({ state: 'error', port: runtimePort, error });
|
|
}
|
|
void terminateStartup().then(
|
|
() => reject(new Error(error)),
|
|
reject,
|
|
);
|
|
}, this.startupTimeoutMs);
|
|
|
|
abortStartup = () => {
|
|
if (!claimSettlement()) return;
|
|
void terminateStartup().then(
|
|
() => reject(new OpencodeStartCancelledError()),
|
|
reject,
|
|
);
|
|
};
|
|
attempt.controller.signal.addEventListener('abort', abortStartup, { once: true });
|
|
if (attempt.controller.signal.aborted) abortStartup();
|
|
|
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
if (!this.isActiveGeneration(attempt.generation) || this.proc !== proc) return;
|
|
stdout = `${stdout}${chunk.toString()}`;
|
|
if (stdout.length > 8_000) {
|
|
stdout = stdout.slice(-8_000);
|
|
}
|
|
const match = stdout.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/);
|
|
if (!match) return;
|
|
const listeningPort = parseRuntimePort(match[1]);
|
|
if (!listeningPort) return;
|
|
|
|
finish(() => {
|
|
this.setStatus({
|
|
state: 'running',
|
|
port: listeningPort,
|
|
url: match[1],
|
|
pid: proc.pid,
|
|
startedAt: Date.now(),
|
|
});
|
|
resolve(this.getStatus());
|
|
});
|
|
});
|
|
|
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
if (!this.isActiveGeneration(attempt.generation)) return;
|
|
const message = chunk.toString();
|
|
appendStderr(message);
|
|
this.emit('stderr', message);
|
|
});
|
|
|
|
proc.on('error', (error) => {
|
|
finish(() => {
|
|
if (this.proc === proc) this.proc = null;
|
|
this.ignoredExitProcesses.add(proc);
|
|
if (this.isActiveGeneration(attempt.generation)) {
|
|
this.setStatus({ state: 'error', port: runtimePort, error: error.message });
|
|
}
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
proc.on('exit', (code) => {
|
|
if (this.proc === proc) this.proc = null;
|
|
if (!settled) {
|
|
clearTimeout(timer);
|
|
if (this.ignoredExitProcesses.has(proc)) {
|
|
finish(() => reject(new Error('opencode startup was stopped')));
|
|
return;
|
|
}
|
|
|
|
void this.inspectRuntimePort(runtimePort, true).then((portState) => {
|
|
if (!this.isActiveGeneration(attempt.generation)) {
|
|
finish(() => reject(new OpencodeStartCancelledError()));
|
|
return;
|
|
}
|
|
const existingServer = portState.existingServer;
|
|
if (existingServer) {
|
|
finish(() => {
|
|
logger.warn(
|
|
'[opencode-runtime] Attached to an existing server; newly generated provider config may not be active',
|
|
{
|
|
port: runtimePort,
|
|
url: existingServer.url,
|
|
},
|
|
);
|
|
this.setStatus(existingServer);
|
|
resolve(this.getStatus());
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
allowPortFallback
|
|
&& runtimePort === this.options.port
|
|
&& portState.occupied
|
|
) {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
this.logPortFallback();
|
|
void this.startRuntime(requestSequence, 0, false).then(resolve, reject);
|
|
return;
|
|
}
|
|
|
|
finish(() => {
|
|
const error = formatStartupError(code == null ? 'opencode process exited' : `Exited with code ${code}`);
|
|
if (this.isActiveGeneration(attempt.generation)) {
|
|
this.setStatus({ state: 'error', port: runtimePort, error });
|
|
}
|
|
reject(new Error(error));
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (this.ignoredExitProcesses.has(proc)) return;
|
|
this.beginUnexpectedExitCleanup(
|
|
proc,
|
|
code,
|
|
this.isActiveGeneration(attempt.generation) ? this.status.port : runtimePort,
|
|
attempt.generation,
|
|
);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof OpencodeStartCancelledError)
|
|
&& this.isActiveGeneration(attempt.generation)
|
|
&& this.status.state === 'starting'
|
|
) {
|
|
this.setStatus({
|
|
state: 'error',
|
|
port: this.status.port,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (this.activeStartAttempt === attempt) {
|
|
this.activeStartAttempt = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private beginStartAttempt(requestSequence: number): RuntimeStartAttempt {
|
|
this.assertStartAllowed(requestSequence);
|
|
const attempt = {
|
|
generation: ++this.runtimeGeneration,
|
|
requestSequence,
|
|
controller: new AbortController(),
|
|
};
|
|
this.activeRuntimeGeneration = attempt.generation;
|
|
this.activeStartAttempt = attempt;
|
|
return attempt;
|
|
}
|
|
|
|
private cancelEarlierStarts(requestSequence: number): void {
|
|
this.cancelStartsBeforeSequence = Math.max(
|
|
this.cancelStartsBeforeSequence,
|
|
requestSequence,
|
|
);
|
|
if (
|
|
this.activeStartAttempt
|
|
&& this.activeStartAttempt.requestSequence < requestSequence
|
|
) {
|
|
this.activeStartAttempt.controller.abort();
|
|
}
|
|
}
|
|
|
|
private assertStartAllowed(requestSequence: number): void {
|
|
if (requestSequence < this.cancelStartsBeforeSequence) {
|
|
throw new OpencodeStartCancelledError();
|
|
}
|
|
}
|
|
|
|
private isActiveGeneration(generation: number): boolean {
|
|
return this.activeRuntimeGeneration === generation;
|
|
}
|
|
|
|
private async inspectPreferredPort(): Promise<{
|
|
occupied: boolean;
|
|
existingServer: OpencodeStatus | null;
|
|
}> {
|
|
return await this.inspectRuntimePort(this.options.port);
|
|
}
|
|
|
|
private async inspectRuntimePort(port: number, checkHealthFirst = false): Promise<{
|
|
occupied: boolean;
|
|
existingServer: OpencodeStatus | null;
|
|
}> {
|
|
if (port <= 0) return { occupied: false, existingServer: null };
|
|
|
|
if (checkHealthFirst) {
|
|
const existingServer = await this.findExistingServer(port);
|
|
if (existingServer) return { occupied: true, existingServer };
|
|
}
|
|
|
|
if (!this.options.findPortOwner) {
|
|
const available = await isLoopbackPortAvailable(port);
|
|
if (available) return { occupied: false, existingServer: null };
|
|
return {
|
|
occupied: true,
|
|
existingServer: await this.findExistingServer(port),
|
|
};
|
|
}
|
|
|
|
if (!checkHealthFirst) {
|
|
const existingServer = await this.findExistingServer(port);
|
|
if (existingServer) return { occupied: true, existingServer };
|
|
}
|
|
return {
|
|
occupied: Boolean(await this.findPortOwner(port)),
|
|
existingServer: null,
|
|
};
|
|
}
|
|
|
|
private async findExistingServer(port: number): Promise<OpencodeStatus | null> {
|
|
if (typeof globalThis.fetch !== 'function') return null;
|
|
|
|
const url = `http://127.0.0.1:${port}`;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), Math.min(this.startupTimeoutMs, 1_000));
|
|
|
|
try {
|
|
const response = await globalThis.fetch(`${url}/global/health`, {
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) return null;
|
|
|
|
const body = await response.json().catch(() => null);
|
|
if (!body || typeof body !== 'object') return null;
|
|
if ((body as { healthy?: unknown }).healthy !== true) return null;
|
|
|
|
return {
|
|
state: 'running',
|
|
port,
|
|
url,
|
|
startedAt: Date.now(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
private async stopAttachedServerIfManaged(port: number): Promise<void> {
|
|
const owner = await this.findPortOwner(port);
|
|
if (!owner || !isManagedOpencodePortOwner(owner, this.options.binPath)) {
|
|
return;
|
|
}
|
|
|
|
if (!this.killProcess(owner.pid)) {
|
|
this.trackedUnreleasedPorts.add(port);
|
|
throw new Error(`Failed to stop attached opencode process ${owner.pid}`);
|
|
}
|
|
|
|
logger.warn('[opencode-runtime] Stopped attached bundled opencode server on managed port', {
|
|
port,
|
|
pid: owner.pid,
|
|
});
|
|
const released = await this.waitForPortToBeReleased(port);
|
|
if (!released) {
|
|
this.trackedUnreleasedPorts.add(port);
|
|
throw new OpencodePortReleaseError(port);
|
|
}
|
|
this.trackedUnreleasedPorts.delete(port);
|
|
}
|
|
|
|
private async waitForPortToBeReleased(port: number): Promise<boolean> {
|
|
if (port <= 0) return true;
|
|
const deadline = Date.now() + ATTACHED_SERVER_STOP_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
const released = await this.isPortReleased(port);
|
|
if (released) return true;
|
|
await sleep(ATTACHED_SERVER_STOP_POLL_MS);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async isPortReleased(port: number): Promise<boolean> {
|
|
if (port <= 0) return true;
|
|
return this.options.findPortOwner
|
|
? !await this.findPortOwner(port)
|
|
: await isLoopbackPortAvailable(port);
|
|
}
|
|
|
|
private async verifyTrackedPortsReleased(): Promise<void> {
|
|
for (const port of this.trackedUnreleasedPorts) {
|
|
if (await this.isPortReleased(port)) {
|
|
this.trackedUnreleasedPorts.delete(port);
|
|
continue;
|
|
}
|
|
throw new OpencodePortReleaseError(port);
|
|
}
|
|
}
|
|
|
|
private recordPortReleaseOutcome(
|
|
port: number,
|
|
processExited: boolean,
|
|
portReleased: boolean,
|
|
): void {
|
|
if (port <= 0) return;
|
|
if (processExited && portReleased) {
|
|
this.trackedUnreleasedPorts.delete(port);
|
|
return;
|
|
}
|
|
this.trackedUnreleasedPorts.add(port);
|
|
}
|
|
|
|
private waitForProcessExit(proc: ChildProcess): Promise<boolean> {
|
|
if (typeof proc.exitCode === 'number' || proc.signalCode != null) {
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const onExit = () => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
proc.off('exit', onExit);
|
|
resolve(false);
|
|
}, ATTACHED_SERVER_STOP_TIMEOUT_MS);
|
|
proc.once('exit', onExit);
|
|
});
|
|
}
|
|
|
|
private beginUnexpectedExitCleanup(
|
|
proc: ChildProcess,
|
|
code: number | null,
|
|
port: number,
|
|
generation: number,
|
|
): void {
|
|
const cleanupPromise = this.enqueueLifecycle(async () => {
|
|
const released = typeof proc.pid !== 'number'
|
|
|| await this.waitForPortToBeReleased(port);
|
|
if (!this.isActiveGeneration(generation)) {
|
|
if (!released && port > 0) this.trackedUnreleasedPorts.add(port);
|
|
return;
|
|
}
|
|
this.recordPortReleaseOutcome(port, true, released);
|
|
if (released) {
|
|
this.activeRuntimeGeneration = null;
|
|
this.setStatus({
|
|
state: 'stopped',
|
|
port: this.options.port,
|
|
error: code == null ? undefined : `Exited with code ${code}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const error = new OpencodePortReleaseError(port).message;
|
|
this.setStatus({
|
|
state: 'error',
|
|
port,
|
|
...(typeof proc.pid === 'number' ? { pid: proc.pid } : {}),
|
|
error,
|
|
});
|
|
});
|
|
this.exitCleanupPromise = cleanupPromise;
|
|
void cleanupPromise.finally(() => {
|
|
if (this.exitCleanupPromise === cleanupPromise) {
|
|
this.exitCleanupPromise = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
private logPortFallback(): void {
|
|
logger.warn('[opencode-runtime] Preferred port is occupied by an unhealthy listener; using an ephemeral port', {
|
|
preferredPort: this.options.port,
|
|
});
|
|
}
|
|
|
|
private resolveRuntimeEnv(runtimeEnv: Record<string, string>): Record<string, string> {
|
|
const inheritedEnvironment = mergeProcessEnvironment(runtimeEnv);
|
|
const userDataDir = this.options.userDataDir?.trim();
|
|
if (!userDataDir) {
|
|
return prependManagedRuntimesToPath(
|
|
inheritedEnvironment,
|
|
this.options.pythonRuntime,
|
|
this.options.uvRuntime,
|
|
);
|
|
}
|
|
|
|
const baseDir = join(userDataDir, 'opencode');
|
|
const configHome = join(baseDir, 'config');
|
|
const dataHome = join(baseDir, 'data');
|
|
const cacheHome = join(baseDir, 'cache');
|
|
const managedConfigDir = this.getManagedConfigDir() ?? getManagedOpencodeConfigDir(userDataDir);
|
|
|
|
mkdirSync(configHome, { recursive: true });
|
|
mkdirSync(dataHome, { recursive: true });
|
|
mkdirSync(cacheHome, { recursive: true });
|
|
mkdirSync(managedConfigDir, { recursive: true });
|
|
ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: this.options.bundledSuperpowersDir,
|
|
});
|
|
ensureBundledCourseSkills({
|
|
managedConfigDir,
|
|
sourceDir: this.options.bundledCourseSkillsDir,
|
|
});
|
|
ensureBundledAgentBrowserPlugin({
|
|
managedConfigDir,
|
|
sourcePath: this.options.bundledAgentBrowserPluginPath,
|
|
});
|
|
const environment = {
|
|
...inheritedEnvironment,
|
|
OPENCODE_CONFIG_DIR: managedConfigDir,
|
|
// Makelore exposes its managed and project-owned Skills explicitly. Avoid
|
|
// rescanning unrelated user-wide ~/.claude and ~/.agents Skill libraries
|
|
// during every project's cold bootstrap.
|
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
|
|
NIANCODE_GAME_ASSETS_CLI: join(managedConfigDir, 'skills', 'game-assets', 'meowart_api.py'),
|
|
XDG_CONFIG_HOME: configHome,
|
|
XDG_DATA_HOME: dataHome,
|
|
XDG_CACHE_HOME: cacheHome,
|
|
};
|
|
return prependManagedRuntimesToPath(
|
|
environment,
|
|
this.options.pythonRuntime,
|
|
this.options.uvRuntime,
|
|
);
|
|
}
|
|
|
|
private setStatus(next: OpencodeStatus): void {
|
|
this.status = next;
|
|
this.emit('status', this.getStatus());
|
|
}
|
|
|
|
private resolveSpawnCommand(runtimeArgs: string[], runtimeEnv: Record<string, string>): SpawnCommand {
|
|
const binPath = this.options.binPath;
|
|
if (process.platform !== 'win32') {
|
|
return {
|
|
command: binPath,
|
|
args: runtimeArgs,
|
|
env: runtimeEnv,
|
|
};
|
|
}
|
|
|
|
if (/\.(?:exe|cmd|bat)$/i.test(binPath)) {
|
|
return {
|
|
command: binPath,
|
|
args: runtimeArgs,
|
|
env: runtimeEnv,
|
|
shell: /\.(?:cmd|bat)$/i.test(binPath),
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: process.execPath,
|
|
args: [binPath, ...runtimeArgs],
|
|
env: {
|
|
...runtimeEnv,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
},
|
|
};
|
|
}
|
|
|
|
private resolveRuntimeConfig(): RuntimeConfig | Promise<RuntimeConfig> {
|
|
if (this.runtimeConfigProvider) {
|
|
const providedRuntimeConfig = this.runtimeConfigProvider();
|
|
if (providedRuntimeConfig && typeof (providedRuntimeConfig as Promise<unknown>).then === 'function') {
|
|
return providedRuntimeConfig.then((runtimeConfig) => ({
|
|
config: runtimeConfig.config,
|
|
env: runtimeConfig.env ?? {},
|
|
}));
|
|
}
|
|
return {
|
|
config: providedRuntimeConfig.config,
|
|
env: providedRuntimeConfig.env ?? {},
|
|
};
|
|
}
|
|
|
|
const providedConfig = this.configProvider ? this.configProvider() : {};
|
|
if (providedConfig && typeof (providedConfig as Promise<unknown>).then === 'function') {
|
|
return providedConfig.then((config) => ({ config, env: {} }));
|
|
}
|
|
|
|
return { config: providedConfig, env: {} };
|
|
}
|
|
}
|