cursor-opencode-provider 0.7.5 → 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 +3 -28
  12. package/dist/context/dynamic-catalog.js +25 -130
  13. package/dist/context/epoch.d.ts +6 -4
  14. package/dist/context/epoch.js +22 -10
  15. package/dist/context/frozen.js +37 -9
  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 +59 -8
  32. package/dist/language-model.js +458 -118
  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, buildSkillCatalogNudge, } 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);
@@ -542,19 +601,34 @@ async function doStreamImpl(modelId, options, callOptions) {
542
601
  if (historical > 0) {
543
602
  trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
544
603
  }
545
- // Host may start a new user turn while the prior Run still has pending
546
- // tools. Finish that Run on the same conversation (cancel stranded execs,
547
- // drain turn_ended) before opening the new Run so the checkpoint prefix
548
- // is preserved. registerSession will not close a prior Run that still
549
- // has real pending execs; a failed prepare leaves that Run held.
550
- try {
551
- await preparePriorSessionForFreshTurn(sessionKey);
604
+ // An in-session helper (title/memory/task child) that reuses the parent
605
+ // OpenCode session id with a strictly smaller catalog must not cancel or
606
+ // supersede the held parent Run — that remints (`interrupted-run`) when
607
+ // the trailing tool result returns. Isolate onto an ephemeral conversation.
608
+ const busyPrior = sessionManager.findOpenByOpenCodeSessionId(sessionKey);
609
+ if (busyPrior
610
+ && !busyPrior.closed
611
+ && isProperCatalogSubset(extractTools(callOptions), busyPrior.toolCatalog ?? [])) {
612
+ trace(`fresh turn: isolating in-session helper — incomingTools subset of ` +
613
+ `parentTools=${busyPrior.toolCatalog?.length ?? 0} ` +
614
+ `priorSession=${busyPrior.sessionId} pending=${busyPrior.pending.size}`);
615
+ session = await openSession({ isolate: true });
552
616
  }
553
- catch (error) {
554
- trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
555
- `any still-pending prior held — ${error.message}`);
617
+ else {
618
+ // Host may start a new user turn while the prior Run still has pending
619
+ // tools. Finish that Run on the same conversation (cancel stranded execs,
620
+ // drain turn_ended) before opening the new Run so the checkpoint prefix
621
+ // is preserved. registerSession will not close a prior Run that still
622
+ // has real pending execs; a failed prepare leaves that Run held.
623
+ try {
624
+ await preparePriorSessionForFreshTurn(sessionKey);
625
+ }
626
+ catch (error) {
627
+ trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
628
+ `any still-pending prior held — ${error.message}`);
629
+ }
630
+ session = await openSession();
556
631
  }
557
- session = await openSession();
558
632
  }
559
633
  }
560
634
  let activeSession = session;
@@ -574,6 +648,9 @@ async function doStreamImpl(modelId, options, callOptions) {
574
648
  trace(`pull: stream-start enqueue failed (cancelled) err=${e.message}`);
575
649
  return;
576
650
  }
651
+ if (activeSession.openCodeSessionId) {
652
+ beginEmittedStep(activeSession.openCodeSessionId, activeSession.conversationId);
653
+ }
577
654
  activeSession = await pumpWithRecovery({
578
655
  initialSession: activeSession,
579
656
  controller,
@@ -581,6 +658,9 @@ async function doStreamImpl(modelId, options, callOptions) {
581
658
  retryPolicy,
582
659
  recover: (recovery) => openSession({ recovery }),
583
660
  onSession: (next) => { activeSession = next; },
661
+ ...(credentialRenewable
662
+ ? { renewRejectedCredential: () => { forceCredentialRefresh = true; } }
663
+ : {}),
584
664
  });
585
665
  try {
586
666
  controller.close();
@@ -640,7 +720,29 @@ export async function pumpWithRecovery(input) {
640
720
  maxAttempts: (input.maxRecoveries ?? 1) + 1,
641
721
  };
642
722
  const maxRecoveries = retryPolicy.maxAttempts - 1;
723
+ let credentialRenewed = false;
643
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
+ };
644
746
  for (let attempt = 0;; attempt++) {
645
747
  const pumpedSession = session;
646
748
  const pumpOwner = Symbol("cursor-pump");
@@ -657,6 +759,19 @@ export async function pumpWithRecovery(input) {
657
759
  replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
658
760
  fallback: "Cursor Run interrupted",
659
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
+ }
660
775
  if (!failure.transient)
661
776
  throw failure;
662
777
  const checkpoint = pumpedSession.resumeCheckpoint;
@@ -672,21 +787,7 @@ export async function pumpWithRecovery(input) {
672
787
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
673
788
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
674
789
  await sleepForRetry(delayMs, input.abortSignal);
675
- const recovery = checkpoint
676
- ? {
677
- kind: "resume",
678
- conversationId: pumpedSession.conversationId,
679
- checkpoint: Uint8Array.from(checkpoint),
680
- }
681
- : { kind: "rebase" };
682
- session = await input.recover(recovery);
683
- if (recovery.kind === "resume") {
684
- session.usageEstimate = { ...pumpedSession.usageEstimate };
685
- session.editToolCalls = new Map(pumpedSession.editToolCalls);
686
- // mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
687
- // session, seeded in startSession) — no handoff needed here.
688
- }
689
- input.onSession?.(session);
790
+ session = await reopen(pumpedSession, failure);
690
791
  }
691
792
  finally {
692
793
  sessionManager.endPump(pumpedSession, pumpOwner);
@@ -756,6 +857,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
756
857
  const prompt = callOptions.prompt;
757
858
  const incomingTools = extractTools(callOptions);
758
859
  const sessionKey = opencodeSessionKey(callOptions);
860
+ const isolateHelper = startOptions?.isolate === true;
759
861
  const cacheDir = opencodeGlobalCacheDir();
760
862
  if (sessionKey) {
761
863
  const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
@@ -772,6 +874,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
772
874
  ...(restored.systemPromptHash ? { systemPromptHash: restored.systemPromptHash } : {}),
773
875
  });
774
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
+ });
775
881
  }
776
882
  const providerOptions = callOptions.providerOptions?.cursor;
777
883
  const hostAgent = typeof providerOptions?.[CURSOR_HOST_AGENT_OPTION] === "string"
@@ -807,6 +913,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
807
913
  let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
808
914
  let resuming = !!resumeRecovery;
809
915
  const lifecycle = !allowTools && !isCompaction && !recovery;
916
+ // Isolated helpers must not share the parent's openCodeSessionId or sticky
917
+ // conversation: registerSession would otherwise supersede the held parent Run.
918
+ const ephemeralRun = lifecycle || isolateHelper;
810
919
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
811
920
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
812
921
  // projects, so its static option is only a last-resort fallback. Prefer the
@@ -819,10 +928,11 @@ async function startSession(modelId, token, callOptions, options, startOptions)
819
928
  workspaceRoot: options.workspaceRoot,
820
929
  });
821
930
  const baseSystemPrompt = extractSystemPrompt(prompt);
822
- // One merged-config load per Run: guidance MCP ids and RequestContext
823
- // descriptors must agree, and warm turns must not pay for a second disk read.
824
- const mergedConfig = isCompaction ? undefined : await loadMergedConfig(workspaceRoot);
825
- 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 ?? {});
826
936
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot, {
827
937
  knownMcpServers,
828
938
  });
@@ -834,16 +944,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
834
944
  isCompaction,
835
945
  historyRewrite: providerOptions?.[CURSOR_HISTORY_REWRITE_OPTION] === true,
836
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;
837
960
  // Compaction must not reuse the prior conversation; its first normal turn
838
961
  // must also rebase so the summary-agent checkpoint cannot replace the normal
839
962
  // system prompt and OpenCode's newly compacted history.
840
963
  let bound = resuming
841
964
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
842
965
  : bindConversationId(sessionKey, {
843
- reset: resetState.reset || recovery?.kind === "rebase",
844
- ephemeral: lifecycle,
966
+ reset: resetState.reset || recovery?.kind === "rebase" || !!foreignHistory,
967
+ ephemeral: ephemeralRun,
845
968
  });
846
- let conversationState = lifecycle
969
+ if (sessionKey && !ephemeralRun)
970
+ trackTurnProvenance(sessionKey, bound.conversationId);
971
+ let conversationState = ephemeralRun
847
972
  ? undefined
848
973
  : resuming
849
974
  ? resumeRecovery.checkpoint
@@ -851,7 +976,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
851
976
  let checkpointGraph = conversationState
852
977
  ? inspectConversationBlobGraph(bound.conversationId, conversationState)
853
978
  : { count: 0, bytes: 0, complete: true };
854
- const forcedResetReason = undefined;
979
+ const forcedResetReason = foreignHistory
980
+ ? `foreign-history:${foreignHistory}`
981
+ : checkpointUnusable ? "checkpoint-unusable" : undefined;
855
982
  // CLI soft-reuses incomplete / oversized graphs (100 MiB is export-only).
856
983
  // Never remint — warn and keep the sticky conversation + checkpoint.
857
984
  if (conversationState) {
@@ -877,12 +1004,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
877
1004
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
878
1005
  }
879
1006
  const lastUser = [...prompt].reverse().find((message) => message.role === "user");
880
- let userText = recovery?.kind === "rebase"
1007
+ let userText = recovery?.kind === "rebase" && !checkpointUnusable
881
1008
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
882
1009
  : (extractUserText(lastUser) || ".");
883
- // Skill matching must see the live user utterance only — not Mid-Conversation
884
- // MCP/system injections appended below (those false-trigger firecrawl dumps).
885
- const skillMatchText = userText;
886
1010
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
887
1011
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
888
1012
  const startedWithCheckpoint = !!conversationState;
@@ -906,7 +1030,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
906
1030
  modeReminder,
907
1031
  kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
908
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.
909
1037
  let systemPrompt;
1038
+ let systemInstructions;
910
1039
  if (isCompaction || lifecycle) {
911
1040
  // Ephemeral summary/title Runs — do not initialize a sticky Context Epoch.
912
1041
  systemPrompt = startedWithCheckpoint
@@ -915,6 +1044,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
915
1044
  if (startedWithCheckpoint && oneShotReminders.length) {
916
1045
  userText = appendMidConversationMessage(userText, oneShotReminders.join("\n\n"));
917
1046
  }
1047
+ const ephemeralText = systemPrompt
1048
+ ?? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n");
1049
+ if (ephemeralText)
1050
+ systemInstructions = { text: ephemeralText, authoritative: true };
918
1051
  }
919
1052
  else {
920
1053
  const admitted = admitContextEpoch({
@@ -925,9 +1058,19 @@ async function startSession(modelId, token, callOptions, options, startOptions)
925
1058
  hostAgent,
926
1059
  workspaceRoot,
927
1060
  oneShotReminders,
1061
+ recoveredBaseline: systemInstructionsRuleText(getFrozenRequestContext(conversationId) ?? {}),
928
1062
  });
929
1063
  systemPrompt = startedWithCheckpoint ? undefined : admitted.seedSystemPrompt;
930
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
+ }
931
1074
  // A recovered epoch has no baseline bytes. Keep the hash restored from
932
1075
  // the checkpoint snapshot so this turn's TurnEnded save does not drop it.
933
1076
  const previousIdentity = sessionKey ? promptIdentityBySession.get(sessionKey) : undefined;
@@ -940,8 +1083,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
940
1083
  }
941
1084
  }
942
1085
  const history = extractPromptHistory(prompt, {
943
- preserveTrailingUser: recovery?.kind === "rebase",
944
- 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"),
945
1090
  });
946
1091
  await loadAvailableModels();
947
1092
  // Resolve the region-specific Run stream origin once per process (memoized
@@ -971,9 +1116,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
971
1116
  : await extractCursorPromptImages(prompt, lastUser, {
972
1117
  supportsImages,
973
1118
  // Content hashes are retained for the OpenCode session so growing
974
- // history does not re-upload old screenshots. A recovery rebase opens
975
- // a new Cursor conversation, so it must resend the same payload.
976
- 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
977
1123
  ? undefined
978
1124
  : sentHistoryImageHashes(sessionKey),
979
1125
  signal: callOptions.abortSignal,
@@ -997,6 +1143,16 @@ async function startSession(modelId, token, callOptions, options, startOptions)
997
1143
  picked,
998
1144
  maxMode: hintMaxMode,
999
1145
  });
1146
+ if (foreignHistory || checkpointUnusable) {
1147
+ assertForeignHistoryRebaseFits({
1148
+ modelInfo,
1149
+ cursorModelId,
1150
+ maxMode,
1151
+ history,
1152
+ systemPrompt,
1153
+ userText,
1154
+ });
1155
+ }
1000
1156
  // Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
1001
1157
  // that signal when a turn ends with tool-calls; the Cursor stream must stay
1002
1158
  // open until we write the exec results on the next doStream.
@@ -1006,22 +1162,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1006
1162
  });
1007
1163
  // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
1008
1164
  // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
1009
- const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig });
1010
- // Issue #29: after skills are in RequestContext, nudge matched skill ids onto
1011
- // the Mid-Conversation channel so models CallDynamicTool(`skill`) instead of
1012
- // Grep/Read SKILL.md. Shared by OpenCode 1.x and 2.0 catalogs. Gate on the
1013
- // host-permitted set, not the epoch-held advertisement: a turn that withheld
1014
- // `skill` would refuse the call this nudge asks for.
1015
- if (!isCompaction && !lifecycle) {
1016
- const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1017
- const skillNudge = buildSkillCatalogNudge({
1018
- hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
1019
- skills: getHeldOverlaySkills(conversationId),
1020
- userText: skillMatchText,
1021
- skillArgKey: skillDialect.skillArgKey,
1022
- });
1023
- userText = appendMidConversationMessage(userText, skillNudge);
1024
- }
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.
1025
1169
  const contextSubagents = Array.isArray(requestContext.custom_subagents)
1026
1170
  ? requestContext.custom_subagents
1027
1171
  .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
@@ -1039,11 +1183,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1039
1183
  agents: [...new Map([...discoveredSubagentCatalog.agents, ...contextSubagents]
1040
1184
  .map((agent) => [agent.name, agent])).values()],
1041
1185
  };
1042
- // Resolve descriptors once from the merged OpenCode config so MCP identity is
1043
- // consistent across AgentRunRequest and both request_context reply paths.
1044
- const toolDescriptors = Array.isArray(requestContext.tools)
1045
- ? requestContext.tools
1046
- : [];
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);
1047
1189
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
1048
1190
  // After compaction or an unsafe checkpoint reset there is no checkpoint —
1049
1191
  // seed a new Cursor conversation from OpenCode's authoritative history.
@@ -1053,7 +1195,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1053
1195
  modelId: cursorModelId,
1054
1196
  conversationId,
1055
1197
  conversationGroupId,
1056
- systemPrompt: conversationState ? undefined : systemPrompt,
1057
1198
  history: conversationState ? undefined : history,
1058
1199
  conversationState,
1059
1200
  parameterValues,
@@ -1066,9 +1207,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1066
1207
  // Content hashes — Cursor content-addresses large payloads; logging these lets
1067
1208
  // us match a server get_blob_args.blob_id to what it wants served.
1068
1209
  const sha = (b) => createHash("sha256").update(b).digest("hex");
1069
- const skillsCount = Array.isArray(requestContext.agent_skills)
1070
- ? requestContext.agent_skills.length
1071
- : 0;
1072
1210
  const hooksCtx = typeof requestContext.hooks_additional_context === "string"
1073
1211
  ? requestContext.hooks_additional_context
1074
1212
  : "";
@@ -1101,7 +1239,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1101
1239
  `params=${JSON.stringify(parameterValues ?? [])} ` +
1102
1240
  `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} ` +
1103
1241
  `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
1104
- `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
1242
+ `hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
1105
1243
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
1106
1244
  `images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
1107
1245
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
@@ -1159,11 +1297,13 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1159
1297
  createPlanInTurn: false,
1160
1298
  switchModeInTurn: false,
1161
1299
  },
1162
- openCodeSessionId: lifecycle ? undefined : sessionKey,
1300
+ openCodeSessionId: ephemeralRun ? undefined : sessionKey,
1301
+ checkpointRebaseEligible: !ephemeralRun && !resuming && !recovery && !!conversationState,
1163
1302
  hostAgent,
1164
1303
  stableSystemPromptHash: frozenSystemPromptHash,
1165
1304
  postCompactionRebase: isCompaction,
1166
- toolCatalog: snapshotToolCatalog(sessionKey),
1305
+ toolCatalog: sessionKey ? snapshotToolCatalog(sessionKey) : structuredClone(tools),
1306
+ knownMcpServers,
1167
1307
  stream,
1168
1308
  frames: stream.frames()[Symbol.asyncIterator](),
1169
1309
  pending: new Map(),
@@ -1172,7 +1312,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1172
1312
  // Seed from the per-OpenCode-session copy: merges in this turn (and after
1173
1313
  // checkpoint resumes/rebases, which rebuild the session here) expand
1174
1314
  // against the last observed host list, not an empty one.
1175
- mirroredTodos: snapshotMirroredTodosBySession(lifecycle ? undefined : sessionKey),
1315
+ mirroredTodos: snapshotMirroredTodosBySession(ephemeralRun ? undefined : sessionKey),
1176
1316
  nextBridgedExecId: 900_000,
1177
1317
  blobs: new Map(),
1178
1318
  toolDescriptors,
@@ -1215,12 +1355,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1215
1355
  };
1216
1356
  session.reopenWithUserMessage = async (text) => {
1217
1357
  abortIfNeeded();
1218
- const { resolveBearerToken } = await import("./auth.js");
1219
- const freshToken = await resolveBearerToken({
1220
- accessToken: options.accessToken,
1221
- apiKey: options.apiKey,
1222
- baseUrl: resolveApiBaseURL(options),
1223
- });
1358
+ const freshToken = await resolveRunBearerToken(options);
1224
1359
  abortIfNeeded();
1225
1360
  // Do not pass OpenCode's abortSignal into the h2 stream: a tool-calls abort
1226
1361
  // must not tear down a Run we still need to pump. Check abort around open.
@@ -1276,6 +1411,39 @@ export function findContinuationSession(toolResults) {
1276
1411
  }
1277
1412
  return undefined;
1278
1413
  }
1414
+ /**
1415
+ * True when `incoming` is a non-empty proper subset of `parent` by tool name.
1416
+ * Used to spot in-session helpers that reuse the parent OpenCode session id
1417
+ * with a reduced catalog (e.g. stripping `task` / `question` / `plan_exit`).
1418
+ */
1419
+ export function isProperCatalogSubset(incoming, parent) {
1420
+ if (incoming.length === 0 || parent.length === 0)
1421
+ return false;
1422
+ if (incoming.length >= parent.length)
1423
+ return false;
1424
+ const parentNames = new Set(parent.map((tool) => tool.name).filter((name) => !!name));
1425
+ if (parentNames.size === 0)
1426
+ return false;
1427
+ let matched = 0;
1428
+ for (const tool of incoming) {
1429
+ const name = tool.name;
1430
+ if (!name || !parentNames.has(name))
1431
+ return false;
1432
+ matched++;
1433
+ }
1434
+ return matched > 0 && matched < parentNames.size;
1435
+ }
1436
+ /**
1437
+ * An open parent Run for this OpenCode session whose catalog strictly contains
1438
+ * the incoming tools is treated as an in-session helper. Those calls must not
1439
+ * cancel/supersede the parent (that remints on the trailing tool result).
1440
+ */
1441
+ export function shouldIsolateInSessionHelper(openCodeSessionId, incomingTools) {
1442
+ const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
1443
+ if (!prior || prior.closed)
1444
+ return false;
1445
+ return isProperCatalogSubset(incomingTools, prior.toolCatalog ?? []);
1446
+ }
1279
1447
  /** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
1280
1448
  export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
1281
1449
  /**
@@ -2058,6 +2226,23 @@ export async function pump(session, controller, ids, abortSignal) {
2058
2226
  switchModeInTurn: false,
2059
2227
  };
2060
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
+ };
2061
2246
  const { textId, reasoningId } = ids;
2062
2247
  const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
2063
2248
  const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
@@ -2103,6 +2288,9 @@ export async function pump(session, controller, ids, abortSignal) {
2103
2288
  return false;
2104
2289
  try {
2105
2290
  controller.enqueue(part);
2291
+ if (session.openCodeSessionId) {
2292
+ recordEmittedPart(session.openCodeSessionId, session.conversationId, part);
2293
+ }
2106
2294
  return true;
2107
2295
  }
2108
2296
  catch (e) {
@@ -2453,13 +2641,13 @@ export async function pump(session, controller, ids, abortSignal) {
2453
2641
  const failure = error instanceof CursorProviderError
2454
2642
  ? error
2455
2643
  : new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
2456
- throw replaySafety.applyTo(failure);
2644
+ throw finalizeFailure(failure);
2457
2645
  }
2458
2646
  if (next.done) {
2459
2647
  closeOpenSpans();
2460
2648
  trace("pump: frames iterator ended before turn_ended");
2461
2649
  const failure = new CursorRunInterruptedError();
2462
- throw replaySafety.applyTo(failure);
2650
+ throw finalizeFailure(failure);
2463
2651
  }
2464
2652
  const frame = next.value;
2465
2653
  if (frame.flags & 0x02) {
@@ -2479,7 +2667,7 @@ export async function pump(session, controller, ids, abortSignal) {
2479
2667
  const failure = payload
2480
2668
  ? connectFrameError(payload)
2481
2669
  : new CursorRunInterruptedError();
2482
- throw replaySafety.applyTo(failure);
2670
+ throw finalizeFailure(failure);
2483
2671
  }
2484
2672
  // decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
2485
2673
  // Skip the frame rather than abort the whole turn.
@@ -2489,6 +2677,7 @@ export async function pump(session, controller, ids, abortSignal) {
2489
2677
  }
2490
2678
  catch (e) {
2491
2679
  replaySafety.markBarrier("unknown-or-malformed-frame");
2680
+ onlyControlFrames = false;
2492
2681
  trace(`gunzip FAILED (skipping frame): flags=0x${frame.flags.toString(16)} len=${frame.payload.length} err=${e.message}`);
2493
2682
  continue;
2494
2683
  }
@@ -2501,6 +2690,7 @@ export async function pump(session, controller, ids, abortSignal) {
2501
2690
  // (protobufjs throws "index out of range: …" on length overruns). Log it
2502
2691
  // and keep pumping.
2503
2692
  replaySafety.markBarrier("unknown-or-malformed-frame");
2693
+ onlyControlFrames = false;
2504
2694
  const channel = responseRequiredChannel(payload);
2505
2695
  if (channel) {
2506
2696
  failRunProtocol(`Cursor ${channel} request could not be decoded`, RUN_REQUEST_DECODE_FAILED);
@@ -2536,6 +2726,11 @@ export async function pump(session, controller, ids, abortSignal) {
2536
2726
  }
2537
2727
  if (replayFrame.barrier)
2538
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;
2539
2734
  {
2540
2735
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
2541
2736
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
@@ -2731,13 +2926,12 @@ export async function pump(session, controller, ids, abortSignal) {
2731
2926
  cacheDiagnostics.execRequests++;
2732
2927
  const esmId = esm.id ?? 0;
2733
2928
  if (esm.request_context_args) {
2734
- // 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.
2735
2930
  {
2736
2931
  const rc = session.requestContext;
2737
- const skills = Array.isArray(rc.agent_skills) ? rc.agent_skills.length : 0;
2738
2932
  const hooks = typeof rc.hooks_additional_context === "string" ? rc.hooks_additional_context : "";
2739
2933
  trace(`exec request_context: id=${esmId} — replying context ` +
2740
- `tools=${session.toolDescriptors.length} skills=${skills} ` +
2934
+ `tools=${session.toolDescriptors.length} ` +
2741
2935
  `hooks=${hooks ? hooks.split("\n").length : 0}`);
2742
2936
  if (hooks)
2743
2937
  trace(`exec request_context hooks_additional_context: ${hooks}`);
@@ -2754,14 +2948,15 @@ export async function pump(session, controller, ids, abortSignal) {
2754
2948
  }
2755
2949
  else if (esm.mcp_state_exec_args) {
2756
2950
  // MCP-backed writes/reads can be preceded by this control-plane probe.
2757
- // Confirm the virtual servers from the already-advertised context, then
2758
- // 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.
2759
2954
  const stateArgs = esm.mcp_state_exec_args;
2760
2955
  const requested = Array.isArray(stateArgs.server_identifiers)
2761
2956
  ? stateArgs.server_identifiers.join(",")
2762
2957
  : "";
2763
2958
  try {
2764
- 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}`);
2765
2960
  trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
2766
2961
  }
2767
2962
  catch (error) {
@@ -3249,6 +3444,10 @@ export async function pump(session, controller, ids, abortSignal) {
3249
3444
  `setBlobIdLen=${kv.set_blob_args?.blob_id?.length ?? "-"} ` +
3250
3445
  `setDataLen=${kv.set_blob_args?.blob_data?.length ?? "-"}`);
3251
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;
3252
3451
  if (handled) {
3253
3452
  try {
3254
3453
  await writeWithBackpressure(session.stream, handled.reply, `KV ${handled.kind}_blob reply id=${handled.id}`);
@@ -3329,23 +3528,73 @@ function extractToolResults(prompt) {
3329
3528
  * messages after the last non-tool message. Mid-prompt historical tool results
3330
3529
  * are ignored — they are conversation history, not replies for a held-open Run.
3331
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
+ }
3332
3574
  export function extractTrailingToolResults(prompt) {
3333
- if (prompt.length === 0)
3334
- return [];
3335
- let i = prompt.length - 1;
3575
+ const { end, notes } = liveTail(prompt);
3576
+ let i = end - 1;
3336
3577
  while (i >= 0 && prompt[i].role === "tool")
3337
3578
  i--;
3338
- // Continuations end with tool messages. Anything else (user/assistant/system)
3579
+ // Continuations end with tool messages. Anything else (user/assistant)
3339
3580
  // means this is a fresh model call that merely carries tools in history.
3340
- if (i === prompt.length - 1)
3581
+ if (i === end - 1)
3341
3582
  return [];
3342
- 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;
3343
3591
  }
3344
3592
  /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
3345
3593
  export function hasApprovedUncorrelatedPlanStageResult(prompt) {
3346
- if (prompt.length === 0 || prompt[prompt.length - 1].role !== "tool")
3594
+ const { end } = liveTail(prompt);
3595
+ if (end === 0 || prompt[end - 1].role !== "tool")
3347
3596
  return false;
3348
- 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--) {
3349
3598
  const message = prompt[i];
3350
3599
  if (!Array.isArray(message.content))
3351
3600
  continue;
@@ -3396,9 +3645,8 @@ function extractSystemPrompt(prompt) {
3396
3645
  return parts.length > 0 ? parts.join("\n\n") : undefined;
3397
3646
  }
3398
3647
  /**
3399
- * Checkpointed Runs do not resend the system prompt. Keep the workspace root
3400
- * on the live user message, and require absolute `path` arguments when that is
3401
- * 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.
3402
3650
  */
3403
3651
  export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, tools) {
3404
3652
  if (!checkpoint)
@@ -3645,10 +3893,45 @@ function appendSeedHistory(out, role, content) {
3645
3893
  }
3646
3894
  out.push({ role, content });
3647
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
+ }
3648
3926
  /** OpenCode session id header, if present. */
3649
3927
  export function opencodeSessionKey(callOptions) {
3650
3928
  const h = callOptions.headers ?? {};
3651
- 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"] ??
3652
3935
  h["X-Session-Id"] ??
3653
3936
  h["x-session-affinity"] ??
3654
3937
  h["x-opencode-session"];
@@ -3665,6 +3948,63 @@ export function resolveConversationId(callOptions) {
3665
3948
  return bindConversationId(opencodeSessionKey(callOptions)).conversationId;
3666
3949
  }
3667
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
+ }
3668
4008
  function extractTools(callOptions) {
3669
4009
  const tools = callOptions.tools;
3670
4010
  if (!tools || tools.length === 0) {