Files

947 lines
31 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const {
buildOpenCodeNativeSkillsPermissionRules,
buildOpenCodeSkillsPermissionRules,
} = require("./netcattySkillsOpenCodePermissions.cjs");
const OPENCODE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const DEFAULT_OPENCODE_PORT = 4096;
function resolveUsableOpenCodeBinPath(binPath, env) {
const candidates = [];
if (binPath) candidates.push(String(binPath));
if (env?.OPENCODE_BIN) candidates.push(String(env.OPENCODE_BIN));
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {}
}
return undefined;
}
function isOpenCodeImageAttachment(attachment) {
return Boolean(
attachment &&
OPENCODE_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
attachment.filePath,
);
}
function parseOpenCodeModel(model) {
const raw = String(model || "").trim();
const slash = raw.indexOf("/");
if (slash <= 0 || slash === raw.length - 1) return undefined;
return {
providerID: raw.slice(0, slash),
modelID: raw.slice(slash + 1),
};
}
function toOpenCodeMcpConfig(injectedMcpServers) {
const mcp = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name) continue;
mcp[cfg.name] = {
type: "local",
command: [cfg.command, ...(cfg.args || [])],
environment: mcpEnvPairsToObject(cfg.env),
enabled: true,
};
}
return mcp;
}
function buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist } = {}) {
const allowBash = toolIntegrationMode === "skills";
const permission = {
edit: "deny",
bash: allowBash ? "allow" : "deny",
webfetch: "deny",
// Netcatty does not yet bridge OpenCode's question reply API to the UI.
// Leaving it enabled creates a tool call that can never be completed.
question: "deny",
// Keep external access locked down, but let OpenCode's native skills
// (e.g. ~/.opencode/skills, ~/.config/opencode/skills) read their own
// reference files in every mode (issue #1939).
...buildOpenCodeNativeSkillsPermissionRules(),
};
if (allowBash && Array.isArray(skillsPathAllowlist) && skillsPathAllowlist.length > 0) {
Object.assign(permission, buildOpenCodeSkillsPermissionRules(skillsPathAllowlist));
}
const config = {
share: "disabled",
autoupdate: false,
permission,
mcp: toOpenCodeMcpConfig(injectedMcpServers),
};
if (model) config.model = model;
return config;
}
function buildOpenCodePromptParts(prompt, attachments) {
const parts = [{ type: "text", text: String(prompt || "") }];
for (const attachment of Array.isArray(attachments) ? attachments : []) {
if (!isOpenCodeImageAttachment(attachment)) continue;
parts.push({
type: "file",
mime: String(attachment.mediaType).toLowerCase(),
filename: attachment.filename,
url: pathToFileURL(attachment.filePath).href,
});
}
return parts;
}
function extractOpenCodeErrorMessage(error) {
if (!error) return "";
if (typeof error === "string") return error;
return String(
error.data?.message ||
error.message ||
error.name ||
"",
);
}
function getOpenCodeResultError(result) {
if (!result || typeof result !== "object") return null;
return result.error || null;
}
function getOpenCodeEventPayload(event) {
if (event?.payload && typeof event.payload === "object") return event.payload;
if (event?.type && event?.properties) return event;
return null;
}
function getOpenCodeSessionIdFromEvent(event) {
const properties = getOpenCodeEventPayload(event)?.properties;
return properties?.sessionID
|| properties?.sessionId
|| properties?.part?.sessionID
|| properties?.part?.sessionId
|| properties?.info?.sessionID
|| properties?.info?.sessionId
|| properties?.info?.id
|| null;
}
function getOpenCodePartId(part) {
return part?.id || part?.partID || part?.partId || null;
}
function rememberOpenCodePartType(state, part) {
const partId = getOpenCodePartId(part);
if (!partId || !part?.type) return;
state.partTypes = state.partTypes || new Map();
state.partTypes.set(partId, part.type);
}
function rememberOpenCodeMessageRole(state, info) {
if (!info || typeof info !== "object") return;
const messageId = info.id;
const role = info.role;
if (!messageId || !role) return;
state.messageRoles = state.messageRoles || new Map();
state.messageRoles.set(messageId, role);
}
function getOpenCodeMessageId(source) {
if (!source || typeof source !== "object") return null;
return source.messageID
|| source.messageId
|| source.part?.messageID
|| source.part?.messageId
|| null;
}
function shouldEmitOpenCodeAssistantPart(state, source) {
const messageId = getOpenCodeMessageId(source);
if (!messageId) return true;
const role = state.messageRoles?.get(messageId);
if (!role) return true;
return role === "assistant";
}
function forgetOpenCodeMessageRole(state, messageId) {
if (!messageId) return;
state.messageRoles?.delete(messageId);
}
function getOpenCodeDeltaKind(properties, state) {
const partId = properties?.partID || properties?.partId || null;
const knownType = partId && state.partTypes?.get(partId);
if (knownType === "reasoning" || knownType === "text") return knownType;
const field = String(properties?.field || "").toLowerCase();
if (field.includes("reason") || field.includes("thinking")) return "reasoning";
if (field === "text" || field === "content" || field.endsWith(".text") || field.endsWith(".content")) return "text";
return null;
}
function emitOpenCodePartChunk({ emitter, state, partId, kind, text, isDelta }) {
if (typeof text !== "string" || text.length === 0) return false;
let chunk = text;
if (partId) {
state.partOffsets = state.partOffsets || new Map();
const emittedLength = state.partOffsets.get(partId) || 0;
if (isDelta) {
state.partOffsets.set(partId, emittedLength + text.length);
} else {
chunk = text.slice(emittedLength);
state.partOffsets.set(partId, Math.max(emittedLength, text.length));
}
}
if (!chunk) return false;
if (kind === "reasoning") {
emitter.reasoning(chunk);
state.reasoningOpen = true;
} else {
emitter.text(chunk);
}
return true;
}
function translateOpenCodeEvent(event, emitter, state = {}) {
const payload = getOpenCodeEventPayload(event);
if (!payload || typeof payload !== "object") return { idle: false, error: false, content: false };
if (payload.type === "message.updated") {
rememberOpenCodeMessageRole(state, payload.properties?.info);
return { idle: false, error: false, content: false };
}
if (payload.type === "message.removed") {
forgetOpenCodeMessageRole(state, payload.properties?.messageID || payload.properties?.messageId);
return { idle: false, error: false, content: false };
}
if (payload.type === "message.part.updated") {
const part = payload.properties?.part;
if (!part || typeof part !== "object") return { idle: false, error: false, content: false };
if (!shouldEmitOpenCodeAssistantPart(state, part)) {
return { idle: false, error: false, content: false };
}
rememberOpenCodePartType(state, part);
if (part.type === "text") {
const delta = payload.properties?.delta;
if (emitOpenCodePartChunk({
emitter,
state,
partId: getOpenCodePartId(part),
kind: "text",
text: typeof delta === "string" ? delta : part.text,
isDelta: typeof delta === "string",
})) {
return { idle: false, error: false, content: true };
}
return { idle: false, error: false, content: false };
}
if (part.type === "reasoning") {
const delta = payload.properties?.delta;
if (emitOpenCodePartChunk({
emitter,
state,
partId: getOpenCodePartId(part),
kind: "reasoning",
text: typeof delta === "string" ? delta : part.text,
isDelta: typeof delta === "string",
})) {
return { idle: false, error: false, content: true };
}
return { idle: false, error: false, content: false };
}
if (part.type === "tool") {
if (state.reasoningOpen) {
emitter.reasoningEnd?.();
state.reasoningOpen = false;
}
const callId = part.callID || part.id || "";
const toolName = part.tool || "tool";
const input = part.state?.input || {};
if (part.state?.status === "running" || part.state?.status === "pending") {
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
} else if (part.state?.status === "completed") {
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
state.toolResults = state.toolResults || new Set();
if (!state.toolResults.has(callId)) {
state.toolResults.add(callId);
emitter.toolResult(callId, part.state.output || "", toolName);
}
} else if (part.state?.status === "error") {
// Tool-level failures must not abort the whole OpenCode turn. Other
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
// so the model can adapt and continue multi-step work (issue #2718).
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
state.toolResults = state.toolResults || new Set();
if (!state.toolResults.has(callId)) {
state.toolResults.add(callId);
// Prefer non-empty error, then output, then a stable default (blank
// string error must not hide a useful output payload).
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
const errorText = typeof rawError === "string"
? rawError
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
emitter.toolResult(callId, errorText, toolName);
}
return { idle: false, error: false, content: true };
}
}
return { idle: false, error: false, content: part.type === "tool" };
}
if (payload.type === "message.part.delta") {
const properties = payload.properties || {};
if (!shouldEmitOpenCodeAssistantPart(state, properties)) {
return { idle: false, error: false, content: false };
}
const delta = typeof properties.delta === "string" ? properties.delta : "";
const kind = getOpenCodeDeltaKind(properties, state);
if (!delta || !kind) return { idle: false, error: false, content: false };
if (emitOpenCodePartChunk({
emitter,
state,
partId: properties.partID || properties.partId || null,
kind,
text: delta,
isDelta: true,
})) {
return { idle: false, error: false, content: true };
}
return { idle: false, error: false, content: false };
}
if (payload.type === "session.error") {
emitter.emitError(extractOpenCodeErrorMessage(payload.properties?.error) || "OpenCode session failed");
return { idle: false, error: true, content: false };
}
if (payload.type === "session.idle") {
if (state.reasoningOpen) {
emitter.reasoningEnd?.();
state.reasoningOpen = false;
}
emitter.status("OpenCode session idle");
return { idle: true, error: false, content: false };
}
if (payload.type === "session.status" && payload.properties?.status?.type) {
emitter.status(`OpenCode session ${payload.properties.status.type}`);
}
return { idle: false, error: false, content: false };
}
function classifyOpenCodeSpawnError(error) {
const code = error && error.code;
const msg = String((error && error.message) || error || "");
return {
isSpawnEnoent: code === "ENOENT" || /ENOENT/i.test(msg) || /not found/i.test(msg),
message: msg,
};
}
function shellQuotePosix(value) {
return `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
}
function createOpenCodeShim(binPath, options = {}) {
if (!binPath) return null;
const platform = options.platform || process.platform;
const tempDirBridge = options.tempDirBridge || require("../../tempDirBridge.cjs");
const getTempFilePath = options.getTempFilePath || tempDirBridge.getTempFilePath;
const shimParent = getTempFilePath("opencode-sdk-shim");
const uniqueId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const shimRoot = path.join(shimParent, uniqueId);
fs.mkdirSync(shimRoot, { recursive: true });
const shimName = platform === "win32" ? "opencode.cmd" : "opencode";
const shimPath = path.join(shimRoot, shimName);
if (platform === "win32") {
fs.writeFileSync(shimPath, `@echo off\r\n"${binPath}" %*\r\n`);
} else {
fs.writeFileSync(shimPath, `#!/bin/sh\nexec ${shellQuotePosix(binPath)} "$@"\n`);
fs.chmodSync(shimPath, 0o755);
}
return {
dir: shimRoot,
path: shimPath,
cleanup() {
try { fs.rmSync(shimRoot, { recursive: true, force: true }); } catch {}
try { fs.rmdirSync(shimParent); } catch {}
},
};
}
function createOpenCodeProcessEnv(env, binPath, options = {}) {
const next = { ...(env || {}) };
let shim = null;
const explicitBinPath = binPath ? resolveUsableOpenCodeBinPath(binPath, null) : undefined;
const envBinPath = explicitBinPath ? undefined : resolveUsableOpenCodeBinPath(null, next);
if (explicitBinPath) {
shim = createOpenCodeShim(explicitBinPath, options);
next.OPENCODE_BIN = explicitBinPath;
next.PATH = [shim?.dir || path.dirname(explicitBinPath), next.PATH || process.env.PATH || ""]
.filter(Boolean)
.join(path.delimiter);
} else if (envBinPath) {
next.OPENCODE_BIN = envBinPath;
} else if (binPath || next.OPENCODE_BIN) {
delete next.OPENCODE_BIN;
}
return {
env: next,
cleanup() {
shim?.cleanup?.();
},
};
}
function withOpenCodeProcessEnv(env, binPath, fn) {
const previous = {};
const { env: next, cleanup } = createOpenCodeProcessEnv(env, binPath);
const restore = () => {
for (const key of Object.keys(next)) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
cleanup();
};
for (const [key, value] of Object.entries(next)) {
previous[key] = process.env[key];
process.env[key] = String(value);
}
try {
return fn();
} catch (error) {
throw error;
} finally {
restore();
}
}
function getAvailablePort(host = "127.0.0.1") {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, host, () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => {
if (error) reject(error);
else resolve(port === DEFAULT_OPENCODE_PORT ? getAvailablePort(host) : port);
});
});
});
}
async function withOpenCodeServerPort(options = {}) {
if (options.port != null) return options;
return { ...options, port: await getAvailablePort(options.hostname || "127.0.0.1") };
}
function closeOpenCodeInstance(opencode) {
try { opencode?.server?.close?.(); } catch {}
}
async function createDefaultOpenCode(options, env, binPath) {
let sdk;
try { sdk = await import("@opencode-ai/sdk"); } catch {
throw new Error("OpenCode SDK not installed. Run: npm install @opencode-ai/sdk");
}
const { env: nextEnv, cleanup: cleanupShim } = createOpenCodeProcessEnv(env, binPath);
const previous = {};
for (const [key, value] of Object.entries(nextEnv)) {
previous[key] = process.env[key];
process.env[key] = String(value);
}
// Restore the Electron main-process environment as soon as the child has been
// spawned. Keeping PATH/OPENCODE_BIN pointed at a temporary shim for the
// server lifetime (or list-models idle window) can leak into later turns and
// other spawns; see #2184 review. The on-disk shim stays until close() so a
// still-running child that re-resolves helpers does not race a deleted path.
const restoreProcessEnv = () => {
if (restoreProcessEnv.done) return;
restoreProcessEnv.done = true;
for (const key of Object.keys(nextEnv)) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
};
const cleanup = () => {
if (cleanup.done) return;
cleanup.done = true;
restoreProcessEnv();
cleanupShim();
};
try {
const opencode = await sdk.createOpencode(options);
restoreProcessEnv();
const originalClose = opencode.server?.close?.bind(opencode.server);
if (typeof originalClose === "function") {
opencode.server.close = () => {
try { originalClose(); } catch {}
cleanup();
};
} else {
cleanup();
}
return opencode;
} catch (error) {
cleanup();
throw error;
}
}
function createAbortWait(signal) {
if (!signal) return { promise: new Promise(() => {}), dispose() {} };
if (signal.aborted) return { promise: Promise.resolve(), dispose() {} };
let resolveAbort;
const promise = new Promise((resolve) => { resolveAbort = resolve; });
const onAbort = () => resolveAbort();
signal.addEventListener("abort", onAbort, { once: true });
return {
promise,
dispose() {
signal.removeEventListener("abort", onAbort);
},
};
}
function createStopWait() {
let stopped = false;
let resolveStop;
const promise = new Promise((resolve) => { resolveStop = resolve; });
return {
promise,
get stopped() { return stopped; },
stop() {
if (stopped) return;
stopped = true;
resolveStop();
},
};
}
async function runOpenCodeTurn({
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
}) {
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
let opencode = null;
let sessionId = resumeSessionId || null;
let hasContent = false;
let failed = false;
let abortSent = false;
let removeAbortListener = null;
const state = { reasoningOpen: false };
const directoryQuery = cwd ? { directory: cwd } : undefined;
try {
const factory = openCodeFactory || ((options) => createDefaultOpenCode(options, env, binPath));
opencode = await factory(await withOpenCodeServerPort({ config, signal: abortController?.signal }));
const { client } = opencode;
const abortOpenCode = async () => {
if (abortSent) return;
abortSent = true;
if (sessionId) {
try { await client.session.abort({ path: { id: sessionId }, query: directoryQuery }); } catch {}
}
try { opencode?.server?.close?.(); } catch {}
};
if (abortController?.signal) {
const onAbort = () => { void abortOpenCode(); };
abortController.signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => abortController.signal.removeEventListener("abort", onAbort);
}
const events = await client.global.event({ signal: abortController?.signal });
if (!sessionId) {
const created = await client.session.create({
body: { title: "Netcatty OpenCode" },
query: directoryQuery,
});
sessionId = created?.data?.id || created?.id || null;
}
if (!sessionId) throw new Error("OpenCode did not create a session");
emitter.sessionId(sessionId);
const stopEventLoopWait = createStopWait();
const eventLoop = (async () => {
const iterator = events.stream?.[Symbol.asyncIterator]?.();
if (!iterator) return;
const abortWait = createAbortWait(abortController?.signal);
try {
while (true) {
const nextEvent = iterator.next();
const raced = await Promise.race([
nextEvent.then(
(value) => ({ type: "event", value }),
(error) => ({ type: "error", error }),
),
abortWait.promise.then(() => ({ type: "abort" })),
stopEventLoopWait.promise.then(() => ({ type: "stop" })),
]);
if (raced.type === "abort") break;
if (raced.type === "stop") break;
if (raced.type === "error") throw raced.error;
const { value: event, done } = raced.value;
if (done) break;
if (abortController?.signal?.aborted) break;
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
if (eventSessionId && eventSessionId !== sessionId) continue;
const result = translateOpenCodeEvent(event, emitter, state);
if (result.content) hasContent = true;
if (result.error) {
failed = true;
break;
}
if (result.idle) break;
}
} finally {
abortWait.dispose();
if (abortController?.signal?.aborted || stopEventLoopWait.stopped) {
try { void iterator.return?.(); } catch {}
}
}
})();
const body = {
parts: buildOpenCodePromptParts(prompt, attachments),
};
if (systemPrompt) body.system = String(systemPrompt);
const parsedModel = parseOpenCodeModel(model);
if (parsedModel) body.model = parsedModel;
const promptAbortWait = createAbortWait(abortController?.signal);
const promptResult = await Promise.race([
client.session.promptAsync({
path: { id: sessionId },
query: directoryQuery,
body,
signal: abortController?.signal,
throwOnError: true,
}).then(
(result) => {
const error = getOpenCodeResultError(result);
return error ? { type: "error", error } : { type: "prompt" };
},
(error) => ({ type: "error", error }),
),
promptAbortWait.promise.then(() => ({ type: "abort" })),
]);
promptAbortWait.dispose();
if (promptResult.type === "error") {
failed = true;
await abortOpenCode();
stopEventLoopWait.stop();
await eventLoop.catch(() => {});
throw promptResult.error;
}
if (promptResult.type === "abort") {
await abortOpenCode();
} else {
await eventLoop;
}
if (abortController?.signal?.aborted) {
await abortOpenCode();
}
if (!hasContent && !failed && !abortController?.signal?.aborted) {
emitter.emitError("OpenCode returned an empty response. Run `opencode` in a terminal to configure authentication and models.");
return { sessionId };
}
if (!failed && !abortController?.signal?.aborted) emitter.emitDone();
return { sessionId };
} catch (error) {
const classified = classifyOpenCodeSpawnError(error);
if (classified.isSpawnEnoent) {
emitter.emitError("OpenCode CLI not found or not runnable. Install OpenCode and ensure `opencode` is on PATH, or set a custom path in Settings.");
} else {
emitter.emitError(extractOpenCodeErrorMessage(error) || classified.message || "OpenCode turn failed");
}
return { sessionId };
} finally {
removeAbortListener?.();
closeOpenCodeInstance(opencode);
}
}
function mapOpenCodeModels(response) {
const providers = Array.isArray(response?.providers) ? response.providers : [];
const models = [];
for (const provider of providers) {
const providerId = provider?.id || provider?.providerID;
if (!providerId || !provider?.models || typeof provider.models !== "object") continue;
for (const [modelId, info] of Object.entries(provider.models)) {
models.push({
id: `${providerId}/${modelId}`,
name: `${provider.name || providerId} ${info?.name || modelId}`,
});
}
}
return models;
}
function getOpenCodeDefaultModelId(response) {
const value = response?.default;
if (!value) return null;
if (typeof value === "string") return value.includes("/") ? value : null;
if (typeof value !== "object") return null;
if (typeof value.model === "string" && value.model.includes("/")) return value.model;
if (typeof value.providerID === "string" && typeof value.modelID === "string") {
return `${value.providerID}/${value.modelID}`;
}
if (typeof value.provider === "string" && typeof value.model === "string") {
return `${value.provider}/${value.model}`;
}
for (const [providerId, modelId] of Object.entries(value)) {
if (typeof modelId === "string" && providerId && modelId) {
return modelId.includes("/") ? modelId : `${providerId}/${modelId}`;
}
if (modelId && typeof modelId === "object" && typeof modelId.modelID === "string") {
const nestedProvider = typeof modelId.providerID === "string" ? modelId.providerID : providerId;
return `${nestedProvider}/${modelId.modelID}`;
}
}
return null;
}
function emptyOpenCodeModelCatalog() {
return { currentModelId: null, models: [] };
}
function abortError(signal) {
return signal?.reason instanceof Error
? signal.reason
: new Error(String(signal?.reason || "aborted"));
}
function whenAborted(signal) {
if (!signal) return new Promise(() => {});
if (signal.aborted) return Promise.reject(abortError(signal));
return new Promise((_, reject) => {
signal.addEventListener("abort", () => reject(abortError(signal)), { once: true });
});
}
// Env vars that can change which OpenCode config / provider catalog is visible.
const OPENCODE_CATALOG_ENV_KEYS = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"OPENCODE_BIN",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
"OPENCODE_CONFIG_CONTENT",
];
function buildOpenCodeCatalogEnvFingerprint(env) {
return OPENCODE_CATALOG_ENV_KEYS
.map((key) => `${key}=${env?.[key] == null ? "" : String(env[key])}`)
.join("\u0000");
}
function buildOpenCodeListServerKey(binPath, env) {
const resolvedBin = String(
resolveUsableOpenCodeBinPath(binPath, env)
|| binPath
|| env?.OPENCODE_BIN
|| "default",
);
// Same binary + different HOME/XDG/OpenCode config must not share a catalog
// server or cache entry (multi-agent / multi-profile setups).
return `${resolvedBin}\u0000${buildOpenCodeCatalogEnvFingerprint(env)}`;
}
// Shared list-models servers: coalesce concurrent catalog loads for the same
// binary, then tear down after a short idle so idle Netcatty does not keep
// opencode processes around (issue #2184).
const OPENCODE_LIST_SERVER_IDLE_MS = 1500;
const openCodeListServers = new Map();
function clearOpenCodeListServerIdle(entry) {
if (!entry?.idleTimer) return;
clearTimeout(entry.idleTimer);
entry.idleTimer = null;
}
function disposeOpenCodeListServer(key, entry) {
const current = openCodeListServers.get(key);
if (current && current !== entry) return;
openCodeListServers.delete(key);
clearOpenCodeListServerIdle(entry);
try { entry?.createAbort?.abort?.(); } catch {}
closeOpenCodeInstance(entry?.opencode);
entry.opencode = null;
}
function releaseOpenCodeListServer(key) {
const entry = openCodeListServers.get(key);
if (!entry) return;
entry.refs = Math.max(0, (entry.refs || 0) - 1);
if (entry.refs > 0) return;
// Create still in flight with no waiters: abort so the SDK kills the child.
if (!entry.opencode && entry.createAbort && !entry.createAbort.signal.aborted) {
try { entry.createAbort.abort(); } catch {}
disposeOpenCodeListServer(key, entry);
return;
}
clearOpenCodeListServerIdle(entry);
entry.idleTimer = setTimeout(() => {
const current = openCodeListServers.get(key);
if (!current || current !== entry || current.refs > 0) return;
disposeOpenCodeListServer(key, entry);
}, OPENCODE_LIST_SERVER_IDLE_MS);
if (typeof entry.idleTimer.unref === "function") entry.idleTimer.unref();
}
async function acquireOpenCodeListServer({ env, binPath, openCodeFactory, signal } = {}) {
if (signal?.aborted) throw abortError(signal);
const key = buildOpenCodeListServerKey(binPath, env);
let entry = openCodeListServers.get(key);
if (entry) {
clearOpenCodeListServerIdle(entry);
} else {
const createAbort = new AbortController();
entry = {
key,
refs: 0,
opencode: null,
ready: null,
idleTimer: null,
createAbort,
};
const factory = openCodeFactory || ((options) => createDefaultOpenCode(options, env, binPath));
entry.ready = (async () => {
const options = await withOpenCodeServerPort({
config: { autoupdate: false },
timeout: 10000,
signal: createAbort.signal,
});
const opencode = await factory(options);
// If the last waiter cancelled while create was finishing, kill immediately
// so the process cannot leak outside the pool map.
if (createAbort.signal.aborted) {
closeOpenCodeInstance(opencode);
throw abortError(createAbort.signal);
}
entry.opencode = opencode;
return opencode;
})().catch((error) => {
// Drop a failed create immediately so the next list-models can retry.
disposeOpenCodeListServer(key, entry);
throw error;
});
openCodeListServers.set(key, entry);
}
entry.refs += 1;
try {
const opencode = await Promise.race([
entry.ready,
whenAborted(signal),
]);
if (signal?.aborted) throw abortError(signal);
return { key, opencode };
} catch (error) {
entry.refs = Math.max(0, entry.refs - 1);
if (entry.refs <= 0) {
// Last waiter left before ready: abort spawn so the SDK child is killed.
try { entry.createAbort?.abort?.(); } catch {}
disposeOpenCodeListServer(key, entry);
}
throw error;
}
}
function resetOpenCodeListServerPool() {
for (const [key, entry] of openCodeListServers.entries()) {
disposeOpenCodeListServer(key, entry);
}
openCodeListServers.clear();
}
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal } = {}) {
const effectiveSignal = signal || abortController?.signal;
let acquired = null;
try {
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
acquired = await acquireOpenCodeListServer({
env,
binPath,
openCodeFactory,
signal: effectiveSignal,
});
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
const response = await Promise.race([
acquired.opencode.client.config.providers(),
whenAborted(effectiveSignal),
]);
if (response?.error) {
throw new Error(extractOpenCodeErrorMessage(response.error) || "OpenCode providers unavailable");
}
const data = response?.data || response;
return {
currentModelId: getOpenCodeDefaultModelId(data),
models: mapOpenCodeModels(data),
};
} catch {
return emptyOpenCodeModelCatalog();
} finally {
if (acquired) releaseOpenCodeListServer(acquired.key);
}
}
module.exports = {
buildOpenCodeConfig,
buildOpenCodePromptParts,
classifyOpenCodeSpawnError,
closeOpenCodeInstance,
createOpenCodeProcessEnv,
withOpenCodeProcessEnv,
listOpenCodeModels,
mapOpenCodeModels,
parseOpenCodeModel,
resolveUsableOpenCodeBinPath,
resetOpenCodeListServerPool,
runOpenCodeTurn,
toOpenCodeMcpConfig,
translateOpenCodeEvent,
OPENCODE_LIST_SERVER_IDLE_MS,
};