cursor-opencode-provider 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +9 -7
  2. package/dist/agent-url.js +2 -2
  3. package/dist/auth-renewal.d.ts +85 -0
  4. package/dist/auth-renewal.js +284 -0
  5. package/dist/auth.d.ts +38 -22
  6. package/dist/auth.js +79 -96
  7. package/dist/classic-tools.d.ts +15 -0
  8. package/dist/classic-tools.js +45 -0
  9. package/dist/context/build.d.ts +42 -5
  10. package/dist/context/build.js +96 -92
  11. package/dist/context/dynamic-catalog.d.ts +2 -58
  12. package/dist/context/dynamic-catalog.js +10 -160
  13. package/dist/context/epoch.d.ts +6 -4
  14. package/dist/context/epoch.js +22 -10
  15. package/dist/context/frozen.js +37 -14
  16. package/dist/context/index.d.ts +1 -1
  17. package/dist/context/index.js +1 -1
  18. package/dist/context/overlay.d.ts +2 -23
  19. package/dist/context/overlay.js +6 -30
  20. package/dist/context/paths.d.ts +7 -4
  21. package/dist/context/paths.js +13 -9
  22. package/dist/context/rules.d.ts +3 -15
  23. package/dist/context/rules.js +10 -186
  24. package/dist/debug.d.ts +2 -0
  25. package/dist/debug.js +4 -0
  26. package/dist/errors.d.ts +13 -0
  27. package/dist/errors.js +27 -3
  28. package/dist/host-agent-mode.js +2 -2
  29. package/dist/image-save.js +3 -3
  30. package/dist/index.d.ts +10 -0
  31. package/dist/language-model.d.ts +41 -8
  32. package/dist/language-model.js +391 -98
  33. package/dist/opencode2/integration.d.ts +30 -7
  34. package/dist/opencode2/integration.js +75 -46
  35. package/dist/plan-execution-kickoff.js +2 -2
  36. package/dist/plugin-opencode2.js +10 -16
  37. package/dist/plugin.d.ts +0 -10
  38. package/dist/plugin.js +138 -109
  39. package/dist/protocol/blob-store.js +3 -2
  40. package/dist/protocol/conversation-bind.js +1 -1
  41. package/dist/protocol/conversation-persistence.d.ts +2 -0
  42. package/dist/protocol/conversation-persistence.js +11 -1
  43. package/dist/protocol/conversation-state.d.ts +2 -0
  44. package/dist/protocol/conversation-state.js +19 -0
  45. package/dist/protocol/create-plan.js +2 -1
  46. package/dist/protocol/messages.js +22 -4
  47. package/dist/protocol/request.d.ts +11 -9
  48. package/dist/protocol/request.js +15 -25
  49. package/dist/protocol/tools.d.ts +17 -10
  50. package/dist/protocol/tools.js +53 -93
  51. package/dist/protocol/turn-provenance.d.ts +52 -0
  52. package/dist/protocol/turn-provenance.js +174 -0
  53. package/dist/session.d.ts +9 -1
  54. package/dist/shared.d.ts +7 -0
  55. package/dist/shared.js +7 -0
  56. package/package.json +1 -1
  57. package/dist/context/agents.d.ts +0 -12
  58. package/dist/context/agents.js +0 -136
  59. package/dist/context/skills.d.ts +0 -11
  60. package/dist/context/skills.js +0 -110
@@ -1,12 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
+ import { APICallError } from "@ai-sdk/provider";
4
5
  import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
5
6
  import { trace, traceRequestContextPaths } from "./debug.js";
7
+ import { isExchangeableApiKey } from "./auth.js";
8
+ import { resolveBearerToken } from "./auth-renewal.js";
6
9
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
10
  import { decodeFramePayload } from "./protocol/framing.js";
8
11
  import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, 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";
12
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, toolsToDescriptors, 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";
10
13
  import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
11
14
  import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
12
15
  import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
@@ -25,16 +28,17 @@ import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
25
28
  import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
26
29
  import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
27
30
  import { conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
28
- import { bindConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
29
- import { clearPersistedConversationState, hydrateConversationState, persistConversationState, } from "./protocol/conversation-state.js";
31
+ import { bindConversationId, peekConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
32
+ import { beginEmittedStep, detectForeignHistory, recordEmittedPart, trackTurnProvenance, } from "./protocol/turn-provenance.js";
33
+ import { clearPersistedConversationState, hydrateConversationState, hydrateTurnProvenance, persistConversationState, } from "./protocol/conversation-state.js";
30
34
  import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
31
35
  import { resolveContinuationPolicy, sessionManager, } from "./session.js";
32
- import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
36
+ import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isRejectedCredentialError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
33
37
  import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
34
- import { getOrBuildRequestContext } from "./context/frozen.js";
35
- import { getHeldOverlaySkills } from "./context/overlay.js";
38
+ import { getFrozenRequestContext, getOrBuildRequestContext } from "./context/frozen.js";
39
+ import { systemInstructionsRuleText } from "./context/build.js";
36
40
  import { loadMergedConfig } from "./context/rules.js";
37
- import { buildDynamicCatalogRoutingInstruction, takeSkillCatalogChangeReminder, } from "./context/dynamic-catalog.js";
41
+ import { buildDynamicCatalogRoutingInstruction, } from "./context/dynamic-catalog.js";
38
42
  import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
39
43
  import { workspaceRootFromRequestContext } from "./context/env.js";
40
44
  import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
@@ -43,7 +47,7 @@ import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HISTORY_REWRITE_OPTIO
43
47
  import { isCompactionSession } from "./compaction-marker.js";
44
48
  import { resolveSessionWorkspaceRoot } from "./session-directory.js";
45
49
  import { assertCursorUserImageSupport, extractCursorPromptImages } from "./image-input.js";
46
- import { resolveCursorModelSupportsImages } from "./model-metadata.js";
50
+ import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages } from "./model-metadata.js";
47
51
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
48
52
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
49
53
  import { readAllFieldsStrict } from "./protocol/struct.js";
@@ -112,6 +116,33 @@ function responseRequiredChannel(payload) {
112
116
  const tag = payload[0];
113
117
  return tag !== undefined ? RESPONSE_REQUIRED_CHANNEL_BY_FIELD.get(tag >> 3) : undefined;
114
118
  }
119
+ // AgentServerMessage fields that never carry output or stateful activity.
120
+ const ASM_INTERACTION_UPDATE_FIELD = 1;
121
+ const ASM_CHECKPOINT_UPDATE_FIELD = 3;
122
+ const ASM_KV_SERVER_MESSAGE_FIELD = 4;
123
+ const INTERACTION_UPDATE_HEARTBEAT_FIELD = 13;
124
+ /**
125
+ * True when the raw frame holds exactly one control message: a KV request, a
126
+ * checkpoint update, or an interaction update that is only a heartbeat. Extra
127
+ * fields inside a KV request are tolerated (Cursor has sent them live); extra
128
+ * top-level fields are not.
129
+ */
130
+ export function isSoleControlFrame(payload) {
131
+ const fields = readAllFieldsStrict(payload);
132
+ if (!fields || fields.length !== 1)
133
+ return false;
134
+ const [field] = fields;
135
+ if (field.wt !== 2)
136
+ return false;
137
+ if (field.fn === ASM_KV_SERVER_MESSAGE_FIELD || field.fn === ASM_CHECKPOINT_UPDATE_FIELD)
138
+ return true;
139
+ if (field.fn !== ASM_INTERACTION_UPDATE_FIELD || !field.bytes)
140
+ return false;
141
+ const update = readAllFieldsStrict(field.bytes);
142
+ return update?.length === 1
143
+ && update[0].fn === INTERACTION_UPDATE_HEARTBEAT_FIELD
144
+ && update[0].wt === 2;
145
+ }
115
146
  function retryInteger(name, value, fallback) {
116
147
  const resolved = value === undefined ? fallback : value;
117
148
  if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved <= 0) {
@@ -243,7 +274,10 @@ export function connectFrameError(payload) {
243
274
  const envelope = JSON.parse(payload);
244
275
  const code = typeof envelope.error?.code === "string" ? envelope.error.code : "unknown";
245
276
  if (code === "unauthenticated" || code === "permission_denied") {
246
- return new CursorAuthError(`Cursor authentication failed (${code}); reauthenticate with Cursor`, { code });
277
+ return new CursorAuthError(`Cursor authentication failed (${code}); reauthenticate with Cursor`, {
278
+ code,
279
+ replaySafe: code === "unauthenticated",
280
+ });
247
281
  }
248
282
  let retryAfterMs = retryDelayFromValue(envelope.error?.retryAfter ?? envelope.error?.retry_after);
249
283
  let hasRetryInfo = false;
@@ -485,24 +519,42 @@ export function createCursorLanguageModel(modelId, providerId, options) {
485
519
  },
486
520
  };
487
521
  }
488
- async function doStreamImpl(modelId, options, callOptions) {
489
- // A raw `crsr_...` API key must be exchanged for a JWT before it can be used
490
- // as a Bearer token (the plugin path does this in auth.ts). The accessToken
491
- // path is already a JWT from OAuth/key-exchange, so we use it as-is.
492
- // resolveBearerToken caches apiKey exchanges so we don't hit /auth/exchange
493
- // on every turn.
494
- const { resolveBearerToken } = await import("./auth.js");
495
- const token = await resolveBearerToken({
522
+ /** Bearer token for a new Run from whichever single credential source `options` carries. */
523
+ function resolveRunBearerToken(options, forceRefresh = false) {
524
+ return resolveBearerToken({
525
+ getAccessToken: options.getAccessToken,
496
526
  accessToken: options.accessToken,
497
527
  apiKey: options.apiKey,
498
528
  baseUrl: resolveApiBaseURL(options),
529
+ forceRefresh,
499
530
  });
531
+ }
532
+ async function doStreamImpl(modelId, options, callOptions) {
500
533
  const prompt = callOptions.prompt;
501
534
  const retryPolicy = resolveRetryPolicy(options.retry);
535
+ // The Bearer token is resolved for every Run this call opens, including
536
+ // recovery Runs after a long-held tool: a host `getAccessToken` (or the
537
+ // apiKey exchange) renews it, so a Run never starts on a token that expired
538
+ // while the turn was in progress. A held Run being continued keeps the
539
+ // token it was opened with. After Cursor rejects the credential, the next
540
+ // open asks the source for a forced renewal.
541
+ let forceCredentialRefresh = false;
542
+ const credentialRenewable = Boolean(options.getAccessToken)
543
+ || (!options.accessToken && options.apiKey !== undefined && isExchangeableApiKey(options.apiKey));
544
+ let prefetchedToken;
502
545
  // pumpWithRecovery owns the complete per-turn attempt budget. Opening a
503
546
  // replacement session here must be a single attempt; otherwise setup retry
504
547
  // loops nest inside recovery and `maxAttempts` no longer caps total Runs.
505
- const openSession = (startOptions) => startSession(modelId, token, callOptions, options, startOptions);
548
+ const openSession = async (startOptions) => {
549
+ const forceRefresh = forceCredentialRefresh;
550
+ forceCredentialRefresh = false;
551
+ const prefetched = prefetchedToken;
552
+ prefetchedToken = undefined;
553
+ const token = prefetched !== undefined && !forceRefresh
554
+ ? prefetched
555
+ : await resolveRunBearerToken(options, forceRefresh);
556
+ return startSession(modelId, token, callOptions, options, startOptions);
557
+ };
506
558
  // ── Continuation vs fresh turn ──
507
559
  // OpenCode embeds *all* historical tool results in every prompt. Only the
508
560
  // trailing tool-message suffix (after the last assistant/user message) is a
@@ -510,18 +562,25 @@ async function doStreamImpl(modelId, options, callOptions) {
510
562
  // false "orphaned tool results" errors after Cursor turn_ended and OpenCode
511
563
  // started the next step with old tools still in the prompt body.
512
564
  const trailingToolResults = extractTrailingToolResults(prompt);
565
+ let session = findContinuationSession(trailingToolResults);
566
+ // A call that must open a Run gets its credential before it changes any
567
+ // session state (plan mode, finishing the prior held Run), so a login that
568
+ // cannot be renewed fails the turn without side effects.
569
+ if (!session)
570
+ prefetchedToken = await resolveRunBearerToken(options);
513
571
  // A host may submit an already rendered plan through its canonical stage
514
572
  // tool after the Cursor Run has ended. The next call carries that tool result
515
573
  // without a Cursor exec id; reconcile the approved mode before a fresh Run.
516
574
  if (hasApprovedUncorrelatedPlanStageResult(prompt)) {
517
575
  setActiveCursorMode(opencodeSessionKey(callOptions), "agent");
518
576
  }
519
- let session = findContinuationSession(trailingToolResults);
520
577
  if (session) {
521
578
  // Write pending results onto the held-open Run. A dead stream closes the
522
579
  // session and returns undefined so we fall through to history rebase
523
580
  // instead of pumping a connection that can no longer accept writes.
524
581
  session = deliverContinuationResults(session, trailingToolResults);
582
+ if (session)
583
+ await refreshHeldSessionToolCatalog(session, callOptions);
525
584
  }
526
585
  if (!session) {
527
586
  const sessionKey = opencodeSessionKey(callOptions);
@@ -589,6 +648,9 @@ async function doStreamImpl(modelId, options, callOptions) {
589
648
  trace(`pull: stream-start enqueue failed (cancelled) err=${e.message}`);
590
649
  return;
591
650
  }
651
+ if (activeSession.openCodeSessionId) {
652
+ beginEmittedStep(activeSession.openCodeSessionId, activeSession.conversationId);
653
+ }
592
654
  activeSession = await pumpWithRecovery({
593
655
  initialSession: activeSession,
594
656
  controller,
@@ -596,6 +658,9 @@ async function doStreamImpl(modelId, options, callOptions) {
596
658
  retryPolicy,
597
659
  recover: (recovery) => openSession({ recovery }),
598
660
  onSession: (next) => { activeSession = next; },
661
+ ...(credentialRenewable
662
+ ? { renewRejectedCredential: () => { forceCredentialRefresh = true; } }
663
+ : {}),
599
664
  });
600
665
  try {
601
666
  controller.close();
@@ -655,7 +720,29 @@ export async function pumpWithRecovery(input) {
655
720
  maxAttempts: (input.maxRecoveries ?? 1) + 1,
656
721
  };
657
722
  const maxRecoveries = retryPolicy.maxAttempts - 1;
723
+ let credentialRenewed = false;
658
724
  input.onSession?.(session);
725
+ const reopen = async (pumpedSession, failure) => {
726
+ const checkpoint = pumpedSession.resumeCheckpoint;
727
+ const recovery = failure.checkpointUnusable
728
+ ? { kind: "rebase", reason: "checkpoint-unusable" }
729
+ : checkpoint
730
+ ? {
731
+ kind: "resume",
732
+ conversationId: pumpedSession.conversationId,
733
+ checkpoint: Uint8Array.from(checkpoint),
734
+ }
735
+ : { kind: "rebase" };
736
+ const next = await input.recover(recovery);
737
+ if (recovery.kind === "resume") {
738
+ next.usageEstimate = { ...pumpedSession.usageEstimate };
739
+ next.editToolCalls = new Map(pumpedSession.editToolCalls);
740
+ // mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
741
+ // session, seeded in startSession) — no handoff needed here.
742
+ }
743
+ input.onSession?.(next);
744
+ return next;
745
+ };
659
746
  for (let attempt = 0;; attempt++) {
660
747
  const pumpedSession = session;
661
748
  const pumpOwner = Symbol("cursor-pump");
@@ -672,6 +759,19 @@ export async function pumpWithRecovery(input) {
672
759
  replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
673
760
  fallback: "Cursor Run interrupted",
674
761
  });
762
+ if (input.renewRejectedCredential
763
+ && !credentialRenewed
764
+ && isRejectedCredentialError(failure)
765
+ && failure.replaySafe) {
766
+ credentialRenewed = true;
767
+ trace(`Run credential rejected: sessionId=${pumpedSession.sessionId} err=${failure.message} ` +
768
+ `— renewing the token and reopening once`);
769
+ sessionManager.close(pumpedSession, "remote-error", failure);
770
+ input.renewRejectedCredential();
771
+ session = await reopen(pumpedSession, failure);
772
+ attempt--;
773
+ continue;
774
+ }
675
775
  if (!failure.transient)
676
776
  throw failure;
677
777
  const checkpoint = pumpedSession.resumeCheckpoint;
@@ -687,21 +787,7 @@ export async function pumpWithRecovery(input) {
687
787
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
688
788
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
689
789
  await sleepForRetry(delayMs, input.abortSignal);
690
- const recovery = checkpoint
691
- ? {
692
- kind: "resume",
693
- conversationId: pumpedSession.conversationId,
694
- checkpoint: Uint8Array.from(checkpoint),
695
- }
696
- : { kind: "rebase" };
697
- session = await input.recover(recovery);
698
- if (recovery.kind === "resume") {
699
- session.usageEstimate = { ...pumpedSession.usageEstimate };
700
- session.editToolCalls = new Map(pumpedSession.editToolCalls);
701
- // mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
702
- // session, seeded in startSession) — no handoff needed here.
703
- }
704
- input.onSession?.(session);
790
+ session = await reopen(pumpedSession, failure);
705
791
  }
706
792
  finally {
707
793
  sessionManager.endPump(pumpedSession, pumpOwner);
@@ -788,6 +874,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
788
874
  ...(restored.systemPromptHash ? { systemPromptHash: restored.systemPromptHash } : {}),
789
875
  });
790
876
  }
877
+ // Provenance has its own LRU; refill it if only that entry was evicted.
878
+ await hydrateTurnProvenance(cacheDir, sessionKey).catch((error) => {
879
+ trace(`turn provenance: restore failed sessionKey=${sessionKey}: ${String(error)}`);
880
+ });
791
881
  }
792
882
  const providerOptions = callOptions.providerOptions?.cursor;
793
883
  const hostAgent = typeof providerOptions?.[CURSOR_HOST_AGENT_OPTION] === "string"
@@ -838,10 +928,11 @@ async function startSession(modelId, token, callOptions, options, startOptions)
838
928
  workspaceRoot: options.workspaceRoot,
839
929
  });
840
930
  const baseSystemPrompt = extractSystemPrompt(prompt);
841
- // One merged-config load per Run: guidance MCP ids and RequestContext
842
- // descriptors must agree, and warm turns must not pay for a second disk read.
843
- const mergedConfig = isCompaction ? undefined : await loadMergedConfig(workspaceRoot);
844
- const knownMcpServers = Object.keys(mergedConfig?.mcp ?? {});
931
+ // One merged-config load per Run: RequestContext names, session descriptors
932
+ // (exec remap, exec #36) and guidance MCP ids must agree on server identity,
933
+ // and warm turns must not pay for a second disk read.
934
+ const mergedConfig = await loadMergedConfig(workspaceRoot);
935
+ const knownMcpServers = Object.keys(mergedConfig.mcp ?? {});
845
936
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot, {
846
937
  knownMcpServers,
847
938
  });
@@ -853,15 +944,30 @@ async function startSession(modelId, token, callOptions, options, startOptions)
853
944
  isCompaction,
854
945
  historyRewrite: providerOptions?.[CURSOR_HISTORY_REWRITE_OPTION] === true,
855
946
  });
947
+ // Cursor could not restore the stored checkpoint (missing blobs) before this
948
+ // turn produced anything: reseed from the full host history as a new turn.
949
+ const checkpointUnusable = recovery?.kind === "rebase" && recovery.reason === "checkpoint-unusable";
950
+ // Another model (other provider or a local model) answered since this
951
+ // conversation's last checkpoint: resuming it would hide that work from Cursor.
952
+ // A Cursor-to-Cursor model switch resumes the same conversation, as in the CLI.
953
+ const foreignHistory = sessionKey && !resuming && !ephemeralRun && !resetState.reset && recovery?.kind !== "rebase"
954
+ ? detectForeignHistory({
955
+ sessionKey,
956
+ conversationId: peekConversationId(sessionKey),
957
+ prompt,
958
+ })
959
+ : undefined;
856
960
  // Compaction must not reuse the prior conversation; its first normal turn
857
961
  // must also rebase so the summary-agent checkpoint cannot replace the normal
858
962
  // system prompt and OpenCode's newly compacted history.
859
963
  let bound = resuming
860
964
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
861
965
  : bindConversationId(sessionKey, {
862
- reset: resetState.reset || recovery?.kind === "rebase",
966
+ reset: resetState.reset || recovery?.kind === "rebase" || !!foreignHistory,
863
967
  ephemeral: ephemeralRun,
864
968
  });
969
+ if (sessionKey && !ephemeralRun)
970
+ trackTurnProvenance(sessionKey, bound.conversationId);
865
971
  let conversationState = ephemeralRun
866
972
  ? undefined
867
973
  : resuming
@@ -870,7 +976,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
870
976
  let checkpointGraph = conversationState
871
977
  ? inspectConversationBlobGraph(bound.conversationId, conversationState)
872
978
  : { count: 0, bytes: 0, complete: true };
873
- const forcedResetReason = undefined;
979
+ const forcedResetReason = foreignHistory
980
+ ? `foreign-history:${foreignHistory}`
981
+ : checkpointUnusable ? "checkpoint-unusable" : undefined;
874
982
  // CLI soft-reuses incomplete / oversized graphs (100 MiB is export-only).
875
983
  // Never remint — warn and keep the sticky conversation + checkpoint.
876
984
  if (conversationState) {
@@ -896,7 +1004,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
896
1004
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
897
1005
  }
898
1006
  const lastUser = [...prompt].reverse().find((message) => message.role === "user");
899
- let userText = recovery?.kind === "rebase"
1007
+ let userText = recovery?.kind === "rebase" && !checkpointUnusable
900
1008
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
901
1009
  : (extractUserText(lastUser) || ".");
902
1010
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
@@ -922,7 +1030,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
922
1030
  modeReminder,
923
1031
  kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
924
1032
  ].filter((part) => !!part);
1033
+ // `systemPrompt` is the host system context composed for a seed Run (kept for
1034
+ // diagnostics and size estimates). It reaches Cursor only as the frozen
1035
+ // system-instructions rule in RequestContext, never as a seeded `system`
1036
+ // message, which Cursor does not follow.
925
1037
  let systemPrompt;
1038
+ let systemInstructions;
926
1039
  if (isCompaction || lifecycle) {
927
1040
  // Ephemeral summary/title Runs — do not initialize a sticky Context Epoch.
928
1041
  systemPrompt = startedWithCheckpoint
@@ -931,6 +1044,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
931
1044
  if (startedWithCheckpoint && oneShotReminders.length) {
932
1045
  userText = appendMidConversationMessage(userText, oneShotReminders.join("\n\n"));
933
1046
  }
1047
+ const ephemeralText = systemPrompt
1048
+ ?? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n");
1049
+ if (ephemeralText)
1050
+ systemInstructions = { text: ephemeralText, authoritative: true };
934
1051
  }
935
1052
  else {
936
1053
  const admitted = admitContextEpoch({
@@ -941,9 +1058,19 @@ async function startSession(modelId, token, callOptions, options, startOptions)
941
1058
  hostAgent,
942
1059
  workspaceRoot,
943
1060
  oneShotReminders,
1061
+ recoveredBaseline: systemInstructionsRuleText(getFrozenRequestContext(conversationId) ?? {}),
944
1062
  });
945
1063
  systemPrompt = startedWithCheckpoint ? undefined : admitted.seedSystemPrompt;
946
1064
  userText = appendMidConversationMessage(userText, admitted.midConversationMessage);
1065
+ // The epoch baseline is the rule for every Run of this conversation. A
1066
+ // recovered epoch keeps the persisted rule, and uses live context only if
1067
+ // a legacy checkpoint was persisted without baseline bytes.
1068
+ const frozenBaseline = admitted.epoch.baselineSystemPrompt;
1069
+ const instructionText = frozenBaseline
1070
+ || [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n");
1071
+ if (instructionText) {
1072
+ systemInstructions = { text: instructionText, authoritative: !!frozenBaseline };
1073
+ }
947
1074
  // A recovered epoch has no baseline bytes. Keep the hash restored from
948
1075
  // the checkpoint snapshot so this turn's TurnEnded save does not drop it.
949
1076
  const previousIdentity = sessionKey ? promptIdentityBySession.get(sessionKey) : undefined;
@@ -956,8 +1083,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
956
1083
  }
957
1084
  }
958
1085
  const history = extractPromptHistory(prompt, {
959
- preserveTrailingUser: recovery?.kind === "rebase",
960
- toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
1086
+ preserveTrailingUser: recovery?.kind === "rebase" && !checkpointUnusable,
1087
+ // A foreign-history rebase replays every tool result: the other model's work
1088
+ // exists only in OpenCode history, never in a Cursor checkpoint.
1089
+ toolResults: isCompaction || foreignHistory || checkpointUnusable ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
961
1090
  });
962
1091
  await loadAvailableModels();
963
1092
  // Resolve the region-specific Run stream origin once per process (memoized
@@ -987,9 +1116,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
987
1116
  : await extractCursorPromptImages(prompt, lastUser, {
988
1117
  supportsImages,
989
1118
  // Content hashes are retained for the OpenCode session so growing
990
- // history does not re-upload old screenshots. A recovery rebase opens
991
- // a new Cursor conversation, so it must resend the same payload.
992
- seenHistoryHashes: recovery?.kind === "rebase"
1119
+ // history does not re-upload old screenshots. A recovery or
1120
+ // foreign-history rebase opens a new Cursor conversation, so it must
1121
+ // resend the same payload.
1122
+ seenHistoryHashes: recovery?.kind === "rebase" || foreignHistory
993
1123
  ? undefined
994
1124
  : sentHistoryImageHashes(sessionKey),
995
1125
  signal: callOptions.abortSignal,
@@ -1013,6 +1143,16 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1013
1143
  picked,
1014
1144
  maxMode: hintMaxMode,
1015
1145
  });
1146
+ if (foreignHistory || checkpointUnusable) {
1147
+ assertForeignHistoryRebaseFits({
1148
+ modelInfo,
1149
+ cursorModelId,
1150
+ maxMode,
1151
+ history,
1152
+ systemPrompt,
1153
+ userText,
1154
+ });
1155
+ }
1016
1156
  // Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
1017
1157
  // that signal when a turn ends with tool-calls; the Cursor stream must stay
1018
1158
  // open until we write the exec results on the next doStream.
@@ -1022,20 +1162,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1022
1162
  });
1023
1163
  // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
1024
1164
  // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
1025
- const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig });
1026
- // Issue #29: after skills are in RequestContext, admit the catalog. OpenCode
1027
- // only Mid-Conversation-updates when the available-skills list changes
1028
- // (SkillGuidance / SkillInstructions) — never per-turn matched-id nudges.
1029
- // Gate on the host-permitted set, not the epoch-held advertisement.
1030
- if (!isCompaction && !lifecycle) {
1031
- const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1032
- const skillNudge = takeSkillCatalogChangeReminder(conversationId, {
1033
- hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
1034
- skills: getHeldOverlaySkills(conversationId),
1035
- skillArgKey: skillDialect.skillArgKey,
1036
- });
1037
- userText = appendMidConversationMessage(userText, skillNudge);
1038
- }
1165
+ const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig, systemInstructions });
1166
+ // Skills live in the host system prompt and `skill` tool. Do not scan disk or
1167
+ // emit RequestContext `agent_skills` Mid-Conversation XML; host `<system-update>`
1168
+ // is the catalog-change channel.
1039
1169
  const contextSubagents = Array.isArray(requestContext.custom_subagents)
1040
1170
  ? requestContext.custom_subagents
1041
1171
  .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
@@ -1053,11 +1183,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1053
1183
  agents: [...new Map([...discoveredSubagentCatalog.agents, ...contextSubagents]
1054
1184
  .map((agent) => [agent.name, agent])).values()],
1055
1185
  };
1056
- // Resolve descriptors once from the merged OpenCode config so MCP identity is
1057
- // consistent across AgentRunRequest and both request_context reply paths.
1058
- const toolDescriptors = Array.isArray(requestContext.tools)
1059
- ? requestContext.tools
1060
- : [];
1186
+ // Session exec remap / bridges need full McpToolDefinition identity. The wire
1187
+ // omits RequestContext.tools (#7); do not read descriptors from there.
1188
+ const toolDescriptors = toolsToDescriptors(cursorTools, "opencode", knownMcpServers);
1061
1189
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
1062
1190
  // After compaction or an unsafe checkpoint reset there is no checkpoint —
1063
1191
  // seed a new Cursor conversation from OpenCode's authoritative history.
@@ -1067,7 +1195,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1067
1195
  modelId: cursorModelId,
1068
1196
  conversationId,
1069
1197
  conversationGroupId,
1070
- systemPrompt: conversationState ? undefined : systemPrompt,
1071
1198
  history: conversationState ? undefined : history,
1072
1199
  conversationState,
1073
1200
  parameterValues,
@@ -1080,9 +1207,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1080
1207
  // Content hashes — Cursor content-addresses large payloads; logging these lets
1081
1208
  // us match a server get_blob_args.blob_id to what it wants served.
1082
1209
  const sha = (b) => createHash("sha256").update(b).digest("hex");
1083
- const skillsCount = Array.isArray(requestContext.agent_skills)
1084
- ? requestContext.agent_skills.length
1085
- : 0;
1086
1210
  const hooksCtx = typeof requestContext.hooks_additional_context === "string"
1087
1211
  ? requestContext.hooks_additional_context
1088
1212
  : "";
@@ -1115,7 +1239,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1115
1239
  `params=${JSON.stringify(parameterValues ?? [])} ` +
1116
1240
  `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} ` +
1117
1241
  `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
1118
- `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
1242
+ `hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
1119
1243
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
1120
1244
  `images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
1121
1245
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
@@ -1174,10 +1298,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1174
1298
  switchModeInTurn: false,
1175
1299
  },
1176
1300
  openCodeSessionId: ephemeralRun ? undefined : sessionKey,
1301
+ checkpointRebaseEligible: !ephemeralRun && !resuming && !recovery && !!conversationState,
1177
1302
  hostAgent,
1178
1303
  stableSystemPromptHash: frozenSystemPromptHash,
1179
1304
  postCompactionRebase: isCompaction,
1180
- toolCatalog: snapshotToolCatalog(sessionKey),
1305
+ toolCatalog: sessionKey ? snapshotToolCatalog(sessionKey) : structuredClone(tools),
1306
+ knownMcpServers,
1181
1307
  stream,
1182
1308
  frames: stream.frames()[Symbol.asyncIterator](),
1183
1309
  pending: new Map(),
@@ -1229,12 +1355,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1229
1355
  };
1230
1356
  session.reopenWithUserMessage = async (text) => {
1231
1357
  abortIfNeeded();
1232
- const { resolveBearerToken } = await import("./auth.js");
1233
- const freshToken = await resolveBearerToken({
1234
- accessToken: options.accessToken,
1235
- apiKey: options.apiKey,
1236
- baseUrl: resolveApiBaseURL(options),
1237
- });
1358
+ const freshToken = await resolveRunBearerToken(options);
1238
1359
  abortIfNeeded();
1239
1360
  // Do not pass OpenCode's abortSignal into the h2 stream: a tool-calls abort
1240
1361
  // must not tear down a Run we still need to pump. Check abort around open.
@@ -2105,6 +2226,23 @@ export async function pump(session, controller, ids, abortSignal) {
2105
2226
  switchModeInTurn: false,
2106
2227
  };
2107
2228
  cacheDiagnostics.pumpPasses++;
2229
+ // Only the first pass of a fresh Run resumed from a stored checkpoint, before
2230
+ // anything but control frames (KV, heartbeat, checkpoint) arrived, may be
2231
+ // reseeded after Cursor asked for blobs this client does not hold.
2232
+ const checkpointRebaseCandidate = session.checkpointRebaseEligible === true
2233
+ && cacheDiagnostics.pumpPasses === 1;
2234
+ let blobMiss = false;
2235
+ let onlyControlFrames = true;
2236
+ const finalizeFailure = (failure) => {
2237
+ if (checkpointRebaseCandidate && blobMiss && onlyControlFrames && failure.transient) {
2238
+ trace(`checkpoint unusable: Run failed after missing KV blobs before any output ` +
2239
+ `sessionId=${session.sessionId} conversationId=${session.conversationId} err=${failure.message}`);
2240
+ failure.replaySafe = true;
2241
+ failure.checkpointUnusable = true;
2242
+ return failure;
2243
+ }
2244
+ return replaySafety.applyTo(failure);
2245
+ };
2108
2246
  const { textId, reasoningId } = ids;
2109
2247
  const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
2110
2248
  const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
@@ -2150,6 +2288,9 @@ export async function pump(session, controller, ids, abortSignal) {
2150
2288
  return false;
2151
2289
  try {
2152
2290
  controller.enqueue(part);
2291
+ if (session.openCodeSessionId) {
2292
+ recordEmittedPart(session.openCodeSessionId, session.conversationId, part);
2293
+ }
2153
2294
  return true;
2154
2295
  }
2155
2296
  catch (e) {
@@ -2500,13 +2641,13 @@ export async function pump(session, controller, ids, abortSignal) {
2500
2641
  const failure = error instanceof CursorProviderError
2501
2642
  ? error
2502
2643
  : new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
2503
- throw replaySafety.applyTo(failure);
2644
+ throw finalizeFailure(failure);
2504
2645
  }
2505
2646
  if (next.done) {
2506
2647
  closeOpenSpans();
2507
2648
  trace("pump: frames iterator ended before turn_ended");
2508
2649
  const failure = new CursorRunInterruptedError();
2509
- throw replaySafety.applyTo(failure);
2650
+ throw finalizeFailure(failure);
2510
2651
  }
2511
2652
  const frame = next.value;
2512
2653
  if (frame.flags & 0x02) {
@@ -2526,7 +2667,7 @@ export async function pump(session, controller, ids, abortSignal) {
2526
2667
  const failure = payload
2527
2668
  ? connectFrameError(payload)
2528
2669
  : new CursorRunInterruptedError();
2529
- throw replaySafety.applyTo(failure);
2670
+ throw finalizeFailure(failure);
2530
2671
  }
2531
2672
  // decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
2532
2673
  // Skip the frame rather than abort the whole turn.
@@ -2536,6 +2677,7 @@ export async function pump(session, controller, ids, abortSignal) {
2536
2677
  }
2537
2678
  catch (e) {
2538
2679
  replaySafety.markBarrier("unknown-or-malformed-frame");
2680
+ onlyControlFrames = false;
2539
2681
  trace(`gunzip FAILED (skipping frame): flags=0x${frame.flags.toString(16)} len=${frame.payload.length} err=${e.message}`);
2540
2682
  continue;
2541
2683
  }
@@ -2548,6 +2690,7 @@ export async function pump(session, controller, ids, abortSignal) {
2548
2690
  // (protobufjs throws "index out of range: …" on length overruns). Log it
2549
2691
  // and keep pumping.
2550
2692
  replaySafety.markBarrier("unknown-or-malformed-frame");
2693
+ onlyControlFrames = false;
2551
2694
  const channel = responseRequiredChannel(payload);
2552
2695
  if (channel) {
2553
2696
  failRunProtocol(`Cursor ${channel} request could not be decoded`, RUN_REQUEST_DECODE_FAILED);
@@ -2583,6 +2726,11 @@ export async function pump(session, controller, ids, abortSignal) {
2583
2726
  }
2584
2727
  if (replayFrame.barrier)
2585
2728
  replaySafety.markBarrier(replayFrame.barrier);
2729
+ // Reseeding is allowed only while every frame so far was positively a
2730
+ // control frame. Anything else, including unknown top-level fields, may have
2731
+ // carried output or stateful activity.
2732
+ if (!isSoleControlFrame(payload))
2733
+ onlyControlFrames = false;
2586
2734
  {
2587
2735
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
2588
2736
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
@@ -2778,13 +2926,12 @@ export async function pump(session, controller, ids, abortSignal) {
2778
2926
  cacheDiagnostics.execRequests++;
2779
2927
  const esmId = esm.id ?? 0;
2780
2928
  if (esm.request_context_args) {
2781
- // Server turn-setup probe (#10). Reply with full OpenCode-sourced context.
2929
+ // Server turn-setup probe (#10). Reply with the same RequestContext the Run sent.
2782
2930
  {
2783
2931
  const rc = session.requestContext;
2784
- const skills = Array.isArray(rc.agent_skills) ? rc.agent_skills.length : 0;
2785
2932
  const hooks = typeof rc.hooks_additional_context === "string" ? rc.hooks_additional_context : "";
2786
2933
  trace(`exec request_context: id=${esmId} — replying context ` +
2787
- `tools=${session.toolDescriptors.length} skills=${skills} ` +
2934
+ `tools=${session.toolDescriptors.length} ` +
2788
2935
  `hooks=${hooks ? hooks.split("\n").length : 0}`);
2789
2936
  if (hooks)
2790
2937
  trace(`exec request_context hooks_additional_context: ${hooks}`);
@@ -2801,14 +2948,15 @@ export async function pump(session, controller, ids, abortSignal) {
2801
2948
  }
2802
2949
  else if (esm.mcp_state_exec_args) {
2803
2950
  // MCP-backed writes/reads can be preceded by this control-plane probe.
2804
- // Confirm the virtual servers from the already-advertised context, then
2805
- // keep pumping until Cursor emits the actual mcp_args tool request.
2951
+ // Confirm servers from the live host catalog (refreshed on each
2952
+ // doStream, including continuation), then keep pumping until Cursor
2953
+ // emits the actual mcp_args tool request.
2806
2954
  const stateArgs = esm.mcp_state_exec_args;
2807
2955
  const requested = Array.isArray(stateArgs.server_identifiers)
2808
2956
  ? stateArgs.server_identifiers.join(",")
2809
2957
  : "";
2810
2958
  try {
2811
- await writeWithBackpressure(session.stream, buildMcpStateResult(esmId, stateArgs, session.requestContext), `MCP-state reply id=${esmId}`);
2959
+ await writeWithBackpressure(session.stream, buildMcpStateResult(esmId, stateArgs, session.toolDescriptors), `MCP-state reply id=${esmId}`);
2812
2960
  trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
2813
2961
  }
2814
2962
  catch (error) {
@@ -3296,6 +3444,10 @@ export async function pump(session, controller, ids, abortSignal) {
3296
3444
  `setBlobIdLen=${kv.set_blob_args?.blob_id?.length ?? "-"} ` +
3297
3445
  `setDataLen=${kv.set_blob_args?.blob_data?.length ?? "-"}`);
3298
3446
  const handled = handleKvServerMessage(kv, session);
3447
+ // Content-as-id reads are answered by echoing the id back (`echoed`); only a
3448
+ // hash we cannot serve means the checkpoint references state we lost.
3449
+ if (handled?.kind === "get" && !handled.found && !handled.echoed)
3450
+ blobMiss = true;
3299
3451
  if (handled) {
3300
3452
  try {
3301
3453
  await writeWithBackpressure(session.stream, handled.reply, `KV ${handled.kind}_blob reply id=${handled.id}`);
@@ -3376,23 +3528,73 @@ function extractToolResults(prompt) {
3376
3528
  * messages after the last non-tool message. Mid-prompt historical tool results
3377
3529
  * are ignored — they are conversation history, not replies for a held-open Run.
3378
3530
  */
3531
+ // OpenCode 2.x appends these host notes after the tool results of a step:
3532
+ // mid-turn system updates (skill / MCP availability changes) are lowered to a
3533
+ // user message wrapping `<system-update>`, and tool-result media is re-sent as
3534
+ // a user message starting with this caption.
3535
+ const SYSTEM_UPDATE_OPEN = "<system-update>";
3536
+ const SYSTEM_UPDATE_CLOSE = "</system-update>";
3537
+ const TOOL_MEDIA_CAPTION = "Attached media from tool result:";
3538
+ function hostTailNote(message) {
3539
+ if (message.role === "system")
3540
+ return { text: message.content };
3541
+ if (message.role !== "user" || !Array.isArray(message.content) || message.content.length === 0)
3542
+ return undefined;
3543
+ const [first] = message.content;
3544
+ if (first?.type === "text" && first.text === TOOL_MEDIA_CAPTION)
3545
+ return {};
3546
+ const texts = [];
3547
+ for (const part of message.content) {
3548
+ if (part.type !== "text")
3549
+ return undefined;
3550
+ const text = part.text.trim();
3551
+ if (!text.startsWith(SYSTEM_UPDATE_OPEN) || !text.endsWith(SYSTEM_UPDATE_CLOSE))
3552
+ return undefined;
3553
+ texts.push(text);
3554
+ }
3555
+ return { text: texts.join("\n") };
3556
+ }
3557
+ /**
3558
+ * Split off host notes that trail the live tool results. They are not a new
3559
+ * user turn: the held Run must still receive its tool results.
3560
+ */
3561
+ function liveTail(prompt) {
3562
+ let end = prompt.length;
3563
+ const notes = [];
3564
+ while (end > 0) {
3565
+ const note = hostTailNote(prompt[end - 1]);
3566
+ if (!note)
3567
+ break;
3568
+ if (note.text)
3569
+ notes.unshift(note.text);
3570
+ end--;
3571
+ }
3572
+ return { end, notes };
3573
+ }
3379
3574
  export function extractTrailingToolResults(prompt) {
3380
- if (prompt.length === 0)
3381
- return [];
3382
- let i = prompt.length - 1;
3575
+ const { end, notes } = liveTail(prompt);
3576
+ let i = end - 1;
3383
3577
  while (i >= 0 && prompt[i].role === "tool")
3384
3578
  i--;
3385
- // Continuations end with tool messages. Anything else (user/assistant/system)
3579
+ // Continuations end with tool messages. Anything else (user/assistant)
3386
3580
  // means this is a fresh model call that merely carries tools in history.
3387
- if (i === prompt.length - 1)
3581
+ if (i === end - 1)
3388
3582
  return [];
3389
- return extractToolResults(prompt.slice(i + 1));
3583
+ const results = extractToolResults(prompt.slice(i + 1, end));
3584
+ // A Run continuation only carries exec results, so the host notes ride on the
3585
+ // last one; otherwise Cursor would never see e.g. a removed skill.
3586
+ const last = results.at(-1);
3587
+ if (last && notes.length > 0) {
3588
+ results[results.length - 1] = { ...last, output: [last.output, ...notes].filter(Boolean).join("\n\n") };
3589
+ }
3590
+ return results;
3390
3591
  }
3391
3592
  /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
3392
3593
  export function hasApprovedUncorrelatedPlanStageResult(prompt) {
3393
- if (prompt.length === 0 || prompt[prompt.length - 1].role !== "tool")
3594
+ const { end } = liveTail(prompt);
3595
+ if (end === 0 || prompt[end - 1].role !== "tool")
3394
3596
  return false;
3395
- for (let i = prompt.length - 1; i >= 0 && prompt[i].role === "tool"; i--) {
3597
+ for (let i = end - 1; i >= 0 && prompt[i].role === "tool"; i--) {
3396
3598
  const message = prompt[i];
3397
3599
  if (!Array.isArray(message.content))
3398
3600
  continue;
@@ -3443,9 +3645,8 @@ function extractSystemPrompt(prompt) {
3443
3645
  return parts.length > 0 ? parts.join("\n\n") : undefined;
3444
3646
  }
3445
3647
  /**
3446
- * Checkpointed Runs do not resend the system prompt. Keep the workspace root
3447
- * on the live user message, and require absolute `path` arguments when that is
3448
- * the host's file-tool dialect.
3648
+ * Keep the workspace root on a checkpointed turn's live user message too, and
3649
+ * require absolute `path` arguments when that is the host's file-tool dialect.
3449
3650
  */
3450
3651
  export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, tools) {
3451
3652
  if (!checkpoint)
@@ -3692,10 +3893,45 @@ function appendSeedHistory(out, role, content) {
3692
3893
  }
3693
3894
  out.push({ role, content });
3694
3895
  }
3896
+ /** Share of the target context a foreign-history rebase may fill before compaction. */
3897
+ export const FOREIGN_HISTORY_REBASE_CONTEXT_SHARE = 0.8;
3898
+ /**
3899
+ * A foreign-history rebase replays the full host history. When that cannot fit,
3900
+ * fail before opening a Run with an error hosts classify as context overflow
3901
+ * (HTTP 413 + "prompt is too long"), so the host compacts and retries.
3902
+ */
3903
+ export function assertForeignHistoryRebaseFits(input) {
3904
+ const documented = getDocumentedCursorModelContext(input.cursorModelId);
3905
+ const limit = input.maxMode
3906
+ ? (input.modelInfo?.maxContextForMaxMode ?? documented?.maxContextForMaxMode ?? 1_000_000)
3907
+ : (input.modelInfo?.maxContext ?? documented?.maxContext ?? 200_000);
3908
+ const chars = input.history.reduce((sum, message) => sum + message.content.length, 0)
3909
+ + (input.systemPrompt?.length ?? 0)
3910
+ + input.userText.length;
3911
+ const tokens = estimateTokens(chars);
3912
+ const budget = Math.floor(limit * FOREIGN_HISTORY_REBASE_CONTEXT_SHARE);
3913
+ if (tokens <= budget)
3914
+ return;
3915
+ trace(`foreign-history rebase too large: model=${input.cursorModelId} estimatedTokens=${tokens} ` +
3916
+ `budget=${budget} limit=${limit} → requesting host compaction`);
3917
+ throw new APICallError({
3918
+ message: `prompt is too long: rebasing this session onto Cursor needs ~${tokens} tokens, ` +
3919
+ `over ${budget} of the ${limit}-token context`,
3920
+ url: "cursor://agent.v1.AgentService/Run",
3921
+ requestBodyValues: {},
3922
+ statusCode: 413,
3923
+ isRetryable: false,
3924
+ });
3925
+ }
3695
3926
  /** OpenCode session id header, if present. */
3696
3927
  export function opencodeSessionKey(callOptions) {
3697
3928
  const h = callOptions.headers ?? {};
3698
- const raw = h["x-session-id"] ??
3929
+ // OpenCode 2.x sends x-session-id / x-session-affinity / x-opencode-session
3930
+ // as the parent (or fork source) session so subagents share prompt-cache
3931
+ // affinity. Only x-opencode-session-id names the requesting session; keying
3932
+ // on the others makes a subagent take over its parent's Cursor conversation.
3933
+ const raw = h["x-opencode-session-id"] ??
3934
+ h["x-session-id"] ??
3699
3935
  h["X-Session-Id"] ??
3700
3936
  h["x-session-affinity"] ??
3701
3937
  h["x-opencode-session"];
@@ -3712,6 +3948,63 @@ export function resolveConversationId(callOptions) {
3712
3948
  return bindConversationId(opencodeSessionKey(callOptions)).conversationId;
3713
3949
  }
3714
3950
  export { sessionIdToUuid } from "./protocol/conversation-bind.js";
3951
+ /**
3952
+ * Grow the held Run's advertised + permitted catalog from this `doStream`
3953
+ * call. Continuation skips `startSession`, so without this, exec #36 and
3954
+ * permission keep the freeze from Run open. MCP server ids stay those of the
3955
+ * Run's one merged-config load (`startSession`); a tool that connects later
3956
+ * still gets its identity from that set.
3957
+ */
3958
+ export async function refreshHeldSessionToolCatalog(session, callOptions) {
3959
+ const sessionKey = session.openCodeSessionId;
3960
+ const incomingTools = extractTools(callOptions);
3961
+ const providerOptions = callOptions.providerOptions?.cursor;
3962
+ const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
3963
+ const isCompaction = compactionOption === true || (compactionOption === undefined && !!sessionKey && isCompactionSession(sessionKey));
3964
+ const toolState = await resolveTurnToolState({
3965
+ sessionKey,
3966
+ incomingTools,
3967
+ toolChoice: callOptions.toolChoice,
3968
+ isCompaction,
3969
+ abortSignal: callOptions.abortSignal,
3970
+ });
3971
+ // A standalone caller has no host session key for the process catalog cache.
3972
+ // Its held Run still owns an epoch: keep its prefix and append new names.
3973
+ const cachedTools = !sessionKey ? session.toolCatalog ?? [] : [];
3974
+ const cachedNames = new Set(cachedTools.map(tool => tool.name));
3975
+ const advertisedTools = cachedTools.length > 0
3976
+ ? [...cachedTools, ...toolState.advertisedTools.filter(tool => !cachedNames.has(tool.name))]
3977
+ : toolState.advertisedTools;
3978
+ const webToolAliases = buildCustomWebToolAliases(advertisedTools);
3979
+ const cursorTools = webToolAliases.advertisedTools;
3980
+ const knownMcpServers = session.knownMcpServers ?? [];
3981
+ const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
3982
+ const contextSubagents = Array.isArray(session.requestContext.custom_subagents)
3983
+ ? session.requestContext.custom_subagents
3984
+ .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
3985
+ ? {
3986
+ name: agent.name,
3987
+ description: typeof agent.description === "string"
3988
+ ? agent.description
3989
+ : undefined,
3990
+ }
3991
+ : undefined)
3992
+ .filter((agent) => !!agent)
3993
+ : [];
3994
+ session.toolCatalog = sessionKey ? snapshotToolCatalog(sessionKey) : structuredClone(advertisedTools);
3995
+ session.toolDescriptors = toolsToDescriptors(cursorTools, "opencode", knownMcpServers);
3996
+ session.toolAliases = webToolAliases.aliases;
3997
+ session.hostToolDialect = hostToolDialectFromTools(advertisedTools, session.hostToolDialect);
3998
+ session.subagentCatalog = {
3999
+ ...discoveredSubagentCatalog,
4000
+ agents: [...new Map([...discoveredSubagentCatalog.agents, ...contextSubagents]
4001
+ .map((agent) => [agent.name, agent])).values()],
4002
+ };
4003
+ session.allowTools = toolState.allowTools;
4004
+ session.permittedToolNames = new Set(toolState.allowTools
4005
+ ? incomingTools.map((tool) => tool.name).filter((name) => !!name)
4006
+ : []);
4007
+ }
3715
4008
  function extractTools(callOptions) {
3716
4009
  const tools = callOptions.tools;
3717
4010
  if (!tools || tools.length === 0) {