cursor-opencode-provider 0.4.2 → 0.5.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 (41) hide show
  1. package/README.md +17 -12
  2. package/dist/context/build.d.ts +8 -0
  3. package/dist/context/build.js +72 -23
  4. package/dist/context/frozen.d.ts +29 -0
  5. package/dist/context/frozen.js +168 -0
  6. package/dist/context/index.d.ts +2 -1
  7. package/dist/context/index.js +2 -1
  8. package/dist/context/rules.d.ts +1 -0
  9. package/dist/context/rules.js +1 -1
  10. package/dist/language-model.d.ts +10 -9
  11. package/dist/language-model.js +390 -112
  12. package/dist/pricing-data.d.ts +6 -0
  13. package/dist/pricing-data.js +6 -0
  14. package/dist/pricing.d.ts +1 -1
  15. package/dist/pricing.js +1 -1
  16. package/dist/protocol/blob-reachability.d.ts +15 -0
  17. package/dist/protocol/blob-reachability.js +140 -0
  18. package/dist/protocol/blob-store.d.ts +37 -0
  19. package/dist/protocol/blob-store.js +115 -0
  20. package/dist/protocol/conversation-bind.d.ts +13 -0
  21. package/dist/protocol/conversation-bind.js +27 -1
  22. package/dist/protocol/conversation-persistence.d.ts +32 -0
  23. package/dist/protocol/conversation-persistence.js +504 -0
  24. package/dist/protocol/conversation-state.d.ts +16 -0
  25. package/dist/protocol/conversation-state.js +62 -0
  26. package/dist/protocol/kv.d.ts +1 -0
  27. package/dist/protocol/kv.js +2 -0
  28. package/dist/protocol/messages.d.ts +2 -0
  29. package/dist/protocol/messages.js +30 -2
  30. package/dist/protocol/request.d.ts +2 -0
  31. package/dist/protocol/request.js +2 -0
  32. package/dist/protocol/token-details.d.ts +33 -0
  33. package/dist/protocol/token-details.js +155 -0
  34. package/dist/protocol/tools.d.ts +17 -0
  35. package/dist/protocol/tools.js +76 -0
  36. package/dist/session.d.ts +39 -11
  37. package/dist/shared.d.ts +3 -0
  38. package/dist/shared.js +3 -0
  39. package/dist/usage.d.ts +42 -22
  40. package/dist/usage.js +200 -45
  41. package/package.json +2 -1
@@ -5,19 +5,22 @@ 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, 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
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";
14
+ import { clearCheckpoint, getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
15
+ import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
16
+ import { clearConversationBlobs, conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
17
+ import { bindConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
18
+ import { clearPersistedConversationState, hydrateConversationState, persistConversationState, } from "./protocol/conversation-state.js";
19
+ import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
17
20
  import { resolveContinuationPolicy, sessionManager, } from "./session.js";
18
21
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
19
22
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
20
- import { buildRequestContext } from "./context/build.js";
23
+ import { getOrBuildRequestContext } from "./context/frozen.js";
21
24
  import { workspaceRootFromRequestContext } from "./context/env.js";
22
25
  import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
23
26
  import { resolveAgentUrl } from "./agent-url.js";
@@ -29,7 +32,7 @@ import { resolveCursorModelSupportsImages } from "./model-metadata.js";
29
32
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
30
33
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
31
34
  import { readAllFieldsStrict } from "./protocol/struct.js";
32
- import { buildLanguageModelV3UsageFromEstimate, buildLanguageModelV3UsageFromTurnEnded, turnEndedCounter, } from "./usage.js";
35
+ import { buildLanguageModelV3UsageFromCounters, cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, turnEndedCounter, } from "./usage.js";
33
36
  let _availableModels;
34
37
  // mtime of the cache file the last time we loaded it. Compared on each call
35
38
  // so discoverModels' background refresh is picked up without a process restart.
@@ -56,6 +59,11 @@ const MAX_RETRY_DELAY_MS = 30_000;
56
59
  const RUN_REQUEST_DECODE_FAILED = "CURSOR_RUN_REQUEST_DECODE_FAILED";
57
60
  const RUN_REQUEST_UNSUPPORTED = "CURSOR_RUN_REQUEST_UNSUPPORTED";
58
61
  const RUN_REPLY_FAILED = "CURSOR_RUN_REPLY_FAILED";
62
+ /** Cursor's own conversation export path treats state above 100 MiB as large. */
63
+ export const MAX_CHECKPOINT_BLOB_GRAPH_BYTES = 100 * 1024 * 1024;
64
+ export function checkpointBlobGraphRequiresRebase(stats, maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
65
+ return !stats.complete || stats.bytes > maxBytes;
66
+ }
59
67
  const RESPONSE_REQUIRED_CHANNEL_BY_FIELD = new Map([
60
68
  [2, "exec"],
61
69
  [4, "kv"],
@@ -237,7 +245,7 @@ export function connectFrameError(payload) {
237
245
  }
238
246
  function rememberToolCatalog(sessionKey, tools) {
239
247
  toolCatalogBySession.delete(sessionKey);
240
- toolCatalogBySession.set(sessionKey, tools);
248
+ toolCatalogBySession.set(sessionKey, structuredClone(tools));
241
249
  while (toolCatalogBySession.size > MAX_TURN_STATE_SESSIONS) {
242
250
  const oldest = toolCatalogBySession.keys().next().value;
243
251
  if (!oldest)
@@ -245,6 +253,17 @@ function rememberToolCatalog(sessionKey, tools) {
245
253
  toolCatalogBySession.delete(oldest);
246
254
  }
247
255
  }
256
+ /** Restore the last real catalog solely for lifecycle turns such as compaction. */
257
+ export function restoreTurnToolCatalog(sessionKey, tools) {
258
+ if (!sessionKey || tools.length === 0)
259
+ return;
260
+ rememberToolCatalog(sessionKey, tools);
261
+ }
262
+ function snapshotToolCatalog(sessionKey) {
263
+ if (!sessionKey)
264
+ return [];
265
+ return structuredClone(toolCatalogBySession.get(sessionKey) ?? []);
266
+ }
248
267
  function rememberPostCompactionRebase(sessionKey) {
249
268
  postCompactionRebaseBySession.delete(sessionKey);
250
269
  postCompactionRebaseBySession.add(sessionKey);
@@ -292,6 +311,11 @@ export function createCursorLanguageModel(modelId, providerId, options) {
292
311
  // Host Path.cache / explicit override wins over XDG heuristics for this process.
293
312
  if (options.cacheDir)
294
313
  setHostCacheDirOverride(options.cacheDir);
314
+ // Start loading and pruning restart state as soon as the provider is built.
315
+ // startSession awaits this same per-cache-root initialization before binding.
316
+ void initializeConversationPersistence(opencodeGlobalCacheDir()).catch((error) => {
317
+ trace(`conversation persistence: startup load failed: ${String(error)}`);
318
+ });
295
319
  return {
296
320
  specificationVersion: "v3",
297
321
  provider: providerId,
@@ -327,7 +351,6 @@ async function doStreamImpl(modelId, options, callOptions) {
327
351
  baseUrl: resolveApiBaseURL(options),
328
352
  });
329
353
  const prompt = callOptions.prompt;
330
- const promptTokens = estimatePromptTokens(prompt);
331
354
  const retryPolicy = resolveRetryPolicy(options.retry);
332
355
  // pumpWithRecovery owns the complete per-turn attempt budget. Opening a
333
356
  // replacement session here must be a single attempt; otherwise setup retry
@@ -389,7 +412,6 @@ async function doStreamImpl(modelId, options, callOptions) {
389
412
  initialSession: activeSession,
390
413
  controller,
391
414
  abortSignal: callOptions.abortSignal,
392
- promptTokens,
393
415
  retryPolicy,
394
416
  recover: (recovery) => openSession({ recovery }),
395
417
  onSession: (next) => { activeSession = next; },
@@ -429,7 +451,6 @@ export async function pumpWithRecovery(input) {
429
451
  maxAttempts: (input.maxRecoveries ?? 1) + 1,
430
452
  };
431
453
  const maxRecoveries = retryPolicy.maxAttempts - 1;
432
- const requestUsage = { outputChars: 0 };
433
454
  input.onSession?.(session);
434
455
  for (let attempt = 0;; attempt++) {
435
456
  const pumpedSession = session;
@@ -439,8 +460,6 @@ export async function pumpWithRecovery(input) {
439
460
  await pump(pumpedSession, input.controller, {
440
461
  textId: crypto.randomUUID(),
441
462
  reasoningId: crypto.randomUUID(),
442
- promptTokens: input.promptTokens ?? 0,
443
- requestUsage,
444
463
  }, input.abortSignal);
445
464
  return session;
446
465
  }
@@ -473,7 +492,6 @@ export async function pumpWithRecovery(input) {
473
492
  : { kind: "rebase" };
474
493
  session = await input.recover(recovery);
475
494
  if (recovery.kind === "resume") {
476
- session.hadToolCallBoundary = pumpedSession.hadToolCallBoundary;
477
495
  session.usageEstimate = { ...pumpedSession.usageEstimate };
478
496
  session.editToolCalls = new Map(pumpedSession.editToolCalls);
479
497
  }
@@ -489,6 +507,17 @@ async function startSession(modelId, token, callOptions, options, startOptions)
489
507
  const prompt = callOptions.prompt;
490
508
  const incomingTools = extractTools(callOptions);
491
509
  const sessionKey = opencodeSessionKey(callOptions);
510
+ const cacheDir = opencodeGlobalCacheDir();
511
+ if (sessionKey) {
512
+ const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
513
+ trace(`conversation persistence: restore failed sessionKey=${sessionKey}: ${String(error)}`);
514
+ return undefined;
515
+ });
516
+ if (restored?.postCompactionRebase)
517
+ rememberPostCompactionRebase(sessionKey);
518
+ if (restored?.toolCatalog.length)
519
+ restoreTurnToolCatalog(sessionKey, restored.toolCatalog);
520
+ }
492
521
  const providerOptions = callOptions.providerOptions?.cursor;
493
522
  // The classic plugin marks OpenCode's agent="compaction" through chat.params.
494
523
  // OpenCode 2.0 removed that hook, so its plugin records the same fact against
@@ -513,17 +542,55 @@ async function startSession(modelId, token, callOptions, options, startOptions)
513
542
  const allowTools = toolState.allowTools;
514
543
  const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
515
544
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
516
- const recovery = startOptions?.recovery;
517
- const resuming = recovery?.kind === "resume";
545
+ let recovery = startOptions?.recovery;
546
+ let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
547
+ let resuming = !!resumeRecovery;
518
548
  // Compaction must not reuse the prior conversation; its first normal turn
519
549
  // must also rebase so the summary-agent checkpoint cannot replace the normal
520
550
  // system prompt and OpenCode's newly compacted history.
521
- const bound = resuming
522
- ? { conversationId: recovery.conversationId, reset: false, previousId: undefined }
551
+ let bound = resuming
552
+ ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
523
553
  : bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
554
+ let conversationState = resuming
555
+ ? resumeRecovery.checkpoint
556
+ : (bound.reset ? undefined : getCheckpoint(bound.conversationId));
557
+ let checkpointGraph = conversationState
558
+ ? inspectConversationBlobGraph(bound.conversationId, conversationState)
559
+ : { count: 0, bytes: 0, complete: true };
560
+ let forcedResetReason;
561
+ if (conversationState && checkpointBlobGraphRequiresRebase(checkpointGraph)) {
562
+ const previousConversationId = bound.conversationId;
563
+ forcedResetReason = checkpointGraph.complete
564
+ ? "oversized-checkpoint-graph"
565
+ : "incomplete-checkpoint-graph";
566
+ trace(`checkpoint state rejected: reason=${forcedResetReason} ` +
567
+ `conversationId=${previousConversationId} checkpointBytes=${conversationState.length} ` +
568
+ `blobCount=${checkpointGraph.count} blobBytes=${checkpointGraph.bytes} ` +
569
+ `limitBytes=${MAX_CHECKPOINT_BLOB_GRAPH_BYTES} ` +
570
+ `detail=${checkpointGraph.fallbackReason ?? "-"} action=rebase`);
571
+ // A recovery resume must become a history rebase so the interrupted user
572
+ // request remains in the seed. An ordinary turn keeps its normal user text.
573
+ if (recovery?.kind === "resume")
574
+ recovery = { kind: "rebase" };
575
+ resumeRecovery = undefined;
576
+ resuming = false;
577
+ if (!sessionKey) {
578
+ clearCheckpoint(previousConversationId);
579
+ clearConversationBlobs(previousConversationId);
580
+ }
581
+ bound = bindConversationId(sessionKey, { reset: true });
582
+ conversationState = undefined;
583
+ checkpointGraph = { count: 0, bytes: 0, complete: true };
584
+ }
524
585
  const conversationId = bound.conversationId;
586
+ const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
525
587
  if (bound.reset) {
526
- trace(`conversation reset: reason=${recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
588
+ if (sessionKey) {
589
+ await clearPersistedConversationState(cacheDir, sessionKey, bound.previousId).catch((error) => {
590
+ trace(`conversation persistence: reset cleanup failed sessionKey=${sessionKey}: ${String(error)}`);
591
+ });
592
+ }
593
+ trace(`conversation reset: reason=${forcedResetReason ?? (recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown"))} ` +
527
594
  `sessionKey=${sessionKey ?? "(none)"} ` +
528
595
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
529
596
  }
@@ -604,7 +671,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
604
671
  baseURL: agentBaseUrl,
605
672
  headers: options.headers,
606
673
  });
607
- const requestContext = await buildRequestContext({ workspaceRoot, tools: cursorTools });
674
+ // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
675
+ // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
676
+ const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools });
608
677
  const contextSubagents = Array.isArray(requestContext.custom_subagents)
609
678
  ? requestContext.custom_subagents
610
679
  .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
@@ -628,15 +697,14 @@ async function startSession(modelId, token, callOptions, options, startOptions)
628
697
  ? requestContext.tools
629
698
  : [];
630
699
  // 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));
700
+ // After compaction or an unsafe checkpoint reset there is no checkpoint —
701
+ // seed a new Cursor conversation from OpenCode's authoritative history.
635
702
  const reqBytes = buildRunRequest({
636
703
  text: userText,
637
704
  images,
638
705
  modelId: cursorModelId,
639
706
  conversationId,
707
+ conversationGroupId,
640
708
  systemPrompt: conversationState ? undefined : systemPrompt,
641
709
  history: conversationState ? undefined : history,
642
710
  conversationState,
@@ -657,14 +725,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
657
725
  ? requestContext.hooks_additional_context
658
726
  : "";
659
727
  const historyChars = history.reduce((n, m) => n + m.content.length, 0);
728
+ const seedChars = conversationState
729
+ ? 0
730
+ : (systemPrompt?.length ?? 0) + userText.length + historyChars;
731
+ const seedEstimateIn = estimateTokens(seedChars);
732
+ const checkpointEnvelopeEstimateIn = conversationState
733
+ ? estimateTokens(userText.length + conversationState.length)
734
+ : 0;
735
+ const runRequestWireEstimateIn = estimateTokens(reqBytes.length);
736
+ const encodedRequestContext = encodeMessage("RequestContext", requestContext);
737
+ const priorTokenDetails = decodeConversationTokenDetails(conversationState);
738
+ const requestContextHash = sha(encodedRequestContext);
739
+ const systemPromptHash = systemPrompt ? sha(systemPrompt) : undefined;
660
740
  const usageEstimate = {
661
- inputTokens: estimateTokens((systemPrompt?.length ?? 0) + userText.length + historyChars + (conversationState?.length ?? 0)),
741
+ // This is used only before Cursor supplies authoritative TurnEnded usage.
742
+ // Estimate the actual protobuf request, not history/system text omitted by
743
+ // checkpoint Runs. The opaque remote interpretation of KV blobs is logged
744
+ // separately and deliberately is not mislabeled as a token count.
745
+ inputTokens: runRequestWireEstimateIn,
662
746
  outputTokens: 0,
663
747
  cacheRead: 0,
664
748
  cacheWrite: 0,
665
749
  reasoningTokens: 0,
666
750
  };
667
- trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
751
+ trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} ` +
752
+ `conversationId=${conversationId} conversationGroupId=${conversationGroupId} ` +
668
753
  `params=${JSON.stringify(parameterValues ?? [])} ` +
669
754
  `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} ` +
670
755
  `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
@@ -672,14 +757,20 @@ async function startSession(modelId, token, callOptions, options, startOptions)
672
757
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
673
758
  `images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
674
759
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
675
- `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
676
- `resume=${resuming} ` +
677
- `usageEstimateIn=${usageEstimate.inputTokens} runRequestBytes=${reqBytes.length}`);
760
+ `checkpointLen=${conversationState?.length ?? 0} checkpointBlobCount=${checkpointGraph.count} ` +
761
+ `checkpointBlobBytes=${checkpointGraph.bytes} checkpointGraphComplete=${checkpointGraph.complete} ` +
762
+ `seedChars=${seedChars} seedEstimateIn=${seedEstimateIn} ` +
763
+ `checkpointEnvelopeEstimateIn=${checkpointEnvelopeEstimateIn} ` +
764
+ `reset=${bound.reset} ` +
765
+ `resume=${resuming} requestContextReused=${requestContextReused} ` +
766
+ `usageEstimateMode=run-request-wire usageEstimateIn=${usageEstimate.inputTokens} ` +
767
+ `runRequestBytes=${reqBytes.length} requestContextBytes=${encodedRequestContext.length}`);
678
768
  if (hooksCtx)
679
769
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
680
770
  trace(`hash run_request sha256=${sha(reqBytes)}`);
681
771
  if (systemPrompt)
682
772
  trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
773
+ trace(`hash requestContext sha256=${requestContextHash}`);
683
774
  if (conversationState)
684
775
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
685
776
  try {
@@ -693,8 +784,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
693
784
  const session = {
694
785
  sessionId: crypto.randomUUID(),
695
786
  conversationId,
787
+ cacheDir,
696
788
  resumeCheckpoint: undefined,
789
+ tokenDetails: priorTokenDetails,
790
+ tokenDetailsFresh: false,
791
+ cacheDiagnostics: {
792
+ sessionKey,
793
+ conversationId,
794
+ conversationGroupId,
795
+ modelId: cursorModelId,
796
+ priorTokenDetails,
797
+ startedWithCheckpoint: !!conversationState,
798
+ requestContextReused,
799
+ requestContextHash,
800
+ systemPromptHash,
801
+ checkpointUpdates: 0,
802
+ tokenDetailUpdates: 0,
803
+ pumpPasses: 0,
804
+ stepStarts: 0,
805
+ stepCompletes: 0,
806
+ displayToolCalls: 0,
807
+ execRequests: 0,
808
+ },
697
809
  openCodeSessionId: sessionKey,
810
+ postCompactionRebase: isCompaction,
811
+ toolCatalog: snapshotToolCatalog(sessionKey),
698
812
  stream,
699
813
  frames: stream.frames()[Symbol.asyncIterator](),
700
814
  pending: new Map(),
@@ -730,11 +844,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
730
844
  if (session.closed)
731
845
  return;
732
846
  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
- }));
847
+ // The pending write has its own bounded drain wait and will close the
848
+ // session with the actual failing operation. Do not race it with another
849
+ // heartbeat or misattribute an existing KV backlog to the heartbeat.
850
+ trace(`heartbeat skipped: prior heartbeat write is still pending sessionId=${session.sessionId}`);
738
851
  return;
739
852
  }
740
853
  heartbeatWritePending = true;
@@ -805,16 +918,39 @@ export function deliverContinuationResults(session, trailingToolResults) {
805
918
  || pending.resultField === "background_shell_spawn_result"
806
919
  ? consumeCursorShellResult(r.toolCallId, r.output)
807
920
  : 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
- });
921
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
922
+ const correlatedEditCallId = pending.resultMetadata?.correlatedEditCallId;
923
+ const requestedPath = pending.resultMetadata?.path;
924
+ const correlatedEdit = !r.error
925
+ && pending.resultField === "read_result"
926
+ && pending.toolName === "read"
927
+ && typeof correlatedEditCallId === "string"
928
+ && typeof requestedPath === "string"
929
+ ? session.editToolCalls?.get(correlatedEditCallId)
930
+ : undefined;
931
+ if (correlatedEdit) {
932
+ const absolutePath = path.resolve(workspaceRoot, requestedPath);
933
+ if (absolutePath === path.resolve(workspaceRoot, correlatedEdit.path)) {
934
+ frames = buildCompleteEditReadMessages(r.execId, absolutePath, requestedPath) ?? [];
935
+ if (frames.length > 0) {
936
+ correlatedEdit.completeRead = true;
937
+ trace(`continuation: upgraded authorized correlated edit read execId=${r.execId} ` +
938
+ `path=${JSON.stringify(requestedPath)}`);
939
+ }
940
+ }
941
+ }
942
+ if (frames.length === 0) {
943
+ frames = buildExecClientMessages({
944
+ execId: r.execId,
945
+ resultField: pending.resultField,
946
+ output: shellResult?.output ?? r.output,
947
+ error: r.error,
948
+ toolName: pending.toolName ?? r.toolName,
949
+ resultMetadata: pending.resultMetadata,
950
+ shellOutcome: shellResult?.outcome,
951
+ workspaceRoot,
952
+ });
953
+ }
818
954
  }
819
955
  catch (error) {
820
956
  trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
@@ -879,7 +1015,27 @@ function resolveExplicitAgentBaseURL(options) {
879
1015
  function isTruthyEnv(value) {
880
1016
  return value === "1" || value === "true";
881
1017
  }
1018
+ const streamWriteChains = new WeakMap();
1019
+ /**
1020
+ * Keep awaited protocol writes ordered per Run stream. In particular, a large
1021
+ * KV get_blob reply must drain before another KV reply or heartbeat is queued;
1022
+ * otherwise one heartbeat observes the backlog created by dozens of ignored
1023
+ * false write() results and reports the wrong operation as the failure.
1024
+ */
882
1025
  async function writeWithBackpressure(stream, message, operation) {
1026
+ const previous = streamWriteChains.get(stream);
1027
+ const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
1028
+ .then(() => writeWithBackpressureNow(stream, message, operation));
1029
+ streamWriteChains.set(stream, current);
1030
+ try {
1031
+ await current;
1032
+ }
1033
+ finally {
1034
+ if (streamWriteChains.get(stream) === current)
1035
+ streamWriteChains.delete(stream);
1036
+ }
1037
+ }
1038
+ async function writeWithBackpressureNow(stream, message, operation) {
883
1039
  let accepted;
884
1040
  try {
885
1041
  accepted = stream.write(message);
@@ -892,6 +1048,7 @@ async function writeWithBackpressure(stream, message, operation) {
892
1048
  }
893
1049
  if (accepted !== false)
894
1050
  return;
1051
+ trace(`stream write backpressured: operation=${operation} bytes=${message.length}`);
895
1052
  if (!stream.waitForDrain) {
896
1053
  throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
897
1054
  transient: false,
@@ -901,6 +1058,7 @@ async function writeWithBackpressure(stream, message, operation) {
901
1058
  }
902
1059
  try {
903
1060
  await stream.waitForDrain(5_000);
1061
+ trace(`stream write drained: operation=${operation} bytes=${message.length}`);
904
1062
  }
905
1063
  catch (cause) {
906
1064
  throw toCursorProviderError(cause, {
@@ -947,13 +1105,27 @@ async function nextFrameWithSemanticDeadline(session) {
947
1105
  */
948
1106
  export async function pump(session, controller, ids, abortSignal) {
949
1107
  sessionManager.registerSession(session);
1108
+ const cacheDiagnostics = session.cacheDiagnostics ??= {
1109
+ sessionKey: session.openCodeSessionId,
1110
+ conversationId: session.conversationId,
1111
+ priorTokenDetails: session.tokenDetails,
1112
+ startedWithCheckpoint: !!session.tokenDetails,
1113
+ requestContextReused: false,
1114
+ requestContextHash: "unavailable",
1115
+ checkpointUpdates: 0,
1116
+ tokenDetailUpdates: 0,
1117
+ pumpPasses: 0,
1118
+ stepStarts: 0,
1119
+ stepCompletes: 0,
1120
+ displayToolCalls: 0,
1121
+ execRequests: 0,
1122
+ };
1123
+ cacheDiagnostics.pumpPasses++;
950
1124
  const { textId, reasoningId } = ids;
951
- const promptTokens = ids.promptTokens ?? 0;
952
1125
  const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
953
1126
  const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
954
1127
  let textStarted = false;
955
1128
  let reasoningStarted = false;
956
- const requestUsage = ids.requestUsage ?? { outputChars: 0 };
957
1129
  const replaySafety = new AttemptReplaySafety(session.sessionId);
958
1130
  const failRunProtocol = (message, code) => {
959
1131
  replaySafety.markBarrier("unknown-or-malformed-frame");
@@ -961,6 +1133,10 @@ export async function pump(session, controller, ids, abortSignal) {
961
1133
  sessionManager.close(session, "remote-error", error);
962
1134
  throw error;
963
1135
  };
1136
+ const rethrowTransportWriteFailure = (error) => {
1137
+ if (error instanceof CursorProviderError && error.origin !== "protocol")
1138
+ throw error;
1139
+ };
964
1140
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
965
1141
  // in doStreamImpl). The frames iterator can still yield a final `done` after
966
1142
  // the cancel lands — controller.enqueue on a cancelled controller throws.
@@ -1016,13 +1192,17 @@ export async function pump(session, controller, ids, abortSignal) {
1016
1192
  }
1017
1193
  };
1018
1194
  /**
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.
1195
+ * Cursor's streamed edit handshake needs the complete target before it sends
1196
+ * the replacement through write_args. OpenCode's ordinary read tool caps at
1197
+ * 50 KB, so forwarding this private prerequisite read teaches Cursor's editor
1198
+ * that a large file ends at the cap and produces a destructive replacement.
1199
+ *
1200
+ * Answer a correlated workspace-file read directly up to Cursor's own 50 MB
1201
+ * edit limit. Missing targets remain empty-file successes so creation can
1202
+ * continue. External and otherwise ineligible reads retain OpenCode's normal
1203
+ * permission-aware path.
1024
1204
  */
1025
- const recoverMissingEditRead = (parsed, displayCallId) => {
1205
+ const recoverCorrelatedEditRead = (parsed, displayCallId) => {
1026
1206
  if (!displayCallId ||
1027
1207
  parsed.resultField !== "read_result" ||
1028
1208
  parsed.toolName !== "read" ||
@@ -1043,8 +1223,54 @@ export async function pump(session, controller, ids, abortSignal) {
1043
1223
  const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
1044
1224
  const resolvePath = (value) => path.resolve(workspaceRoot, value);
1045
1225
  const absolutePath = resolvePath(requestedPath);
1046
- if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
1226
+ if (absolutePath !== resolvePath(editPath))
1047
1227
  return false;
1228
+ let exists = true;
1229
+ try {
1230
+ fs.lstatSync(absolutePath);
1231
+ }
1232
+ catch (error) {
1233
+ const code = error && typeof error === "object" && "code" in error
1234
+ ? error.code
1235
+ : undefined;
1236
+ if (code !== "ENOENT")
1237
+ return false;
1238
+ exists = false;
1239
+ }
1240
+ if (exists) {
1241
+ // Do not bypass OpenCode's external-directory permission boundary, even
1242
+ // through a symlink rooted in the workspace.
1243
+ try {
1244
+ const realRoot = fs.realpathSync(workspaceRoot);
1245
+ const realTarget = fs.realpathSync(absolutePath);
1246
+ const relative = path.relative(realRoot, realTarget);
1247
+ if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1248
+ parsed.resultMetadata = {
1249
+ ...parsed.resultMetadata,
1250
+ correlatedEditCallId: displayCallId,
1251
+ };
1252
+ return false;
1253
+ }
1254
+ const frames = buildCompleteEditReadMessages(parsed.id, absolutePath, requestedPath);
1255
+ if (!frames)
1256
+ return false;
1257
+ for (const frame of frames)
1258
+ session.stream.write(frame);
1259
+ const editCall = session.editToolCalls?.get(displayCallId);
1260
+ if (editCall)
1261
+ editCall.completeRead = true;
1262
+ trace(`exec: correlated edit read completed directly id=${parsed.id} ` +
1263
+ `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
1264
+ return true;
1265
+ }
1266
+ catch {
1267
+ parsed.resultMetadata = {
1268
+ ...parsed.resultMetadata,
1269
+ correlatedEditCallId: displayCallId,
1270
+ };
1271
+ return false;
1272
+ }
1273
+ }
1048
1274
  try {
1049
1275
  for (const frame of buildExecClientMessages({
1050
1276
  execId: parsed.id,
@@ -1061,7 +1287,7 @@ export async function pump(session, controller, ids, abortSignal) {
1061
1287
  return true;
1062
1288
  }
1063
1289
  catch (e) {
1064
- const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
1290
+ const error = new Error(`Failed to recover Cursor edit read: ${e.message}`);
1065
1291
  trace(`exec: edit read recovery FAILED ${error.message}`);
1066
1292
  safeError(error);
1067
1293
  sessionManager.close(session);
@@ -1079,7 +1305,7 @@ export async function pump(session, controller, ids, abortSignal) {
1079
1305
  *
1080
1306
  * Read-only by construction — write/edit targets are never checked, so
1081
1307
  * legitimate file creation (path resolves, file absent) is never denied. Runs
1082
- * after recoverMissingEditRead, which has already converted the new-file edit
1308
+ * after recoverCorrelatedEditRead, which has already converted the edit
1083
1309
  * handshake read into an empty-file success. EACCES/EPERM and other stat
1084
1310
  * errors fall through to OpenCode so a genuine permission decision stands.
1085
1311
  */
@@ -1141,9 +1367,7 @@ export async function pump(session, controller, ids, abortSignal) {
1141
1367
  textStarted = true;
1142
1368
  }
1143
1369
  session.usageEstimate.outputTokens += estimateTokens(text.length);
1144
- if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
1145
- requestUsage.outputChars += text.length;
1146
- }
1370
+ safeEnqueue({ type: "text-delta", id: textId, delta: text });
1147
1371
  };
1148
1372
  const emitReasoning = (text) => {
1149
1373
  if (!text)
@@ -1154,33 +1378,32 @@ export async function pump(session, controller, ids, abortSignal) {
1154
1378
  reasoningStarted = true;
1155
1379
  }
1156
1380
  session.usageEstimate.outputTokens += estimateTokens(text.length);
1157
- if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
1158
- requestUsage.outputChars += text.length;
1159
- }
1381
+ safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
1160
1382
  };
1161
- const emitFinish = (te, reason) => {
1383
+ const emitFinish = (te, reason, settledUsage, settledSource) => {
1162
1384
  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 cumulativeTurnEnded = !!te && session.hadToolCallBoundary === true;
1168
- if (te && !cumulativeTurnEnded) {
1169
- session.usageEstimate = {
1170
- inputTokens: turnEndedCounter(te, "input_tokens"),
1171
- outputTokens: turnEndedCounter(te, "output_tokens"),
1172
- cacheRead: turnEndedCounter(te, "cache_read"),
1173
- cacheWrite: turnEndedCounter(te, "cache_write"),
1174
- reasoningTokens: turnEndedCounter(te, "reasoning_tokens"),
1175
- };
1176
- }
1177
1385
  const est = session.usageEstimate;
1178
- const usage = te && !cumulativeTurnEnded
1179
- ? buildLanguageModelV3UsageFromTurnEnded(te)
1180
- : buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
1181
- if (!te)
1182
- session.hadToolCallBoundary = true;
1183
- const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
1386
+ // OpenCode persists and sums every AI SDK finish. Cursor does not expose a
1387
+ // final context total at tool boundaries, so charging the growing estimate
1388
+ // there multiplies one held Run across all of its steps. Emit zero at
1389
+ // intermediate boundaries, then prefer checkpoint tokenDetails exactly once
1390
+ // when TurnEnded closes the held Run. Cursor CLI keeps context occupancy in
1391
+ // agentStore tokenDetails; it does not reinterpret TurnEnded as occupancy.
1392
+ const tokenDetails = te ? session.tokenDetails : undefined;
1393
+ const contextSource = tokenDetails
1394
+ ? session.tokenDetailsFresh
1395
+ ? "checkpoint-current-run"
1396
+ : "checkpoint-previous-turn"
1397
+ : undefined;
1398
+ const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
1399
+ const usage = settledUsage ?? (te
1400
+ ? tokenDetails
1401
+ ? buildLanguageModelV3UsageFromCounters(counters, { contextTotalTokens: tokenDetails.usedTokens })
1402
+ : emptyLanguageModelV3Usage()
1403
+ : emptyLanguageModelV3Usage());
1404
+ const providerMetadata = te
1405
+ ? cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
1406
+ : undefined;
1184
1407
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1185
1408
  ? String(reason.unified ?? "unknown")
1186
1409
  : String(reason);
@@ -1194,7 +1417,13 @@ export async function pump(session, controller, ids, abortSignal) {
1194
1417
  `rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
1195
1418
  `rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
1196
1419
  `rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
1197
- `source=${te ? (cumulativeTurnEnded ? "estimate-cumulative-turn-ended" : "turn_ended") : "estimate"}`);
1420
+ `source=${te
1421
+ ? settledSource ?? (contextSource ?? "unavailable")
1422
+ : "intermediate-zero"}`);
1423
+ if (counters) {
1424
+ trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
1425
+ trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
1426
+ }
1198
1427
  safeEnqueue({
1199
1428
  type: "finish",
1200
1429
  usage,
@@ -1325,9 +1554,20 @@ export async function pump(session, controller, ids, abortSignal) {
1325
1554
  if (checkpointRaw != null) {
1326
1555
  const bytes = normalizeCheckpointBytes(checkpointRaw);
1327
1556
  if (bytes && bytes.length > 0) {
1557
+ cacheDiagnostics.checkpointUpdates++;
1328
1558
  setCheckpoint(session.conversationId, bytes);
1329
1559
  session.resumeCheckpoint = Uint8Array.from(bytes);
1330
- trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}`);
1560
+ const tokenDetails = decodeConversationTokenDetails(bytes);
1561
+ if (tokenDetails) {
1562
+ cacheDiagnostics.tokenDetailUpdates++;
1563
+ session.tokenDetails = tokenDetails;
1564
+ session.tokenDetailsFresh = true;
1565
+ }
1566
+ const context = tokenDetails
1567
+ ? ` context=${tokenDetails.usedTokens}/${tokenDetails.maxTokens} ` +
1568
+ `categories=${formatCursorTokenCategories(tokenDetails)}`
1569
+ : " context=unavailable";
1570
+ trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}${context}`);
1331
1571
  }
1332
1572
  }
1333
1573
  if (iu?.text_delta) {
@@ -1338,11 +1578,25 @@ export async function pump(session, controller, ids, abortSignal) {
1338
1578
  }
1339
1579
  else if (iu?.turn_ended) {
1340
1580
  trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
1341
- emitFinish(iu.turn_ended, { unified: "stop", raw: undefined });
1581
+ const turnEnded = iu.turn_ended;
1582
+ if (session.openCodeSessionId) {
1583
+ await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
1584
+ sessionKey: session.openCodeSessionId,
1585
+ conversationId: session.conversationId,
1586
+ requestContext: session.requestContext,
1587
+ toolCatalog: session.toolCatalog ?? [],
1588
+ postCompactionRebase: session.postCompactionRebase,
1589
+ }).catch((error) => {
1590
+ trace(`conversation persistence: TurnEnded save failed ` +
1591
+ `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
1592
+ });
1593
+ }
1594
+ emitFinish(turnEnded, { unified: "stop", raw: undefined });
1342
1595
  sessionManager.close(session);
1343
1596
  return;
1344
1597
  }
1345
1598
  else if (iu?.tool_call_started) {
1599
+ cacheDiagnostics.displayToolCalls++;
1346
1600
  // Stash Cursor display ToolCall until exec claims it, or completed bridges it.
1347
1601
  const started = iu.tool_call_started;
1348
1602
  const callId = typeof started.call_id === "string" ? started.call_id : "";
@@ -1429,7 +1683,14 @@ export async function pump(session, controller, ids, abortSignal) {
1429
1683
  }
1430
1684
  }
1431
1685
  }
1686
+ else if (iu?.step_started) {
1687
+ cacheDiagnostics.stepStarts++;
1688
+ }
1689
+ else if (iu?.step_completed) {
1690
+ cacheDiagnostics.stepCompletes++;
1691
+ }
1432
1692
  else if (esm) {
1693
+ cacheDiagnostics.execRequests++;
1433
1694
  const esmId = esm.id ?? 0;
1434
1695
  if (esm.request_context_args) {
1435
1696
  // Server turn-setup probe (#10). Reply with full OpenCode-sourced context.
@@ -1445,10 +1706,11 @@ export async function pump(session, controller, ids, abortSignal) {
1445
1706
  }
1446
1707
  try {
1447
1708
  traceRequestContextPaths(`exec request_context reply id=${esmId}`, session.requestContext);
1448
- session.stream.write(buildRequestContextResult(esmId, session.requestContext));
1709
+ await writeWithBackpressure(session.stream, buildRequestContextResult(esmId, session.requestContext), `request-context reply id=${esmId}`);
1449
1710
  trace(`exec request_context: replied`);
1450
1711
  }
1451
- catch {
1712
+ catch (error) {
1713
+ rethrowTransportWriteFailure(error);
1452
1714
  failRunProtocol("Cursor request-context reply failed", RUN_REPLY_FAILED);
1453
1715
  }
1454
1716
  }
@@ -1461,10 +1723,11 @@ export async function pump(session, controller, ids, abortSignal) {
1461
1723
  ? stateArgs.server_identifiers.join(",")
1462
1724
  : "";
1463
1725
  try {
1464
- session.stream.write(buildMcpStateResult(esmId, stateArgs, session.requestContext));
1726
+ await writeWithBackpressure(session.stream, buildMcpStateResult(esmId, stateArgs, session.requestContext), `MCP-state reply id=${esmId}`);
1465
1727
  trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
1466
1728
  }
1467
- catch {
1729
+ catch (error) {
1730
+ rethrowTransportWriteFailure(error);
1468
1731
  failRunProtocol("Cursor MCP-state reply failed", RUN_REPLY_FAILED);
1469
1732
  }
1470
1733
  }
@@ -1475,10 +1738,11 @@ export async function pump(session, controller, ids, abortSignal) {
1475
1738
  const args = esm.list_mcp_resources_exec_args;
1476
1739
  const server = typeof args.server === "string" ? args.server : "";
1477
1740
  try {
1478
- session.stream.write(buildListMcpResourcesFallback(esmId));
1741
+ await writeWithBackpressure(session.stream, buildListMcpResourcesFallback(esmId), `list-MCP-resources reply id=${esmId}`);
1479
1742
  trace(`exec list_mcp_resources: replied id=${esmId} server=${server || "(all)"} success{resources:[]}`);
1480
1743
  }
1481
- catch {
1744
+ catch (error) {
1745
+ rethrowTransportWriteFailure(error);
1482
1746
  failRunProtocol("Cursor list_mcp_resources reply failed", RUN_REPLY_FAILED);
1483
1747
  }
1484
1748
  }
@@ -1488,10 +1752,11 @@ export async function pump(session, controller, ids, abortSignal) {
1488
1752
  const server = typeof args.server === "string" ? args.server : "";
1489
1753
  const uri = typeof args.uri === "string" ? args.uri : "";
1490
1754
  try {
1491
- session.stream.write(buildReadMcpResourceFallback(esmId, server, uri));
1755
+ await writeWithBackpressure(session.stream, buildReadMcpResourceFallback(esmId, server, uri), `read-MCP-resource reply id=${esmId}`);
1492
1756
  trace(`exec read_mcp_resource: replied id=${esmId} server=${server} uri=${uri} error{server not found}`);
1493
1757
  }
1494
- catch {
1758
+ catch (error) {
1759
+ rethrowTransportWriteFailure(error);
1495
1760
  failRunProtocol("Cursor read_mcp_resource reply failed", RUN_REPLY_FAILED);
1496
1761
  }
1497
1762
  }
@@ -1509,6 +1774,11 @@ export async function pump(session, controller, ids, abortSignal) {
1509
1774
  const editCall = displayCallId
1510
1775
  ? session.editToolCalls?.get(displayCallId)
1511
1776
  : undefined;
1777
+ // A complete correlated edit read is trusted provenance: unlike an
1778
+ // ordinary capped read it cannot have appended our warning marker,
1779
+ // and the source itself may legitimately contain the marker text.
1780
+ if (!editCall?.completeRead)
1781
+ rejectPartialReadMutation(parsed);
1512
1782
  if (editCall) {
1513
1783
  const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
1514
1784
  if (parsed.resultField === "write_result" && displayCallId) {
@@ -1560,7 +1830,7 @@ export async function pump(session, controller, ids, abortSignal) {
1560
1830
  return;
1561
1831
  continue;
1562
1832
  }
1563
- if (recoverMissingEditRead(parsed, displayCallId))
1833
+ if (recoverCorrelatedEditRead(parsed, displayCallId))
1564
1834
  continue;
1565
1835
  if (rejectMissingReadTarget(parsed)) {
1566
1836
  if (displayCallId)
@@ -1621,11 +1891,12 @@ export async function pump(session, controller, ids, abortSignal) {
1621
1891
  if (handled.outcome === "acknowledged") {
1622
1892
  replaySafety.markBarrier("stateful-interaction");
1623
1893
  }
1624
- session.stream.write(handled.reply);
1894
+ await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
1625
1895
  trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
1626
1896
  `field=${handled.variantField} outcome=${handled.outcome}`);
1627
1897
  }
1628
- catch {
1898
+ catch (error) {
1899
+ rethrowTransportWriteFailure(error);
1629
1900
  failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
1630
1901
  }
1631
1902
  }
@@ -1640,12 +1911,14 @@ export async function pump(session, controller, ids, abortSignal) {
1640
1911
  const handled = handleKvServerMessage(kv, session);
1641
1912
  if (handled) {
1642
1913
  try {
1643
- session.stream.write(handled.reply);
1914
+ await writeWithBackpressure(session.stream, handled.reply, `KV ${handled.kind}_blob reply id=${handled.id}`);
1644
1915
  trace(`kv replied: kind=${handled.kind} id=${handled.id} blobId=${handled.blobIdHex.slice(0, 16)}… ` +
1645
1916
  `found=${handled.found} echoed=${!!handled.echoed} ` +
1917
+ `replyBytes=${handled.reply.length} replyBlobBytes=${handled.replyBlobBytes} ` +
1646
1918
  `sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
1647
1919
  }
1648
- catch {
1920
+ catch (error) {
1921
+ rethrowTransportWriteFailure(error);
1649
1922
  failRunProtocol("Cursor KV reply failed", RUN_REPLY_FAILED);
1650
1923
  }
1651
1924
  }
@@ -1655,7 +1928,7 @@ export async function pump(session, controller, ids, abortSignal) {
1655
1928
  }
1656
1929
  }
1657
1930
  catch (e) {
1658
- if (e instanceof CursorProtocolError)
1931
+ if (e instanceof CursorProviderError)
1659
1932
  throw e;
1660
1933
  if (requiredChannel) {
1661
1934
  failRunProtocol(`Cursor ${requiredChannel} request could not be handled`, RUN_REQUEST_UNSUPPORTED);
@@ -1822,6 +2095,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1822
2095
  // leaving the turn with no file-editing guidance at all.
1823
2096
  instructions.push("- Use OpenCode `apply_patch` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to `apply_patch` automatically.");
1824
2097
  }
2098
+ if (names.has("edit") || names.has("write") || names.has("apply_patch")) {
2099
+ instructions.push("- Never use a read result as complete file content when it says the output is capped, partial, or requires another offset. Read the remaining ranges first, or make a targeted edit/patch from complete context; do not pass a partial read back as a whole-file replacement.");
2100
+ }
1825
2101
  return [
1826
2102
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1827
2103
  `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.`,
@@ -1841,25 +2117,19 @@ export function estimateTokens(chars) {
1841
2117
  return 0;
1842
2118
  return Math.ceil(chars / 4);
1843
2119
  }
1844
- /** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
1845
- export function estimatePromptTokens(prompt) {
1846
- const serializedContent = prompt
1847
- .map((message) => typeof message.content === "string"
1848
- ? message.content
1849
- : (JSON.stringify(message.content) ?? ""))
1850
- .join("\n");
1851
- return estimateTokens(serializedContent.length);
1852
- }
1853
- /** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
1854
- export function cursorTurnEndedProviderMetadata(te) {
2120
+ /** Preserve exact request-local Cursor counters as diagnostics. */
2121
+ export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource) {
1855
2122
  return {
1856
2123
  cursor: {
1857
- usageVersion: 2,
2124
+ usageVersion: 3,
1858
2125
  inputTokensRaw: turnEndedCounter(te, "input_tokens"),
1859
2126
  outputTokensRaw: turnEndedCounter(te, "output_tokens"),
1860
2127
  cacheReadRaw: turnEndedCounter(te, "cache_read"),
1861
2128
  cacheWriteRaw: turnEndedCounter(te, "cache_write"),
1862
2129
  reasoningTokensRaw: turnEndedCounter(te, "reasoning_tokens"),
2130
+ ...(tokenDetails
2131
+ ? { context: cursorContextUsageMetadata(tokenDetails, contextSource) }
2132
+ : {}),
1863
2133
  },
1864
2134
  };
1865
2135
  }
@@ -2072,6 +2342,7 @@ function foldStreamParts(parts) {
2072
2342
  let reasoning = "";
2073
2343
  const content = [];
2074
2344
  let finishReason = { unified: "stop", raw: undefined };
2345
+ let providerMetadata;
2075
2346
  let usage = {
2076
2347
  inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
2077
2348
  outputTokens: { total: undefined, text: undefined, reasoning: undefined },
@@ -2092,11 +2363,18 @@ function foldStreamParts(parts) {
2092
2363
  else if (part.type === "finish") {
2093
2364
  finishReason = part.finishReason;
2094
2365
  usage = part.usage;
2366
+ providerMetadata = part.providerMetadata;
2095
2367
  }
2096
2368
  }
2097
2369
  if (reasoning)
2098
2370
  content.unshift({ type: "reasoning", text: reasoning });
2099
2371
  if (text)
2100
2372
  content.unshift({ type: "text", text });
2101
- return { content, finishReason, usage, warnings: [] };
2373
+ return {
2374
+ content,
2375
+ finishReason,
2376
+ usage,
2377
+ warnings: [],
2378
+ ...(providerMetadata ? { providerMetadata } : {}),
2379
+ };
2102
2380
  }