790 lines
25 KiB
JavaScript
790 lines
25 KiB
JavaScript
|
|
"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,
|
||
|
|
};
|