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
560 lines
18 KiB
JavaScript
560 lines
18 KiB
JavaScript
"use strict";
|
|
|
|
/**
|
|
* Cursor backend driver — wraps @cursor/sdk.
|
|
*
|
|
* Cursor SDK local agents use Agent.create({ apiKey, model, local:{cwd},
|
|
* mcpServers }) and stream SDKMessage events from run.stream().
|
|
*/
|
|
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
|
|
|
const DEFAULT_CURSOR_MODEL = "composer-2.5";
|
|
|
|
function toCursorMcpServers(injectedMcpServers) {
|
|
const servers = {};
|
|
for (const cfg of injectedMcpServers || []) {
|
|
if (!cfg || !cfg.name || !cfg.command) continue;
|
|
servers[cfg.name] = {
|
|
type: "stdio",
|
|
command: cfg.command,
|
|
args: cfg.args || [],
|
|
env: mcpEnvPairsToObject(cfg.env),
|
|
};
|
|
}
|
|
return servers;
|
|
}
|
|
|
|
const CURSOR_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
|
const CURSOR_FALLBACK_THINKING = {
|
|
"gpt-5.5": ["low", "medium", "high"],
|
|
"gpt-5.2": ["low", "medium", "high"],
|
|
"gpt-5.1": ["low", "medium", "high"],
|
|
"gpt-5": ["low", "medium", "high"],
|
|
"claude-opus-4.6": ["low", "medium", "high"],
|
|
"claude-sonnet-4.6": ["low", "medium", "high"],
|
|
};
|
|
|
|
function parseCursorModelSelection(model) {
|
|
const raw = String(model || DEFAULT_CURSOR_MODEL).trim() || DEFAULT_CURSOR_MODEL;
|
|
const queryIndex = raw.indexOf("?");
|
|
if (queryIndex >= 0) {
|
|
const id = raw.slice(0, queryIndex);
|
|
const search = new URLSearchParams(raw.slice(queryIndex + 1));
|
|
const params = [];
|
|
for (const [paramId, value] of search.entries()) {
|
|
if (paramId && value) params.push({ id: paramId, value });
|
|
}
|
|
return params.length > 0 ? { id, params } : { id };
|
|
}
|
|
const slash = raw.lastIndexOf("/");
|
|
if (slash > 0) {
|
|
const effort = raw.slice(slash + 1).toLowerCase();
|
|
if (CURSOR_REASONING_EFFORTS.has(effort)) {
|
|
return { id: raw.slice(0, slash), params: [{ id: "effort", value: effort }] };
|
|
}
|
|
}
|
|
return { id: raw };
|
|
}
|
|
|
|
function encodeCursorCliModel(model) {
|
|
const raw = String(model || "").trim();
|
|
if (!raw) return "";
|
|
const selection = parseCursorModelSelection(raw);
|
|
if (!selection.params?.length) return selection.id || "";
|
|
const search = new URLSearchParams();
|
|
for (const param of selection.params) {
|
|
if (param?.id && param?.value) search.set(param.id, param.value);
|
|
}
|
|
const qs = search.toString();
|
|
return qs ? `${selection.id}?${qs}` : (selection.id || "");
|
|
}
|
|
|
|
function buildCursorAgentOptions({ apiKey, env, model, cwd, injectedMcpServers }) {
|
|
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
|
|
const options = {
|
|
apiKey: effectiveApiKey,
|
|
model: parseCursorModelSelection(model),
|
|
local: {
|
|
cwd: cwd || process.cwd(),
|
|
autoReview: false,
|
|
},
|
|
};
|
|
const mcpServers = toCursorMcpServers(injectedMcpServers);
|
|
if (Object.keys(mcpServers).length > 0) options.mcpServers = mcpServers;
|
|
return options;
|
|
}
|
|
|
|
function applyTemporaryProcessEnv(env) {
|
|
if (!env || typeof env !== "object") return () => {};
|
|
const previous = new Map();
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (typeof value !== "string") continue;
|
|
previous.set(key, Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : undefined);
|
|
process.env[key] = value;
|
|
}
|
|
|
|
return () => {
|
|
for (const [key, value] of previous.entries()) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
};
|
|
}
|
|
|
|
async function withTemporaryProcessEnv(env, fn) {
|
|
const restore = applyTemporaryProcessEnv(env);
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
restore();
|
|
}
|
|
}
|
|
|
|
function buildCursorSendMessage(prompt, attachments) {
|
|
const images = [];
|
|
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
|
if (!attachment?.base64Data || !attachment?.mediaType) continue;
|
|
if (!String(attachment.mediaType).toLowerCase().startsWith("image/")) continue;
|
|
images.push({ data: attachment.base64Data, mimeType: attachment.mediaType });
|
|
}
|
|
if (images.length === 0) return String(prompt || "");
|
|
return { text: String(prompt || ""), images };
|
|
}
|
|
|
|
function resultToText(result) {
|
|
if (result == null) return "";
|
|
if (typeof result === "string") return result;
|
|
if (typeof result === "number" || typeof result === "boolean") return String(result);
|
|
const content = result.content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((block) => {
|
|
if (!block) return "";
|
|
if (typeof block.text === "string") return block.text;
|
|
if (block.type === "image") return "[image]";
|
|
return JSON.stringify(block);
|
|
})
|
|
.join("");
|
|
}
|
|
return JSON.stringify(result);
|
|
}
|
|
|
|
function redactCursorSecret(value) {
|
|
return String(value || "")
|
|
.replace(/crsr[_-]?[A-Za-z0-9_-]{8,}/g, "[redacted-cursor-key]")
|
|
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted-token]");
|
|
}
|
|
|
|
function cursorErrorDiagnostics(error) {
|
|
if (!error || typeof error !== "object") {
|
|
return { message: redactCursorSecret(error) };
|
|
}
|
|
return {
|
|
name: error.name || null,
|
|
message: redactCursorSecret(error.message || String(error)),
|
|
code: error.code || null,
|
|
status: error.status || null,
|
|
operation: error.operation || null,
|
|
endpoint: error.endpoint || null,
|
|
requestId: error.requestId || null,
|
|
isRetryable: typeof error.isRetryable === "boolean" ? error.isRetryable : null,
|
|
cause: error.cause && typeof error.cause === "object"
|
|
? {
|
|
name: error.cause.name || null,
|
|
message: redactCursorSecret(error.cause.message || String(error.cause)),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function isCursorAuthMessage(message) {
|
|
return /api.?key|auth|unauthorized|unauthenticated/i.test(String(message || ""));
|
|
}
|
|
|
|
async function logCursorApiKeyValidation(resolvedModule, apiKey) {
|
|
if (!apiKey || typeof resolvedModule?.Cursor?.me !== "function") return;
|
|
try {
|
|
const user = await resolvedModule.Cursor.me({ apiKey });
|
|
console.info("[Cursor SDK] API key validation ok", {
|
|
hasUserId: user?.userId != null,
|
|
hasEmail: Boolean(user?.email),
|
|
createdAt: user?.createdAt || null,
|
|
});
|
|
} catch (error) {
|
|
console.warn("[Cursor SDK] API key validation failed", cursorErrorDiagnostics(error));
|
|
}
|
|
}
|
|
|
|
function closeReasoning(state, emitter) {
|
|
if (state?.reasoningOpen) {
|
|
emitter.reasoningEnd();
|
|
state.reasoningOpen = false;
|
|
}
|
|
}
|
|
|
|
function emitCursorToolCallOnce(event, emitter, state, toolName, args, id) {
|
|
if (!id) return false;
|
|
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
|
if (state.emittedToolCalls.has(id)) return false;
|
|
state.emittedToolCalls.add(id);
|
|
emitter.toolCall(toolName || "tool", args && typeof args === "object" ? args : {}, id);
|
|
return true;
|
|
}
|
|
|
|
function emitCursorToolResultOnce(event, emitter, state, id, result, toolName) {
|
|
if (!id) return false;
|
|
if (!state.emittedToolResults) state.emittedToolResults = new Set();
|
|
if (state.emittedToolResults.has(id)) return false;
|
|
state.emittedToolResults.add(id);
|
|
emitter.toolResult(id, resultToText(result), toolName);
|
|
return true;
|
|
}
|
|
|
|
function getCursorDisplayToolName(rawName, args) {
|
|
const name = String(rawName || "").trim();
|
|
const input = args && typeof args === "object" ? args : {};
|
|
const nestedToolName = typeof input.toolName === "string" ? input.toolName.trim() : "";
|
|
if ((name === "mcp" || name === "tool" || !name) && nestedToolName) {
|
|
return nestedToolName;
|
|
}
|
|
return name || nestedToolName || "tool";
|
|
}
|
|
|
|
function formatCursorErrorForUser(message) {
|
|
const text = String(message || "").trim();
|
|
if (/api.?key|auth|unauthorized/i.test(text)) {
|
|
return "Cursor authentication failed. Update the Cursor API Key in Settings -> AI.";
|
|
}
|
|
return text || "Cursor turn failed";
|
|
}
|
|
|
|
function isCursorAgentNotFoundError(error) {
|
|
const message = String(error?.message || error || "");
|
|
return /\bAgent\b.+\bnot found\b/i.test(message);
|
|
}
|
|
|
|
function translateCursorEvent(event, emitter, state = {}) {
|
|
if (!event || typeof event !== "object") return;
|
|
|
|
switch (event.type) {
|
|
case "thinking":
|
|
if (event.text) {
|
|
emitter.reasoning(String(event.text));
|
|
state.reasoningOpen = true;
|
|
}
|
|
return;
|
|
case "assistant": {
|
|
closeReasoning(state, emitter);
|
|
const content = event.message?.content;
|
|
if (!Array.isArray(content)) return;
|
|
for (const block of content) {
|
|
if (!block) continue;
|
|
if (block.type === "text" && block.text) {
|
|
emitter.text(String(block.text));
|
|
} else if (block.type === "tool_use") {
|
|
emitCursorToolCallOnce(
|
|
event,
|
|
emitter,
|
|
state,
|
|
getCursorDisplayToolName(block.name, block.input),
|
|
block.input,
|
|
block.id,
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
case "tool_call": {
|
|
closeReasoning(state, emitter);
|
|
const id = event.call_id;
|
|
const name = getCursorDisplayToolName(event.name, event.args);
|
|
if (event.status === "running") {
|
|
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
|
|
} else if (event.status === "completed" || event.status === "error") {
|
|
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
|
|
emitCursorToolResultOnce(event, emitter, state, id, event.result || event.error || "", name);
|
|
}
|
|
return;
|
|
}
|
|
case "status":
|
|
if (event.status === "ERROR") {
|
|
closeReasoning(state, emitter);
|
|
state.failed = true;
|
|
state.errorMessage = String(event.message || "");
|
|
console.warn("[Cursor SDK] status error", {
|
|
message: redactCursorSecret(event.message || ""),
|
|
});
|
|
emitter.emitError(formatCursorErrorForUser(event.message));
|
|
return true;
|
|
}
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class CursorTurnAbortError extends Error {
|
|
constructor() {
|
|
super("Cursor turn aborted");
|
|
this.name = "CursorTurnAbortError";
|
|
}
|
|
}
|
|
|
|
function isCursorTurnAbortError(error) {
|
|
return error instanceof CursorTurnAbortError || error?.name === "CursorTurnAbortError";
|
|
}
|
|
|
|
async function abortable(promise, signal, onLateResolve) {
|
|
if (!signal) return promise;
|
|
if (signal.aborted) {
|
|
promise.then((value) => onLateResolve?.(value)).catch(() => {});
|
|
throw new CursorTurnAbortError();
|
|
}
|
|
|
|
let aborted = false;
|
|
let removeAbortListener = () => {};
|
|
const abortPromise = new Promise((_, reject) => {
|
|
const onAbort = () => {
|
|
aborted = true;
|
|
reject(new CursorTurnAbortError());
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([promise, abortPromise]);
|
|
} finally {
|
|
removeAbortListener();
|
|
if (aborted) {
|
|
promise.then((value) => onLateResolve?.(value)).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runCursorTurn({
|
|
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
|
|
}) {
|
|
let resolvedModule = sdkModule;
|
|
if (!resolvedModule) {
|
|
try {
|
|
resolvedModule = await import("@cursor/sdk");
|
|
} catch {
|
|
emitter.emitError("Cursor SDK not installed. Run: npm install @cursor/sdk");
|
|
return { sessionId: resumeSessionId || null };
|
|
}
|
|
}
|
|
|
|
const { Agent } = resolvedModule;
|
|
let agent = null;
|
|
let run = null;
|
|
let sessionId = resumeSessionId || null;
|
|
try {
|
|
const restoreCreateEnv = applyTemporaryProcessEnv(runtimeEnv);
|
|
try {
|
|
const createAgent = () => Agent.create(agentOptions);
|
|
let agentPromise;
|
|
if (resumeSessionId && typeof Agent.resume === "function") {
|
|
agentPromise = Agent.resume(resumeSessionId, agentOptions).catch((error) => {
|
|
// Stale Cursor agent IDs (expired local store, or a CLI session UUID
|
|
// resumed on the SDK path) should start a fresh agent instead of
|
|
// failing the whole turn with "Agent … not found".
|
|
if (!isCursorAgentNotFoundError(error)) throw error;
|
|
console.warn("[Cursor SDK] resume missed; creating a new agent", {
|
|
resumeSessionId,
|
|
message: error?.message || String(error),
|
|
});
|
|
sessionId = null;
|
|
return createAgent();
|
|
});
|
|
} else {
|
|
agentPromise = createAgent();
|
|
}
|
|
agent = await abortable(agentPromise, signal, (lateAgent) => {
|
|
try { lateAgent?.close?.(); } catch { /* best effort */ }
|
|
});
|
|
} finally {
|
|
restoreCreateEnv();
|
|
}
|
|
sessionId = agent.agentId || sessionId;
|
|
if (sessionId) emitter.sessionId(sessionId);
|
|
if (signal?.aborted) return { sessionId };
|
|
|
|
const sendMessage = buildCursorSendMessage(prompt, attachments);
|
|
const restoreSendEnv = applyTemporaryProcessEnv(runtimeEnv);
|
|
try {
|
|
run = await abortable(agent.send(sendMessage), signal, (lateRun) => {
|
|
if (lateRun && typeof lateRun.cancel === "function") {
|
|
void lateRun.cancel().catch(() => {});
|
|
}
|
|
});
|
|
} finally {
|
|
restoreSendEnv();
|
|
}
|
|
const state = { reasoningOpen: false };
|
|
let hasContent = false;
|
|
let failed = false;
|
|
const onAbort = () => {
|
|
if (run && typeof run.cancel === "function") {
|
|
void run.cancel().catch(() => {});
|
|
}
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) onAbort();
|
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
try {
|
|
for await (const event of run.stream()) {
|
|
if (signal?.aborted) break;
|
|
if (event?.type === "assistant" || event?.type === "tool_call") hasContent = true;
|
|
const streamFailed = translateCursorEvent(event, emitter, state);
|
|
if (streamFailed || state.failed) {
|
|
failed = true;
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
}
|
|
closeReasoning(state, emitter);
|
|
if (failed) {
|
|
if (isCursorAuthMessage(state.errorMessage)) {
|
|
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
|
|
}
|
|
return { sessionId };
|
|
}
|
|
if (!hasContent && !signal?.aborted) {
|
|
emitter.emitError("Cursor returned an empty response. Check the Cursor API Key in Settings -> AI.");
|
|
return { sessionId };
|
|
}
|
|
if (!signal?.aborted) emitter.emitDone();
|
|
return { sessionId };
|
|
} catch (error) {
|
|
if (isCursorTurnAbortError(error) || signal?.aborted) {
|
|
return { sessionId };
|
|
}
|
|
{
|
|
const message = error?.message || String(error);
|
|
console.warn("[Cursor SDK] run error", cursorErrorDiagnostics(error));
|
|
if (isCursorAuthMessage(message)) {
|
|
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
|
|
}
|
|
emitter.emitError(formatCursorErrorForUser(message));
|
|
}
|
|
return { sessionId };
|
|
} finally {
|
|
try { await agent?.close?.(); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
function modelVariantId(modelId, params) {
|
|
const search = new URLSearchParams();
|
|
for (const param of params || []) {
|
|
if (param?.id && param?.value) search.set(param.id, param.value);
|
|
}
|
|
const qs = search.toString();
|
|
return qs ? `${modelId}?${qs}` : modelId;
|
|
}
|
|
|
|
function collectCursorEffortLevels(model) {
|
|
const levels = [];
|
|
const add = (raw) => {
|
|
const level = String(raw || "").toLowerCase();
|
|
if (CURSOR_REASONING_EFFORTS.has(level) && !levels.includes(level)) levels.push(level);
|
|
};
|
|
const effortParam = (model.parameters || []).find((param) => param?.id === "effort");
|
|
if (effortParam && Array.isArray(effortParam.values) && effortParam.values.length > 0) {
|
|
for (const item of effortParam.values) add(item?.value);
|
|
return levels;
|
|
}
|
|
for (const level of CURSOR_FALLBACK_THINKING[model.id] || []) add(level);
|
|
if (levels.length > 0) return levels;
|
|
for (const variant of model.variants || []) {
|
|
const params = Array.isArray(variant.params) ? variant.params : [];
|
|
const effortOnly = params.length === 1 && params[0]?.id === "effort" && params[0]?.value;
|
|
if (effortOnly) add(params[0].value);
|
|
}
|
|
return levels;
|
|
}
|
|
|
|
function mapCursorModels(models) {
|
|
const out = [];
|
|
if (!Array.isArray(models)) return out;
|
|
for (const model of models) {
|
|
if (!model?.id) continue;
|
|
const name = model.displayName || model.name || model.id;
|
|
const extraVariants = [];
|
|
for (const variant of model.variants || []) {
|
|
const params = Array.isArray(variant.params) ? variant.params : [];
|
|
const effortOnly = params.length === 1 && params[0]?.id === "effort" && params[0]?.value;
|
|
if (!effortOnly) extraVariants.push(variant);
|
|
}
|
|
const thinkingLevels = collectCursorEffortLevels(model);
|
|
out.push({
|
|
id: model.id,
|
|
name,
|
|
...(model.description ? { description: model.description } : {}),
|
|
...(thinkingLevels.length > 0 ? {
|
|
thinkingLevels,
|
|
defaultThinkingLevel: thinkingLevels.includes("medium") ? "medium" : thinkingLevels[0],
|
|
} : {}),
|
|
});
|
|
for (const variant of extraVariants) {
|
|
const id = modelVariantId(model.id, variant.params || []);
|
|
if (id === model.id) continue;
|
|
out.push({
|
|
id,
|
|
name: `${name} - ${variant.displayName || id}`,
|
|
...(variant.description ? { description: variant.description } : {}),
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function listCursorModels({ apiKey, env, sdkModule, abortController, signal } = {}) {
|
|
const externalSignal = signal || abortController?.signal;
|
|
if (externalSignal?.aborted) return [];
|
|
let resolvedModule = sdkModule;
|
|
if (!resolvedModule) {
|
|
try { resolvedModule = await import("@cursor/sdk"); } catch { return []; }
|
|
}
|
|
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
|
|
if (!effectiveApiKey) return [];
|
|
let abortHandler;
|
|
try {
|
|
const result = await Promise.race([
|
|
Promise.resolve(resolvedModule.Cursor.models.list({
|
|
apiKey: effectiveApiKey,
|
|
signal: externalSignal,
|
|
})).then((models) => ({ type: "models", models })),
|
|
new Promise((resolve) => {
|
|
if (externalSignal?.aborted) return resolve({ type: "aborted" });
|
|
abortHandler = () => resolve({ type: "aborted" });
|
|
externalSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
}),
|
|
]);
|
|
return result.type === "models" ? mapCursorModels(result.models) : [];
|
|
} finally {
|
|
if (abortHandler) externalSignal?.removeEventListener("abort", abortHandler);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_CURSOR_MODEL,
|
|
abortable,
|
|
applyTemporaryProcessEnv,
|
|
buildCursorAgentOptions,
|
|
buildCursorSendMessage,
|
|
formatCursorErrorForUser,
|
|
isCursorAgentNotFoundError,
|
|
listCursorModels,
|
|
mapCursorModels,
|
|
parseCursorModelSelection,
|
|
encodeCursorCliModel,
|
|
runCursorTurn,
|
|
toCursorMcpServers,
|
|
translateCursorEvent,
|
|
withTemporaryProcessEnv,
|
|
};
|