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.
- 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 +3 -28
- package/dist/context/dynamic-catalog.js +25 -130
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +37 -9
- 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 +59 -8
- package/dist/language-model.js +458 -118
- 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);
|
|
@@ -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
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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:
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
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 ?? {});
|
|
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:
|
|
966
|
+
reset: resetState.reset || recovery?.kind === "rebase" || !!foreignHistory,
|
|
967
|
+
ephemeral: ephemeralRun,
|
|
845
968
|
});
|
|
846
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
975
|
-
// a new Cursor conversation, so it must
|
|
976
|
-
|
|
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
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
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
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
const toolDescriptors =
|
|
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
|
-
`
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
2758
|
-
// 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.
|
|
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.
|
|
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
|
-
|
|
3334
|
-
|
|
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
|
|
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 ===
|
|
3581
|
+
if (i === end - 1)
|
|
3341
3582
|
return [];
|
|
3342
|
-
|
|
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
|
-
|
|
3594
|
+
const { end } = liveTail(prompt);
|
|
3595
|
+
if (end === 0 || prompt[end - 1].role !== "tool")
|
|
3347
3596
|
return false;
|
|
3348
|
-
for (let 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
|
-
*
|
|
3400
|
-
*
|
|
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
|
-
|
|
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) {
|