[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
789
electron/bridges/aiBridge/sdk/cursorCliDriver.cjs
Normal file
789
electron/bridges/aiBridge/sdk/cursorCliDriver.cjs
Normal file
@@ -0,0 +1,789 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Cursor Agent CLI turn runner — subscription / login session path.
|
||||
*
|
||||
* Spawns `cursor-agent` in print/stream-json mode so Catty can use the local
|
||||
* CLI login quota without CURSOR_API_KEY.
|
||||
*/
|
||||
const { spawn } = require("node:child_process");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { resolveCursorCliSpawnSpec } = require("../cursorCliSpawn.cjs");
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
const { encodeCursorCliModel } = require("./cursorDriver.cjs");
|
||||
|
||||
const DEFAULT_CURSOR_CLI_MODEL = "auto";
|
||||
const NETCATTY_MCP_NAME = "netcatty-remote-hosts";
|
||||
const CURSOR_CLI_ABORT_GRACE_MS = 1_500;
|
||||
const MAX_CURSOR_CLI_STDERR_CHARS = 64 * 1024;
|
||||
const MAX_CURSOR_CLI_MODEL_STDOUT_CHARS = 1024 * 1024;
|
||||
const MAX_CURSOR_CLI_LINE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function signalCursorCliProcessTree(child, signal, forceKillImpl) {
|
||||
if (!child) return;
|
||||
if (typeof forceKillImpl === "function") {
|
||||
try { forceKillImpl(child, signal); } catch {}
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32" && signal === "SIGKILL" && child.pid) {
|
||||
try {
|
||||
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
killer.on("error", () => {});
|
||||
killer.unref?.();
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to ChildProcess.kill below.
|
||||
}
|
||||
}
|
||||
if (process.platform !== "win32" && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// The child may not be a process-group leader (for injected tests or an
|
||||
// older runtime). Fall back to killing the direct child.
|
||||
}
|
||||
}
|
||||
try { child.kill(signal); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function stripCursorApiKeyFromEnv(env) {
|
||||
const out = { ...(env || {}) };
|
||||
delete out.CURSOR_API_KEY;
|
||||
return out;
|
||||
}
|
||||
|
||||
function spawnCursorCliProcess(spawnImpl, cliPath, args, options = {}) {
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
const spawnSpec = resolveCursorCliSpawnSpec(cliPath, args);
|
||||
return spawnFn(spawnSpec.command, spawnSpec.args, {
|
||||
...options,
|
||||
shell: spawnSpec.shell,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCursorCliModel(model) {
|
||||
const encoded = encodeCursorCliModel(model);
|
||||
return encoded || DEFAULT_CURSOR_CLI_MODEL;
|
||||
}
|
||||
|
||||
/** Map Netcatty permission mode → Cursor CLI execution class. */
|
||||
function resolveCursorCliExecMode(permissionMode) {
|
||||
return String(permissionMode || "confirm").toLowerCase() === "observer" ? "ask" : "agent";
|
||||
}
|
||||
|
||||
function buildCursorCliArgs({
|
||||
model,
|
||||
resumeSessionId,
|
||||
permissionMode,
|
||||
cwd,
|
||||
prompt,
|
||||
}) {
|
||||
const args = [
|
||||
"--print",
|
||||
"--trust",
|
||||
"--approve-mcps",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--stream-partial-output",
|
||||
"--model",
|
||||
resolveCursorCliModel(model),
|
||||
];
|
||||
|
||||
if (cwd) {
|
||||
args.push("--workspace", cwd);
|
||||
}
|
||||
|
||||
if (resumeSessionId) {
|
||||
args.push("--resume", String(resumeSessionId));
|
||||
}
|
||||
|
||||
if (resolveCursorCliExecMode(permissionMode) === "ask") {
|
||||
// Read-only ask mode; no shell write approvals expected.
|
||||
args.push("--mode", "ask");
|
||||
} else {
|
||||
// confirm/auto (and any other agent mode): stdin is ignored for the child, so
|
||||
// interactive y/n command approval cannot work. Cursor docs require --force
|
||||
// (--yolo) to auto-allow shell/tools in non-interactive runs.
|
||||
args.push("--force");
|
||||
}
|
||||
|
||||
args.push(String(prompt || ""));
|
||||
return args;
|
||||
}
|
||||
|
||||
function mcpConfigToCursorMcpJsonEntry(cfg) {
|
||||
if (!cfg || !cfg.name || !cfg.command) return null;
|
||||
const entry = {
|
||||
type: "stdio",
|
||||
command: cfg.command,
|
||||
args: Array.isArray(cfg.args) ? cfg.args : [],
|
||||
};
|
||||
const env = mcpEnvPairsToObject(cfg.env);
|
||||
if (env && Object.keys(env).length > 0) entry.env = env;
|
||||
return { name: cfg.name, entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor CLI discovers MCP via `{cwd}/.cursor/mcp.json`. Packaged Netcatty
|
||||
* launched from Finder/Dock often has `process.cwd() === "/"`, which cannot
|
||||
* host that file. Always prefer a writable Netcatty temp workspace.
|
||||
*/
|
||||
function resolveCursorCliWorkspaceCwd({
|
||||
preferredCwd,
|
||||
chatSessionId,
|
||||
getTempDir,
|
||||
mkdirSync,
|
||||
} = {}) {
|
||||
const mkdir = mkdirSync || fs.mkdirSync;
|
||||
const resolveTempRoot = typeof getTempDir === "function"
|
||||
? getTempDir
|
||||
: () => {
|
||||
try {
|
||||
return require("../../tempDirBridge.cjs").getTempDir();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tempRoot = String(resolveTempRoot?.() || "").trim();
|
||||
if (tempRoot) {
|
||||
const safeId = String(chatSessionId || "default")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.slice(0, 80) || "default";
|
||||
const dir = path.join(tempRoot, "cursor-cli-mcp", safeId);
|
||||
mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const fallback = String(preferredCwd || process.cwd() || "").trim() || process.cwd();
|
||||
try {
|
||||
mkdir(path.join(fallback, ".cursor"), { recursive: true });
|
||||
} catch {
|
||||
/* caller / merge may still fail loudly */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Per-path refcount so concurrent CLI turns share one original snapshot and only
|
||||
// the last restorer writes the pre-merge file back (avoids last-writer-wins races).
|
||||
const mcpMergeRefcounts = new Map();
|
||||
|
||||
function mergeWorkspaceMcpJson(cwd, injectedMcpServers, { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } = {}) {
|
||||
const read = readFileSync || fs.readFileSync;
|
||||
const write = writeFileSync || fs.writeFileSync;
|
||||
const mkdir = mkdirSync || fs.mkdirSync;
|
||||
const exists = existsSync || fs.existsSync;
|
||||
const unlink = unlinkSync || ((p) => fs.unlinkSync(p));
|
||||
|
||||
const cursorDir = path.join(cwd || process.cwd(), ".cursor");
|
||||
const mcpPath = path.join(cursorDir, "mcp.json");
|
||||
|
||||
let state = mcpMergeRefcounts.get(mcpPath);
|
||||
if (!state) {
|
||||
let previousRaw = null;
|
||||
let previousExisted = false;
|
||||
if (exists(mcpPath)) {
|
||||
previousExisted = true;
|
||||
previousRaw = read(mcpPath, "utf8");
|
||||
}
|
||||
state = { refCount: 0, previousRaw, previousExisted };
|
||||
mcpMergeRefcounts.set(mcpPath, state);
|
||||
}
|
||||
state.refCount += 1;
|
||||
|
||||
let doc = { mcpServers: {} };
|
||||
if (exists(mcpPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(mcpPath, "utf8"));
|
||||
if (parsed && typeof parsed === "object") {
|
||||
doc = parsed;
|
||||
if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
doc = { mcpServers: {} };
|
||||
}
|
||||
} else if (state.previousExisted && state.previousRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(state.previousRaw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
doc = parsed;
|
||||
if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
doc = { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
for (const cfg of injectedMcpServers || []) {
|
||||
const mapped = mcpConfigToCursorMcpJsonEntry(cfg);
|
||||
if (!mapped) continue;
|
||||
doc.mcpServers[mapped.name] = mapped.entry;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!exists(cursorDir)) {
|
||||
mkdir(cursorDir, { recursive: true });
|
||||
}
|
||||
write(mcpPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
||||
} catch (err) {
|
||||
// Roll back refcount so a failed write does not pin the lock forever.
|
||||
state.refCount = Math.max(0, state.refCount - 1);
|
||||
if (state.refCount === 0) mcpMergeRefcounts.delete(mcpPath);
|
||||
throw err;
|
||||
}
|
||||
|
||||
let restored = false;
|
||||
return {
|
||||
mcpPath,
|
||||
restore() {
|
||||
if (restored) return;
|
||||
restored = true;
|
||||
const current = mcpMergeRefcounts.get(mcpPath);
|
||||
if (!current) return;
|
||||
current.refCount = Math.max(0, current.refCount - 1);
|
||||
if (current.refCount > 0) return;
|
||||
mcpMergeRefcounts.delete(mcpPath);
|
||||
try {
|
||||
if (current.previousExisted) write(mcpPath, current.previousRaw, "utf8");
|
||||
else if (exists(mcpPath)) unlink(mcpPath);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper: clear MCP merge refcount state between unit tests. */
|
||||
function resetMcpMergeRefcountsForTests() {
|
||||
mcpMergeRefcounts.clear();
|
||||
}
|
||||
|
||||
function resultToText(result) {
|
||||
if (result == null) return "";
|
||||
if (typeof result === "string") return result;
|
||||
if (typeof result === "number" || typeof result === "boolean") return String(result);
|
||||
if (typeof result === "object") {
|
||||
if (typeof result.content === "string") return result.content;
|
||||
if (result.success && typeof result.success.content === "string") return result.success.content;
|
||||
try { return JSON.stringify(result); } catch { return String(result); }
|
||||
}
|
||||
return String(result);
|
||||
}
|
||||
|
||||
function extractCliToolCall(event) {
|
||||
const callId = event?.call_id || event?.toolCallId || null;
|
||||
const toolCall = event?.tool_call || event?.toolCall || null;
|
||||
if (!toolCall || typeof toolCall !== "object") {
|
||||
return { id: callId, name: event?.name || "tool", args: event?.args || {}, result: event?.result };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(toolCall)) {
|
||||
if (!key.endsWith("ToolCall") || !value || typeof value !== "object") continue;
|
||||
const name = key.replace(/ToolCall$/, "");
|
||||
const args = value.args && typeof value.args === "object" ? value.args : {};
|
||||
const result = value.result != null ? value.result : undefined;
|
||||
return { id: callId || value.toolCallId || null, name, args, result };
|
||||
}
|
||||
|
||||
return {
|
||||
id: callId,
|
||||
name: event?.name || "tool",
|
||||
args: toolCall.args || {},
|
||||
result: toolCall.result,
|
||||
};
|
||||
}
|
||||
|
||||
function closeReasoning(state, emitter) {
|
||||
if (state?.reasoningOpen) {
|
||||
emitter.reasoningEnd();
|
||||
state.reasoningOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function translateCursorCliEvent(event, emitter, state = {}) {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
|
||||
switch (event.type) {
|
||||
case "system":
|
||||
if (event.session_id) {
|
||||
state.sessionId = event.session_id;
|
||||
emitter.sessionId?.(event.session_id);
|
||||
}
|
||||
return false;
|
||||
|
||||
case "thinking":
|
||||
if (event.subtype === "completed") {
|
||||
closeReasoning(state, emitter);
|
||||
return false;
|
||||
}
|
||||
if (event.text) {
|
||||
emitter.reasoning(String(event.text));
|
||||
state.reasoningOpen = true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case "assistant": {
|
||||
closeReasoning(state, emitter);
|
||||
// With --stream-partial-output, Cursor emits three assistant shapes:
|
||||
// timestamp_ms only → streaming delta (use)
|
||||
// timestamp_ms + model_call_id → buffered flush before tool (skip)
|
||||
// neither → final flush (skip if already streamed)
|
||||
// See https://cursor.com/docs/cli/reference/output-format.md#stream-json-format
|
||||
if (event.model_call_id) return false;
|
||||
const isPartial = Boolean(event.timestamp_ms);
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) return false;
|
||||
let text = "";
|
||||
for (const block of content) {
|
||||
if (block?.type === "text" && block.text) text += String(block.text);
|
||||
}
|
||||
if (!text) return false;
|
||||
if (!isPartial) {
|
||||
if (state.streamedAssistantText) return false;
|
||||
emitter.text(text);
|
||||
state.streamedAssistantText = true;
|
||||
return false;
|
||||
}
|
||||
emitter.text(text);
|
||||
state.streamedAssistantText = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
case "tool_call": {
|
||||
closeReasoning(state, emitter);
|
||||
const { id, name, args, result } = extractCliToolCall(event);
|
||||
if (!id) return false;
|
||||
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
||||
if (!state.emittedToolResults) state.emittedToolResults = new Set();
|
||||
|
||||
const subtype = String(event.subtype || "");
|
||||
if (subtype === "started" || subtype === "running" || !subtype) {
|
||||
if (!state.emittedToolCalls.has(id)) {
|
||||
state.emittedToolCalls.add(id);
|
||||
emitter.toolCall(name || "tool", args && typeof args === "object" ? args : {}, id);
|
||||
}
|
||||
}
|
||||
if (subtype === "completed" || subtype === "error") {
|
||||
if (!state.emittedToolCalls.has(id)) {
|
||||
state.emittedToolCalls.add(id);
|
||||
emitter.toolCall(name || "tool", args && typeof args === "object" ? args : {}, id);
|
||||
}
|
||||
if (!state.emittedToolResults.has(id)) {
|
||||
state.emittedToolResults.add(id);
|
||||
emitter.toolResult(id, resultToText(result || event.error || ""), name || "tool");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case "result":
|
||||
closeReasoning(state, emitter);
|
||||
if (event.session_id) {
|
||||
state.sessionId = event.session_id;
|
||||
emitter.sessionId?.(event.session_id);
|
||||
}
|
||||
if (event.is_error || event.subtype === "error") {
|
||||
state.failed = true;
|
||||
const message = String(event.result || event.error || event.message || "Cursor CLI turn failed");
|
||||
emitter.emitError(formatCursorCliErrorForUser(message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case "error":
|
||||
closeReasoning(state, emitter);
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(event.message || event.error || "Cursor CLI turn failed"));
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCursorCliErrorForUser(message) {
|
||||
const text = String(message || "").trim();
|
||||
if (
|
||||
/not authenticated|not logged in|please run .*login|unauthenticated|unauthorized/i.test(text)
|
||||
|| /(?:^|\b)(?:agent|cursor-agent)\s+login\b/i.test(text)
|
||||
) {
|
||||
return "Cursor CLI is not logged in. Run `cursor-agent login` in a terminal, then retry.";
|
||||
}
|
||||
if (/\bapi[_\s-]?key\b/i.test(text) && /invalid|missing|required|auth/i.test(text)) {
|
||||
return "Cursor CLI authentication failed. Run `cursor-agent login` or switch Cursor to API Key mode in Settings → AI.";
|
||||
}
|
||||
return text || "Cursor CLI turn failed";
|
||||
}
|
||||
|
||||
function createLineBuffer(onLine, maxBufferBytes = MAX_CURSOR_CLI_LINE_BYTES) {
|
||||
let buffer = "";
|
||||
let bufferedBytes = 0;
|
||||
let overflowed = false;
|
||||
const decoder = new StringDecoder("utf8");
|
||||
return {
|
||||
push(chunk) {
|
||||
if (overflowed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
|
||||
bufferedBytes += bytes.length;
|
||||
buffer += decoder.write(bytes);
|
||||
let idx;
|
||||
let consumedLine = false;
|
||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
consumedLine = true;
|
||||
if (line) onLine(line);
|
||||
}
|
||||
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
|
||||
if (bufferedBytes > maxBufferBytes) {
|
||||
overflowed = true;
|
||||
buffer = "";
|
||||
const error = new Error(`Cursor CLI message exceeded ${maxBufferBytes} bytes`);
|
||||
error.code = "CURSOR_CLI_LINE_LIMIT";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
if (overflowed) return;
|
||||
buffer += decoder.end();
|
||||
const line = buffer.trim();
|
||||
buffer = "";
|
||||
if (line) onLine(line);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runCursorCliTurn({
|
||||
prompt,
|
||||
binPath,
|
||||
cwd,
|
||||
chatSessionId,
|
||||
getTempDir,
|
||||
model,
|
||||
env,
|
||||
permissionMode,
|
||||
resumeSessionId,
|
||||
injectedMcpServers,
|
||||
emitter,
|
||||
signal,
|
||||
spawnImpl,
|
||||
mergeMcp,
|
||||
workspaceCwd,
|
||||
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
emitter.emitError("Cursor Agent CLI not found. Install the Cursor CLI (`cursor-agent`) and ensure it is on PATH.");
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
|
||||
let effectiveCwd;
|
||||
try {
|
||||
effectiveCwd = workspaceCwd || resolveCursorCliWorkspaceCwd({
|
||||
preferredCwd: cwd,
|
||||
chatSessionId,
|
||||
getTempDir,
|
||||
});
|
||||
} catch (err) {
|
||||
emitter.emitError(
|
||||
"Failed to prepare Netcatty MCP for Cursor CLI "
|
||||
+ `(cannot create workspace: ${err?.message || err}). `
|
||||
+ "Terminal tools will be unavailable.",
|
||||
);
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
|
||||
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
|
||||
const args = buildCursorCliArgs({
|
||||
model,
|
||||
resumeSessionId,
|
||||
permissionMode,
|
||||
cwd: effectiveCwd,
|
||||
prompt,
|
||||
});
|
||||
|
||||
const doMerge = mergeMcp || mergeWorkspaceMcpJson;
|
||||
let mcpHandle = null;
|
||||
if (Array.isArray(injectedMcpServers) && injectedMcpServers.length > 0) {
|
||||
try {
|
||||
mcpHandle = doMerge(effectiveCwd, injectedMcpServers);
|
||||
} catch (err) {
|
||||
emitter.emitError(
|
||||
"Failed to prepare Netcatty MCP for Cursor CLI "
|
||||
+ `(cannot write workspace MCP config: ${err?.message || err}). `
|
||||
+ "Terminal tools will be unavailable.",
|
||||
);
|
||||
return { sessionId: resumeSessionId || null };
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
sessionId: resumeSessionId || null,
|
||||
reasoningOpen: false,
|
||||
streamedAssistantText: false,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
let child = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
try { mcpHandle?.restore?.(); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
try {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
emitter.emitError(formatCursorCliErrorForUser(err?.message || String(err)));
|
||||
return { sessionId: state.sessionId };
|
||||
}
|
||||
|
||||
const handleLine = (line) => {
|
||||
// Soft-cancel: ignore late stream-json after Stop (result/error would emitError).
|
||||
if (signal?.aborted) return;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stop = translateCursorCliEvent(event, emitter, state);
|
||||
if (stop && !signal?.aborted) state.failed = true;
|
||||
};
|
||||
|
||||
const stdoutBuffer = createLineBuffer(handleLine);
|
||||
let stderrText = "";
|
||||
let stderrBytes = 0;
|
||||
let stderrTruncated = false;
|
||||
let stderrEnded = false;
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
stdoutBuffer.push(chunk);
|
||||
} catch (error) {
|
||||
if (!state.failed) {
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(error?.message || String(error)));
|
||||
}
|
||||
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
if (signal?.aborted) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remaining = Math.max(0, MAX_CURSOR_CLI_STDERR_CHARS - stderrBytes);
|
||||
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||||
if (accepted.length > 0) stderrText += stderrDecoder.write(accepted);
|
||||
stderrBytes += accepted.length;
|
||||
if (accepted.length < buffer.length) stderrTruncated = true;
|
||||
});
|
||||
|
||||
let abortHandler = null;
|
||||
let forceKillTimer = null;
|
||||
await new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(forceKillTimer);
|
||||
// Only flush remaining lines if not aborted — late error/result after
|
||||
// Stop must not surface as a failed turn.
|
||||
if (!signal?.aborted) stdoutBuffer.flush();
|
||||
resolve();
|
||||
};
|
||||
child.on("error", (err) => {
|
||||
// Soft-cancel: do not surface spawn errors after user Stop.
|
||||
if (!state.failed && !signal?.aborted) {
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(err?.message || String(err)));
|
||||
}
|
||||
finish();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (!stderrEnded) {
|
||||
stderrEnded = true;
|
||||
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderrText += stderrDecoder.end();
|
||||
}
|
||||
// Soft-cancel: SIGTERM/kill after abort is not a turn failure.
|
||||
if (!state.failed && !signal?.aborted && code && code !== 0 && !state.streamedAssistantText) {
|
||||
const stderr = stderrText.trim();
|
||||
const message = stderr || `Cursor CLI exited with code ${code}`;
|
||||
state.failed = true;
|
||||
emitter.emitError(formatCursorCliErrorForUser(message));
|
||||
}
|
||||
finish();
|
||||
});
|
||||
|
||||
let terminationStarted = false;
|
||||
abortHandler = () => {
|
||||
if (settled || terminationStarted) return;
|
||||
terminationStarted = true;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
|
||||
// Process APIs do not guarantee a close event when process-tree
|
||||
// termination itself fails. Stop must still release MCP config and the
|
||||
// renderer request within a fixed deadline.
|
||||
finish();
|
||||
}, Math.max(0, abortGraceMs));
|
||||
forceKillTimer.unref?.();
|
||||
signalCursorCliProcessTree(child, "SIGTERM");
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) abortHandler();
|
||||
else signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
if (signal) signal.removeEventListener("abort", abortHandler);
|
||||
cleanup();
|
||||
closeReasoning(state, emitter);
|
||||
|
||||
// Match cursorDriver: aborted turns must not report as successful done.
|
||||
if (!state.failed && !signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
}
|
||||
|
||||
return { sessionId: state.sessionId };
|
||||
}
|
||||
|
||||
async function listCursorCliModels({
|
||||
binPath,
|
||||
env,
|
||||
spawnImpl,
|
||||
abortController,
|
||||
signal,
|
||||
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
} = {}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) return { currentModelId: null, models: [] };
|
||||
const abortSignal = signal || abortController?.signal;
|
||||
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
|
||||
|
||||
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
let stdoutBytes = 0;
|
||||
let stdoutTruncated = false;
|
||||
let stdoutEnded = false;
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
let settled = false;
|
||||
let abortHandler = null;
|
||||
let forceKillTimer = null;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(forceKillTimer);
|
||||
if (abortSignal && abortHandler) {
|
||||
abortSignal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, ["models"], {
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
} catch {
|
||||
finish({ currentModelId: null, models: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
if (abortSignal?.aborted) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remaining = Math.max(0, MAX_CURSOR_CLI_MODEL_STDOUT_CHARS - stdoutBytes);
|
||||
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||||
if (accepted.length > 0) stdout += stdoutDecoder.write(accepted);
|
||||
stdoutBytes += accepted.length;
|
||||
if (accepted.length < buffer.length) stdoutTruncated = true;
|
||||
});
|
||||
child.on("error", () => finish({ currentModelId: null, models: [] }));
|
||||
child.on("close", () => {
|
||||
if (!stdoutEnded) {
|
||||
stdoutEnded = true;
|
||||
if (!stdoutTruncated || stdoutDecoder.lastNeed === 0) stdout += stdoutDecoder.end();
|
||||
}
|
||||
const models = [];
|
||||
const seen = new Set();
|
||||
let currentModelId = null;
|
||||
for (const line of String(stdout).split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || /^available models$/i.test(trimmed)) continue;
|
||||
const match = trimmed.match(/^([a-z0-9][a-z0-9._-]*)\s+-\s+(.+)$/i);
|
||||
if (!match) continue;
|
||||
const id = match[1];
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const rawName = match[2].trim();
|
||||
const isCurrent = /\(\s*current(?:\s*,\s*default)?\s*\)/i.test(rawName);
|
||||
if (isCurrent) currentModelId = id;
|
||||
const name = rawName
|
||||
.replace(/\s*\(\s*current(?:\s*,\s*default)?\s*\)\s*/ig, " ")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim() || id;
|
||||
models.push({ id, name });
|
||||
}
|
||||
if (!currentModelId && models.some((model) => model.id === "auto")) {
|
||||
currentModelId = "auto";
|
||||
}
|
||||
finish({ currentModelId, models });
|
||||
});
|
||||
|
||||
abortHandler = () => {
|
||||
if (settled) return;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
|
||||
finish({ currentModelId: null, models: [] });
|
||||
}, Math.max(0, abortGraceMs));
|
||||
forceKillTimer.unref?.();
|
||||
signalCursorCliProcessTree(child, "SIGTERM", forceKillImpl);
|
||||
};
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) abortHandler();
|
||||
else abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CURSOR_CLI_MODEL,
|
||||
MAX_CURSOR_CLI_LINE_BYTES,
|
||||
NETCATTY_MCP_NAME,
|
||||
buildCursorCliArgs,
|
||||
createLineBuffer,
|
||||
formatCursorCliErrorForUser,
|
||||
listCursorCliModels,
|
||||
mergeWorkspaceMcpJson,
|
||||
resetMcpMergeRefcountsForTests,
|
||||
resolveCursorCliExecMode,
|
||||
resolveCursorCliModel,
|
||||
resolveCursorCliSpawnSpec,
|
||||
resolveCursorCliWorkspaceCwd,
|
||||
runCursorCliTurn,
|
||||
spawnCursorCliProcess,
|
||||
stripCursorApiKeyFromEnv,
|
||||
translateCursorCliEvent,
|
||||
};
|
||||
Reference in New Issue
Block a user