Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
947 lines
31 KiB
JavaScript
947 lines
31 KiB
JavaScript
"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,
|
|
};
|