cursor-opencode-provider 0.2.3 → 0.2.4

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);
139
- }
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);
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);
146
281
  }
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,27 @@ 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, { preserveTrailingUser: recovery });
244
443
  await loadAvailableModels();
245
444
  // Resolve the region-specific Run stream origin once per process (memoized
246
445
  // in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
@@ -275,7 +474,6 @@ async function startSession(modelId, token, callOptions, options) {
275
474
  baseURL: agentBaseUrl,
276
475
  headers: options.headers,
277
476
  });
278
- const workspaceRoot = options.workspaceRoot || process.cwd();
279
477
  const requestContext = await buildRequestContext({ workspaceRoot, tools });
280
478
  // Resolve descriptors once from the merged OpenCode config so MCP identity is
281
479
  // consistent across AgentRunRequest and both request_context reply paths.
@@ -294,7 +492,6 @@ async function startSession(modelId, token, callOptions, options) {
294
492
  conversationState,
295
493
  parameterValues,
296
494
  maxMode,
297
- availableModels: _availableModels,
298
495
  tools,
299
496
  toolDescriptors,
300
497
  requestContext,
@@ -331,10 +528,17 @@ async function startSession(modelId, token, callOptions, options) {
331
528
  trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
332
529
  if (conversationState)
333
530
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
334
- stream.write(reqBytes);
531
+ try {
532
+ await writeWithBackpressure(stream, reqBytes, "initial Run request");
533
+ }
534
+ catch (error) {
535
+ stream.destroy();
536
+ throw error;
537
+ }
335
538
  const session = {
336
539
  sessionId: crypto.randomUUID(),
337
540
  conversationId,
541
+ openCodeSessionId: sessionKey,
338
542
  stream,
339
543
  frames: stream.frames()[Symbol.asyncIterator](),
340
544
  pending: new Map(),
@@ -346,15 +550,50 @@ async function startSession(modelId, token, callOptions, options) {
346
550
  allowTools,
347
551
  usageEstimate,
348
552
  pumpActive: false,
553
+ pumpOwner: null,
349
554
  heartbeat: null,
350
- expiresAt: Date.now() + 300_000,
555
+ heartbeatCancel: null,
556
+ hardDeadlineTimer: null,
557
+ semanticDeadlineCancel: null,
558
+ terminalUnsubscribe: null,
559
+ deferredTerminalReason: null,
560
+ policy: continuationPolicy,
561
+ createdAt: Date.now(),
562
+ lastInboundAt: Date.now(),
563
+ lastHeartbeatWriteAt: Date.now(),
564
+ semanticDeadlineAt: Date.now() + continuationPolicy.semanticIdleMs,
565
+ closeError: null,
566
+ closed: false,
351
567
  };
568
+ sessionManager.registerSession(session);
569
+ let heartbeatWritePending = false;
352
570
  session.heartbeat = setInterval(() => {
353
- try {
354
- stream.write(buildHeartbeat());
571
+ if (session.closed)
572
+ return;
573
+ if (heartbeatWritePending) {
574
+ sessionManager.close(session, "heartbeat-write-failed", new CursorTransportError("Cursor heartbeat write remained backpressured", {
575
+ transient: false,
576
+ replaySafe: false,
577
+ code: "CURSOR_HEARTBEAT_BACKPRESSURE",
578
+ }));
579
+ return;
355
580
  }
356
- catch { /* closed */ }
357
- }, 5000);
581
+ heartbeatWritePending = true;
582
+ void writeWithBackpressure(stream, buildHeartbeat(), "heartbeat")
583
+ .then(() => sessionManager.recordHeartbeatWrite(session))
584
+ .catch((cause) => {
585
+ sessionManager.close(session, "heartbeat-write-failed", toCursorProviderError(cause, {
586
+ replaySafe: false,
587
+ fallback: "Cursor heartbeat write failed",
588
+ }));
589
+ })
590
+ .finally(() => { heartbeatWritePending = false; });
591
+ }, continuationPolicy.heartbeatMs);
592
+ session.heartbeat.unref?.();
593
+ session.heartbeatCancel = () => {
594
+ if (session.heartbeat)
595
+ clearInterval(session.heartbeat);
596
+ };
358
597
  callOptions.abortSignal?.addEventListener("abort", () => {
359
598
  // Abort after tool-calls is normal — preserve pending sessions.
360
599
  trace("abortSignal aborted → closeUnlessPending");
@@ -362,37 +601,6 @@ async function startSession(modelId, token, callOptions, options) {
362
601
  }, { once: true });
363
602
  return session;
364
603
  }
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
604
  /**
397
605
  * OpenCode re-sends the full tool-result history on every continuation. Prefer
398
606
  * the newest result that still has a live pending exec on its tagged session.
@@ -406,6 +614,70 @@ export function findContinuationSession(toolResults) {
406
614
  }
407
615
  return undefined;
408
616
  }
617
+ /**
618
+ * Deliver trailing tool results onto a live continuation session.
619
+ * Returns the same session when writes succeed (or only bridged results were
620
+ * cleared). Returns undefined after closing the session when a write fails, so
621
+ * the caller can rebase onto a fresh Run instead of pumping a dead stream.
622
+ */
623
+ export function deliverContinuationResults(session, trailingToolResults) {
624
+ const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
625
+ trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
626
+ `${pendingResults.length} pending for sessionId=${session.sessionId} ` +
627
+ `pending={${[...session.pending.keys()].join(",")}}`);
628
+ for (const r of pendingResults) {
629
+ const claim = sessionManager.claim(session.sessionId, r.execId);
630
+ if ("kind" in claim) {
631
+ if (claim.kind === "deliverable") {
632
+ throw new CursorProtocolError("Cursor continuation claim remained unclaimed");
633
+ }
634
+ if (claim.kind === "duplicate") {
635
+ trace(`continuation: skipped duplicate execId=${r.execId} reason=${claim.reason}`);
636
+ continue;
637
+ }
638
+ trace(`continuation: unavailable execId=${r.execId} reason=${claim.reason}`);
639
+ return undefined;
640
+ }
641
+ const pending = claim.pending;
642
+ let frames = [];
643
+ if (!pending.bridged) {
644
+ try {
645
+ const shellResult = pending.resultField === "shell_stream"
646
+ ? consumeCursorShellResult(r.toolCallId, r.output)
647
+ : undefined;
648
+ frames = buildExecClientMessages({
649
+ execId: r.execId,
650
+ resultField: pending.resultField,
651
+ output: shellResult?.output ?? r.output,
652
+ error: r.error,
653
+ toolName: pending.toolName ?? r.toolName,
654
+ resultMetadata: pending.resultMetadata,
655
+ shellOutcome: shellResult?.outcome,
656
+ });
657
+ }
658
+ catch (error) {
659
+ trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
660
+ sessionManager.close(session, "result-write-failed");
661
+ return undefined;
662
+ }
663
+ }
664
+ const outcome = sessionManager.deliverClaim(claim, frames);
665
+ if (outcome.kind !== "delivered") {
666
+ trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`);
667
+ if (outcome.kind === "duplicate")
668
+ continue;
669
+ return undefined;
670
+ }
671
+ session.usageEstimate.inputTokens += estimateTokens(r.output.length);
672
+ if (pending.bridged) {
673
+ trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
674
+ continue;
675
+ }
676
+ trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
677
+ `frames=${outcome.framesWritten} outLen=${r.output.length}`);
678
+ }
679
+ return session;
680
+ }
409
681
  async function loadAvailableModels() {
410
682
  const cacheDir = opencodeGlobalCacheDir();
411
683
  try {
@@ -439,23 +711,89 @@ function resolveExplicitAgentBaseURL(options) {
439
711
  return undefined;
440
712
  const normalized = normalizeAgentRunOrigin(raw);
441
713
  if (!normalized) {
442
- throw new Error("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
714
+ throw new CursorProtocolError("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
443
715
  }
444
716
  return normalized;
445
717
  }
446
718
  function isTruthyEnv(value) {
447
719
  return value === "1" || value === "true";
448
720
  }
721
+ async function writeWithBackpressure(stream, message, operation) {
722
+ let accepted;
723
+ try {
724
+ accepted = stream.write(message);
725
+ }
726
+ catch (cause) {
727
+ throw toCursorProviderError(cause, {
728
+ replaySafe: false,
729
+ fallback: `Cursor ${operation} write failed`,
730
+ });
731
+ }
732
+ if (accepted !== false)
733
+ return;
734
+ if (!stream.waitForDrain) {
735
+ throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
736
+ transient: false,
737
+ replaySafe: false,
738
+ code: "CURSOR_WRITE_BACKPRESSURE",
739
+ });
740
+ }
741
+ try {
742
+ await stream.waitForDrain(5_000);
743
+ }
744
+ catch (cause) {
745
+ throw toCursorProviderError(cause, {
746
+ replaySafe: false,
747
+ fallback: `Cursor ${operation} backpressure drain failed`,
748
+ });
749
+ }
750
+ }
751
+ async function nextFrameWithSemanticDeadline(session) {
752
+ const remainingMs = session.semanticDeadlineAt - Date.now();
753
+ if (remainingMs <= 0) {
754
+ throw new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
755
+ }
756
+ let timer;
757
+ let rejectCancelled;
758
+ const deadline = new Promise((_, reject) => {
759
+ rejectCancelled = reject;
760
+ timer = setTimeout(() => {
761
+ reject(new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }));
762
+ }, remainingMs);
763
+ timer.unref?.();
764
+ });
765
+ session.semanticDeadlineCancel = () => {
766
+ rejectCancelled?.(session.closeError ?? new CursorTransportError("Cursor semantic wait cancelled locally", {
767
+ transient: false,
768
+ replaySafe: false,
769
+ }));
770
+ };
771
+ try {
772
+ return await Promise.race([session.frames.next(), deadline]);
773
+ }
774
+ finally {
775
+ if (timer)
776
+ clearTimeout(timer);
777
+ session.semanticDeadlineCancel = null;
778
+ }
779
+ }
449
780
  /**
450
781
  * Read the held-open stream, emitting stream parts, until the turn boundary:
451
782
  * - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
452
783
  * and KEEP the session open for the result on the next doStream call;
453
- * - turn_ended / stream end → finish "stop" and close the session.
784
+ * - turn_ended → finish "stop" and close the session;
785
+ * - transport EOF before turn_ended → throw for one fresh-Run recovery.
454
786
  */
455
787
  export async function pump(session, controller, ids, abortSignal) {
788
+ sessionManager.registerSession(session);
456
789
  const { textId, reasoningId } = ids;
790
+ const promptTokens = ids.promptTokens ?? 0;
791
+ const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
792
+ const advertisedToolNameSet = new Set(advertisedToolNames);
457
793
  let textStarted = false;
458
794
  let reasoningStarted = false;
795
+ let emittedOutputChars = 0;
796
+ let replaySafe = true;
459
797
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
460
798
  // in doStreamImpl). The frames iterator can still yield a final `done` after
461
799
  // the cancel lands — controller.enqueue on a cancelled controller throws.
@@ -485,6 +823,30 @@ export async function pump(session, controller, ids, abortSignal) {
485
823
  }
486
824
  streamClosed = true;
487
825
  };
826
+ /** Reply on Cursor's correlated exec channel without exposing a host tool call. */
827
+ const rejectExec = (parsed, reason, label) => {
828
+ try {
829
+ for (const frame of buildExecClientMessages({
830
+ execId: parsed.id,
831
+ resultField: parsed.resultField,
832
+ output: "",
833
+ error: reason,
834
+ toolName: parsed.toolName,
835
+ resultMetadata: parsed.resultMetadata,
836
+ })) {
837
+ session.stream.write(frame);
838
+ }
839
+ trace(`exec: REFUSED ${label} toolName=${parsed.toolName} id=${parsed.id}`);
840
+ return true;
841
+ }
842
+ catch (e) {
843
+ const error = new Error(`Failed to reject Cursor tool request (${label}): ${e.message}`);
844
+ trace(`exec: REFUSED reply FAILED ${error.message}`);
845
+ safeError(error);
846
+ sessionManager.close(session);
847
+ return false;
848
+ }
849
+ };
488
850
  /** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
489
851
  const closeOpenSpans = () => {
490
852
  for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
@@ -496,6 +858,7 @@ export async function pump(session, controller, ids, abortSignal) {
496
858
  const emitText = (text) => {
497
859
  if (!text)
498
860
  return;
861
+ replaySafe = false;
499
862
  // Close reasoning before text (hosts expect reasoning-end before text-start).
500
863
  if (reasoningStarted && !textStarted) {
501
864
  safeEnqueue({ type: "reasoning-end", id: reasoningId });
@@ -506,17 +869,22 @@ export async function pump(session, controller, ids, abortSignal) {
506
869
  textStarted = true;
507
870
  }
508
871
  session.usageEstimate.outputTokens += estimateTokens(text.length);
509
- safeEnqueue({ type: "text-delta", id: textId, delta: text });
872
+ if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
873
+ emittedOutputChars += text.length;
874
+ }
510
875
  };
511
876
  const emitReasoning = (text) => {
512
877
  if (!text)
513
878
  return;
879
+ replaySafe = false;
514
880
  if (!reasoningStarted) {
515
881
  safeEnqueue({ type: "reasoning-start", id: reasoningId });
516
882
  reasoningStarted = true;
517
883
  }
518
884
  session.usageEstimate.outputTokens += estimateTokens(text.length);
519
- safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
885
+ if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
886
+ emittedOutputChars += text.length;
887
+ }
520
888
  };
521
889
  const emitFinish = (te, reason) => {
522
890
  closeOpenSpans();
@@ -532,25 +900,32 @@ export async function pump(session, controller, ids, abortSignal) {
532
900
  const est = session.usageEstimate;
533
901
  const usage = {
534
902
  inputTokens: {
535
- total: est.inputTokens,
903
+ total: promptTokens,
536
904
  noCache: undefined,
537
- cacheRead: est.cacheRead,
538
- cacheWrite: est.cacheWrite,
905
+ cacheRead: 0,
906
+ cacheWrite: 0,
539
907
  },
540
908
  outputTokens: {
541
- total: est.outputTokens,
909
+ total: estimateTokens(emittedOutputChars),
542
910
  text: undefined,
543
911
  reasoning: undefined,
544
912
  },
545
913
  };
914
+ const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
546
915
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
547
916
  ? String(reason.unified ?? "unknown")
548
917
  : String(reason);
549
918
  trace(`finish: reason=${reasonLabel} ` +
550
- `in=${est.inputTokens} out=${est.outputTokens} ` +
551
- `cacheRead=${est.cacheRead} cacheWrite=${est.cacheWrite} ` +
919
+ `requestIn=${usage.inputTokens.total} requestOut=${usage.outputTokens.total} ` +
920
+ `rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
921
+ `rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
552
922
  `source=${te ? "turn_ended" : "estimate"}`);
553
- safeEnqueue({ type: "finish", usage, finishReason: reason });
923
+ safeEnqueue({
924
+ type: "finish",
925
+ usage,
926
+ finishReason: reason,
927
+ ...(providerMetadata ? { providerMetadata } : {}),
928
+ });
554
929
  };
555
930
  while (true) {
556
931
  // Consumer cancelled / closed the ReadableStream. Stop reading Cursor
@@ -568,31 +943,45 @@ export async function pump(session, controller, ids, abortSignal) {
568
943
  sessionManager.closeUnlessPending(session);
569
944
  return;
570
945
  }
571
- const next = await session.frames.next();
946
+ let next;
947
+ try {
948
+ next = session.pending.size === 0
949
+ ? await nextFrameWithSemanticDeadline(session)
950
+ : await session.frames.next();
951
+ }
952
+ catch (error) {
953
+ closeOpenSpans();
954
+ const failure = error instanceof CursorProviderError
955
+ ? error
956
+ : new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
957
+ failure.replaySafe = replaySafe && failure.replaySafe;
958
+ throw failure;
959
+ }
572
960
  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;
961
+ closeOpenSpans();
962
+ trace("pump: frames iterator ended before turn_ended");
963
+ const failure = new CursorRunInterruptedError();
964
+ failure.replaySafe = replaySafe;
965
+ throw failure;
577
966
  }
578
967
  const frame = next.value;
579
968
  if (frame.flags & 0x02) {
580
- // End-stream: the real server half-closes without a trailer, but surface
581
- // any Connect error payload if present.
969
+ // A successful agent turn has an explicit turn_ended update before the
970
+ // Connect envelope closes. Reaching end-stream here means the Run was
971
+ // interrupted, even if the HTTP status itself was 200.
972
+ let payload = "";
582
973
  if (frame.payload.length > 0) {
583
974
  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
- }
975
+ payload = new TextDecoder().decode(decodeFramePayload(frame));
590
976
  }
591
977
  catch { /* not decodable */ }
592
978
  }
593
- emitFinish(undefined, { unified: "stop", raw: undefined });
594
- sessionManager.close(session);
595
- return;
979
+ closeOpenSpans();
980
+ const failure = payload
981
+ ? connectFrameError(payload)
982
+ : new CursorRunInterruptedError();
983
+ failure.replaySafe = replaySafe && failure.replaySafe;
984
+ throw failure;
596
985
  }
597
986
  // decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
598
987
  // Skip the frame rather than abort the whole turn.
@@ -612,6 +1001,7 @@ export async function pump(session, controller, ids, abortSignal) {
612
1001
  // A single malformed/truncated frame must not abort the whole turn
613
1002
  // (protobufjs throws "index out of range: …" on length overruns). Log it
614
1003
  // and keep pumping.
1004
+ replaySafe = false;
615
1005
  const preview = Array.from(payload.subarray(0, 32))
616
1006
  .map((x) => x.toString(16).padStart(2, "0"))
617
1007
  .join("");
@@ -625,6 +1015,21 @@ export async function pump(session, controller, ids, abortSignal) {
625
1015
  const interactionQuery = asm.interaction_query;
626
1016
  const checkpointRaw = asm.conversation_checkpoint_update;
627
1017
  const topField = payload.length > 0 ? payload[0] >> 3 : 0;
1018
+ const textProgress = iu?.text_delta?.text;
1019
+ const thinkingProgress = iu?.thinking_delta?.text;
1020
+ const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
1021
+ if ((typeof textProgress === "string" && textProgress.length > 0) ||
1022
+ (typeof thinkingProgress === "string" && thinkingProgress.length > 0) ||
1023
+ !!iu?.turn_ended ||
1024
+ !!iu?.tool_call_started ||
1025
+ !!iu?.tool_call_completed ||
1026
+ !!esm ||
1027
+ !!kv ||
1028
+ !!interactionQuery ||
1029
+ !!checkpointProgress?.length) {
1030
+ replaySafe = false;
1031
+ sessionManager.recordSemanticProgress(session);
1032
+ }
628
1033
  {
629
1034
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
630
1035
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
@@ -781,25 +1186,8 @@ export async function pump(session, controller, ids, abortSignal) {
781
1186
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
782
1187
  if (parsed) {
783
1188
  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);
1189
+ if (!rejectExec(parsed, parsed.localError, "invalid mapping"))
801
1190
  return;
802
- }
803
1191
  continue;
804
1192
  }
805
1193
  // OpenCode throws "Tool call not allowed while generating summary"
@@ -808,26 +1196,35 @@ export async function pump(session, controller, ids, abortSignal) {
808
1196
  // pumping for text / turn_ended instead of emitting tool-call.
809
1197
  if (!session.allowTools) {
810
1198
  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
- }
1199
+ if (!rejectExec(parsed, reason, "allowTools=false"))
1200
+ return;
1201
+ continue;
1202
+ }
1203
+ // Cursor has native capabilities (Task, filesystem, shell, etc.) in
1204
+ // addition to the MCP descriptors sent by this provider. The model
1205
+ // can request one even when the current OpenCode agent omitted its
1206
+ // corresponding host tool. Emitting that request makes OpenCode
1207
+ // manufacture an `invalid` tool result. Refuse it on the held-open
1208
+ // Cursor exec channel instead, using the request's exact typed result.
1209
+ if (!advertisedToolNameSet.has(parsed.toolName)) {
1210
+ const available = advertisedToolNames.length > 0
1211
+ ? advertisedToolNames.join(", ")
1212
+ : "none";
1213
+ const reason = `OpenCode tool '${parsed.toolName}' is unavailable for the current agent. ` +
1214
+ `Available tools: ${available}. Continue using only available tools; do not retry ` +
1215
+ `'${parsed.toolName}'.`;
1216
+ trace(`exec: unavailable catalog target toolName=${parsed.toolName} ` +
1217
+ `advertised=[${advertisedToolNames.join(",")}]`);
1218
+ if (!rejectExec(parsed, reason, "unavailable tool"))
1219
+ return;
826
1220
  continue;
827
1221
  }
828
1222
  const tc = buildToolCallPart(parsed, session.sessionId);
1223
+ if (parsed.resultField === "shell_stream") {
1224
+ registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
1225
+ }
829
1226
  // Keep the stream open; the result arrives on the next doStream call.
830
- sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
1227
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
831
1228
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
832
1229
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
833
1230
  // Close open text/reasoning spans before tool-call (required by AI SDK V3).
@@ -846,11 +1243,12 @@ export async function pump(session, controller, ids, abortSignal) {
846
1243
  // wrong reply recreates the heartbeat-only deadlock. Fail promptly so
847
1244
  // schema drift is actionable.
848
1245
  const variantField = detectExecVariantField(payload);
1246
+ const variantDescription = describeCursorExecVariant(variantField);
849
1247
  const hex = Array.from(payload.subarray(0, 48))
850
1248
  .map((x) => x.toString(16).padStart(2, "0"))
851
1249
  .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})`);
1250
+ trace(`exec UNMAPPED: id=${esmId} variant=${variantDescription} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
1251
+ const err = new Error(`Unsupported Cursor exec variant ${variantDescription} (id=${esmId})`);
854
1252
  safeError(err);
855
1253
  sessionManager.close(session);
856
1254
  return;
@@ -931,11 +1329,13 @@ function extractToolResults(prompt) {
931
1329
  const p = part;
932
1330
  if (p.type !== "tool-result")
933
1331
  continue;
934
- const parsed = parseExecIdFromToolCallId(p.toolCallId ?? "");
1332
+ const toolCallId = p.toolCallId ?? "";
1333
+ const parsed = parseExecIdFromToolCallId(toolCallId);
935
1334
  if (!parsed)
936
1335
  continue;
937
1336
  const { text, isError } = toolResultOutputToText(p.output);
938
1337
  out.push({
1338
+ toolCallId,
939
1339
  sessionId: parsed.sessionId,
940
1340
  execId: parsed.execId,
941
1341
  toolName: p.toolName ?? "mcp",
@@ -1001,10 +1401,12 @@ function extractSystemPrompt(prompt) {
1001
1401
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
1002
1402
  * compaction keeps its dedicated summary prompt unchanged.
1003
1403
  */
1004
- export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
1404
+ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
1005
1405
  if (isCompaction)
1006
1406
  return undefined;
1007
1407
  const names = new Set(tools.map((tool) => tool.name));
1408
+ if (names.size === 0)
1409
+ return undefined;
1008
1410
  const instructions = [];
1009
1411
  if (names.has("question")) {
1010
1412
  instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
@@ -1021,10 +1423,13 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
1021
1423
  if (names.has("webfetch")) {
1022
1424
  instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1023
1425
  }
1024
- if (instructions.length === 0)
1025
- return undefined;
1026
1426
  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:",
1427
+ `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1428
+ `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.`,
1429
+ "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.",
1430
+ ...(instructions.length > 0
1431
+ ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
1432
+ : []),
1028
1433
  ...instructions,
1029
1434
  "Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
1030
1435
  ].join("\n");
@@ -1035,12 +1440,40 @@ export function estimateTokens(chars) {
1035
1440
  return 0;
1036
1441
  return Math.ceil(chars / 4);
1037
1442
  }
1443
+ /** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
1444
+ export function estimatePromptTokens(prompt) {
1445
+ const serializedContent = prompt
1446
+ .map((message) => typeof message.content === "string"
1447
+ ? message.content
1448
+ : (JSON.stringify(message.content) ?? ""))
1449
+ .join("\n");
1450
+ return estimateTokens(serializedContent.length);
1451
+ }
1452
+ /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
1453
+ export function cursorTurnEndedProviderMetadata(te) {
1454
+ const counter = (key) => {
1455
+ const value = te[key];
1456
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
1457
+ ? Math.trunc(value)
1458
+ : 0;
1459
+ };
1460
+ return {
1461
+ cursor: {
1462
+ usageVersion: 2,
1463
+ inputTokensRaw: counter("input_tokens"),
1464
+ outputTokensRaw: counter("output_tokens"),
1465
+ cacheReadRaw: counter("cache_read"),
1466
+ cacheWriteRaw: counter("cache_write"),
1467
+ reasoningTokensRaw: counter("reasoning_tokens"),
1468
+ },
1469
+ };
1470
+ }
1038
1471
  /**
1039
1472
  * Prior prompt turns for a seed ConversationStateStructure after compaction
1040
1473
  * reset. Drops the trailing user message (live action), but preserves tool
1041
1474
  * result payloads as labeled assistant observations for Cursor's text history.
1042
1475
  */
1043
- export function extractPromptHistory(prompt) {
1476
+ export function extractPromptHistory(prompt, options) {
1044
1477
  const out = [];
1045
1478
  for (const m of prompt) {
1046
1479
  if (m.role === "system") {
@@ -1077,8 +1510,9 @@ export function extractPromptHistory(prompt) {
1077
1510
  }
1078
1511
  }
1079
1512
  // Live user message is the Run action, not seed history.
1080
- if (out.length > 0 && out[out.length - 1].role === "user")
1513
+ if (!options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
1081
1514
  out.pop();
1515
+ }
1082
1516
  return out;
1083
1517
  }
1084
1518
  function extractAssistantHistoryText(msg) {