cursor-opencode-provider 0.7.2 → 0.7.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 (50) hide show
  1. package/README.md +8 -5
  2. package/dist/context/agents.js +1 -1
  3. package/dist/context/build.d.ts +2 -0
  4. package/dist/context/build.js +22 -10
  5. package/dist/context/epoch.d.ts +59 -0
  6. package/dist/context/epoch.js +224 -0
  7. package/dist/context/frozen.d.ts +4 -2
  8. package/dist/context/frozen.js +31 -11
  9. package/dist/context/index.d.ts +2 -0
  10. package/dist/context/index.js +2 -0
  11. package/dist/context/overlay.d.ts +44 -0
  12. package/dist/context/overlay.js +96 -0
  13. package/dist/debug.d.ts +9 -1
  14. package/dist/debug.js +46 -5
  15. package/dist/host-event-bridge.d.ts +22 -0
  16. package/dist/host-event-bridge.js +22 -0
  17. package/dist/image-input.js +36 -11
  18. package/dist/index.d.ts +3 -0
  19. package/dist/language-model.d.ts +51 -3
  20. package/dist/language-model.js +569 -140
  21. package/dist/model-config.js +5 -1
  22. package/dist/plugin-opencode2.js +10 -4
  23. package/dist/plugin.js +26 -15
  24. package/dist/pricing-data.d.ts +40 -0
  25. package/dist/pricing-data.js +40 -0
  26. package/dist/protocol/ask-question.d.ts +8 -9
  27. package/dist/protocol/ask-question.js +8 -9
  28. package/dist/protocol/conversation-bind.js +18 -8
  29. package/dist/protocol/create-plan.d.ts +6 -7
  30. package/dist/protocol/create-plan.js +4 -5
  31. package/dist/protocol/messages.js +16 -1
  32. package/dist/protocol/struct.js +1 -1
  33. package/dist/protocol/switch-mode.d.ts +3 -4
  34. package/dist/protocol/switch-mode.js +21 -12
  35. package/dist/protocol/tool-call-bridge.d.ts +4 -1
  36. package/dist/protocol/tool-call-bridge.js +27 -5
  37. package/dist/protocol/tools.d.ts +4 -2
  38. package/dist/protocol/tools.js +247 -51
  39. package/dist/session-directory.d.ts +23 -4
  40. package/dist/session-directory.js +36 -4
  41. package/dist/session.d.ts +23 -3
  42. package/dist/session.js +68 -8
  43. package/dist/shared.d.ts +2 -0
  44. package/dist/shared.js +2 -0
  45. package/dist/transport/connect.js +78 -10
  46. package/dist/transport/https-proxy.d.ts +56 -0
  47. package/dist/transport/https-proxy.js +348 -0
  48. package/dist/usage.d.ts +11 -0
  49. package/dist/usage.js +33 -7
  50. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
11
11
  import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
12
12
  import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
13
13
  import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
14
- import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
14
+ import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, isNativeDisplayToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
15
15
  import { handleKvServerMessage } from "./protocol/kv.js";
16
16
  import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
17
17
  import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
@@ -22,9 +22,9 @@ import { flushHostAgentModeSwitch, queueHostAgentModeSwitch, } from "./host-agen
22
22
  import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
23
23
  import { stageCursorImage } from "./image-staging.js";
24
24
  import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
25
- import { clearCheckpoint, getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
25
+ import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
26
26
  import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
27
- import { clearConversationBlobs, conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
27
+ import { conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
28
28
  import { bindConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
29
29
  import { clearPersistedConversationState, hydrateConversationState, persistConversationState, } from "./protocol/conversation-state.js";
30
30
  import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
@@ -32,18 +32,19 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
32
32
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
33
33
  import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
34
34
  import { getOrBuildRequestContext } from "./context/frozen.js";
35
+ import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
35
36
  import { workspaceRootFromRequestContext } from "./context/env.js";
36
37
  import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
37
38
  import { resolveAgentUrl } from "./agent-url.js";
38
- import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
39
+ import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HISTORY_REWRITE_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
39
40
  import { isCompactionSession } from "./compaction-marker.js";
40
- import { getSessionDirectory } from "./session-directory.js";
41
+ import { resolveSessionWorkspaceRoot } from "./session-directory.js";
41
42
  import { assertCursorUserImageSupport, extractCursorPromptImages } from "./image-input.js";
42
43
  import { resolveCursorModelSupportsImages } from "./model-metadata.js";
43
44
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
44
45
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
45
46
  import { readAllFieldsStrict } from "./protocol/struct.js";
46
- import { buildLanguageModelV3UsageFromCounters, cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, occupancyUsageFromTokenDetails, OPENCODE_DISPLAY_ONLY_COST_METADATA, turnEndedCounter, } from "./usage.js";
47
+ import { cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, occupancyUsageFromTokenDetails, occupancyValidationCounters, OPENCODE_DISPLAY_ONLY_COST_METADATA, turnEndedCounter, } from "./usage.js";
47
48
  let _availableModels;
48
49
  // mtime of the cache file the last time we loaded it. Compared on each call
49
50
  // so discoverModels' background refresh is picked up without a process restart.
@@ -71,10 +72,23 @@ const MAX_RETRY_DELAY_MS = 30_000;
71
72
  const RUN_REQUEST_DECODE_FAILED = "CURSOR_RUN_REQUEST_DECODE_FAILED";
72
73
  const RUN_REQUEST_UNSUPPORTED = "CURSOR_RUN_REQUEST_UNSUPPORTED";
73
74
  const RUN_REPLY_FAILED = "CURSOR_RUN_REPLY_FAILED";
74
- /** Cursor's own conversation export path treats state above 100 MiB as large. */
75
+ /**
76
+ * Cursor CLI's conversation **export / transfer-to-cloud** path treats state
77
+ * above 100 MiB as large (`conversation-export.ts` default `104857600`). That
78
+ * budget must not remint a sticky conversation on resume — CLI soft-reuses.
79
+ */
75
80
  export const MAX_CHECKPOINT_BLOB_GRAPH_BYTES = 100 * 1024 * 1024;
76
- export function checkpointBlobGraphRequiresRebase(stats, maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
77
- return !stats.complete || stats.bytes > maxBytes;
81
+ /** @deprecated Never remints; kept for call-site/tests. Always false. */
82
+ export function checkpointBlobGraphRequiresRebase(_stats, _maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
83
+ return false;
84
+ }
85
+ /** Warn-only concern for incomplete / oversized checkpoint graphs (no remint). */
86
+ export function checkpointBlobGraphConcern(stats, maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
87
+ if (!stats.complete)
88
+ return "incomplete-checkpoint-graph";
89
+ if (stats.bytes > maxBytes)
90
+ return "oversized-checkpoint-graph";
91
+ return undefined;
78
92
  }
79
93
  const RESPONSE_REQUIRED_CHANNEL_BY_FIELD = new Map([
80
94
  [2, "exec"],
@@ -396,13 +410,13 @@ function rememberPromptIdentity(sessionKey, value) {
396
410
  promptIdentityBySession.delete(oldest);
397
411
  }
398
412
  }
399
- function promptIdentityChanged(previous, current) {
400
- // Compare only facts the current host supplied. This keeps standalone calls
401
- // and older compatibility surfaces from invalidating a valid checkpoint just
402
- // because they cannot expose an agent id.
403
- return ((current.hostAgent !== undefined && previous.hostAgent !== current.hostAgent)
404
- || (current.systemPromptHash !== undefined
405
- && previous.systemPromptHash !== current.systemPromptHash));
413
+ /**
414
+ * Prompt identity is diagnostics / persistence only and never drives remint.
415
+ * Kept as a named helper so call sites / tests that still mention identity
416
+ * churn have a single no-op definition.
417
+ */
418
+ export function promptIdentityWouldRemint(_previous, _current) {
419
+ return false;
406
420
  }
407
421
  function sentHistoryImageHashes(sessionKey) {
408
422
  if (!sessionKey)
@@ -501,7 +515,8 @@ async function doStreamImpl(modelId, options, callOptions) {
501
515
  session = deliverContinuationResults(session, trailingToolResults);
502
516
  }
503
517
  if (!session) {
504
- if (trailingToolResults.length > 0) {
518
+ const sessionKey = opencodeSessionKey(callOptions);
519
+ if (!session && trailingToolResults.length > 0) {
505
520
  // True continuation (prompt ends with tool results) but the held-open Run
506
521
  // is gone (or its write path just failed). Rebase the complete OpenCode
507
522
  // prompt onto a fresh conversation: its seed history includes the
@@ -518,6 +533,18 @@ async function doStreamImpl(modelId, options, callOptions) {
518
533
  if (historical > 0) {
519
534
  trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
520
535
  }
536
+ // Host may start a new user turn while the prior Run still has pending
537
+ // tools. Finish that Run on the same conversation (cancel stranded execs,
538
+ // drain turn_ended) before opening the new Run so the checkpoint prefix
539
+ // is preserved. registerSession will not close a prior Run that still
540
+ // has real pending execs; a failed prepare leaves that Run held.
541
+ try {
542
+ await preparePriorSessionForFreshTurn(sessionKey);
543
+ }
544
+ catch (error) {
545
+ trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
546
+ `any still-pending prior held — ${error.message}`);
547
+ }
521
548
  session = await openSession();
522
549
  }
523
550
  }
@@ -773,21 +800,23 @@ async function startSession(modelId, token, callOptions, options, startOptions)
773
800
  const lifecycle = !allowTools && !isCompaction && !recovery;
774
801
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
775
802
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
776
- // projects, so its static option is only a fallback for the directory recorded
777
- // from `session.hook("context")`.
778
- const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
803
+ // projects, so its static option is only a last-resort fallback. Prefer the
804
+ // per-request `x-opencode-directory` header, then the session mark recorded
805
+ // from `session.hook("context")` via `getSessionDirectory`.
806
+ const workspaceRoot = resolveSessionWorkspaceRoot({
807
+ sessionKey,
808
+ headers: callOptions.headers,
809
+ workspaceRoot: options.workspaceRoot,
810
+ });
779
811
  const baseSystemPrompt = extractSystemPrompt(prompt);
780
812
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
781
- const stableSystemPrompt = [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n");
782
- const stableSystemPromptHash = stableSystemPrompt
783
- ? createHash("sha256").update(stableSystemPrompt).digest("hex")
784
- : undefined;
813
+ // Prompt-identity diagnostics are filled after Context Epoch admission below
814
+ // (baseline hash is the epoch baseline, not a per-turn host hash).
815
+ let frozenSystemPromptHash;
785
816
  const resetState = resolveTurnConversationReset({
786
817
  sessionKey,
787
818
  isCompaction,
788
- ...(lifecycle
789
- ? {}
790
- : { promptIdentity: { hostAgent, systemPromptHash: stableSystemPromptHash } }),
819
+ historyRewrite: providerOptions?.[CURSOR_HISTORY_REWRITE_OPTION] === true,
791
820
  });
792
821
  // Compaction must not reuse the prior conversation; its first normal turn
793
822
  // must also rebase so the summary-agent checkpoint cannot replace the normal
@@ -806,30 +835,18 @@ async function startSession(modelId, token, callOptions, options, startOptions)
806
835
  let checkpointGraph = conversationState
807
836
  ? inspectConversationBlobGraph(bound.conversationId, conversationState)
808
837
  : { count: 0, bytes: 0, complete: true };
809
- let forcedResetReason;
810
- if (conversationState && checkpointBlobGraphRequiresRebase(checkpointGraph)) {
811
- const previousConversationId = bound.conversationId;
812
- forcedResetReason = checkpointGraph.complete
813
- ? "oversized-checkpoint-graph"
814
- : "incomplete-checkpoint-graph";
815
- trace(`checkpoint state rejected: reason=${forcedResetReason} ` +
816
- `conversationId=${previousConversationId} checkpointBytes=${conversationState.length} ` +
817
- `blobCount=${checkpointGraph.count} blobBytes=${checkpointGraph.bytes} ` +
818
- `limitBytes=${MAX_CHECKPOINT_BLOB_GRAPH_BYTES} ` +
819
- `detail=${checkpointGraph.fallbackReason ?? "-"} action=rebase`);
820
- // A recovery resume must become a history rebase so the interrupted user
821
- // request remains in the seed. An ordinary turn keeps its normal user text.
822
- if (recovery?.kind === "resume")
823
- recovery = { kind: "rebase" };
824
- resumeRecovery = undefined;
825
- resuming = false;
826
- if (!sessionKey) {
827
- clearCheckpoint(previousConversationId);
828
- clearConversationBlobs(previousConversationId);
829
- }
830
- bound = bindConversationId(sessionKey, { reset: true });
831
- conversationState = undefined;
832
- checkpointGraph = { count: 0, bytes: 0, complete: true };
838
+ const forcedResetReason = undefined;
839
+ // CLI soft-reuses incomplete / oversized graphs (100 MiB is export-only).
840
+ // Never remint — warn and keep the sticky conversation + checkpoint.
841
+ if (conversationState) {
842
+ const concern = checkpointBlobGraphConcern(checkpointGraph);
843
+ if (concern) {
844
+ trace(`checkpoint state warning: reason=${concern} ` +
845
+ `conversationId=${bound.conversationId} checkpointBytes=${conversationState.length} ` +
846
+ `blobCount=${checkpointGraph.count} blobBytes=${checkpointGraph.bytes} ` +
847
+ `limitBytes=${MAX_CHECKPOINT_BLOB_GRAPH_BYTES} ` +
848
+ `detail=${checkpointGraph.fallbackReason ?? "-"} action=warm-reuse`);
849
+ }
833
850
  }
834
851
  const conversationId = bound.conversationId;
835
852
  const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
@@ -856,22 +873,53 @@ async function startSession(modelId, token, callOptions, options, startOptions)
856
873
  const activeMode = getActiveCursorMode(sessionKey);
857
874
  const nativePlanPromptOwnsMode = hostAgent === "plan"
858
875
  && (activeMode === "plan" || activeMode === "spec");
859
- const modeReminder = isCompaction || startedWithCheckpoint || lifecycle || nativePlanPromptOwnsMode
876
+ // Mode/kickoff are chronological Mid-Conversation updates (V2), including on
877
+ // checkpoint Turns — never folded into the frozen system baseline.
878
+ const modeReminder = isCompaction || lifecycle || nativePlanPromptOwnsMode
860
879
  ? undefined
861
880
  : takeActiveCursorModeReminder(sessionKey, {
862
881
  advertisedTools: cursorTools.map((tool) => tool.name),
863
882
  });
864
- const kickoffWarning = isCompaction || startedWithCheckpoint || lifecycle
883
+ const kickoffWarning = isCompaction || lifecycle
865
884
  ? undefined
866
885
  : takePlanExecutionKickoffWarning(sessionKey);
867
- const systemPrompt = [
868
- baseSystemPrompt,
869
- interactionGuidance,
886
+ const oneShotReminders = [
870
887
  modeReminder,
871
888
  kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
872
- ]
873
- .filter(Boolean)
874
- .join("\n\n");
889
+ ].filter((part) => !!part);
890
+ let systemPrompt;
891
+ if (isCompaction || lifecycle) {
892
+ // Ephemeral summary/title Runs — do not initialize a sticky Context Epoch.
893
+ systemPrompt = startedWithCheckpoint
894
+ ? undefined
895
+ : [baseSystemPrompt, interactionGuidance, ...oneShotReminders].filter(Boolean).join("\n\n");
896
+ if (startedWithCheckpoint && oneShotReminders.length) {
897
+ userText = appendMidConversationMessage(userText, oneShotReminders.join("\n\n"));
898
+ }
899
+ }
900
+ else {
901
+ const admitted = admitContextEpoch({
902
+ conversationId,
903
+ hasCheckpoint: startedWithCheckpoint,
904
+ hostSystem: baseSystemPrompt,
905
+ guidance: interactionGuidance,
906
+ hostAgent,
907
+ workspaceRoot,
908
+ oneShotReminders,
909
+ });
910
+ systemPrompt = startedWithCheckpoint ? undefined : admitted.seedSystemPrompt;
911
+ userText = appendMidConversationMessage(userText, admitted.midConversationMessage);
912
+ // A recovered epoch has no baseline bytes. Keep the hash restored from
913
+ // the checkpoint snapshot so this turn's TurnEnded save does not drop it.
914
+ const previousIdentity = sessionKey ? promptIdentityBySession.get(sessionKey) : undefined;
915
+ frozenSystemPromptHash = admitted.epoch.baselineHash.trim() || previousIdentity?.systemPromptHash;
916
+ if (sessionKey) {
917
+ rememberPromptIdentity(sessionKey, {
918
+ hostAgent: hostAgent || previousIdentity?.hostAgent,
919
+ systemPromptHash: frozenSystemPromptHash,
920
+ });
921
+ }
922
+ }
875
923
  const history = extractPromptHistory(prompt, {
876
924
  preserveTrailingUser: recovery?.kind === "rebase",
877
925
  toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
@@ -1047,7 +1095,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1047
1095
  stream.destroy();
1048
1096
  throw error;
1049
1097
  }
1050
- const hostToolDialect = hostToolDialectFromTools(tools);
1098
+ const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1051
1099
  trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
1052
1100
  `tools=[${tools.map((t) => t.name).join(",")}]`);
1053
1101
  const session = {
@@ -1074,10 +1122,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1074
1122
  stepCompletes: 0,
1075
1123
  displayToolCalls: 0,
1076
1124
  execRequests: 0,
1125
+ createPlanInTurn: false,
1126
+ switchModeInTurn: false,
1077
1127
  },
1078
1128
  openCodeSessionId: lifecycle ? undefined : sessionKey,
1079
1129
  hostAgent,
1080
- stableSystemPromptHash,
1130
+ stableSystemPromptHash: frozenSystemPromptHash,
1081
1131
  postCompactionRebase: isCompaction,
1082
1132
  toolCatalog: snapshotToolCatalog(sessionKey),
1083
1133
  stream,
@@ -1097,6 +1147,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1097
1147
  subagentCatalog,
1098
1148
  requestContext,
1099
1149
  allowTools,
1150
+ permittedToolNames: new Set(allowTools ? incomingTools.map((tool) => tool.name).filter((name) => !!name) : []),
1100
1151
  usageEstimate,
1101
1152
  pumpActive: false,
1102
1153
  pumpOwner: null,
@@ -1191,6 +1242,295 @@ export function findContinuationSession(toolResults) {
1191
1242
  }
1192
1243
  return undefined;
1193
1244
  }
1245
+ /** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
1246
+ export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
1247
+ /**
1248
+ * Error text written onto Cursor execs that the host abandoned by starting a
1249
+ * new user turn before returning the tool result. Completing the held Run with
1250
+ * this cancel (then draining to `turn_ended`) keeps the same conversation
1251
+ * prefix instead of `superseded-by-new-run` mid-exec.
1252
+ */
1253
+ export const FRESH_TURN_PENDING_CANCEL_REASON = "Host started a new user turn before this tool result was delivered";
1254
+ /**
1255
+ * Before opening a new user-turn Run, finish the prior held-open Run cleanly:
1256
+ * settle display-only bridged pendings, cancel any real execs still waiting on
1257
+ * the host, then drain for `turn_ended`. That preserves the same
1258
+ * conversation_id / checkpoint prefix instead of aborting mid-tool.
1259
+ */
1260
+ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
1261
+ const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
1262
+ if (!prior)
1263
+ return "none";
1264
+ const settledBridged = sessionManager.settleBridgedPending(prior);
1265
+ if (settledBridged > 0) {
1266
+ trace(`fresh turn: settled ${settledBridged} bridged pending on prior session ${prior.sessionId} ` +
1267
+ `remainingPending=${prior.pending.size}`);
1268
+ }
1269
+ if (sessionManager.isActivelyPumping(prior)) {
1270
+ // A live pull owns the stream; do not race cancel writes against it.
1271
+ // Wait briefly for the pump to finish, then cancel + drain.
1272
+ const waited = await waitUntilNotPumping(prior, {
1273
+ timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
1274
+ });
1275
+ if (!waited) {
1276
+ trace(`fresh turn: prior session ${prior.sessionId} is still pumping after wait — ` +
1277
+ `leaving that Run held; registerSession will not close it`);
1278
+ return "busy";
1279
+ }
1280
+ }
1281
+ if (prior.closed)
1282
+ return "none";
1283
+ const cancelled = cancelPendingExecsForFreshTurn(prior);
1284
+ if (cancelled > 0) {
1285
+ trace(`fresh turn: cancelled ${cancelled} pending exec(s) on prior session ${prior.sessionId} ` +
1286
+ `remainingPending=${prior.pending.size}`);
1287
+ }
1288
+ if (prior.closed) {
1289
+ // Cancel path closed the session (encode/write failure). Caller opens a
1290
+ // fresh Run on the same conversation_id — better than mid-exec supersede
1291
+ // with stranded pending, but turn_ended may be missing.
1292
+ return cancelled > 0 || settledBridged > 0 ? "settled-only" : "none";
1293
+ }
1294
+ if (prior.pending.size > 0) {
1295
+ trace(`fresh turn: prior session ${prior.sessionId} still has ${prior.pending.size} ` +
1296
+ `pending after cancel — leaving that Run held; registerSession will not close it`);
1297
+ return "busy";
1298
+ }
1299
+ const outcome = await drainSessionUntilTurnEnded(prior, {
1300
+ timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
1301
+ });
1302
+ trace(`fresh turn: prior session ${prior.sessionId} drain outcome=${outcome}`);
1303
+ if (outcome === "turn-ended")
1304
+ return "drained";
1305
+ if (cancelled > 0 || settledBridged > 0)
1306
+ return "settled-only";
1307
+ return outcome === "timeout" || outcome === "interrupted" ? "settled-only" : "busy";
1308
+ }
1309
+ /**
1310
+ * Write error/reject results for every still-open non-bridged pending on the
1311
+ * held Run so Cursor can finish the agent turn instead of being superseded.
1312
+ *
1313
+ * @returns number of pendings successfully cancelled
1314
+ */
1315
+ export function cancelPendingExecsForFreshTurn(session) {
1316
+ if (session.closed || session.pending.size === 0)
1317
+ return 0;
1318
+ const synthetic = [];
1319
+ for (const [execId, pending] of session.pending.entries()) {
1320
+ if (pending.bridged)
1321
+ continue;
1322
+ if (pending.state !== "pending")
1323
+ continue;
1324
+ synthetic.push({
1325
+ toolCallId: `cursor_${session.sessionId}_${execId}`,
1326
+ sessionId: session.sessionId,
1327
+ execId,
1328
+ toolName: pending.toolName ?? "unknown",
1329
+ output: FRESH_TURN_PENDING_CANCEL_REASON,
1330
+ error: FRESH_TURN_PENDING_CANCEL_REASON,
1331
+ });
1332
+ }
1333
+ if (synthetic.length === 0)
1334
+ return 0;
1335
+ const before = session.pending.size;
1336
+ const delivered = deliverContinuationResults(session, synthetic);
1337
+ if (!delivered || delivered.closed) {
1338
+ // deliverContinuationResults closes on encode/write failure after some
1339
+ // claims may already have succeeded.
1340
+ return Math.max(0, before - session.pending.size);
1341
+ }
1342
+ return Math.max(0, before - session.pending.size);
1343
+ }
1344
+ async function waitUntilNotPumping(session, opts) {
1345
+ if (!sessionManager.isActivelyPumping(session))
1346
+ return true;
1347
+ const deadlineAt = Date.now() + Math.max(1, opts.timeoutMs);
1348
+ while (Date.now() < deadlineAt) {
1349
+ if (session.closed)
1350
+ return false;
1351
+ if (!sessionManager.isActivelyPumping(session))
1352
+ return true;
1353
+ await new Promise((resolve) => {
1354
+ const timer = setTimeout(resolve, 25);
1355
+ timer.unref?.();
1356
+ });
1357
+ }
1358
+ return !sessionManager.isActivelyPumping(session);
1359
+ }
1360
+ function nextFrameWithTimeout(frames, timeoutMs) {
1361
+ return new Promise((resolve, reject) => {
1362
+ let settled = false;
1363
+ const finish = (value, error) => {
1364
+ if (settled)
1365
+ return;
1366
+ settled = true;
1367
+ clearTimeout(timer);
1368
+ if (error !== undefined)
1369
+ reject(error);
1370
+ else
1371
+ resolve(value);
1372
+ };
1373
+ const timer = setTimeout(() => finish({ done: true, timedOut: true }), timeoutMs);
1374
+ timer.unref?.();
1375
+ void frames.next().then(value => finish(value), error => finish(undefined, error));
1376
+ });
1377
+ }
1378
+ /**
1379
+ * Read remaining frames on an idle held-open Run until `turn_ended`, a
1380
+ * response-requiring request we cannot answer without the host, or timeout.
1381
+ * Used only on the fresh-turn settle path — not a general pump substitute.
1382
+ */
1383
+ export async function drainSessionUntilTurnEnded(session, opts) {
1384
+ if (session.closed)
1385
+ return "skipped";
1386
+ if (session.pending.size > 0 || sessionManager.isActivelyPumping(session))
1387
+ return "busy";
1388
+ const timeoutMs = Math.max(1, opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS);
1389
+ const owner = Symbol(`fresh-turn-drain:${session.sessionId}`);
1390
+ sessionManager.beginPump(session, owner);
1391
+ const deadlineAt = Date.now() + timeoutMs;
1392
+ let outcome = "timeout";
1393
+ try {
1394
+ while (Date.now() < deadlineAt) {
1395
+ if (session.closed) {
1396
+ outcome = "interrupted";
1397
+ break;
1398
+ }
1399
+ const remainingMs = Math.max(1, deadlineAt - Date.now());
1400
+ let next;
1401
+ try {
1402
+ next = await nextFrameWithTimeout(session.frames, remainingMs);
1403
+ }
1404
+ catch (error) {
1405
+ trace(`fresh turn drain: frame wait failed sessionId=${session.sessionId} ` +
1406
+ `err=${error.message}`);
1407
+ outcome = "interrupted";
1408
+ break;
1409
+ }
1410
+ if ("timedOut" in next && next.timedOut) {
1411
+ outcome = "timeout";
1412
+ break;
1413
+ }
1414
+ if (next.done) {
1415
+ outcome = "interrupted";
1416
+ break;
1417
+ }
1418
+ const frame = next.value;
1419
+ if (frame.flags & 0x02) {
1420
+ outcome = "interrupted";
1421
+ break;
1422
+ }
1423
+ let payload;
1424
+ try {
1425
+ payload = decodeFramePayload(frame);
1426
+ }
1427
+ catch {
1428
+ continue;
1429
+ }
1430
+ let asm;
1431
+ try {
1432
+ asm = decodeMessage("AgentServerMessage", payload);
1433
+ }
1434
+ catch {
1435
+ continue;
1436
+ }
1437
+ const iu = asm.interaction_update;
1438
+ const kv = asm.kv_server_message;
1439
+ const esm = asm.exec_server_message;
1440
+ const interactionQuery = asm.interaction_query;
1441
+ const checkpointRaw = asm.conversation_checkpoint_update;
1442
+ const requiredChannel = responseRequiredChannel(payload);
1443
+ if (checkpointRaw != null) {
1444
+ const bytes = normalizeCheckpointBytes(checkpointRaw);
1445
+ if (bytes && bytes.length > 0) {
1446
+ if (session.cacheDiagnostics)
1447
+ session.cacheDiagnostics.checkpointUpdates++;
1448
+ setCheckpoint(session.conversationId, bytes);
1449
+ session.resumeCheckpoint = Uint8Array.from(bytes);
1450
+ const tokenDetails = decodeConversationTokenDetails(bytes);
1451
+ if (tokenDetails) {
1452
+ if (session.cacheDiagnostics)
1453
+ session.cacheDiagnostics.tokenDetailUpdates++;
1454
+ session.tokenDetails = tokenDetails;
1455
+ session.tokenDetailsFresh = true;
1456
+ }
1457
+ }
1458
+ }
1459
+ if (iu?.turn_ended) {
1460
+ trace(`fresh turn drain: turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
1461
+ if (session.openCodeSessionId) {
1462
+ await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
1463
+ sessionKey: session.openCodeSessionId,
1464
+ conversationId: session.conversationId,
1465
+ requestContext: session.requestContext,
1466
+ toolCatalog: session.toolCatalog ?? [],
1467
+ postCompactionRebase: session.postCompactionRebase,
1468
+ hostAgent: session.hostAgent,
1469
+ systemPromptHash: session.stableSystemPromptHash,
1470
+ }).catch((error) => {
1471
+ trace(`fresh turn drain: TurnEnded save failed ` +
1472
+ `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
1473
+ });
1474
+ }
1475
+ // Record TurnEnded cache counters on diagnostics for the abandoned Run
1476
+ // even though no OpenCode stream consumer will see this finish.
1477
+ const turnEnded = iu.turn_ended;
1478
+ const counters = cursorUsageCountersFromTurnEnded(turnEnded);
1479
+ if (session.cacheDiagnostics) {
1480
+ trace(formatCursorCacheDiagnostics(counters, session.tokenDetails, session.cacheDiagnostics.priorTokenDetails, {
1481
+ ...session.cacheDiagnostics,
1482
+ conversationGroupId: session.cacheDiagnostics.conversationGroupId
1483
+ ?? resolveConversationGroupId(session.openCodeSessionId, session.conversationId),
1484
+ modelId: session.cacheDiagnostics.modelId,
1485
+ }));
1486
+ }
1487
+ else {
1488
+ trace(`fresh turn drain: turn_ended input=${counters.inputTokens} ` +
1489
+ `cacheRead=${counters.cacheRead} output=${counters.outputTokens}`);
1490
+ }
1491
+ sessionManager.close(session, "turn-ended");
1492
+ outcome = "turn-ended";
1493
+ break;
1494
+ }
1495
+ if (kv) {
1496
+ const handled = handleKvServerMessage(kv, session);
1497
+ if (!handled) {
1498
+ outcome = "busy";
1499
+ break;
1500
+ }
1501
+ try {
1502
+ await writeWithBackpressure(session.stream, handled.reply, `fresh-turn-drain KV ${handled.kind}_blob reply id=${handled.id}`);
1503
+ }
1504
+ catch (error) {
1505
+ trace(`fresh turn drain: KV reply failed sessionId=${session.sessionId} ` +
1506
+ `err=${error.message}`);
1507
+ outcome = "interrupted";
1508
+ break;
1509
+ }
1510
+ sessionManager.recordSemanticProgress(session);
1511
+ continue;
1512
+ }
1513
+ // Text/thinking/heartbeat/partial display updates can be ignored while draining.
1514
+ if (iu?.text_delta || iu?.thinking_delta || iu?.heartbeat || iu?.partial_tool_call || iu?.step_started || iu?.step_completed) {
1515
+ sessionManager.recordSemanticProgress(session);
1516
+ continue;
1517
+ }
1518
+ if (iu?.tool_call_started || iu?.tool_call_completed) {
1519
+ // A new display tool needs host mediation — stop draining.
1520
+ outcome = "busy";
1521
+ break;
1522
+ }
1523
+ if (esm || interactionQuery || requiredChannel === "exec" || requiredChannel === "interaction") {
1524
+ outcome = "busy";
1525
+ break;
1526
+ }
1527
+ }
1528
+ }
1529
+ finally {
1530
+ sessionManager.endPump(session, owner);
1531
+ }
1532
+ return outcome;
1533
+ }
1194
1534
  /**
1195
1535
  * Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
1196
1536
  *
@@ -1672,6 +2012,8 @@ export async function pump(session, controller, ids, abortSignal) {
1672
2012
  stepCompletes: 0,
1673
2013
  displayToolCalls: 0,
1674
2014
  execRequests: 0,
2015
+ createPlanInTurn: false,
2016
+ switchModeInTurn: false,
1675
2017
  };
1676
2018
  cacheDiagnostics.pumpPasses++;
1677
2019
  const { textId, reasoningId } = ids;
@@ -1949,38 +2291,41 @@ export async function pump(session, controller, ids, abortSignal) {
1949
2291
  // OpenCode TUI/GUI replace each assistant message's tokens (they do not
1950
2292
  // sum occupancy) and the TUI footer requires tokens.output > 0. Cost is
1951
2293
  // added per step-finish. Emit checkpoint occupancy snapshots at tool-call
1952
- // boundaries with a $0 Copilot cost override, then the billed TurnEnded
1953
- // snapshot once at stop. Char/4 usageEstimate stays traces-only.
2294
+ // boundaries with a $0 Copilot cost override, then one more occupancy
2295
+ // snapshot at TurnEnded/stop. Held-Run TurnEnded counters are cumulative
2296
+ // across every tool step, but this finish only spans the last generation
2297
+ // slice — putting output_tokens/reasoning there makes host tok/s
2298
+ // (generated/stepElapsed) absurd. Keep exact request counters under
2299
+ // providerMetadata.cursor.*Raw. Char/4 usageEstimate stays traces-only.
1954
2300
  const tokenDetails = session.tokenDetails;
1955
- const occupancyDetails = !te && tokenDetails && tokenDetails.usedTokens > 0 ? tokenDetails : undefined;
2301
+ const occupancyDetails = tokenDetails && tokenDetails.usedTokens > 0 ? tokenDetails : undefined;
1956
2302
  const contextSource = tokenDetails
1957
2303
  ? session.tokenDetailsFresh
1958
2304
  ? "checkpoint-current-run"
1959
2305
  : "checkpoint-previous-turn"
1960
2306
  : undefined;
2307
+ const usage = settledUsage ?? (occupancyDetails
2308
+ ? occupancyUsageFromTokenDetails(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails)
2309
+ : emptyLanguageModelV3Usage());
1961
2310
  const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
1962
- const usage = settledUsage ?? (te
1963
- ? tokenDetails
1964
- ? buildLanguageModelV3UsageFromCounters(counters, {
1965
- contextTotalTokens: tokenDetails.usedTokens,
1966
- priorContextTokens: session.cacheDiagnostics?.priorTokenDetails?.usedTokens,
1967
- })
1968
- : emptyLanguageModelV3Usage()
1969
- : occupancyDetails
1970
- ? occupancyUsageFromTokenDetails(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails)
1971
- : emptyLanguageModelV3Usage());
2311
+ // TurnEnded stays a real (non-occupancyOnly) finish so hosts that collapse
2312
+ // tool-boundary occupancy still keep one context snapshot for the sidebar.
2313
+ // Copilot $0 avoids billing the occupancy-shaped counters as a new prompt.
1972
2314
  const providerMetadata = te
1973
- ? cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
1974
- : occupancyDetails && contextSource
1975
- ? {
1976
- ...OPENCODE_DISPLAY_ONLY_COST_METADATA,
1977
- cursor: {
1978
- usageVersion: 3,
1979
- occupancyOnly: true,
1980
- context: cursorContextUsageMetadata(occupancyDetails, contextSource),
1981
- },
1982
- }
1983
- : undefined;
2315
+ ? {
2316
+ ...OPENCODE_DISPLAY_ONLY_COST_METADATA,
2317
+ ...cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource),
2318
+ }
2319
+ : {
2320
+ ...OPENCODE_DISPLAY_ONLY_COST_METADATA,
2321
+ cursor: {
2322
+ usageVersion: 3,
2323
+ occupancyOnly: true,
2324
+ ...(occupancyDetails && contextSource
2325
+ ? { context: cursorContextUsageMetadata(occupancyDetails, contextSource) }
2326
+ : {}),
2327
+ },
2328
+ };
1984
2329
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1985
2330
  ? String(reason.unified ?? "unknown")
1986
2331
  : String(reason);
@@ -1989,29 +2334,48 @@ export async function pump(session, controller, ids, abortSignal) {
1989
2334
  const occupancySource = occupancyDetails
1990
2335
  ? `occupancy-${contextSource ?? "unavailable"}`
1991
2336
  : "intermediate-zero";
2337
+ // Occupancy finishes never see Cursor TurnEnded cache_read. usageEstimate.cacheRead
2338
+ // stays 0 for the whole Run, so logging it as rawCacheRead falsely reports 0% on
2339
+ // every tool-call step. Prefer the V3 occupancy partition (prior prefix → cacheRead)
2340
+ // and label the estimate separately from billed TurnEnded counters.
2341
+ const occupancyPrefixCache = occupancyDetails
2342
+ ? (session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0)
2343
+ : undefined;
2344
+ const rawIn = te
2345
+ ? turnEndedCounter(te, "input_tokens")
2346
+ : occupancyDetails
2347
+ ? occupancyDetails.usedTokens
2348
+ : est.inputTokens;
2349
+ const rawOut = te
2350
+ ? turnEndedCounter(te, "output_tokens")
2351
+ : occupancyDetails
2352
+ ? 1
2353
+ : est.outputTokens;
2354
+ const rawCacheRead = te
2355
+ ? turnEndedCounter(te, "cache_read")
2356
+ : occupancyPrefixCache ?? est.cacheRead;
2357
+ const rawCacheWrite = te ? turnEndedCounter(te, "cache_write") : est.cacheWrite;
1992
2358
  trace(`finish: reason=${reasonLabel} ` +
1993
2359
  `v3In=${inTotal} v3Out=${outTotal} ` +
1994
2360
  `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
1995
2361
  `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
1996
- `rawIn=${te ? turnEndedCounter(te, "input_tokens") : est.inputTokens} ` +
1997
- `rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
1998
- `rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
1999
- `rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
2362
+ `rawIn=${rawIn} rawOut=${rawOut} rawCacheRead=${rawCacheRead} rawCacheWrite=${rawCacheWrite} ` +
2363
+ `${occupancyPrefixCache !== undefined ? `occupancyPrefixCache=${occupancyPrefixCache} ` : ""}` +
2000
2364
  `source=${te
2001
2365
  ? settledSource ?? (contextSource ?? "unavailable")
2002
2366
  : occupancySource}`);
2003
- if (counters) {
2367
+ // Validate the usage we actually send. Occupancy finishes (tool-call and
2368
+ // TurnEnded/stop) use prior-prefix cacheRead — never compare that against
2369
+ // aggregate TurnEnded request cache ratios (false mismatch). Raw request
2370
+ // counters stay on `finish:` and cache diagnosis only.
2371
+ if (occupancyDetails) {
2372
+ trace(formatTurnUsageValidation(occupancyValidationCounters(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails), usage, occupancyDetails, contextSource));
2373
+ }
2374
+ else if (counters) {
2004
2375
  trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
2005
- trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
2006
2376
  }
2007
- else if (occupancyDetails) {
2008
- trace(formatTurnUsageValidation({
2009
- inputTokens: occupancyDetails.usedTokens,
2010
- outputTokens: 1,
2011
- cacheRead: session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0,
2012
- cacheWrite: 0,
2013
- reasoningTokens: 0,
2014
- }, usage, occupancyDetails, contextSource));
2377
+ if (counters) {
2378
+ trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
2015
2379
  }
2016
2380
  safeEnqueue({
2017
2381
  type: "finish",
@@ -2262,7 +2626,7 @@ export async function pump(session, controller, ids, abortSignal) {
2262
2626
  const display = parseDisplayToolCall(callId, toolCall, session.mirroredTodos);
2263
2627
  const advertised = advertisedToolNamesFromDescriptors(session.toolDescriptors);
2264
2628
  const bridged = display
2265
- ? resolveBridgedOpenCodeToolCall(display, advertised)
2629
+ ? resolveBridgedOpenCodeToolCall(display, advertised, session.hostToolDialect)
2266
2630
  : undefined;
2267
2631
  if (!display) {
2268
2632
  const callIdLog = callId.replace(/\r?\n/g, "\\n");
@@ -2275,9 +2639,17 @@ export async function pump(session, controller, ids, abortSignal) {
2275
2639
  `keys=[${Object.keys(toolCall).join(",")}]${wire}`);
2276
2640
  }
2277
2641
  else if (!bridged) {
2278
- trace(`display tool_call_completed: no advertised OpenCode tool ` +
2279
- `callId=${callId} variant=${display.variant} preferred=${display.preferredToolName} ` +
2280
- `advertised=[${advertised.join(",")}]`);
2642
+ // GetMcpTools / GetDynamicTools is executed by Cursor after mcp_state;
2643
+ // large catalogs then spill through write_args. Not a missing host tool.
2644
+ if (isNativeDisplayToolCall(display.variant)) {
2645
+ trace(`display tool_call_completed: native ${display.preferredToolName} callId=${callId} ` +
2646
+ `(server-side catalog; spill uses write_args)`);
2647
+ }
2648
+ else {
2649
+ trace(`display tool_call_completed: no advertised OpenCode tool ` +
2650
+ `callId=${callId} variant=${display.variant} preferred=${display.preferredToolName} ` +
2651
+ `advertised=[${advertised.join(",")}]`);
2652
+ }
2281
2653
  }
2282
2654
  else {
2283
2655
  // Snapshot only Cursor todo writes (not create_plan's synthetic
@@ -2544,6 +2916,22 @@ export async function pump(session, controller, ids, abortSignal) {
2544
2916
  return;
2545
2917
  continue;
2546
2918
  }
2919
+ // Epoch advertisement may keep tools the host filtered this turn
2920
+ // (plan denies edit). Refuse those here — do not emit to OpenCode.
2921
+ const permittedToolNames = session.permittedToolNames;
2922
+ if (permittedToolNames
2923
+ && permittedToolNames.size > 0
2924
+ && !permittedToolNames.has(parsed.toolName)) {
2925
+ const permitted = [...permittedToolNames].sort().join(", ");
2926
+ const reason = `OpenCode tool '${parsed.toolName}' is not permitted for the current agent this turn. ` +
2927
+ `Permitted tools: ${permitted || "none"}. Continue using only permitted tools; do not retry ` +
2928
+ `'${parsed.toolName}'.`;
2929
+ trace(`exec: not permitted this turn toolName=${parsed.toolName} ` +
2930
+ `permitted=[${[...permittedToolNames].sort().join(",")}]`);
2931
+ if (!await rejectExec(parsed, reason, "not permitted this turn"))
2932
+ return;
2933
+ continue;
2934
+ }
2547
2935
  if (recoverCorrelatedEditRead(parsed, displayCallId))
2548
2936
  continue;
2549
2937
  if (rejectMissingReadTarget(parsed)) {
@@ -2662,6 +3050,15 @@ export async function pump(session, controller, ids, abortSignal) {
2662
3050
  trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
2663
3051
  `field=${handled.variantField} outcome=${handled.outcome}` +
2664
3052
  (handled.reply ? "" : " (deferred to host tool result)"));
3053
+ // Tag the Run for cache diagnosis: a first CreatePlan/SwitchMode can
3054
+ // coincide with a one-time upstream tools-category expansion. Set on
3055
+ // any outcome (acknowledged/approved/bridged) via presence, not kind.
3056
+ if (handled.createPlan && session.cacheDiagnostics) {
3057
+ session.cacheDiagnostics.createPlanInTurn = true;
3058
+ }
3059
+ if (handled.switchMode && session.cacheDiagnostics) {
3060
+ session.cacheDiagnostics.switchModeInTurn = true;
3061
+ }
2665
3062
  if (handled.generateImage) {
2666
3063
  trace(`interaction_query: approved generate_image target=` +
2667
3064
  `${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
@@ -2974,7 +3371,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2974
3371
  // CreatePlan in Cursor's function definitions, notices it is absent from the
2975
3372
  // OpenCode list, and narrates "No CreatePlan MCP tool is available" even
2976
3373
  // though the provider bridges the native interaction.
2977
- instructions.push("- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally; the provider writes the plan under the host's calculated plans directory and handles execution approval. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool.");
3374
+ instructions.push(names.has("cursor_plan_stage")
3375
+ ? "- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally. The host stage tool waits for the host plan review and does not return until the user accepts or declines. Do not call `plan_exit` to submit or skip that review, and do not implement until that tool returns success. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool. When the task will need a user-approved plan, record the first version as soon as its shape is clear, then keep investigating: refining afterward is expected and follow-up plans cost nothing extra. Do not defer the first plan until investigation is complete."
3376
+ : "- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally; the provider writes the plan under the host's calculated plans directory and handles execution approval. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool. When the task will need a user-approved plan, record the first version as soon as its shape is clear, then keep investigating: refining afterward is expected and follow-up plans cost nothing extra. Do not defer the first plan until investigation is complete.");
2978
3377
  if (names.has("plan_enter")) {
2979
3378
  instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool. Cursor-native SwitchMode requests for plan/spec are also accepted and answered through it.");
2980
3379
  }
@@ -3007,6 +3406,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
3007
3406
  instructions.push(shell
3008
3407
  ? `- OpenCode \`execute\` is Code Mode JavaScript (\`code\`); it is not a shell. For OS commands, call OpenCode ${shell}. Do not pass \`command\` to \`execute\`.`
3009
3408
  : "- OpenCode `execute` is Code Mode JavaScript (`code`); it is not a shell. Do not pass `command` to `execute`.");
3409
+ instructions.push("- When the host's Code Mode catalog lists additional tools, including MCP server tools, call them inside `execute` through `tools`. Use only the exact paths and signatures in that catalog or returned by its `search` function. Call `execute` with `{ code }` to run them; do not request a Code Mode tool as a direct OpenCode tool call.");
3010
3410
  }
3011
3411
  if (names.has("task") || names.has("subagent")) {
3012
3412
  const target = names.has("task") ? "`task`" : "`subagent`";
@@ -3037,11 +3437,11 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
3037
3437
  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.");
3038
3438
  }
3039
3439
  return [
3040
- `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
3440
+ `OpenCode exposes these direct tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
3041
3441
  `Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
3042
3442
  subagents.executor
3043
- ? "Call only tools in that exact OpenCode list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
3044
- : "Call only tools in that exact OpenCode list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
3443
+ ? "Call only tools in that direct OpenCode list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
3444
+ : "Call only tools in that direct OpenCode list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
3045
3445
  ...(instructions.length > 0
3046
3446
  ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
3047
3447
  : []),
@@ -3203,12 +3603,13 @@ function extractTools(callOptions) {
3203
3603
  const out = [];
3204
3604
  for (const t of tools) {
3205
3605
  // LanguageModelV3FunctionTool always has type:"function". Be defensive in
3206
- // case a middleware strips it — still accept anything with a name + schema.
3606
+ // case a middleware strips it or passes schema as parameters/schema.
3207
3607
  const any = t;
3208
- if (any.type === "function" || (any.name && any.inputSchema !== undefined)) {
3608
+ const schema = any.inputSchema ?? any.parameters ?? any.schema;
3609
+ if (any.type === "function" || (any.name && schema !== undefined)) {
3209
3610
  if (!any.name)
3210
3611
  continue;
3211
- out.push({ name: any.name, description: any.description, inputSchema: any.inputSchema });
3612
+ out.push({ name: any.name, description: any.description, inputSchema: schema });
3212
3613
  }
3213
3614
  }
3214
3615
  trace(`extractTools: ${tools.length} incoming → ${out.length} advertised [${out.map((t) => t.name).join(",")}]`);
@@ -3223,8 +3624,15 @@ export function spanEndParts(opts) {
3223
3624
  out.push({ type: "text-end", id: opts.textId });
3224
3625
  return out;
3225
3626
  }
3627
+ /** UTF-16 name order for the first catalog freeze and for newcomers only. */
3226
3628
  function toolsInFixedOrder(tools) {
3227
- return tools.map((tool) => ({ ...tool })).sort((left, right) => (left.name ?? "").localeCompare(right.name ?? ""));
3629
+ return tools
3630
+ .map((tool) => ({ ...tool }))
3631
+ .sort((left, right) => {
3632
+ const a = left.name ?? "";
3633
+ const b = right.name ?? "";
3634
+ return a < b ? -1 : a > b ? 1 : 0;
3635
+ });
3228
3636
  }
3229
3637
  /** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
3230
3638
  export function computeAllowTools(toolCount, toolChoice) {
@@ -3232,39 +3640,62 @@ export function computeAllowTools(toolCount, toolChoice) {
3232
3640
  }
3233
3641
  export async function resolveTurnToolState(input) {
3234
3642
  const { sessionKey, incomingTools, isCompaction } = input;
3235
- if (sessionKey && incomingTools.length > 0) {
3236
- rememberToolCatalog(sessionKey, toolsInFixedOrder(incomingTools));
3237
- }
3238
3643
  // Advertisement and permission are deliberately independent.
3239
3644
  //
3240
3645
  // Advertisement must stay byte-stable across every Run of a conversation or
3241
- // the RequestContext changes shape and Cursor's prompt cache goes cold. A
3242
- // zero-tool call is never a smaller catalog — it is a lifecycle turn
3243
- // (compaction, title generation, summarization) that OpenCode opens alongside
3244
- // the real one — so it re-advertises the last real catalog rather than
3245
- // collapsing the context to tools=0. A genuinely restricted turn still sends
3246
- // a non-empty set and is advertised verbatim.
3646
+ // the RequestContext changes shape and Cursor's prompt cache goes cold.
3647
+ //
3648
+ // OpenCode V1 *does* shrink the catalog on plan (edit denied → tools dropped
3649
+ // in request.ts resolveTools). Copying that into Cursor RequestContext costs
3650
+ // the whole tools prefix. Prefer: keep the epoch's fullest catalog for
3651
+ // advertisement; compute allowTools from what actually arrived this turn.
3652
+ //
3653
+ // A zero-tool call is never a smaller catalog — it is a lifecycle turn
3654
+ // (compaction, title generation) that re-advertises the last real catalog.
3655
+ // New tool names (MCP connect) append at the tail without rewriting
3656
+ // descriptors already frozen. Equal name-sets and host shrinks keep the
3657
+ // frozen advertisement and its order — schema/description churn must not
3658
+ // retokenize tools, and inserting a name that sorts earlier than `z` must
3659
+ // not reshuffle the prefix.
3247
3660
  //
3248
3661
  // On cold start the lifecycle Run may arrive before any catalog exists. For a
3249
3662
  // valid session key, wait until a sibling doStream publishes the first real
3250
- // catalog; cancellation is the only escape. Never time out to tools=[] and
3251
- // never invent or filter the enabled set.
3252
- //
3253
- // Permission is computed from what actually arrived, so those lifecycle turns
3254
- // still refuse execution and cannot duplicate the real turn's side effects.
3663
+ // catalog; cancellation is the only escape.
3255
3664
  let advertisedTools;
3256
3665
  if (incomingTools.length > 0) {
3257
- advertisedTools = toolsInFixedOrder(incomingTools);
3666
+ if (sessionKey) {
3667
+ const cached = toolCatalogBySession.get(sessionKey);
3668
+ if (cached && cached.length > 0) {
3669
+ const cachedNames = new Set(cached.map((tool) => tool.name));
3670
+ const incomingNames = new Set(incomingTools.map((tool) => tool.name));
3671
+ const hasNew = [...incomingNames].some((name) => !cachedNames.has(name));
3672
+ if (hasNew) {
3673
+ const newcomers = toolsInFixedOrder(incomingTools.filter((tool) => !cachedNames.has(tool.name)));
3674
+ const merged = [...cached, ...newcomers];
3675
+ rememberToolCatalog(sessionKey, merged);
3676
+ advertisedTools = merged;
3677
+ }
3678
+ else {
3679
+ rememberToolCatalog(sessionKey, cached);
3680
+ advertisedTools = cached;
3681
+ }
3682
+ }
3683
+ else {
3684
+ const initial = toolsInFixedOrder(incomingTools);
3685
+ rememberToolCatalog(sessionKey, initial);
3686
+ advertisedTools = initial;
3687
+ }
3688
+ }
3689
+ else {
3690
+ advertisedTools = toolsInFixedOrder(incomingTools);
3691
+ }
3258
3692
  }
3259
3693
  else if (sessionKey) {
3260
3694
  const cached = toolCatalogBySession.get(sessionKey)
3261
3695
  ?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
3262
- advertisedTools = toolsInFixedOrder(cached);
3696
+ advertisedTools = cached;
3263
3697
  }
3264
3698
  else {
3265
- // Without a session key there is no safe sibling-correlation key. Preserve
3266
- // the host's literal no-tool call rather than borrowing another session's
3267
- // catalog.
3268
3699
  advertisedTools = [];
3269
3700
  }
3270
3701
  return {
@@ -3279,25 +3710,22 @@ export function resolveTurnConversationReset(input) {
3279
3710
  rememberPostCompactionRebase(sessionKey);
3280
3711
  return { reset: true, reason: "compaction" };
3281
3712
  }
3282
- let promptChange;
3713
+ if (input.historyRewrite) {
3714
+ if (sessionKey)
3715
+ postCompactionRebaseBySession.delete(sessionKey);
3716
+ return { reset: true, reason: "history-rewrite" };
3717
+ }
3718
+ // Diagnostics only: accept promptIdentity for remember without reminting.
3283
3719
  if (sessionKey && input.promptIdentity) {
3284
- const current = normalizePromptIdentity(input.promptIdentity);
3285
3720
  const previous = promptIdentityBySession.get(sessionKey);
3286
- if (previous && promptIdentityChanged(previous, current)) {
3287
- promptChange = current.hostAgent !== undefined && previous.hostAgent !== current.hostAgent
3288
- ? "agent-change"
3289
- : "system-prompt-change";
3290
- }
3291
3721
  rememberPromptIdentity(sessionKey, {
3292
3722
  ...previous,
3293
- ...current,
3723
+ ...normalizePromptIdentity(input.promptIdentity),
3294
3724
  });
3295
3725
  }
3296
3726
  if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
3297
3727
  return { reset: true, reason: "post-compaction-rebase" };
3298
3728
  }
3299
- if (promptChange)
3300
- return { reset: true, reason: promptChange };
3301
3729
  return { reset: false };
3302
3730
  }
3303
3731
  export function resetTurnStateForTests() {
@@ -3310,6 +3738,7 @@ export function resetTurnStateForTests() {
3310
3738
  postCompactionRebaseBySession.clear();
3311
3739
  promptIdentityBySession.clear();
3312
3740
  mirroredTodosBySession.clear();
3741
+ resetContextEpochsForTests();
3313
3742
  }
3314
3743
  function extractUserText(lastUser) {
3315
3744
  if (!lastUser)