cursor-opencode-provider 0.2.3 → 0.2.5

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.
@@ -1,23 +1,27 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { createHash } from "node:crypto";
3
- import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
4
+ import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
4
5
  import { trace } from "./debug.js";
5
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
6
7
  import { decodeFramePayload } from "./protocol/framing.js";
7
8
  import { decodeMessage } from "./protocol/messages.js";
8
9
  import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, } from "./protocol/tools.js";
10
+ import { describeCursorExecVariant } from "./protocol/exec-variants.js";
9
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
10
12
  import { handleKvServerMessage } from "./protocol/kv.js";
11
13
  import { handleInteractionQuery, inspectInteractionQueryWire } from "./protocol/interactions.js";
12
14
  import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
13
15
  import { conversationBlobCount } from "./protocol/blob-store.js";
14
16
  import { bindConversationId, } from "./protocol/conversation-bind.js";
15
- import { sessionManager } from "./session.js";
17
+ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
18
+ import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
16
19
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
17
20
  import { buildRequestContext } from "./context/build.js";
18
21
  import { opencodeGlobalCacheDir } from "./context/paths.js";
19
22
  import { resolveAgentUrl } from "./agent-url.js";
20
23
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
24
+ import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
21
25
  let _availableModels;
22
26
  // mtime of the cache file the last time we loaded it. Compared on each call
23
27
  // so discoverModels' background refresh is picked up without a process restart.
@@ -32,6 +36,173 @@ const toolCatalogBySession = new Map();
32
36
  // tool use instead of emitting exec requests. Rebase once on the next turn.
33
37
  const postCompactionRebaseBySession = new Set();
34
38
  export const MAX_TURN_STATE_SESSIONS = 256;
39
+ const DEFAULT_RETRY_POLICY = {
40
+ maxAttempts: 3,
41
+ baseDelayMs: 500,
42
+ maxDelayMs: 8_000,
43
+ };
44
+ const MAX_RETRY_ATTEMPTS = 10;
45
+ const MAX_RETRY_DELAY_MS = 30_000;
46
+ function retryInteger(name, value, fallback) {
47
+ const resolved = value === undefined ? fallback : value;
48
+ if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved <= 0) {
49
+ throw new CursorProtocolError(`Cursor retry ${name} must be a positive integer`);
50
+ }
51
+ return resolved;
52
+ }
53
+ export function resolveRetryPolicy(options) {
54
+ if (options !== undefined && (options === null || typeof options !== "object" || Array.isArray(options))) {
55
+ throw new CursorProtocolError("Cursor retry options must be an object");
56
+ }
57
+ for (const key of Object.keys(options ?? {})) {
58
+ if (!["maxAttempts", "baseDelayMs", "maxDelayMs"].includes(key)) {
59
+ throw new CursorProtocolError(`Unknown Cursor retry option: ${key}`);
60
+ }
61
+ }
62
+ const maxAttempts = retryInteger("maxAttempts", options?.maxAttempts, DEFAULT_RETRY_POLICY.maxAttempts);
63
+ const baseDelayMs = retryInteger("baseDelayMs", options?.baseDelayMs, DEFAULT_RETRY_POLICY.baseDelayMs);
64
+ const maxDelayMs = retryInteger("maxDelayMs", options?.maxDelayMs, DEFAULT_RETRY_POLICY.maxDelayMs);
65
+ if (maxAttempts > MAX_RETRY_ATTEMPTS) {
66
+ throw new CursorProtocolError(`Cursor retry maxAttempts must be no greater than ${MAX_RETRY_ATTEMPTS}`);
67
+ }
68
+ if (baseDelayMs > MAX_RETRY_DELAY_MS || maxDelayMs > MAX_RETRY_DELAY_MS) {
69
+ throw new CursorProtocolError(`Cursor retry delays must be no greater than ${MAX_RETRY_DELAY_MS}ms`);
70
+ }
71
+ if (baseDelayMs > maxDelayMs) {
72
+ throw new CursorProtocolError("Cursor retry baseDelayMs must be no greater than maxDelayMs");
73
+ }
74
+ return { maxAttempts, baseDelayMs, maxDelayMs };
75
+ }
76
+ function retryDelayMs(error, attempt, policy) {
77
+ if (error.retryAfterMs !== undefined)
78
+ return Math.min(MAX_RETRY_DELAY_MS, error.retryAfterMs);
79
+ const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** Math.max(0, attempt - 1));
80
+ return Math.floor(Math.random() * ceiling);
81
+ }
82
+ function sleepForRetry(delayMs, signal) {
83
+ if (signal?.aborted)
84
+ return Promise.reject(new CursorLocalCancellationError("Cursor retry cancelled"));
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(finish, delayMs);
87
+ const onAbort = () => finish(new CursorLocalCancellationError("Cursor retry cancelled"));
88
+ function finish(error) {
89
+ clearTimeout(timer);
90
+ signal?.removeEventListener("abort", onAbort);
91
+ if (error)
92
+ reject(error);
93
+ else
94
+ resolve();
95
+ }
96
+ signal?.addEventListener("abort", onAbort, { once: true });
97
+ });
98
+ }
99
+ function retryDelayFromValue(value) {
100
+ if (typeof value === "string") {
101
+ const seconds = /^(\d+(?:\.\d+)?)s$/.exec(value.trim());
102
+ if (seconds)
103
+ return Math.ceil(Number(seconds[1]) * 1_000);
104
+ const protobufDelay = retryInfoProtobufDelayMs(value.trim());
105
+ if (protobufDelay !== undefined)
106
+ return protobufDelay;
107
+ }
108
+ if (!value || typeof value !== "object")
109
+ return undefined;
110
+ const duration = value;
111
+ const seconds = Number(duration.seconds ?? 0);
112
+ const nanos = Number(duration.nanos ?? 0);
113
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos) || seconds < 0 || nanos < 0)
114
+ return undefined;
115
+ return Math.ceil(seconds * 1_000 + nanos / 1_000_000);
116
+ }
117
+ /** Decode google.rpc.RetryInfo.value without adding another protobuf schema. */
118
+ function retryInfoProtobufDelayMs(encoded) {
119
+ if (!encoded || encoded.length > 512 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded)) {
120
+ return undefined;
121
+ }
122
+ let bytes;
123
+ try {
124
+ bytes = Buffer.from(encoded.replaceAll("-", "+").replaceAll("_", "/"), "base64");
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ const readVarint = (input, start) => {
130
+ let value = 0n;
131
+ let shift = 0n;
132
+ for (let offset = start; offset < input.length && offset < start + 10; offset++) {
133
+ const byte = input[offset];
134
+ value |= BigInt(byte & 0x7f) << shift;
135
+ if ((byte & 0x80) === 0)
136
+ return [value, offset + 1];
137
+ shift += 7n;
138
+ }
139
+ return undefined;
140
+ };
141
+ const outerKey = readVarint(bytes, 0);
142
+ if (!outerKey || outerKey[0] !== 0x0an)
143
+ return undefined;
144
+ const outerLength = readVarint(bytes, outerKey[1]);
145
+ if (!outerLength || outerLength[0] > BigInt(bytes.length - outerLength[1]))
146
+ return undefined;
147
+ const duration = bytes.subarray(outerLength[1], outerLength[1] + Number(outerLength[0]));
148
+ let offset = 0;
149
+ let seconds = 0n;
150
+ let nanos = 0n;
151
+ while (offset < duration.length) {
152
+ const key = readVarint(duration, offset);
153
+ if (!key)
154
+ return undefined;
155
+ offset = key[1];
156
+ const field = Number(key[0] >> 3n);
157
+ if (Number(key[0] & 7n) !== 0)
158
+ return undefined;
159
+ const item = readVarint(duration, offset);
160
+ if (!item)
161
+ return undefined;
162
+ offset = item[1];
163
+ if (field === 1)
164
+ seconds = item[0];
165
+ else if (field === 2)
166
+ nanos = item[0];
167
+ }
168
+ if (seconds > BigInt(Number.MAX_SAFE_INTEGER) || nanos > 999999999n)
169
+ return undefined;
170
+ return Math.ceil(Number(seconds) * 1_000 + Number(nanos) / 1_000_000);
171
+ }
172
+ export function connectFrameError(payload) {
173
+ try {
174
+ const envelope = JSON.parse(payload);
175
+ const code = typeof envelope.error?.code === "string" ? envelope.error.code : "unknown";
176
+ if (code === "unauthenticated" || code === "permission_denied") {
177
+ return new CursorAuthError(`Cursor authentication failed (${code}); reauthenticate with Cursor`, { code });
178
+ }
179
+ let retryAfterMs = retryDelayFromValue(envelope.error?.retryAfter ?? envelope.error?.retry_after);
180
+ let hasRetryInfo = false;
181
+ if (Array.isArray(envelope.error?.details)) {
182
+ for (const detail of envelope.error.details) {
183
+ if (!detail || typeof detail !== "object")
184
+ continue;
185
+ const record = detail;
186
+ const type = record.type;
187
+ if (type === "google.rpc.RetryInfo" || (typeof type === "string" && type.endsWith("/google.rpc.RetryInfo"))) {
188
+ hasRetryInfo = true;
189
+ retryAfterMs ??= retryDelayFromValue(record.retryDelay ?? record.retry_delay ?? record.value);
190
+ }
191
+ }
192
+ }
193
+ return new CursorServerError(`Cursor API error (code=${code})`, {
194
+ transient: isTransientGrpcStatus(code) || hasRetryInfo,
195
+ replaySafe: true,
196
+ code,
197
+ retryAfterMs: retryAfterMs === undefined
198
+ ? undefined
199
+ : Math.min(MAX_RETRY_DELAY_MS, retryAfterMs),
200
+ });
201
+ }
202
+ catch {
203
+ return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
204
+ }
205
+ }
35
206
  function rememberToolCatalog(sessionKey, tools) {
36
207
  toolCatalogBySession.delete(sessionKey);
37
208
  toolCatalogBySession.set(sessionKey, tools);
@@ -88,6 +259,12 @@ async function doStreamImpl(modelId, options, callOptions) {
88
259
  baseUrl: resolveApiBaseURL(options),
89
260
  });
90
261
  const prompt = callOptions.prompt;
262
+ const promptTokens = estimatePromptTokens(prompt);
263
+ const retryPolicy = resolveRetryPolicy(options.retry);
264
+ // pumpWithRecovery owns the complete per-turn attempt budget. Opening a
265
+ // replacement session here must be a single attempt; otherwise setup retry
266
+ // loops nest inside recovery and `maxAttempts` no longer caps total Runs.
267
+ const openSession = (startOptions) => startSession(modelId, token, callOptions, options, startOptions);
91
268
  // ── Continuation vs fresh turn ──
92
269
  // OpenCode embeds *all* historical tool results in every prompt. Only the
93
270
  // trailing tool-message suffix (after the last assistant/user message) is a
@@ -97,65 +274,33 @@ async function doStreamImpl(modelId, options, callOptions) {
97
274
  const trailingToolResults = extractTrailingToolResults(prompt);
98
275
  let session = findContinuationSession(trailingToolResults);
99
276
  if (session) {
100
- // Deliver only results that this live session is still awaiting.
101
- const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
102
- trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
103
- `${pendingResults.length} pending for sessionId=${session.sessionId} ` +
104
- `pending={${[...session.pending.keys()].join(",")}}`);
105
- for (const r of pendingResults) {
106
- const pending = session.pending.get(r.execId);
107
- if (!pending)
108
- continue;
109
- if (pending.bridged) {
110
- // Display-only Cursor tool — OpenCode already ran it; nothing to write
111
- // back on the Run stream.
112
- session.usageEstimate.inputTokens += estimateTokens(r.output.length);
113
- trace(`continuation: bridged tool result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
114
- sessionManager.resolve(session.sessionId, r.execId);
115
- continue;
116
- }
117
- try {
118
- for (const frame of buildExecClientMessages({
119
- execId: r.execId,
120
- resultField: pending.resultField,
121
- output: r.output,
122
- error: r.error,
123
- toolName: pending.toolName ?? r.toolName,
124
- })) {
125
- session.stream.write(frame);
126
- }
127
- // Tool results enlarge Cursor's context for the rest of this Run.
128
- session.usageEstimate.inputTokens += estimateTokens(r.output.length);
129
- trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} outLen=${r.output.length}`);
130
- // Only resolve after the write succeeded — if it threw, the server never
131
- // got the result and would block on heartbeats forever otherwise.
132
- sessionManager.resolve(session.sessionId, r.execId);
133
- }
134
- catch (e) {
135
- trace(`continuation: write FAILED execId=${r.execId} err=${e.message} — leaving pending`);
136
- }
137
- }
138
- sessionManager.touch(session);
277
+ // Write pending results onto the held-open Run. A dead stream closes the
278
+ // session and returns undefined so we fall through to history rebase
279
+ // instead of pumping a connection that can no longer accept writes.
280
+ session = deliverContinuationResults(session, trailingToolResults);
139
281
  }
140
- else if (trailingToolResults.length > 0) {
141
- // True continuation (prompt ends with tool results) but the Cursor Run is
142
- // gone. Soft-stop instead of re-prompting (doom loop) or hard-erroring.
143
- const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
144
- trace(`continuation: ${trailingToolResults.length} orphaned trailing tool result(s) [${ids}] soft-stop`);
145
- return orphanedToolResultsStream(ids);
146
- }
147
- else {
148
- // Fresh turn (prompt ends with user/assistant text). Historical tool
149
- // results may exist mid-prompt; they are not live exec replies.
150
- const historical = extractToolResults(prompt).length;
151
- if (historical > 0) {
152
- trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
282
+ if (!session) {
283
+ if (trailingToolResults.length > 0) {
284
+ // True continuation (prompt ends with tool results) but the held-open Run
285
+ // is gone (or its write path just failed). Rebase the complete OpenCode
286
+ // prompt onto a fresh conversation: its seed history includes the
287
+ // completed tool result, so no result or advertised tool is lost and
288
+ // Cursor can continue instead of deadlocking.
289
+ const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
290
+ trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] rebasing fresh Run`);
291
+ session = await openSession({ recovery: true });
292
+ }
293
+ else {
294
+ // Fresh turn (prompt ends with user/assistant text). Historical tool
295
+ // results may exist mid-prompt; they are not live exec replies.
296
+ const historical = extractToolResults(prompt).length;
297
+ if (historical > 0) {
298
+ trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
299
+ }
300
+ session = await openSession();
153
301
  }
154
- session = await startSession(modelId, token, callOptions, options);
155
302
  }
156
- const boundSession = session;
157
- const textId = crypto.randomUUID();
158
- const reasoningId = crypto.randomUUID();
303
+ let activeSession = session;
159
304
  return {
160
305
  stream: new ReadableStream({
161
306
  async pull(controller) {
@@ -172,13 +317,15 @@ async function doStreamImpl(modelId, options, callOptions) {
172
317
  trace(`pull: stream-start enqueue failed (cancelled) err=${e.message}`);
173
318
  return;
174
319
  }
175
- boundSession.pumpActive = true;
176
- try {
177
- await pump(boundSession, controller, { textId, reasoningId }, callOptions.abortSignal);
178
- }
179
- finally {
180
- boundSession.pumpActive = false;
181
- }
320
+ activeSession = await pumpWithRecovery({
321
+ initialSession: activeSession,
322
+ controller,
323
+ abortSignal: callOptions.abortSignal,
324
+ promptTokens,
325
+ retryPolicy,
326
+ recover: () => openSession({ recovery: true }),
327
+ onSession: (next) => { activeSession = next; },
328
+ });
182
329
  try {
183
330
  controller.close();
184
331
  }
@@ -187,9 +334,9 @@ async function doStreamImpl(modelId, options, callOptions) {
187
334
  }
188
335
  }
189
336
  catch (e) {
190
- boundSession.pumpActive = false;
337
+ activeSession.pumpActive = false;
191
338
  trace(`pull: pump threw (cleaning up): ${e.message}`);
192
- sessionManager.closeUnlessPending(boundSession);
339
+ sessionManager.close(activeSession);
193
340
  try {
194
341
  controller.error(e instanceof Error ? e : new Error(String(e)));
195
342
  }
@@ -202,12 +349,60 @@ async function doStreamImpl(modelId, options, callOptions) {
202
349
  // OpenCode cancels the ReadableStream after "tool-calls"; keep the
203
350
  // Cursor Run stream alive so the next doStream can write results.
204
351
  trace("ReadableStream cancel() → closeUnlessPending");
205
- sessionManager.closeUnlessPending(boundSession);
352
+ sessionManager.closeUnlessPending(activeSession);
206
353
  },
207
354
  }),
208
355
  };
209
356
  }
210
- async function startSession(modelId, token, callOptions, options) {
357
+ export async function pumpWithRecovery(input) {
358
+ let session = input.initialSession;
359
+ const retryPolicy = input.retryPolicy ?? {
360
+ ...DEFAULT_RETRY_POLICY,
361
+ maxAttempts: (input.maxRecoveries ?? 1) + 1,
362
+ };
363
+ const maxRecoveries = retryPolicy.maxAttempts - 1;
364
+ input.onSession?.(session);
365
+ for (let attempt = 0;; attempt++) {
366
+ const pumpedSession = session;
367
+ const pumpOwner = Symbol("cursor-pump");
368
+ sessionManager.beginPump(pumpedSession, pumpOwner);
369
+ try {
370
+ await pump(pumpedSession, input.controller, {
371
+ textId: crypto.randomUUID(),
372
+ reasoningId: crypto.randomUUID(),
373
+ promptTokens: input.promptTokens ?? 0,
374
+ }, input.abortSignal);
375
+ return session;
376
+ }
377
+ catch (error) {
378
+ const failure = toCursorProviderError(error, {
379
+ replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
380
+ fallback: "Cursor Run interrupted",
381
+ });
382
+ if (!failure.transient)
383
+ throw failure;
384
+ if (!failure.replaySafe) {
385
+ throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
386
+ }
387
+ if (attempt >= maxRecoveries) {
388
+ throw new CursorRetryExhaustedError(attempt + 1, failure);
389
+ }
390
+ trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
391
+ `err=${failure.message} — rebasing fresh Run`);
392
+ sessionManager.close(pumpedSession, "remote-error", failure);
393
+ const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
394
+ trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
395
+ await sleepForRetry(delayMs, input.abortSignal);
396
+ session = await input.recover();
397
+ input.onSession?.(session);
398
+ }
399
+ finally {
400
+ sessionManager.endPump(pumpedSession, pumpOwner);
401
+ }
402
+ }
403
+ }
404
+ async function startSession(modelId, token, callOptions, options, startOptions) {
405
+ const continuationPolicy = resolveContinuationPolicy(options.continuation);
211
406
  const prompt = callOptions.prompt;
212
407
  const incomingTools = extractTools(callOptions);
213
408
  const sessionKey = opencodeSessionKey(callOptions);
@@ -224,23 +419,30 @@ async function startSession(modelId, token, callOptions, options) {
224
419
  const tools = toolState.advertisedTools;
225
420
  const allowTools = toolState.allowTools;
226
421
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
422
+ const recovery = startOptions?.recovery === true;
227
423
  // Compaction must not reuse the prior conversation; its first normal turn
228
424
  // must also rebase so the summary-agent checkpoint cannot replace the normal
229
425
  // system prompt and OpenCode's newly compacted history.
230
- const bound = bindConversationId(sessionKey, { reset: resetState.reset });
426
+ const bound = bindConversationId(sessionKey, { reset: resetState.reset || recovery });
231
427
  const conversationId = bound.conversationId;
232
428
  if (bound.reset) {
233
- trace(`conversation reset: reason=${resetState.reason ?? "unknown"} ` +
429
+ trace(`conversation reset: reason=${recovery ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
234
430
  `sessionKey=${sessionKey ?? "(none)"} ` +
235
431
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
236
432
  }
237
- const userText = extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".";
433
+ const userText = recovery
434
+ ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
435
+ : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
436
+ const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
238
437
  const baseSystemPrompt = extractSystemPrompt(prompt);
239
- const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction);
438
+ const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot);
240
439
  const systemPrompt = interactionGuidance
241
440
  ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
242
441
  : baseSystemPrompt;
243
- const history = extractPromptHistory(prompt);
442
+ const history = extractPromptHistory(prompt, {
443
+ preserveTrailingUser: recovery,
444
+ toolResults: isCompaction ? "all" : (recovery ? "trailing" : "omit"),
445
+ });
244
446
  await loadAvailableModels();
245
447
  // Resolve the region-specific Run stream origin once per process (memoized
246
448
  // in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
@@ -275,7 +477,6 @@ async function startSession(modelId, token, callOptions, options) {
275
477
  baseURL: agentBaseUrl,
276
478
  headers: options.headers,
277
479
  });
278
- const workspaceRoot = options.workspaceRoot || process.cwd();
279
480
  const requestContext = await buildRequestContext({ workspaceRoot, tools });
280
481
  // Resolve descriptors once from the merged OpenCode config so MCP identity is
281
482
  // consistent across AgentRunRequest and both request_context reply paths.
@@ -294,7 +495,6 @@ async function startSession(modelId, token, callOptions, options) {
294
495
  conversationState,
295
496
  parameterValues,
296
497
  maxMode,
297
- availableModels: _availableModels,
298
498
  tools,
299
499
  toolDescriptors,
300
500
  requestContext,
@@ -331,10 +531,17 @@ async function startSession(modelId, token, callOptions, options) {
331
531
  trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
332
532
  if (conversationState)
333
533
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
334
- stream.write(reqBytes);
534
+ try {
535
+ await writeWithBackpressure(stream, reqBytes, "initial Run request");
536
+ }
537
+ catch (error) {
538
+ stream.destroy();
539
+ throw error;
540
+ }
335
541
  const session = {
336
542
  sessionId: crypto.randomUUID(),
337
543
  conversationId,
544
+ openCodeSessionId: sessionKey,
338
545
  stream,
339
546
  frames: stream.frames()[Symbol.asyncIterator](),
340
547
  pending: new Map(),
@@ -346,15 +553,50 @@ async function startSession(modelId, token, callOptions, options) {
346
553
  allowTools,
347
554
  usageEstimate,
348
555
  pumpActive: false,
556
+ pumpOwner: null,
349
557
  heartbeat: null,
350
- expiresAt: Date.now() + 300_000,
558
+ heartbeatCancel: null,
559
+ hardDeadlineTimer: null,
560
+ semanticDeadlineCancel: null,
561
+ terminalUnsubscribe: null,
562
+ deferredTerminalReason: null,
563
+ policy: continuationPolicy,
564
+ createdAt: Date.now(),
565
+ lastInboundAt: Date.now(),
566
+ lastHeartbeatWriteAt: Date.now(),
567
+ semanticDeadlineAt: Date.now() + continuationPolicy.semanticIdleMs,
568
+ closeError: null,
569
+ closed: false,
351
570
  };
571
+ sessionManager.registerSession(session);
572
+ let heartbeatWritePending = false;
352
573
  session.heartbeat = setInterval(() => {
353
- try {
354
- stream.write(buildHeartbeat());
574
+ if (session.closed)
575
+ return;
576
+ if (heartbeatWritePending) {
577
+ sessionManager.close(session, "heartbeat-write-failed", new CursorTransportError("Cursor heartbeat write remained backpressured", {
578
+ transient: false,
579
+ replaySafe: false,
580
+ code: "CURSOR_HEARTBEAT_BACKPRESSURE",
581
+ }));
582
+ return;
355
583
  }
356
- catch { /* closed */ }
357
- }, 5000);
584
+ heartbeatWritePending = true;
585
+ void writeWithBackpressure(stream, buildHeartbeat(), "heartbeat")
586
+ .then(() => sessionManager.recordHeartbeatWrite(session))
587
+ .catch((cause) => {
588
+ sessionManager.close(session, "heartbeat-write-failed", toCursorProviderError(cause, {
589
+ replaySafe: false,
590
+ fallback: "Cursor heartbeat write failed",
591
+ }));
592
+ })
593
+ .finally(() => { heartbeatWritePending = false; });
594
+ }, continuationPolicy.heartbeatMs);
595
+ session.heartbeat.unref?.();
596
+ session.heartbeatCancel = () => {
597
+ if (session.heartbeat)
598
+ clearInterval(session.heartbeat);
599
+ };
358
600
  callOptions.abortSignal?.addEventListener("abort", () => {
359
601
  // Abort after tool-calls is normal — preserve pending sessions.
360
602
  trace("abortSignal aborted → closeUnlessPending");
@@ -362,37 +604,6 @@ async function startSession(modelId, token, callOptions, options) {
362
604
  }, { once: true });
363
605
  return session;
364
606
  }
365
- /** Emit a soft-stop stream when trailing tool results have no live Cursor Run. */
366
- function orphanedToolResultsStream(ids) {
367
- const textId = crypto.randomUUID();
368
- const message = `The Cursor agent stream ended before tool results could be delivered ` +
369
- `(${ids.split(",").length} result(s)). Please retry your request.`;
370
- trace(`orphanedToolResultsStream: soft-stop for [${ids}]`);
371
- return {
372
- stream: new ReadableStream({
373
- pull(controller) {
374
- try {
375
- controller.enqueue({ type: "stream-start", warnings: [] });
376
- controller.enqueue({ type: "text-start", id: textId });
377
- controller.enqueue({ type: "text-delta", id: textId, delta: message });
378
- controller.enqueue({ type: "text-end", id: textId });
379
- controller.enqueue({
380
- type: "finish",
381
- finishReason: { unified: "stop", raw: undefined },
382
- usage: {
383
- inputTokens: { total: 0, noCache: undefined, cacheRead: 0, cacheWrite: 0 },
384
- outputTokens: { total: 0, text: undefined, reasoning: undefined },
385
- },
386
- });
387
- controller.close();
388
- }
389
- catch (e) {
390
- trace(`orphanedToolResultsStream: enqueue failed err=${e.message}`);
391
- }
392
- },
393
- }),
394
- };
395
- }
396
607
  /**
397
608
  * OpenCode re-sends the full tool-result history on every continuation. Prefer
398
609
  * the newest result that still has a live pending exec on its tagged session.
@@ -406,6 +617,70 @@ export function findContinuationSession(toolResults) {
406
617
  }
407
618
  return undefined;
408
619
  }
620
+ /**
621
+ * Deliver trailing tool results onto a live continuation session.
622
+ * Returns the same session when writes succeed (or only bridged results were
623
+ * cleared). Returns undefined after closing the session when a write fails, so
624
+ * the caller can rebase onto a fresh Run instead of pumping a dead stream.
625
+ */
626
+ export function deliverContinuationResults(session, trailingToolResults) {
627
+ const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
628
+ trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
629
+ `${pendingResults.length} pending for sessionId=${session.sessionId} ` +
630
+ `pending={${[...session.pending.keys()].join(",")}}`);
631
+ for (const r of pendingResults) {
632
+ const claim = sessionManager.claim(session.sessionId, r.execId);
633
+ if ("kind" in claim) {
634
+ if (claim.kind === "deliverable") {
635
+ throw new CursorProtocolError("Cursor continuation claim remained unclaimed");
636
+ }
637
+ if (claim.kind === "duplicate") {
638
+ trace(`continuation: skipped duplicate execId=${r.execId} reason=${claim.reason}`);
639
+ continue;
640
+ }
641
+ trace(`continuation: unavailable execId=${r.execId} reason=${claim.reason}`);
642
+ return undefined;
643
+ }
644
+ const pending = claim.pending;
645
+ let frames = [];
646
+ if (!pending.bridged) {
647
+ try {
648
+ const shellResult = pending.resultField === "shell_stream"
649
+ ? consumeCursorShellResult(r.toolCallId, r.output)
650
+ : undefined;
651
+ frames = buildExecClientMessages({
652
+ execId: r.execId,
653
+ resultField: pending.resultField,
654
+ output: shellResult?.output ?? r.output,
655
+ error: r.error,
656
+ toolName: pending.toolName ?? r.toolName,
657
+ resultMetadata: pending.resultMetadata,
658
+ shellOutcome: shellResult?.outcome,
659
+ });
660
+ }
661
+ catch (error) {
662
+ trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
663
+ sessionManager.close(session, "result-write-failed");
664
+ return undefined;
665
+ }
666
+ }
667
+ const outcome = sessionManager.deliverClaim(claim, frames);
668
+ if (outcome.kind !== "delivered") {
669
+ trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`);
670
+ if (outcome.kind === "duplicate")
671
+ continue;
672
+ return undefined;
673
+ }
674
+ session.usageEstimate.inputTokens += estimateTokens(r.output.length);
675
+ if (pending.bridged) {
676
+ trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
677
+ continue;
678
+ }
679
+ trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
680
+ `frames=${outcome.framesWritten} outLen=${r.output.length}`);
681
+ }
682
+ return session;
683
+ }
409
684
  async function loadAvailableModels() {
410
685
  const cacheDir = opencodeGlobalCacheDir();
411
686
  try {
@@ -439,23 +714,89 @@ function resolveExplicitAgentBaseURL(options) {
439
714
  return undefined;
440
715
  const normalized = normalizeAgentRunOrigin(raw);
441
716
  if (!normalized) {
442
- throw new Error("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
717
+ throw new CursorProtocolError("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
443
718
  }
444
719
  return normalized;
445
720
  }
446
721
  function isTruthyEnv(value) {
447
722
  return value === "1" || value === "true";
448
723
  }
724
+ async function writeWithBackpressure(stream, message, operation) {
725
+ let accepted;
726
+ try {
727
+ accepted = stream.write(message);
728
+ }
729
+ catch (cause) {
730
+ throw toCursorProviderError(cause, {
731
+ replaySafe: false,
732
+ fallback: `Cursor ${operation} write failed`,
733
+ });
734
+ }
735
+ if (accepted !== false)
736
+ return;
737
+ if (!stream.waitForDrain) {
738
+ throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
739
+ transient: false,
740
+ replaySafe: false,
741
+ code: "CURSOR_WRITE_BACKPRESSURE",
742
+ });
743
+ }
744
+ try {
745
+ await stream.waitForDrain(5_000);
746
+ }
747
+ catch (cause) {
748
+ throw toCursorProviderError(cause, {
749
+ replaySafe: false,
750
+ fallback: `Cursor ${operation} backpressure drain failed`,
751
+ });
752
+ }
753
+ }
754
+ async function nextFrameWithSemanticDeadline(session) {
755
+ const remainingMs = session.semanticDeadlineAt - Date.now();
756
+ if (remainingMs <= 0) {
757
+ throw new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
758
+ }
759
+ let timer;
760
+ let rejectCancelled;
761
+ const deadline = new Promise((_, reject) => {
762
+ rejectCancelled = reject;
763
+ timer = setTimeout(() => {
764
+ reject(new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }));
765
+ }, remainingMs);
766
+ timer.unref?.();
767
+ });
768
+ session.semanticDeadlineCancel = () => {
769
+ rejectCancelled?.(session.closeError ?? new CursorTransportError("Cursor semantic wait cancelled locally", {
770
+ transient: false,
771
+ replaySafe: false,
772
+ }));
773
+ };
774
+ try {
775
+ return await Promise.race([session.frames.next(), deadline]);
776
+ }
777
+ finally {
778
+ if (timer)
779
+ clearTimeout(timer);
780
+ session.semanticDeadlineCancel = null;
781
+ }
782
+ }
449
783
  /**
450
784
  * Read the held-open stream, emitting stream parts, until the turn boundary:
451
785
  * - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
452
786
  * and KEEP the session open for the result on the next doStream call;
453
- * - turn_ended / stream end → finish "stop" and close the session.
787
+ * - turn_ended → finish "stop" and close the session;
788
+ * - transport EOF before turn_ended → throw for one fresh-Run recovery.
454
789
  */
455
790
  export async function pump(session, controller, ids, abortSignal) {
791
+ sessionManager.registerSession(session);
456
792
  const { textId, reasoningId } = ids;
793
+ const promptTokens = ids.promptTokens ?? 0;
794
+ const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
795
+ const advertisedToolNameSet = new Set(advertisedToolNames);
457
796
  let textStarted = false;
458
797
  let reasoningStarted = false;
798
+ let emittedOutputChars = 0;
799
+ let replaySafe = true;
459
800
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
460
801
  // in doStreamImpl). The frames iterator can still yield a final `done` after
461
802
  // the cancel lands — controller.enqueue on a cancelled controller throws.
@@ -485,6 +826,80 @@ export async function pump(session, controller, ids, abortSignal) {
485
826
  }
486
827
  streamClosed = true;
487
828
  };
829
+ /** Reply on Cursor's correlated exec channel without exposing a host tool call. */
830
+ const rejectExec = (parsed, reason, label) => {
831
+ try {
832
+ for (const frame of buildExecClientMessages({
833
+ execId: parsed.id,
834
+ resultField: parsed.resultField,
835
+ output: "",
836
+ error: reason,
837
+ toolName: parsed.toolName,
838
+ resultMetadata: parsed.resultMetadata,
839
+ })) {
840
+ session.stream.write(frame);
841
+ }
842
+ trace(`exec: REFUSED ${label} toolName=${parsed.toolName} id=${parsed.id}`);
843
+ return true;
844
+ }
845
+ catch (e) {
846
+ const error = new Error(`Failed to reject Cursor tool request (${label}): ${e.message}`);
847
+ trace(`exec: REFUSED reply FAILED ${error.message}`);
848
+ safeError(error);
849
+ sessionManager.close(session);
850
+ return false;
851
+ }
852
+ };
853
+ /**
854
+ * Cursor's streamed edit handshake reads the target before it sends the
855
+ * replacement through write_args. For a new file that read naturally fails,
856
+ * which makes the model abandon the edit and fall back to a shell heredoc.
857
+ * Treat only a missing target correlated to an edit_tool_call as an empty
858
+ * file, allowing Cursor to continue to the ordinary OpenCode write call.
859
+ */
860
+ const recoverMissingEditRead = (parsed, displayCallId) => {
861
+ if (!displayCallId ||
862
+ parsed.resultField !== "read_result" ||
863
+ parsed.toolName !== "read" ||
864
+ !advertisedToolNameSet.has("write"))
865
+ return false;
866
+ const stored = session.displayToolCalls.get(displayCallId);
867
+ const display = parseDisplayToolCall(displayCallId, stored);
868
+ if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
869
+ return false;
870
+ const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
871
+ const editPath = typeof display.args.path === "string" ? display.args.path : "";
872
+ if (!requestedPath || !editPath)
873
+ return false;
874
+ const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
875
+ ? session.requestContext.workspace_project_dir
876
+ : process.cwd();
877
+ const resolvePath = (value) => path.resolve(workspaceRoot, value);
878
+ const absolutePath = resolvePath(requestedPath);
879
+ if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
880
+ return false;
881
+ try {
882
+ for (const frame of buildExecClientMessages({
883
+ execId: parsed.id,
884
+ resultField: parsed.resultField,
885
+ output: "",
886
+ toolName: parsed.toolName,
887
+ resultMetadata: { path: requestedPath },
888
+ })) {
889
+ session.stream.write(frame);
890
+ }
891
+ trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
892
+ `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
893
+ return true;
894
+ }
895
+ catch (e) {
896
+ const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
897
+ trace(`exec: edit read recovery FAILED ${error.message}`);
898
+ safeError(error);
899
+ sessionManager.close(session);
900
+ return true;
901
+ }
902
+ };
488
903
  /** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
489
904
  const closeOpenSpans = () => {
490
905
  for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
@@ -496,6 +911,7 @@ export async function pump(session, controller, ids, abortSignal) {
496
911
  const emitText = (text) => {
497
912
  if (!text)
498
913
  return;
914
+ replaySafe = false;
499
915
  // Close reasoning before text (hosts expect reasoning-end before text-start).
500
916
  if (reasoningStarted && !textStarted) {
501
917
  safeEnqueue({ type: "reasoning-end", id: reasoningId });
@@ -506,17 +922,22 @@ export async function pump(session, controller, ids, abortSignal) {
506
922
  textStarted = true;
507
923
  }
508
924
  session.usageEstimate.outputTokens += estimateTokens(text.length);
509
- safeEnqueue({ type: "text-delta", id: textId, delta: text });
925
+ if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
926
+ emittedOutputChars += text.length;
927
+ }
510
928
  };
511
929
  const emitReasoning = (text) => {
512
930
  if (!text)
513
931
  return;
932
+ replaySafe = false;
514
933
  if (!reasoningStarted) {
515
934
  safeEnqueue({ type: "reasoning-start", id: reasoningId });
516
935
  reasoningStarted = true;
517
936
  }
518
937
  session.usageEstimate.outputTokens += estimateTokens(text.length);
519
- safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
938
+ if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
939
+ emittedOutputChars += text.length;
940
+ }
520
941
  };
521
942
  const emitFinish = (te, reason) => {
522
943
  closeOpenSpans();
@@ -532,25 +953,32 @@ export async function pump(session, controller, ids, abortSignal) {
532
953
  const est = session.usageEstimate;
533
954
  const usage = {
534
955
  inputTokens: {
535
- total: est.inputTokens,
956
+ total: promptTokens,
536
957
  noCache: undefined,
537
- cacheRead: est.cacheRead,
538
- cacheWrite: est.cacheWrite,
958
+ cacheRead: 0,
959
+ cacheWrite: 0,
539
960
  },
540
961
  outputTokens: {
541
- total: est.outputTokens,
962
+ total: estimateTokens(emittedOutputChars),
542
963
  text: undefined,
543
964
  reasoning: undefined,
544
965
  },
545
966
  };
967
+ const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
546
968
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
547
969
  ? String(reason.unified ?? "unknown")
548
970
  : String(reason);
549
971
  trace(`finish: reason=${reasonLabel} ` +
550
- `in=${est.inputTokens} out=${est.outputTokens} ` +
551
- `cacheRead=${est.cacheRead} cacheWrite=${est.cacheWrite} ` +
972
+ `requestIn=${usage.inputTokens.total} requestOut=${usage.outputTokens.total} ` +
973
+ `rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
974
+ `rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
552
975
  `source=${te ? "turn_ended" : "estimate"}`);
553
- safeEnqueue({ type: "finish", usage, finishReason: reason });
976
+ safeEnqueue({
977
+ type: "finish",
978
+ usage,
979
+ finishReason: reason,
980
+ ...(providerMetadata ? { providerMetadata } : {}),
981
+ });
554
982
  };
555
983
  while (true) {
556
984
  // Consumer cancelled / closed the ReadableStream. Stop reading Cursor
@@ -568,31 +996,45 @@ export async function pump(session, controller, ids, abortSignal) {
568
996
  sessionManager.closeUnlessPending(session);
569
997
  return;
570
998
  }
571
- const next = await session.frames.next();
999
+ let next;
1000
+ try {
1001
+ next = session.pending.size === 0
1002
+ ? await nextFrameWithSemanticDeadline(session)
1003
+ : await session.frames.next();
1004
+ }
1005
+ catch (error) {
1006
+ closeOpenSpans();
1007
+ const failure = error instanceof CursorProviderError
1008
+ ? error
1009
+ : new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
1010
+ failure.replaySafe = replaySafe && failure.replaySafe;
1011
+ throw failure;
1012
+ }
572
1013
  if (next.done) {
573
- trace("pump: frames iterator ended with NO frames received (silent end)");
574
- emitFinish(undefined, { unified: "stop", raw: undefined });
575
- sessionManager.close(session);
576
- return;
1014
+ closeOpenSpans();
1015
+ trace("pump: frames iterator ended before turn_ended");
1016
+ const failure = new CursorRunInterruptedError();
1017
+ failure.replaySafe = replaySafe;
1018
+ throw failure;
577
1019
  }
578
1020
  const frame = next.value;
579
1021
  if (frame.flags & 0x02) {
580
- // End-stream: the real server half-closes without a trailer, but surface
581
- // any Connect error payload if present.
1022
+ // A successful agent turn has an explicit turn_ended update before the
1023
+ // Connect envelope closes. Reaching end-stream here means the Run was
1024
+ // interrupted, even if the HTTP status itself was 200.
1025
+ let payload = "";
582
1026
  if (frame.payload.length > 0) {
583
1027
  try {
584
- const text = new TextDecoder().decode(decodeFramePayload(frame));
585
- if (text.includes('"error"')) {
586
- safeError(new Error(`Cursor API error: ${text.slice(0, 500)}`));
587
- sessionManager.close(session);
588
- return;
589
- }
1028
+ payload = new TextDecoder().decode(decodeFramePayload(frame));
590
1029
  }
591
1030
  catch { /* not decodable */ }
592
1031
  }
593
- emitFinish(undefined, { unified: "stop", raw: undefined });
594
- sessionManager.close(session);
595
- return;
1032
+ closeOpenSpans();
1033
+ const failure = payload
1034
+ ? connectFrameError(payload)
1035
+ : new CursorRunInterruptedError();
1036
+ failure.replaySafe = replaySafe && failure.replaySafe;
1037
+ throw failure;
596
1038
  }
597
1039
  // decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
598
1040
  // Skip the frame rather than abort the whole turn.
@@ -612,6 +1054,7 @@ export async function pump(session, controller, ids, abortSignal) {
612
1054
  // A single malformed/truncated frame must not abort the whole turn
613
1055
  // (protobufjs throws "index out of range: …" on length overruns). Log it
614
1056
  // and keep pumping.
1057
+ replaySafe = false;
615
1058
  const preview = Array.from(payload.subarray(0, 32))
616
1059
  .map((x) => x.toString(16).padStart(2, "0"))
617
1060
  .join("");
@@ -625,6 +1068,21 @@ export async function pump(session, controller, ids, abortSignal) {
625
1068
  const interactionQuery = asm.interaction_query;
626
1069
  const checkpointRaw = asm.conversation_checkpoint_update;
627
1070
  const topField = payload.length > 0 ? payload[0] >> 3 : 0;
1071
+ const textProgress = iu?.text_delta?.text;
1072
+ const thinkingProgress = iu?.thinking_delta?.text;
1073
+ const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
1074
+ if ((typeof textProgress === "string" && textProgress.length > 0) ||
1075
+ (typeof thinkingProgress === "string" && thinkingProgress.length > 0) ||
1076
+ !!iu?.turn_ended ||
1077
+ !!iu?.tool_call_started ||
1078
+ !!iu?.tool_call_completed ||
1079
+ !!esm ||
1080
+ !!kv ||
1081
+ !!interactionQuery ||
1082
+ !!checkpointProgress?.length) {
1083
+ replaySafe = false;
1084
+ sessionManager.recordSemanticProgress(session);
1085
+ }
628
1086
  {
629
1087
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
630
1088
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
@@ -748,7 +1206,11 @@ export async function pump(session, controller, ids, abortSignal) {
748
1206
  trace(`exec request_context: replied`);
749
1207
  }
750
1208
  catch (e) {
751
- trace(`exec request_context: write FAILED ${e.message}`);
1209
+ const error = new Error(`Failed to answer Cursor request_context probe: ${e.message}`);
1210
+ trace(`exec request_context: write FAILED ${error.message}`);
1211
+ safeError(error);
1212
+ sessionManager.close(session);
1213
+ return;
752
1214
  }
753
1215
  }
754
1216
  else if (esm.mcp_state_exec_args) {
@@ -774,32 +1236,11 @@ export async function pump(session, controller, ids, abortSignal) {
774
1236
  else {
775
1237
  const parsed = parseExecServerMessage(esm);
776
1238
  const displayCallId = extractExecDisplayCallId(esm);
777
- if (displayCallId) {
778
- session.displayToolCalls.delete(displayCallId);
779
- trace(`exec: claimed display callId=${displayCallId}`);
780
- }
781
1239
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
782
1240
  if (parsed) {
783
1241
  if (parsed.localError) {
784
- trace(`exec: REFUSED invalid mapping toolName=${parsed.toolName} error=${parsed.localError}`);
785
- try {
786
- for (const frame of buildExecClientMessages({
787
- execId: parsed.id,
788
- resultField: parsed.resultField,
789
- output: "",
790
- error: parsed.localError,
791
- toolName: parsed.toolName,
792
- })) {
793
- session.stream.write(frame);
794
- }
795
- }
796
- catch (e) {
797
- const error = new Error(`Failed to reject unsupported Cursor tool request: ${e.message}`);
798
- trace(`exec: invalid-mapping reply FAILED ${error.message}`);
799
- safeError(error);
800
- sessionManager.close(session);
1242
+ if (!rejectExec(parsed, parsed.localError, "invalid mapping"))
801
1243
  return;
802
- }
803
1244
  continue;
804
1245
  }
805
1246
  // OpenCode throws "Tool call not allowed while generating summary"
@@ -808,26 +1249,41 @@ export async function pump(session, controller, ids, abortSignal) {
808
1249
  // pumping for text / turn_ended instead of emitting tool-call.
809
1250
  if (!session.allowTools) {
810
1251
  const reason = "Tool calls are not available during this turn (summary/compaction).";
811
- trace(`exec: REFUSED (allowTools=false) toolName=${parsed.toolName} — auto-replying`);
812
- try {
813
- for (const frame of buildExecClientMessages({
814
- execId: parsed.id,
815
- resultField: parsed.resultField,
816
- output: "",
817
- error: reason,
818
- toolName: parsed.toolName,
819
- })) {
820
- session.stream.write(frame);
821
- }
822
- }
823
- catch (e) {
824
- trace(`exec: REFUSED write FAILED ${e.message}`);
825
- }
1252
+ if (!rejectExec(parsed, reason, "allowTools=false"))
1253
+ return;
1254
+ continue;
1255
+ }
1256
+ // Cursor has native capabilities (Task, filesystem, shell, etc.) in
1257
+ // addition to the MCP descriptors sent by this provider. The model
1258
+ // can request one even when the current OpenCode agent omitted its
1259
+ // corresponding host tool. Emitting that request makes OpenCode
1260
+ // manufacture an `invalid` tool result. Refuse it on the held-open
1261
+ // Cursor exec channel instead, using the request's exact typed result.
1262
+ if (!advertisedToolNameSet.has(parsed.toolName)) {
1263
+ const available = advertisedToolNames.length > 0
1264
+ ? advertisedToolNames.join(", ")
1265
+ : "none";
1266
+ const reason = `OpenCode tool '${parsed.toolName}' is unavailable for the current agent. ` +
1267
+ `Available tools: ${available}. Continue using only available tools; do not retry ` +
1268
+ `'${parsed.toolName}'.`;
1269
+ trace(`exec: unavailable catalog target toolName=${parsed.toolName} ` +
1270
+ `advertised=[${advertisedToolNames.join(",")}]`);
1271
+ if (!rejectExec(parsed, reason, "unavailable tool"))
1272
+ return;
826
1273
  continue;
827
1274
  }
1275
+ if (recoverMissingEditRead(parsed, displayCallId))
1276
+ continue;
1277
+ if (displayCallId) {
1278
+ session.displayToolCalls.delete(displayCallId);
1279
+ trace(`exec: claimed display callId=${displayCallId}`);
1280
+ }
828
1281
  const tc = buildToolCallPart(parsed, session.sessionId);
1282
+ if (parsed.resultField === "shell_stream") {
1283
+ registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
1284
+ }
829
1285
  // Keep the stream open; the result arrives on the next doStream call.
830
- sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
1286
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
831
1287
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
832
1288
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
833
1289
  // Close open text/reasoning spans before tool-call (required by AI SDK V3).
@@ -846,11 +1302,12 @@ export async function pump(session, controller, ids, abortSignal) {
846
1302
  // wrong reply recreates the heartbeat-only deadlock. Fail promptly so
847
1303
  // schema drift is actionable.
848
1304
  const variantField = detectExecVariantField(payload);
1305
+ const variantDescription = describeCursorExecVariant(variantField);
849
1306
  const hex = Array.from(payload.subarray(0, 48))
850
1307
  .map((x) => x.toString(16).padStart(2, "0"))
851
1308
  .join("");
852
- trace(`exec UNMAPPED: id=${esmId} variantField=${variantField} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
853
- const err = new Error(`Unsupported Cursor exec variant field #${variantField ?? "unknown"} (id=${esmId})`);
1309
+ trace(`exec UNMAPPED: id=${esmId} variant=${variantDescription} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
1310
+ const err = new Error(`Unsupported Cursor exec variant ${variantDescription} (id=${esmId})`);
854
1311
  safeError(err);
855
1312
  sessionManager.close(session);
856
1313
  return;
@@ -859,7 +1316,8 @@ export async function pump(session, controller, ids, abortSignal) {
859
1316
  else if (interactionQuery) {
860
1317
  // InteractionQuery is a must-reply channel, just like exec and KV. AI
861
1318
  // SDK has no Cursor-specific UI callback, so answer immediately with the
862
- // conservative headless policy from protocol/interactions.ts.
1319
+ // conservative headless policy from protocol/interactions.ts (including
1320
+ // F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
863
1321
  try {
864
1322
  const handled = handleInteractionQuery(interactionQuery, payload);
865
1323
  session.stream.write(handled.reply);
@@ -892,7 +1350,13 @@ export async function pump(session, controller, ids, abortSignal) {
892
1350
  `found=${handled.found} echoed=${!!handled.echoed} ` +
893
1351
  `sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
894
1352
  }
895
- catch { /* stream closed; pump will surface end */ }
1353
+ catch (e) {
1354
+ const error = new Error(`Failed to answer Cursor KV blob request: ${e.message}`);
1355
+ trace(`kv: write FAILED ${error.message}`);
1356
+ safeError(error);
1357
+ sessionManager.close(session);
1358
+ return;
1359
+ }
896
1360
  }
897
1361
  }
898
1362
  }
@@ -931,11 +1395,13 @@ function extractToolResults(prompt) {
931
1395
  const p = part;
932
1396
  if (p.type !== "tool-result")
933
1397
  continue;
934
- const parsed = parseExecIdFromToolCallId(p.toolCallId ?? "");
1398
+ const toolCallId = p.toolCallId ?? "";
1399
+ const parsed = parseExecIdFromToolCallId(toolCallId);
935
1400
  if (!parsed)
936
1401
  continue;
937
1402
  const { text, isError } = toolResultOutputToText(p.output);
938
1403
  out.push({
1404
+ toolCallId,
939
1405
  sessionId: parsed.sessionId,
940
1406
  execId: parsed.execId,
941
1407
  toolName: p.toolName ?? "mcp",
@@ -1001,10 +1467,12 @@ function extractSystemPrompt(prompt) {
1001
1467
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
1002
1468
  * compaction keeps its dedicated summary prompt unchanged.
1003
1469
  */
1004
- export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
1470
+ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
1005
1471
  if (isCompaction)
1006
1472
  return undefined;
1007
1473
  const names = new Set(tools.map((tool) => tool.name));
1474
+ if (names.size === 0)
1475
+ return undefined;
1008
1476
  const instructions = [];
1009
1477
  if (names.has("question")) {
1010
1478
  instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
@@ -1021,10 +1489,18 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
1021
1489
  if (names.has("webfetch")) {
1022
1490
  instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1023
1491
  }
1024
- if (instructions.length === 0)
1025
- return undefined;
1492
+ if (names.has("write")) {
1493
+ instructions.push(names.has("edit")
1494
+ ? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
1495
+ : "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
1496
+ }
1026
1497
  return [
1027
- "OpenCode owns interactive workflows and tool execution for this session. Use the advertised OpenCode tools below instead of equivalent Cursor-native UI interactions:",
1498
+ `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1499
+ `Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
1500
+ "Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
1501
+ ...(instructions.length > 0
1502
+ ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
1503
+ : []),
1028
1504
  ...instructions,
1029
1505
  "Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
1030
1506
  ].join("\n");
@@ -1035,14 +1511,53 @@ export function estimateTokens(chars) {
1035
1511
  return 0;
1036
1512
  return Math.ceil(chars / 4);
1037
1513
  }
1514
+ /** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
1515
+ export function estimatePromptTokens(prompt) {
1516
+ const serializedContent = prompt
1517
+ .map((message) => typeof message.content === "string"
1518
+ ? message.content
1519
+ : (JSON.stringify(message.content) ?? ""))
1520
+ .join("\n");
1521
+ return estimateTokens(serializedContent.length);
1522
+ }
1523
+ /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
1524
+ export function cursorTurnEndedProviderMetadata(te) {
1525
+ const counter = (key) => {
1526
+ const value = te[key];
1527
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
1528
+ ? Math.trunc(value)
1529
+ : 0;
1530
+ };
1531
+ return {
1532
+ cursor: {
1533
+ usageVersion: 2,
1534
+ inputTokensRaw: counter("input_tokens"),
1535
+ outputTokensRaw: counter("output_tokens"),
1536
+ cacheReadRaw: counter("cache_read"),
1537
+ cacheWriteRaw: counter("cache_write"),
1538
+ reasoningTokensRaw: counter("reasoning_tokens"),
1539
+ },
1540
+ };
1541
+ }
1038
1542
  /**
1039
- * Prior prompt turns for a seed ConversationStateStructure after compaction
1040
- * reset. Drops the trailing user message (live action), but preserves tool
1041
- * result payloads as labeled assistant observations for Cursor's text history.
1543
+ * Prior prompt turns for a seed ConversationStateStructure. Tool results must
1544
+ * never be replayed as assistant-authored prose: that teaches the model to
1545
+ * counterfeit `Tool result (...)` text instead of emitting a real tool call.
1546
+ * Normal rebases omit old results; compaction can retain all results and
1547
+ * interrupted continuations retain only the trailing live result suffix as
1548
+ * explicit OpenCode-host observations.
1042
1549
  */
1043
- export function extractPromptHistory(prompt) {
1550
+ export function extractPromptHistory(prompt, options) {
1044
1551
  const out = [];
1045
- for (const m of prompt) {
1552
+ const toolResults = options?.toolResults ?? "omit";
1553
+ let trailingToolStart = prompt.length;
1554
+ if (toolResults === "trailing") {
1555
+ while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
1556
+ trailingToolStart--;
1557
+ }
1558
+ }
1559
+ for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
1560
+ const m = prompt[messageIndex];
1046
1561
  if (m.role === "system") {
1047
1562
  if (typeof m.content === "string" && m.content.length > 0) {
1048
1563
  out.push({ role: "system", content: m.content });
@@ -1062,25 +1577,43 @@ export function extractPromptHistory(prompt) {
1062
1577
  continue;
1063
1578
  }
1064
1579
  if (m.role === "tool" && Array.isArray(m.content)) {
1580
+ if (toolResults === "omit" ||
1581
+ (toolResults === "trailing" && messageIndex < trailingToolStart))
1582
+ continue;
1065
1583
  const results = [];
1066
1584
  for (const part of m.content) {
1067
1585
  const p = part;
1068
1586
  if (p.type !== "tool-result")
1069
1587
  continue;
1070
1588
  const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
1589
+ const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
1071
1590
  const result = toolResultOutputToText(p.output);
1072
- const label = result.isError ? "Tool error" : "Tool result";
1073
- results.push(`${label} (${toolName}):\n${result.text}`);
1591
+ results.push(formatSeedToolObservation({
1592
+ toolName,
1593
+ toolCallId,
1594
+ output: result.text,
1595
+ isError: result.isError,
1596
+ }));
1074
1597
  }
1075
1598
  if (results.length > 0)
1076
- appendSeedHistory(out, "assistant", results.join("\n\n"));
1599
+ appendSeedHistory(out, "user", results.join("\n\n"));
1077
1600
  }
1078
1601
  }
1079
1602
  // Live user message is the Run action, not seed history.
1080
- if (out.length > 0 && out[out.length - 1].role === "user")
1603
+ if (!options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
1081
1604
  out.pop();
1605
+ }
1082
1606
  return out;
1083
1607
  }
1608
+ function formatSeedToolObservation(input) {
1609
+ const metadata = JSON.stringify({
1610
+ source: "opencode-tool",
1611
+ tool: input.toolName,
1612
+ callId: input.toolCallId,
1613
+ status: input.isError ? "error" : "completed",
1614
+ });
1615
+ return `OpenCode host observation ${metadata}:\n${input.output}`;
1616
+ }
1084
1617
  function extractAssistantHistoryText(msg) {
1085
1618
  const content = msg.content;
1086
1619
  if (typeof content === "string")