[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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,
};