cursor-opencode-provider 0.4.3 → 0.6.0

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.
Files changed (71) hide show
  1. package/README.md +33 -23
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +11 -1
  6. package/dist/context/build.js +76 -25
  7. package/dist/context/frozen.d.ts +29 -0
  8. package/dist/context/frozen.js +168 -0
  9. package/dist/context/index.d.ts +3 -2
  10. package/dist/context/index.js +3 -2
  11. package/dist/context/paths.d.ts +34 -53
  12. package/dist/context/paths.js +54 -118
  13. package/dist/context/rules.d.ts +1 -0
  14. package/dist/context/rules.js +2 -2
  15. package/dist/image-save-tool.d.ts +19 -0
  16. package/dist/image-save-tool.js +25 -0
  17. package/dist/image-save.d.ts +58 -0
  18. package/dist/image-save.js +189 -0
  19. package/dist/image-staging.d.ts +59 -0
  20. package/dist/image-staging.js +91 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/language-model.d.ts +13 -11
  23. package/dist/language-model.js +957 -150
  24. package/dist/plan-execution-kickoff.d.ts +55 -0
  25. package/dist/plan-execution-kickoff.js +162 -0
  26. package/dist/plugin-opencode2.js +52 -3
  27. package/dist/plugin-v2.d.ts +11 -9
  28. package/dist/plugin-v2.js +6 -13
  29. package/dist/plugin.d.ts +9 -0
  30. package/dist/plugin.js +61 -5
  31. package/dist/pricing-data.d.ts +16 -4
  32. package/dist/pricing-data.js +16 -4
  33. package/dist/protocol/ask-question.d.ts +133 -0
  34. package/dist/protocol/ask-question.js +320 -0
  35. package/dist/protocol/blob-reachability.d.ts +15 -0
  36. package/dist/protocol/blob-reachability.js +140 -0
  37. package/dist/protocol/blob-store.d.ts +37 -0
  38. package/dist/protocol/blob-store.js +115 -0
  39. package/dist/protocol/conversation-bind.d.ts +13 -0
  40. package/dist/protocol/conversation-bind.js +27 -1
  41. package/dist/protocol/conversation-persistence.d.ts +32 -0
  42. package/dist/protocol/conversation-persistence.js +504 -0
  43. package/dist/protocol/conversation-state.d.ts +16 -0
  44. package/dist/protocol/conversation-state.js +62 -0
  45. package/dist/protocol/create-plan.d.ts +141 -0
  46. package/dist/protocol/create-plan.js +373 -0
  47. package/dist/protocol/generate-image.d.ts +54 -0
  48. package/dist/protocol/generate-image.js +101 -0
  49. package/dist/protocol/interactions.d.ts +110 -8
  50. package/dist/protocol/interactions.js +295 -35
  51. package/dist/protocol/kv.d.ts +1 -0
  52. package/dist/protocol/kv.js +2 -0
  53. package/dist/protocol/messages.d.ts +2 -0
  54. package/dist/protocol/messages.js +148 -8
  55. package/dist/protocol/request.d.ts +2 -0
  56. package/dist/protocol/request.js +2 -0
  57. package/dist/protocol/switch-mode.d.ts +178 -0
  58. package/dist/protocol/switch-mode.js +417 -0
  59. package/dist/protocol/token-details.d.ts +33 -0
  60. package/dist/protocol/token-details.js +155 -0
  61. package/dist/protocol/tool-call-bridge.js +10 -5
  62. package/dist/protocol/tools.d.ts +44 -11
  63. package/dist/protocol/tools.js +171 -41
  64. package/dist/session.d.ts +39 -11
  65. package/dist/shared.d.ts +3 -0
  66. package/dist/shared.js +3 -0
  67. package/dist/usage.d.ts +42 -37
  68. package/dist/usage.js +201 -70
  69. package/dist/web-search-tool.d.ts +22 -26
  70. package/dist/web-search-tool.js +25 -15
  71. package/package.json +7 -4
@@ -5,21 +5,31 @@ import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } fr
5
5
  import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
- import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.js";
8
+ import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/messages.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.js";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
13
- import { handleInteractionQuery } from "./protocol/interactions.js";
14
- import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
15
- import { conversationBlobCount } from "./protocol/blob-store.js";
16
- import { bindConversationId, } from "./protocol/conversation-bind.js";
13
+ import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
14
+ import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
15
+ import { SWITCH_MODE_RESULT_FIELD, getActiveCursorMode, isBridgedCursorPlanModeActive, isCursorPlanModeActive, setActiveCursorMode, switchModeResultFromQuestionOutput, switchModeResultFromToolOutput, switchModeToolInput, takeActiveCursorModeReminder, } from "./protocol/switch-mode.js";
16
+ import { CREATE_PLAN_NOT_APPROVED_REASON, CREATE_PLAN_RESULT_FIELD, CURSOR_PLAN_STAGE_TOOL, createPlanApproved, createPlanStageInput, } from "./protocol/create-plan.js";
17
+ import { flushPlanExecutionKickoff, formatPlanKickoffPath, hasPlanExecutionKickoff, planExecutionKickoffState, planPathFromUri, queuePlanExecutionKickoff, takePlanExecutionKickoffWarning, } from "./plan-execution-kickoff.js";
18
+ import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
19
+ import { stageCursorImage } from "./image-staging.js";
20
+ import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
21
+ import { clearCheckpoint, getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
22
+ import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
23
+ import { clearConversationBlobs, conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
24
+ import { bindConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
25
+ import { clearPersistedConversationState, hydrateConversationState, persistConversationState, } from "./protocol/conversation-state.js";
26
+ import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
17
27
  import { resolveContinuationPolicy, sessionManager, } from "./session.js";
18
28
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
19
29
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
20
- import { buildRequestContext } from "./context/build.js";
30
+ import { getOrBuildRequestContext } from "./context/frozen.js";
21
31
  import { workspaceRootFromRequestContext } from "./context/env.js";
22
- import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
32
+ import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
23
33
  import { resolveAgentUrl } from "./agent-url.js";
24
34
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
25
35
  import { isCompactionSession } from "./compaction-marker.js";
@@ -29,7 +39,7 @@ import { resolveCursorModelSupportsImages } from "./model-metadata.js";
29
39
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
30
40
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
31
41
  import { readAllFieldsStrict } from "./protocol/struct.js";
32
- import { buildLanguageModelV3UsageFromEstimate, buildLanguageModelV3UsageFromTurnEnded, exceedsRequestLocalBudget, turnEndedCounter, } from "./usage.js";
42
+ import { buildLanguageModelV3UsageFromCounters, cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, turnEndedCounter, } from "./usage.js";
33
43
  let _availableModels;
34
44
  // mtime of the cache file the last time we loaded it. Compared on each call
35
45
  // so discoverModels' background refresh is picked up without a process restart.
@@ -56,6 +66,11 @@ const MAX_RETRY_DELAY_MS = 30_000;
56
66
  const RUN_REQUEST_DECODE_FAILED = "CURSOR_RUN_REQUEST_DECODE_FAILED";
57
67
  const RUN_REQUEST_UNSUPPORTED = "CURSOR_RUN_REQUEST_UNSUPPORTED";
58
68
  const RUN_REPLY_FAILED = "CURSOR_RUN_REPLY_FAILED";
69
+ /** Cursor's own conversation export path treats state above 100 MiB as large. */
70
+ export const MAX_CHECKPOINT_BLOB_GRAPH_BYTES = 100 * 1024 * 1024;
71
+ export function checkpointBlobGraphRequiresRebase(stats, maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
72
+ return !stats.complete || stats.bytes > maxBytes;
73
+ }
59
74
  const RESPONSE_REQUIRED_CHANNEL_BY_FIELD = new Map([
60
75
  [2, "exec"],
61
76
  [4, "kv"],
@@ -235,9 +250,61 @@ export function connectFrameError(payload) {
235
250
  return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
236
251
  }
237
252
  }
253
+ const toolCatalogWaitersBySession = new Map();
254
+ function publishToolCatalogWaiters(sessionKey, tools) {
255
+ const waiters = toolCatalogWaitersBySession.get(sessionKey);
256
+ if (!waiters || waiters.size === 0)
257
+ return;
258
+ toolCatalogWaitersBySession.delete(sessionKey);
259
+ for (const waiter of waiters)
260
+ waiter.resolve(tools);
261
+ }
262
+ function waitForSiblingToolCatalog(sessionKey, signal) {
263
+ const existing = toolCatalogBySession.get(sessionKey);
264
+ if (existing && existing.length > 0) {
265
+ return Promise.resolve(structuredClone(existing));
266
+ }
267
+ if (signal?.aborted) {
268
+ return Promise.reject(new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
269
+ }
270
+ return new Promise((resolve, reject) => {
271
+ let settled = false;
272
+ let set = toolCatalogWaitersBySession.get(sessionKey);
273
+ const finish = (tools, error) => {
274
+ if (settled)
275
+ return;
276
+ settled = true;
277
+ set?.delete(waiter);
278
+ if (set?.size === 0)
279
+ toolCatalogWaitersBySession.delete(sessionKey);
280
+ signal?.removeEventListener("abort", onAbort);
281
+ if (error)
282
+ reject(error);
283
+ else
284
+ resolve(structuredClone(tools));
285
+ };
286
+ const onAbort = () => finish(undefined, new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
287
+ const waiter = {
288
+ resolve: (tools) => finish(tools),
289
+ cancel: onAbort,
290
+ };
291
+ if (!set) {
292
+ set = new Set();
293
+ toolCatalogWaitersBySession.set(sessionKey, set);
294
+ }
295
+ set.add(waiter);
296
+ signal?.addEventListener("abort", onAbort, { once: true });
297
+ // Re-check after enqueue: a sibling may have published between the initial
298
+ // map lookup and waiter registration.
299
+ const raced = toolCatalogBySession.get(sessionKey);
300
+ if (raced && raced.length > 0)
301
+ finish(raced);
302
+ });
303
+ }
238
304
  function rememberToolCatalog(sessionKey, tools) {
239
305
  toolCatalogBySession.delete(sessionKey);
240
- toolCatalogBySession.set(sessionKey, tools);
306
+ toolCatalogBySession.set(sessionKey, structuredClone(tools));
307
+ publishToolCatalogWaiters(sessionKey, tools);
241
308
  while (toolCatalogBySession.size > MAX_TURN_STATE_SESSIONS) {
242
309
  const oldest = toolCatalogBySession.keys().next().value;
243
310
  if (!oldest)
@@ -245,6 +312,17 @@ function rememberToolCatalog(sessionKey, tools) {
245
312
  toolCatalogBySession.delete(oldest);
246
313
  }
247
314
  }
315
+ /** Restore the last real catalog solely for lifecycle turns such as compaction. */
316
+ export function restoreTurnToolCatalog(sessionKey, tools) {
317
+ if (!sessionKey || tools.length === 0)
318
+ return;
319
+ rememberToolCatalog(sessionKey, tools);
320
+ }
321
+ function snapshotToolCatalog(sessionKey) {
322
+ if (!sessionKey)
323
+ return [];
324
+ return structuredClone(toolCatalogBySession.get(sessionKey) ?? []);
325
+ }
248
326
  function rememberPostCompactionRebase(sessionKey) {
249
327
  postCompactionRebaseBySession.delete(sessionKey);
250
328
  postCompactionRebaseBySession.add(sessionKey);
@@ -292,6 +370,11 @@ export function createCursorLanguageModel(modelId, providerId, options) {
292
370
  // Host Path.cache / explicit override wins over XDG heuristics for this process.
293
371
  if (options.cacheDir)
294
372
  setHostCacheDirOverride(options.cacheDir);
373
+ // Start loading and pruning restart state as soon as the provider is built.
374
+ // startSession awaits this same per-cache-root initialization before binding.
375
+ void initializeConversationPersistence(opencodeGlobalCacheDir()).catch((error) => {
376
+ trace(`conversation persistence: startup load failed: ${String(error)}`);
377
+ });
295
378
  return {
296
379
  specificationVersion: "v3",
297
380
  provider: providerId,
@@ -327,7 +410,6 @@ async function doStreamImpl(modelId, options, callOptions) {
327
410
  baseUrl: resolveApiBaseURL(options),
328
411
  });
329
412
  const prompt = callOptions.prompt;
330
- const promptTokens = estimatePromptTokens(prompt);
331
413
  const retryPolicy = resolveRetryPolicy(options.retry);
332
414
  // pumpWithRecovery owns the complete per-turn attempt budget. Opening a
333
415
  // replacement session here must be a single attempt; otherwise setup retry
@@ -389,7 +471,6 @@ async function doStreamImpl(modelId, options, callOptions) {
389
471
  initialSession: activeSession,
390
472
  controller,
391
473
  abortSignal: callOptions.abortSignal,
392
- promptTokens,
393
474
  retryPolicy,
394
475
  recover: (recovery) => openSession({ recovery }),
395
476
  onSession: (next) => { activeSession = next; },
@@ -400,6 +481,23 @@ async function doStreamImpl(modelId, options, callOptions) {
400
481
  catch (e) {
401
482
  trace(`pull: close failed (already closed/cancelled) err=${e.message}`);
402
483
  }
484
+ // pumpWithRecovery has returned only after the Run reached terminal
485
+ // turn_ended and endPump cleared ownership. This is the first safe
486
+ // point to start a second OpenCode generation. A failed prior handoff
487
+ // is retried only because this explicit new provider turn reached its
488
+ // own terminal boundary — never from a timer or tight loop.
489
+ const kickoff = await flushPlanExecutionKickoff(activeSession.openCodeSessionId, {
490
+ cursorSessionID: activeSession.sessionId,
491
+ terminal: activeSession.closed,
492
+ pumpActive: activeSession.pumpActive || activeSession.pumpOwner != null,
493
+ pendingExecs: activeSession.pending.size,
494
+ });
495
+ if (!kickoff) {
496
+ const state = planExecutionKickoffState(activeSession.openCodeSessionId);
497
+ if (state?.status === "failed") {
498
+ setActiveCursorMode(activeSession.openCodeSessionId, "plan");
499
+ }
500
+ }
403
501
  }
404
502
  catch (e) {
405
503
  activeSession.pumpActive = false;
@@ -429,7 +527,6 @@ export async function pumpWithRecovery(input) {
429
527
  maxAttempts: (input.maxRecoveries ?? 1) + 1,
430
528
  };
431
529
  const maxRecoveries = retryPolicy.maxAttempts - 1;
432
- const requestUsage = { outputChars: 0 };
433
530
  input.onSession?.(session);
434
531
  for (let attempt = 0;; attempt++) {
435
532
  const pumpedSession = session;
@@ -439,8 +536,6 @@ export async function pumpWithRecovery(input) {
439
536
  await pump(pumpedSession, input.controller, {
440
537
  textId: crypto.randomUUID(),
441
538
  reasoningId: crypto.randomUUID(),
442
- promptTokens: input.promptTokens ?? 0,
443
- requestUsage,
444
539
  }, input.abortSignal);
445
540
  return session;
446
541
  }
@@ -473,7 +568,6 @@ export async function pumpWithRecovery(input) {
473
568
  : { kind: "rebase" };
474
569
  session = await input.recover(recovery);
475
570
  if (recovery.kind === "resume") {
476
- session.hadToolCallBoundary = pumpedSession.hadToolCallBoundary;
477
571
  session.usageEstimate = { ...pumpedSession.usageEstimate };
478
572
  session.editToolCalls = new Map(pumpedSession.editToolCalls);
479
573
  }
@@ -489,17 +583,29 @@ async function startSession(modelId, token, callOptions, options, startOptions)
489
583
  const prompt = callOptions.prompt;
490
584
  const incomingTools = extractTools(callOptions);
491
585
  const sessionKey = opencodeSessionKey(callOptions);
586
+ const cacheDir = opencodeGlobalCacheDir();
587
+ if (sessionKey) {
588
+ const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
589
+ trace(`conversation persistence: restore failed sessionKey=${sessionKey}: ${String(error)}`);
590
+ return undefined;
591
+ });
592
+ if (restored?.postCompactionRebase)
593
+ rememberPostCompactionRebase(sessionKey);
594
+ if (restored?.toolCatalog.length)
595
+ restoreTurnToolCatalog(sessionKey, restored.toolCatalog);
596
+ }
492
597
  const providerOptions = callOptions.providerOptions?.cursor;
493
598
  // The classic plugin marks OpenCode's agent="compaction" through chat.params.
494
599
  // OpenCode 2.0 removed that hook, so its plugin records the same fact against
495
600
  // the session id from session.hook("context") instead; consult both.
496
601
  // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
497
602
  const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
498
- const toolState = resolveTurnToolState({
603
+ const toolState = await resolveTurnToolState({
499
604
  sessionKey,
500
605
  incomingTools,
501
606
  toolChoice: callOptions.toolChoice,
502
607
  isCompaction,
608
+ abortSignal: callOptions.abortSignal,
503
609
  });
504
610
  const tools = toolState.advertisedTools;
505
611
  const webToolAliases = buildCustomWebToolAliases(tools);
@@ -513,17 +619,55 @@ async function startSession(modelId, token, callOptions, options, startOptions)
513
619
  const allowTools = toolState.allowTools;
514
620
  const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
515
621
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
516
- const recovery = startOptions?.recovery;
517
- const resuming = recovery?.kind === "resume";
622
+ let recovery = startOptions?.recovery;
623
+ let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
624
+ let resuming = !!resumeRecovery;
518
625
  // Compaction must not reuse the prior conversation; its first normal turn
519
626
  // must also rebase so the summary-agent checkpoint cannot replace the normal
520
627
  // system prompt and OpenCode's newly compacted history.
521
- const bound = resuming
522
- ? { conversationId: recovery.conversationId, reset: false, previousId: undefined }
628
+ let bound = resuming
629
+ ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
523
630
  : bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
631
+ let conversationState = resuming
632
+ ? resumeRecovery.checkpoint
633
+ : (bound.reset ? undefined : getCheckpoint(bound.conversationId));
634
+ let checkpointGraph = conversationState
635
+ ? inspectConversationBlobGraph(bound.conversationId, conversationState)
636
+ : { count: 0, bytes: 0, complete: true };
637
+ let forcedResetReason;
638
+ if (conversationState && checkpointBlobGraphRequiresRebase(checkpointGraph)) {
639
+ const previousConversationId = bound.conversationId;
640
+ forcedResetReason = checkpointGraph.complete
641
+ ? "oversized-checkpoint-graph"
642
+ : "incomplete-checkpoint-graph";
643
+ trace(`checkpoint state rejected: reason=${forcedResetReason} ` +
644
+ `conversationId=${previousConversationId} checkpointBytes=${conversationState.length} ` +
645
+ `blobCount=${checkpointGraph.count} blobBytes=${checkpointGraph.bytes} ` +
646
+ `limitBytes=${MAX_CHECKPOINT_BLOB_GRAPH_BYTES} ` +
647
+ `detail=${checkpointGraph.fallbackReason ?? "-"} action=rebase`);
648
+ // A recovery resume must become a history rebase so the interrupted user
649
+ // request remains in the seed. An ordinary turn keeps its normal user text.
650
+ if (recovery?.kind === "resume")
651
+ recovery = { kind: "rebase" };
652
+ resumeRecovery = undefined;
653
+ resuming = false;
654
+ if (!sessionKey) {
655
+ clearCheckpoint(previousConversationId);
656
+ clearConversationBlobs(previousConversationId);
657
+ }
658
+ bound = bindConversationId(sessionKey, { reset: true });
659
+ conversationState = undefined;
660
+ checkpointGraph = { count: 0, bytes: 0, complete: true };
661
+ }
524
662
  const conversationId = bound.conversationId;
663
+ const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
525
664
  if (bound.reset) {
526
- trace(`conversation reset: reason=${recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
665
+ if (sessionKey) {
666
+ await clearPersistedConversationState(cacheDir, sessionKey, bound.previousId).catch((error) => {
667
+ trace(`conversation persistence: reset cleanup failed sessionKey=${sessionKey}: ${String(error)}`);
668
+ });
669
+ }
670
+ trace(`conversation reset: reason=${forcedResetReason ?? (recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown"))} ` +
527
671
  `sessionKey=${sessionKey ?? "(none)"} ` +
528
672
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
529
673
  }
@@ -539,9 +683,24 @@ async function startSession(modelId, token, callOptions, options, startOptions)
539
683
  const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
540
684
  const baseSystemPrompt = extractSystemPrompt(prompt);
541
685
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
542
- const systemPrompt = interactionGuidance
543
- ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
544
- : baseSystemPrompt;
686
+ // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
687
+ // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
688
+ const modeReminder = isCompaction
689
+ ? undefined
690
+ : takeActiveCursorModeReminder(sessionKey, {
691
+ advertisedTools: cursorTools.map((tool) => tool.name),
692
+ });
693
+ const kickoffWarning = isCompaction
694
+ ? undefined
695
+ : takePlanExecutionKickoffWarning(sessionKey);
696
+ const systemPrompt = [
697
+ baseSystemPrompt,
698
+ interactionGuidance,
699
+ modeReminder,
700
+ kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
701
+ ]
702
+ .filter(Boolean)
703
+ .join("\n\n");
545
704
  const history = extractPromptHistory(prompt, {
546
705
  preserveTrailingUser: recovery?.kind === "rebase",
547
706
  toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
@@ -604,7 +763,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
604
763
  baseURL: agentBaseUrl,
605
764
  headers: options.headers,
606
765
  });
607
- const requestContext = await buildRequestContext({ workspaceRoot, tools: cursorTools });
766
+ // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
767
+ // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
768
+ const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools });
608
769
  const contextSubagents = Array.isArray(requestContext.custom_subagents)
609
770
  ? requestContext.custom_subagents
610
771
  .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
@@ -628,15 +789,14 @@ async function startSession(modelId, token, callOptions, options, startOptions)
628
789
  ? requestContext.tools
629
790
  : [];
630
791
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
631
- // After compaction reset there is no checkpoint — seed from OpenCode history.
632
- const conversationState = resuming
633
- ? recovery.checkpoint
634
- : (bound.reset ? undefined : getCheckpoint(conversationId));
792
+ // After compaction or an unsafe checkpoint reset there is no checkpoint —
793
+ // seed a new Cursor conversation from OpenCode's authoritative history.
635
794
  const reqBytes = buildRunRequest({
636
795
  text: userText,
637
796
  images,
638
797
  modelId: cursorModelId,
639
798
  conversationId,
799
+ conversationGroupId,
640
800
  systemPrompt: conversationState ? undefined : systemPrompt,
641
801
  history: conversationState ? undefined : history,
642
802
  conversationState,
@@ -657,14 +817,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
657
817
  ? requestContext.hooks_additional_context
658
818
  : "";
659
819
  const historyChars = history.reduce((n, m) => n + m.content.length, 0);
820
+ const seedChars = conversationState
821
+ ? 0
822
+ : (systemPrompt?.length ?? 0) + userText.length + historyChars;
823
+ const seedEstimateIn = estimateTokens(seedChars);
824
+ const checkpointEnvelopeEstimateIn = conversationState
825
+ ? estimateTokens(userText.length + conversationState.length)
826
+ : 0;
827
+ const runRequestWireEstimateIn = estimateTokens(reqBytes.length);
828
+ const encodedRequestContext = encodeMessage("RequestContext", requestContext);
829
+ const priorTokenDetails = decodeConversationTokenDetails(conversationState);
830
+ const requestContextHash = sha(encodedRequestContext);
831
+ const systemPromptHash = systemPrompt ? sha(systemPrompt) : undefined;
660
832
  const usageEstimate = {
661
- inputTokens: estimateTokens((systemPrompt?.length ?? 0) + userText.length + historyChars + (conversationState?.length ?? 0)),
833
+ // This is used only before Cursor supplies authoritative TurnEnded usage.
834
+ // Estimate the actual protobuf request, not history/system text omitted by
835
+ // checkpoint Runs. The opaque remote interpretation of KV blobs is logged
836
+ // separately and deliberately is not mislabeled as a token count.
837
+ inputTokens: runRequestWireEstimateIn,
662
838
  outputTokens: 0,
663
839
  cacheRead: 0,
664
840
  cacheWrite: 0,
665
841
  reasoningTokens: 0,
666
842
  };
667
- trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
843
+ trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} ` +
844
+ `conversationId=${conversationId} conversationGroupId=${conversationGroupId} ` +
668
845
  `params=${JSON.stringify(parameterValues ?? [])} ` +
669
846
  `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} ` +
670
847
  `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
@@ -672,14 +849,20 @@ async function startSession(modelId, token, callOptions, options, startOptions)
672
849
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
673
850
  `images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
674
851
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
675
- `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
676
- `resume=${resuming} ` +
677
- `usageEstimateIn=${usageEstimate.inputTokens} runRequestBytes=${reqBytes.length}`);
852
+ `checkpointLen=${conversationState?.length ?? 0} checkpointBlobCount=${checkpointGraph.count} ` +
853
+ `checkpointBlobBytes=${checkpointGraph.bytes} checkpointGraphComplete=${checkpointGraph.complete} ` +
854
+ `seedChars=${seedChars} seedEstimateIn=${seedEstimateIn} ` +
855
+ `checkpointEnvelopeEstimateIn=${checkpointEnvelopeEstimateIn} ` +
856
+ `reset=${bound.reset} ` +
857
+ `resume=${resuming} requestContextReused=${requestContextReused} ` +
858
+ `usageEstimateMode=run-request-wire usageEstimateIn=${usageEstimate.inputTokens} ` +
859
+ `runRequestBytes=${reqBytes.length} requestContextBytes=${encodedRequestContext.length}`);
678
860
  if (hooksCtx)
679
861
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
680
862
  trace(`hash run_request sha256=${sha(reqBytes)}`);
681
863
  if (systemPrompt)
682
864
  trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
865
+ trace(`hash requestContext sha256=${requestContextHash}`);
683
866
  if (conversationState)
684
867
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
685
868
  try {
@@ -693,8 +876,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
693
876
  const session = {
694
877
  sessionId: crypto.randomUUID(),
695
878
  conversationId,
879
+ cacheDir,
696
880
  resumeCheckpoint: undefined,
881
+ tokenDetails: priorTokenDetails,
882
+ tokenDetailsFresh: false,
883
+ cacheDiagnostics: {
884
+ sessionKey,
885
+ conversationId,
886
+ conversationGroupId,
887
+ modelId: cursorModelId,
888
+ priorTokenDetails,
889
+ startedWithCheckpoint: !!conversationState,
890
+ requestContextReused,
891
+ requestContextHash,
892
+ systemPromptHash,
893
+ checkpointUpdates: 0,
894
+ tokenDetailUpdates: 0,
895
+ pumpPasses: 0,
896
+ stepStarts: 0,
897
+ stepCompletes: 0,
898
+ displayToolCalls: 0,
899
+ execRequests: 0,
900
+ },
697
901
  openCodeSessionId: sessionKey,
902
+ postCompactionRebase: isCompaction,
903
+ toolCatalog: snapshotToolCatalog(sessionKey),
698
904
  stream,
699
905
  frames: stream.frames()[Symbol.asyncIterator](),
700
906
  pending: new Map(),
@@ -730,11 +936,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
730
936
  if (session.closed)
731
937
  return;
732
938
  if (heartbeatWritePending) {
733
- sessionManager.close(session, "heartbeat-write-failed", new CursorTransportError("Cursor heartbeat write remained backpressured", {
734
- transient: false,
735
- replaySafe: false,
736
- code: "CURSOR_HEARTBEAT_BACKPRESSURE",
737
- }));
939
+ // The pending write has its own bounded drain wait and will close the
940
+ // session with the actual failing operation. Do not race it with another
941
+ // heartbeat or misattribute an existing KV backlog to the heartbeat.
942
+ trace(`heartbeat skipped: prior heartbeat write is still pending sessionId=${session.sessionId}`);
738
943
  return;
739
944
  }
740
945
  heartbeatWritePending = true;
@@ -773,6 +978,124 @@ export function findContinuationSession(toolResults) {
773
978
  }
774
979
  return undefined;
775
980
  }
981
+ /**
982
+ * Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
983
+ *
984
+ * A synchronous AskQuestion still has its InteractionQuery open, so the answer
985
+ * goes back as the InteractionResponse for that query id. A `run_async` one was
986
+ * already released with `async{}`, so — exactly as Cursor CLI does — the answer
987
+ * travels as a ConversationAction keyed by the originating tool call id, with
988
+ * the server's own AskQuestionArgs bytes echoed back untouched.
989
+ */
990
+ function buildAskQuestionContinuationFrame(pending, result) {
991
+ const metadata = pending.resultMetadata ?? {};
992
+ const args = metadata.askQuestionArgs;
993
+ if (!args)
994
+ throw new CursorProtocolError("Bridged AskQuestion lost its decoded arguments");
995
+ const answer = askQuestionResultFromToolOutput(args, result.output, result.error !== undefined);
996
+ if (!args.runAsync) {
997
+ const interactionId = metadata.interactionId;
998
+ if (typeof interactionId !== "number") {
999
+ throw new CursorProtocolError("Bridged AskQuestion lost its interaction id");
1000
+ }
1001
+ return buildAskQuestionInteractionReply(interactionId, answer);
1002
+ }
1003
+ const rawArgs = metadata.askQuestionRawArgs;
1004
+ if (!(rawArgs instanceof Uint8Array)) {
1005
+ throw new CursorProtocolError("Bridged async AskQuestion lost its original arguments");
1006
+ }
1007
+ const toolCallId = metadata.askQuestionToolCallId;
1008
+ return buildAsyncAskQuestionCompletion(typeof toolCallId === "string" ? toolCallId : "", rawArgs, answer);
1009
+ }
1010
+ /**
1011
+ * Turn the host's plan-approval outcome into the CreatePlan reply Cursor is
1012
+ * still blocking on. One contract across every channel: `success` means the
1013
+ * user approved execution, `error` means the plan was written but not accepted,
1014
+ * so the model keeps planning instead of starting work.
1015
+ *
1016
+ * A host plan-stage tool reports that outcome as tool success/failure. The
1017
+ * emulated path asks through `question`, so only an explicit "Yes" approves.
1018
+ * On approval the session leaves plan mode, which arms the agent-mode
1019
+ * `<system_reminder>` for the next Run.
1020
+ */
1021
+ function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSessionID) {
1022
+ const metadata = pending.resultMetadata ?? {};
1023
+ const interactionId = metadata.interactionId;
1024
+ if (typeof interactionId !== "number") {
1025
+ throw new CursorProtocolError("Bridged CreatePlan lost its interaction id");
1026
+ }
1027
+ const planUri = metadata.planUri;
1028
+ if (typeof planUri !== "string" || !planUri.trim()) {
1029
+ throw new CursorProtocolError("Bridged CreatePlan lost its plan URI");
1030
+ }
1031
+ const approved = metadata.createPlanBridgeKind === "approve"
1032
+ ? createPlanApproved(result.output, result.error !== undefined, typeof metadata.createPlanQuestion === "string" ? metadata.createPlanQuestion : "")
1033
+ : result.error === undefined;
1034
+ if (!approved) {
1035
+ const reason = result.error?.trim();
1036
+ return buildCreatePlanInteractionReply(interactionId, {
1037
+ error: { error: reason || CREATE_PLAN_NOT_APPROVED_REASON },
1038
+ plan_uri: "",
1039
+ });
1040
+ }
1041
+ // The question-emulated path can report execution approval only when a host
1042
+ // kickoff exists. Otherwise keep planning mode active and return an error.
1043
+ if (metadata.createPlanBridgeKind === "approve" && !hasPlanExecutionKickoff()) {
1044
+ return buildCreatePlanInteractionReply(interactionId, {
1045
+ error: { error: "The plan was approved, but this host cannot start its execution turn." },
1046
+ plan_uri: planUri,
1047
+ });
1048
+ }
1049
+ // Approved: planning is over for this plan, so drop the plan-mode reminder
1050
+ // and hand the next Run the agent-mode contract, exactly as an approved
1051
+ // plan_exit does.
1052
+ setActiveCursorMode(sessionKey, "agent");
1053
+ // Only the question-emulated path needs the provider's synthetic build turn.
1054
+ // A host stage tool owns both approval and execution handoff itself.
1055
+ if (metadata.createPlanBridgeKind === "approve" && sessionKey) {
1056
+ const absolute = typeof metadata.planPath === "string" && metadata.planPath.trim()
1057
+ ? metadata.planPath.trim()
1058
+ : planPathFromUri(planUri);
1059
+ const workspaceRoot = typeof metadata.workspaceRoot === "string" ? metadata.workspaceRoot : undefined;
1060
+ if (!queuePlanExecutionKickoff({
1061
+ sessionID: sessionKey,
1062
+ planPath: formatPlanKickoffPath(absolute, workspaceRoot),
1063
+ cursorSessionID,
1064
+ })) {
1065
+ return buildCreatePlanInteractionReply(interactionId, {
1066
+ error: { error: "The plan was approved, but this host cannot start its execution turn." },
1067
+ plan_uri: planUri,
1068
+ });
1069
+ }
1070
+ }
1071
+ return buildCreatePlanInteractionReply(interactionId, {
1072
+ success: {},
1073
+ plan_uri: planUri,
1074
+ });
1075
+ }
1076
+ function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1077
+ const metadata = pending.resultMetadata ?? {};
1078
+ const interactionId = metadata.interactionId;
1079
+ if (typeof interactionId !== "number") {
1080
+ throw new CursorProtocolError("Bridged SwitchMode lost its interaction id");
1081
+ }
1082
+ // A question-emulated exit carries the user's Yes/No as prose, not a plan
1083
+ // tool's success/failure, so it needs the answer parser rather than the
1084
+ // error-shaped mapping.
1085
+ const answer = metadata.switchModeBridgeKind === "question"
1086
+ ? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
1087
+ : switchModeResultFromToolOutput(result.output, result.error !== undefined);
1088
+ if ("approved" in answer) {
1089
+ const target = metadata.switchModeTarget;
1090
+ if (typeof target === "string" && target.trim()) {
1091
+ const normalized = target.trim().toLowerCase();
1092
+ setActiveCursorMode(sessionKey, target, {
1093
+ bridgedPlanEntered: normalized === "plan" || normalized === "spec",
1094
+ });
1095
+ }
1096
+ }
1097
+ return buildSwitchModeInteractionReply(interactionId, answer);
1098
+ }
776
1099
  /**
777
1100
  * Deliver trailing tool results onto a live continuation session.
778
1101
  * Returns the same session when writes succeed (or only bridged results were
@@ -799,22 +1122,131 @@ export function deliverContinuationResults(session, trailingToolResults) {
799
1122
  }
800
1123
  const pending = claim.pending;
801
1124
  let frames = [];
802
- if (!pending.bridged) {
1125
+ if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
1126
+ // A bridged Cursor AskQuestion. The host tool result carries the user's
1127
+ // choices; translate them back into the Cursor result the interaction is
1128
+ // waiting for (or into the deferred completion, when Cursor was already
1129
+ // released with `async`).
1130
+ try {
1131
+ frames = [buildAskQuestionContinuationFrame(pending, r)];
1132
+ }
1133
+ catch (error) {
1134
+ trace(`continuation: ask_question encode FAILED execId=${r.execId} err=${error.message}`);
1135
+ sessionManager.close(session, "result-write-failed");
1136
+ return undefined;
1137
+ }
1138
+ }
1139
+ else if (pending.resultField === SWITCH_MODE_RESULT_FIELD) {
1140
+ // Bridged SwitchMode: host plan_enter / plan_exit outcome becomes
1141
+ // approved{} or rejected{reason} on the still-open InteractionQuery.
1142
+ // Approval also arms the CLI-shaped mode reminder for the next Run.
1143
+ try {
1144
+ frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
1145
+ }
1146
+ catch (error) {
1147
+ trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
1148
+ sessionManager.close(session, "result-write-failed");
1149
+ return undefined;
1150
+ }
1151
+ }
1152
+ else if (pending.resultField === CREATE_PLAN_RESULT_FIELD) {
1153
+ try {
1154
+ frames = [buildCreatePlanContinuationFrame(pending, r, session.openCodeSessionId, session.sessionId)];
1155
+ }
1156
+ catch (error) {
1157
+ trace(`continuation: create_plan encode FAILED execId=${r.execId} err=${error.message}`);
1158
+ sessionManager.close(session, "result-write-failed");
1159
+ return undefined;
1160
+ }
1161
+ }
1162
+ else if (!pending.bridged) {
803
1163
  try {
804
1164
  const shellResult = pending.resultField === "shell_stream"
805
1165
  || pending.resultField === "background_shell_spawn_result"
806
1166
  ? consumeCursorShellResult(r.toolCallId, r.output)
807
1167
  : undefined;
808
- frames = buildExecClientMessages({
809
- execId: r.execId,
810
- resultField: pending.resultField,
811
- output: shellResult?.output ?? r.output,
812
- error: r.error,
813
- toolName: pending.toolName ?? r.toolName,
814
- resultMetadata: pending.resultMetadata,
815
- shellOutcome: shellResult?.outcome,
816
- workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
817
- });
1168
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
1169
+ const correlatedEditCallId = pending.resultMetadata?.correlatedEditCallId;
1170
+ const requestedPath = pending.resultMetadata?.path;
1171
+ const correlatedEdit = !r.error
1172
+ && pending.resultField === "read_result"
1173
+ && pending.toolName === "read"
1174
+ && typeof correlatedEditCallId === "string"
1175
+ && typeof requestedPath === "string"
1176
+ ? session.editToolCalls?.get(correlatedEditCallId)
1177
+ : undefined;
1178
+ if (correlatedEdit) {
1179
+ const absolutePath = path.resolve(workspaceRoot, requestedPath);
1180
+ if (absolutePath === path.resolve(workspaceRoot, correlatedEdit.path)) {
1181
+ frames = buildCompleteEditReadMessages(r.execId, absolutePath, requestedPath) ?? [];
1182
+ if (frames.length > 0) {
1183
+ correlatedEdit.completeRead = true;
1184
+ trace(`continuation: upgraded authorized correlated edit read execId=${r.execId} ` +
1185
+ `path=${JSON.stringify(requestedPath)}`);
1186
+ }
1187
+ }
1188
+ }
1189
+ // A refused image commit is Cursor's own `permission_denied` variant,
1190
+ // not a generic write error: its agent branches on that case. The
1191
+ // marker is set by this provider's own tool, so matching it is a
1192
+ // contract rather than a guess at someone else's message text.
1193
+ if (pending.toolName === CURSOR_IMAGE_SAVE_TOOL
1194
+ && pending.resultField === "write_result"
1195
+ && r.error?.includes(IMAGE_PERMISSION_DENIED_PREFIX)) {
1196
+ const deniedPath = typeof pending.resultMetadata?.path === "string"
1197
+ ? pending.resultMetadata.path
1198
+ : "";
1199
+ frames = [encodeMessage("AgentClientMessage", {
1200
+ exec_client_message: {
1201
+ id: r.execId,
1202
+ write_result: {
1203
+ permission_denied: {
1204
+ path: deniedPath,
1205
+ directory: deniedPath ? path.dirname(deniedPath) : "",
1206
+ operation: "write",
1207
+ error: r.error.replace(`${IMAGE_PERMISSION_DENIED_PREFIX} `, ""),
1208
+ is_readonly: false,
1209
+ },
1210
+ },
1211
+ },
1212
+ })];
1213
+ }
1214
+ // A committed image reports the file Cursor asked about, not the tool's
1215
+ // prose. `buildExecClientMessages` would otherwise put the message's
1216
+ // character count in `WriteSuccess.file_size`.
1217
+ if (frames.length === 0
1218
+ && !r.error
1219
+ && pending.toolName === CURSOR_IMAGE_SAVE_TOOL
1220
+ && pending.resultField === "write_result") {
1221
+ frames = [encodeMessage("AgentClientMessage", {
1222
+ exec_client_message: {
1223
+ id: r.execId,
1224
+ write_result: {
1225
+ success: {
1226
+ path: typeof pending.resultMetadata?.path === "string"
1227
+ ? pending.resultMetadata.path
1228
+ : "",
1229
+ lines_created: 0,
1230
+ file_size: typeof pending.resultMetadata?.imageByteLength === "number"
1231
+ ? pending.resultMetadata.imageByteLength
1232
+ : 0,
1233
+ },
1234
+ },
1235
+ },
1236
+ })];
1237
+ }
1238
+ if (frames.length === 0) {
1239
+ frames = buildExecClientMessages({
1240
+ execId: r.execId,
1241
+ resultField: pending.resultField,
1242
+ output: shellResult?.output ?? r.output,
1243
+ error: r.error,
1244
+ toolName: pending.toolName ?? r.toolName,
1245
+ resultMetadata: pending.resultMetadata,
1246
+ shellOutcome: shellResult?.outcome,
1247
+ workspaceRoot,
1248
+ });
1249
+ }
818
1250
  }
819
1251
  catch (error) {
820
1252
  trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
@@ -834,7 +1266,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
834
1266
  trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
835
1267
  continue;
836
1268
  }
837
- trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
1269
+ const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
1270
+ ? "ask_question answer"
1271
+ : pending.resultField === SWITCH_MODE_RESULT_FIELD
1272
+ ? "switch_mode answer"
1273
+ : pending.resultField === CREATE_PLAN_RESULT_FIELD
1274
+ ? "create_plan answer"
1275
+ : "exec result";
1276
+ trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
838
1277
  `frames=${outcome.framesWritten} outLen=${r.output.length}`);
839
1278
  }
840
1279
  return session;
@@ -879,7 +1318,27 @@ function resolveExplicitAgentBaseURL(options) {
879
1318
  function isTruthyEnv(value) {
880
1319
  return value === "1" || value === "true";
881
1320
  }
1321
+ const streamWriteChains = new WeakMap();
1322
+ /**
1323
+ * Keep awaited protocol writes ordered per Run stream. In particular, a large
1324
+ * KV get_blob reply must drain before another KV reply or heartbeat is queued;
1325
+ * otherwise one heartbeat observes the backlog created by dozens of ignored
1326
+ * false write() results and reports the wrong operation as the failure.
1327
+ */
882
1328
  async function writeWithBackpressure(stream, message, operation) {
1329
+ const previous = streamWriteChains.get(stream);
1330
+ const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
1331
+ .then(() => writeWithBackpressureNow(stream, message, operation));
1332
+ streamWriteChains.set(stream, current);
1333
+ try {
1334
+ await current;
1335
+ }
1336
+ finally {
1337
+ if (streamWriteChains.get(stream) === current)
1338
+ streamWriteChains.delete(stream);
1339
+ }
1340
+ }
1341
+ async function writeWithBackpressureNow(stream, message, operation) {
883
1342
  let accepted;
884
1343
  try {
885
1344
  accepted = stream.write(message);
@@ -892,6 +1351,7 @@ async function writeWithBackpressure(stream, message, operation) {
892
1351
  }
893
1352
  if (accepted !== false)
894
1353
  return;
1354
+ trace(`stream write backpressured: operation=${operation} bytes=${message.length}`);
895
1355
  if (!stream.waitForDrain) {
896
1356
  throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
897
1357
  transient: false,
@@ -901,6 +1361,7 @@ async function writeWithBackpressure(stream, message, operation) {
901
1361
  }
902
1362
  try {
903
1363
  await stream.waitForDrain(5_000);
1364
+ trace(`stream write drained: operation=${operation} bytes=${message.length}`);
904
1365
  }
905
1366
  catch (cause) {
906
1367
  throw toCursorProviderError(cause, {
@@ -947,13 +1408,27 @@ async function nextFrameWithSemanticDeadline(session) {
947
1408
  */
948
1409
  export async function pump(session, controller, ids, abortSignal) {
949
1410
  sessionManager.registerSession(session);
1411
+ const cacheDiagnostics = session.cacheDiagnostics ??= {
1412
+ sessionKey: session.openCodeSessionId,
1413
+ conversationId: session.conversationId,
1414
+ priorTokenDetails: session.tokenDetails,
1415
+ startedWithCheckpoint: !!session.tokenDetails,
1416
+ requestContextReused: false,
1417
+ requestContextHash: "unavailable",
1418
+ checkpointUpdates: 0,
1419
+ tokenDetailUpdates: 0,
1420
+ pumpPasses: 0,
1421
+ stepStarts: 0,
1422
+ stepCompletes: 0,
1423
+ displayToolCalls: 0,
1424
+ execRequests: 0,
1425
+ };
1426
+ cacheDiagnostics.pumpPasses++;
950
1427
  const { textId, reasoningId } = ids;
951
- const promptTokens = ids.promptTokens ?? 0;
952
1428
  const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
953
1429
  const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
954
1430
  let textStarted = false;
955
1431
  let reasoningStarted = false;
956
- const requestUsage = ids.requestUsage ?? { outputChars: 0 };
957
1432
  const replaySafety = new AttemptReplaySafety(session.sessionId);
958
1433
  const failRunProtocol = (message, code) => {
959
1434
  replaySafety.markBarrier("unknown-or-malformed-frame");
@@ -961,6 +1436,10 @@ export async function pump(session, controller, ids, abortSignal) {
961
1436
  sessionManager.close(session, "remote-error", error);
962
1437
  throw error;
963
1438
  };
1439
+ const rethrowTransportWriteFailure = (error) => {
1440
+ if (error instanceof CursorProviderError && error.origin !== "protocol")
1441
+ throw error;
1442
+ };
964
1443
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
965
1444
  // in doStreamImpl). The frames iterator can still yield a final `done` after
966
1445
  // the cancel lands — controller.enqueue on a cancelled controller throws.
@@ -1016,13 +1495,17 @@ export async function pump(session, controller, ids, abortSignal) {
1016
1495
  }
1017
1496
  };
1018
1497
  /**
1019
- * Cursor's streamed edit handshake reads the target before it sends the
1020
- * replacement through write_args. For a new file that read naturally fails,
1021
- * which makes the model abandon the edit and fall back to a shell heredoc.
1022
- * Treat only a missing target correlated to an edit_tool_call as an empty
1023
- * file, allowing Cursor to continue to the ordinary OpenCode write call.
1498
+ * Cursor's streamed edit handshake needs the complete target before it sends
1499
+ * the replacement through write_args. OpenCode's ordinary read tool caps at
1500
+ * 50 KB, so forwarding this private prerequisite read teaches Cursor's editor
1501
+ * that a large file ends at the cap and produces a destructive replacement.
1502
+ *
1503
+ * Answer a correlated workspace-file read directly up to Cursor's own 50 MB
1504
+ * edit limit. Missing targets remain empty-file successes so creation can
1505
+ * continue. External and otherwise ineligible reads retain OpenCode's normal
1506
+ * permission-aware path.
1024
1507
  */
1025
- const recoverMissingEditRead = (parsed, displayCallId) => {
1508
+ const recoverCorrelatedEditRead = (parsed, displayCallId) => {
1026
1509
  if (!displayCallId ||
1027
1510
  parsed.resultField !== "read_result" ||
1028
1511
  parsed.toolName !== "read" ||
@@ -1043,8 +1526,54 @@ export async function pump(session, controller, ids, abortSignal) {
1043
1526
  const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
1044
1527
  const resolvePath = (value) => path.resolve(workspaceRoot, value);
1045
1528
  const absolutePath = resolvePath(requestedPath);
1046
- if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
1529
+ if (absolutePath !== resolvePath(editPath))
1047
1530
  return false;
1531
+ let exists = true;
1532
+ try {
1533
+ fs.lstatSync(absolutePath);
1534
+ }
1535
+ catch (error) {
1536
+ const code = error && typeof error === "object" && "code" in error
1537
+ ? error.code
1538
+ : undefined;
1539
+ if (code !== "ENOENT")
1540
+ return false;
1541
+ exists = false;
1542
+ }
1543
+ if (exists) {
1544
+ // Do not bypass OpenCode's external-directory permission boundary, even
1545
+ // through a symlink rooted in the workspace.
1546
+ try {
1547
+ const realRoot = fs.realpathSync(workspaceRoot);
1548
+ const realTarget = fs.realpathSync(absolutePath);
1549
+ const relative = path.relative(realRoot, realTarget);
1550
+ if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1551
+ parsed.resultMetadata = {
1552
+ ...parsed.resultMetadata,
1553
+ correlatedEditCallId: displayCallId,
1554
+ };
1555
+ return false;
1556
+ }
1557
+ const frames = buildCompleteEditReadMessages(parsed.id, absolutePath, requestedPath);
1558
+ if (!frames)
1559
+ return false;
1560
+ for (const frame of frames)
1561
+ session.stream.write(frame);
1562
+ const editCall = session.editToolCalls?.get(displayCallId);
1563
+ if (editCall)
1564
+ editCall.completeRead = true;
1565
+ trace(`exec: correlated edit read completed directly id=${parsed.id} ` +
1566
+ `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
1567
+ return true;
1568
+ }
1569
+ catch {
1570
+ parsed.resultMetadata = {
1571
+ ...parsed.resultMetadata,
1572
+ correlatedEditCallId: displayCallId,
1573
+ };
1574
+ return false;
1575
+ }
1576
+ }
1048
1577
  try {
1049
1578
  for (const frame of buildExecClientMessages({
1050
1579
  execId: parsed.id,
@@ -1061,7 +1590,7 @@ export async function pump(session, controller, ids, abortSignal) {
1061
1590
  return true;
1062
1591
  }
1063
1592
  catch (e) {
1064
- const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
1593
+ const error = new Error(`Failed to recover Cursor edit read: ${e.message}`);
1065
1594
  trace(`exec: edit read recovery FAILED ${error.message}`);
1066
1595
  safeError(error);
1067
1596
  sessionManager.close(session);
@@ -1079,7 +1608,7 @@ export async function pump(session, controller, ids, abortSignal) {
1079
1608
  *
1080
1609
  * Read-only by construction — write/edit targets are never checked, so
1081
1610
  * legitimate file creation (path resolves, file absent) is never denied. Runs
1082
- * after recoverMissingEditRead, which has already converted the new-file edit
1611
+ * after recoverCorrelatedEditRead, which has already converted the edit
1083
1612
  * handshake read into an empty-file success. EACCES/EPERM and other stat
1084
1613
  * errors fall through to OpenCode so a genuine permission decision stands.
1085
1614
  */
@@ -1089,10 +1618,9 @@ export async function pump(session, controller, ids, abortSignal) {
1089
1618
  const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1090
1619
  if (!requested)
1091
1620
  return false;
1092
- // A scheme-addressed target is the host's to resolve, not a local file to
1093
- // stat. Rejecting it here makes host-mounted capabilities (oh-my-pi's
1094
- // `xd://` device URLs, which is how every MCP tool is exposed by default)
1095
- // permanently unreachable through this provider.
1621
+ // A scheme-addressed target is the advertised host executor's to resolve,
1622
+ // not a local file to stat. Rejecting it here would make any URI-backed
1623
+ // capability permanently unreachable through this provider.
1096
1624
  if (isUriReadTarget(requested)) {
1097
1625
  trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
1098
1626
  return false;
@@ -1141,9 +1669,7 @@ export async function pump(session, controller, ids, abortSignal) {
1141
1669
  textStarted = true;
1142
1670
  }
1143
1671
  session.usageEstimate.outputTokens += estimateTokens(text.length);
1144
- if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
1145
- requestUsage.outputChars += text.length;
1146
- }
1672
+ safeEnqueue({ type: "text-delta", id: textId, delta: text });
1147
1673
  };
1148
1674
  const emitReasoning = (text) => {
1149
1675
  if (!text)
@@ -1154,38 +1680,32 @@ export async function pump(session, controller, ids, abortSignal) {
1154
1680
  reasoningStarted = true;
1155
1681
  }
1156
1682
  session.usageEstimate.outputTokens += estimateTokens(text.length);
1157
- if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
1158
- requestUsage.outputChars += text.length;
1159
- }
1683
+ safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
1160
1684
  };
1161
- const emitFinish = (te, reason) => {
1685
+ const emitFinish = (te, reason, settledUsage, settledSource) => {
1162
1686
  closeOpenSpans();
1163
- // TurnEnded is request-local only when this Run did not previously stop at
1164
- // a tool boundary. On a held multi-step Run Cursor aggregates every model
1165
- // invocation into these counters; treating that billed total as the final
1166
- // request's prompt size makes OpenCode compact after ordinary tasks.
1167
- const cumulativeByBoundary = !!te && session.hadToolCallBoundary === true;
1168
- const cumulativeByBudget = !!te && !cumulativeByBoundary && exceedsRequestLocalBudget(te, {
1169
- inputTokens: session.usageEstimate.inputTokens,
1170
- outputTokens: session.usageEstimate.outputTokens,
1171
- });
1172
- const cumulativeTurnEnded = cumulativeByBoundary || cumulativeByBudget;
1173
- if (te && !cumulativeTurnEnded) {
1174
- session.usageEstimate = {
1175
- inputTokens: turnEndedCounter(te, "input_tokens"),
1176
- outputTokens: turnEndedCounter(te, "output_tokens"),
1177
- cacheRead: turnEndedCounter(te, "cache_read"),
1178
- cacheWrite: turnEndedCounter(te, "cache_write"),
1179
- reasoningTokens: turnEndedCounter(te, "reasoning_tokens"),
1180
- };
1181
- }
1182
1687
  const est = session.usageEstimate;
1183
- const usage = te && !cumulativeTurnEnded
1184
- ? buildLanguageModelV3UsageFromTurnEnded(te)
1185
- : buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
1186
- if (!te)
1187
- session.hadToolCallBoundary = true;
1188
- const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
1688
+ // OpenCode persists and sums every AI SDK finish. Cursor does not expose a
1689
+ // final context total at tool boundaries, so charging the growing estimate
1690
+ // there multiplies one held Run across all of its steps. Emit zero at
1691
+ // intermediate boundaries, then prefer checkpoint tokenDetails exactly once
1692
+ // when TurnEnded closes the held Run. Cursor CLI keeps context occupancy in
1693
+ // agentStore tokenDetails; it does not reinterpret TurnEnded as occupancy.
1694
+ const tokenDetails = te ? session.tokenDetails : undefined;
1695
+ const contextSource = tokenDetails
1696
+ ? session.tokenDetailsFresh
1697
+ ? "checkpoint-current-run"
1698
+ : "checkpoint-previous-turn"
1699
+ : undefined;
1700
+ const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
1701
+ const usage = settledUsage ?? (te
1702
+ ? tokenDetails
1703
+ ? buildLanguageModelV3UsageFromCounters(counters, { contextTotalTokens: tokenDetails.usedTokens })
1704
+ : emptyLanguageModelV3Usage()
1705
+ : emptyLanguageModelV3Usage());
1706
+ const providerMetadata = te
1707
+ ? cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
1708
+ : undefined;
1189
1709
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1190
1710
  ? String(reason.unified ?? "unknown")
1191
1711
  : String(reason);
@@ -1199,8 +1719,13 @@ export async function pump(session, controller, ids, abortSignal) {
1199
1719
  `rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
1200
1720
  `rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
1201
1721
  `rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
1202
- `guard=${cumulativeByBoundary ? "boundary" : cumulativeByBudget ? "over-budget" : "none"} ` +
1203
- `source=${te ? (cumulativeTurnEnded ? "estimate-cumulative-turn-ended" : "turn_ended") : "estimate"}`);
1722
+ `source=${te
1723
+ ? settledSource ?? (contextSource ?? "unavailable")
1724
+ : "intermediate-zero"}`);
1725
+ if (counters) {
1726
+ trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
1727
+ trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
1728
+ }
1204
1729
  safeEnqueue({
1205
1730
  type: "finish",
1206
1731
  usage,
@@ -1331,9 +1856,20 @@ export async function pump(session, controller, ids, abortSignal) {
1331
1856
  if (checkpointRaw != null) {
1332
1857
  const bytes = normalizeCheckpointBytes(checkpointRaw);
1333
1858
  if (bytes && bytes.length > 0) {
1859
+ cacheDiagnostics.checkpointUpdates++;
1334
1860
  setCheckpoint(session.conversationId, bytes);
1335
1861
  session.resumeCheckpoint = Uint8Array.from(bytes);
1336
- trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}`);
1862
+ const tokenDetails = decodeConversationTokenDetails(bytes);
1863
+ if (tokenDetails) {
1864
+ cacheDiagnostics.tokenDetailUpdates++;
1865
+ session.tokenDetails = tokenDetails;
1866
+ session.tokenDetailsFresh = true;
1867
+ }
1868
+ const context = tokenDetails
1869
+ ? ` context=${tokenDetails.usedTokens}/${tokenDetails.maxTokens} ` +
1870
+ `categories=${formatCursorTokenCategories(tokenDetails)}`
1871
+ : " context=unavailable";
1872
+ trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}${context}`);
1337
1873
  }
1338
1874
  }
1339
1875
  if (iu?.text_delta) {
@@ -1344,11 +1880,25 @@ export async function pump(session, controller, ids, abortSignal) {
1344
1880
  }
1345
1881
  else if (iu?.turn_ended) {
1346
1882
  trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
1347
- emitFinish(iu.turn_ended, { unified: "stop", raw: undefined });
1883
+ const turnEnded = iu.turn_ended;
1884
+ if (session.openCodeSessionId) {
1885
+ await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
1886
+ sessionKey: session.openCodeSessionId,
1887
+ conversationId: session.conversationId,
1888
+ requestContext: session.requestContext,
1889
+ toolCatalog: session.toolCatalog ?? [],
1890
+ postCompactionRebase: session.postCompactionRebase,
1891
+ }).catch((error) => {
1892
+ trace(`conversation persistence: TurnEnded save failed ` +
1893
+ `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
1894
+ });
1895
+ }
1896
+ emitFinish(turnEnded, { unified: "stop", raw: undefined });
1348
1897
  sessionManager.close(session);
1349
1898
  return;
1350
1899
  }
1351
1900
  else if (iu?.tool_call_started) {
1901
+ cacheDiagnostics.displayToolCalls++;
1352
1902
  // Stash Cursor display ToolCall until exec claims it, or completed bridges it.
1353
1903
  const started = iu.tool_call_started;
1354
1904
  const callId = typeof started.call_id === "string" ? started.call_id : "";
@@ -1435,7 +1985,14 @@ export async function pump(session, controller, ids, abortSignal) {
1435
1985
  }
1436
1986
  }
1437
1987
  }
1988
+ else if (iu?.step_started) {
1989
+ cacheDiagnostics.stepStarts++;
1990
+ }
1991
+ else if (iu?.step_completed) {
1992
+ cacheDiagnostics.stepCompletes++;
1993
+ }
1438
1994
  else if (esm) {
1995
+ cacheDiagnostics.execRequests++;
1439
1996
  const esmId = esm.id ?? 0;
1440
1997
  if (esm.request_context_args) {
1441
1998
  // Server turn-setup probe (#10). Reply with full OpenCode-sourced context.
@@ -1451,10 +2008,11 @@ export async function pump(session, controller, ids, abortSignal) {
1451
2008
  }
1452
2009
  try {
1453
2010
  traceRequestContextPaths(`exec request_context reply id=${esmId}`, session.requestContext);
1454
- session.stream.write(buildRequestContextResult(esmId, session.requestContext));
2011
+ await writeWithBackpressure(session.stream, buildRequestContextResult(esmId, session.requestContext), `request-context reply id=${esmId}`);
1455
2012
  trace(`exec request_context: replied`);
1456
2013
  }
1457
- catch {
2014
+ catch (error) {
2015
+ rethrowTransportWriteFailure(error);
1458
2016
  failRunProtocol("Cursor request-context reply failed", RUN_REPLY_FAILED);
1459
2017
  }
1460
2018
  }
@@ -1467,10 +2025,11 @@ export async function pump(session, controller, ids, abortSignal) {
1467
2025
  ? stateArgs.server_identifiers.join(",")
1468
2026
  : "";
1469
2027
  try {
1470
- session.stream.write(buildMcpStateResult(esmId, stateArgs, session.requestContext));
2028
+ await writeWithBackpressure(session.stream, buildMcpStateResult(esmId, stateArgs, session.requestContext), `MCP-state reply id=${esmId}`);
1471
2029
  trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
1472
2030
  }
1473
- catch {
2031
+ catch (error) {
2032
+ rethrowTransportWriteFailure(error);
1474
2033
  failRunProtocol("Cursor MCP-state reply failed", RUN_REPLY_FAILED);
1475
2034
  }
1476
2035
  }
@@ -1481,10 +2040,11 @@ export async function pump(session, controller, ids, abortSignal) {
1481
2040
  const args = esm.list_mcp_resources_exec_args;
1482
2041
  const server = typeof args.server === "string" ? args.server : "";
1483
2042
  try {
1484
- session.stream.write(buildListMcpResourcesFallback(esmId));
2043
+ await writeWithBackpressure(session.stream, buildListMcpResourcesFallback(esmId), `list-MCP-resources reply id=${esmId}`);
1485
2044
  trace(`exec list_mcp_resources: replied id=${esmId} server=${server || "(all)"} success{resources:[]}`);
1486
2045
  }
1487
- catch {
2046
+ catch (error) {
2047
+ rethrowTransportWriteFailure(error);
1488
2048
  failRunProtocol("Cursor list_mcp_resources reply failed", RUN_REPLY_FAILED);
1489
2049
  }
1490
2050
  }
@@ -1494,10 +2054,11 @@ export async function pump(session, controller, ids, abortSignal) {
1494
2054
  const server = typeof args.server === "string" ? args.server : "";
1495
2055
  const uri = typeof args.uri === "string" ? args.uri : "";
1496
2056
  try {
1497
- session.stream.write(buildReadMcpResourceFallback(esmId, server, uri));
2057
+ await writeWithBackpressure(session.stream, buildReadMcpResourceFallback(esmId, server, uri), `read-MCP-resource reply id=${esmId}`);
1498
2058
  trace(`exec read_mcp_resource: replied id=${esmId} server=${server} uri=${uri} error{server not found}`);
1499
2059
  }
1500
- catch {
2060
+ catch (error) {
2061
+ rethrowTransportWriteFailure(error);
1501
2062
  failRunProtocol("Cursor read_mcp_resource reply failed", RUN_REPLY_FAILED);
1502
2063
  }
1503
2064
  }
@@ -1515,6 +2076,11 @@ export async function pump(session, controller, ids, abortSignal) {
1515
2076
  const editCall = displayCallId
1516
2077
  ? session.editToolCalls?.get(displayCallId)
1517
2078
  : undefined;
2079
+ // A complete correlated edit read is trusted provenance: unlike an
2080
+ // ordinary capped read it cannot have appended our warning marker,
2081
+ // and the source itself may legitimately contain the marker text.
2082
+ if (!editCall?.completeRead)
2083
+ rejectPartialReadMutation(parsed);
1518
2084
  if (editCall) {
1519
2085
  const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
1520
2086
  if (parsed.resultField === "write_result" && displayCallId) {
@@ -1547,6 +2113,63 @@ export async function pump(session, controller, ids, abortSignal) {
1547
2113
  return;
1548
2114
  continue;
1549
2115
  }
2116
+ // Cursor writes a generated image with an ordinary write exec whose
2117
+ // `file_bytes` are binary (Cursor CLI's agent does exactly this, then
2118
+ // reads the client's WriteResult back). OpenCode's `write` takes a
2119
+ // string, so those bytes are staged and committed by the host tool
2120
+ // that can raise the `edit` permission before anything hits disk.
2121
+ const binaryWrite = binaryWritePayload(parsed);
2122
+ if (binaryWrite) {
2123
+ if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
2124
+ const reason = "This OpenCode agent cannot write binary file content. "
2125
+ + "Do not retry this write with the same bytes.";
2126
+ if (!rejectExec(parsed, reason, "binary write unsupported"))
2127
+ return;
2128
+ continue;
2129
+ }
2130
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
2131
+ const projectDir = ensureOpencodeProjectDir(workspaceRoot);
2132
+ const target = remapCursorImageWritePath(binaryWrite.path, {
2133
+ workspaceRoot,
2134
+ projectDir,
2135
+ });
2136
+ let imageId;
2137
+ try {
2138
+ imageId = stageCursorImage({
2139
+ path: target,
2140
+ projectDir,
2141
+ mime: imageMimeForPath(target),
2142
+ data: binaryWrite.data,
2143
+ sessionId: session.openCodeSessionId,
2144
+ });
2145
+ }
2146
+ catch (error) {
2147
+ if (!rejectExec(parsed, error.message, "binary write too large"))
2148
+ return;
2149
+ continue;
2150
+ }
2151
+ if (displayCallId)
2152
+ session.displayToolCalls.delete(displayCallId);
2153
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, CURSOR_IMAGE_SAVE_TOOL, false, {
2154
+ ...parsed.resultMetadata,
2155
+ path: target,
2156
+ binaryWriteBytes: undefined,
2157
+ imageByteLength: binaryWrite.data.length,
2158
+ });
2159
+ const toolCallId = `cursor_${session.sessionId}_${parsed.id}`;
2160
+ trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
2161
+ `requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
2162
+ `bytes=${binaryWrite.data.length}`);
2163
+ closeOpenSpans();
2164
+ safeEnqueue({
2165
+ type: "tool-call",
2166
+ toolCallId,
2167
+ toolName: CURSOR_IMAGE_SAVE_TOOL,
2168
+ input: JSON.stringify({ image_id: imageId }),
2169
+ });
2170
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2171
+ return;
2172
+ }
1550
2173
  // Cursor has native capabilities (Task, filesystem, shell, etc.) in
1551
2174
  // addition to the MCP descriptors sent by this provider. The model
1552
2175
  // can request one even when the current OpenCode agent omitted its
@@ -1566,7 +2189,7 @@ export async function pump(session, controller, ids, abortSignal) {
1566
2189
  return;
1567
2190
  continue;
1568
2191
  }
1569
- if (recoverMissingEditRead(parsed, displayCallId))
2192
+ if (recoverCorrelatedEditRead(parsed, displayCallId))
1570
2193
  continue;
1571
2194
  if (rejectMissingReadTarget(parsed)) {
1572
2195
  if (displayCallId)
@@ -1613,11 +2236,25 @@ export async function pump(session, controller, ids, abortSignal) {
1613
2236
  else if (interactionQuery) {
1614
2237
  // InteractionQuery is a must-reply channel, just like exec and KV. AI
1615
2238
  // SDK has no Cursor-specific UI callback, so answer immediately with the
1616
- // conservative headless policy from protocol/interactions.ts (including
1617
- // F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
2239
+ // policy from protocol/interactions.ts (AskQuestion bridge, CreatePlan
2240
+ // write under OpenCode defaults, GenerateImage approve when saveable).
1618
2241
  const handled = (() => {
1619
2242
  try {
1620
- return handleInteractionQuery(interactionQuery, payload);
2243
+ return handleInteractionQuery(interactionQuery, payload, {
2244
+ canBridgeAskQuestion: session.allowTools && advertisedToolNameSet.has("question"),
2245
+ allowTools: session.allowTools,
2246
+ advertisedTools: advertisedToolNameSet,
2247
+ canSaveGeneratedImage: session.allowTools && advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL),
2248
+ canBridgeCreatePlan: session.allowTools
2249
+ && isBridgedCursorPlanModeActive(session.openCodeSessionId)
2250
+ && advertisedToolNameSet.has(CURSOR_PLAN_STAGE_TOOL)
2251
+ && advertisedToolNameSet.has("write"),
2252
+ planModeActive: isCursorPlanModeActive(session.openCodeSessionId),
2253
+ ...(getActiveCursorMode(session.openCodeSessionId)
2254
+ ? { activeCursorModeId: getActiveCursorMode(session.openCodeSessionId) }
2255
+ : {}),
2256
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2257
+ });
1621
2258
  }
1622
2259
  catch {
1623
2260
  return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
@@ -1627,13 +2264,139 @@ export async function pump(session, controller, ids, abortSignal) {
1627
2264
  if (handled.outcome === "acknowledged") {
1628
2265
  replaySafety.markBarrier("stateful-interaction");
1629
2266
  }
1630
- session.stream.write(handled.reply);
2267
+ if (handled.reply) {
2268
+ await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
2269
+ }
1631
2270
  trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
1632
- `field=${handled.variantField} outcome=${handled.outcome}`);
2271
+ `field=${handled.variantField} outcome=${handled.outcome}` +
2272
+ (handled.reply ? "" : " (deferred to host tool result)"));
2273
+ if (handled.generateImage) {
2274
+ trace(`interaction_query: approved generate_image target=` +
2275
+ `${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
2276
+ `refs=${handled.generateImage.referenceImagePaths.length} ` +
2277
+ `cursorToolCallId=${handled.generateImage.toolCallId || "(none)"}`);
2278
+ }
1633
2279
  }
1634
- catch {
2280
+ catch (error) {
2281
+ rethrowTransportWriteFailure(error);
1635
2282
  failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
1636
2283
  }
2284
+ if (handled.outcome === "bridged" && handled.askQuestion) {
2285
+ // Cursor raised AskQuestion; OpenCode owns the tool loop, so the prompt
2286
+ // leaves as a `question` tool call and this doStream ends. The Run stays
2287
+ // open on the pending entry, and the answer is written back on the next
2288
+ // continuation (InteractionResponse when Cursor is blocking on it,
2289
+ // ConversationAction when it was already acknowledged with `async`).
2290
+ const ask = handled.askQuestion;
2291
+ const execId = session.nextBridgedExecId++;
2292
+ sessionManager.registerPending(execId, session, ASK_QUESTION_RESULT_FIELD, "question", false, {
2293
+ interactionId: handled.id,
2294
+ askQuestionArgs: ask.args,
2295
+ askQuestionRawArgs: ask.rawArgs,
2296
+ askQuestionToolCallId: ask.toolCallId,
2297
+ });
2298
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
2299
+ const input = JSON.stringify(askQuestionToolInput(ask.args));
2300
+ trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
2301
+ `questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
2302
+ `cursorToolCallId=${ask.toolCallId || "(none)"}`);
2303
+ closeOpenSpans();
2304
+ safeEnqueue({
2305
+ type: "tool-call",
2306
+ toolCallId,
2307
+ toolName: "question",
2308
+ input,
2309
+ });
2310
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2311
+ return;
2312
+ }
2313
+ if (handled.outcome === "approved" && handled.switchMode) {
2314
+ // Entering plan mode without a host plan tool: Cursor was already
2315
+ // released with approved{} above, and the behavioural contract travels
2316
+ // as the <system_reminder> injected on subsequent Runs. Nothing is
2317
+ // pending, so keep pumping this Run rather than ending doStream.
2318
+ const sw = handled.switchMode;
2319
+ setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
2320
+ bridgedPlanEntered: false,
2321
+ });
2322
+ trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
2323
+ `target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
2324
+ `provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
2325
+ continue;
2326
+ }
2327
+ if (handled.outcome === "bridged" && handled.switchMode) {
2328
+ // Cursor raised SwitchMode; the host owns the outcome, so the switch
2329
+ // leaves as a tool call (native plan_enter / plan_exit, or the emulated
2330
+ // `question` approval prompt) and this doStream ends. The Run stays open
2331
+ // on the pending entry; approved/rejected is written back on the next
2332
+ // continuation while Cursor is still blocking on the query.
2333
+ const sw = handled.switchMode;
2334
+ const bridgeKind = sw.bridge.kind;
2335
+ const toolName = sw.toolName ?? "plan_exit";
2336
+ const execId = session.nextBridgedExecId++;
2337
+ sessionManager.registerPending(execId, session, SWITCH_MODE_RESULT_FIELD, toolName, false, {
2338
+ interactionId: handled.id,
2339
+ switchModeTarget: sw.args.targetModeId,
2340
+ switchModeToolCallId: sw.toolCallId,
2341
+ switchModeBridgeKind: bridgeKind,
2342
+ });
2343
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
2344
+ const input = JSON.stringify(sw.bridge.kind === "question" ? sw.bridge.input : switchModeToolInput());
2345
+ trace(`interaction_query: BRIDGED switch_mode id=${handled.id} toolCallId=${toolCallId} ` +
2346
+ `bridge=${bridgeKind} hostTool=${toolName} ` +
2347
+ `target=${JSON.stringify(sw.args.targetModeId)} ` +
2348
+ `cursorToolCallId=${sw.toolCallId || "(none)"}`);
2349
+ closeOpenSpans();
2350
+ safeEnqueue({
2351
+ type: "tool-call",
2352
+ toolCallId,
2353
+ toolName,
2354
+ input,
2355
+ });
2356
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2357
+ return;
2358
+ }
2359
+ if (handled.outcome === "bridged" && handled.createPlan) {
2360
+ // Either a host plan-stage tool (writes the plan and runs its own
2361
+ // approval UI) or the emulated prompt after the provider already wrote
2362
+ // the plan. Both hold Cursor's query open until the user decides.
2363
+ const plan = handled.createPlan;
2364
+ const staged = plan.bridge.kind === "stage" ? createPlanStageInput(plan.args) : undefined;
2365
+ const planUri = staged?.plan_uri ?? plan.planUri ?? "";
2366
+ const input = staged ?? plan.questionInput ?? {};
2367
+ const stageExecId = session.nextBridgedExecId++;
2368
+ sessionManager.registerPending(stageExecId, session, CREATE_PLAN_RESULT_FIELD, plan.toolName, false, {
2369
+ interactionId: handled.id,
2370
+ createPlanToolCallId: plan.toolCallId,
2371
+ createPlanBridgeKind: plan.bridge.kind,
2372
+ // The host echoes the prompt verbatim; it anchors the answer parse.
2373
+ createPlanQuestion: plan.questionInput?.questions[0]?.question ?? "",
2374
+ planUri,
2375
+ // Absolute path for the post-Yes OpenCode kickoff (plan_exit shape).
2376
+ ...(typeof plan.planPath === "string" && plan.planPath.trim()
2377
+ ? { planPath: plan.planPath.trim() }
2378
+ : {}),
2379
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2380
+ });
2381
+ const stageToolCallId = `cursor_${session.sessionId}_${stageExecId}`;
2382
+ trace(`interaction_query: BRIDGED create_plan id=${handled.id} bridge=${plan.bridge.kind} ` +
2383
+ `hostTool=${plan.toolName} stageToolCallId=${stageToolCallId} ` +
2384
+ `planUri=${planUri || "(none)"} cursorToolCallId=${plan.toolCallId || "(none)"}`);
2385
+ // Cursor sends the plan body through the interaction query, not the
2386
+ // text stream, so nothing has shown it yet. Put it in the transcript
2387
+ // before asking — approving a plan you cannot read is not approval.
2388
+ if (plan.planReview)
2389
+ emitText(plan.planReview);
2390
+ closeOpenSpans();
2391
+ safeEnqueue({
2392
+ type: "tool-call",
2393
+ toolCallId: stageToolCallId,
2394
+ toolName: plan.toolName,
2395
+ input: JSON.stringify(input),
2396
+ });
2397
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2398
+ return;
2399
+ }
1637
2400
  }
1638
2401
  else if (kv) {
1639
2402
  // KV blob channel: ack set_blob / answer get_blob, then keep pumping.
@@ -1646,12 +2409,14 @@ export async function pump(session, controller, ids, abortSignal) {
1646
2409
  const handled = handleKvServerMessage(kv, session);
1647
2410
  if (handled) {
1648
2411
  try {
1649
- session.stream.write(handled.reply);
2412
+ await writeWithBackpressure(session.stream, handled.reply, `KV ${handled.kind}_blob reply id=${handled.id}`);
1650
2413
  trace(`kv replied: kind=${handled.kind} id=${handled.id} blobId=${handled.blobIdHex.slice(0, 16)}… ` +
1651
2414
  `found=${handled.found} echoed=${!!handled.echoed} ` +
2415
+ `replyBytes=${handled.reply.length} replyBlobBytes=${handled.replyBlobBytes} ` +
1652
2416
  `sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
1653
2417
  }
1654
- catch {
2418
+ catch (error) {
2419
+ rethrowTransportWriteFailure(error);
1655
2420
  failRunProtocol("Cursor KV reply failed", RUN_REPLY_FAILED);
1656
2421
  }
1657
2422
  }
@@ -1661,7 +2426,7 @@ export async function pump(session, controller, ids, abortSignal) {
1661
2426
  }
1662
2427
  }
1663
2428
  catch (e) {
1664
- if (e instanceof CursorProtocolError)
2429
+ if (e instanceof CursorProviderError)
1665
2430
  throw e;
1666
2431
  if (requiredChannel) {
1667
2432
  failRunProtocol(`Cursor ${requiredChannel} request could not be handled`, RUN_REQUEST_UNSUPPORTED);
@@ -1783,16 +2548,26 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1783
2548
  const instructions = [];
1784
2549
  const subagents = extractHostSubagentCatalog(tools);
1785
2550
  if (names.has("question")) {
1786
- instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
1787
- }
2551
+ // Cursor-native AskQuestion is translated into this tool (see
2552
+ // protocol/ask-question.ts), so both routes reach the user. Saying so stops
2553
+ // the model from treating a question as impossible when it reaches for its
2554
+ // native interaction first.
2555
+ instructions.push("- When user input is required, call the OpenCode `question` tool. Cursor-native AskQuestion requests are also accepted and answered through it.");
2556
+ }
2557
+ // CreatePlan is a Cursor InteractionQuery (#7), not an OpenCode/MCP catalog
2558
+ // tool. Always say so when we advertise any tools — otherwise the model sees
2559
+ // CreatePlan in Cursor's function definitions, notices it is absent from the
2560
+ // OpenCode list, and narrates "No CreatePlan MCP tool is available" even
2561
+ // though the provider bridges the native interaction.
2562
+ instructions.push("- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally; the provider writes the plan under the host's calculated plans directory and handles execution approval. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool.");
1788
2563
  if (names.has("plan_enter")) {
1789
- instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool; do not use Cursor's native SwitchMode or CreatePlan interactions.");
2564
+ instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool. Cursor-native SwitchMode requests for plan/spec are also accepted and answered through it.");
1790
2565
  }
1791
2566
  else if (names.has("todowrite")) {
1792
- instructions.push("- For planning, call the OpenCode `todowrite` tool and explain the plan in normal text; do not use Cursor's native SwitchMode or CreatePlan interactions.");
2567
+ instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
1793
2568
  }
1794
2569
  if (names.has("plan_exit")) {
1795
- instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
2570
+ instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool. Cursor-native SwitchMode for any non-plan target (agent, build, chat, debug, edit, background, multitask, triage, project, …) is also accepted and answered through it; the provider then injects the Cursor CLI-shaped mode reminder for that target.");
1796
2571
  }
1797
2572
  if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
1798
2573
  instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
@@ -1806,8 +2581,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1806
2581
  if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
1807
2582
  instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
1808
2583
  }
1809
- if (names.has("task") || names.has("actor")) {
1810
- const target = names.has("actor") ? "`actor`" : "`task`";
2584
+ if (names.has("task")) {
2585
+ const target = "`task`";
1811
2586
  const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
1812
2587
  instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
1813
2588
  "Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
@@ -1835,8 +2610,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1835
2610
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1836
2611
  `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.`,
1837
2612
  subagents.executor
1838
- ? "Call only tools in that exact list. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed; other unlisted Cursor-native tools remain unavailable."
1839
- : "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.",
2613
+ ? "Call only tools in that exact list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
2614
+ : "Call only tools in that exact list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
1840
2615
  ...(instructions.length > 0
1841
2616
  ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
1842
2617
  : []),
@@ -1850,25 +2625,19 @@ export function estimateTokens(chars) {
1850
2625
  return 0;
1851
2626
  return Math.ceil(chars / 4);
1852
2627
  }
1853
- /** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
1854
- export function estimatePromptTokens(prompt) {
1855
- const serializedContent = prompt
1856
- .map((message) => typeof message.content === "string"
1857
- ? message.content
1858
- : (JSON.stringify(message.content) ?? ""))
1859
- .join("\n");
1860
- return estimateTokens(serializedContent.length);
1861
- }
1862
- /** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
1863
- export function cursorTurnEndedProviderMetadata(te) {
2628
+ /** Preserve exact request-local Cursor counters as diagnostics. */
2629
+ export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource) {
1864
2630
  return {
1865
2631
  cursor: {
1866
- usageVersion: 2,
2632
+ usageVersion: 3,
1867
2633
  inputTokensRaw: turnEndedCounter(te, "input_tokens"),
1868
2634
  outputTokensRaw: turnEndedCounter(te, "output_tokens"),
1869
2635
  cacheReadRaw: turnEndedCounter(te, "cache_read"),
1870
2636
  cacheWriteRaw: turnEndedCounter(te, "cache_write"),
1871
2637
  reasoningTokensRaw: turnEndedCounter(te, "reasoning_tokens"),
2638
+ ...(tokenDetails
2639
+ ? { context: cursorContextUsageMetadata(tokenDetails, contextSource) }
2640
+ : {}),
1872
2641
  },
1873
2642
  };
1874
2643
  }
@@ -2025,18 +2794,43 @@ export function spanEndParts(opts) {
2025
2794
  export function computeAllowTools(toolCount, toolChoice) {
2026
2795
  return toolCount > 0 && toolChoice?.type !== "none";
2027
2796
  }
2028
- export function resolveTurnToolState(input) {
2797
+ export async function resolveTurnToolState(input) {
2029
2798
  const { sessionKey, incomingTools, isCompaction } = input;
2030
2799
  if (sessionKey && incomingTools.length > 0) {
2031
2800
  rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
2032
2801
  }
2033
- const cached = sessionKey ? toolCatalogBySession.get(sessionKey) : undefined;
2034
- if (sessionKey && cached && incomingTools.length === 0) {
2035
- rememberToolCatalog(sessionKey, cached);
2802
+ // Advertisement and permission are deliberately independent.
2803
+ //
2804
+ // Advertisement must stay byte-stable across every Run of a conversation or
2805
+ // the RequestContext changes shape and Cursor's prompt cache goes cold. A
2806
+ // zero-tool call is never a smaller catalog — it is a lifecycle turn
2807
+ // (compaction, title generation, summarization) that OpenCode opens alongside
2808
+ // the real one — so it re-advertises the last real catalog rather than
2809
+ // collapsing the context to tools=0. A genuinely restricted turn still sends
2810
+ // a non-empty set and is advertised verbatim.
2811
+ //
2812
+ // On cold start the lifecycle Run may arrive before any catalog exists. For a
2813
+ // valid session key, wait until a sibling doStream publishes the first real
2814
+ // catalog; cancellation is the only escape. Never time out to tools=[] and
2815
+ // never invent or filter the enabled set.
2816
+ //
2817
+ // Permission is computed from what actually arrived, so those lifecycle turns
2818
+ // still refuse execution and cannot duplicate the real turn's side effects.
2819
+ let advertisedTools;
2820
+ if (incomingTools.length > 0) {
2821
+ advertisedTools = incomingTools;
2822
+ }
2823
+ else if (sessionKey) {
2824
+ const cached = toolCatalogBySession.get(sessionKey)
2825
+ ?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
2826
+ advertisedTools = cached.map((tool) => ({ ...tool }));
2827
+ }
2828
+ else {
2829
+ // Without a session key there is no safe sibling-correlation key. Preserve
2830
+ // the host's literal no-tool call rather than borrowing another session's
2831
+ // catalog.
2832
+ advertisedTools = [];
2036
2833
  }
2037
- const advertisedTools = isCompaction && incomingTools.length === 0
2038
- ? (cached?.map((tool) => ({ ...tool })) ?? [])
2039
- : incomingTools;
2040
2834
  return {
2041
2835
  advertisedTools,
2042
2836
  allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
@@ -2055,6 +2849,11 @@ export function resolveTurnConversationReset(input) {
2055
2849
  return { reset: false };
2056
2850
  }
2057
2851
  export function resetTurnStateForTests() {
2852
+ for (const waiters of toolCatalogWaitersBySession.values()) {
2853
+ for (const waiter of waiters)
2854
+ waiter.cancel();
2855
+ }
2856
+ toolCatalogWaitersBySession.clear();
2058
2857
  toolCatalogBySession.clear();
2059
2858
  postCompactionRebaseBySession.clear();
2060
2859
  }
@@ -2081,6 +2880,7 @@ function foldStreamParts(parts) {
2081
2880
  let reasoning = "";
2082
2881
  const content = [];
2083
2882
  let finishReason = { unified: "stop", raw: undefined };
2883
+ let providerMetadata;
2084
2884
  let usage = {
2085
2885
  inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
2086
2886
  outputTokens: { total: undefined, text: undefined, reasoning: undefined },
@@ -2101,11 +2901,18 @@ function foldStreamParts(parts) {
2101
2901
  else if (part.type === "finish") {
2102
2902
  finishReason = part.finishReason;
2103
2903
  usage = part.usage;
2904
+ providerMetadata = part.providerMetadata;
2104
2905
  }
2105
2906
  }
2106
2907
  if (reasoning)
2107
2908
  content.unshift({ type: "reasoning", text: reasoning });
2108
2909
  if (text)
2109
2910
  content.unshift({ type: "text", text });
2110
- return { content, finishReason, usage, warnings: [] };
2911
+ return {
2912
+ content,
2913
+ finishReason,
2914
+ usage,
2915
+ warnings: [],
2916
+ ...(providerMetadata ? { providerMetadata } : {}),
2917
+ };
2111
2918
  }