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.
- package/README.md +9 -7
- package/dist/agent-url.js +2 -2
- package/dist/auth-renewal.d.ts +85 -0
- package/dist/auth-renewal.js +284 -0
- package/dist/auth.d.ts +38 -22
- package/dist/auth.js +79 -96
- package/dist/classic-tools.d.ts +15 -0
- package/dist/classic-tools.js +45 -0
- package/dist/context/build.d.ts +42 -5
- package/dist/context/build.js +96 -92
- package/dist/context/dynamic-catalog.d.ts +2 -58
- package/dist/context/dynamic-catalog.js +10 -160
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +37 -14
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +7 -4
- package/dist/context/paths.js +13 -9
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +4 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +27 -3
- package/dist/host-agent-mode.js +2 -2
- package/dist/image-save.js +3 -3
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +41 -8
- package/dist/language-model.js +391 -98
- package/dist/opencode2/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- package/dist/plan-execution-kickoff.js +2 -2
- package/dist/plugin-opencode2.js +10 -16
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +138 -109
- package/dist/protocol/blob-store.js +3 -2
- package/dist/protocol/conversation-bind.js +1 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +11 -1
- package/dist/protocol/conversation-state.d.ts +2 -0
- package/dist/protocol/conversation-state.js +19 -0
- package/dist/protocol/create-plan.js +2 -1
- package/dist/protocol/messages.js +22 -4
- package/dist/protocol/request.d.ts +11 -9
- package/dist/protocol/request.js +15 -25
- package/dist/protocol/tools.d.ts +17 -10
- package/dist/protocol/tools.js +53 -93
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/session.d.ts +9 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- package/package.json +1 -1
- package/dist/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/context/skills.d.ts +0 -11
- package/dist/context/skills.js +0 -110
package/dist/language-model.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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,
|
|
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`, {
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
842
|
-
//
|
|
843
|
-
|
|
844
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
|
991
|
-
// a new Cursor conversation, so it must
|
|
992
|
-
|
|
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
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
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
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
const toolDescriptors =
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
2805
|
-
// keep pumping until Cursor
|
|
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.
|
|
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
|
-
|
|
3381
|
-
|
|
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
|
|
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 ===
|
|
3581
|
+
if (i === end - 1)
|
|
3388
3582
|
return [];
|
|
3389
|
-
|
|
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
|
-
|
|
3594
|
+
const { end } = liveTail(prompt);
|
|
3595
|
+
if (end === 0 || prompt[end - 1].role !== "tool")
|
|
3394
3596
|
return false;
|
|
3395
|
-
for (let 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
|
-
*
|
|
3447
|
-
*
|
|
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
|
-
|
|
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) {
|