fix: 修复 OpenCode 运行时启动与本地打包

This commit is contained in:
2026-08-09 00:02:49 +08:00
parent d092132d86
commit a6fe14a8d6
20 changed files with 1847 additions and 154 deletions

View File

@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:events';
import { mkdirSync } from 'node:fs';
import { createServer } from 'node:net';
import { join } from 'node:path';
import {
execFile,
@@ -15,8 +16,9 @@ import {
} from './superpowers';
import { logger } from '../utils/logger';
import {
prependPythonToPath,
prependManagedRuntimesToPath,
type PythonRuntime,
type UvRuntime,
} from '../utils/python-runtime';
import { promisify } from 'node:util';
@@ -166,10 +168,12 @@ export interface OpencodeManagerOptions {
bundledCourseSkillsDir?: string;
bundledAgentBrowserPluginPath?: string;
pythonRuntime?: PythonRuntime;
uvRuntime?: UvRuntime;
spawn?: SpawnFn;
configProvider?: ConfigProvider;
runtimeConfigProvider?: RuntimeConfigProvider;
startupTimeoutMs?: number;
preflightPreferredPort?: boolean;
findPortOwner?: FindPortOwner;
killProcess?: KillProcess;
}
@@ -178,12 +182,108 @@ 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;
@@ -297,7 +397,16 @@ export class OpencodeManager extends EventEmitter {
private readonly killProcess: KillProcess;
private readonly startupTimeoutMs: number;
private proc: ChildProcess | null = null;
private startPromise: Promise<OpencodeStatus> | 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) {
@@ -320,40 +429,126 @@ export class OpencodeManager extends EventEmitter {
return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null;
}
async start(): Promise<OpencodeStatus> {
if (this.status.state === 'running') return this.getStatus();
if (this.startPromise) return await this.startPromise;
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();
},
);
};
this.startPromise = this.startRuntime();
try {
return await this.startPromise;
} finally {
this.startPromise = null;
}
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;
if (proc) {
if (!proc.kill()) {
throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`);
}
if (typeof proc.pid === 'number') {
const released = await this.waitForPortOwnerToRelease(proc.pid);
if (!released) {
throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`);
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);
}
} else {
await this.stopAttachedServerIfManaged();
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;
}
this.proc = null;
this.setStatus({ state: 'stopped', port: this.options.port });
}
async restart(): Promise<OpencodeStatus> {
await this.stop();
return await this.start();
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 }> {
@@ -363,24 +558,45 @@ export class OpencodeManager extends EventEmitter {
};
}
private async startRuntime(): Promise<OpencodeStatus> {
this.setStatus({ state: 'starting', port: this.options.port });
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 providedRuntimeConfig
? await waitForAbortable(providedRuntimeConfig, attempt.controller.signal)
: providedRuntimeConfig;
const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${this.options.port}`];
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: this.options.port,
port: runtimePort,
command: spawnCommand.command,
args: spawnCommand.args,
...summarizeRuntimeConfigForDiagnostics(runtimeConfig.config, runtimeConfig.env),
});
const childEnv = {
...process.env,
...spawnCommand.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(runtimeConfig.config),
};
@@ -399,6 +615,8 @@ export class OpencodeManager extends EventEmitter {
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) {
@@ -409,31 +627,68 @@ export class OpencodeManager extends EventEmitter {
const detail = stderr.trim();
return detail ? `${message}\n${detail}` : message;
};
const finish = (callback: () => void) => {
if (settled) return;
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(() => {
finish(() => {
const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`);
this.setStatus({ state: 'error', port: this.options.port, error });
proc.kill();
reject(new Error(error));
});
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) => {
const text = chunk.toString();
const match = text.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/);
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: this.options.port,
port: listeningPort,
url: match[1],
pid: proc.pid,
startedAt: Date.now(),
@@ -443,6 +698,7 @@ export class OpencodeManager extends EventEmitter {
});
proc.stderr?.on('data', (chunk: Buffer) => {
if (!this.isActiveGeneration(attempt.generation)) return;
const message = chunk.toString();
appendStderr(message);
this.emit('stderr', message);
@@ -450,23 +706,36 @@ export class OpencodeManager extends EventEmitter {
proc.on('error', (error) => {
finish(() => {
this.proc = null;
this.setStatus({ state: 'error', port: this.options.port, error: error.message });
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) => {
this.proc = null;
if (this.proc === proc) this.proc = null;
if (!settled) {
clearTimeout(timer);
void this.findExistingServer().then((existingServer) => {
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: this.options.port,
port: runtimePort,
url: existingServer.url,
},
);
@@ -476,30 +745,135 @@ export class OpencodeManager extends EventEmitter {
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}`);
this.setStatus({ state: 'error', port: this.options.port, error });
if (this.isActiveGeneration(attempt.generation)) {
this.setStatus({ state: 'error', port: runtimePort, error });
}
reject(new Error(error));
});
});
return;
}
if (this.status.state !== 'stopped') {
this.setStatus({
state: 'stopped',
port: this.options.port,
error: code == null ? undefined : `Exited with code ${code}`,
});
}
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 async findExistingServer(): Promise<OpencodeStatus | 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:${this.options.port}`;
const url = `http://127.0.0.1:${port}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.min(this.startupTimeoutMs, 1_000));
@@ -515,7 +889,7 @@ export class OpencodeManager extends EventEmitter {
return {
state: 'running',
port: this.options.port,
port,
url,
startedAt: Date.now(),
};
@@ -526,42 +900,143 @@ export class OpencodeManager extends EventEmitter {
}
}
private async stopAttachedServerIfManaged(): Promise<void> {
const owner = await this.findPortOwner(this.options.port);
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: this.options.port,
port,
pid: owner.pid,
});
const released = await this.waitForPortOwnerToRelease(owner.pid);
const released = await this.waitForPortToBeReleased(port);
if (!released) {
throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`);
this.trackedUnreleasedPorts.add(port);
throw new OpencodePortReleaseError(port);
}
this.trackedUnreleasedPorts.delete(port);
}
private async waitForPortOwnerToRelease(pid: number): Promise<boolean> {
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 owner = await this.findPortOwner(this.options.port);
if (!owner || owner.pid !== pid) return true;
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 this.options.pythonRuntime
? prependPythonToPath(runtimeEnv, this.options.pythonRuntime)
: runtimeEnv;
return prependManagedRuntimesToPath(
inheritedEnvironment,
this.options.pythonRuntime,
this.options.uvRuntime,
);
}
const baseDir = join(userDataDir, 'opencode');
@@ -587,7 +1062,7 @@ export class OpencodeManager extends EventEmitter {
sourcePath: this.options.bundledAgentBrowserPluginPath,
});
const environment = {
...runtimeEnv,
...inheritedEnvironment,
OPENCODE_CONFIG_DIR: managedConfigDir,
// Makelore exposes its managed and project-owned Skills explicitly. Avoid
// rescanning unrelated user-wide ~/.claude and ~/.agents Skill libraries
@@ -598,9 +1073,11 @@ export class OpencodeManager extends EventEmitter {
XDG_DATA_HOME: dataHome,
XDG_CACHE_HOME: cacheHome,
};
return this.options.pythonRuntime
? prependPythonToPath(environment, this.options.pythonRuntime)
: environment;
return prependManagedRuntimesToPath(
environment,
this.options.pythonRuntime,
this.options.uvRuntime,
);
}
private setStatus(next: OpencodeStatus): void {