cursor-opencode-provider 0.8.1 → 0.8.3

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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -2,20 +2,29 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { APICallError } from "@ai-sdk/provider";
5
+ import protobuf from "protobufjs";
6
+ import { newRetryBudget, turnRetryBudget, forgetRetryBudget, reserveRetry, resetRetryBudgetsForTests } from "./retry-budget.js";
5
7
  import { PARALLEL_STEP_IDLE_MS, createParallelStep, isParallelStepProgressFrame, noteListedCallCount, noteParallelStepProgress, noteToolRequestsListedSeen, parallelStepCountMet, recordParallelStepEmission, resolveParallelStepCall, shouldGuardCloseParallelStep, shouldHoldParallelStep, } from "./parallel-step.js";
6
- import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
8
+ import { bidiRunStream, CursorRunInterruptedError, frameArrival, lastFrameArrival, normalizeAgentRunOrigin, } from "./transport/connect.js";
9
+ import { isStreamWriteBlocked, StreamWriteDrainError, waitForStreamWrites, writeStreamMessage as writeWithBackpressure, } from "./stream-write.js";
10
+ import { clearUnansweredEarlySteers, forgetEarlySteerRecords, forgetUndeliveredEarlySteer, handOverEarlySteers, isEarlySteerTracked, needsHostHistory, listenForHostSteers, markEarlySteerDelivered, markEarlySteersCheckpointed, matchEarlySteers, recordEarlySteer, resetHostSteersForTests, settleEarlySteers, serializeEarlySteers, } from "./host-steer.js";
7
11
  import { isDebugEnabled, trace, traceRequestContextPaths } from "./debug.js";
12
+ import { toolResultFailure } from "./opencode2/tool-results.js";
8
13
  import { isExchangeableApiKey } from "./auth.js";
9
14
  import { resolveBearerToken } from "./auth-renewal.js";
10
- import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
15
+ import { buildRunRequest, buildHeartbeat, buildCancelAction, buildExecHeartbeat } from "./protocol/request.js";
16
+ import { onHostInterrupt } from "./host-interrupt.js";
11
17
  import { decodeFramePayload } from "./protocol/framing.js";
12
18
  import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/messages.js";
13
- import { parseExecServerMessage, missingRequiredToolArguments, misplacedShellCommand, buildToolCallPart, buildExecClientMessages, execResultImages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, toolsToDescriptors, resolveToolServerIdentity, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, preferCorrelatedTaskDescription, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, hostToolDialectFromTools, opencodePathArg, } from "./protocol/tools.js";
19
+ import { parseExecServerMessage, missingRequiredToolArguments, misplacedShellCommand, buildToolCallPart, buildExecClientMessages, attachHookAdditionalContexts, extractOpenCodeReadInstruction, execResultImages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, toolsToDescriptors, resolveToolServerIdentity, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, preferCorrelatedTaskDescription, appendTaskAttachments, buildTaskAttachmentReadMessages, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, hostToolForCursorEdit, cursorEditReadAnswer, cursorEditReadReachesHost, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, hostToolDialectFromTools, opencodePathArg, } from "./protocol/tools.js";
20
+ import { hostPatchTool, isHostPatchTool } from "./protocol/apply-patch.js";
14
21
  import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
22
+ import { runtimeExecMessages, runtimeRootFromContext } from "./runtime-files.js";
23
+ import { createManagedTerminal } from "./terminal-runner.js";
15
24
  import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
16
25
  import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
17
26
  import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
18
- import { advertisedToolNamesFromDescriptors, displayNativeDiscoveryError, displayMcpToolError, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, isNativeDisplayToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
27
+ import { advertisedToolNamesFromDescriptors, displayNativeDiscoveryError, displayMcpToolError, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, taskToolCallAttachments, resolveBridgedOpenCodeToolCall, isNativeDisplayToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
19
28
  import { handleKvServerMessage } from "./protocol/kv.js";
20
29
  import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
21
30
  import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
@@ -27,15 +36,18 @@ import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, }
27
36
  import { stageCursorImage } from "./image-staging.js";
28
37
  import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
29
38
  import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
30
- import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
39
+ import { EARLY_ROW_CLOSED, EarlyToolRows, cursorEditAnnouncement, earlyRowHandoff, earlyRowToolCallId, parseEarlyRowToolCallId, shouldOpenEarlyRow, } from "./early-tool-rows.js";
40
+ import { carryLostRequest, dropLostRequest, hasLostRequest, noteCheckpoint, rememberLostRequest, } from "./lost-request.js";
41
+ import { currentCursorTokenBreakdown, cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
31
42
  import { conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
32
43
  import { bindConversationId, peekConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
33
- import { beginEmittedStep, detectForeignHistory, recordEmittedPart, trackTurnProvenance, } from "./protocol/turn-provenance.js";
44
+ import { beginEmittedStep, detectForeignHistory, finishEmittedStep, recordEmittedPart, resumeEmittedStep, trackTurnProvenance, } from "./protocol/turn-provenance.js";
34
45
  import { clearPersistedConversationState, hydrateConversationState, hydrateTurnProvenance, persistConversationState, } from "./protocol/conversation-state.js";
46
+ import { confirmHistoryImagesAtTurnEnd, forgetHeldHistoryImages, heldHistoryImageHashes, noteHistoryImageCheckpoint, noteHistoryImagesSent, resetHeldHistoryImagesForTests, runHistoryImages, } from "./protocol/sent-history-images.js";
35
47
  import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
36
- import { resolveContinuationPolicy, readSessionFrame, sessionManager, } from "./session.js";
37
- import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isRejectedCredentialError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
38
- import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
48
+ import { resolveContinuationPolicy, readSessionFrame, sessionManager, stoppedWith, } from "./session.js";
49
+ import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorServerError, CursorTransportError, hostFacingError, hostRetryError, isRejectedCredentialError, isTransientGrpcStatus, noProgressResumeError, retrySuppressedError, toCursorProviderError, } from "./errors.js";
50
+ import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId, parseCursorContextLimit } from "./models.js";
39
51
  import { getFrozenRequestContext, getOrBuildRequestContext, resetFrozenRequestContextsForTests } from "./context/frozen.js";
40
52
  import { systemInstructionsRuleText } from "./context/build.js";
41
53
  import { loadMergedConfig } from "./context/rules.js";
@@ -49,10 +61,12 @@ import { isCompactionSession } from "./compaction-marker.js";
49
61
  import { resolveSessionWorkspaceRoot } from "./session-directory.js";
50
62
  import { assertCursorUserImageSupport, extractCursorPromptImages, extractCursorToolResultImages, MAX_CURSOR_IMAGE_INPUT_BYTES, } from "./image-input.js";
51
63
  import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages } from "./model-metadata.js";
52
- import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
64
+ import { CURSOR_TIMEOUT_BACKGROUND, consumeCursorShellResult, registerCursorShellCall, resolveCursorShellPath, shellPolicyFromMetadata, } from "./shell-timeout.js";
65
+ import { decodePersistedHostNotes, encodePersistedHostNotes, hostNoteHookContexts, hostNoteText, wrapHostNotesForCursor, } from "./protocol/host-notes.js";
53
66
  import { analyzeReplayFrame, AttemptReplaySafety, describeFrameLayout } from "./replay-safety.js";
54
67
  import { readAllFieldsStrict } from "./protocol/struct.js";
55
- import { cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, occupancyUsageFromTokenDetails, occupancyValidationCounters, OPENCODE_DISPLAY_ONLY_COST_METADATA, turnEndedCounter, } from "./usage.js";
68
+ import { cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatFinishTrace, formatTurnUsageValidation, missingRulesWarning, occupancyValidationCounters, turnEndedCounter, } from "./usage.js";
69
+ import { billingLedger, cursorRunCost, hostStepPrices, nanoAiuForUsd, shapeStepUsage, turnEndedCostUsd, } from "./billing.js";
56
70
  /** Unknown frame layouts already traced in this process; each is logged once. */
57
71
  const unknownFrameLayouts = new Set();
58
72
  let _availableModels;
@@ -63,7 +77,17 @@ let _availableModelsMtimeMs = -1;
63
77
  // session so the new Cursor conversation is still born with tool definitions;
64
78
  // execution remains disabled for the summary turn itself.
65
79
  const toolCatalogBySession = new Map();
66
- const sentHistoryImageHashesBySession = new Map();
80
+ /** Host note bodies a turn ended without delivering, for the session's next user turn. */
81
+ let nextHostNoteOrder = 0;
82
+ const undeliveredHostNotesBySession = new Map();
83
+ /**
84
+ * Host notes injected into a Run and not yet acknowledged, per OpenCode session
85
+ * and injection id. Cleared on `delivered`; anything else returns the notes to
86
+ * `undeliveredHostNotesBySession`, as Cursor SDK `Run.steer` reverts to a
87
+ * follow-up. The owning Run is kept so notes are released only once it can no
88
+ * longer deliver them.
89
+ */
90
+ const hostNoteInjectionsBySession = new Map();
67
91
  // A compaction Run uses its own summary-agent system prompt. Its opaque Cursor
68
92
  // checkpoint must never become the base for the resumed normal agent: doing so
69
93
  // suppresses OpenCode's compacted prompt/system seed and makes Cursor narrate
@@ -71,9 +95,7 @@ const sentHistoryImageHashesBySession = new Map();
71
95
  const postCompactionRebaseBySession = new Set();
72
96
  const promptIdentityBySession = new Map();
73
97
  export const MAX_TURN_STATE_SESSIONS = 256;
74
- const MAX_SENT_HISTORY_IMAGES_PER_SESSION = 256;
75
98
  const DEFAULT_RETRY_POLICY = {
76
- maxAttempts: 3,
77
99
  baseDelayMs: 500,
78
100
  maxDelayMs: 8_000,
79
101
  };
@@ -162,10 +184,10 @@ export function resolveRetryPolicy(options) {
162
184
  throw new CursorProtocolError(`Unknown Cursor retry option: ${key}`);
163
185
  }
164
186
  }
165
- const maxAttempts = retryInteger("maxAttempts", options?.maxAttempts, DEFAULT_RETRY_POLICY.maxAttempts);
187
+ const maxAttempts = options?.maxAttempts === undefined ? undefined : retryInteger("maxAttempts", options.maxAttempts, 1);
166
188
  const baseDelayMs = retryInteger("baseDelayMs", options?.baseDelayMs, DEFAULT_RETRY_POLICY.baseDelayMs);
167
189
  const maxDelayMs = retryInteger("maxDelayMs", options?.maxDelayMs, DEFAULT_RETRY_POLICY.maxDelayMs);
168
- if (maxAttempts > MAX_RETRY_ATTEMPTS) {
190
+ if (maxAttempts !== undefined && maxAttempts > MAX_RETRY_ATTEMPTS) {
169
191
  throw new CursorProtocolError(`Cursor retry maxAttempts must be no greater than ${MAX_RETRY_ATTEMPTS}`);
170
192
  }
171
193
  if (baseDelayMs > MAX_RETRY_DELAY_MS || maxDelayMs > MAX_RETRY_DELAY_MS) {
@@ -272,18 +294,97 @@ function retryInfoProtobufDelayMs(encoded) {
272
294
  return undefined;
273
295
  return Math.ceil(Number(seconds) * 1_000 + Number(nanos) / 1_000_000);
274
296
  }
297
+ /**
298
+ * Decode `aiserver.v1.ErrorDetails`: error #1 and details #2 (title #1,
299
+ * detail #2, is_retryable #4, analytics_metadata #10 { action_required #1 }).
300
+ */
301
+ function decodeCursorErrorDetails(encoded) {
302
+ try {
303
+ const outer = protobuf.Reader.create(Buffer.from(encoded, "base64"));
304
+ const details = { error: 0 };
305
+ while (outer.pos < outer.len) {
306
+ const tag = outer.uint32();
307
+ if (tag === 0x08) {
308
+ details.error = outer.int32();
309
+ continue;
310
+ }
311
+ if (tag !== 0x12) {
312
+ outer.skipType(tag & 7);
313
+ continue;
314
+ }
315
+ const inner = protobuf.Reader.create(outer.bytes());
316
+ while (inner.pos < inner.len) {
317
+ const innerTag = inner.uint32();
318
+ if (innerTag === 0x0a)
319
+ details.title = inner.string().trim() || undefined;
320
+ else if (innerTag === 0x12)
321
+ details.detail = inner.string().trim() || undefined;
322
+ else if (innerTag === 0x20)
323
+ details.isRetryable = inner.bool();
324
+ else if (innerTag === 0x52) {
325
+ const analytics = protobuf.Reader.create(inner.bytes());
326
+ while (analytics.pos < analytics.len) {
327
+ const analyticsTag = analytics.uint32();
328
+ if (analyticsTag === 0x0a)
329
+ details.actionRequired = analytics.string() || undefined;
330
+ else
331
+ analytics.skipType(analyticsTag & 7);
332
+ }
333
+ }
334
+ else
335
+ inner.skipType(innerTag & 7);
336
+ }
337
+ }
338
+ return details;
339
+ }
340
+ catch {
341
+ return undefined;
342
+ }
343
+ }
344
+ // `ErrorDetails.Error` values Cursor CLI never retries: cancellation
345
+ // (USER_ABORTED_REQUEST, DEBOUNCED) and login, usage/rate limit, usage pricing
346
+ // and API-key/outdated-client actions.
347
+ const CURSOR_FINAL_ERRORS = new Set([
348
+ 21, 35,
349
+ 2, 18, 11, 12, 3, 38, 46, 47,
350
+ 9, 7, 23, 10, 8, 50, 51, 22,
351
+ 44, 45,
352
+ 1, 42, 30,
353
+ ]);
354
+ const CURSOR_ERROR_CUSTOM_MESSAGE = 29;
355
+ /**
356
+ * Cursor CLI's retry verdict for a Connect error (its `ErrorDetails`
357
+ * classifier): cancellation, a required user action, an explicit
358
+ * `isRetryable: false`, an unflagged custom message, or a bare
359
+ * `permission_denied` end the request; anything else may be retried.
360
+ */
361
+ function cursorCliRetryable(code, details) {
362
+ if (code === "canceled" || code === "aborted")
363
+ return false;
364
+ if (details) {
365
+ if (CURSOR_FINAL_ERRORS.has(details.error) || details.actionRequired)
366
+ return false;
367
+ if (details.error === CURSOR_ERROR_CUSTOM_MESSAGE && details.isRetryable !== true)
368
+ return false;
369
+ if (details.isRetryable !== undefined)
370
+ return details.isRetryable;
371
+ }
372
+ if (code === "permission_denied")
373
+ return false;
374
+ return undefined;
375
+ }
275
376
  export function connectFrameError(payload) {
276
377
  try {
277
378
  const envelope = JSON.parse(payload);
278
379
  const code = typeof envelope.error?.code === "string" ? envelope.error.code : "unknown";
279
- if (code === "unauthenticated" || code === "permission_denied") {
280
- return new CursorAuthError(`Cursor authentication failed (${code}); reauthenticate with Cursor`, {
281
- code,
282
- replaySafe: code === "unauthenticated",
283
- });
380
+ // Hosts classify retries from the error text, so Cursor's own message and
381
+ // details (the specific reason) stay in the log.
382
+ if (envelope.error?.message !== undefined || envelope.error?.details !== undefined) {
383
+ trace(`connect error: code=${code} message=${JSON.stringify(envelope.error.message)?.slice(0, 2_000)} details=${JSON.stringify(envelope.error.details)?.slice(0, 4_000)}`);
284
384
  }
285
385
  let retryAfterMs = retryDelayFromValue(envelope.error?.retryAfter ?? envelope.error?.retry_after);
286
386
  let hasRetryInfo = false;
387
+ let cursorDetails;
287
388
  if (Array.isArray(envelope.error?.details)) {
288
389
  for (const detail of envelope.error.details) {
289
390
  if (!detail || typeof detail !== "object")
@@ -294,10 +395,22 @@ export function connectFrameError(payload) {
294
395
  hasRetryInfo = true;
295
396
  retryAfterMs ??= retryDelayFromValue(record.retryDelay ?? record.retry_delay ?? record.value);
296
397
  }
398
+ if (type === "aiserver.v1.ErrorDetails" && typeof record.value === "string") {
399
+ cursorDetails ??= decodeCursorErrorDetails(record.value);
400
+ }
297
401
  }
298
402
  }
299
- return new CursorServerError(`Cursor API error (code=${code})`, {
300
- transient: isTransientGrpcStatus(code) || hasRetryInfo,
403
+ // Cursor CLI shows `${title} ${detail}` and follows its own retry verdict.
404
+ const explanation = [cursorDetails?.title, cursorDetails?.detail].filter(Boolean).join(" ");
405
+ const retryable = cursorCliRetryable(code, cursorDetails);
406
+ // Cursor's final details take precedence over the Connect login code.
407
+ // Only a bare credential rejection is eligible for one forced renewal.
408
+ if (code === "unauthenticated" && retryable !== false) {
409
+ return new CursorAuthError(explanation || `Cursor authentication failed (${code}); reauthenticate with Cursor`, { code, replaySafe: true });
410
+ }
411
+ return new CursorServerError(`Cursor API error (code=${code})${explanation ? `: ${explanation}` : ""}`, {
412
+ transient: retryable ?? (isTransientGrpcStatus(code) || hasRetryInfo),
413
+ retryable,
301
414
  replaySafe: true,
302
415
  code,
303
416
  retryAfterMs: retryAfterMs === undefined
@@ -406,62 +519,92 @@ function rememberPromptIdentity(sessionKey, value) {
406
519
  export function promptIdentityWouldRemint(_previous, _current) {
407
520
  return false;
408
521
  }
409
- function sentHistoryImageHashes(sessionKey) {
410
- if (!sessionKey)
411
- return undefined;
412
- const hashes = sentHistoryImageHashesBySession.get(sessionKey);
413
- if (!hashes)
414
- return undefined;
415
- sentHistoryImageHashesBySession.delete(sessionKey);
416
- sentHistoryImageHashesBySession.set(sessionKey, hashes);
417
- return hashes;
522
+ function noteRunImageCheckpoint(session, checkpoint, frame) {
523
+ if (!session.historyImages || !session.openCodeSessionId)
524
+ return;
525
+ noteHistoryImageCheckpoint(session.historyImages, session.openCodeSessionId, session.conversationId, checkpoint, frameArrival(frame));
526
+ }
527
+ /** A resumed model step cannot replay a result or follow-up absent from its checkpoint. */
528
+ function invalidateResumeCheckpoint(session, afterFrame = lastFrameArrival(), hostResult = false) {
529
+ session.resumeCheckpoint = undefined;
530
+ // A later result cannot make an earlier uncheckpointed follow-up appear
531
+ // represented in this call's host history. Only a new checkpoint clears it.
532
+ session.checkpointRevokedByHostResult = hostResult
533
+ && (!session.checkpointRevoked || session.checkpointRevokedByHostResult === true);
534
+ session.checkpointRevoked = true;
535
+ session.resumeCheckpointAfterFrame = afterFrame;
418
536
  }
419
- function rememberSentHistoryImageHashes(sessionKey, hashes) {
420
- if (!sessionKey || hashes.length === 0)
537
+ /** A queued checkpoint predating a delivered result still describes the old step. */
538
+ function acceptResumeCheckpoint(session, bytes, frame) {
539
+ const arrived = frameArrival(frame);
540
+ if (session.resumeCheckpointAfterFrame !== undefined && arrived !== undefined
541
+ && arrived <= session.resumeCheckpointAfterFrame)
421
542
  return;
422
- const remembered = sentHistoryImageHashesBySession.get(sessionKey) ?? new Set();
423
- for (const hash of hashes) {
424
- remembered.delete(hash);
425
- remembered.add(hash);
426
- while (remembered.size > MAX_SENT_HISTORY_IMAGES_PER_SESSION) {
427
- const oldest = remembered.values().next().value;
428
- if (!oldest)
429
- break;
430
- remembered.delete(oldest);
431
- }
432
- }
433
- sentHistoryImageHashesBySession.delete(sessionKey);
434
- sentHistoryImageHashesBySession.set(sessionKey, remembered);
435
- while (sentHistoryImageHashesBySession.size > MAX_TURN_STATE_SESSIONS) {
436
- const oldest = sentHistoryImageHashesBySession.keys().next().value;
437
- if (!oldest)
438
- break;
439
- sentHistoryImageHashesBySession.delete(oldest);
440
- }
543
+ session.resumeCheckpoint = Uint8Array.from(bytes);
544
+ session.checkpointRevoked = false;
545
+ session.checkpointRevokedByHostResult = false;
546
+ session.resumeCheckpointAfterFrame = undefined;
441
547
  }
442
- /** Test helper: content hashes recorded after a successful held-Run image write. */
443
- export function snapshotSentHistoryImageHashesForTests(sessionKey) {
444
- return [...(sentHistoryImageHashesBySession.get(sessionKey) ?? [])];
548
+ /** `resumeCheckpoint` is set only by a checkpoint this Run received. */
549
+ function confirmRunImagesAtTurnEnd(session) {
550
+ if (!session.historyImages || !session.openCodeSessionId)
551
+ return;
552
+ confirmHistoryImagesAtTurnEnd(session.historyImages, session.openCodeSessionId, session.conversationId, session.resumeCheckpoint);
553
+ }
554
+ /** Test helper: the release `startSession` performs, then the host-note stores. */
555
+ export function releaseHostNoteInjectionsForTests(sessionKey) {
556
+ releaseHostNoteInjections(sessionKey, "run-closed");
557
+ return {
558
+ inFlight: [...(hostNoteInjectionsBySession.get(sessionKey)?.values() ?? [])].map((entry) => entry.notes),
559
+ undelivered: undeliveredHostNotes(sessionKey),
560
+ };
445
561
  }
446
562
  export function createCursorLanguageModel(modelId, providerId, options) {
447
563
  // Host Path.cache / explicit override wins over XDG heuristics for this process.
448
564
  if (options.cacheDir)
449
565
  setHostCacheDirOverride(options.cacheDir);
450
- // Start loading and pruning restart state as soon as the provider is built.
451
- // startSession awaits this same per-cache-root initialization before binding.
566
+ // Prepare restart storage and schedule bounded background maintenance. Session
567
+ // hydration awaits directory readiness only, never the directory-wide sweep.
452
568
  void initializeConversationPersistence(opencodeGlobalCacheDir()).catch((error) => {
453
569
  trace(`conversation persistence: startup load failed: ${String(error)}`);
454
570
  });
571
+ // Credential resolution and session setup can fail before a stream exists.
572
+ // Both public entrypoints must project those failures just like pump errors.
573
+ const doStream = async (callOptions) => {
574
+ const retryPolicy = resolveRetryPolicy(options.retry);
575
+ const retryBudget = turnRetryBudget(opencodeSessionKey(callOptions), retryTurnKey(callOptions.prompt), retryTurnKey(callOptions.prompt, true));
576
+ try {
577
+ return await doStreamImpl(modelId, options, callOptions, retryPolicy, retryBudget);
578
+ }
579
+ catch (error) {
580
+ const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor Run setup failed" });
581
+ try {
582
+ if (callOptions.abortSignal?.aborted) {
583
+ forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget);
584
+ throw new CursorLocalCancellationError("Cursor Run setup cancelled", error);
585
+ }
586
+ if (!failure.replaySafe && (failure.transient || failure.origin === "transport")) {
587
+ throw retrySuppressedError(failure, "while opening the Run", retryBudget.retries + 1, retryPolicy.maxAttempts ?? 14);
588
+ }
589
+ if (!failure.transient)
590
+ throw failure;
591
+ reserveRetry(retryBudget, failure, retryPolicy.maxAttempts);
592
+ retryBudget.hostRetryPending = true;
593
+ throw hostRetryError(failure, true);
594
+ }
595
+ catch (terminal) {
596
+ throw hostFacingError(terminal);
597
+ }
598
+ }
599
+ };
455
600
  return {
456
601
  specificationVersion: "v3",
457
602
  provider: providerId,
458
603
  modelId,
459
604
  supportedUrls: {},
460
- async doStream(callOptions) {
461
- return doStreamImpl(modelId, options, callOptions);
462
- },
605
+ doStream,
463
606
  async doGenerate(callOptions) {
464
- const result = await doStreamImpl(modelId, options, callOptions);
607
+ const result = await doStream(callOptions);
465
608
  const parts = [];
466
609
  const reader = result.stream.getReader();
467
610
  while (true) {
@@ -484,9 +627,14 @@ function resolveRunBearerToken(options, forceRefresh = false) {
484
627
  forceRefresh,
485
628
  });
486
629
  }
487
- async function doStreamImpl(modelId, options, callOptions) {
630
+ async function doStreamImpl(modelId, options, callOptions, retryPolicy, retryBudget) {
631
+ await restoreSessionState(opencodeSessionKey(callOptions), opencodeGlobalCacheDir());
632
+ const withoutAnswered = await leaveOutAnsweredEarlySteers(callOptions.prompt, opencodeSessionKey(callOptions));
633
+ if (withoutAnswered === "all")
634
+ return { stream: answeredSteerStream() };
635
+ if (withoutAnswered)
636
+ callOptions = { ...callOptions, prompt: withoutAnswered };
488
637
  const prompt = callOptions.prompt;
489
- const retryPolicy = resolveRetryPolicy(options.retry);
490
638
  // The Bearer token is resolved for every Run this call opens, including
491
639
  // recovery Runs after a long-held tool: a host `getAccessToken` (or the
492
640
  // apiKey exchange) renews it, so a Run never starts on a token that expired
@@ -497,6 +645,7 @@ async function doStreamImpl(modelId, options, callOptions) {
497
645
  const credentialRenewable = Boolean(options.getAccessToken)
498
646
  || (!options.accessToken && options.apiKey !== undefined && isExchangeableApiKey(options.apiKey));
499
647
  let prefetchedToken;
648
+ const requestedAt = Date.now();
500
649
  // pumpWithRecovery owns the complete per-turn attempt budget. Opening a
501
650
  // replacement session here must be a single attempt; otherwise setup retry
502
651
  // loops nest inside recovery and `maxAttempts` no longer caps total Runs.
@@ -508,7 +657,9 @@ async function doStreamImpl(modelId, options, callOptions) {
508
657
  const token = prefetched !== undefined && !forceRefresh
509
658
  ? prefetched
510
659
  : await resolveRunBearerToken(options, forceRefresh);
511
- return startSession(modelId, token, callOptions, options, startOptions);
660
+ const opened = await startSession(modelId, token, callOptions, options, { ...startOptions, requestedAt });
661
+ cancelIfHostStoppedSince(opened);
662
+ return opened;
512
663
  };
513
664
  // ── Continuation vs fresh turn ──
514
665
  // OpenCode embeds *all* historical tool results in every prompt. Only the
@@ -516,8 +667,13 @@ async function doStreamImpl(modelId, options, callOptions) {
516
667
  // live continuation. Treating mid-prompt history as continuation caused
517
668
  // false "orphaned tool results" errors after Cursor turn_ended and OpenCode
518
669
  // started the next step with old tools still in the prompt body.
519
- const trailingToolResults = extractTrailingToolResults(prompt);
670
+ let trailingToolResults = extractTrailingToolResults(prompt);
520
671
  let session = findContinuationSession(trailingToolResults);
672
+ if (trailingToolResults.length === 0) {
673
+ const steered = continuationAfterEarlySteers(prompt, opencodeSessionKey(callOptions));
674
+ if (steered)
675
+ ({ results: trailingToolResults, session } = steered);
676
+ }
521
677
  // A call that must open a Run gets its credential before it changes any
522
678
  // session state (plan mode, finishing the prior held Run), so a login that
523
679
  // cannot be renewed fails the turn without side effects.
@@ -534,33 +690,39 @@ async function doStreamImpl(modelId, options, callOptions) {
534
690
  // session and returns undefined so we fall through to history rebase
535
691
  // instead of pumping a connection that can no longer accept writes.
536
692
  const tailMedia = trailingToolResults.reduce((n, r) => n + (r.media?.length ?? 0), 0);
537
- const roles = prompt.slice(Math.max(0, prompt.length - 4)).map((m) => {
538
- if (!Array.isArray(m.content))
539
- return `${m.role}:scalar`;
540
- const kinds = m.content.map((p) => {
541
- const part = p;
542
- if (part.type === "tool-result") {
543
- const out = part.output;
544
- const value = out?.value;
545
- const nonText = Array.isArray(value)
546
- ? value.filter((item) => item && typeof item === "object" && item.type !== "text").length
547
- : 0;
548
- return `tool-result(${part.toolName},${typeof out?.type === "string" ? out.type : "?"},mediaish=${nonText})`;
549
- }
550
- return String(part.type ?? "?");
551
- });
552
- return `${m.role}:[${kinds.join(",")}]`;
553
- });
554
693
  trace(`continuation: prompt-tail mediaParts=${tailMedia} supportsImages=${session.supportsImages} ` +
555
- `roles=${roles.join(" | ")}`);
694
+ `roles=${describePromptTail(prompt)}`);
556
695
  const results = session.supportsImages
557
696
  ? await decodeTrailingToolImages(session, trailingToolResults, callOptions.abortSignal)
558
697
  : trailingToolResults;
559
- session = deliverContinuationResults(session, results, {
560
- hostAgent: hostAgentFromCallOptions(callOptions),
561
- });
698
+ const held = session;
699
+ const cancelled = () => new CursorLocalCancellationError("Cursor continuation upload cancelled");
700
+ const cancelUpload = () => {
701
+ dropLostRequest(held.openCodeSessionId);
702
+ sessionManager.close(held, "host-interrupted", cancelled());
703
+ };
704
+ const signal = callOptions.abortSignal;
705
+ signal?.addEventListener("abort", cancelUpload, { once: true });
706
+ try {
707
+ if (signal?.aborted) {
708
+ cancelUpload();
709
+ throw cancelled();
710
+ }
711
+ session = await deliverContinuationResults(held, results, {
712
+ hostAgent: hostAgentFromCallOptions(callOptions),
713
+ });
714
+ // Cancellation is not a failed delivery to rebase and upload again.
715
+ if (signal?.aborted)
716
+ throw cancelled();
717
+ }
718
+ finally {
719
+ signal?.removeEventListener("abort", cancelUpload);
720
+ }
562
721
  if (session)
563
722
  await refreshHeldSessionToolCatalog(session, callOptions);
723
+ // The rebase below seeds from host history, which holds OpenCode's copy of any promoted steer.
724
+ else
725
+ handOverEarlySteers(held.openCodeSessionId, held.sessionId, undefined);
564
726
  }
565
727
  if (!session) {
566
728
  const sessionKey = opencodeSessionKey(callOptions);
@@ -570,8 +732,14 @@ async function doStreamImpl(modelId, options, callOptions) {
570
732
  // prompt onto a fresh conversation: its seed history includes the
571
733
  // completed tool result, so no result or advertised tool is lost and
572
734
  // Cursor can continue instead of deadlocking.
735
+ // Resuming the last checkpoint cannot take these results: Cursor
736
+ // checkpoints only after tool results, so that checkpoint predates the
737
+ // outstanding calls, and `resume_action` re-runs the model step, which
738
+ // asks for the tool again under a new call id. Verified live 2026-10-09
739
+ // (claude-sonnet-5-5): the resumed Run re-requested the same read.
573
740
  const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
574
- trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] — rebasing fresh Run`);
741
+ trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] ` +
742
+ `heldRun=${describeHeldRunLoss(trailingToolResults)} — rebasing fresh Run`);
575
743
  session = await openSession({ recovery: { kind: "rebase" } });
576
744
  }
577
745
  else {
@@ -580,14 +748,25 @@ async function doStreamImpl(modelId, options, callOptions) {
580
748
  const historicalResults = extractToolResults(prompt);
581
749
  if (historicalResults.length > 0) {
582
750
  trace(`fresh turn: reconciling ${historicalResults.length} historical tool result(s) with held pending calls`);
751
+ trace(`fresh turn: prompt-tail roles=${describePromptTail(prompt)}`);
583
752
  }
584
753
  // An in-session helper (title/memory/task child) that reuses the parent
585
754
  // OpenCode session id with a strictly smaller catalog must not cancel or
586
755
  // supersede the held parent Run — that remints (`interrupted-run`) when
587
756
  // the trailing tool result returns. Isolate onto an ephemeral conversation.
588
757
  const busyPrior = sessionManager.findOpenByOpenCodeSessionId(sessionKey);
589
- if (busyPrior && shouldIsolateInSessionHelper(sessionKey, extractTools(callOptions), historicalResults)) {
590
- trace(`fresh turn: isolating in-session helper — incomingTools subset of ` +
758
+ const incomingTools = extractTools(callOptions);
759
+ // A tool-less call that is not compaction (title, summary) is a lifecycle
760
+ // Run: ephemeral and refused execution. Without a result for the parent's
761
+ // pending call, the host is still running that tool (OpenCode 2 sends one
762
+ // mid-tool), so the call must not cancel it. With that result, the host
763
+ // has moved past the tool and the parent Run is finished as usual.
764
+ const lifecycleCall = !computeAllowTools(incomingTools.length, callOptions.toolChoice)
765
+ && !isCompactionCall(callOptions, sessionKey)
766
+ && !historicalResults.some((result) => result.sessionId === busyPrior?.sessionId && busyPrior.pending.has(result.execId));
767
+ if (busyPrior
768
+ && (lifecycleCall || shouldIsolateInSessionHelper(sessionKey, incomingTools, historicalResults))) {
769
+ trace(`fresh turn: isolating in-session ${lifecycleCall ? "tool-less lifecycle call" : "helper — incomingTools subset of"} ` +
591
770
  `parentTools=${busyPrior.toolCatalog?.length ?? 0} ` +
592
771
  `priorSession=${busyPrior.sessionId} pending=${busyPrior.pending.size}`);
593
772
  session = await openSession({ isolate: true });
@@ -598,21 +777,40 @@ async function doStreamImpl(modelId, options, callOptions) {
598
777
  // drain turn_ended) before opening the new Run so the checkpoint prefix
599
778
  // is preserved. registerSession will not close a prior Run that still
600
779
  // has real pending execs; a failed prepare leaves that Run held.
780
+ let unfinishedPrior = false;
601
781
  try {
602
- await preparePriorSessionForFreshTurn(sessionKey, {
782
+ const prepared = await preparePriorSessionForFreshTurn(sessionKey, {
603
783
  toolResults: historicalResults,
604
784
  hostAgent: hostAgentFromCallOptions(callOptions),
605
785
  });
786
+ unfinishedPrior = prepared !== "drained" && prepared !== "none";
787
+ if (unfinishedPrior)
788
+ trace(`fresh turn: prior preparation=${prepared} — rebasing from host history`);
606
789
  }
607
790
  catch (error) {
608
- trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
791
+ unfinishedPrior = true;
792
+ trace(`fresh turn: prepare-prior failed — rebasing the new Run and leaving ` +
609
793
  `any still-pending prior held — ${error.message}`);
610
794
  }
611
- session = await openSession();
795
+ if (!unfinishedPrior && needsHostHistory(sessionKey, sessionManager.findOpenByOpenCodeSessionId(sessionKey)?.sessionId)) {
796
+ unfinishedPrior = true;
797
+ trace("fresh turn: an early steer of an unfinished Run is not where the checkpoint has it — rebasing from host history");
798
+ }
799
+ // Without TurnEnded, the checkpoint may predate completed side effects.
800
+ // Include their host results instead of resuming that model step.
801
+ session = await openSession(unfinishedPrior
802
+ ? { recovery: { kind: "rebase", reason: "unfinished-prior-run" } }
803
+ : undefined);
804
+ // A helper Run (title, lifecycle) or a compaction Run is not the user's next turn.
805
+ if (session.openCodeSessionId && !session.postCompactionRebase) {
806
+ clearUnansweredEarlySteers(sessionKey);
807
+ steerAnsweringRuns.delete(session.openCodeSessionId);
808
+ }
612
809
  }
613
810
  }
614
811
  }
615
812
  let activeSession = session;
813
+ let pumpingThisStream = false;
616
814
  return {
617
815
  stream: new ReadableStream({
618
816
  async pull(controller) {
@@ -632,17 +830,37 @@ async function doStreamImpl(modelId, options, callOptions) {
632
830
  if (activeSession.openCodeSessionId) {
633
831
  beginEmittedStep(activeSession.openCodeSessionId, activeSession.conversationId);
634
832
  }
635
- activeSession = await pumpWithRecovery({
636
- initialSession: activeSession,
637
- controller,
638
- abortSignal: callOptions.abortSignal,
639
- retryPolicy,
640
- recover: (recovery) => openSession({ recovery }),
641
- onSession: (next) => { activeSession = next; },
642
- ...(credentialRenewable
643
- ? { renewRejectedCredential: () => { forceCredentialRefresh = true; } }
644
- : {}),
645
- });
833
+ pumpingThisStream = true;
834
+ try {
835
+ activeSession = await pumpWithRecovery({
836
+ initialSession: activeSession,
837
+ controller,
838
+ abortSignal: callOptions.abortSignal,
839
+ retryPolicy,
840
+ retryBudget,
841
+ canRebaseFromHistory: (failed) => {
842
+ if (failed !== session || trailingToolResults.length === 0
843
+ || !failed.checkpointRevokedByHostResult || failed.pending.size > 0
844
+ || failed.replaySafety?.historyRebaseUnsafe)
845
+ return false;
846
+ const completed = new Set(extractToolResults(prompt)
847
+ .map(result => `${result.sessionId}:${result.execId}`));
848
+ const execs = failed.replaySafety?.execs;
849
+ return !!execs?.length && execs.every(exec => completed.has(`${exec.sessionId}:${exec.id}`));
850
+ },
851
+ recover: (recovery) => openSession({ recovery }),
852
+ onSession: (next) => { activeSession = next; },
853
+ ...(credentialRenewable
854
+ ? { renewRejectedCredential: () => { forceCredentialRefresh = true; } }
855
+ : {}),
856
+ });
857
+ }
858
+ finally {
859
+ pumpingThisStream = false;
860
+ }
861
+ if (activeSession.closed && activeSession.pending.size === 0) {
862
+ forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget);
863
+ }
646
864
  try {
647
865
  controller.close();
648
866
  }
@@ -660,11 +878,22 @@ async function doStreamImpl(modelId, options, callOptions) {
660
878
  });
661
879
  }
662
880
  catch (e) {
881
+ if (e instanceof CursorLocalCancellationError || callOptions.abortSignal?.aborted) {
882
+ forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget);
883
+ }
663
884
  activeSession.pumpActive = false;
664
885
  trace(`pull: pump threw (cleaning up): ${e.message}`);
665
- sessionManager.close(activeSession);
886
+ // A Run the host stopped is drained and closed by its cancel.
887
+ if (!activeSession.hostInterrupted) {
888
+ // Only the snapshot carries a request Cursor never checkpointed
889
+ // past a restart; a failed Run reaches no other save.
890
+ if (hasLostRequest(activeSession.openCodeSessionId)) {
891
+ await persistRunInProgress(activeSession, "failed Run");
892
+ }
893
+ sessionManager.close(activeSession);
894
+ }
666
895
  try {
667
- controller.error(e instanceof Error ? e : new Error(String(e)));
896
+ controller.error(hostFacingError(e));
668
897
  }
669
898
  catch {
670
899
  /* controller already errored/closed */
@@ -674,8 +903,19 @@ async function doStreamImpl(modelId, options, callOptions) {
674
903
  cancel() {
675
904
  // OpenCode cancels the ReadableStream after "tool-calls"; keep the
676
905
  // Cursor Run stream alive so the next doStream can write results.
906
+ // Mid-generation with nothing owed, the host stopped reading: OpenCode 2
907
+ // cancels the stream on Stop before it reports the interrupt. Stop the
908
+ // Run as Cursor CLI does, so Cursor ends it and its checkpoint is kept.
909
+ // A step stream cancelled after a continuation took over the Run is not a Stop.
910
+ if (pumpingThisStream && !activeSession.closed && !activeSession.hostInterrupted
911
+ && activeSession.pending.size === 0 && sessionManager.isActivelyPumping(activeSession)) {
912
+ trace("ReadableStream cancel() mid-generation → cancelling the Run");
913
+ cancelForHostInterrupt(activeSession, "stream-cancelled");
914
+ return;
915
+ }
677
916
  trace("ReadableStream cancel() → closeUnlessPending");
678
- sessionManager.closeUnlessPending(activeSession);
917
+ if (!activeSession.hostInterrupted)
918
+ sessionManager.closeUnlessPending(activeSession);
679
919
  },
680
920
  }),
681
921
  };
@@ -684,13 +924,20 @@ export async function pumpWithRecovery(input) {
684
924
  let session = input.initialSession;
685
925
  const retryPolicy = input.retryPolicy ?? {
686
926
  ...DEFAULT_RETRY_POLICY,
687
- maxAttempts: (input.maxRecoveries ?? 1) + 1,
927
+ ...(input.maxRecoveries === undefined ? {} : { maxAttempts: input.maxRecoveries + 1 }),
688
928
  };
689
- const maxRecoveries = retryPolicy.maxAttempts - 1;
929
+ const retryBudget = input.retryBudget ?? newRetryBudget();
930
+ const maxRecoveries = retryPolicy.maxAttempts === undefined ? 13 : retryPolicy.maxAttempts - 1;
690
931
  let credentialRenewed = false;
932
+ // As in Cursor CLI's Run retry loop: an attempt without a checkpoint of its
933
+ // own resumes the latest one this turn received, and two resumed attempts
934
+ // that streamed without saving a new checkpoint stop the automatic retries.
935
+ let turnCheckpoint;
936
+ let resumedAttempt = false;
937
+ let noProgressResumes = 0;
938
+ let openingFailure;
691
939
  input.onSession?.(session);
692
- const reopen = async (pumpedSession, failure) => {
693
- const checkpoint = pumpedSession.resumeCheckpoint;
940
+ const reopen = async (pumpedSession, failure, checkpoint) => {
694
941
  const recovery = failure.checkpointUnusable
695
942
  ? { kind: "rebase", reason: "checkpoint-unusable" }
696
943
  : checkpoint
@@ -699,75 +946,213 @@ export async function pumpWithRecovery(input) {
699
946
  conversationId: pumpedSession.conversationId,
700
947
  checkpoint: Uint8Array.from(checkpoint),
701
948
  }
702
- : { kind: "rebase" };
703
- const next = await input.recover(recovery);
949
+ : { kind: "rebase", ...(input.canRebaseFromHistory?.(pumpedSession) ? { reason: "completed-tool-results" } : {}) };
950
+ resumedAttempt = recovery.kind === "resume";
951
+ // A rebase opens another conversation; its checkpoints start over.
952
+ if (!resumedAttempt)
953
+ turnCheckpoint = undefined;
954
+ // Host notes the failed Run never acknowledged belong to this turn.
955
+ const unackedNotes = takeHostNoteInjections(pumpedSession);
956
+ const unackedNoteCount = unackedNotes.reduce((count, batch) => count + batch.notes.length, 0);
957
+ if (recovery.kind === "rebase" && pumpedSession.openCodeSessionId) {
958
+ // A replacement seed may omit old read outputs or have rewritten history.
959
+ // Restore notes before opening it; startSession removes only notes its
960
+ // actual seed contains. Initial user-action notes also need this transfer
961
+ // when the attempt failed before obtaining a checkpoint.
962
+ appendUndeliveredHostNotes(pumpedSession.openCodeSessionId, [
963
+ ...(pumpedSession.userTurnHostNotes ?? []), ...unackedNotes,
964
+ ]);
965
+ }
966
+ let next;
967
+ try {
968
+ next = await input.recover(recovery);
969
+ }
970
+ catch (error) {
971
+ if (recovery.kind === "resume" && unackedNotes.length > 0 && pumpedSession.openCodeSessionId) {
972
+ appendUndeliveredHostNotes(pumpedSession.openCodeSessionId, unackedNotes);
973
+ }
974
+ throw error;
975
+ }
976
+ handOverEarlySteers(pumpedSession.openCodeSessionId, pumpedSession.sessionId, recovery.kind === "resume" ? next.sessionId : undefined);
704
977
  if (recovery.kind === "resume") {
978
+ next.replaySafety = {
979
+ ...pumpedSession.replaySafety,
980
+ execs: pumpedSession.replaySafety?.execs?.slice(),
981
+ };
982
+ next.userTurnHostNotes = pumpedSession.userTurnHostNotes;
705
983
  next.usageEstimate = { ...pumpedSession.usageEstimate };
706
984
  next.editToolCalls = new Map(pumpedSession.editToolCalls);
707
985
  // mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
708
986
  // session, seeded in startSession) — no handoff needed here.
987
+ // The resumed Run continues the same turn, so the notes go to it.
988
+ // A failed write closes `next` and keeps the notes tracked against it,
989
+ // so the next recovery or user turn picks them up.
990
+ if (unackedNotes.length > 0) {
991
+ trace(`Run resume: re-injecting ${unackedNoteCount} unacknowledged host note(s)`);
992
+ for (const batch of unackedNotes) {
993
+ if (!await injectHostNotes(next, batch.notes, batch.order)) {
994
+ // A failed first batch must not discard later batches.
995
+ if (next.openCodeSessionId) {
996
+ appendUndeliveredHostNotes(next.openCodeSessionId, unackedNotes.filter((other) => other.order > batch.order));
997
+ }
998
+ throw next.closeError ?? new CursorTransportError("Cursor host note injection write failed", { transient: false, replaySafe: false });
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+ else if (unackedNotes.length > 0) {
1004
+ trace(`Run rebase: ${unackedNoteCount} unacknowledged host note(s) transferred to the replacement seed`);
709
1005
  }
710
1006
  input.onSession?.(next);
711
1007
  return next;
712
1008
  };
1009
+ // A replacement can fail before pump() starts (for example while connecting).
1010
+ // Classify it in this same loop rather than exposing an unbudgeted host retry.
1011
+ const reopenAttempt = async (pumped, failure, checkpoint) => {
1012
+ try {
1013
+ session = await reopen(pumped, failure, checkpoint);
1014
+ }
1015
+ catch (error) {
1016
+ const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor recovery setup failed" });
1017
+ // The failed upload belongs to the replacement Run. The preceding Run's
1018
+ // checkpoint cannot prove that the replacement did not already start work.
1019
+ if (!failure.replaySafe && (failure.transient || failure.origin === "transport")) {
1020
+ throw retrySuppressedError(failure, "while opening the replacement Run", retryBudget.retries + 1, maxRecoveries + 1);
1021
+ }
1022
+ if (!failure.transient)
1023
+ throw error;
1024
+ openingFailure = failure;
1025
+ }
1026
+ };
713
1027
  for (let attempt = 0;; attempt++) {
714
1028
  const pumpedSession = session;
715
1029
  const pumpOwner = Symbol("cursor-pump");
716
- sessionManager.beginPump(pumpedSession, pumpOwner);
1030
+ if (!openingFailure)
1031
+ sessionManager.beginPump(pumpedSession, pumpOwner);
1032
+ // OpenCode 1.x stops a turn by aborting the signal, before the AI SDK cancels
1033
+ // the stream. Cancel at Cursor, as the stream's cancel() does, so Cursor ends
1034
+ // the Run on a checkpoint; a reply still waiting for drain would hold the
1035
+ // cancel back, so then reset the Run at once.
1036
+ const stopPumpedRun = () => {
1037
+ if (!sessionManager.isPumpOwner(pumpedSession, pumpOwner) || pumpedSession.pending.size > 0 || pumpedSession.hostInterrupted)
1038
+ return;
1039
+ if (!isStreamWriteBlocked(pumpedSession.stream)) {
1040
+ cancelForHostInterrupt(pumpedSession, "aborted");
1041
+ return;
1042
+ }
1043
+ dropLostRequest(pumpedSession.openCodeSessionId);
1044
+ sessionManager.close(pumpedSession, "host-interrupted", new CursorLocalCancellationError("Cursor Run cancelled locally"));
1045
+ };
1046
+ input.abortSignal?.addEventListener("abort", stopPumpedRun, { once: true });
1047
+ // A compaction Run keeps the session id, but a steer is for the agent, not the summarizer.
1048
+ const stopSteers = pumpedSession.openCodeSessionId && !pumpedSession.postCompactionRebase
1049
+ ? listenForHostSteers(pumpedSession.openCodeSessionId, (steer) => injectHostSteer(pumpedSession, steer))
1050
+ : undefined;
717
1051
  try {
1052
+ if (openingFailure) {
1053
+ const failure = openingFailure;
1054
+ openingFailure = undefined;
1055
+ throw failure;
1056
+ }
718
1057
  await pump(pumpedSession, input.controller, {
719
1058
  textId: crypto.randomUUID(),
720
1059
  reasoningId: crypto.randomUUID(),
721
1060
  }, input.abortSignal);
1061
+ if (pumpedSession.closed && pumpedSession.pending.size === 0) {
1062
+ forgetRetryBudget(pumpedSession.openCodeSessionId, retryBudget);
1063
+ }
722
1064
  return session;
723
1065
  }
724
1066
  catch (error) {
1067
+ // A cancelled Run's stream fails or ends on purpose; recovering would restart the stopped turn.
1068
+ if (pumpedSession.hostInterrupted || input.abortSignal?.aborted) {
1069
+ forgetRetryBudget(pumpedSession.openCodeSessionId, retryBudget);
1070
+ if (error instanceof CursorLocalCancellationError)
1071
+ throw error;
1072
+ throw new CursorLocalCancellationError("Cursor Run cancelled: the host stopped the turn", error);
1073
+ }
725
1074
  const failure = toCursorProviderError(error, {
726
1075
  replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
727
1076
  fallback: "Cursor Run interrupted",
728
1077
  });
1078
+ // A renewable credential error may follow a checkpoint too. Retain it
1079
+ // before either recovery path so a later attempt can still resume it.
1080
+ if (pumpedSession.resumeCheckpoint) {
1081
+ turnCheckpoint = pumpedSession.resumeCheckpoint;
1082
+ noProgressResumes = 0;
1083
+ }
1084
+ else if (pumpedSession.checkpointRevoked) {
1085
+ turnCheckpoint = undefined;
1086
+ }
1087
+ const checkpoint = turnCheckpoint;
1088
+ // The inherited tool barrier forbids replaying the action, but an untouched
1089
+ // replacement may renew and resume its checkpoint without replaying tools.
1090
+ const untouchedCheckpointResume = resumedAttempt && checkpoint !== undefined
1091
+ && failure.replayBarrier === undefined && !failure.checkpointUnusable;
729
1092
  if (input.renewRejectedCredential
730
1093
  && !credentialRenewed
731
1094
  && isRejectedCredentialError(failure)
732
- && failure.replaySafe) {
1095
+ && (failure.replaySafe || untouchedCheckpointResume)) {
733
1096
  credentialRenewed = true;
734
1097
  trace(`Run credential rejected: sessionId=${pumpedSession.sessionId} err=${failure.message} ` +
735
1098
  `— renewing the token and reopening once`);
736
1099
  sessionManager.close(pumpedSession, "remote-error", failure);
737
1100
  input.renewRejectedCredential();
738
- session = await reopen(pumpedSession, failure);
1101
+ await reopenAttempt(pumpedSession, failure, checkpoint);
739
1102
  attempt--;
740
1103
  continue;
741
1104
  }
742
1105
  if (!failure.transient)
743
1106
  throw failure;
744
- const checkpoint = pumpedSession.resumeCheckpoint;
745
- if (!failure.replaySafe && !checkpoint) {
746
- throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
1107
+ if (!pumpedSession.resumeCheckpoint && !pumpedSession.checkpointRevoked
1108
+ && resumedAttempt && failure.replayBarrier) {
1109
+ noProgressResumes++;
1110
+ if (noProgressResumes >= 2) {
1111
+ trace(`Run resume made no progress: sessionId=${pumpedSession.sessionId} resumes=${noProgressResumes} ` +
1112
+ `err=${failure.message} — stopping automatic resume`);
1113
+ throw noProgressResumeError(failure, noProgressResumes);
1114
+ }
747
1115
  }
748
- if (attempt >= maxRecoveries) {
749
- throw new CursorRetryExhaustedError(attempt + 1, failure);
1116
+ const historyRecovery = failure.replayBarrier === undefined && input.canRebaseFromHistory?.(pumpedSession);
1117
+ if (!failure.replaySafe && !checkpoint && !historyRecovery) {
1118
+ // Cursor CLI sends the same action again. Shown output keeps the
1119
+ // provider from doing so; the host can, unless a side effect would repeat.
1120
+ if (failure.replayBarrier === "output-only" && !pumpedSession.checkpointRevoked) {
1121
+ trace(`Run interrupted after output only: sessionId=${pumpedSession.sessionId} — leaving the retry to the host`);
1122
+ reserveRetry(retryBudget, failure, retryPolicy.maxAttempts);
1123
+ retryBudget.hostRetryPending = true;
1124
+ throw hostRetryError(failure);
1125
+ }
1126
+ throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
750
1127
  }
1128
+ reserveRetry(retryBudget, failure, retryPolicy.maxAttempts);
751
1129
  trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
752
1130
  `err=${failure.message} — ${checkpoint ? `resuming ${checkpoint.length}B checkpoint` : "rebasing fresh Run"}`);
753
1131
  sessionManager.close(pumpedSession, "remote-error", failure);
754
1132
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
755
1133
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
756
1134
  await sleepForRetry(delayMs, input.abortSignal);
757
- session = await reopen(pumpedSession, failure);
1135
+ await reopenAttempt(pumpedSession, failure, checkpoint);
758
1136
  }
759
1137
  finally {
1138
+ input.abortSignal?.removeEventListener("abort", stopPumpedRun);
1139
+ stopSteers?.();
760
1140
  // Cancellation cleanup inside pump() must keep an active owner alive.
761
1141
  // Retry after releasing this owner so a stopped consumer with no pending
762
1142
  // tools cannot leave its Run and heartbeat open. Pending tool turns and
763
- // a newer pump owner still retain their session.
764
- if (sessionManager.endPump(pumpedSession, pumpOwner)) {
765
- sessionManager.closeUnlessPending(pumpedSession);
1143
+ // a newer pump owner still retain their session, and a host interrupt
1144
+ // closes its Run itself after draining Cursor's checkpoint.
1145
+ if (sessionManager.endPump(pumpedSession, pumpOwner) && !pumpedSession.hostInterrupted) {
1146
+ // Held for the host's tools: keep answering what Cursor sends meanwhile.
1147
+ if (!sessionManager.closeUnlessPending(pumpedSession))
1148
+ readHeldRun(pumpedSession);
766
1149
  }
767
1150
  }
768
1151
  }
769
1152
  }
770
1153
  const heartbeatWritePendingBySession = new WeakMap();
1154
+ /** Runs whose first current category breakdown was checked for missing rules. */
1155
+ const rulesCategoryChecked = new WeakSet();
771
1156
  const heartbeatGenerationBySession = new WeakMap();
772
1157
  function bumpHeartbeatGeneration(session) {
773
1158
  const generation = (heartbeatGenerationBySession.get(session) ?? 0) + 1;
@@ -817,28 +1202,88 @@ export function attachSessionHeartbeat(session) {
817
1202
  }, session.policy.heartbeatMs);
818
1203
  interval.unref?.();
819
1204
  session.heartbeat = interval;
1205
+ const execInterval = setInterval(() => {
1206
+ if (session.closed || !isCurrentHeartbeatGeneration(session, generation))
1207
+ return;
1208
+ if (execHeartbeatsPendingBySession.has(session))
1209
+ return;
1210
+ void writeExecHeartbeats(session, generation);
1211
+ }, EXEC_HEARTBEAT_MS);
1212
+ execInterval.unref?.();
820
1213
  session.heartbeatCancel = () => {
821
1214
  if (session.heartbeat)
822
1215
  clearInterval(session.heartbeat);
823
1216
  session.heartbeat = null;
1217
+ clearInterval(execInterval);
824
1218
  if (isCurrentHeartbeatGeneration(session, generation))
825
1219
  bumpHeartbeatGeneration(session);
826
1220
  };
827
1221
  }
828
- async function startSession(modelId, token, callOptions, options, startOptions) {
829
- const continuationPolicy = resolveContinuationPolicy(options.continuation);
830
- const prompt = callOptions.prompt;
831
- const incomingTools = extractTools(callOptions);
832
- const sessionKey = opencodeSessionKey(callOptions);
833
- const isolateHelper = startOptions?.isolate === true;
834
- const cacheDir = opencodeGlobalCacheDir();
1222
+ /** Cursor CLI's per-exec heartbeat period (`agent-exec` handler). */
1223
+ const EXEC_HEARTBEAT_MS = 3_000;
1224
+ const execHeartbeatsPendingBySession = new WeakSet();
1225
+ const execHeartbeatCount = new WeakMap();
1226
+ /** A Cursor exec the host still runs, as opposed to an interaction answer or a display-only mirror. */
1227
+ function isOwedCursorExec(pending) {
1228
+ return !pending.bridged
1229
+ && pending.resultField !== ASK_QUESTION_RESULT_FIELD
1230
+ && pending.resultField !== SWITCH_MODE_RESULT_FIELD
1231
+ && pending.resultField !== CREATE_PLAN_RESULT_FIELD;
1232
+ }
1233
+ /**
1234
+ * Tell Cursor each exec the host is still running is alive, as Cursor CLI does
1235
+ * while its exec handler runs. A failed write is left to the session heartbeat,
1236
+ * which owns closing a broken stream; the CLI ignores it too.
1237
+ */
1238
+ async function writeExecHeartbeats(session, generation) {
1239
+ execHeartbeatsPendingBySession.add(session);
1240
+ const stream = session.stream;
1241
+ try {
1242
+ for (const [execId, pending] of [...session.pending]) {
1243
+ if (!isOwedCursorExec(pending))
1244
+ continue;
1245
+ const stillRunning = () => !session.closed
1246
+ && session.stream === stream
1247
+ && isCurrentHeartbeatGeneration(session, generation)
1248
+ && session.pending.get(execId) === pending
1249
+ && pending.state === "pending"
1250
+ && !pending.aborted;
1251
+ if (!stillRunning())
1252
+ continue;
1253
+ const written = await writeWithBackpressure(stream, buildExecHeartbeat(execId), `exec heartbeat id=${execId}`, stillRunning);
1254
+ if (!written)
1255
+ continue;
1256
+ const count = (execHeartbeatCount.get(pending) ?? 0) + 1;
1257
+ execHeartbeatCount.set(pending, count);
1258
+ trace(`exec heartbeat: id=${execId} tool=${pending.toolName ?? "?"} n=${count} sessionId=${session.sessionId}`);
1259
+ }
1260
+ }
1261
+ catch (error) {
1262
+ trace(`exec heartbeat write failed sessionId=${session.sessionId}: ${error.message}`);
1263
+ }
1264
+ finally {
1265
+ execHeartbeatsPendingBySession.delete(session);
1266
+ }
1267
+ }
1268
+ /** Restore receipts before matching a promoted copy, which may need no Run at all. */
1269
+ async function restoreSessionState(sessionKey, cacheDir) {
835
1270
  if (sessionKey) {
836
1271
  const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
837
1272
  trace(`conversation persistence: restore failed sessionKey=${sessionKey}: ${String(error)}`);
838
1273
  return undefined;
839
1274
  });
1275
+ if (restored && serializeEarlySteers(sessionKey)) {
1276
+ const requestContext = getFrozenRequestContext(restored.conversationId);
1277
+ if (requestContext)
1278
+ rememberSteerAnsweringRun(sessionKey, {
1279
+ openCodeSessionId: sessionKey, conversationId: restored.conversationId, cacheDir, requestContext,
1280
+ toolCatalog: restored.toolCatalog, postCompactionRebase: restored.postCompactionRebase,
1281
+ hostAgent: restored.hostAgent, stableSystemPromptHash: restored.systemPromptHash,
1282
+ });
1283
+ }
840
1284
  if (restored?.postCompactionRebase)
841
1285
  rememberPostCompactionRebase(sessionKey);
1286
+ restorePersistedHostNotes(sessionKey, restored?.hostNote);
842
1287
  if (restored?.toolCatalog.length)
843
1288
  restoreTurnToolCatalog(sessionKey, restored.toolCatalog);
844
1289
  if (restored?.hostAgent || restored?.systemPromptHash) {
@@ -852,16 +1297,58 @@ async function startSession(modelId, token, callOptions, options, startOptions)
852
1297
  trace(`turn provenance: restore failed sessionKey=${sessionKey}: ${String(error)}`);
853
1298
  });
854
1299
  }
1300
+ }
1301
+ /** Own cancellation until an opening Run has uploaded its request and can be registered. */
1302
+ async function openRunWithRequest(token, options, request, operation, owner) {
1303
+ let stream;
1304
+ let cancelled = false;
1305
+ const cancel = () => {
1306
+ cancelled = true;
1307
+ dropLostRequest(owner.stoppedWithSessionId);
1308
+ stream?.destroy();
1309
+ };
1310
+ // Only the opening phase follows this signal. The host's later tool-calls
1311
+ // abort must leave the registered Run alive for its continuation.
1312
+ owner.signal?.addEventListener("abort", cancel, { once: true });
1313
+ const unsubscribe = onHostInterrupt((sessionId, reason, at) => {
1314
+ if (reason !== "shutdown" && sessionId && sessionId === owner.stoppedWithSessionId && owner.requestedAt < at)
1315
+ cancel();
1316
+ });
1317
+ const previousStop = owner.stoppedWithSessionId ? hostInterrupts.get(owner.stoppedWithSessionId) : undefined;
1318
+ if (owner.signal?.aborted || (previousStop && owner.requestedAt < previousStop.at))
1319
+ cancel();
1320
+ try {
1321
+ if (cancelled)
1322
+ throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`);
1323
+ stream = await bidiRunStream(token, options);
1324
+ if (cancelled)
1325
+ throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`);
1326
+ await writeWithBackpressure(stream, request, operation);
1327
+ if (cancelled)
1328
+ throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`);
1329
+ return stream;
1330
+ }
1331
+ catch (error) {
1332
+ stream?.destroy();
1333
+ if (cancelled)
1334
+ throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`, error);
1335
+ throw error;
1336
+ }
1337
+ finally {
1338
+ owner.signal?.removeEventListener("abort", cancel);
1339
+ unsubscribe();
1340
+ }
1341
+ }
1342
+ async function startSession(modelId, token, callOptions, options, startOptions) {
1343
+ const continuationPolicy = resolveContinuationPolicy(options.continuation);
1344
+ const prompt = callOptions.prompt;
1345
+ const incomingTools = extractTools(callOptions);
1346
+ const sessionKey = opencodeSessionKey(callOptions);
1347
+ const isolateHelper = startOptions?.isolate === true;
1348
+ const cacheDir = opencodeGlobalCacheDir();
855
1349
  const providerOptions = callOptions.providerOptions?.cursor;
856
1350
  const hostAgent = hostAgentFromCallOptions(callOptions);
857
- // The classic plugin marks OpenCode's agent="compaction" through chat.params.
858
- // OpenCode 2.0 removed that hook, so its plugin writes the same request-local
859
- // option from session hooks. The session marker is a fallback only when a
860
- // host does not preserve mutable options; explicit false prevents concurrent
861
- // title/generate calls from inheriting a compaction marker.
862
- // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
863
- const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
864
- const isCompaction = compactionOption === true || (compactionOption === undefined && isCompactionSession(sessionKey));
1351
+ const isCompaction = isCompactionCall(callOptions, sessionKey);
865
1352
  const toolState = await resolveTurnToolState({
866
1353
  sessionKey,
867
1354
  incomingTools,
@@ -881,9 +1368,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
881
1368
  const allowTools = toolState.allowTools;
882
1369
  const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
883
1370
  let recovery = startOptions?.recovery;
1371
+ const unfinishedPriorRun = recovery?.kind === "rebase" && recovery.reason === "unfinished-prior-run";
884
1372
  let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
885
1373
  let resuming = !!resumeRecovery;
886
- const lifecycle = !allowTools && !isCompaction && !recovery;
1374
+ const lifecycle = !allowTools && !isCompaction && (!recovery || unfinishedPriorRun);
887
1375
  // Isolated helpers must not share the parent's openCodeSessionId or sticky
888
1376
  // conversation: registerSession would otherwise supersede the held parent Run.
889
1377
  const ephemeralRun = lifecycle || isolateHelper;
@@ -950,7 +1438,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
950
1438
  : { count: 0, bytes: 0, complete: true };
951
1439
  const forcedResetReason = foreignHistory
952
1440
  ? `foreign-history:${foreignHistory}`
953
- : checkpointUnusable ? "checkpoint-unusable" : undefined;
1441
+ : checkpointUnusable ? "checkpoint-unusable"
1442
+ : unfinishedPriorRun ? "unfinished-prior-run" : undefined;
954
1443
  // CLI soft-reuses incomplete / oversized graphs (100 MiB is export-only).
955
1444
  // Never remint — warn and keep the sticky conversation + checkpoint.
956
1445
  if (conversationState) {
@@ -965,20 +1454,51 @@ async function startSession(modelId, token, callOptions, options, startOptions)
965
1454
  }
966
1455
  const conversationId = bound.conversationId;
967
1456
  const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
1457
+ const resetReason = forcedResetReason
1458
+ ?? (recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown"));
968
1459
  if (bound.reset) {
969
1460
  if (sessionKey) {
1461
+ forgetHeldHistoryImages(sessionKey);
970
1462
  await clearPersistedConversationState(cacheDir, sessionKey, bound.previousId).catch((error) => {
971
1463
  trace(`conversation persistence: reset cleanup failed sessionKey=${sessionKey}: ${String(error)}`);
972
1464
  });
973
1465
  }
974
- trace(`conversation reset: reason=${forcedResetReason ?? (recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown"))} ` +
1466
+ trace(`conversation reset: reason=${resetReason} ` +
975
1467
  `sessionKey=${sessionKey ?? "(none)"} ` +
976
1468
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
977
1469
  }
978
1470
  const lastUser = [...prompt].reverse().find((message) => message.role === "user");
979
- let userText = recovery?.kind === "rebase" && !checkpointUnusable
1471
+ const liveTurn = !isCompaction && !ephemeralRun && !resuming
1472
+ && (!recovery || checkpointUnusable || unfinishedPriorRun)
1473
+ ? liveUserTurn(prompt) : undefined;
1474
+ let userText = recovery?.kind === "rebase" && !checkpointUnusable && !unfinishedPriorRun
980
1475
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
981
- : (extractUserText(lastUser) || ".");
1476
+ : (liveTurn?.text || extractUserText(lastUser) || ".");
1477
+ if (sessionKey && liveTurn && recovery?.kind !== "rebase") {
1478
+ // A Run without a checkpoint seeds the host history, which already holds
1479
+ // the earlier request; only a checkpoint can be missing it.
1480
+ const rebuildFromHistory = !conversationState?.length;
1481
+ const liveText = userText;
1482
+ userText = carryLostRequest({
1483
+ sessionKey,
1484
+ conversationId,
1485
+ checkpoint: conversationState,
1486
+ text: userText,
1487
+ rebuildFromHistory,
1488
+ });
1489
+ if (!rebuildFromHistory) {
1490
+ rememberLostRequest({
1491
+ sessionKey,
1492
+ conversationId,
1493
+ checkpoint: conversationState,
1494
+ text: userText,
1495
+ latest: liveText,
1496
+ });
1497
+ }
1498
+ }
1499
+ else if (sessionKey && recovery?.kind === "rebase") {
1500
+ dropLostRequest(sessionKey);
1501
+ }
982
1502
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
983
1503
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
984
1504
  const startedWithCheckpoint = !!conversationState;
@@ -1012,9 +1532,28 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1012
1532
  ...(hostPlanFileFor(sessionKey) ? { hostPlanFile: hostPlanFileFor(sessionKey) } : {}),
1013
1533
  server: resolveToolServerIdentity("plan_exit", "opencode", knownMcpServers).server,
1014
1534
  });
1535
+ const history = extractPromptHistory(prompt, {
1536
+ liveTurnStart: liveTurn?.start,
1537
+ preserveTrailingUser: recovery?.kind === "rebase" && !checkpointUnusable && !unfinishedPriorRun,
1538
+ // A foreign-history rebase replays the other model's tool results too.
1539
+ toolResults: isCompaction || foreignHistory || checkpointUnusable || unfinishedPriorRun
1540
+ || (recovery?.kind === "rebase" && recovery.reason === "completed-tool-results")
1541
+ ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
1542
+ });
1543
+ const userTurnHostNotes = prepareUserTurnHostNotes({
1544
+ sessionKey,
1545
+ prompt,
1546
+ startedWithCheckpoint,
1547
+ isCompaction,
1548
+ ephemeralRun,
1549
+ resuming,
1550
+ seedHistory: history,
1551
+ liveTurnStart: liveTurn?.start,
1552
+ });
1015
1553
  const oneShotReminders = [
1016
1554
  modeReminder,
1017
1555
  hostPlanAgentNote,
1556
+ ...userTurnHostNotes.reminders,
1018
1557
  ].filter((part) => !!part);
1019
1558
  // `systemPrompt` is the host system context composed for a seed Run (kept for
1020
1559
  // diagnostics and size estimates). It reaches Cursor only as the frozen
@@ -1068,12 +1607,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1068
1607
  });
1069
1608
  }
1070
1609
  }
1071
- const history = extractPromptHistory(prompt, {
1072
- preserveTrailingUser: recovery?.kind === "rebase" && !checkpointUnusable,
1073
- // A foreign-history rebase replays every tool result: the other model's work
1074
- // exists only in OpenCode history, never in a Cursor checkpoint.
1075
- toolResults: isCompaction || foreignHistory || checkpointUnusable ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
1076
- });
1077
1610
  await loadAvailableModels();
1078
1611
  // Resolve the region-specific Run stream origin once per process (memoized
1079
1612
  // in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
@@ -1095,19 +1628,20 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1095
1628
  const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
1096
1629
  const supportsImages = resolveCursorModelSupportsImages(cursorModelId, modelInfo?.supportsImages);
1097
1630
  if (!resuming) {
1098
- assertCursorUserImageSupport(lastUser, supportsImages, cursorModelId);
1631
+ const liveMessages = liveTurn ? prompt.slice(liveTurn.start).filter((message) => message.role === "user") : [lastUser];
1632
+ for (const message of liveMessages) {
1633
+ assertCursorUserImageSupport(message, supportsImages, cursorModelId);
1634
+ }
1099
1635
  }
1636
+ // A Run without a checkpoint (new, reset, rebased or compacted conversation) sends every image.
1637
+ const heldImages = sessionKey && !ephemeralRun && conversationState
1638
+ ? heldHistoryImageHashes(sessionKey, conversationId, conversationState)
1639
+ : undefined;
1100
1640
  const imageExtraction = resuming
1101
1641
  ? { images: [], hashes: [], candidateCount: 0, duplicateCount: 0, userImageCount: 0, omittedCount: 0 }
1102
1642
  : await extractCursorPromptImages(prompt, lastUser, {
1103
1643
  supportsImages,
1104
- // Content hashes are retained for the OpenCode session so growing
1105
- // history does not re-upload old screenshots. A recovery or
1106
- // foreign-history rebase opens a new Cursor conversation, so it must
1107
- // resend the same payload.
1108
- seenHistoryHashes: recovery?.kind === "rebase" || foreignHistory
1109
- ? undefined
1110
- : sentHistoryImageHashes(sessionKey),
1644
+ seenHistoryHashes: heldImages,
1111
1645
  signal: callOptions.abortSignal,
1112
1646
  });
1113
1647
  if (imageExtraction.omittedCount) {
@@ -1128,28 +1662,24 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1128
1662
  picked,
1129
1663
  });
1130
1664
  // An explicit pick owns the context tier; otherwise honor the maxMode hint.
1131
- // OpenCode's variant paramMap does not include maxMode when the user selects 1m.
1665
+ // OpenCode's variant paramMap does not include maxMode, so the cataloged
1666
+ // variant's isMaxMode marker decides it (1m tiers, Grok 4.7's 500k).
1132
1667
  const maxMode = resolveVariantMaxMode(parameterValues, {
1133
1668
  picked,
1134
1669
  maxMode: hintMaxMode,
1670
+ model: modelInfo,
1135
1671
  });
1136
- if (foreignHistory || checkpointUnusable) {
1672
+ if (foreignHistory || checkpointUnusable || unfinishedPriorRun) {
1137
1673
  assertForeignHistoryRebaseFits({
1138
1674
  modelInfo,
1139
1675
  cursorModelId,
1140
1676
  maxMode,
1677
+ parameters: parameterValues,
1141
1678
  history,
1142
1679
  systemPrompt,
1143
1680
  userText,
1144
1681
  });
1145
1682
  }
1146
- // Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
1147
- // that signal when a turn ends with tool-calls; the Cursor stream must stay
1148
- // open until we write the exec results on the next doStream.
1149
- const stream = await bidiRunStream(token, {
1150
- baseURL: agentBaseUrl,
1151
- headers: options.headers,
1152
- });
1153
1683
  // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
1154
1684
  // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
1155
1685
  const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig, systemInstructions, sessionID: sessionKey });
@@ -1254,23 +1784,34 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1254
1784
  trace(`hash requestContext sha256=${requestContextHash}`);
1255
1785
  if (conversationState)
1256
1786
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
1257
- try {
1258
- await writeWithBackpressure(stream, reqBytes, "initial Run request");
1259
- rememberSentHistoryImageHashes(sessionKey, imageExtraction.hashes);
1260
- }
1261
- catch (error) {
1262
- stream.destroy();
1263
- throw error;
1264
- }
1787
+ const stream = await openRunWithRequest(token, {
1788
+ baseURL: agentBaseUrl, headers: options.headers,
1789
+ }, reqBytes, "initial Run request", {
1790
+ signal: callOptions.abortSignal,
1791
+ stoppedWithSessionId: lifecycle ? undefined : sessionKey,
1792
+ requestedAt: startOptions?.requestedAt ?? Date.now(),
1793
+ });
1794
+ userTurnHostNotes.sent();
1795
+ const historyImages = sessionKey && !ephemeralRun ? runHistoryImages(heldImages) : undefined;
1796
+ if (historyImages)
1797
+ noteHistoryImagesSent(historyImages, imageExtraction.hashes, lastFrameArrival());
1265
1798
  const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1266
1799
  trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
1267
1800
  `skillArgKey=${hostToolDialect.skillArgKey} tools=[${tools.map((t) => t.name).join(",")}]`);
1801
+ const billingCost = cursorRunCost(cursorModelId, parameterValues);
1268
1802
  const session = {
1803
+ billing: {
1804
+ // Title and other ephemeral Runs settle within themselves.
1805
+ key: !ephemeralRun && sessionKey ? sessionKey : `run:${runId}`,
1806
+ ...(billingCost ? { cost: billingCost } : {}),
1807
+ prefixTokens: priorTokenDetails?.usedTokens ?? 0,
1808
+ },
1269
1809
  sessionId: crypto.randomUUID(),
1270
1810
  runId,
1271
1811
  conversationId,
1272
1812
  cacheDir,
1273
1813
  resumeCheckpoint: undefined,
1814
+ ...(historyImages ? { historyImages } : {}),
1274
1815
  tokenDetails: priorTokenDetails,
1275
1816
  tokenDetailsFresh: false,
1276
1817
  cacheDiagnostics: {
@@ -1280,6 +1821,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1280
1821
  modelId: cursorModelId,
1281
1822
  priorTokenDetails,
1282
1823
  startedWithCheckpoint: !!conversationState,
1824
+ ...(conversationState
1825
+ ? {}
1826
+ : { coldReason: ephemeralRun ? "ephemeral" : bound.reset ? resetReason : "no-checkpoint" }),
1283
1827
  requestContextReused,
1284
1828
  requestContextHash,
1285
1829
  systemPromptHash,
@@ -1294,6 +1838,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1294
1838
  switchModeInTurn: false,
1295
1839
  },
1296
1840
  openCodeSessionId: ephemeralRun ? undefined : sessionKey,
1841
+ stoppedWithSessionId: lifecycle ? undefined : sessionKey,
1842
+ userTurnHostNotes: userTurnHostNotes.carriedNotes,
1297
1843
  checkpointRebaseEligible: !ephemeralRun && !resuming && !recovery && !!conversationState,
1298
1844
  hostAgent,
1299
1845
  stableSystemPromptHash: frozenSystemPromptHash,
@@ -1330,6 +1876,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1330
1876
  deferredTerminalReason: null,
1331
1877
  policy: continuationPolicy,
1332
1878
  createdAt: Date.now(),
1879
+ requestedAt: startOptions?.requestedAt ?? Date.now(),
1333
1880
  lastInboundAt: Date.now(),
1334
1881
  lastHeartbeatWriteAt: Date.now(),
1335
1882
  semanticDeadlineAt: Date.now() + continuationPolicy.semanticIdleMs,
@@ -1338,26 +1885,22 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1338
1885
  };
1339
1886
  sessionManager.registerSession(session);
1340
1887
  attachSessionHeartbeat(session);
1341
- const abortIfNeeded = (stream) => {
1342
- if (!session.closed && !callOptions.abortSignal?.aborted)
1888
+ const abortIfNeeded = (signal, stream) => {
1889
+ if (!session.closed && !signal?.aborted && !session.hostInterrupted)
1343
1890
  return;
1344
1891
  try {
1345
1892
  stream?.destroy();
1346
1893
  }
1347
1894
  catch { /* already closed */ }
1348
- if (callOptions.abortSignal?.aborted) {
1895
+ if (signal?.aborted || session.hostInterrupted) {
1349
1896
  throw new CursorLocalCancellationError("Cursor progress-only continuation cancelled");
1350
1897
  }
1351
1898
  throw new CursorProtocolError("Cannot reopen a closed Cursor session");
1352
1899
  };
1353
- session.reopenWithUserMessage = async (text) => {
1354
- abortIfNeeded();
1900
+ session.reopenWithUserMessage = async (text, signal) => {
1901
+ abortIfNeeded(signal);
1355
1902
  const freshToken = await resolveRunBearerToken(options);
1356
- abortIfNeeded();
1357
- // Do not pass OpenCode's abortSignal into the h2 stream: a tool-calls abort
1358
- // must not tear down a Run we still need to pump. Check abort around open.
1359
- const next = await bidiRunStream(freshToken, { baseURL: agentBaseUrl, headers: options.headers });
1360
- abortIfNeeded(next);
1903
+ abortIfNeeded(signal);
1361
1904
  const conversationState = session.resumeCheckpoint ?? getCheckpoint(session.conversationId);
1362
1905
  const nextRunId = crypto.randomUUID();
1363
1906
  const reqBytes = buildRunRequest({
@@ -1374,20 +1917,17 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1374
1917
  action: "user",
1375
1918
  mode: cursorAgentModeWireValue(getActiveCursorMode(session.openCodeSessionId)),
1376
1919
  });
1377
- try {
1378
- await writeWithBackpressure(next, reqBytes, "progress-only continuation Run");
1379
- }
1380
- catch (error) {
1381
- try {
1382
- next.destroy();
1383
- }
1384
- catch { }
1385
- throw error;
1386
- }
1387
- abortIfNeeded(next);
1920
+ const next = await openRunWithRequest(freshToken, {
1921
+ baseURL: agentBaseUrl, headers: options.headers,
1922
+ }, reqBytes, "progress-only continuation Run", {
1923
+ signal,
1924
+ stoppedWithSessionId: session.stoppedWithSessionId ?? session.openCodeSessionId,
1925
+ requestedAt: session.requestedAt ?? session.createdAt,
1926
+ });
1927
+ abortIfNeeded(signal, next);
1388
1928
  session.heartbeatCancel?.();
1389
1929
  await waitForStreamWrites(session.stream);
1390
- abortIfNeeded(next);
1930
+ abortIfNeeded(signal, next);
1391
1931
  sessionManager.replaceStream(session, next, nextRunId);
1392
1932
  attachSessionHeartbeat(session);
1393
1933
  };
@@ -1402,6 +1942,14 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1402
1942
  * OpenCode re-sends the full tool-result history on every continuation. Prefer
1403
1943
  * the newest result that still has a live pending exec on its tagged session.
1404
1944
  */
1945
+ /** Why the held Run for these results is gone, from the session manager's tombstones. */
1946
+ function describeHeldRunLoss(toolResults) {
1947
+ const reasons = new Set(toolResults.map((r) => {
1948
+ const classification = sessionManager.classify(r.sessionId, r.execId);
1949
+ return classification.kind === "deliverable" ? "open" : classification.reason;
1950
+ }));
1951
+ return [...reasons].join(",");
1952
+ }
1405
1953
  export function findContinuationSession(toolResults) {
1406
1954
  for (let i = toolResults.length - 1; i >= 0; i--) {
1407
1955
  const r = toolResults[i];
@@ -1457,16 +2005,40 @@ export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
1457
2005
  * prefix instead of `superseded-by-new-run` mid-exec.
1458
2006
  */
1459
2007
  export const FRESH_TURN_PENDING_CANCEL_REASON = "Host started a new user turn before this tool result was delivered";
2008
+ /** Persist an acknowledged terminal checkpoint without inventing usage counters. */
2009
+ async function persistTerminalCheckpoint(session) {
2010
+ if (!session.openCodeSessionId)
2011
+ return;
2012
+ await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
2013
+ sessionKey: session.openCodeSessionId,
2014
+ conversationId: session.conversationId,
2015
+ requestContext: session.requestContext,
2016
+ toolCatalog: session.toolCatalog ?? [],
2017
+ postCompactionRebase: session.postCompactionRebase,
2018
+ hostAgent: session.hostAgent,
2019
+ systemPromptHash: session.stableSystemPromptHash,
2020
+ hostNote: encodePersistedHostNotes(undeliveredHostNotes(session.openCodeSessionId)),
2021
+ }).catch((error) => {
2022
+ trace(`conversation persistence: terminal save failed sessionKey=${session.openCodeSessionId}: ${String(error)}`);
2023
+ });
2024
+ }
1460
2025
  /**
1461
- * Before opening a new user-turn Run, finish the prior held-open Run cleanly:
1462
- * settle display-only bridged pendings, cancel any real execs still waiting on
1463
- * the host, then drain for `turn_ended`. That preserves the same
1464
- * conversation_id / checkpoint prefix instead of aborting mid-tool.
2026
+ * Before opening a new user-turn Run, settle the held Run's pending results
2027
+ * and drain its terminal checkpoint. The unseen follow-up to results settled
2028
+ * here can finish through Cursor's cancellation contract; an unacknowledged
2029
+ * turn must rebase.
1465
2030
  */
1466
2031
  export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
1467
2032
  const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
1468
2033
  if (!prior)
1469
2034
  return "none";
2035
+ const hostCancel = cancelsInFlight.get(prior);
2036
+ if (hostCancel) {
2037
+ // The host stopped that turn; its cancel closes and saves the Run, and this turn resumes from it.
2038
+ await hostCancel;
2039
+ trace(`fresh turn: prior session ${prior.sessionId} was cancelled for the host's stop`);
2040
+ return "none";
2041
+ }
1470
2042
  const settledBridged = sessionManager.settleBridgedPending(prior);
1471
2043
  if (settledBridged > 0) {
1472
2044
  trace(`fresh turn: settled ${settledBridged} bridged pending on prior session ${prior.sessionId} ` +
@@ -1490,11 +2062,11 @@ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
1490
2062
  if (historicalPending.length > 0) {
1491
2063
  trace(`fresh turn: delivering ${historicalPending.length} historical tool result(s) ` +
1492
2064
  `for pending exec(s) on prior session ${prior.sessionId}`);
1493
- deliverContinuationResults(prior, historicalPending, { hostAgent: opts?.hostAgent });
2065
+ await deliverContinuationResults(prior, historicalPending, { hostAgent: opts?.hostAgent });
1494
2066
  if (prior.closed)
1495
2067
  return "settled-only";
1496
2068
  }
1497
- const cancelled = cancelPendingExecsForFreshTurn(prior);
2069
+ const cancelled = await cancelPendingExecsForFreshTurn(prior);
1498
2070
  if (cancelled > 0) {
1499
2071
  trace(`fresh turn: cancelled ${cancelled} pending exec(s) on prior session ${prior.sessionId} ` +
1500
2072
  `remainingPending=${prior.pending.size}`);
@@ -1510,11 +2082,22 @@ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
1510
2082
  `pending after cancel — leaving that Run held; registerSession will not close it`);
1511
2083
  return "busy";
1512
2084
  }
1513
- const outcome = await drainSessionUntilTurnEnded(prior, {
2085
+ let outcome = await drainSessionUntilTurnEnded(prior, {
1514
2086
  timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
1515
2087
  });
2088
+ if (outcome === "busy" && (historicalPending.length > 0 || cancelled > 0)) {
2089
+ // The host ended that turn (rejection, interruption, or an abandoned
2090
+ // call) before its next model call; OpenCode 1.x and a restarted host do
2091
+ // not say which. Cursor started a follow-up nobody will read. Cancel it
2092
+ // through Cursor's protocol action; retain continuity only with its
2093
+ // terminal acknowledgment and checkpoint.
2094
+ outcome = await drainSessionUntilTurnEnded(prior, {
2095
+ timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
2096
+ cancelReason: "host_new_user_turn",
2097
+ });
2098
+ }
1516
2099
  trace(`fresh turn: prior session ${prior.sessionId} drain outcome=${outcome}`);
1517
- if (outcome === "turn-ended")
2100
+ if (outcome === "turn-ended" || outcome === "cancelled")
1518
2101
  return "drained";
1519
2102
  if (cancelled > 0 || settledBridged > 0)
1520
2103
  return "settled-only";
@@ -1526,12 +2109,12 @@ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
1526
2109
  *
1527
2110
  * @returns number of pendings successfully cancelled
1528
2111
  */
1529
- export function cancelPendingExecsForFreshTurn(session) {
2112
+ export async function cancelPendingExecsForFreshTurn(session) {
1530
2113
  if (session.closed || session.pending.size === 0)
1531
2114
  return 0;
1532
2115
  const synthetic = [];
1533
2116
  for (const [execId, pending] of session.pending.entries()) {
1534
- if (pending.bridged)
2117
+ if (pending.bridged || pending.aborted)
1535
2118
  continue;
1536
2119
  if (pending.state !== "pending")
1537
2120
  continue;
@@ -1553,7 +2136,7 @@ export function cancelPendingExecsForFreshTurn(session) {
1553
2136
  if (synthetic.length === 0)
1554
2137
  return 0;
1555
2138
  const before = session.pending.size;
1556
- const delivered = deliverContinuationResults(session, synthetic);
2139
+ const delivered = await deliverContinuationResults(session, synthetic);
1557
2140
  if (!delivered || delivered.closed) {
1558
2141
  // deliverContinuationResults closes on encode/write failure after some
1559
2142
  // claims may already have succeeded.
@@ -1561,6 +2144,347 @@ export function cancelPendingExecsForFreshTurn(session) {
1561
2144
  }
1562
2145
  return Math.max(0, before - session.pending.size);
1563
2146
  }
2147
+ /** `ExecServerControlMessage`: Cursor withdrew an exec, e.g. when it stops the Run. */
2148
+ function applyExecControl(session, control, where) {
2149
+ const id = control.abort?.id;
2150
+ if (typeof id !== "number") {
2151
+ trace(`exec control: unknown message ${JSON.stringify(Object.keys(control))} (${where}) sessionId=${session.sessionId}`);
2152
+ return;
2153
+ }
2154
+ const pending = session.pending.get(id);
2155
+ // Interaction answers share the pending map under their own ids, never an exec's.
2156
+ const marked = pending !== undefined && isOwedCursorExec(pending) && sessionManager.markExecAborted(session, id);
2157
+ trace(`exec control: Cursor aborted execId=${id} (${where}) sessionId=${session.sessionId} ` +
2158
+ `${marked ? "— no result will be written for it" : "— not an exec the host still owes"}`);
2159
+ }
2160
+ const ASM_EXEC_SERVER_CONTROL_FIELD = 5;
2161
+ /** A frame the held reader may take: a server heartbeat, a KV request or exec control, and nothing else. */
2162
+ function heldControlFrame(frame) {
2163
+ if (frame.flags & 0x02)
2164
+ return undefined;
2165
+ let payload;
2166
+ try {
2167
+ payload = decodeFramePayload(frame);
2168
+ }
2169
+ catch {
2170
+ return undefined;
2171
+ }
2172
+ const fields = readAllFieldsStrict(payload);
2173
+ if (!fields || fields.length !== 1 || fields[0].wt !== 2)
2174
+ return undefined;
2175
+ const field = fields[0];
2176
+ if (field.fn === ASM_INTERACTION_UPDATE_FIELD) {
2177
+ const update = field.bytes ? readAllFieldsStrict(field.bytes) : undefined;
2178
+ const heartbeat = update?.length === 1
2179
+ && update[0].fn === INTERACTION_UPDATE_HEARTBEAT_FIELD
2180
+ && update[0].wt === 2;
2181
+ return heartbeat ? { kind: "heartbeat" } : undefined;
2182
+ }
2183
+ if (field.fn !== ASM_KV_SERVER_MESSAGE_FIELD && field.fn !== ASM_EXEC_SERVER_CONTROL_FIELD)
2184
+ return undefined;
2185
+ let asm;
2186
+ try {
2187
+ asm = decodeMessage("AgentServerMessage", payload);
2188
+ }
2189
+ catch {
2190
+ return undefined;
2191
+ }
2192
+ const kv = asm.kv_server_message;
2193
+ if (kv)
2194
+ return { kind: "kv", kv };
2195
+ const control = asm.exec_server_control_message;
2196
+ return control ? { kind: "exec-control", control } : undefined;
2197
+ }
2198
+ const heldRunReaders = new WeakSet();
2199
+ /** Unread frames then wait for the pump, under HTTP/2 flow control, as before the reader. */
2200
+ const MAX_HELD_FRAMES = 1_000;
2201
+ function isHeldRunIdle(session, frames) {
2202
+ return !session.closed
2203
+ && !session.hostInterrupted
2204
+ && !sessionManager.isActivelyPumping(session)
2205
+ && session.pending.size > 0
2206
+ && session.frames === frames
2207
+ && (session.heldFrames?.length ?? 0) < MAX_HELD_FRAMES;
2208
+ }
2209
+ /** An abort the reader may apply now: its exec is registered and still waits on the host. */
2210
+ function abortsPendingExec(session, control) {
2211
+ const id = control.abort?.id;
2212
+ const pending = typeof id === "number" ? session.pending.get(id) : undefined;
2213
+ return !!pending && isOwedCursorExec(pending) && pending.state === "pending" && !pending.aborted;
2214
+ }
2215
+ /**
2216
+ * While the host runs a step's tools nothing pumps the Run, but Cursor keeps
2217
+ * writing: right after a step it sends `tool_requests_listed` or a checkpoint,
2218
+ * then KV blob writes, and it may abort an exec. Cursor CLI's single reader
2219
+ * handles KV and exec control on their own channels at once; an unanswered KV
2220
+ * write left Cursor waiting until the tool finished (seen live). Answer KV,
2221
+ * apply an abort for an exec still waiting on the host, and skip heartbeats;
2222
+ * queue every other frame in `heldFrames`, in arrival order, for the next pump.
2223
+ * An abort whose exec request is still queued stays queued behind it.
2224
+ *
2225
+ * The reader peeks the shared `pendingFrameRead`: a pump that starts meanwhile
2226
+ * awaits the same read, and the reader leaves that frame to it.
2227
+ */
2228
+ export function readHeldRun(session) {
2229
+ const frames = session.frames;
2230
+ if (!isHeldRunIdle(session, frames))
2231
+ return;
2232
+ // A reader from before the last pump may still be waiting on the stream.
2233
+ applyQueuedAborts(session);
2234
+ if (heldRunReaders.has(session))
2235
+ return;
2236
+ heldRunReaders.add(session);
2237
+ void readHeldFrames(session, frames).finally(() => heldRunReaders.delete(session));
2238
+ }
2239
+ /**
2240
+ * An abort queued behind its own exec request waits only until a pump has
2241
+ * registered that exec. If the pump ended its step before reaching the abort,
2242
+ * apply it now, as Cursor CLI does on its control channel; otherwise the exec
2243
+ * runs, keeps its heartbeats and has its late result written.
2244
+ */
2245
+ function applyQueuedAborts(session) {
2246
+ const queued = session.heldFrames;
2247
+ if (!queued?.length)
2248
+ return;
2249
+ for (let index = 0; index < queued.length;) {
2250
+ const held = heldControlFrame(queued[index]);
2251
+ if (held?.kind === "exec-control" && abortsPendingExec(session, held.control)) {
2252
+ queued.splice(index, 1);
2253
+ applyExecControl(session, held.control, "held queue");
2254
+ }
2255
+ else {
2256
+ index++;
2257
+ }
2258
+ }
2259
+ }
2260
+ async function readHeldFrames(session, frames) {
2261
+ while (isHeldRunIdle(session, frames)) {
2262
+ applyQueuedAborts(session);
2263
+ const read = session.pendingFrameRead ??= frames.next();
2264
+ void read.catch(() => { });
2265
+ let result;
2266
+ try {
2267
+ result = await read;
2268
+ }
2269
+ catch {
2270
+ return; // the next pump reads the same rejection, after the queued frames
2271
+ }
2272
+ // A pump started meanwhile reads this frame itself; an ended stream stays for it too.
2273
+ if (!isHeldRunIdle(session, frames) || session.pendingFrameRead !== read || result.done)
2274
+ return;
2275
+ session.pendingFrameRead = undefined;
2276
+ sessionManager.recordSemanticProgress(session);
2277
+ const frame = result.value;
2278
+ const held = heldControlFrame(frame);
2279
+ const kvReply = held?.kind === "kv" ? handleKvServerMessage(held.kv, session) : undefined;
2280
+ if (held?.kind === "heartbeat")
2281
+ continue;
2282
+ if (kvReply) {
2283
+ try {
2284
+ await writeWithBackpressure(session.stream, kvReply.reply, `held KV ${kvReply.kind}_blob reply id=${kvReply.id}`);
2285
+ trace(`held Run: answered KV ${kvReply.kind} id=${kvReply.id} sessionId=${session.sessionId}`);
2286
+ }
2287
+ catch (error) {
2288
+ trace(`held Run: KV reply failed sessionId=${session.sessionId}: ${error.message}`);
2289
+ sessionManager.close(session, "reply-write-failed", toCursorProviderError(error, {
2290
+ replaySafe: false,
2291
+ fallback: "Cursor KV reply failed",
2292
+ }));
2293
+ return;
2294
+ }
2295
+ continue;
2296
+ }
2297
+ if (held?.kind === "exec-control" && abortsPendingExec(session, held.control)) {
2298
+ applyExecControl(session, held.control, "held");
2299
+ if (![...session.pending.values()].some((pending) => isOwedCursorExec(pending) && !pending.aborted)) {
2300
+ trace(`held Run: Cursor aborted every exec it was waiting on sessionId=${session.sessionId}`);
2301
+ }
2302
+ continue;
2303
+ }
2304
+ // Everything else, a KV request this client cannot answer included (the
2305
+ // pump fails the Run on it), waits for the next pump in order.
2306
+ ;
2307
+ (session.heldFrames ??= []).push(frame);
2308
+ trace(`held Run: queued a frame for the next pump queued=${session.heldFrames.length} sessionId=${session.sessionId}`);
2309
+ if (frame.flags & 0x02)
2310
+ return;
2311
+ }
2312
+ }
2313
+ /** In-progress saves still writing; see `settleRunSavesForTests`. */
2314
+ const runSavesInFlight = new Set();
2315
+ /** Save a Run's conversation before Cursor ends its turn, with the latest checkpoint Cursor sent. */
2316
+ function persistRunInProgress(session, where) {
2317
+ const sessionKey = session.openCodeSessionId;
2318
+ if (!sessionKey)
2319
+ return Promise.resolve();
2320
+ const save = persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
2321
+ sessionKey,
2322
+ conversationId: session.conversationId,
2323
+ requestContext: session.requestContext,
2324
+ toolCatalog: session.toolCatalog ?? [],
2325
+ postCompactionRebase: session.postCompactionRebase,
2326
+ hostAgent: session.hostAgent,
2327
+ systemPromptHash: session.stableSystemPromptHash,
2328
+ hostNote: encodePersistedHostNotes(undeliveredHostNotes(sessionKey)),
2329
+ runInProgress: true,
2330
+ }).catch((error) => {
2331
+ trace(`conversation persistence: ${where} save failed sessionKey=${sessionKey}: ${String(error)}`);
2332
+ });
2333
+ runSavesInFlight.add(save);
2334
+ void save.finally(() => runSavesInFlight.delete(save));
2335
+ return save;
2336
+ }
2337
+ /** How long a Run must wait on host tools before its state is saved for a restart. */
2338
+ export const HELD_RUN_SAVE_DELAY_MS = 1_000;
2339
+ const heldRunSaveTimers = new WeakMap();
2340
+ // A step that ended in a tool call is recorded only in memory until TurnEnded.
2341
+ // Save it once the host's tool runs a while, so a Stop or a restart during a
2342
+ // shell, question or permission prompt still finds this step on disk.
2343
+ function scheduleHeldRunSave(session) {
2344
+ if (!session.openCodeSessionId)
2345
+ return;
2346
+ clearTimeout(heldRunSaveTimers.get(session));
2347
+ const timer = setTimeout(() => {
2348
+ heldRunSaveTimers.delete(session);
2349
+ if (session.closed || session.hostInterrupted || session.pending.size === 0)
2350
+ return;
2351
+ if (sessionManager.isActivelyPumping(session))
2352
+ return;
2353
+ void persistRunInProgress(session, "held Run");
2354
+ }, HELD_RUN_SAVE_DELAY_MS);
2355
+ timer.unref?.();
2356
+ heldRunSaveTimers.set(session, timer);
2357
+ }
2358
+ /** Cursor CLI's cancel reason for a user Stop. */
2359
+ const HOST_INTERRUPT_CANCEL_REASON = "user_cancelled";
2360
+ /** How long a cancelled Run may take to end by itself before its stream is closed. */
2361
+ export const HOST_INTERRUPT_GRACE_MS = 3_000;
2362
+ /**
2363
+ * The host stopped the turn. OpenCode 2 never aborts `doStream` for that, so
2364
+ * the Run would keep generating, and a held one would wait on results that
2365
+ * never come. Cancel it as Cursor CLI's Stop does: forget what the host owed,
2366
+ * send `cancel_action`, give Cursor a moment to end the Run, then close it.
2367
+ * A Run that ends without `turn_ended` is saved with the last checkpoint
2368
+ * Cursor sent, so the next turn resumes it and recognizes the stopped step.
2369
+ */
2370
+ export function cancelRunForHostInterrupt(session, reason, opts) {
2371
+ if (session.closed || session.hostInterrupted)
2372
+ return cancelsInFlight.get(session) ?? Promise.resolve();
2373
+ const cancel = cancelRun(session, reason, opts).catch((error) => {
2374
+ trace(`host interrupt: cancel failed sessionId=${session.sessionId} err=${error.message}`);
2375
+ });
2376
+ cancelsInFlight.set(session, cancel);
2377
+ void cancel.finally(() => cancelsInFlight.delete(session));
2378
+ return cancel;
2379
+ }
2380
+ async function cancelRun(session, reason, opts) {
2381
+ session.hostInterrupted = reason;
2382
+ forgetRetryBudget(session.openCodeSessionId);
2383
+ dropLostRequest(session.openCodeSessionId);
2384
+ // Stopped while its turn end was being saved, the host may have kept only part of the step.
2385
+ if (session.openCodeSessionId && sessionManager.isActivelyPumping(session)) {
2386
+ resumeEmittedStep(session.openCodeSessionId, session.conversationId);
2387
+ }
2388
+ const graceMs = opts?.graceMs ?? HOST_INTERRUPT_GRACE_MS;
2389
+ const dropped = sessionManager.abandonPending(session);
2390
+ session.heartbeatCancel?.();
2391
+ trace(`host interrupt: cancelling Run sessionId=${session.sessionId} reason=${reason} ` +
2392
+ `pumping=${sessionManager.isActivelyPumping(session)} droppedPending=${dropped}`);
2393
+ const startedAt = Date.now();
2394
+ const remainingMs = () => Math.max(1, graceMs - (Date.now() - startedAt));
2395
+ let outcome;
2396
+ const write = writeWithBackpressure(session.stream, buildCancelAction(HOST_INTERRUPT_CANCEL_REASON), "cancel action");
2397
+ let timer;
2398
+ const writeTimedOut = new Promise((resolve) => {
2399
+ timer = setTimeout(() => resolve("write-timeout"), remainingMs());
2400
+ timer.unref?.();
2401
+ });
2402
+ try {
2403
+ outcome = await Promise.race([write.then(() => "written"), writeTimedOut]);
2404
+ }
2405
+ catch (error) {
2406
+ trace(`host interrupt: cancel write failed sessionId=${session.sessionId} err=${error.message}`);
2407
+ outcome = "write-failed";
2408
+ }
2409
+ finally {
2410
+ clearTimeout(timer);
2411
+ write.catch(() => { });
2412
+ }
2413
+ if (outcome === "written") {
2414
+ // A pump stops at its next frame and leaves it queued for the drain.
2415
+ if (await waitUntilNotPumping(session, { timeoutMs: remainingMs() })) {
2416
+ // The pump may have taken an exec it was already handling.
2417
+ sessionManager.abandonPending(session);
2418
+ outcome = await drainSessionUntilTurnEnded(session, { timeoutMs: remainingMs(), cancelled: true });
2419
+ }
2420
+ else {
2421
+ outcome = "still-pumping";
2422
+ }
2423
+ }
2424
+ trace(`host interrupt: Run sessionId=${session.sessionId} outcome=${outcome} afterMs=${Date.now() - startedAt}`);
2425
+ const saved = outcome === "turn-ended" || outcome === "cancelled";
2426
+ if (!saved)
2427
+ keepUndeliveredHostNotes(session);
2428
+ sessionManager.close(session, "host-interrupted");
2429
+ // Closing leaves the conversation's checkpoint and blobs in place for this save.
2430
+ if (!saved)
2431
+ await persistRunInProgress(session, "host interrupt");
2432
+ }
2433
+ const HOST_INTERRUPT_MEMORY_MS = 10 * 60_000;
2434
+ /** The latest stop per OpenCode session, for a Run whose model call started before it but opened after it. */
2435
+ const hostInterrupts = new Map();
2436
+ /** Cancels still draining; a shutdown or the next turn waits for them so their final save is not cut off. */
2437
+ const cancelsInFlight = new Map();
2438
+ /**
2439
+ * Test helper: wait for host-interrupt cancels and in-progress Run saves, which
2440
+ * write after the call that started them returned. Close the Runs first, so a
2441
+ * held Run's save timer finds them closed instead of starting another save.
2442
+ */
2443
+ export async function settleRunSavesForTests() {
2444
+ while (cancelsInFlight.size > 0 || runSavesInFlight.size > 0) {
2445
+ await Promise.all([...cancelsInFlight.values(), ...runSavesInFlight]);
2446
+ }
2447
+ }
2448
+ function cancelForHostInterrupt(session, reason) {
2449
+ void cancelRunForHostInterrupt(session, reason);
2450
+ }
2451
+ /** Runs of the stopped call were requested before the stop; the next turn's after it. */
2452
+ function requestedBefore(session, at) {
2453
+ return (session.requestedAt ?? session.createdAt) < at;
2454
+ }
2455
+ export function cancelIfHostStoppedSince(session) {
2456
+ const sessionId = stoppedWith(session);
2457
+ const stop = sessionId ? hostInterrupts.get(sessionId) : undefined;
2458
+ if (stop && requestedBefore(session, stop.at))
2459
+ cancelForHostInterrupt(session, stop.reason);
2460
+ }
2461
+ onHostInterrupt(async (openCodeSessionId, reason, at) => {
2462
+ if (reason === "shutdown") {
2463
+ // The next server start resumes the turn; keep what it needs on disk. A Run
2464
+ // still being cancelled is saved now too, in case its drain is cut off.
2465
+ const open = sessionManager.openSessionsStoppedWith(openCodeSessionId)
2466
+ .filter((session) => session.openCodeSessionId);
2467
+ if (open.length > 0)
2468
+ trace(`host interrupt: shutdown — saving ${open.length} open Run(s)`);
2469
+ await Promise.all([
2470
+ ...open.map((session) => persistRunInProgress(session, "shutdown")),
2471
+ ...cancelsInFlight.values(),
2472
+ ]);
2473
+ return;
2474
+ }
2475
+ if (!openCodeSessionId)
2476
+ return;
2477
+ for (const [id, stop] of hostInterrupts) {
2478
+ if (stop.at < at - HOST_INTERRUPT_MEMORY_MS)
2479
+ hostInterrupts.delete(id);
2480
+ }
2481
+ forgetRetryBudget(openCodeSessionId);
2482
+ hostInterrupts.set(openCodeSessionId, { at, reason });
2483
+ for (const session of sessionManager.openSessionsStoppedWith(openCodeSessionId)) {
2484
+ if (requestedBefore(session, at))
2485
+ cancelForHostInterrupt(session, reason);
2486
+ }
2487
+ });
1564
2488
  async function waitUntilNotPumping(session, opts) {
1565
2489
  if (!sessionManager.isActivelyPumping(session))
1566
2490
  return true;
@@ -1580,7 +2504,11 @@ async function waitUntilNotPumping(session, opts) {
1580
2504
  /**
1581
2505
  * Read remaining frames on an idle held-open Run until `turn_ended`, a
1582
2506
  * response-requiring request we cannot answer without the host, or timeout.
1583
- * Used only on the fresh-turn settle path — not a general pump substitute.
2507
+ * Used only to settle a Run the host left (a fresh turn, a host interrupt) —
2508
+ * not a general pump substitute. `cancelReason` sends `cancel_action` first;
2509
+ * `cancelled`: the caller already sent it. Either way nothing the Run still
2510
+ * sends (display updates, an exec already in flight) needs the host, and
2511
+ * Cursor's closing frames (the final checkpoint) are worth reading.
1584
2512
  */
1585
2513
  export async function drainSessionUntilTurnEnded(session, opts) {
1586
2514
  if (session.closed)
@@ -1592,7 +2520,15 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1592
2520
  sessionManager.beginPump(session, owner);
1593
2521
  const deadlineAt = Date.now() + timeoutMs;
1594
2522
  let outcome = "timeout";
2523
+ let cancellationCheckpoint = false;
2524
+ const cancelling = opts?.cancelReason !== undefined || opts?.cancelled === true;
1595
2525
  try {
2526
+ if (opts?.cancelReason) {
2527
+ session.heartbeatCancel?.();
2528
+ await writeWithBackpressure(session.stream, encodeMessage("AgentClientMessage", {
2529
+ conversation_action: { cancel_action: { reason: opts.cancelReason } },
2530
+ }), "fresh-turn cancellation");
2531
+ }
1596
2532
  while (Date.now() < deadlineAt) {
1597
2533
  if (session.closed) {
1598
2534
  outcome = "interrupted";
@@ -1618,7 +2554,22 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1618
2554
  break;
1619
2555
  }
1620
2556
  const frame = next.value;
2557
+ sessionManager.recordSemanticProgress(session);
1621
2558
  if (frame.flags & 0x02) {
2559
+ if (cancelling) {
2560
+ try {
2561
+ const failure = connectFrameError(new TextDecoder().decode(decodeFramePayload(frame)));
2562
+ trace(`fresh turn drain: cancellation terminal code=${failure.code ?? "unknown"} checkpoint=${cancellationCheckpoint}`);
2563
+ if (failure.origin === "server" && failure.code === "canceled" && cancellationCheckpoint) {
2564
+ keepUndeliveredHostNotes(session);
2565
+ await persistTerminalCheckpoint(session);
2566
+ sessionManager.close(session);
2567
+ outcome = "cancelled";
2568
+ break;
2569
+ }
2570
+ }
2571
+ catch { /* an invalid terminal acknowledgment cannot preserve continuity */ }
2572
+ }
1622
2573
  outcome = "interrupted";
1623
2574
  break;
1624
2575
  }
@@ -1642,13 +2593,22 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1642
2593
  const interactionQuery = asm.interaction_query;
1643
2594
  const checkpointRaw = asm.conversation_checkpoint_update;
1644
2595
  const requiredChannel = responseRequiredChannel(payload);
2596
+ const execControl = asm.exec_server_control_message;
2597
+ if (execControl) {
2598
+ applyExecControl(session, execControl, "drain");
2599
+ continue;
2600
+ }
1645
2601
  if (checkpointRaw != null) {
1646
2602
  const bytes = normalizeCheckpointBytes(checkpointRaw);
1647
2603
  if (bytes && bytes.length > 0) {
2604
+ cancellationCheckpoint = true;
1648
2605
  if (session.cacheDiagnostics)
1649
2606
  session.cacheDiagnostics.checkpointUpdates++;
1650
2607
  setCheckpoint(session.conversationId, bytes);
1651
- session.resumeCheckpoint = Uint8Array.from(bytes);
2608
+ noteCheckpoint(session.openCodeSessionId, bytes);
2609
+ acceptResumeCheckpoint(session, bytes, frame);
2610
+ markEarlySteersCheckpointed(session.openCodeSessionId, session.sessionId);
2611
+ noteRunImageCheckpoint(session, bytes, frame);
1652
2612
  const tokenDetails = decodeConversationTokenDetails(bytes);
1653
2613
  if (tokenDetails) {
1654
2614
  if (session.cacheDiagnostics)
@@ -1658,8 +2618,17 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1658
2618
  }
1659
2619
  }
1660
2620
  }
2621
+ if (iu?.context_injection_state)
2622
+ applyContextInjectionState(session, iu.context_injection_state);
1661
2623
  if (iu?.turn_ended) {
2624
+ if (opts?.cancelReason && !cancellationCheckpoint) {
2625
+ outcome = "interrupted";
2626
+ break;
2627
+ }
1662
2628
  trace(`fresh turn drain: turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
2629
+ keepUndeliveredHostNotes(session);
2630
+ settleEarlySteers(session.openCodeSessionId, session.sessionId);
2631
+ confirmRunImagesAtTurnEnd(session);
1663
2632
  if (session.openCodeSessionId) {
1664
2633
  await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
1665
2634
  sessionKey: session.openCodeSessionId,
@@ -1669,6 +2638,7 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1669
2638
  postCompactionRebase: session.postCompactionRebase,
1670
2639
  hostAgent: session.hostAgent,
1671
2640
  systemPromptHash: session.stableSystemPromptHash,
2641
+ hostNote: encodePersistedHostNotes(undeliveredHostNotes(session.openCodeSessionId)),
1672
2642
  }).catch((error) => {
1673
2643
  trace(`fresh turn drain: TurnEnded save failed ` +
1674
2644
  `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
@@ -1678,6 +2648,7 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1678
2648
  // even though no OpenCode stream consumer will see this finish.
1679
2649
  const turnEnded = iu.turn_ended;
1680
2650
  const counters = cursorUsageCountersFromTurnEnded(turnEnded);
2651
+ recordTurnEndedCost(session, turnEnded);
1681
2652
  if (session.cacheDiagnostics) {
1682
2653
  trace(formatCursorCacheDiagnostics(counters, session.tokenDetails, session.cacheDiagnostics.priorTokenDetails, {
1683
2654
  ...session.cacheDiagnostics,
@@ -1709,7 +2680,6 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1709
2680
  outcome = "interrupted";
1710
2681
  break;
1711
2682
  }
1712
- sessionManager.recordSemanticProgress(session);
1713
2683
  continue;
1714
2684
  }
1715
2685
  // The model is answering in a Run the host no longer reads: that output
@@ -1717,30 +2687,33 @@ export async function drainSessionUntilTurnEnded(session, opts) {
1717
2687
  // once instead of paying for (and waiting on) an invisible answer; only
1718
2688
  // the control tail of a turn that is already ending is worth draining.
1719
2689
  if (iu?.text_delta || iu?.thinking_delta || iu?.partial_tool_call) {
2690
+ if (cancelling)
2691
+ continue;
1720
2692
  trace(`fresh turn drain: model output resumed sessionId=${session.sessionId}`);
1721
2693
  outcome = "busy";
1722
2694
  break;
1723
2695
  }
1724
- if (iu?.heartbeat || iu?.step_started || iu?.step_completed) {
1725
- sessionManager.recordSemanticProgress(session);
2696
+ if (iu?.heartbeat || iu?.step_started || iu?.step_completed)
1726
2697
  continue;
1727
- }
1728
2698
  if (iu?.tool_call_completed) {
1729
2699
  // Display close of a call Cursor already has its answer for (the
1730
2700
  // result this drain just delivered, typically). Cursor does not wait
1731
2701
  // on it, and its checkpoint and turn_ended follow; stopping here would
1732
2702
  // supersede the Run and lose that turn. Its display mirror, if any,
1733
2703
  // has no host consumer on this path.
1734
- sessionManager.recordSemanticProgress(session);
1735
2704
  trace(`fresh turn drain: skipped display tool_call_completed sessionId=${session.sessionId}`);
1736
2705
  continue;
1737
2706
  }
1738
2707
  if (iu?.tool_call_started) {
2708
+ if (cancelling)
2709
+ continue;
1739
2710
  // A new tool needs host mediation — stop draining.
1740
2711
  outcome = "busy";
1741
2712
  break;
1742
2713
  }
1743
2714
  if (esm || interactionQuery || requiredChannel === "exec" || requiredChannel === "interaction") {
2715
+ if (cancelling)
2716
+ continue;
1744
2717
  outcome = "busy";
1745
2718
  break;
1746
2719
  }
@@ -1860,6 +2833,11 @@ function buildSwitchModeContinuationFrame(pending, result) {
1860
2833
  * cleared). Returns undefined after closing the session when a write fails, so
1861
2834
  * the caller can rebase onto a fresh Run instead of pumping a dead stream.
1862
2835
  */
2836
+ function isShellResultField(resultField) {
2837
+ return resultField === "shell_stream"
2838
+ || resultField === "shell_result"
2839
+ || resultField === "background_shell_spawn_result";
2840
+ }
1863
2841
  /** Host primary agent of one request, as reported through provider options. */
1864
2842
  function hostAgentFromCallOptions(callOptions) {
1865
2843
  const providerOptions = callOptions.providerOptions?.cursor;
@@ -1867,14 +2845,35 @@ function hostAgentFromCallOptions(callOptions) {
1867
2845
  ? String(providerOptions[CURSOR_HOST_AGENT_OPTION]).trim() || undefined
1868
2846
  : undefined;
1869
2847
  }
1870
- export function deliverContinuationResults(session, trailingToolResults,
2848
+ const continuationDeliveryChains = new WeakMap();
2849
+ export async function deliverContinuationResults(session, trailingToolResults, delivery = {}) {
2850
+ // Claim, result writes, state transitions and the step's note injection form
2851
+ // one delivery. Serializing individual frames cannot prevent two callers
2852
+ // from splitting a step's claims and each injecting the same notes.
2853
+ const previous = continuationDeliveryChains.get(session);
2854
+ const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
2855
+ .then(() => deliverContinuationResultsNow(session, trailingToolResults, delivery));
2856
+ continuationDeliveryChains.set(session, current);
2857
+ try {
2858
+ return await current;
2859
+ }
2860
+ finally {
2861
+ if (continuationDeliveryChains.get(session) === current)
2862
+ continuationDeliveryChains.delete(session);
2863
+ }
2864
+ }
2865
+ async function deliverContinuationResultsNow(session, trailingToolResults,
1871
2866
  /** Host primary agent of the request that carries these results. */
1872
2867
  delivery = {}) {
1873
2868
  const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
1874
2869
  trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
1875
2870
  `${pendingResults.length} pending for sessionId=${session.sessionId} ` +
1876
2871
  `pending={${[...session.pending.keys()].join(",")}}`);
1877
- for (const r of pendingResults) {
2872
+ // Host notes that trailed this step: injected once, after its results.
2873
+ const stepNotes = collectHostNoteBodies(trailingToolResults.flatMap((r) => r.notes ?? []));
2874
+ let deliveredResults = 0;
2875
+ for (const original of pendingResults) {
2876
+ const r = original.error === "" ? { ...original, error: "OpenCode tool execution failed" } : original;
1878
2877
  const claim = sessionManager.claim(session.sessionId, r.execId);
1879
2878
  if ("kind" in claim) {
1880
2879
  if (claim.kind === "deliverable") {
@@ -1938,11 +2937,14 @@ delivery = {}) {
1938
2937
  return undefined;
1939
2938
  }
1940
2939
  }
2940
+ else if (pending.aborted) {
2941
+ if (isShellResultField(pending.resultField))
2942
+ consumeCursorShellResult(r.toolCallId, r.output);
2943
+ trace(`continuation: dropped the result of execId=${r.execId}, which Cursor aborted`);
2944
+ }
1941
2945
  else if (!pending.bridged) {
1942
2946
  try {
1943
- const shellResult = pending.resultField === "shell_stream"
1944
- || pending.resultField === "shell_result"
1945
- || pending.resultField === "background_shell_spawn_result"
2947
+ const shellResult = isShellResultField(pending.resultField)
1946
2948
  ? consumeCursorShellResult(r.toolCallId, r.output)
1947
2949
  : undefined;
1948
2950
  const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
@@ -2016,14 +3018,19 @@ delivery = {}) {
2016
3018
  })];
2017
3019
  }
2018
3020
  if (frames.length === 0) {
2019
- const images = r.error ? [] : execResultImages(pending.resultField, r.images);
3021
+ const error = r.error ?? shellResult?.error;
3022
+ const images = error ? [] : execResultImages(pending.resultField, r.images);
3023
+ // A failed host tool reports one text as both output and error. Include
3024
+ // failures captured by the shell hook when the SDK keeps only text.
3025
+ // Distinct partial command output still belongs on stdout.
3026
+ const failureText = shellResult !== undefined && error !== undefined && r.output === error;
2020
3027
  frames = buildExecClientMessages({
2021
3028
  execId: r.execId,
2022
3029
  resultField: pending.resultField,
2023
- output: pending.resultField === "read_result" && r.notes && r.output.endsWith(r.notes)
2024
- ? r.output.slice(0, -r.notes.length).trimEnd()
2025
- : shellResult?.output ?? r.output,
2026
- error: r.error,
3030
+ output: failureText ? "" : shellResult?.output ?? r.output,
3031
+ error,
3032
+ refusal: r.refusal,
3033
+ interrupted: r.interrupted,
2027
3034
  toolName: pending.toolName ?? r.toolName,
2028
3035
  resultMetadata: pending.resultMetadata,
2029
3036
  shellOutcome: shellResult?.outcome,
@@ -2042,27 +3049,33 @@ delivery = {}) {
2042
3049
  return undefined;
2043
3050
  }
2044
3051
  }
2045
- if (r.notes && !pending.bridged && (pending.resultField === "read_result" || r.error)) {
2046
- // Native read output is a content/data oneof; typed errors also discard
2047
- // output text. Inject updates before releasing the waiting executor.
2048
- frames.unshift(encodeMessage("AgentClientMessage", {
2049
- conversation_action: { inject_context_action: {
2050
- injection_id: crypto.randomUUID(),
2051
- ...(session.runId ? { expected_run_id: session.runId } : {}),
2052
- system_context: { producer: "opencode", content: r.notes },
2053
- } },
2054
- }));
2055
- }
2056
- const outcome = sessionManager.deliverClaim(claim, frames);
3052
+ // A provider notice about this one result rides on it as postToolUse hook
3053
+ // context (e.g. images a read could not keep).
3054
+ const hookContexts = hostNoteHookContexts(r.notices ?? []);
3055
+ const notedFrames = hookContexts.length > 0
3056
+ ? attachHookAdditionalContexts(frames, hookContexts)
3057
+ : undefined;
3058
+ if (notedFrames)
3059
+ frames = notedFrames;
3060
+ // Ignore checkpoints from before the final result frame's actual write,
3061
+ // including time spent queued behind another write, but admit those that
3062
+ // arrive while that frame drains. Earlier result frames are incomplete.
3063
+ let beforeResultWrite = lastFrameArrival();
3064
+ const outcome = await sessionManager.deliverClaim(claim, frames, () => {
3065
+ beforeResultWrite = lastFrameArrival();
3066
+ });
2057
3067
  if (outcome.kind !== "delivered") {
2058
3068
  trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`);
2059
3069
  if (outcome.kind === "duplicate")
2060
3070
  continue;
2061
3071
  return undefined;
2062
3072
  }
2063
- // Cursor now holds these in the exec result; the next fresh Run must not
2064
- // attach them again as history images.
2065
- rememberSentHistoryImageHashes(session.openCodeSessionId, deliveredImageHashes);
3073
+ deliveredResults++;
3074
+ if (outcome.framesWritten > 0 || (pending.bridged && !pending.aborted)) {
3075
+ invalidateResumeCheckpoint(session, beforeResultWrite, true);
3076
+ }
3077
+ if (session.historyImages)
3078
+ noteHistoryImagesSent(session.historyImages, deliveredImageHashes, lastFrameArrival());
2066
3079
  if (deliveredSwitchMode) {
2067
3080
  const normalized = deliveredSwitchMode.target.toLowerCase();
2068
3081
  setActiveCursorMode(session.openCodeSessionId, deliveredSwitchMode.target, {
@@ -2102,22 +3115,428 @@ delivery = {}) {
2102
3115
  trace(`continuation: mirrored todoread snapshot items=${snapshot.length}`);
2103
3116
  }
2104
3117
  }
2105
- session.usageEstimate.inputTokens += estimateTokens(r.output.length);
2106
- if (pending.bridged) {
2107
- trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
3118
+ if (pending.aborted)
3119
+ continue;
3120
+ session.usageEstimate.inputTokens += estimateTokens(r.output.length);
3121
+ if (pending.bridged) {
3122
+ trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
3123
+ continue;
3124
+ }
3125
+ const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
3126
+ ? "ask_question answer"
3127
+ : pending.resultField === SWITCH_MODE_RESULT_FIELD
3128
+ ? "switch_mode answer"
3129
+ : pending.resultField === CREATE_PLAN_RESULT_FIELD
3130
+ ? "create_plan answer"
3131
+ : "exec result";
3132
+ trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
3133
+ `frames=${outcome.framesWritten} outLen=${r.output.length}`);
3134
+ }
3135
+ if (stepNotes.length > 0) {
3136
+ if (deliveredResults === 0) {
3137
+ // Every result of this step was already delivered, and its notes with it.
3138
+ trace(`continuation: host notes belong to an already delivered step; not injected again count=${stepNotes.length}`);
3139
+ }
3140
+ else if (!(await injectHostNotes(session, stepNotes))) {
3141
+ return undefined;
3142
+ }
3143
+ }
3144
+ return session;
3145
+ }
3146
+ /**
3147
+ * Add host notes to the running turn as Cursor SDK `Run.steer` does:
3148
+ * `inject_context_action.user_context` with plain text, bound to this Run.
3149
+ * Written after the step's results, so Cursor sees them before its next step.
3150
+ */
3151
+ async function injectHostNotes(session, notes, order = nextHostNoteOrder++) {
3152
+ const text = hostNoteText(notes);
3153
+ if (!text)
3154
+ return true;
3155
+ const injectionId = crypto.randomUUID();
3156
+ const sessionKey = session.openCodeSessionId;
3157
+ if (sessionKey)
3158
+ trackHostNoteInjection(sessionKey, injectionId, [...notes], session, order);
3159
+ try {
3160
+ await writeWithBackpressure(session.stream, encodeMessage("AgentClientMessage", {
3161
+ conversation_action: {
3162
+ inject_context_action: {
3163
+ injection_id: injectionId,
3164
+ ...(session.runId ? { expected_run_id: session.runId } : {}),
3165
+ user_context: { user_message: { text, message_id: crypto.randomUUID() } },
3166
+ },
3167
+ },
3168
+ }), "host note injection");
3169
+ trace(`continuation: host notes injected id=${injectionId} count=${notes.length} chars=${text.length}`);
3170
+ return true;
3171
+ }
3172
+ catch (error) {
3173
+ // The notes stay tracked against this closed Run and reach the next one.
3174
+ trace(`continuation: host note injection write failed id=${injectionId}: ${error.message}`);
3175
+ sessionManager.close(session, "result-write-failed", toCursorProviderError(error, {
3176
+ replaySafe: false, fallback: "Cursor host note injection write failed",
3177
+ }));
3178
+ return false;
3179
+ }
3180
+ }
3181
+ function trackHostNoteInjection(sessionKey, injectionId, notes, session, order) {
3182
+ const entries = hostNoteInjectionsBySession.get(sessionKey) ?? new Map();
3183
+ entries.set(injectionId, { notes, session, order });
3184
+ hostNoteInjectionsBySession.delete(sessionKey);
3185
+ hostNoteInjectionsBySession.set(sessionKey, entries);
3186
+ while (hostNoteInjectionsBySession.size > MAX_TURN_STATE_SESSIONS) {
3187
+ const oldest = hostNoteInjectionsBySession.keys().next().value;
3188
+ if (!oldest)
3189
+ break;
3190
+ hostNoteInjectionsBySession.delete(oldest);
3191
+ }
3192
+ }
3193
+ /**
3194
+ * `InteractionUpdate.context_injection_state` (#23) for an injection this
3195
+ * provider sent. Same outcomes as Cursor SDK `Run.steer`: `delivered` hands
3196
+ * the notes to the turn; `queued` waits; `queued_for_next_turn`, `cancelled`
3197
+ * and `rejected` keep them for the session's next user turn.
3198
+ * Returns true when Cursor delivered an early steer: the model answers it next.
3199
+ */
3200
+ function applyContextInjectionState(session, value) {
3201
+ const update = value;
3202
+ const [kind, detail] = Object.entries(update.state ?? {}).find(([, v]) => v != null) ?? ["unknown", undefined];
3203
+ const reason = detail?.reason;
3204
+ trace(`context_injection_state: id=${update.injection_id ?? ""} state=${kind}` +
3205
+ (reason ? ` reason=${JSON.stringify(reason)}` : ""));
3206
+ const sessionKey = session.openCodeSessionId;
3207
+ if (kind === "delivered" && markEarlySteerDelivered(sessionKey, update.injection_id))
3208
+ return true;
3209
+ const entries = sessionKey ? hostNoteInjectionsBySession.get(sessionKey) : undefined;
3210
+ const entry = update.injection_id ? entries?.get(update.injection_id) : undefined;
3211
+ if (!sessionKey || !entries || !entry || kind === "queued" || kind === "unknown")
3212
+ return false;
3213
+ entries.delete(update.injection_id);
3214
+ if (entries.size === 0)
3215
+ hostNoteInjectionsBySession.delete(sessionKey);
3216
+ if (kind === "delivered")
3217
+ return false;
3218
+ appendUndeliveredHostNotes(sessionKey, [{ notes: entry.notes, order: entry.order }]);
3219
+ trace(`continuation: host note injection ${kind}; kept for the next user turn sessionKey=${sessionKey}`);
3220
+ return false;
3221
+ }
3222
+ /**
3223
+ * Return unacknowledged injections to the next-user-turn store: those of
3224
+ * `owner` when its turn ended, else those whose Run has closed.
3225
+ */
3226
+ function releaseHostNoteInjections(sessionKey, reason, owner) {
3227
+ const entries = hostNoteInjectionsBySession.get(sessionKey);
3228
+ if (!entries)
3229
+ return;
3230
+ for (const [injectionId, entry] of entries) {
3231
+ if (owner ? entry.session !== owner : !entry.session.closed)
3232
+ continue;
3233
+ entries.delete(injectionId);
3234
+ appendUndeliveredHostNotes(sessionKey, [{ notes: entry.notes, order: entry.order }]);
3235
+ trace(`continuation: host note injection unacknowledged (${reason}); kept for the next user turn id=${injectionId}`);
3236
+ }
3237
+ if (entries.size === 0)
3238
+ hostNoteInjectionsBySession.delete(sessionKey);
3239
+ }
3240
+ /** Remove and return `owner`'s unacknowledged injections, oldest first. */
3241
+ function takeHostNoteInjections(owner) {
3242
+ const sessionKey = owner.openCodeSessionId;
3243
+ const entries = sessionKey ? hostNoteInjectionsBySession.get(sessionKey) : undefined;
3244
+ if (!sessionKey || !entries)
3245
+ return [];
3246
+ const notes = [];
3247
+ for (const [injectionId, entry] of entries) {
3248
+ if (entry.session !== owner)
3249
+ continue;
3250
+ entries.delete(injectionId);
3251
+ notes.push({ notes: entry.notes, order: entry.order });
3252
+ }
3253
+ if (entries.size === 0)
3254
+ hostNoteInjectionsBySession.delete(sessionKey);
3255
+ return notes.sort((a, b) => a.order - b.order);
3256
+ }
3257
+ /** Notes the restart snapshot kept for this session's next user turn. */
3258
+ export function restorePersistedHostNotes(sessionKey, persisted) {
3259
+ const notes = decodePersistedHostNotes(persisted);
3260
+ if (notes.length > 0)
3261
+ rememberUndeliveredHostNotes(sessionKey, [{ notes, order: nextHostNoteOrder++ }]);
3262
+ }
3263
+ /**
3264
+ * Host notes a new Run's user turn carries, as `<system_reminder>` blocks:
3265
+ * notes no earlier Run delivered, then notes OpenCode placed before this user
3266
+ * message (they exist only in host history, which a checkpointed Run does not
3267
+ * resend). Call `sent()` once the Run request is written.
3268
+ */
3269
+ export function prepareUserTurnHostNotes(input) {
3270
+ const { sessionKey, startedWithCheckpoint, isCompaction, ephemeralRun, resuming } = input;
3271
+ // Notes injected into a Run that closed without acknowledging them can no
3272
+ // longer be delivered there; a still-open Run keeps its own.
3273
+ if (sessionKey && !isCompaction && !ephemeralRun)
3274
+ releaseHostNoteInjections(sessionKey, "run-closed");
3275
+ // An interrupted-turn resume carries no user message; notes wait for the next one.
3276
+ const undelivered = isCompaction || ephemeralRun || resuming || !sessionKey
3277
+ ? undefined
3278
+ : undeliveredHostNotesBySession.get(sessionKey);
3279
+ // Compaction/history rewrites can remove notes, and ordinary seeds omit tool
3280
+ // results (including read instructions). Only omit a deferred note when its
3281
+ // text really reaches Cursor in this Run's seed. The seed merges adjacent
3282
+ // user-role entries and carries tool observations as user text, so a note
3283
+ // can sit inside a larger entry: match its text, not whole entries.
3284
+ const seededText = startedWithCheckpoint ? "" : (input.seedHistory ?? extractPromptHistory(input.prompt))
3285
+ .filter((message) => message.role === "user")
3286
+ .map((message) => message.content)
3287
+ .join("\n\n");
3288
+ const turnBoundary = !isCompaction && !ephemeralRun && !resuming
3289
+ ? input.liveTurnStart !== undefined
3290
+ ? collectHostNoteBodies(input.prompt.slice(input.liveTurnStart).map((message) => hostTailNote(message)?.text))
3291
+ : startedWithCheckpoint ? hostNotesBeforeUserTurn(input.prompt) : []
3292
+ : [];
3293
+ const deferred = undelivered?.flatMap((batch) => batch.notes)
3294
+ .filter((note) => !seededText.includes(note.trim()) && !turnBoundary.some((text) => text.includes(note.trim()))) ?? [];
3295
+ if (turnBoundary.length > 0) {
3296
+ trace(`fresh turn: ${turnBoundary.length} host note(s) before the user message ride with it`);
3297
+ }
3298
+ return {
3299
+ carriedNotes: [
3300
+ ...(undelivered ?? []),
3301
+ ...(turnBoundary.length ? [{ notes: turnBoundary, order: nextHostNoteOrder++ }] : []),
3302
+ ],
3303
+ reminders: [
3304
+ ...wrapHostNotesForCursor(deferred),
3305
+ ...wrapHostNotesForCursor(turnBoundary),
3306
+ ],
3307
+ sent: () => {
3308
+ if (sessionKey && undelivered?.length) {
3309
+ forgetUndeliveredHostNotes(sessionKey, undelivered, startedWithCheckpoint ? "user-turn" : "seed-history");
3310
+ }
3311
+ },
3312
+ };
3313
+ }
3314
+ function undeliveredHostNotes(sessionKey) {
3315
+ return (undeliveredHostNotesBySession.get(sessionKey) ?? []).flatMap((batch) => batch.notes);
3316
+ }
3317
+ function appendUndeliveredHostNotes(sessionKey, notes) {
3318
+ if (notes.length === 0)
3319
+ return;
3320
+ rememberUndeliveredHostNotes(sessionKey, [
3321
+ ...(undeliveredHostNotesBySession.get(sessionKey) ?? []),
3322
+ ...notes,
3323
+ ]);
3324
+ }
3325
+ function collectHostNoteBodies(notes) {
3326
+ return notes.filter((note) => typeof note === "string" && note.length > 0);
3327
+ }
3328
+ /** At turn_ended, keep this Run's unacknowledged host notes for the next user turn. */
3329
+ function keepUndeliveredHostNotes(session) {
3330
+ if (session.openCodeSessionId)
3331
+ releaseHostNoteInjections(session.openCodeSessionId, "turn-ended", session);
3332
+ }
3333
+ function rememberUndeliveredHostNotes(sessionKey, notes) {
3334
+ undeliveredHostNotesBySession.delete(sessionKey);
3335
+ undeliveredHostNotesBySession.set(sessionKey, [...new Set(notes)].sort((a, b) => a.order - b.order));
3336
+ while (undeliveredHostNotesBySession.size > MAX_TURN_STATE_SESSIONS) {
3337
+ const oldest = undeliveredHostNotesBySession.keys().next().value;
3338
+ if (!oldest)
3339
+ break;
3340
+ undeliveredHostNotesBySession.delete(oldest);
3341
+ }
3342
+ }
3343
+ function forgetUndeliveredHostNotes(sessionKey, notes, carrier) {
3344
+ const current = undeliveredHostNotesBySession.get(sessionKey);
3345
+ if (!current)
3346
+ return;
3347
+ const remaining = current.filter((batch) => !notes.includes(batch));
3348
+ if (remaining.length)
3349
+ undeliveredHostNotesBySession.set(sessionKey, remaining);
3350
+ else
3351
+ undeliveredHostNotesBySession.delete(sessionKey);
3352
+ trace(`continuation: undelivered host notes sent in the ${carrier} sessionKey=${sessionKey} count=${notes.length}`);
3353
+ }
3354
+ /** Runs whose `turn_ended` is being handled; a message injected now would never be delivered. */
3355
+ const endingTurns = new WeakSet();
3356
+ /** Inject a message OpenCode holds until this step ends, while Cursor still works on the step. */
3357
+ function injectHostSteer(session, steer) {
3358
+ const { runId, stream } = session;
3359
+ const takesSteers = () => !session.closed && !session.hostInterrupted && !!session.runId && !endingTurns.has(session)
3360
+ && sessionManager.isActivelyPumping(session) && session.stream === stream && session.runId === runId;
3361
+ if (!takesSteers())
3362
+ return false;
3363
+ const injectionId = crypto.randomUUID();
3364
+ if (!recordEarlySteer({
3365
+ ...steer, injectionId, run: session.sessionId, delivered: false, checkpointed: false, answered: false,
3366
+ })) {
3367
+ trace(`steer: too many early steers tracked for sessionID=${steer.sessionID}; left for the next step`);
3368
+ return false;
3369
+ }
3370
+ // While it waits behind another write, the host may withdraw it, or its Run may end or be replaced.
3371
+ writeWithBackpressure(stream, encodeMessage("AgentClientMessage", {
3372
+ conversation_action: {
3373
+ inject_context_action: {
3374
+ injection_id: injectionId,
3375
+ expected_run_id: runId,
3376
+ user_context: { user_message: { text: steer.text, message_id: crypto.randomUUID() } },
3377
+ },
3378
+ },
3379
+ }), "steer injection", () => isEarlySteerTracked(steer.sessionID, injectionId) && takesSteers()).then((written) => {
3380
+ if (written)
3381
+ trace(`steer: injected a mid-step message runId=${runId} id=${injectionId} inboxID=${steer.inboxID}`);
3382
+ else
3383
+ forgetUndeliveredEarlySteer(steer.sessionID, injectionId);
3384
+ }, (error) => {
3385
+ // Bytes buffered before a failed drain may still reach Cursor; only a rejected write is certainly lost.
3386
+ if (!(error instanceof StreamWriteDrainError))
3387
+ forgetUndeliveredEarlySteer(steer.sessionID, injectionId);
3388
+ trace(`steer: injection write failed id=${injectionId}: ${error.message}`);
3389
+ });
3390
+ return true;
3391
+ }
3392
+ /** Match only wholly textual messages; keep attachment-bearing neighbors opaque and intact. */
3393
+ function trailingUserTexts(prompt) {
3394
+ let start = prompt.length;
3395
+ while (start > 0 && (prompt[start - 1].role === "user" || prompt[start - 1].role === "system"))
3396
+ start--;
3397
+ if (start === prompt.length)
3398
+ return undefined;
3399
+ const texts = [];
3400
+ const nonText = [];
3401
+ const notes = [];
3402
+ for (let index = start; index < prompt.length; index++) {
3403
+ const message = prompt[index];
3404
+ if (hostTailNote(message)) {
3405
+ notes.push(message);
3406
+ continue;
3407
+ }
3408
+ if (message.role !== "user" || message.content.some((part) => part.type !== "text")) {
3409
+ nonText.push(index);
2108
3410
  continue;
2109
3411
  }
2110
- const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
2111
- ? "ask_question answer"
2112
- : pending.resultField === SWITCH_MODE_RESULT_FIELD
2113
- ? "switch_mode answer"
2114
- : pending.resultField === CREATE_PLAN_RESULT_FIELD
2115
- ? "create_plan answer"
2116
- : "exec result";
2117
- trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
2118
- `frames=${outcome.framesWritten} outLen=${r.output.length}`);
3412
+ texts.push({ index, text: message.content.map((part) => part.text).join("\n") });
2119
3413
  }
2120
- return session;
3414
+ return { start, texts, nonText, notes };
3415
+ }
3416
+ /** The same host history boundary must scope both answered copies and already-promoted receipts. */
3417
+ function steerHostTurn(prompt, start) {
3418
+ return createHash("sha256").update(JSON.stringify(prompt[start - 1])).digest("hex");
3419
+ }
3420
+ /** What the Run that answered a session's early steers needs to save the session's snapshot. */
3421
+ const steerAnsweringRuns = new Map();
3422
+ function rememberSteerAnsweringRun(sessionKey, session) {
3423
+ const { openCodeSessionId, cacheDir, conversationId, requestContext, toolCatalog, postCompactionRebase, hostAgent, stableSystemPromptHash } = session;
3424
+ steerAnsweringRuns.delete(sessionKey);
3425
+ steerAnsweringRuns.set(sessionKey, {
3426
+ openCodeSessionId, cacheDir, conversationId, requestContext, toolCatalog, postCompactionRebase, hostAgent, stableSystemPromptHash,
3427
+ });
3428
+ while (steerAnsweringRuns.size > MAX_TURN_STATE_SESSIONS) {
3429
+ const oldest = steerAnsweringRuns.keys().next().value;
3430
+ if (!oldest)
3431
+ break;
3432
+ steerAnsweringRuns.delete(oldest);
3433
+ }
3434
+ }
3435
+ /**
3436
+ * OpenCode adds its own copy of an early steer once the model has answered it. A step made only of
3437
+ * such copies needs no Run (`"all"`); otherwise the copies are left out of the prompt.
3438
+ */
3439
+ async function leaveOutAnsweredEarlySteers(prompt, sessionKey) {
3440
+ if (!sessionKey || sessionManager.findOpenByOpenCodeSessionId(sessionKey)?.pending.size)
3441
+ return undefined;
3442
+ const tail = trailingUserTexts(prompt);
3443
+ if (!tail || !["assistant", "tool"].includes(prompt[tail.start - 1]?.role))
3444
+ return undefined;
3445
+ if (prompt[tail.start - 1]?.role === "assistant" && detectForeignHistory({
3446
+ sessionKey, conversationId: peekConversationId(sessionKey), prompt,
3447
+ }))
3448
+ return undefined;
3449
+ const hostTurn = steerHostTurn(prompt, tail.start);
3450
+ const notes = tail.notes.map(hostTailNote);
3451
+ const answered = new Map();
3452
+ for (const { index, text } of tail.texts) {
3453
+ const taken = [...answered.values()];
3454
+ const [record] = matchEarlySteers(sessionKey, [text], (candidate) => candidate.answered && (!candidate.hostTurn || candidate.hostTurn === hostTurn) && !taken.includes(candidate));
3455
+ if (record)
3456
+ answered.set(index, record);
3457
+ }
3458
+ if (answered.size === 0)
3459
+ return undefined;
3460
+ const firstCopy = [...answered.values()].some((record) => !record.hostTurn);
3461
+ for (const record of answered.values())
3462
+ record.hostTurn = hostTurn;
3463
+ trace(`fresh turn: the model already answered ${answered.size} early steer(s) ` +
3464
+ `inboxIDs=${[...answered.values()].map((record) => record.inboxID).join(",")} — left out of this step`);
3465
+ // Remove only matching copies. Preserve all other messages, their parts and their ordering.
3466
+ if (answered.size < tail.texts.length || tail.nonText.length || notes.some((note) => note?.media?.length)) {
3467
+ return prompt.filter((_message, index) => !answered.has(index));
3468
+ }
3469
+ const run = steerAnsweringRuns.get(sessionKey);
3470
+ // Host notes in this step reach Cursor with the next user turn instead.
3471
+ const noteTexts = collectHostNoteBodies(notes.map((note) => note?.text));
3472
+ if (firstCopy && noteTexts.length > 0) {
3473
+ appendUndeliveredHostNotes(sessionKey, [{ notes: noteTexts, order: nextHostNoteOrder++ }]);
3474
+ }
3475
+ if (run)
3476
+ await persistTerminalCheckpoint(run);
3477
+ return "all";
3478
+ }
3479
+ function answeredSteerStream() {
3480
+ return new ReadableStream({
3481
+ start(controller) {
3482
+ controller.enqueue({ type: "stream-start", warnings: [] });
3483
+ controller.enqueue({
3484
+ type: "finish",
3485
+ finishReason: { unified: "stop", raw: undefined },
3486
+ usage: emptyLanguageModelV3Usage(),
3487
+ providerMetadata: { copilot: { totalNanoAiu: 0 }, cursor: { usageVersion: 3, occupancyOnly: true } },
3488
+ });
3489
+ controller.close();
3490
+ },
3491
+ });
3492
+ }
3493
+ /**
3494
+ * OpenCode adds its own copy of an early steer after the step's tool results. The held Run already
3495
+ * has it, so the step continues there without it instead of starting a new user turn.
3496
+ */
3497
+ function continuationAfterEarlySteers(prompt, sessionKey) {
3498
+ const held = sessionKey ? sessionManager.findOpenByOpenCodeSessionId(sessionKey) : undefined;
3499
+ if (!held || held.pending.size === 0)
3500
+ return undefined;
3501
+ const tail = trailingUserTexts(prompt);
3502
+ if (!tail || tail.nonText.length || prompt[tail.start - 1]?.role !== "tool")
3503
+ return undefined;
3504
+ const hostTurn = steerHostTurn(prompt, tail.start);
3505
+ const steers = [];
3506
+ const newTexts = [];
3507
+ for (const { text } of tail.texts) {
3508
+ const [record] = matchEarlySteers(sessionKey, [text], (candidate) => candidate.run === held.sessionId && !candidate.answered && !steers.includes(candidate));
3509
+ if (record)
3510
+ steers.push(record);
3511
+ else
3512
+ newTexts.push(text);
3513
+ }
3514
+ if (steers.length === 0)
3515
+ return undefined;
3516
+ const results = extractTrailingToolResults([...prompt.slice(0, tail.start), ...tail.notes]);
3517
+ if (results.length === 0 || findContinuationSession(results) !== held)
3518
+ return undefined;
3519
+ // The host has already promoted these messages. Keep them for a follow-up after the held
3520
+ // turn, rather than superseding its tool results or dropping all but the last user message.
3521
+ const added = [];
3522
+ for (const text of newTexts) {
3523
+ const record = {
3524
+ sessionID: sessionKey, inboxID: crypto.randomUUID(), injectionId: crypto.randomUUID(),
3525
+ run: held.sessionId, text, delivered: false, checkpointed: false, answered: false, promoted: true, hostTurn,
3526
+ };
3527
+ if (!recordEarlySteer(record)) {
3528
+ forgetEarlySteerRecords(sessionKey, added);
3529
+ return undefined;
3530
+ }
3531
+ added.push(record);
3532
+ }
3533
+ for (const steer of steers) {
3534
+ steer.promoted = true;
3535
+ steer.hostTurn = hostTurn;
3536
+ }
3537
+ trace(`continuation: ${results.length} trailing tool result(s) before ${steers.length} early steer(s) ` +
3538
+ `the held Run already has inboxIDs=${steers.map((steer) => steer.inboxID).join(",")}`);
3539
+ return { results, session: held };
2121
3540
  }
2122
3541
  async function loadAvailableModels() {
2123
3542
  const cacheDir = opencodeGlobalCacheDir();
@@ -2147,6 +3566,7 @@ function resolveTelemetryEnabled(options) {
2147
3566
  return options.telemetryEnabled ?? isTruthyEnv(process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY);
2148
3567
  }
2149
3568
  function resolveExplicitAgentBaseURL(options) {
3569
+ // Legacy `baseURL` aliases `agentBaseURL`; read it without the deprecated accessor.
2150
3570
  const raw = options.agentBaseURL ?? options.baseURL;
2151
3571
  if (!raw)
2152
3572
  return undefined;
@@ -2159,75 +3579,16 @@ function resolveExplicitAgentBaseURL(options) {
2159
3579
  function isTruthyEnv(value) {
2160
3580
  return value === "1" || value === "true";
2161
3581
  }
2162
- const streamWriteChains = new WeakMap();
2163
- async function waitForStreamWrites(stream) {
2164
- const pending = streamWriteChains.get(stream);
2165
- if (pending)
2166
- await pending.catch(() => undefined);
2167
- }
2168
- /**
2169
- * Keep awaited protocol writes ordered per Run stream. In particular, a large
2170
- * KV get_blob reply must drain before another KV reply or heartbeat is queued;
2171
- * otherwise one heartbeat observes the backlog created by dozens of ignored
2172
- * false write() results and reports the wrong operation as the failure.
2173
- */
2174
- async function writeWithBackpressure(stream, message, operation) {
2175
- const previous = streamWriteChains.get(stream);
2176
- const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
2177
- .then(() => writeWithBackpressureNow(stream, message, operation));
2178
- streamWriteChains.set(stream, current);
2179
- try {
2180
- await current;
2181
- }
2182
- finally {
2183
- if (streamWriteChains.get(stream) === current)
2184
- streamWriteChains.delete(stream);
2185
- }
2186
- }
2187
- async function writeWithBackpressureNow(stream, message, operation) {
2188
- let accepted;
2189
- try {
2190
- accepted = stream.write(message);
2191
- }
2192
- catch (cause) {
2193
- throw toCursorProviderError(cause, {
2194
- replaySafe: false,
2195
- fallback: `Cursor ${operation} write failed`,
2196
- });
2197
- }
2198
- if (accepted !== false)
2199
- return;
2200
- trace(`stream write backpressured: operation=${operation} bytes=${message.length}`);
2201
- if (!stream.waitForDrain) {
2202
- throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
2203
- transient: false,
2204
- replaySafe: false,
2205
- code: "CURSOR_WRITE_BACKPRESSURE",
2206
- });
2207
- }
2208
- try {
2209
- await stream.waitForDrain(5_000);
2210
- trace(`stream write drained: operation=${operation} bytes=${message.length}`);
2211
- }
2212
- catch (cause) {
2213
- throw toCursorProviderError(cause, {
2214
- replaySafe: false,
2215
- fallback: `Cursor ${operation} backpressure drain failed`,
2216
- });
2217
- }
2218
- }
2219
3582
  async function nextFrameWithSemanticDeadline(session) {
3583
+ const idleTimeout = () => new CursorTransportError(`Cursor Run sent no frames for ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
2220
3584
  const remainingMs = session.semanticDeadlineAt - Date.now();
2221
- if (remainingMs <= 0) {
2222
- throw new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
2223
- }
3585
+ if (remainingMs <= 0)
3586
+ throw idleTimeout();
2224
3587
  let timer;
2225
3588
  let rejectCancelled;
2226
3589
  const deadline = new Promise((_, reject) => {
2227
3590
  rejectCancelled = reject;
2228
- timer = setTimeout(() => {
2229
- reject(new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }));
2230
- }, remainingMs);
3591
+ timer = setTimeout(() => reject(idleTimeout()), remainingMs);
2231
3592
  timer.unref?.();
2232
3593
  });
2233
3594
  session.semanticDeadlineCancel = () => {
@@ -2245,6 +3606,48 @@ async function nextFrameWithSemanticDeadline(session) {
2245
3606
  session.semanticDeadlineCancel = null;
2246
3607
  }
2247
3608
  }
3609
+ /**
3610
+ * Hand a background or soft-background shell to a supervised terminal record.
3611
+ * Where that is unavailable (native Windows, runtime storage this conversation
3612
+ * never advertised or cannot create), the request keeps the unmanaged
3613
+ * background wrappers it already carries.
3614
+ */
3615
+ function manageBackgroundShell(parsed, policy, context) {
3616
+ const workspaceRoot = workspaceRootFromRequestContext(context);
3617
+ // The host runs the command in the directory its wrapper records, so
3618
+ // OpenCode 2's shell hook can correlate the two by command and directory.
3619
+ // Resolve against the workspace here: the hook sees the host's directory,
3620
+ // and a relative path resolved later would use the daemon's own cwd.
3621
+ policy.workingDirectory = path.resolve(workspaceRoot, policy.workingDirectory || ".");
3622
+ parsed.args.workdir = policy.workingDirectory;
3623
+ parsed.resultMetadata = { ...parsed.resultMetadata, working_directory: policy.workingDirectory };
3624
+ const root = runtimeRootFromContext(context);
3625
+ const env = context?.env;
3626
+ if (process.platform === "win32" || !root || env?.terminals_folder !== path.join(root, "terminals"))
3627
+ return;
3628
+ let managed;
3629
+ try {
3630
+ policy.shellPath = resolveCursorShellPath(undefined, workspaceRoot);
3631
+ managed = createManagedTerminal(policy, root);
3632
+ }
3633
+ catch (error) {
3634
+ trace(`background shell: supervised terminal unavailable, keeping the unmanaged wrapper: ${error.message}`);
3635
+ return;
3636
+ }
3637
+ // A spawn's host command is the launcher, which embeds the command verbatim
3638
+ // for approval. A soft-background command keeps its own text: shell hooks
3639
+ // substitute the launcher, and a host without them runs it in the foreground.
3640
+ // The launcher reports within its own startup deadline, so the host's
3641
+ // default timeout stays in place for a host that runs the command as is.
3642
+ if (policy.backgroundSpawn)
3643
+ parsed.args.command = managed.command;
3644
+ parsed.resultMetadata = {
3645
+ ...parsed.resultMetadata,
3646
+ managed_command: managed.command,
3647
+ managed_job_directory: managed.directory,
3648
+ managed_shell_path: policy.shellPath,
3649
+ };
3650
+ }
2248
3651
  /**
2249
3652
  * Read the held-open stream, emitting stream parts, until the turn boundary:
2250
3653
  * - tool call(s) for one Cursor generation → emit each tool-call as it
@@ -2296,6 +3699,10 @@ export async function pump(session, controller, ids, abortSignal) {
2296
3699
  const { textId, reasoningId } = ids;
2297
3700
  const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
2298
3701
  const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
3702
+ // The epoch catalog still lists `edit`/`write` after a switch to a GPT model; this turn's host list picks the file tool.
3703
+ const fileToolNames = () => session.permittedToolNames && session.permittedToolNames.size > 0
3704
+ ? session.permittedToolNames
3705
+ : advertisedToolNameSet;
2299
3706
  let textStarted = false;
2300
3707
  let reasoningStarted = false;
2301
3708
  let assistantText = "";
@@ -2307,8 +3714,9 @@ export async function pump(session, controller, ids, abortSignal) {
2307
3714
  let lifecycleRefusedExec = false;
2308
3715
  let progressContinuationAttempts = 0;
2309
3716
  let emittedHostTools = 0;
3717
+ const earlyRows = new EarlyToolRows();
2310
3718
  let planHandoffCancellationRequested = false;
2311
- const replaySafety = new AttemptReplaySafety(session.sessionId);
3719
+ const replaySafety = new AttemptReplaySafety(session.sessionId, session.replaySafety ??= {});
2312
3720
  const failRunProtocol = (message, code) => {
2313
3721
  replaySafety.markBarrier("unknown-or-malformed-frame");
2314
3722
  const error = new CursorProtocolError(message, { code });
@@ -2476,13 +3884,13 @@ export async function pump(session, controller, ids, abortSignal) {
2476
3884
  * continue. External and otherwise ineligible reads retain OpenCode's normal
2477
3885
  * permission-aware path.
2478
3886
  */
2479
- const recoverCorrelatedEditRead = (parsed, displayCallId) => {
3887
+ const recoverCorrelatedEditRead = async (parsed, displayCallId) => {
2480
3888
  if (!displayCallId ||
2481
3889
  parsed.resultField !== "read_result" ||
2482
3890
  parsed.toolName !== "read" ||
2483
- // `apply_patch` is the substitute this host offers when it withholds
2484
- // `write` — the follow-up write_args is remapped onto it either way.
2485
- !(advertisedToolNameSet.has("write") || advertisedToolNameSet.has("apply_patch")))
3891
+ // The follow-up write_args can be a targeted edit even when write is
3892
+ // withheld. Every permitted mutation route needs the complete read.
3893
+ !(fileToolNames().has("edit") || fileToolNames().has("write") || hostPatchTool(fileToolNames())))
2486
3894
  return false;
2487
3895
  const stored = session.displayToolCalls.get(displayCallId);
2488
3896
  const display = parseDisplayToolCall(displayCallId, stored);
@@ -2499,37 +3907,24 @@ export async function pump(session, controller, ids, abortSignal) {
2499
3907
  const absolutePath = resolvePath(requestedPath);
2500
3908
  if (absolutePath !== resolvePath(editPath))
2501
3909
  return false;
2502
- let exists = true;
2503
- try {
2504
- fs.lstatSync(absolutePath);
2505
- }
2506
- catch (error) {
2507
- const code = error && typeof error === "object" && "code" in error
2508
- ? error.code
2509
- : undefined;
2510
- if (code !== "ENOENT")
2511
- return false;
2512
- exists = false;
3910
+ const answer = cursorEditReadAnswer(absolutePath, workspaceRoot);
3911
+ if (answer === "external") {
3912
+ parsed.resultMetadata = {
3913
+ ...parsed.resultMetadata,
3914
+ correlatedEditCallId: displayCallId,
3915
+ };
3916
+ return false;
2513
3917
  }
2514
- if (exists) {
2515
- // Do not bypass OpenCode's external-directory permission boundary, even
2516
- // through a symlink rooted in the workspace.
3918
+ if (answer === "host")
3919
+ return false;
3920
+ if (answer === "complete") {
2517
3921
  try {
2518
- const realRoot = fs.realpathSync(workspaceRoot);
2519
- const realTarget = fs.realpathSync(absolutePath);
2520
- const relative = path.relative(realRoot, realTarget);
2521
- if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2522
- parsed.resultMetadata = {
2523
- ...parsed.resultMetadata,
2524
- correlatedEditCallId: displayCallId,
2525
- };
2526
- return false;
2527
- }
2528
3922
  const frames = buildCompleteEditReadMessages(parsed.id, absolutePath, requestedPath);
2529
3923
  if (!frames)
2530
3924
  return false;
2531
- for (const frame of frames)
2532
- session.stream.write(frame);
3925
+ for (const frame of frames) {
3926
+ await writeWithBackpressure(session.stream, frame, "complete edit read reply");
3927
+ }
2533
3928
  const editCall = session.editToolCalls?.get(displayCallId);
2534
3929
  if (editCall)
2535
3930
  editCall.completeRead = true;
@@ -2537,7 +3932,10 @@ export async function pump(session, controller, ids, abortSignal) {
2537
3932
  `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
2538
3933
  return true;
2539
3934
  }
2540
- catch {
3935
+ catch (error) {
3936
+ // Once reply bytes are buffered, falling back to a host read would
3937
+ // answer the same exec again. Transport failures terminate the attempt.
3938
+ rethrowTransportWriteFailure(error);
2541
3939
  parsed.resultMetadata = {
2542
3940
  ...parsed.resultMetadata,
2543
3941
  correlatedEditCallId: displayCallId,
@@ -2545,6 +3943,7 @@ export async function pump(session, controller, ids, abortSignal) {
2545
3943
  return false;
2546
3944
  }
2547
3945
  }
3946
+ // "empty": a missing target.
2548
3947
  try {
2549
3948
  for (const frame of buildExecClientMessages({
2550
3949
  execId: parsed.id,
@@ -2554,13 +3953,14 @@ export async function pump(session, controller, ids, abortSignal) {
2554
3953
  resultMetadata: { path: requestedPath },
2555
3954
  workspaceRoot,
2556
3955
  })) {
2557
- session.stream.write(frame);
3956
+ await writeWithBackpressure(session.stream, frame, "missing edit target reply");
2558
3957
  }
2559
3958
  trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
2560
3959
  `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
2561
3960
  return true;
2562
3961
  }
2563
3962
  catch (e) {
3963
+ rethrowTransportWriteFailure(e);
2564
3964
  const error = new Error(`Failed to recover Cursor edit read: ${e.message}`);
2565
3965
  trace(`exec: edit read recovery FAILED ${error.message}`);
2566
3966
  safeError(error);
@@ -2568,6 +3968,31 @@ export async function pump(session, controller, ids, abortSignal) {
2568
3968
  return true;
2569
3969
  }
2570
3970
  };
3971
+ /**
3972
+ * Cursor reads each Task attachment, tagged with the Task's call id, before
3973
+ * its subagent request. A host read would put the file in this session's
3974
+ * context instead of the child's, cost a step, and claim the display call
3975
+ * that carries the Task's title. Answer it here and keep the display call.
3976
+ * Returns undefined when this is not an attachment read, false when the
3977
+ * reply failed and the attempt was closed.
3978
+ */
3979
+ const answerTaskAttachmentRead = async (parsed, displayCallId) => {
3980
+ if (!displayCallId || parsed.resultField !== "read_result" || parsed.toolName !== "read")
3981
+ return undefined;
3982
+ const display = parseDisplayToolCall(displayCallId, session.displayToolCalls.get(displayCallId));
3983
+ if (display?.variant !== "task_tool_call")
3984
+ return undefined;
3985
+ const requested = opencodePathArg(parsed.args) ?? "";
3986
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
3987
+ const target = resolveReadTargetPath(requested, workspaceRoot);
3988
+ if (!taskToolCallAttachments(session.displayToolCalls.get(displayCallId)).some((attachment) => resolveReadTargetPath(attachment, workspaceRoot) === target))
3989
+ return undefined;
3990
+ const ok = await writeExecFrames(buildTaskAttachmentReadMessages(parsed.id, target, requested), `reply to task attachment read id=${parsed.id}`);
3991
+ if (!ok)
3992
+ return false;
3993
+ trace(`exec: task attachment read answered id=${parsed.id} callId=${displayCallId} path=${JSON.stringify(requested)}`);
3994
+ return true;
3995
+ };
2571
3996
  /**
2572
3997
  * A read of a path that does not exist would otherwise reach OpenCode as a
2573
3998
  * real tool call and raise a permission prompt for a file the user never had.
@@ -2583,8 +4008,12 @@ export async function pump(session, controller, ids, abortSignal) {
2583
4008
  * handshake read into an empty-file success. EACCES/EPERM and other stat
2584
4009
  * errors fall through to OpenCode so a genuine permission decision stands.
2585
4010
  */
2586
- const rejectMissingReadTarget = (parsed) => {
2587
- if (parsed.toolName !== "read")
4011
+ const rejectMissingReadTarget = async (parsed) => {
4012
+ // This validation belongs to Cursor's native LocalReadExecutor contract.
4013
+ // MCP reads belong to the advertised OpenCode executor: both OpenCode
4014
+ // generations support directories and permission-gate missing paths.
4015
+ // Sending a typed ReadResult to an MCP call leaves it without mcp_result.
4016
+ if (parsed.toolName !== "read" || parsed.resultField !== "read_result")
2588
4017
  return false;
2589
4018
  const requested = opencodePathArg(parsed.args) ?? "";
2590
4019
  if (!requested)
@@ -2603,7 +4032,7 @@ export async function pump(session, controller, ids, abortSignal) {
2603
4032
  return false;
2604
4033
  try {
2605
4034
  for (const frame of buildReadRejectionMessages(parsed.id, readResult)) {
2606
- session.stream.write(frame);
4035
+ await writeWithBackpressure(session.stream, frame, "missing read target reply");
2607
4036
  }
2608
4037
  const kind = Object.keys(readResult)[0];
2609
4038
  trace(`exec: rejected missing read target id=${parsed.id} kind=${kind} ` +
@@ -2611,6 +4040,7 @@ export async function pump(session, controller, ids, abortSignal) {
2611
4040
  return true;
2612
4041
  }
2613
4042
  catch (e) {
4043
+ rethrowTransportWriteFailure(e);
2614
4044
  const error = new Error(`Failed to reject Cursor read of a missing path: ${e.message}`);
2615
4045
  trace(`exec: missing read rejection FAILED ${error.message}`);
2616
4046
  safeError(error);
@@ -2687,17 +4117,50 @@ export async function pump(session, controller, ids, abortSignal) {
2687
4117
  session.usageEstimate.outputTokens += estimateTokens(text.length);
2688
4118
  safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
2689
4119
  };
4120
+ const owedPendingCount = () => {
4121
+ let owed = 0;
4122
+ for (const pending of session.pending.values())
4123
+ if (isOwedCursorExec(pending))
4124
+ owed++;
4125
+ return owed;
4126
+ };
4127
+ /** `continuedAs`: the host tool this Cursor call runs as instead; otherwise it never ran. */
4128
+ const closeEarlyRow = (row, continuedAs) => {
4129
+ replaySafety.markBarrier("visible-tool-row");
4130
+ // Text that streamed while the row was open ends before its tool-call.
4131
+ closeOpenSpans();
4132
+ safeEnqueue({ type: "tool-input-end", id: row.toolCallId });
4133
+ safeEnqueue({
4134
+ type: "tool-call",
4135
+ toolCallId: row.toolCallId,
4136
+ toolName: row.toolName,
4137
+ input: "{}",
4138
+ providerExecuted: true,
4139
+ });
4140
+ safeEnqueue({
4141
+ type: "tool-result",
4142
+ toolCallId: row.toolCallId,
4143
+ toolName: row.toolName,
4144
+ result: continuedAs ? earlyRowHandoff(continuedAs) : EARLY_ROW_CLOSED,
4145
+ isError: !continuedAs,
4146
+ });
4147
+ };
4148
+ const settleOpenEarlyRows = () => {
4149
+ for (const row of earlyRows.drain())
4150
+ closeEarlyRow(row);
4151
+ };
2690
4152
  const emitFinish = (te, reason, settledUsage, settledSource) => {
4153
+ settleOpenEarlyRows();
2691
4154
  flushToollessText();
2692
4155
  closeOpenSpans();
2693
4156
  const est = session.usageEstimate;
2694
4157
  // OpenCode TUI/GUI replace each assistant message's tokens (they do not
2695
4158
  // sum occupancy) and the TUI footer requires tokens.output > 0. Cost is
2696
4159
  // added per step-finish. Emit checkpoint occupancy snapshots at tool-call
2697
- // boundaries with a $0 Copilot cost override, then one more occupancy
2698
- // snapshot at TurnEnded/stop. Held-Run TurnEnded counters are cumulative
2699
- // across every tool step, but this finish only spans the last generation
2700
- // slice — putting output_tokens/reasoning there makes host tok/s
4160
+ // boundaries and once more at TurnEnded/stop, each billed through
4161
+ // `src/billing.ts`. Held-Run TurnEnded counters are cumulative across
4162
+ // every tool step, but this finish only spans the last generation slice —
4163
+ // putting output_tokens/reasoning there makes host tok/s
2701
4164
  // (generated/stepElapsed) absurd. Keep exact request counters under
2702
4165
  // providerMetadata.cursor.*Raw. Char/4 usageEstimate stays traces-only.
2703
4166
  const tokenDetails = session.tokenDetails;
@@ -2707,23 +4170,48 @@ export async function pump(session, controller, ids, abortSignal) {
2707
4170
  ? "checkpoint-current-run"
2708
4171
  : "checkpoint-previous-turn"
2709
4172
  : undefined;
2710
- const usage = settledUsage ?? (occupancyDetails
2711
- ? occupancyUsageFromTokenDetails(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails)
2712
- : emptyLanguageModelV3Usage());
4173
+ // Bill the step (see `src/billing.ts`): a tool step is an estimate shaped
4174
+ // like one model call; the turn's last step reconciles the catalog-priced
4175
+ // aggregate counters. Either way its token total stays the occupancy snapshot.
4176
+ const billing = session.billing;
4177
+ const shaped = occupancyDetails && !settledUsage
4178
+ ? shapeStepUsage({
4179
+ usedTokens: occupancyDetails.usedTokens,
4180
+ prefixTokens: billing.prefixTokens,
4181
+ prices: hostStepPrices(billing.cost, occupancyDetails.usedTokens - 1),
4182
+ ...(te && billing.cost ? { targetUsd: billingLedger.outstanding(billing.key) } : {}),
4183
+ })
4184
+ : undefined;
4185
+ const stepCostUsd = shaped?.costUsd ?? 0;
4186
+ const usage = settledUsage ?? shaped?.usage ?? emptyLanguageModelV3Usage();
2713
4187
  const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
2714
- // TurnEnded stays a real (non-occupancyOnly) finish so hosts that collapse
2715
- // tool-boundary occupancy still keep one context snapshot for the sidebar.
2716
- // Copilot $0 avoids billing the occupancy-shaped counters as a new prompt.
4188
+ const ledger = billingLedger.totals(billing.key);
4189
+ // Metadata describes the total *if delivered*. Commit it only after the
4190
+ // consumer accepts the finish, otherwise later turns must still settle it.
4191
+ ledger.billedUsd += stepCostUsd;
4192
+ const estimatedUsd = billingLedger.estimated(billing.key) + (te ? 0 : stepCostUsd);
4193
+ const billingMetadata = {
4194
+ stepUsd: stepCostUsd,
4195
+ estimate: !te,
4196
+ sessionRealUsd: ledger.realUsd,
4197
+ sessionBilledUsd: ledger.billedUsd,
4198
+ ...(estimatedUsd > 0 ? { sessionEstimatedUsd: estimatedUsd } : {}),
4199
+ };
4200
+ // OpenCode 1.x bills `totalNanoAiu`; OpenCode 2 bills the same amount from
4201
+ // the shaped tokens. TurnEnded stays a real (non-occupancyOnly) finish so
4202
+ // hosts that collapse tool-boundary occupancy keep one context snapshot.
4203
+ const copilot = { totalNanoAiu: nanoAiuForUsd(stepCostUsd) };
2717
4204
  const providerMetadata = te
2718
- ? {
2719
- ...OPENCODE_DISPLAY_ONLY_COST_METADATA,
2720
- ...cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource),
2721
- }
4205
+ ? (() => {
4206
+ const metadata = cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource);
4207
+ return { copilot, ...metadata, cursor: { ...metadata.cursor, billing: billingMetadata } };
4208
+ })()
2722
4209
  : {
2723
- ...OPENCODE_DISPLAY_ONLY_COST_METADATA,
4210
+ copilot,
2724
4211
  cursor: {
2725
4212
  usageVersion: 3,
2726
4213
  occupancyOnly: true,
4214
+ billing: billingMetadata,
2727
4215
  ...(occupancyDetails && contextSource
2728
4216
  ? { context: cursorContextUsageMetadata(occupancyDetails, contextSource) }
2729
4217
  : {}),
@@ -2732,47 +4220,29 @@ export async function pump(session, controller, ids, abortSignal) {
2732
4220
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
2733
4221
  ? String(reason.unified ?? "unknown")
2734
4222
  : String(reason);
2735
- const inTotal = usage.inputTokens?.total ?? 0;
2736
- const outTotal = usage.outputTokens?.total ?? 0;
2737
- const occupancySource = occupancyDetails
2738
- ? `occupancy-${contextSource ?? "unavailable"}`
2739
- : "intermediate-zero";
2740
- // Occupancy finishes never see Cursor TurnEnded cache_read. usageEstimate.cacheRead
2741
- // stays 0 for the whole Run, so logging it as rawCacheRead falsely reports 0% on
2742
- // every tool-call step. Prefer the V3 occupancy partition (prior prefix → cacheRead)
2743
- // and label the estimate separately from billed TurnEnded counters.
2744
- const occupancyPrefixCache = occupancyDetails
2745
- ? (session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0)
2746
- : undefined;
2747
- const rawIn = te
2748
- ? turnEndedCounter(te, "input_tokens")
2749
- : occupancyDetails
2750
- ? occupancyDetails.usedTokens
2751
- : est.inputTokens;
2752
- const rawOut = te
2753
- ? turnEndedCounter(te, "output_tokens")
2754
- : occupancyDetails
2755
- ? 1
2756
- : est.outputTokens;
2757
- const rawCacheRead = te
2758
- ? turnEndedCounter(te, "cache_read")
2759
- : occupancyPrefixCache ?? est.cacheRead;
2760
- const rawCacheWrite = te ? turnEndedCounter(te, "cache_write") : est.cacheWrite;
2761
- trace(`finish: reason=${reasonLabel} ` +
2762
- `v3In=${inTotal} v3Out=${outTotal} ` +
2763
- `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
2764
- `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
2765
- `rawIn=${rawIn} rawOut=${rawOut} rawCacheRead=${rawCacheRead} rawCacheWrite=${rawCacheWrite} ` +
2766
- `${occupancyPrefixCache !== undefined ? `occupancyPrefixCache=${occupancyPrefixCache} ` : ""}` +
2767
- `source=${te
4223
+ trace(formatFinishTrace({
4224
+ reason: reasonLabel,
4225
+ usage,
4226
+ turnEnded: counters,
4227
+ occupancy: occupancyDetails
4228
+ ? {
4229
+ usedTokens: occupancyDetails.usedTokens,
4230
+ priorUsedTokens: session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0,
4231
+ }
4232
+ : undefined,
4233
+ estimate: est,
4234
+ source: te
2768
4235
  ? settledSource ?? (contextSource ?? "unavailable")
2769
- : occupancySource}`);
4236
+ : occupancyDetails ? `occupancy-${contextSource ?? "unavailable"}` : "intermediate-zero",
4237
+ }));
2770
4238
  // Validate the usage we actually send. Occupancy finishes (tool-call and
2771
4239
  // TurnEnded/stop) use prior-prefix cacheRead — never compare that against
2772
4240
  // aggregate TurnEnded request cache ratios (false mismatch). Raw request
2773
4241
  // counters stay on `finish:` and cache diagnosis only.
2774
4242
  if (occupancyDetails) {
2775
- trace(formatTurnUsageValidation(occupancyValidationCounters(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails), usage, occupancyDetails, contextSource));
4243
+ // The shaped split is the intended partition; validate totals against it.
4244
+ const cached = (usage.inputTokens.cacheRead ?? 0) + (usage.inputTokens.cacheWrite ?? 0);
4245
+ trace(formatTurnUsageValidation(occupancyValidationCounters(occupancyDetails, { usedTokens: cached, maxTokens: occupancyDetails.maxTokens }), usage, occupancyDetails, contextSource, "occupancy"));
2776
4246
  }
2777
4247
  else if (counters) {
2778
4248
  trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
@@ -2780,29 +4250,42 @@ export async function pump(session, controller, ids, abortSignal) {
2780
4250
  if (counters) {
2781
4251
  trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
2782
4252
  }
2783
- safeEnqueue({
4253
+ const delivered = safeEnqueue({
2784
4254
  type: "finish",
2785
4255
  usage,
2786
4256
  finishReason: reason,
2787
4257
  ...(providerMetadata ? { providerMetadata } : {}),
2788
4258
  });
4259
+ if (delivered && session.openCodeSessionId) {
4260
+ finishEmittedStep(session.openCodeSessionId, session.conversationId);
4261
+ if (reason.unified === "tool-calls")
4262
+ scheduleHeldRunSave(session);
4263
+ }
4264
+ if (delivered && shaped && occupancyDetails) {
4265
+ billingLedger.recordBilled(billing.key, shaped.costUsd, te ? undefined : billing);
4266
+ billing.prefixTokens = occupancyDetails.usedTokens;
4267
+ }
4268
+ const booked = billingLedger.totals(billing.key);
4269
+ trace(`billing: step=${te ? "turn-end" : "estimate"} pricing=${billing.cost ? "priced" : "unpriced"} ` +
4270
+ `delivered=${delivered} stepUsd=${(delivered ? stepCostUsd : 0).toFixed(6)} ` +
4271
+ `sessionRealUsd=${booked.realUsd.toFixed(6)} sessionBilledUsd=${booked.billedUsd.toFixed(6)} ` +
4272
+ `sessionEstimatedUsd=${billingLedger.estimated(billing.key).toFixed(6)} key=${billing.key}`);
2789
4273
  };
2790
- const persistTerminalCheckpoint = async () => {
2791
- if (!session.openCodeSessionId)
2792
- return;
2793
- await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
2794
- sessionKey: session.openCodeSessionId,
2795
- conversationId: session.conversationId,
2796
- requestContext: session.requestContext,
2797
- toolCatalog: session.toolCatalog ?? [],
2798
- postCompactionRebase: session.postCompactionRebase,
2799
- hostAgent: session.hostAgent,
2800
- systemPromptHash: session.stableSystemPromptHash,
2801
- }).catch((error) => {
2802
- trace(`conversation persistence: terminal save failed sessionKey=${session.openCodeSessionId}: ${String(error)}`);
2803
- });
4274
+ // The cancel path drains and closes the Run; a frame read here goes back to it.
4275
+ const stopForHostInterrupt = (read) => {
4276
+ if (read?.done)
4277
+ session.pendingFrameRead = Promise.resolve(read);
4278
+ else if (read)
4279
+ session.pushbackFrame = read.value;
4280
+ settleOpenEarlyRows();
4281
+ closeOpenSpans();
4282
+ clearParallelStep();
4283
+ trace(`pump: host interrupted the turn sessionId=${session.sessionId} — leaving the cancelled Run to its drain`);
4284
+ throw new CursorLocalCancellationError("Cursor Run cancelled: the host stopped the turn");
2804
4285
  };
2805
4286
  while (true) {
4287
+ if (session.hostInterrupted)
4288
+ stopForHostInterrupt();
2806
4289
  // Consumer cancelled / closed the ReadableStream. Stop reading Cursor
2807
4290
  // frames so a continuation doStream can resume the same iterator —
2808
4291
  // keeping the loop alive would discard frames the next pump needs.
@@ -2851,11 +4334,17 @@ export async function pump(session, controller, ids, abortSignal) {
2851
4334
  const failure = error instanceof CursorProviderError
2852
4335
  ? error
2853
4336
  : new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
4337
+ settleOpenEarlyRows();
4338
+ if (session.hostInterrupted)
4339
+ stopForHostInterrupt();
2854
4340
  if (finishInterruptedTools("remote-error", failure))
2855
4341
  return;
2856
4342
  throw finalizeFailure(failure);
2857
4343
  }
4344
+ if (session.hostInterrupted)
4345
+ stopForHostInterrupt(next);
2858
4346
  if (next.done) {
4347
+ settleOpenEarlyRows();
2859
4348
  closeOpenSpans();
2860
4349
  trace("pump: frames iterator ended before turn_ended");
2861
4350
  if (finishInterruptedTools("remote-clean-close"))
@@ -2864,6 +4353,10 @@ export async function pump(session, controller, ids, abortSignal) {
2864
4353
  throw finalizeFailure(failure);
2865
4354
  }
2866
4355
  const frame = next.value;
4356
+ // As in Cursor CLI, any inbound frame (heartbeats included) proves the Run is
4357
+ // alive: a model composing a large tool input sends only heartbeats or token
4358
+ // counts until the call arrives.
4359
+ sessionManager.recordSemanticProgress(session);
2867
4360
  if (frame.flags & 0x02) {
2868
4361
  // A successful agent turn has an explicit turn_ended update before the
2869
4362
  // Connect envelope closes. Reaching end-stream here means the Run was
@@ -2877,6 +4370,7 @@ export async function pump(session, controller, ids, abortSignal) {
2877
4370
  replaySafety.markBarrier("unknown-or-malformed-frame");
2878
4371
  }
2879
4372
  }
4373
+ settleOpenEarlyRows();
2880
4374
  closeOpenSpans();
2881
4375
  const failure = payload
2882
4376
  ? connectFrameError(payload)
@@ -2887,7 +4381,7 @@ export async function pump(session, controller, ids, abortSignal) {
2887
4381
  && session.pending.size === 0 && isHostPlanEntryPending(session.openCodeSessionId)) {
2888
4382
  // Cancellation is an explicit terminal acknowledgment, not TurnEnded.
2889
4383
  // Preserve its final checkpoint without inventing aggregate billing.
2890
- await persistTerminalCheckpoint();
4384
+ await persistTerminalCheckpoint(session);
2891
4385
  trace("host-agent-mode: Run cancellation acknowledged for plan handoff");
2892
4386
  emitFinish(undefined, { unified: "stop", raw: undefined });
2893
4387
  sessionManager.close(session);
@@ -2931,7 +4425,6 @@ export async function pump(session, controller, ids, abortSignal) {
2931
4425
  const interactionQuery = asm.interaction_query;
2932
4426
  const checkpointRaw = asm.conversation_checkpoint_update;
2933
4427
  const topField = payload.length > 0 ? payload[0] >> 3 : 0;
2934
- const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
2935
4428
  const requiredChannel = responseRequiredChannel(payload);
2936
4429
  if (requiredChannel === "multiple" ||
2937
4430
  (requiredChannel === "exec" && !esm) ||
@@ -2945,11 +4438,7 @@ export async function pump(session, controller, ids, abortSignal) {
2945
4438
  kv,
2946
4439
  execControl,
2947
4440
  interactionQuery,
2948
- checkpointBytes: checkpointProgress,
2949
4441
  });
2950
- if (replayFrame.semanticProgress) {
2951
- sessionManager.recordSemanticProgress(session);
2952
- }
2953
4442
  if (replayFrame.barrier === "unknown-or-malformed-frame" && isDebugEnabled()) {
2954
4443
  const layout = describeFrameLayout(payload);
2955
4444
  if (!unknownFrameLayouts.has(layout)) {
@@ -2959,6 +4448,8 @@ export async function pump(session, controller, ids, abortSignal) {
2959
4448
  }
2960
4449
  if (replayFrame.barrier)
2961
4450
  replaySafety.markBarrier(replayFrame.barrier);
4451
+ if (replayFrame.barrier === "non-control-exec")
4452
+ replaySafety.recordExec(esm?.id);
2962
4453
  // Reseeding is allowed only while every frame so far was positively a
2963
4454
  // control frame. Anything else, including unknown top-level fields, may have
2964
4455
  // carried output or stateful activity.
@@ -2997,7 +4488,10 @@ export async function pump(session, controller, ids, abortSignal) {
2997
4488
  if (bytes && bytes.length > 0) {
2998
4489
  cacheDiagnostics.checkpointUpdates++;
2999
4490
  setCheckpoint(session.conversationId, bytes);
3000
- session.resumeCheckpoint = Uint8Array.from(bytes);
4491
+ noteCheckpoint(session.openCodeSessionId, bytes);
4492
+ acceptResumeCheckpoint(session, bytes, frame);
4493
+ markEarlySteersCheckpointed(session.openCodeSessionId, session.sessionId);
4494
+ noteRunImageCheckpoint(session, bytes, frame);
3001
4495
  const tokenDetails = decodeConversationTokenDetails(bytes);
3002
4496
  if (tokenDetails && !(planHandoffCancellationRequested && tokenDetails.usedTokens === 0
3003
4497
  && (session.tokenDetails?.usedTokens ?? 0) > 0)) {
@@ -3010,6 +4504,12 @@ export async function pump(session, controller, ids, abortSignal) {
3010
4504
  `categories=${formatCursorTokenCategories(tokenDetails)}`
3011
4505
  : " context=unavailable";
3012
4506
  trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}${context}`);
4507
+ if (tokenDetails && !rulesCategoryChecked.has(session) && currentCursorTokenBreakdown(tokenDetails)) {
4508
+ rulesCategoryChecked.add(session);
4509
+ const warning = missingRulesWarning(tokenDetails, systemInstructionsRuleText(session.requestContext)?.length ?? 0, session.conversationId);
4510
+ if (warning)
4511
+ trace(warning);
4512
+ }
3013
4513
  }
3014
4514
  }
3015
4515
  if (iu?.text_delta) {
@@ -3029,11 +4529,43 @@ export async function pump(session, controller, ids, abortSignal) {
3029
4529
  return;
3030
4530
  }
3031
4531
  trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
4532
+ endingTurns.add(session);
4533
+ keepUndeliveredHostNotes(session);
4534
+ const steers = settleEarlySteers(session.openCodeSessionId, session.sessionId);
4535
+ if (steers.answered && session.openCodeSessionId) {
4536
+ trace(`steer: the model answered ${steers.answered} early steer(s) in this turn`);
4537
+ rememberSteerAnsweringRun(session.openCodeSessionId, session);
4538
+ }
3032
4539
  const turnEnded = iu.turn_ended;
3033
- await persistTerminalCheckpoint();
4540
+ recordTurnEndedCost(session, turnEnded);
4541
+ if (session.openCodeSessionId)
4542
+ finishEmittedStep(session.openCodeSessionId, session.conversationId);
4543
+ confirmRunImagesAtTurnEnd(session);
4544
+ await persistTerminalCheckpoint(session);
3034
4545
  const checkpoint = session.resumeCheckpoint ?? getCheckpoint(session.conversationId);
4546
+ if (steers.undelivered.length > 0 && typeof session.reopenWithUserMessage === "function") {
4547
+ // OpenCode will not send these again: they were left out of this turn's continuation.
4548
+ trace(`steer: Cursor did not take ${steers.undelivered.length} early steer(s); sending them as a follow-up Run`);
4549
+ if (session.openCodeSessionId)
4550
+ resumeEmittedStep(session.openCodeSessionId, session.conversationId);
4551
+ try {
4552
+ const beforeFollowup = lastFrameArrival();
4553
+ await session.reopenWithUserMessage(steers.undelivered.map((steer) => steer.text).join("\n\n"), abortSignal);
4554
+ // Revoke the old state, admitting checkpoints received while reopening.
4555
+ invalidateResumeCheckpoint(session, beforeFollowup);
4556
+ endingTurns.delete(session);
4557
+ textBreakPending = true;
4558
+ continue;
4559
+ }
4560
+ catch (error) {
4561
+ // Not transient: recovery would resume a checkpoint without the message.
4562
+ throw new CursorTransportError(`Cursor did not take a message sent during this turn, and sending it again failed: ${error.message}`, { transient: false, replaySafe: false, cause: error });
4563
+ }
4564
+ }
3035
4565
  if (typeof session.reopenWithUserMessage === "function"
3036
4566
  && !isHostPlanEntryPending(session.openCodeSessionId)
4567
+ // A steer Cursor did not take runs as OpenCode's next step; a nudge would delay it.
4568
+ && steers.leftToHost === 0
3037
4569
  && checkpoint
3038
4570
  && checkpoint.length > 0
3039
4571
  && shouldContinueProgressOnlyTurn({
@@ -3046,8 +4578,13 @@ export async function pump(session, controller, ids, abortSignal) {
3046
4578
  })) {
3047
4579
  progressContinuationAttempts += 1;
3048
4580
  trace("progress-only: continuing attempt=1");
4581
+ if (session.openCodeSessionId)
4582
+ resumeEmittedStep(session.openCodeSessionId, session.conversationId);
3049
4583
  try {
3050
- await session.reopenWithUserMessage(progressOnlyContinuationPrompt(workspaceRootFromRequestContext(session.requestContext)));
4584
+ const beforeFollowup = lastFrameArrival();
4585
+ await session.reopenWithUserMessage(progressOnlyContinuationPrompt(workspaceRootFromRequestContext(session.requestContext)), abortSignal);
4586
+ invalidateResumeCheckpoint(session, beforeFollowup);
4587
+ endingTurns.delete(session);
3051
4588
  assistantText = "";
3052
4589
  continue;
3053
4590
  }
@@ -3227,6 +4764,10 @@ export async function pump(session, controller, ids, abortSignal) {
3227
4764
  else if (iu?.step_completed) {
3228
4765
  cacheDiagnostics.stepCompletes++;
3229
4766
  }
4767
+ else if (iu?.context_injection_state) {
4768
+ if (applyContextInjectionState(session, iu.context_injection_state))
4769
+ textBreakPending = true;
4770
+ }
3230
4771
  else if (esm) {
3231
4772
  cacheDiagnostics.execRequests++;
3232
4773
  const esmId = esm.id ?? 0;
@@ -3319,8 +4860,58 @@ export async function pump(session, controller, ids, abortSignal) {
3319
4860
  else {
3320
4861
  replaySafety.markBarrier("non-control-exec");
3321
4862
  const displayCallId = extractExecDisplayCallId(esm);
4863
+ // Task prerequisites take precedence over the runtime-file reader:
4864
+ // even a terminal/catalog attachment must keep its Task's display owner.
4865
+ if (session.allowTools && esm.read_args) {
4866
+ const parsedRead = parseExecServerMessage(esm, session.hostToolDialect);
4867
+ if (parsedRead) {
4868
+ const attachmentRead = await answerTaskAttachmentRead(parsedRead, displayCallId);
4869
+ if (attachmentRead === false)
4870
+ return;
4871
+ if (attachmentRead)
4872
+ continue;
4873
+ }
4874
+ }
4875
+ const runtimeMessages = await runtimeExecMessages(esm, session.requestContext, session.allowTools);
4876
+ if (runtimeMessages) {
4877
+ for (const message of runtimeMessages) {
4878
+ await writeWithBackpressure(session.stream, message, `runtime metadata reply id=${esmId}`);
4879
+ }
4880
+ if (displayCallId)
4881
+ session.displayToolCalls.delete(displayCallId);
4882
+ disposeParallelCall(displayCallId);
4883
+ if (closeIfParallelStepComplete())
4884
+ return;
4885
+ continue;
4886
+ }
3322
4887
  const parsed = parseExecServerMessage(esm, session.hostToolDialect);
3323
4888
  if (parsed) {
4889
+ // The model can reach the same internal file through an advertised
4890
+ // canonical read over MCP. It still must not ask the host to approve
4891
+ // access to provider-owned cache metadata.
4892
+ if (parsed.toolName === "read" && parsed.resultField === "mcp_result") {
4893
+ const internalRead = await runtimeExecMessages({
4894
+ id: parsed.id,
4895
+ read_args: { ...parsed.args, path: opencodePathArg(parsed.args) },
4896
+ }, session.requestContext, session.allowTools);
4897
+ if (internalRead) {
4898
+ const reply = decodeMessage("AgentClientMessage", internalRead[0]);
4899
+ const read = reply.exec_client_message.read_result;
4900
+ const error = read.error?.error ?? (read.file_not_found ? "Runtime metadata file was not found" : undefined);
4901
+ const frames = buildExecClientMessages({
4902
+ execId: parsed.id, resultField: "mcp_result", toolName: "read",
4903
+ output: read.success?.content ?? "", error,
4904
+ });
4905
+ for (const message of frames)
4906
+ await writeWithBackpressure(session.stream, message, `runtime MCP read reply id=${esmId}`);
4907
+ if (displayCallId)
4908
+ session.displayToolCalls.delete(displayCallId);
4909
+ disposeParallelCall(displayCallId);
4910
+ if (closeIfParallelStepComplete())
4911
+ return;
4912
+ continue;
4913
+ }
4914
+ }
3324
4915
  const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
3325
4916
  if (executableToolName !== parsed.toolName) {
3326
4917
  trace(`web tool alias resolved: ${parsed.toolName} -> ${executableToolName}`);
@@ -3335,6 +4926,7 @@ export async function pump(session, controller, ids, abortSignal) {
3335
4926
  if (display?.variant === "task_tool_call") {
3336
4927
  const description = typeof display.args.description === "string" ? display.args.description : undefined;
3337
4928
  preferCorrelatedTaskDescription(parsed, description);
4929
+ appendTaskAttachments(parsed, taskToolCallAttachments(stored), workspaceRootFromRequestContext(session.requestContext));
3338
4930
  if (description?.trim()) {
3339
4931
  trace(`exec: preferred TaskToolCall description callId=${displayCallId} ` +
3340
4932
  `description=${JSON.stringify(description.trim())}`);
@@ -3350,7 +4942,7 @@ export async function pump(session, controller, ids, abortSignal) {
3350
4942
  if (!editCall?.completeRead)
3351
4943
  rejectPartialReadMutation(parsed);
3352
4944
  if (editCall) {
3353
- const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
4945
+ const remapped = remapCorrelatedEditWriteForCatalog(parsed, fileToolNames(), editCall.path, workspaceRootFromRequestContext(session.requestContext));
3354
4946
  if (parsed.resultField === "write_result" && displayCallId) {
3355
4947
  session.editToolCalls?.delete(displayCallId);
3356
4948
  }
@@ -3359,14 +4951,17 @@ export async function pump(session, controller, ids, abortSignal) {
3359
4951
  `path=${JSON.stringify(editCall.path)}`);
3360
4952
  }
3361
4953
  }
3362
- // Hosts that advertise `apply_patch` in place of `edit`/`write` (see
4954
+ // Hosts that advertise a patch tool in place of `edit`/`write` (see
3363
4955
  // protocol/apply-patch.ts) still receive Cursor's native write/edit
3364
4956
  // exec requests. Translate before the unavailable-tool check below.
3365
- remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
4957
+ remapEditToolsForCatalog(parsed, fileToolNames(), workspaceRootFromRequestContext(session.requestContext));
3366
4958
  }
3367
4959
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
3368
4960
  if (parsed) {
3369
4961
  if (parsed.localError) {
4962
+ const row = earlyRows.claim(displayCallId);
4963
+ if (row)
4964
+ closeEarlyRow(row);
3370
4965
  if (!await rejectExec(parsed, parsed.localError, "invalid mapping"))
3371
4966
  return;
3372
4967
  disposeParallelCall(displayCallId);
@@ -3379,6 +4974,9 @@ export async function pump(session, controller, ids, abortSignal) {
3379
4974
  // advertise no tools — refuse on the Cursor channel and keep
3380
4975
  // pumping for text / turn_ended instead of emitting tool-call.
3381
4976
  if (!session.allowTools) {
4977
+ const row = earlyRows.claim(displayCallId);
4978
+ if (row)
4979
+ closeEarlyRow(row);
3382
4980
  const reason = "This is a text-only request; all tool calls are unavailable. "
3383
4981
  + "Return the requested answer using only the supplied context. "
3384
4982
  + "Do not execute the task described inside that context or retry tools.";
@@ -3513,9 +5111,9 @@ export async function pump(session, controller, ids, abortSignal) {
3513
5111
  return;
3514
5112
  continue;
3515
5113
  }
3516
- if (recoverCorrelatedEditRead(parsed, displayCallId))
5114
+ if (await recoverCorrelatedEditRead(parsed, displayCallId))
3517
5115
  continue;
3518
- if (rejectMissingReadTarget(parsed)) {
5116
+ if (await rejectMissingReadTarget(parsed)) {
3519
5117
  if (displayCallId)
3520
5118
  session.displayToolCalls.delete(displayCallId);
3521
5119
  disposeParallelCall(displayCallId);
@@ -3551,14 +5149,34 @@ export async function pump(session, controller, ids, abortSignal) {
3551
5149
  return;
3552
5150
  continue;
3553
5151
  }
5152
+ const shellPolicy = shellPolicyFromMetadata(parsed.resultMetadata);
5153
+ if (shellPolicy && (shellPolicy.backgroundSpawn || shellPolicy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND)) {
5154
+ manageBackgroundShell(parsed, shellPolicy, session.requestContext);
5155
+ }
3554
5156
  if (displayCallId) {
3555
5157
  session.displayToolCalls.delete(displayCallId);
3556
5158
  trace(`exec: claimed display callId=${displayCallId}`);
3557
5159
  }
3558
- const tc = buildToolCallPart(parsed, session.sessionId);
5160
+ const boundEarly = earlyRows.claim(displayCallId);
5161
+ let earlyToolCallId;
5162
+ if (boundEarly) {
5163
+ const predicted = hostToolForCursorEdit(boundEarly.path, fileToolNames(), workspaceRootFromRequestContext(session.requestContext));
5164
+ if (parsed.toolName !== boundEarly.toolName || predicted !== boundEarly.toolName) {
5165
+ closeEarlyRow(boundEarly, parsed.toolName);
5166
+ }
5167
+ else {
5168
+ replaySafety.markBarrier("visible-tool-row");
5169
+ safeEnqueue({ type: "tool-input-end", id: boundEarly.toolCallId });
5170
+ sessionManager.bindToolCall(boundEarly.toolCallId, session.sessionId, parsed.id);
5171
+ earlyToolCallId = boundEarly.toolCallId;
5172
+ }
5173
+ }
5174
+ const built = buildToolCallPart(parsed, session.sessionId);
5175
+ const tc = earlyToolCallId ? { ...built, toolCallId: earlyToolCallId } : built;
3559
5176
  if (parsed.resultField === "shell_stream"
3560
5177
  || parsed.resultField === "shell_result"
3561
- || parsed.resultField === "background_shell_spawn_result") {
5178
+ || parsed.resultField === "background_shell_spawn_result"
5179
+ || shellPolicy !== undefined) {
3562
5180
  registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
3563
5181
  }
3564
5182
  // Keep the stream open; the result arrives on the next doStream call.
@@ -3575,6 +5193,8 @@ export async function pump(session, controller, ids, abortSignal) {
3575
5193
  }
3576
5194
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
3577
5195
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
5196
+ if (!(session.parallelStep && shouldHoldParallelStep(session.parallelStep)))
5197
+ settleOpenEarlyRows();
3578
5198
  emittedHostTools++;
3579
5199
  // Close open text/reasoning spans before tool-call (required by AI SDK V3).
3580
5200
  closeOpenSpans();
@@ -3855,6 +5475,9 @@ export async function pump(session, controller, ids, abortSignal) {
3855
5475
  return;
3856
5476
  }
3857
5477
  }
5478
+ else if (execControl) {
5479
+ applyExecControl(session, execControl, "pump");
5480
+ }
3858
5481
  else if (kv) {
3859
5482
  // KV blob channel: ack set_blob / answer get_blob, then keep pumping.
3860
5483
  // Not replying hangs the turn — see protocol/kv.ts.
@@ -3888,6 +5511,7 @@ export async function pump(session, controller, ids, abortSignal) {
3888
5511
  }
3889
5512
  catch (e) {
3890
5513
  if (e instanceof CursorProviderError) {
5514
+ settleOpenEarlyRows();
3891
5515
  if (finishInterruptedTools("remote-error", e))
3892
5516
  return;
3893
5517
  throw e;
@@ -3899,9 +5523,44 @@ export async function pump(session, controller, ids, abortSignal) {
3899
5523
  // exec/args decode) must not abort the whole turn — log and skip.
3900
5524
  trace(`frame dispatch FAILED (skipping): topField=${topField}`);
3901
5525
  }
3902
- // heartbeat / step / partial_tool_call → ignore (partial args are
3903
- // display-only; the exec channel is authoritative. Checkpoints and
3904
- // interaction queries are handled above.)
5526
+ const announced = iu?.partial_tool_call ? cursorEditAnnouncement(iu.partial_tool_call) : undefined;
5527
+ if (announced
5528
+ && !earlyRows.seen(announced.callId)
5529
+ && shouldOpenEarlyRow({
5530
+ allowTools: session.allowTools,
5531
+ hostInterrupted: !!session.hostInterrupted,
5532
+ holdingParallelStep: !!session.parallelStep && shouldHoldParallelStep(session.parallelStep),
5533
+ emittedHostTools,
5534
+ owedPending: owedPendingCount(),
5535
+ })) {
5536
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
5537
+ // An edit whose private read goes to the host gets that read as its first
5538
+ // exec, which would close the row as a failed edit.
5539
+ const toolName = cursorEditReadReachesHost(path.resolve(workspaceRoot, announced.path), workspaceRoot)
5540
+ ? undefined
5541
+ : hostToolForCursorEdit(announced.path, fileToolNames(), workspaceRoot);
5542
+ if (!toolName)
5543
+ earlyRows.skip(announced.callId);
5544
+ else {
5545
+ const row = {
5546
+ cursorCallId: announced.callId,
5547
+ toolCallId: earlyRowToolCallId(session.sessionId, announced.callId),
5548
+ toolName,
5549
+ path: announced.path,
5550
+ };
5551
+ earlyRows.openRow(row);
5552
+ replaySafety.markBarrier("visible-tool-row");
5553
+ safeEnqueue({
5554
+ type: "tool-input-start",
5555
+ id: row.toolCallId,
5556
+ toolName: row.toolName,
5557
+ });
5558
+ }
5559
+ }
5560
+ // heartbeat / step / tool-input deltas stay display-only. An edit
5561
+ // partial with a path may have opened a row above; the exec channel
5562
+ // still decides the call. Checkpoints and interaction queries are
5563
+ // handled above.
3905
5564
  }
3906
5565
  }
3907
5566
  /** Normalize protobufjs bytes / Buffer / number[] into a Uint8Array. */
@@ -3922,7 +5581,9 @@ function normalizeCheckpointBytes(raw) {
3922
5581
  }
3923
5582
  function correlateToolResults(results) {
3924
5583
  return results.flatMap(result => {
3925
- const parsed = parseExecIdFromToolCallId(result.toolCallId);
5584
+ const parsed = parseExecIdFromToolCallId(result.toolCallId)
5585
+ ?? sessionManager.toolCallExec(result.toolCallId)
5586
+ ?? parseEarlyRowToolCallId(result.toolCallId);
3926
5587
  return parsed ? [{ ...result, ...parsed }] : [];
3927
5588
  });
3928
5589
  }
@@ -3936,14 +5597,22 @@ function extractToolResultContent(prompt) {
3936
5597
  if (p.type !== "tool-result")
3937
5598
  continue;
3938
5599
  const toolCallId = p.toolCallId ?? "";
3939
- const { text, isError } = toolResultOutputToText(p.output);
3940
- const media = toolResultOutputMedia(p.output);
5600
+ const failure = toolResultFailure(p.providerOptions, toolCallId);
5601
+ const normalized = toolResultOutputToText(p.output);
5602
+ const text = failure?.text ?? normalized.text;
5603
+ const isError = failure !== undefined || normalized.isError;
5604
+ const media = isError ? [] : toolResultOutputMedia(p.output);
5605
+ const toolName = p.toolName ?? "mcp";
5606
+ const instruction = toolName === "read" && !isError ? extractOpenCodeReadInstruction(text) : undefined;
3941
5607
  out.push({
3942
5608
  toolCallId,
3943
- toolName: p.toolName ?? "mcp",
5609
+ toolName,
3944
5610
  output: text,
3945
- error: isError ? text : undefined,
5611
+ error: isError ? text || "OpenCode tool execution failed" : undefined,
5612
+ ...(failure?.refusal ? { refusal: failure.refusal } : {}),
5613
+ ...(failure?.type === "aborted" ? { interrupted: true } : {}),
3946
5614
  ...(media.length > 0 ? { media } : {}),
5615
+ ...(instruction ? { notes: [instruction] } : {}),
3947
5616
  });
3948
5617
  }
3949
5618
  }
@@ -3959,6 +5628,14 @@ const SYSTEM_UPDATE_CLOSE = "</system-update>";
3959
5628
  const SYSTEM_REMINDER_OPEN = "<system-reminder>";
3960
5629
  const SYSTEM_REMINDER_CLOSE = "</system-reminder>";
3961
5630
  const TOOL_MEDIA_CAPTION = "Attached media from tool result:";
5631
+ // OpenCode 2 loads a nested AGENTS.md during a step as a synthetic user message
5632
+ // of `Instructions from: <absolute path>\n<content>` blocks (the same text
5633
+ // OpenCode 1 puts in a read's <system-reminder>). No marker reaches the provider.
5634
+ const OPENCODE_INSTRUCTIONS = /^Instructions from: (?:\/|[A-Za-z]:[\\/])/;
5635
+ // OpenCode 2 ShellResult.notification, packages/core/src/shell/result.ts.
5636
+ // Its raw command may contain quotes or newlines. Keep the complete notice as
5637
+ // context; do not derive execution status by parsing its command or output.
5638
+ const OPENCODE_SHELL_NOTIFICATION = /^<shell id="[^"\r\n]+" state="(?:completed|cancelled|error)" command="[\s\S]*">\r?\n[\s\S]*\r?\n<\/shell>$/;
3962
5639
  function wrappedHostNote(text, open, close) {
3963
5640
  return text.startsWith(open) && text.endsWith(close);
3964
5641
  }
@@ -3977,7 +5654,9 @@ function hostTailNote(message) {
3977
5654
  if (part.type !== "text")
3978
5655
  return undefined;
3979
5656
  const text = part.text.trim();
3980
- if (wrappedHostNote(text, SYSTEM_UPDATE_OPEN, SYSTEM_UPDATE_CLOSE)) {
5657
+ if (wrappedHostNote(text, SYSTEM_UPDATE_OPEN, SYSTEM_UPDATE_CLOSE)
5658
+ || OPENCODE_INSTRUCTIONS.test(text)
5659
+ || OPENCODE_SHELL_NOTIFICATION.test(text)) {
3981
5660
  texts.push(text);
3982
5661
  append = true;
3983
5662
  continue;
@@ -3994,6 +5673,27 @@ function hostTailNote(message) {
3994
5673
  return reminderOnly ? {} : undefined;
3995
5674
  return { text: texts.join("\n") };
3996
5675
  }
5676
+ /**
5677
+ * Host notes between the previous reply and the closing user message, e.g. an
5678
+ * OpenCode 2 `<system-update>` after MCP servers reconnect. The host's leading
5679
+ * system prompt is never one of them. Empty unless the prompt ends with a
5680
+ * user message that is not itself a note.
5681
+ */
5682
+ export function hostNotesBeforeUserTurn(prompt) {
5683
+ const last = prompt.length - 1;
5684
+ if (last < 0 || prompt[last].role !== "user" || hostTailNote(prompt[last]))
5685
+ return [];
5686
+ const firstNonSystem = prompt.findIndex((message) => message.role !== "system");
5687
+ const notes = [];
5688
+ for (let index = last - 1; index > firstNonSystem; index--) {
5689
+ const note = hostTailNote(prompt[index]);
5690
+ if (!note)
5691
+ break;
5692
+ if (note.text)
5693
+ notes.unshift(note.text);
5694
+ }
5695
+ return notes;
5696
+ }
3997
5697
  /**
3998
5698
  * Split off host notes that trail the live tool results. They are not a new
3999
5699
  * user turn: the held Run must still receive its tool results.
@@ -4029,13 +5729,11 @@ export function extractTrailingToolResults(prompt) {
4029
5729
  if (media.length > 0)
4030
5730
  attributeTrailingMedia(contents, media);
4031
5731
  const results = correlateToolResults(contents);
4032
- // A Run continuation only carries exec results, so the host notes ride on the
4033
- // last one; otherwise Cursor would never see e.g. a removed skill.
5732
+ // Read instructions occurred with their results, before the step's trailing
5733
+ // updates. Keep that order when the delivery injects their combined text.
4034
5734
  const last = results.at(-1);
4035
5735
  if (last && notes.length > 0) {
4036
- results[results.length - 1] = {
4037
- ...last, output: [last.output, ...notes].filter(Boolean).join("\n\n"), notes: notes.join("\n\n"),
4038
- };
5736
+ results[results.length - 1] = { ...last, notes: [...(last.notes ?? []), ...notes] };
4039
5737
  }
4040
5738
  return results;
4041
5739
  }
@@ -4052,12 +5750,16 @@ function attributeTrailingMedia(results, media) {
4052
5750
  if (results.length === 0)
4053
5751
  return;
4054
5752
  if (results.length === 1) {
5753
+ if (results[0].error !== undefined)
5754
+ return;
4055
5755
  if (results[0].toolName === "read" && !MEDIA_READ_OUTPUTS.has(results[0].output))
4056
5756
  return;
4057
5757
  results[0].media = [...(results[0].media ?? []), ...media];
4058
5758
  return;
4059
5759
  }
4060
5760
  const bounds = results.map(result => {
5761
+ if (result.error !== undefined)
5762
+ return { min: 0, max: 0 };
4061
5763
  if (result.toolName === "read") {
4062
5764
  const count = MEDIA_READ_OUTPUTS.has(result.output) ? 1 : 0;
4063
5765
  return { min: count, max: count };
@@ -4066,7 +5768,7 @@ function attributeTrailingMedia(results, media) {
4066
5768
  return { min: 1, max: media.length };
4067
5769
  // These canonical tools have text output only. Opaque MCP tools remain
4068
5770
  // unconstrained, even if another tool also returned descriptive text.
4069
- if (["glob", "grep", "ls", "edit", "write", "apply_patch", "todowrite", "todoread"].includes(result.toolName)) {
5771
+ if (["glob", "grep", "ls", "edit", "write", "todowrite", "todoread"].includes(result.toolName) || isHostPatchTool(result.toolName)) {
4070
5772
  return { min: 0, max: 0 };
4071
5773
  }
4072
5774
  return { min: 0, max: media.length };
@@ -4117,17 +5819,45 @@ export async function decodeTrailingToolImages(session, results, signal, maxByte
4117
5819
  const notice = omittedCount > 0
4118
5820
  ? `[${omittedCount} tool-result image(s) omitted: decoding failed or the attachment budget was exceeded.]`
4119
5821
  : undefined;
5822
+ // A read that kept an image is encoded from its bytes, so output text would
5823
+ // be dropped; its notice rides on that result as hook context instead.
5824
+ const noticeAsContext = notice !== undefined && pending.resultField === "read_result" && images.length > 0;
4120
5825
  decoded.push({
4121
5826
  ...result, images, imageHashes: hashes,
4122
- ...(notice ? {
5827
+ ...(notice && !noticeAsContext ? {
4123
5828
  output: `${result.output}\n\n${notice}`,
4124
- notes: [result.notes, notice].filter(Boolean).join("\n\n"),
4125
- ...(pending.resultField === "read_result" && images.length === 0 ? { error: notice } : {}),
5829
+ ...(pending.resultField === "read_result" ? { error: notice } : {}),
4126
5830
  } : {}),
5831
+ ...(noticeAsContext ? { notices: [...(result.notices ?? []), notice] } : {}),
4127
5832
  });
4128
5833
  }
4129
5834
  return decoded;
4130
5835
  }
5836
+ /** Debug summary of the last prompt messages: roles, part kinds, short text previews. */
5837
+ export function describePromptTail(prompt, count = 4) {
5838
+ const preview = (text) => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
5839
+ return prompt.slice(Math.max(0, prompt.length - count)).map((m) => {
5840
+ if (!Array.isArray(m.content))
5841
+ return `${m.role}:scalar${typeof m.content === "string" ? `(${preview(m.content)})` : ""}`;
5842
+ const kinds = m.content.map((p) => {
5843
+ const part = p;
5844
+ if (part.type === "tool-result") {
5845
+ const out = part.output;
5846
+ const value = out?.value;
5847
+ const nonText = Array.isArray(value)
5848
+ ? value.filter((item) => item && typeof item === "object" && item.type !== "text").length
5849
+ : 0;
5850
+ return `tool-result(${part.toolName},${typeof out?.type === "string" ? out.type : "?"},mediaish=${nonText})`;
5851
+ }
5852
+ if (part.type === "text" && typeof part.text === "string")
5853
+ return `text(${preview(part.text)})`;
5854
+ return String(part.type ?? "?");
5855
+ });
5856
+ const options = m.providerOptions;
5857
+ const marks = options ? `{${Object.entries(options).map(([k, v]) => `${k}:${JSON.stringify(v).slice(0, 80)}`).join(";")}}` : "";
5858
+ return `${m.role}${marks}:[${kinds.join(",")}]`;
5859
+ }).join(" | ");
5860
+ }
4131
5861
  /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
4132
5862
  export function hasApprovedUncorrelatedPlanStageResult(prompt) {
4133
5863
  const { end } = liveTail(prompt);
@@ -4143,7 +5873,7 @@ export function hasApprovedUncorrelatedPlanStageResult(prompt) {
4143
5873
  if (parseExecIdFromToolCallId(part.toolCallId))
4144
5874
  continue;
4145
5875
  const result = toolResultOutputToText(part.output);
4146
- return !result.isError;
5876
+ return !result.isError && !toolResultFailure(part.providerOptions, part.toolCallId);
4147
5877
  }
4148
5878
  }
4149
5879
  return false;
@@ -4157,10 +5887,10 @@ function toolResultOutputToText(output) {
4157
5887
  // LanguageModelV3 tool-result output: { type: "text"|"json"|"error-text"|..., value }
4158
5888
  const isError = o.type === "execution-denied" || (typeof o.type === "string" && o.type.startsWith("error"));
4159
5889
  if (o.type === "execution-denied") {
4160
- return { text: String(o.reason ?? "Tool execution denied"), isError: true };
5890
+ return { text: String(o.reason || "Tool execution denied"), isError: true };
4161
5891
  }
4162
5892
  if (o.type === "text" || o.type === "error-text") {
4163
- return { text: String(o.value ?? ""), isError };
5893
+ return { text: String(o.value ?? "") || (isError ? "OpenCode tool execution failed" : ""), isError };
4164
5894
  }
4165
5895
  if (o.type === "json" || o.type === "error-json") {
4166
5896
  return { text: JSON.stringify(o.value ?? null), isError };
@@ -4225,6 +5955,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
4225
5955
  const names = new Set(tools.map((tool) => tool.name));
4226
5956
  if (names.size === 0)
4227
5957
  return undefined;
5958
+ const patchTool = hostPatchTool(names);
4228
5959
  const instructions = [];
4229
5960
  const subagents = extractHostSubagentCatalog(tools);
4230
5961
  const planEntryHandoff = !names.has("plan_enter") && hostAgentModeSwitchKind() === "resumes";
@@ -4332,16 +6063,16 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
4332
6063
  ? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
4333
6064
  : "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
4334
6065
  }
4335
- else if (names.has("apply_patch")) {
4336
- // This host withheld `edit`/`write` and offers `apply_patch` instead. Native
6066
+ else if (patchTool) {
6067
+ // This host withheld `edit`/`write` and offers a patch tool instead. Native
4337
6068
  // Cursor write/edit requests are translated into it, so say so rather than
4338
6069
  // leaving the turn with no file-editing guidance at all.
4339
- 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.");
6070
+ instructions.push(`- Use OpenCode \`${patchTool}\` 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 \`${patchTool}\` automatically.`);
4340
6071
  }
4341
6072
  if (hostToolDialectFromTools(tools).filePathKey === "path") {
4342
6073
  instructions.push("- OpenCode file tools take `path` as an absolute path under the workspace root above. Do not pass a project-relative path, and do not invent a different absolute prefix.");
4343
6074
  }
4344
- if (names.has("edit") || names.has("write") || names.has("apply_patch")) {
6075
+ if (names.has("edit") || names.has("write") || patchTool) {
4345
6076
  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.");
4346
6077
  }
4347
6078
  return [
@@ -4370,6 +6101,18 @@ export function estimateTokens(chars) {
4370
6101
  return 0;
4371
6102
  return Math.ceil(chars / 4);
4372
6103
  }
6104
+ /**
6105
+ * Add the catalog-priced cost for a finished Run to its session's ledger. Called
6106
+ * once per `TurnEnded`, including Runs no OpenCode step reports (a drained
6107
+ * prior Run, a progress-only continuation); a later priced turn-end attempts to settle it.
6108
+ */
6109
+ function recordTurnEndedCost(session, turnEnded) {
6110
+ const realUsd = turnEndedCostUsd(session.billing.cost, cursorUsageCountersFromTurnEnded(turnEnded), session.tokenDetails?.usedTokens ?? 0);
6111
+ if (realUsd === undefined)
6112
+ return;
6113
+ billingLedger.recordReal(session.billing.key, realUsd, session.billing);
6114
+ trace(`billing: turn real=${realUsd.toFixed(6)} key=${session.billing.key}`);
6115
+ }
4373
6116
  /** Preserve exact request-local Cursor counters as diagnostics. */
4374
6117
  export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource) {
4375
6118
  return {
@@ -4386,6 +6129,51 @@ export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
4386
6129
  },
4387
6130
  };
4388
6131
  }
6132
+ /** Partial assistant output may grow on a host retry; it is not a new user turn. */
6133
+ export function retryTurnKey(prompt, withoutLastInterruptionNotice = false) {
6134
+ // OpenCode 2 runner/llm.ts emits this synthetic after an incomplete stream;
6135
+ // runner/to-llm-message.ts lowers it to an ordinary user text part. Limit
6136
+ // recognition to exact, standalone text after assistant output. A plugin's
6137
+ // trailing host-note parts do not identify a user turn: they may appear only
6138
+ // on this request and disappear from the next request's history. Normalize
6139
+ // them for hashing only. The budget admits the continuation only after a
6140
+ // requested host retry and remembers its full key across tool steps.
6141
+ const interrupted = "The previous response was interrupted. Continue from where you left off without repeating completed content.";
6142
+ const messages = prompt.filter(message => message.role !== "system" && !hostTailNote(message)).map(message => {
6143
+ if (message.role !== "user")
6144
+ return message;
6145
+ let end = message.content.length;
6146
+ while (end > 1 && hostTailNote({ role: "user", content: [message.content[end - 1]] }) !== undefined)
6147
+ end--;
6148
+ return end === message.content.length ? message : { ...message, content: message.content.slice(0, end) };
6149
+ });
6150
+ let end = messages.length;
6151
+ while (end > 0 && messages[end - 1].role !== "user")
6152
+ end--;
6153
+ const last = messages[end - 1];
6154
+ if (withoutLastInterruptionNotice && last?.role === "user" && last.content.length === 1
6155
+ && last.content[0]?.type === "text" && last.content[0].text === interrupted
6156
+ && messages[end - 2]?.role === "assistant") {
6157
+ // Remove only this continuation, retaining earlier literal user requests.
6158
+ end--;
6159
+ while (end > 0 && messages[end - 1].role !== "user")
6160
+ end--;
6161
+ }
6162
+ return createHash("sha256").update(JSON.stringify(messages.slice(0, end))).digest("hex");
6163
+ }
6164
+ /** All user messages after the last assistant/tool boundary form one live user turn. */
6165
+ export function liveUserTurn(prompt) {
6166
+ let start = prompt.length;
6167
+ while (start > 0 && (prompt[start - 1].role === "user" || prompt[start - 1].role === "system"))
6168
+ start--;
6169
+ if (start === 0)
6170
+ while (start < prompt.length && prompt[start].role === "system")
6171
+ start++;
6172
+ if (start === prompt.length)
6173
+ return undefined;
6174
+ const texts = prompt.slice(start).flatMap((message) => message.role === "user" && !hostTailNote(message) ? [extractUserText(message)] : []);
6175
+ return { start, text: texts.join("\n\n") || "." };
6176
+ }
4389
6177
  /**
4390
6178
  * Prior prompt turns for a seed ConversationStateStructure. Tool results must
4391
6179
  * never be replayed as assistant-authored prose: that teaches the model to
@@ -4399,11 +6187,20 @@ export function extractPromptHistory(prompt, options) {
4399
6187
  const toolResults = options?.toolResults ?? "omit";
4400
6188
  let trailingToolStart = prompt.length;
4401
6189
  if (toolResults === "trailing") {
6190
+ // A rebased continuation may end with host notes after the step's results.
6191
+ let notesStart = prompt.length;
6192
+ while (notesStart > 0 && hostTailNote(prompt[notesStart - 1]))
6193
+ notesStart--;
6194
+ trailingToolStart = notesStart;
4402
6195
  while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
4403
6196
  trailingToolStart--;
4404
6197
  }
6198
+ if (trailingToolStart === notesStart)
6199
+ trailingToolStart = prompt.length;
4405
6200
  }
4406
6201
  for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
6202
+ if (!options?.preserveTrailingUser && options?.liveTurnStart !== undefined && messageIndex >= options.liveTurnStart)
6203
+ break;
4407
6204
  const m = prompt[messageIndex];
4408
6205
  if (m.role === "system") {
4409
6206
  if (typeof m.content === "string" && m.content.length > 0) {
@@ -4434,7 +6231,8 @@ export function extractPromptHistory(prompt, options) {
4434
6231
  continue;
4435
6232
  const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
4436
6233
  const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
4437
- const result = toolResultOutputToText(p.output);
6234
+ const failure = toolResultFailure(p.providerOptions, toolCallId);
6235
+ const result = failure ? { text: failure.text, isError: true } : toolResultOutputToText(p.output);
4438
6236
  results.push(formatSeedToolObservation({
4439
6237
  toolName,
4440
6238
  toolCallId,
@@ -4447,7 +6245,7 @@ export function extractPromptHistory(prompt, options) {
4447
6245
  }
4448
6246
  }
4449
6247
  // Live user message is the Run action, not seed history.
4450
- if (!options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
6248
+ if (options?.liveTurnStart === undefined && !options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
4451
6249
  out.pop();
4452
6250
  }
4453
6251
  return out;
@@ -4495,9 +6293,12 @@ export const FOREIGN_HISTORY_REBASE_CONTEXT_SHARE = 0.8;
4495
6293
  */
4496
6294
  export function assertForeignHistoryRebaseFits(input) {
4497
6295
  const documented = getDocumentedCursorModelContext(input.cursorModelId);
4498
- const limit = input.maxMode
6296
+ // Max mode does not imply the model's largest tier: Opus 300k Fast is a
6297
+ // max-mode variant with a 300k window.
6298
+ const variantContext = parseCursorContextLimit(input.parameters?.find((parameter) => parameter.id === "context")?.value);
6299
+ const limit = variantContext ?? (input.maxMode
4499
6300
  ? (input.modelInfo?.maxContextForMaxMode ?? documented?.maxContextForMaxMode ?? 1_000_000)
4500
- : (input.modelInfo?.maxContext ?? documented?.maxContext ?? 200_000);
6301
+ : (input.modelInfo?.maxContext ?? documented?.maxContext ?? 200_000));
4501
6302
  const chars = input.history.reduce((sum, message) => sum + message.content.length, 0)
4502
6303
  + (input.systemPrompt?.length ?? 0)
4503
6304
  + input.userText.length;
@@ -4552,9 +6353,7 @@ export async function refreshHeldSessionToolCatalog(session, callOptions) {
4552
6353
  const sessionKey = session.openCodeSessionId;
4553
6354
  const previousAdvertised = session.toolCatalog?.length ?? 0;
4554
6355
  const incomingTools = extractTools(callOptions);
4555
- const providerOptions = callOptions.providerOptions?.cursor;
4556
- const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
4557
- const isCompaction = compactionOption === true || (compactionOption === undefined && !!sessionKey && isCompactionSession(sessionKey));
6356
+ const isCompaction = isCompactionCall(callOptions, sessionKey);
4558
6357
  const toolState = await resolveTurnToolState({
4559
6358
  sessionKey,
4560
6359
  incomingTools,
@@ -4662,6 +6461,19 @@ function toolsInFixedOrder(tools) {
4662
6461
  });
4663
6462
  }
4664
6463
  /** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
6464
+ /**
6465
+ * The classic plugin marks OpenCode's agent="compaction" through chat.params.
6466
+ * OpenCode 2.0 removed that hook, so its plugin writes the same request-local
6467
+ * option from session hooks. The session marker is a fallback only when a
6468
+ * host does not preserve mutable options; explicit false prevents concurrent
6469
+ * title/generate calls from inheriting a compaction marker.
6470
+ * Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
6471
+ */
6472
+ function isCompactionCall(callOptions, sessionKey) {
6473
+ const providerOptions = callOptions.providerOptions?.cursor;
6474
+ const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
6475
+ return compactionOption === true || (compactionOption === undefined && !!sessionKey && isCompactionSession(sessionKey));
6476
+ }
4665
6477
  export function computeAllowTools(toolCount, toolChoice) {
4666
6478
  return toolCount > 0 && toolChoice?.type !== "none";
4667
6479
  }
@@ -4763,11 +6575,12 @@ export function resolveTurnConversationReset(input) {
4763
6575
  return { reset: true, reason: "history-rewrite" };
4764
6576
  }
4765
6577
  // Diagnostics only: accept promptIdentity for remember without reminting.
4766
- if (sessionKey && input.promptIdentity) {
6578
+ const legacyPromptIdentity = input.promptIdentity;
6579
+ if (sessionKey && legacyPromptIdentity) {
4767
6580
  const previous = promptIdentityBySession.get(sessionKey);
4768
6581
  rememberPromptIdentity(sessionKey, {
4769
6582
  ...previous,
4770
- ...normalizePromptIdentity(input.promptIdentity),
6583
+ ...normalizePromptIdentity(legacyPromptIdentity),
4771
6584
  });
4772
6585
  }
4773
6586
  if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
@@ -4776,11 +6589,17 @@ export function resolveTurnConversationReset(input) {
4776
6589
  return { reset: false };
4777
6590
  }
4778
6591
  export function resetTurnStateForTests() {
6592
+ resetRetryBudgetsForTests();
4779
6593
  toolCatalogBySession.clear();
4780
6594
  postCompactionRebaseBySession.clear();
4781
6595
  promptIdentityBySession.clear();
4782
6596
  mirroredTodosBySession.clear();
4783
- sentHistoryImageHashesBySession.clear();
6597
+ resetHeldHistoryImagesForTests();
6598
+ undeliveredHostNotesBySession.clear();
6599
+ steerAnsweringRuns.clear();
6600
+ resetHostSteersForTests();
6601
+ hostNoteInjectionsBySession.clear();
6602
+ hostInterrupts.clear();
4784
6603
  resetContextEpochsForTests();
4785
6604
  resetFrozenRequestContextsForTests();
4786
6605
  }
@@ -4818,6 +6637,8 @@ function foldStreamParts(parts) {
4818
6637
  else if (part.type === "reasoning-delta")
4819
6638
  reasoning += part.delta;
4820
6639
  else if (part.type === "tool-call") {
6640
+ if (part.providerExecuted)
6641
+ continue;
4821
6642
  content.push({
4822
6643
  type: "tool-call",
4823
6644
  toolCallId: part.toolCallId,