lynkr 9.14.14 → 9.14.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/difficulty-anchors.json +12 -0
- package/package.json +2 -2
- package/scripts/harvest-followup-eval.js +87 -0
- package/src/api/router.js +344 -39
- package/src/cache/embeddings.js +30 -3
- package/src/clients/cursor-utils.js +791 -0
- package/src/clients/databricks.js +136 -4
- package/src/clients/health-probe.js +10 -0
- package/src/config/index.js +10 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/sse-transformer.js +37 -3
- package/src/routing/affinity-store.js +136 -1
- package/src/routing/capabilities.js +6 -0
- package/src/routing/complexity-analyzer.js +8 -4
- package/src/routing/harness-envelope.js +71 -0
- package/src/routing/index.js +98 -13
- package/src/routing/intent-score.js +108 -75
- package/src/routing/jev-router.js +131 -25
- package/src/routing/model-registry.js +6 -3
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/passthrough-route.js +20 -1
- package/src/routing/risk-analyzer.js +8 -1
- package/src/routing/shortfall.js +12 -6
- package/src/routing/task-ledger.js +443 -0
- package/src/routing/telemetry.js +44 -2
- package/src/routing/vision.js +1 -1
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor CLI Format Conversion + Invocation Utilities.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `src/clients/codex-utils.js` / `invokeCodex` in
|
|
5
|
+
* `src/clients/databricks.js`, but for the official `cursor-agent` CLI.
|
|
6
|
+
*
|
|
7
|
+
* Invocation model (reworked 2026-09-26 after the ETIMEDOUT incident):
|
|
8
|
+
* - async `execFile` — a slow CLI call must never block the proxy's event
|
|
9
|
+
* loop (the old `execFileSync` froze EVERY session for up to 120s per
|
|
10
|
+
* call, and a 3-candidate tier fallback froze it for 6 minutes).
|
|
11
|
+
* - prompt travels over STDIN, not argv — argv has an OS size ceiling and
|
|
12
|
+
* agentic payloads exceed it; stdin has none.
|
|
13
|
+
* - session resume: the CLI returns a `session_id`; subsequent turns of
|
|
14
|
+
* the same Lynkr session send `--resume <chatId>` with ONLY the newest
|
|
15
|
+
* user turn instead of re-flattening the whole conversation. This is
|
|
16
|
+
* what makes heavy agentic traffic viable (bounded payload per turn,
|
|
17
|
+
* CLI-side prompt cache reuse). A failed resume falls back to one
|
|
18
|
+
* fresh full-prompt attempt and re-seeds the session.
|
|
19
|
+
* - timeout scales with payload size (base + per-KB), hard-capped.
|
|
20
|
+
* - at most MAX_CONCURRENT_PROCS CLI processes run at once; extra calls
|
|
21
|
+
* queue (each spawn is a full Node worker).
|
|
22
|
+
* - a one-time background warmup absorbs the ~20s worker cold-start so
|
|
23
|
+
* the first real request doesn't pay it.
|
|
24
|
+
*
|
|
25
|
+
* Auth is inherited from the user's own login (`agent login` session or
|
|
26
|
+
* `CURSOR_API_KEY` env) — Lynkr never reads Cursor's token store, it just
|
|
27
|
+
* spawns the official binary with an inherited environment. Auth failures
|
|
28
|
+
* are detected from CLI stderr and reported as such; a timeout is reported
|
|
29
|
+
* as a timeout (the old code blamed every failure on the subscription).
|
|
30
|
+
*
|
|
31
|
+
* @module clients/cursor-utils
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const { execFile, execSync } = require("node:child_process");
|
|
35
|
+
const crypto = require("node:crypto");
|
|
36
|
+
const fs = require("node:fs");
|
|
37
|
+
const os = require("node:os");
|
|
38
|
+
const path = require("node:path");
|
|
39
|
+
const logger = require("../logger");
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MODEL = "composer-2.5";
|
|
42
|
+
const DEFAULT_BINARY = "cursor-agent";
|
|
43
|
+
// Timeout: base covers worker spin-up + small prompts; large payloads earn
|
|
44
|
+
// more, capped hard. Constants, not env knobs (house convention).
|
|
45
|
+
const BASE_TIMEOUT_MS = 120_000;
|
|
46
|
+
const PER_KB_TIMEOUT_MS = 250;
|
|
47
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
48
|
+
const DEFAULT_TIMEOUT_MS = BASE_TIMEOUT_MS; // kept for existing callers/tests
|
|
49
|
+
const MAX_BUFFER_BYTES = 32 * 1024 * 1024;
|
|
50
|
+
const MAX_CONCURRENT_PROCS = 2;
|
|
51
|
+
const SESSION_CACHE_MAX = 200;
|
|
52
|
+
const WARMUP_TIMEOUT_MS = 45_000;
|
|
53
|
+
// Operator decision 2026-09-27 ("No blocking shell"): auto-approve ALL agent
|
|
54
|
+
// permission requests — shell execution included — on both the ACP and
|
|
55
|
+
// one-shot paths. NOTE: with shell allowed, the sandbox cwd is a default
|
|
56
|
+
// directory, not a security boundary. CURSOR_AUTO_APPROVE=false restores the
|
|
57
|
+
// deny-mutations policy (MCPs-only approval, no --force).
|
|
58
|
+
const CURSOR_AUTO_APPROVE = process.env.CURSOR_AUTO_APPROVE?.trim().toLowerCase() !== "false";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the binary to spawn. Test-overridable via env only —
|
|
62
|
+
* no config import here (keeps this module require-cycle free;
|
|
63
|
+
* databricks.js passes model/timeout in).
|
|
64
|
+
*/
|
|
65
|
+
function getBinaryPath(configCursor) {
|
|
66
|
+
return configCursor?.binaryPath?.trim() || process.env.CURSOR_BINARY_PATH?.trim() || DEFAULT_BINARY;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when the `cursor-agent` binary exists on PATH. On the real path
|
|
71
|
+
* (no injected which), a successful check also kicks the one-time
|
|
72
|
+
* background warmup so the worker cold-start is paid before traffic.
|
|
73
|
+
* @param {Function} [whichFn] - injectable for tests.
|
|
74
|
+
*/
|
|
75
|
+
function isAvailable(whichFn) {
|
|
76
|
+
const run = whichFn || ((bin, opts) => require("node:child_process").execFileSync("which", [bin], opts));
|
|
77
|
+
try {
|
|
78
|
+
const binary = process.env.CURSOR_BINARY_PATH?.trim() || DEFAULT_BINARY;
|
|
79
|
+
run(binary, { stdio: "ignore" });
|
|
80
|
+
if (!whichFn) setImmediate(() => warmupCursorAgent().catch(() => {}));
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractText(message) {
|
|
88
|
+
if (!message) return "";
|
|
89
|
+
const content = message.content;
|
|
90
|
+
if (typeof content === "string") return content;
|
|
91
|
+
if (!Array.isArray(content)) return "";
|
|
92
|
+
return content
|
|
93
|
+
.map((block) => {
|
|
94
|
+
if (block.type === "text") return block.text || "";
|
|
95
|
+
if (block.type === "tool_result") {
|
|
96
|
+
const result = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
|
|
97
|
+
return `[Tool Result: ${result}]`;
|
|
98
|
+
}
|
|
99
|
+
if (block.type === "tool_use") {
|
|
100
|
+
return `[Tool Call: ${block.name}(${JSON.stringify(block.input)})]`;
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
})
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Flatten Anthropic system + history into a single CLI prompt.
|
|
110
|
+
* Same strategy as convertAnthropicToCodexPrompt (last user message is
|
|
111
|
+
* the prompt, prior turns become context). Used for FRESH sessions;
|
|
112
|
+
* resumed sessions send latestUserTurnPrompt instead.
|
|
113
|
+
*/
|
|
114
|
+
function convertAnthropicToCursorPrompt(body) {
|
|
115
|
+
const systemContext = body.system || null;
|
|
116
|
+
const messages = body.messages || [];
|
|
117
|
+
if (messages.length === 0) return { prompt: "", systemContext };
|
|
118
|
+
if (messages.length === 1 && messages[0].role === "user") {
|
|
119
|
+
return { prompt: extractText(messages[0]), systemContext };
|
|
120
|
+
}
|
|
121
|
+
let lastUserIndex = -1;
|
|
122
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
123
|
+
if (messages[i]?.role === "user") {
|
|
124
|
+
lastUserIndex = i;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (lastUserIndex === -1) {
|
|
129
|
+
return { prompt: extractText(messages[messages.length - 1]), systemContext };
|
|
130
|
+
}
|
|
131
|
+
const lastUserMessage = extractText(messages[lastUserIndex]);
|
|
132
|
+
const priorMessages = messages.slice(0, lastUserIndex);
|
|
133
|
+
if (priorMessages.length === 0) return { prompt: lastUserMessage, systemContext };
|
|
134
|
+
const contextParts = priorMessages
|
|
135
|
+
.map((m) => {
|
|
136
|
+
const text = extractText(m);
|
|
137
|
+
if (!text) return null;
|
|
138
|
+
return `${m.role === "user" ? "User" : "Assistant"}: ${text}`;
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
const conversationContext = contextParts.join("\n\n");
|
|
142
|
+
return {
|
|
143
|
+
prompt: conversationContext ? `Previous conversation:\n${conversationContext}\n\nUser: ${lastUserMessage}` : lastUserMessage,
|
|
144
|
+
systemContext,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The newest user turn only — what a RESUMED CLI session receives (its own
|
|
150
|
+
* transcript already holds the earlier turns).
|
|
151
|
+
*/
|
|
152
|
+
function latestUserTurnPrompt(body) {
|
|
153
|
+
const messages = body?.messages || [];
|
|
154
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
155
|
+
if (messages[i]?.role === "user") return extractText(messages[i]);
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Stable per-conversation key for the resume cache: the caller's session id
|
|
162
|
+
* when present, else a hash of the first message (stable across turns —
|
|
163
|
+
* flattened prompts are NOT, their prefix changes shape after turn one).
|
|
164
|
+
*/
|
|
165
|
+
function deriveSessionKey(body) {
|
|
166
|
+
if (body?._sessionId && typeof body._sessionId === "string") return `sid:${body._sessionId}`;
|
|
167
|
+
const first = body?.messages?.[0];
|
|
168
|
+
const text = first ? extractText(first) : "";
|
|
169
|
+
if (!text) return null;
|
|
170
|
+
return `msg1:${crypto.createHash("sha1").update(text).digest("hex").slice(0, 32)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pull plain text out of `cursor-agent -p --output-format json` stdout.
|
|
175
|
+
* Handles: {text|result|output|content|string}, stream-json event lines,
|
|
176
|
+
* and falls back to the raw trimmed stdout.
|
|
177
|
+
*/
|
|
178
|
+
function extractCursorText(stdout) {
|
|
179
|
+
const trimmed = String(stdout || "").trim();
|
|
180
|
+
if (!trimmed) return "";
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(trimmed);
|
|
183
|
+
if (typeof parsed === "string") return parsed;
|
|
184
|
+
for (const key of ["text", "result", "output", "content", "response", "message"]) {
|
|
185
|
+
if (typeof parsed?.[key] === "string" && parsed[key].trim()) return parsed[key];
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(parsed?.content)) {
|
|
188
|
+
const t = parsed.content
|
|
189
|
+
.map((b) => (typeof b === "string" ? b : b?.text || ""))
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join("\n");
|
|
192
|
+
if (t.trim()) return t;
|
|
193
|
+
}
|
|
194
|
+
return trimmed;
|
|
195
|
+
} catch {
|
|
196
|
+
// Possibly stream-json (one JSON object per line) — collect assistant deltas.
|
|
197
|
+
const lines = trimmed.split("\n");
|
|
198
|
+
if (lines.length > 1) {
|
|
199
|
+
const parts = [];
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const l = line.trim();
|
|
202
|
+
if (!l) continue;
|
|
203
|
+
try {
|
|
204
|
+
const ev = JSON.parse(l);
|
|
205
|
+
const t =
|
|
206
|
+
ev?.message?.content?.[0]?.text ||
|
|
207
|
+
ev?.content?.[0]?.text ||
|
|
208
|
+
(typeof ev?.text === "string" ? ev.text : "") ||
|
|
209
|
+
(typeof ev?.delta === "string" ? ev.delta : "");
|
|
210
|
+
if (t) parts.push(t);
|
|
211
|
+
} catch {
|
|
212
|
+
parts.push(l);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (parts.length) return parts.join("");
|
|
216
|
+
}
|
|
217
|
+
return trimmed;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Structured view of one CLI run: text, the session id (resume handle),
|
|
223
|
+
* and token usage when the CLI reports it.
|
|
224
|
+
*/
|
|
225
|
+
function parseCursorResult(stdout) {
|
|
226
|
+
const text = extractCursorText(stdout);
|
|
227
|
+
let obj = null;
|
|
228
|
+
const trimmed = String(stdout || "").trim();
|
|
229
|
+
try {
|
|
230
|
+
obj = JSON.parse(trimmed);
|
|
231
|
+
} catch {
|
|
232
|
+
const lines = trimmed.split("\n");
|
|
233
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
234
|
+
try {
|
|
235
|
+
const candidate = JSON.parse(lines[i].trim());
|
|
236
|
+
if (candidate && typeof candidate === "object") {
|
|
237
|
+
obj = candidate;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
} catch { /* keep walking back */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const usage = obj?.usage && typeof obj.usage === "object"
|
|
244
|
+
? {
|
|
245
|
+
inputTokens: Number(obj.usage.inputTokens) || 0,
|
|
246
|
+
outputTokens: Number(obj.usage.outputTokens) || 0,
|
|
247
|
+
cacheReadTokens: Number(obj.usage.cacheReadTokens) || 0,
|
|
248
|
+
cacheWriteTokens: Number(obj.usage.cacheWriteTokens) || 0,
|
|
249
|
+
}
|
|
250
|
+
: null;
|
|
251
|
+
return { text, sessionId: typeof obj?.session_id === "string" ? obj.session_id : null, usage };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function convertCursorResponseToAnthropic(text, model, usage = null, thinking = "") {
|
|
255
|
+
const estimatedOutputTokens = Math.ceil(String(text || "").length / 4);
|
|
256
|
+
const content = [];
|
|
257
|
+
if (thinking) content.push({ type: "thinking", thinking });
|
|
258
|
+
content.push({ type: "text", text: text || "" });
|
|
259
|
+
return {
|
|
260
|
+
id: `msg_cursor_${Date.now()}`,
|
|
261
|
+
type: "message",
|
|
262
|
+
role: "assistant",
|
|
263
|
+
model: model || "cursor",
|
|
264
|
+
content,
|
|
265
|
+
stop_reason: "end_turn",
|
|
266
|
+
stop_sequence: null,
|
|
267
|
+
usage: {
|
|
268
|
+
input_tokens: usage?.inputTokens || 0,
|
|
269
|
+
output_tokens: usage?.outputTokens ?? estimatedOutputTokens,
|
|
270
|
+
cache_creation_input_tokens: usage?.cacheWriteTokens || 0,
|
|
271
|
+
cache_read_input_tokens: usage?.cacheReadTokens || 0,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Payload-scaled timeout: base + per-KB allowance, hard-capped. */
|
|
277
|
+
function scaleTimeoutMs(payloadBytes, baseMs) {
|
|
278
|
+
const base = Number(baseMs) > 0 ? Number(baseMs) : BASE_TIMEOUT_MS;
|
|
279
|
+
const scaled = base + Math.ceil((Number(payloadBytes) || 0) / 1024) * PER_KB_TIMEOUT_MS;
|
|
280
|
+
return Math.min(MAX_TIMEOUT_MS, Math.max(base, scaled));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- concurrency gate (each spawn is a full Node worker) ---------------------
|
|
284
|
+
let _inFlight = 0;
|
|
285
|
+
const _waiters = [];
|
|
286
|
+
async function _acquire() {
|
|
287
|
+
if (_inFlight < MAX_CONCURRENT_PROCS) {
|
|
288
|
+
_inFlight++;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
await new Promise((resolve) => _waiters.push(resolve));
|
|
292
|
+
_inFlight++;
|
|
293
|
+
}
|
|
294
|
+
function _release() {
|
|
295
|
+
_inFlight--;
|
|
296
|
+
const next = _waiters.shift();
|
|
297
|
+
if (next) next();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- resume cache: Lynkr session key → CLI session_id ------------------------
|
|
301
|
+
const _sessionCache = new Map();
|
|
302
|
+
function _sessionCacheSet(key, chatId) {
|
|
303
|
+
if (!key || !chatId) return;
|
|
304
|
+
if (_sessionCache.has(key)) _sessionCache.delete(key);
|
|
305
|
+
_sessionCache.set(key, chatId);
|
|
306
|
+
while (_sessionCache.size > SESSION_CACHE_MAX) {
|
|
307
|
+
_sessionCache.delete(_sessionCache.keys().next().value);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Honest error classification: timeouts are timeouts, auth is auth, missing
|
|
313
|
+
* binary is missing binary. The old code stamped every failure with the
|
|
314
|
+
* subscription hint, which misdiagnosed a plain timeout in production.
|
|
315
|
+
*/
|
|
316
|
+
function classifyCursorError(err, { payloadBytes = 0, timeoutMs = 0 } = {}) {
|
|
317
|
+
const raw = String(err?.message || err || "");
|
|
318
|
+
const stderr = String(err?.stderr || "");
|
|
319
|
+
let hint;
|
|
320
|
+
if (/ENOENT|not found|no such file/i.test(raw)) {
|
|
321
|
+
hint = "is `cursor-agent` installed? run `cursor-agent --version`; set CURSOR_BINARY_PATH if needed";
|
|
322
|
+
} else if (/not (currently )?logged in|sign ?in|unauthoriz|401|no active subscription|login required/i.test(`${raw}\n${stderr}`)) {
|
|
323
|
+
hint = "cursor-agent is not authenticated — run `cursor-agent login` and verify with `cursor-agent status`";
|
|
324
|
+
} else if (err?.killed || err?.signal === "SIGKILL" || /ETIMEDOUT|timed? ?out/i.test(raw)) {
|
|
325
|
+
hint = `timed out after ${Math.round(timeoutMs / 1000)}s with a ${Math.ceil(payloadBytes / 1024)}KB payload (worker cold-start or oversized turn)`;
|
|
326
|
+
}
|
|
327
|
+
const e = new Error(hint ? `${raw} (${hint})` : raw);
|
|
328
|
+
e.cursorHint = hint || null;
|
|
329
|
+
e.stderr = stderr;
|
|
330
|
+
return e;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The CLI is an AGENT with file tools, and a spawned child inherits the
|
|
335
|
+
* server's cwd — which is the Lynkr repo itself. Without an explicit
|
|
336
|
+
* workspace, every "review this project" routed here would read (and could
|
|
337
|
+
* write, on approval) the server's own checkout, .env included (live
|
|
338
|
+
* incident 2026-09-26). No workspace ⇒ empty sandbox dir, always.
|
|
339
|
+
*/
|
|
340
|
+
let _sandboxDir = null;
|
|
341
|
+
function getSandboxDir() {
|
|
342
|
+
if (_sandboxDir) return _sandboxDir;
|
|
343
|
+
const dir = path.join(os.tmpdir(), "lynkr-cursor-sandbox");
|
|
344
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* tmpdir exists */ }
|
|
345
|
+
_sandboxDir = dir;
|
|
346
|
+
return dir;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Default async exec: stdin transport, hard timeout, stderr captured. */
|
|
350
|
+
function _defaultExec({ binaryPath, args, stdin, timeoutMs, maxBuffer, cwd }) {
|
|
351
|
+
return new Promise((resolve, reject) => {
|
|
352
|
+
const child = execFile(
|
|
353
|
+
binaryPath,
|
|
354
|
+
args,
|
|
355
|
+
{ timeout: timeoutMs, maxBuffer, encoding: "utf8", env: { ...process.env }, cwd: cwd || getSandboxDir(), killSignal: "SIGKILL" },
|
|
356
|
+
(err, stdout, stderr) => {
|
|
357
|
+
if (err) {
|
|
358
|
+
err.stderr = String(stderr || "");
|
|
359
|
+
reject(err);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
resolve(String(stdout || ""));
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
if (child.stdin) {
|
|
366
|
+
child.stdin.on("error", () => {});
|
|
367
|
+
child.stdin.end(stdin ?? "");
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// --- ACP persistent channel (spawn-tax elimination, 2026-09-27) --------------
|
|
373
|
+
// `cursor-agent acp` is an official JSON-RPC-over-stdio server mode
|
|
374
|
+
// (cursor.com/docs/cli/acp): one long-lived process, one ndjson line per
|
|
375
|
+
// request, streaming updates, session load/resume, per-session set_model.
|
|
376
|
+
// Spike-measured: warm turns ~1.6s vs 6-9s per one-shot spawn. The one-shot
|
|
377
|
+
// `-p` path below is retained verbatim as the automatic fallback (and is
|
|
378
|
+
// still what injected execFn tests exercise).
|
|
379
|
+
const ACP_ENABLED = true; // kill switch: flip + redeploy (no env knobs)
|
|
380
|
+
const ACP_INIT_TIMEOUT_MS = 30_000;
|
|
381
|
+
const ACP_REQUEST_ID_BASE = 1;
|
|
382
|
+
|
|
383
|
+
class AcpClient {
|
|
384
|
+
constructor({ binaryPath, cwd }) {
|
|
385
|
+
this.binaryPath = binaryPath;
|
|
386
|
+
this.cwd = cwd;
|
|
387
|
+
this.child = null;
|
|
388
|
+
this.buf = "";
|
|
389
|
+
this.nextId = ACP_REQUEST_ID_BASE;
|
|
390
|
+
this.pending = new Map();
|
|
391
|
+
this.collectors = new Map(); // sessionId → {chunks:[]}
|
|
392
|
+
this.knownSessions = new Set();
|
|
393
|
+
this.toolCalls = new Map(); // toolCallId → {kind,title,command,path,start,announced,done}
|
|
394
|
+
this.models = [];
|
|
395
|
+
this.currentModel = new Map(); // sessionId → acpModelId
|
|
396
|
+
this.dead = false;
|
|
397
|
+
this.initPromise = null;
|
|
398
|
+
this.promptQueue = Promise.resolve(); // serialize prompts per process
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_spawn() {
|
|
402
|
+
const { spawn } = require("node:child_process");
|
|
403
|
+
this.child = spawn(this.binaryPath, ["acp"], { cwd: this.cwd, env: { ...process.env } });
|
|
404
|
+
this.child.on("exit", (code) => {
|
|
405
|
+
this.dead = true;
|
|
406
|
+
for (const [, p] of this.pending) p.rej(new Error(`ACP process exited (${code})`));
|
|
407
|
+
this.pending.clear();
|
|
408
|
+
logger.warn({ code, cwd: this.cwd }, "[Cursor/ACP] server process exited");
|
|
409
|
+
});
|
|
410
|
+
this.child.stdin.on("error", () => {});
|
|
411
|
+
this.child.stderr.on("data", () => {});
|
|
412
|
+
this.child.stdout.on("data", (d) => this._onData(String(d)));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
_onData(s) {
|
|
416
|
+
this.buf += s;
|
|
417
|
+
let nl;
|
|
418
|
+
while ((nl = this.buf.indexOf("\n")) >= 0) {
|
|
419
|
+
const line = this.buf.slice(0, nl); this.buf = this.buf.slice(nl + 1);
|
|
420
|
+
if (!line.trim()) continue;
|
|
421
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
422
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
423
|
+
const p = this.pending.get(msg.id); this.pending.delete(msg.id);
|
|
424
|
+
if (msg.error) p.rej(new Error(`${p.method}: ${JSON.stringify(msg.error).slice(0, 200)}`));
|
|
425
|
+
else p.res(msg.result);
|
|
426
|
+
} else if (msg.method === "session/update") {
|
|
427
|
+
const sid = msg.params?.sessionId;
|
|
428
|
+
const u = msg.params?.update || {};
|
|
429
|
+
const c = this.collectors.get(sid);
|
|
430
|
+
if (!c) continue;
|
|
431
|
+
try {
|
|
432
|
+
if (u.sessionUpdate === "agent_message_chunk" && u.content?.text) {
|
|
433
|
+
c.chunks.push(u.content.text);
|
|
434
|
+
c.onChunk?.(u.content.text, "text");
|
|
435
|
+
} else if (u.sessionUpdate === "agent_thought_chunk" && u.content?.text) {
|
|
436
|
+
c.thoughts.push(u.content.text);
|
|
437
|
+
c.onChunk?.(u.content.text, "thought");
|
|
438
|
+
} else if (u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update") {
|
|
439
|
+
// Correlated narration (2026-09-27): the initial tool_call often
|
|
440
|
+
// carries only a generic title; the real target (locations/
|
|
441
|
+
// rawInput) arrives in enrichment updates, and status updates are
|
|
442
|
+
// title-less. Merge everything per toolCallId and emit exactly
|
|
443
|
+
// two meaningful lines per tool — "▶ verb: target" once running,
|
|
444
|
+
// "✓/✗ verb: target (Ns)" on terminal. All else is swallowed.
|
|
445
|
+
const id = u.toolCallId || "?";
|
|
446
|
+
let entry = this.toolCalls.get(id);
|
|
447
|
+
if (!entry) {
|
|
448
|
+
entry = { kind: null, title: null, command: null, path: null, start: Date.now(), announced: false, done: false };
|
|
449
|
+
this.toolCalls.set(id, entry);
|
|
450
|
+
while (this.toolCalls.size > 200) this.toolCalls.delete(this.toolCalls.keys().next().value);
|
|
451
|
+
}
|
|
452
|
+
if (u.kind) entry.kind = u.kind;
|
|
453
|
+
if (u.title) entry.title = u.title;
|
|
454
|
+
if (u.rawInput?.command) entry.command = u.rawInput.command;
|
|
455
|
+
const loc = u.locations?.[0]?.path || u.rawInput?.path;
|
|
456
|
+
if (loc) entry.path = loc;
|
|
457
|
+
|
|
458
|
+
const emit = (line) => {
|
|
459
|
+
c.thoughts.push(`\n[${line}]`);
|
|
460
|
+
c.onChunk?.(line, "tool");
|
|
461
|
+
};
|
|
462
|
+
const label = () => {
|
|
463
|
+
const verb = { execute: "run", read: "read", edit: "edit", delete: "delete", move: "move", search: "search", fetch: "fetch", think: "think" }[entry.kind] || "tool";
|
|
464
|
+
let target = entry.command || entry.path || String(entry.title || "").replace(/`/g, "").trim();
|
|
465
|
+
if (target.startsWith(this.cwd)) target = target.slice(this.cwd.length + 1) || target;
|
|
466
|
+
else if (target.startsWith("/") && target.split("/").length > 3) target = "…/" + target.split("/").slice(-2).join("/");
|
|
467
|
+
const capped = target.slice(0, 80);
|
|
468
|
+
return `${verb}: ${capped}` + (target.length > 80 ? "…" : "");
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
if (u.status === "in_progress" && !entry.announced && !entry.done) {
|
|
472
|
+
entry.announced = true;
|
|
473
|
+
emit(`▶ ${label()}`);
|
|
474
|
+
} else if ((u.status === "completed" || u.status === "failed" || u.status === "cancelled") && !entry.done) {
|
|
475
|
+
entry.done = true;
|
|
476
|
+
const dur = ((Date.now() - entry.start) / 1000).toFixed(1);
|
|
477
|
+
if (u.status === "completed") {
|
|
478
|
+
emit(`✓ ${label()} (${dur}s)`);
|
|
479
|
+
} else {
|
|
480
|
+
const err = String(u.rawOutput?.stderr || "").trim().split("\n")[0].slice(0, 80);
|
|
481
|
+
const code = u.rawOutput?.exitCode != null ? ` exit ${u.rawOutput.exitCode}` : "";
|
|
482
|
+
emit(`✗ ${label()} (${dur}s)${code}${err ? " — " + err : ""}`);
|
|
483
|
+
}
|
|
484
|
+
this.toolCalls.delete(id);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch { /* consumer errors must not kill the reader */ }
|
|
488
|
+
} else if (msg.id !== undefined && msg.method === "session/request_permission") {
|
|
489
|
+
const kind = msg.params?.toolCall?.kind || "";
|
|
490
|
+
const options = msg.params?.options || [];
|
|
491
|
+
let pick;
|
|
492
|
+
if (CURSOR_AUTO_APPROVE) {
|
|
493
|
+
// Allow everything (operator-approved yolo): prefer allow_always to
|
|
494
|
+
// cut repeat prompts, fall back to any allow option.
|
|
495
|
+
pick = options.find((o) => /allow_always/i.test(o.kind || o.optionId || ""))
|
|
496
|
+
|| options.find((o) => /allow/i.test(o.kind || o.optionId || ""));
|
|
497
|
+
} else {
|
|
498
|
+
const mutating = /edit|execute|delete|move|write/i.test(kind);
|
|
499
|
+
pick = mutating
|
|
500
|
+
? options.find((o) => /reject/i.test(o.kind || o.optionId || ""))
|
|
501
|
+
: options.find((o) => /allow/i.test(o.kind || o.optionId || ""));
|
|
502
|
+
}
|
|
503
|
+
const chosen = pick || options[0];
|
|
504
|
+
this._write({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "selected", optionId: chosen?.optionId } } });
|
|
505
|
+
} else if (msg.id !== undefined && msg.method) {
|
|
506
|
+
// Unsupported agent→client request (fs/* etc — we advertise no fs).
|
|
507
|
+
this._write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "unsupported by lynkr acp client" } });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
_write(obj) {
|
|
513
|
+
try { this.child.stdin.write(JSON.stringify(obj) + "\n"); } catch { /* exit handler rejects pendings */ }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
_request(method, params, timeoutMs) {
|
|
517
|
+
if (this.dead) return Promise.reject(new Error("ACP process dead"));
|
|
518
|
+
return new Promise((res, rej) => {
|
|
519
|
+
const id = this.nextId++;
|
|
520
|
+
this.pending.set(id, { res, rej, method });
|
|
521
|
+
this._write({ jsonrpc: "2.0", id, method, params });
|
|
522
|
+
const cap = timeoutMs || ACP_INIT_TIMEOUT_MS;
|
|
523
|
+
setTimeout(() => {
|
|
524
|
+
if (this.pending.has(id)) { this.pending.delete(id); rej(new Error(`${method} timed out after ${cap}ms`)); }
|
|
525
|
+
}, cap).unref?.();
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async init() {
|
|
530
|
+
if (this.initPromise) return this.initPromise;
|
|
531
|
+
this.initPromise = (async () => {
|
|
532
|
+
this._spawn();
|
|
533
|
+
await this._request("initialize", {
|
|
534
|
+
protocolVersion: 1,
|
|
535
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
536
|
+
});
|
|
537
|
+
})();
|
|
538
|
+
return this.initPromise;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Map a Lynkr/CLI-style id (composer-2.5-fast, grok-4.7-medium-fast) to
|
|
542
|
+
* the ACP catalog's bracketed id via longest base-name prefix match. */
|
|
543
|
+
resolveModelId(tierModel) {
|
|
544
|
+
if (!tierModel || tierModel === "auto") return null;
|
|
545
|
+
let best = null;
|
|
546
|
+
for (const m of this.models) {
|
|
547
|
+
const base = String(m.modelId).split("[")[0];
|
|
548
|
+
if (tierModel === base || tierModel.startsWith(base)) {
|
|
549
|
+
if (!best || base.length > best.base.length) best = { base, id: m.modelId };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return best ? best.id : null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async newSession() {
|
|
556
|
+
const r = await this._request("session/new", { cwd: this.cwd, mcpServers: [] });
|
|
557
|
+
if (Array.isArray(r?.models?.availableModels)) this.models = r.models.availableModels;
|
|
558
|
+
this.knownSessions.add(r.sessionId);
|
|
559
|
+
return r.sessionId;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async ensureSession(sessionId) {
|
|
563
|
+
if (this.knownSessions.has(sessionId)) return true;
|
|
564
|
+
await this._request("session/load", { sessionId, cwd: this.cwd, mcpServers: [] }, ACP_INIT_TIMEOUT_MS);
|
|
565
|
+
this.knownSessions.add(sessionId);
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async setModel(sessionId, tierModel) {
|
|
570
|
+
const acpId = this.resolveModelId(tierModel);
|
|
571
|
+
if (!acpId || this.currentModel.get(sessionId) === acpId) return;
|
|
572
|
+
try {
|
|
573
|
+
await this._request("session/set_model", { sessionId, modelId: acpId });
|
|
574
|
+
this.currentModel.set(sessionId, acpId);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
logger.debug({ tierModel, acpId, err: err.message }, "[Cursor/ACP] set_model failed — session default serves");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
prompt(sessionId, text, timeoutMs, onChunk) {
|
|
581
|
+
// One prompt at a time per process — concurrency across sessions in a
|
|
582
|
+
// single ACP server is unverified; the queue keeps ordering sane and
|
|
583
|
+
// warm turns are ~1.6s so the wait is small.
|
|
584
|
+
const run = this.promptQueue.then(async () => {
|
|
585
|
+
const collector = { chunks: [], thoughts: [], onChunk };
|
|
586
|
+
this.collectors.set(sessionId, collector);
|
|
587
|
+
try {
|
|
588
|
+
const r = await this._request("session/prompt", {
|
|
589
|
+
sessionId,
|
|
590
|
+
prompt: [{ type: "text", text }],
|
|
591
|
+
}, timeoutMs);
|
|
592
|
+
return { text: collector.chunks.join(""), thinking: collector.thoughts.join(""), stopReason: r?.stopReason || "end_turn" };
|
|
593
|
+
} finally {
|
|
594
|
+
this.collectors.delete(sessionId);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
this.promptQueue = run.catch(() => {});
|
|
598
|
+
return run;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// One warm server per cwd (model is per-session, so one process serves every
|
|
603
|
+
// tier). Sandbox cwd is the default — same invariant as the one-shot path.
|
|
604
|
+
const _acpClients = new Map();
|
|
605
|
+
async function _getAcpClient(binaryPath, cwd) {
|
|
606
|
+
const key = `${binaryPath}\u0000${cwd}`;
|
|
607
|
+
let client = _acpClients.get(key);
|
|
608
|
+
if (client && !client.dead) return client;
|
|
609
|
+
client = new AcpClient({ binaryPath, cwd });
|
|
610
|
+
_acpClients.set(key, client);
|
|
611
|
+
await client.init();
|
|
612
|
+
return client;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* ACP-path serve. Same return contract as the one-shot path; throws on any
|
|
617
|
+
* ACP-layer failure so runCursorAgent can fall back to the spawn path.
|
|
618
|
+
*/
|
|
619
|
+
async function _runViaAcp({ prompt, resumePrompt, sessionKey, model, baseTimeoutMs, binaryPath, workspace, onDelta }) {
|
|
620
|
+
const cwd = workspace || getSandboxDir();
|
|
621
|
+
const client = await _getAcpClient(binaryPath || DEFAULT_BINARY, cwd);
|
|
622
|
+
const cached = sessionKey ? _sessionCache.get(sessionKey) : null;
|
|
623
|
+
const cachedAcp = cached && typeof cached === "object" && cached.acp ? cached.acp : null;
|
|
624
|
+
|
|
625
|
+
let sessionId = null;
|
|
626
|
+
let resumed = false;
|
|
627
|
+
let stdinText = prompt || "";
|
|
628
|
+
if (cachedAcp && resumePrompt) {
|
|
629
|
+
try {
|
|
630
|
+
await client.ensureSession(cachedAcp);
|
|
631
|
+
sessionId = cachedAcp;
|
|
632
|
+
stdinText = resumePrompt;
|
|
633
|
+
resumed = true;
|
|
634
|
+
} catch { /* stale/unloadable — fall through to a fresh session */ }
|
|
635
|
+
}
|
|
636
|
+
if (!sessionId) {
|
|
637
|
+
if (sessionKey) _sessionCache.delete(sessionKey);
|
|
638
|
+
sessionId = await client.newSession();
|
|
639
|
+
}
|
|
640
|
+
await client.setModel(sessionId, model);
|
|
641
|
+
const timeoutMs = scaleTimeoutMs(Buffer.byteLength(stdinText, "utf8"), baseTimeoutMs || BASE_TIMEOUT_MS);
|
|
642
|
+
const t0 = Date.now();
|
|
643
|
+
const { text, thinking, stopReason } = await client.prompt(sessionId, stdinText, timeoutMs, onDelta);
|
|
644
|
+
if (sessionKey) _sessionCacheSet(sessionKey, { acp: sessionId });
|
|
645
|
+
logger.debug({ resumed, model, ms: Date.now() - t0, stopReason }, "[Cursor/ACP] prompt served");
|
|
646
|
+
return {
|
|
647
|
+
stdout: JSON.stringify({ type: "result", subtype: "acp", result: text, session_id: sessionId }),
|
|
648
|
+
text,
|
|
649
|
+
thinking: thinking || "",
|
|
650
|
+
sessionId,
|
|
651
|
+
usage: null, // ACP updates carry no token usage — converter estimates
|
|
652
|
+
resumed,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let _warmupDone = false;
|
|
657
|
+
/**
|
|
658
|
+
* One-time background warmup: absorbs the CLI worker's ~20s cold start so
|
|
659
|
+
* the first real request doesn't spend its timeout budget on it.
|
|
660
|
+
*/
|
|
661
|
+
async function warmupCursorAgent(binaryPath) {
|
|
662
|
+
if (_warmupDone) return;
|
|
663
|
+
_warmupDone = true;
|
|
664
|
+
const bin = binaryPath || getBinaryPath(null);
|
|
665
|
+
try {
|
|
666
|
+
if (ACP_ENABLED) {
|
|
667
|
+
// Boot the persistent ACP server (pays the worker cold-start once).
|
|
668
|
+
await _getAcpClient(bin, getSandboxDir());
|
|
669
|
+
logger.debug("[Cursor] ACP server warm");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
await _defaultExec({
|
|
673
|
+
binaryPath: bin,
|
|
674
|
+
args: ["-p", "--output-format", "json", "--model", DEFAULT_MODEL, "--trust", "--approve-mcps"],
|
|
675
|
+
stdin: "Reply with OK.",
|
|
676
|
+
timeoutMs: WARMUP_TIMEOUT_MS,
|
|
677
|
+
cwd: getSandboxDir(),
|
|
678
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
679
|
+
});
|
|
680
|
+
logger.debug("[Cursor] warmup complete — worker hot");
|
|
681
|
+
} catch (err) {
|
|
682
|
+
logger.warn({ err: err.message }, "[Cursor] warmup failed (non-fatal) — first request will pay the cold start");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Spawn `cursor-agent -p` (async) and return the parsed run.
|
|
688
|
+
*
|
|
689
|
+
* @param {Object} args
|
|
690
|
+
* @param {string} args.prompt - full flattened prompt (fresh sessions)
|
|
691
|
+
* @param {string} [args.resumePrompt] - newest user turn (resumed sessions)
|
|
692
|
+
* @param {string|null} [args.sessionKey] - stable conversation key (deriveSessionKey)
|
|
693
|
+
* @param {string} args.model - Cursor model id
|
|
694
|
+
* @param {number} [args.baseTimeoutMs] - base before payload scaling (legacy alias: timeoutMs)
|
|
695
|
+
* @param {string} args.binaryPath
|
|
696
|
+
* @param {string|null} args.workspace - passed as --workspace when set
|
|
697
|
+
* @param {Function} [args.execFn] - injectable for tests: async ({binaryPath,args,stdin,timeoutMs,maxBuffer}) => stdout
|
|
698
|
+
* @returns {Promise<{stdout:string, text:string, sessionId:string|null, usage:Object|null, resumed:boolean}>}
|
|
699
|
+
*/
|
|
700
|
+
async function runCursorAgent({ prompt, resumePrompt, sessionKey, model, baseTimeoutMs, timeoutMs, binaryPath, workspace, execFn, onDelta }) {
|
|
701
|
+
const exec = execFn || _defaultExec;
|
|
702
|
+
const base = baseTimeoutMs || timeoutMs || BASE_TIMEOUT_MS;
|
|
703
|
+
// Persistent ACP channel first (real path only — injected execFn keeps the
|
|
704
|
+
// one-shot contract for tests). Any ACP failure falls through to the
|
|
705
|
+
// one-shot spawn below.
|
|
706
|
+
if (ACP_ENABLED && !execFn) {
|
|
707
|
+
try {
|
|
708
|
+
return await _runViaAcp({ prompt, resumePrompt, sessionKey, model, baseTimeoutMs: base, binaryPath, workspace, onDelta });
|
|
709
|
+
} catch (err) {
|
|
710
|
+
logger.warn({ err: err.message }, "[Cursor/ACP] persistent channel failed — falling back to one-shot spawn");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const cachedRaw = sessionKey ? _sessionCache.get(sessionKey) || null : null;
|
|
714
|
+
// Legacy --resume takes a chat id STRING; ACP-era cache entries are
|
|
715
|
+
// objects and must not leak into the spawn path.
|
|
716
|
+
const cachedChat = typeof cachedRaw === "string" ? cachedRaw : null;
|
|
717
|
+
const useResume = Boolean(cachedChat && resumePrompt);
|
|
718
|
+
|
|
719
|
+
const buildArgs = (resumeChatId) => {
|
|
720
|
+
const a = ["-p", "--output-format", "json"];
|
|
721
|
+
if (model) a.push("--model", model);
|
|
722
|
+
a.push("--trust");
|
|
723
|
+
// Required for headless operation (no one can click approve): MCP servers
|
|
724
|
+
// must be pre-approved via --approve-mcps or the run stalls on an
|
|
725
|
+
// approval prompt until timeout. Shell/write approvals follow
|
|
726
|
+
// CURSOR_AUTO_APPROVE — the operator made that call explicitly
|
|
727
|
+
// (2026-09-27); it is not a silent default.
|
|
728
|
+
a.push("--approve-mcps");
|
|
729
|
+
if (CURSOR_AUTO_APPROVE) a.push("--force");
|
|
730
|
+
if (workspace) a.push("--workspace", workspace);
|
|
731
|
+
if (resumeChatId) a.push("--resume", resumeChatId);
|
|
732
|
+
return a;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
await _acquire();
|
|
736
|
+
try {
|
|
737
|
+
let stdout;
|
|
738
|
+
let resumed = useResume;
|
|
739
|
+
const firstStdin = useResume ? resumePrompt : prompt || "";
|
|
740
|
+
const firstTimeout = scaleTimeoutMs(Buffer.byteLength(firstStdin, "utf8"), base);
|
|
741
|
+
logger.debug(
|
|
742
|
+
{ binary: binaryPath, model, payloadBytes: Buffer.byteLength(firstStdin, "utf8"), resumed: useResume, timeoutMs: firstTimeout },
|
|
743
|
+
"[Cursor] Spawning cursor-agent"
|
|
744
|
+
);
|
|
745
|
+
try {
|
|
746
|
+
stdout = await exec({ binaryPath, args: buildArgs(useResume ? cachedChat : null), stdin: firstStdin, timeoutMs: firstTimeout, maxBuffer: MAX_BUFFER_BYTES, cwd: workspace || getSandboxDir() });
|
|
747
|
+
} catch (err) {
|
|
748
|
+
if (!useResume) throw classifyCursorError(err, { payloadBytes: Buffer.byteLength(firstStdin, "utf8"), timeoutMs: firstTimeout });
|
|
749
|
+
// Stale/failed resume → drop the handle, retry once fresh with the
|
|
750
|
+
// full flattened prompt so the conversation re-seeds.
|
|
751
|
+
logger.warn({ sessionKey, err: err.message }, "[Cursor] resume failed — retrying fresh");
|
|
752
|
+
_sessionCache.delete(sessionKey);
|
|
753
|
+
resumed = false;
|
|
754
|
+
const freshTimeout = scaleTimeoutMs(Buffer.byteLength(prompt || "", "utf8"), base);
|
|
755
|
+
try {
|
|
756
|
+
stdout = await exec({ binaryPath, args: buildArgs(null), stdin: prompt || "", timeoutMs: freshTimeout, maxBuffer: MAX_BUFFER_BYTES, cwd: workspace || getSandboxDir() });
|
|
757
|
+
} catch (err2) {
|
|
758
|
+
throw classifyCursorError(err2, { payloadBytes: Buffer.byteLength(prompt || "", "utf8"), timeoutMs: freshTimeout });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const parsed = parseCursorResult(stdout);
|
|
762
|
+
if (sessionKey && parsed.sessionId) _sessionCacheSet(sessionKey, parsed.sessionId);
|
|
763
|
+
return { stdout, text: parsed.text, sessionId: parsed.sessionId, usage: parsed.usage, resumed };
|
|
764
|
+
} finally {
|
|
765
|
+
_release();
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
module.exports = {
|
|
770
|
+
DEFAULT_MODEL,
|
|
771
|
+
DEFAULT_TIMEOUT_MS,
|
|
772
|
+
BASE_TIMEOUT_MS,
|
|
773
|
+
MAX_TIMEOUT_MS,
|
|
774
|
+
MAX_CONCURRENT_PROCS,
|
|
775
|
+
DEFAULT_BINARY,
|
|
776
|
+
getBinaryPath,
|
|
777
|
+
isAvailable,
|
|
778
|
+
extractText,
|
|
779
|
+
convertAnthropicToCursorPrompt,
|
|
780
|
+
latestUserTurnPrompt,
|
|
781
|
+
deriveSessionKey,
|
|
782
|
+
extractCursorText,
|
|
783
|
+
parseCursorResult,
|
|
784
|
+
convertCursorResponseToAnthropic,
|
|
785
|
+
scaleTimeoutMs,
|
|
786
|
+
classifyCursorError,
|
|
787
|
+
getSandboxDir,
|
|
788
|
+
CURSOR_AUTO_APPROVE,
|
|
789
|
+
warmupCursorAgent,
|
|
790
|
+
runCursorAgent,
|
|
791
|
+
};
|