cursor-opencode-provider 0.6.7 → 0.7.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 +52 -180
- package/dist/compaction-marker.d.ts +3 -2
- package/dist/compaction-marker.js +3 -2
- package/dist/host-agent-mode.d.ts +32 -0
- package/dist/host-agent-mode.js +92 -0
- package/dist/language-model.d.ts +10 -1
- package/dist/language-model.js +253 -41
- package/dist/models.d.ts +12 -1
- package/dist/models.js +16 -6
- package/dist/opencode2/catalog.d.ts +46 -11
- package/dist/opencode2/catalog.js +60 -45
- package/dist/opencode2/todo-tools.d.ts +64 -0
- package/dist/opencode2/todo-tools.js +165 -0
- package/dist/opencode2/types.d.ts +138 -83
- package/dist/opencode2/types.js +8 -10
- package/dist/plan-execution-kickoff.d.ts +2 -0
- package/dist/plan-execution-kickoff.js +25 -1
- package/dist/plugin-opencode2.d.ts +4 -1
- package/dist/plugin-opencode2.js +335 -154
- package/dist/plugin.js +5 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +19 -1
- package/dist/protocol/conversation-state.d.ts +4 -0
- package/dist/protocol/conversation-state.js +4 -0
- package/dist/protocol/messages.js +14 -1
- package/dist/protocol/switch-mode.d.ts +5 -4
- package/dist/protocol/switch-mode.js +12 -4
- package/dist/protocol/tool-call-bridge.d.ts +29 -1
- package/dist/protocol/tool-call-bridge.js +187 -7
- package/dist/protocol/tools.d.ts +30 -4
- package/dist/protocol/tools.js +185 -58
- package/dist/session.d.ts +13 -1
- package/dist/session.js +1 -0
- package/dist/shared.d.ts +3 -1
- package/dist/shared.js +3 -1
- package/dist/shell-timeout.d.ts +13 -0
- package/dist/shell-timeout.js +38 -0
- package/dist/todo-store.d.ts +29 -0
- package/dist/todo-store.js +78 -0
- package/dist/web-tools.d.ts +13 -0
- package/dist/web-tools.js +83 -5
- package/package.json +6 -3
package/dist/language-model.js
CHANGED
|
@@ -6,18 +6,19 @@ import { trace, traceRequestContextPaths } from "./debug.js";
|
|
|
6
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
7
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
8
8
|
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, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.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";
|
|
10
10
|
import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
|
|
11
11
|
import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
12
12
|
import { appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
|
|
13
13
|
import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
|
|
14
|
-
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
14
|
+
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
|
|
15
15
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
16
16
|
import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
|
|
17
17
|
import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
|
|
18
18
|
import { SWITCH_MODE_RESULT_FIELD, getActiveCursorMode, isBridgedCursorPlanModeActive, isCursorPlanModeActive, setActiveCursorMode, switchModeResultFromQuestionOutput, switchModeResultFromToolOutput, switchModeToolInput, takeActiveCursorModeReminder, } from "./protocol/switch-mode.js";
|
|
19
19
|
import { CREATE_PLAN_NOT_APPROVED_REASON, CREATE_PLAN_RESULT_FIELD, CURSOR_PLAN_STAGE_TOOL, createPlanApproved, createPlanStageInput, } from "./protocol/create-plan.js";
|
|
20
20
|
import { flushPlanExecutionKickoff, formatPlanKickoffPath, hasPlanExecutionKickoff, planExecutionKickoffState, planPathFromUri, queuePlanExecutionKickoff, takePlanExecutionKickoffWarning, } from "./plan-execution-kickoff.js";
|
|
21
|
+
import { flushHostAgentModeSwitch, queueHostAgentModeSwitch, } from "./host-agent-mode.js";
|
|
21
22
|
import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
|
|
22
23
|
import { stageCursorImage } from "./image-staging.js";
|
|
23
24
|
import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
|
|
@@ -34,7 +35,7 @@ import { getOrBuildRequestContext } from "./context/frozen.js";
|
|
|
34
35
|
import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
35
36
|
import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
|
|
36
37
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
37
|
-
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
|
|
38
|
+
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
|
|
38
39
|
import { isCompactionSession } from "./compaction-marker.js";
|
|
39
40
|
import { getSessionDirectory } from "./session-directory.js";
|
|
40
41
|
import { assertCursorUserImageSupport, extractCursorPromptImages } from "./image-input.js";
|
|
@@ -57,6 +58,7 @@ const sentHistoryImageHashesBySession = new Map();
|
|
|
57
58
|
// suppresses OpenCode's compacted prompt/system seed and makes Cursor narrate
|
|
58
59
|
// tool use instead of emitting exec requests. Rebase once on the next turn.
|
|
59
60
|
const postCompactionRebaseBySession = new Set();
|
|
61
|
+
const promptIdentityBySession = new Map();
|
|
60
62
|
export const MAX_TURN_STATE_SESSIONS = 256;
|
|
61
63
|
const MAX_SENT_HISTORY_IMAGES_PER_SESSION = 256;
|
|
62
64
|
const DEFAULT_RETRY_POLICY = {
|
|
@@ -326,6 +328,43 @@ function snapshotToolCatalog(sessionKey) {
|
|
|
326
328
|
return [];
|
|
327
329
|
return structuredClone(toolCatalogBySession.get(sessionKey) ?? []);
|
|
328
330
|
}
|
|
331
|
+
// Last known full host todo list per OpenCode session. Run-local
|
|
332
|
+
// `session.mirroredTodos` dies with the Run (turn_ended closes the session),
|
|
333
|
+
// but merges in later turns still need a base — same shape of problem as the
|
|
334
|
+
// tool catalog above, same solution. In-memory only: never enters the prompt,
|
|
335
|
+
// RequestContext, or persisted restart state, so the cache prefix is untouched.
|
|
336
|
+
// Refreshed on every todo write/read we observe; replaced wholesale, never
|
|
337
|
+
// merged, so it tracks host truth instead of accumulating guesses.
|
|
338
|
+
const mirroredTodosBySession = new Map();
|
|
339
|
+
/** Record the authoritative host todo list for an OpenCode session. */
|
|
340
|
+
export function rememberMirroredTodos(sessionKey, todos) {
|
|
341
|
+
if (!sessionKey)
|
|
342
|
+
return;
|
|
343
|
+
mirroredTodosBySession.delete(sessionKey);
|
|
344
|
+
mirroredTodosBySession.set(sessionKey, todos.map((t) => ({ ...t })));
|
|
345
|
+
while (mirroredTodosBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
346
|
+
const oldest = mirroredTodosBySession.keys().next().value;
|
|
347
|
+
if (!oldest)
|
|
348
|
+
break;
|
|
349
|
+
mirroredTodosBySession.delete(oldest);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** Copy of the last known host todo list for an OpenCode session, if any. */
|
|
353
|
+
export function snapshotMirroredTodosBySession(sessionKey) {
|
|
354
|
+
if (!sessionKey)
|
|
355
|
+
return undefined;
|
|
356
|
+
const todos = mirroredTodosBySession.get(sessionKey);
|
|
357
|
+
return todos ? todos.map((t) => ({ ...t })) : undefined;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Store a bridged/observed full todo snapshot on both the live Run session
|
|
361
|
+
* (for merges later this turn) and the per-OpenCode-session copy (for merges
|
|
362
|
+
* in later turns and across checkpoint resumes).
|
|
363
|
+
*/
|
|
364
|
+
function storeMirroredTodos(session, todos) {
|
|
365
|
+
session.mirroredTodos = todos.map((t) => ({ ...t }));
|
|
366
|
+
rememberMirroredTodos(session.openCodeSessionId, session.mirroredTodos);
|
|
367
|
+
}
|
|
329
368
|
function rememberPostCompactionRebase(sessionKey) {
|
|
330
369
|
postCompactionRebaseBySession.delete(sessionKey);
|
|
331
370
|
postCompactionRebaseBySession.add(sessionKey);
|
|
@@ -336,6 +375,35 @@ function rememberPostCompactionRebase(sessionKey) {
|
|
|
336
375
|
postCompactionRebaseBySession.delete(oldest);
|
|
337
376
|
}
|
|
338
377
|
}
|
|
378
|
+
function normalizePromptIdentity(value) {
|
|
379
|
+
const hostAgent = value.hostAgent?.trim();
|
|
380
|
+
const systemPromptHash = value.systemPromptHash?.trim();
|
|
381
|
+
return {
|
|
382
|
+
...(hostAgent ? { hostAgent } : {}),
|
|
383
|
+
...(systemPromptHash ? { systemPromptHash } : {}),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function rememberPromptIdentity(sessionKey, value) {
|
|
387
|
+
const normalized = normalizePromptIdentity(value);
|
|
388
|
+
if (!normalized.hostAgent && !normalized.systemPromptHash)
|
|
389
|
+
return;
|
|
390
|
+
promptIdentityBySession.delete(sessionKey);
|
|
391
|
+
promptIdentityBySession.set(sessionKey, normalized);
|
|
392
|
+
while (promptIdentityBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
393
|
+
const oldest = promptIdentityBySession.keys().next().value;
|
|
394
|
+
if (!oldest)
|
|
395
|
+
break;
|
|
396
|
+
promptIdentityBySession.delete(oldest);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function promptIdentityChanged(previous, current) {
|
|
400
|
+
// Compare only facts the current host supplied. This keeps standalone calls
|
|
401
|
+
// and older compatibility surfaces from invalidating a valid checkpoint just
|
|
402
|
+
// because they cannot expose an agent id.
|
|
403
|
+
return ((current.hostAgent !== undefined && previous.hostAgent !== current.hostAgent)
|
|
404
|
+
|| (current.systemPromptHash !== undefined
|
|
405
|
+
&& previous.systemPromptHash !== current.systemPromptHash));
|
|
406
|
+
}
|
|
339
407
|
function sentHistoryImageHashes(sessionKey) {
|
|
340
408
|
if (!sessionKey)
|
|
341
409
|
return undefined;
|
|
@@ -501,6 +569,12 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
501
569
|
setActiveCursorMode(activeSession.openCodeSessionId, "plan");
|
|
502
570
|
}
|
|
503
571
|
}
|
|
572
|
+
await flushHostAgentModeSwitch(activeSession.openCodeSessionId, {
|
|
573
|
+
cursorSessionID: activeSession.sessionId,
|
|
574
|
+
terminal: activeSession.closed,
|
|
575
|
+
pumpActive: activeSession.pumpActive || activeSession.pumpOwner != null,
|
|
576
|
+
pendingExecs: activeSession.pending.size,
|
|
577
|
+
});
|
|
504
578
|
}
|
|
505
579
|
catch (e) {
|
|
506
580
|
activeSession.pumpActive = false;
|
|
@@ -573,6 +647,8 @@ export async function pumpWithRecovery(input) {
|
|
|
573
647
|
if (recovery.kind === "resume") {
|
|
574
648
|
session.usageEstimate = { ...pumpedSession.usageEstimate };
|
|
575
649
|
session.editToolCalls = new Map(pumpedSession.editToolCalls);
|
|
650
|
+
// mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
|
|
651
|
+
// session, seeded in startSession) — no handoff needed here.
|
|
576
652
|
}
|
|
577
653
|
input.onSession?.(session);
|
|
578
654
|
}
|
|
@@ -654,13 +730,25 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
654
730
|
rememberPostCompactionRebase(sessionKey);
|
|
655
731
|
if (restored?.toolCatalog.length)
|
|
656
732
|
restoreTurnToolCatalog(sessionKey, restored.toolCatalog);
|
|
733
|
+
if (restored?.hostAgent || restored?.systemPromptHash) {
|
|
734
|
+
rememberPromptIdentity(sessionKey, {
|
|
735
|
+
...(restored.hostAgent ? { hostAgent: restored.hostAgent } : {}),
|
|
736
|
+
...(restored.systemPromptHash ? { systemPromptHash: restored.systemPromptHash } : {}),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
657
739
|
}
|
|
658
740
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
741
|
+
const hostAgent = typeof providerOptions?.[CURSOR_HOST_AGENT_OPTION] === "string"
|
|
742
|
+
? String(providerOptions[CURSOR_HOST_AGENT_OPTION]).trim() || undefined
|
|
743
|
+
: undefined;
|
|
659
744
|
// The classic plugin marks OpenCode's agent="compaction" through chat.params.
|
|
660
|
-
// OpenCode 2.0 removed that hook, so its plugin
|
|
661
|
-
//
|
|
745
|
+
// OpenCode 2.0 removed that hook, so its plugin writes the same request-local
|
|
746
|
+
// option from session hooks. The session marker is a fallback only when a
|
|
747
|
+
// host does not preserve mutable options; explicit false prevents concurrent
|
|
748
|
+
// title/generate calls from inheriting a compaction marker.
|
|
662
749
|
// Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
|
|
663
|
-
const
|
|
750
|
+
const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
|
|
751
|
+
const isCompaction = compactionOption === true || (compactionOption === undefined && isCompactionSession(sessionKey));
|
|
664
752
|
const toolState = await resolveTurnToolState({
|
|
665
753
|
sessionKey,
|
|
666
754
|
incomingTools,
|
|
@@ -679,14 +767,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
679
767
|
}
|
|
680
768
|
const allowTools = toolState.allowTools;
|
|
681
769
|
const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
|
|
682
|
-
const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
|
|
683
770
|
let recovery = startOptions?.recovery;
|
|
684
771
|
let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
|
|
685
772
|
let resuming = !!resumeRecovery;
|
|
773
|
+
const lifecycle = !allowTools && !isCompaction && !recovery;
|
|
774
|
+
// v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
|
|
775
|
+
// one plugin instance per project). OpenCode 2.0 runs one daemon across many
|
|
776
|
+
// projects, so its static option is only a fallback for the directory recorded
|
|
777
|
+
// from `session.hook("context")`.
|
|
778
|
+
const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
|
|
779
|
+
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
780
|
+
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
781
|
+
const stableSystemPrompt = [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n");
|
|
782
|
+
const stableSystemPromptHash = stableSystemPrompt
|
|
783
|
+
? createHash("sha256").update(stableSystemPrompt).digest("hex")
|
|
784
|
+
: undefined;
|
|
785
|
+
const resetState = resolveTurnConversationReset({
|
|
786
|
+
sessionKey,
|
|
787
|
+
isCompaction,
|
|
788
|
+
...(lifecycle
|
|
789
|
+
? {}
|
|
790
|
+
: { promptIdentity: { hostAgent, systemPromptHash: stableSystemPromptHash } }),
|
|
791
|
+
});
|
|
686
792
|
// Compaction must not reuse the prior conversation; its first normal turn
|
|
687
793
|
// must also rebase so the summary-agent checkpoint cannot replace the normal
|
|
688
794
|
// system prompt and OpenCode's newly compacted history.
|
|
689
|
-
const lifecycle = !allowTools && !isCompaction && !recovery;
|
|
690
795
|
let bound = resuming
|
|
691
796
|
? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
|
|
692
797
|
: bindConversationId(sessionKey, {
|
|
@@ -742,18 +847,13 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
742
847
|
const userText = recovery?.kind === "rebase"
|
|
743
848
|
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
744
849
|
: (extractUserText(lastUser) || ".");
|
|
745
|
-
// v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
|
|
746
|
-
// one plugin instance per project). OpenCode 2.0 runs one daemon across many
|
|
747
|
-
// projects, so its static `options.workspaceRoot` is only a last-resort
|
|
748
|
-
// fallback; `getSessionDirectory` carries the real per-session directory
|
|
749
|
-
// recorded from `session.hook("context")` in `plugin-opencode2.ts`.
|
|
750
|
-
const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
|
|
751
|
-
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
752
|
-
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
753
850
|
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
754
851
|
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
755
852
|
const startedWithCheckpoint = !!conversationState;
|
|
756
|
-
const
|
|
853
|
+
const activeMode = getActiveCursorMode(sessionKey);
|
|
854
|
+
const nativePlanPromptOwnsMode = hostAgent === "plan"
|
|
855
|
+
&& (activeMode === "plan" || activeMode === "spec");
|
|
856
|
+
const modeReminder = isCompaction || startedWithCheckpoint || lifecycle || nativePlanPromptOwnsMode
|
|
757
857
|
? undefined
|
|
758
858
|
: takeActiveCursorModeReminder(sessionKey, {
|
|
759
859
|
advertisedTools: cursorTools.map((tool) => tool.name),
|
|
@@ -944,6 +1044,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
944
1044
|
stream.destroy();
|
|
945
1045
|
throw error;
|
|
946
1046
|
}
|
|
1047
|
+
const hostToolDialect = hostToolDialectFromTools(tools);
|
|
1048
|
+
trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
|
|
1049
|
+
`tools=[${tools.map((t) => t.name).join(",")}]`);
|
|
947
1050
|
const session = {
|
|
948
1051
|
sessionId: crypto.randomUUID(),
|
|
949
1052
|
conversationId,
|
|
@@ -970,6 +1073,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
970
1073
|
execRequests: 0,
|
|
971
1074
|
},
|
|
972
1075
|
openCodeSessionId: lifecycle ? undefined : sessionKey,
|
|
1076
|
+
hostAgent,
|
|
1077
|
+
stableSystemPromptHash,
|
|
973
1078
|
postCompactionRebase: isCompaction,
|
|
974
1079
|
toolCatalog: snapshotToolCatalog(sessionKey),
|
|
975
1080
|
stream,
|
|
@@ -977,10 +1082,15 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
977
1082
|
pending: new Map(),
|
|
978
1083
|
displayToolCalls: new Map(),
|
|
979
1084
|
editToolCalls: new Map(),
|
|
1085
|
+
// Seed from the per-OpenCode-session copy: merges in this turn (and after
|
|
1086
|
+
// checkpoint resumes/rebases, which rebuild the session here) expand
|
|
1087
|
+
// against the last observed host list, not an empty one.
|
|
1088
|
+
mirroredTodos: snapshotMirroredTodosBySession(lifecycle ? undefined : sessionKey),
|
|
980
1089
|
nextBridgedExecId: 900_000,
|
|
981
1090
|
blobs: new Map(),
|
|
982
1091
|
toolDescriptors,
|
|
983
1092
|
toolAliases: webToolAliases.aliases,
|
|
1093
|
+
hostToolDialect,
|
|
984
1094
|
subagentCatalog,
|
|
985
1095
|
requestContext,
|
|
986
1096
|
allowTools,
|
|
@@ -1173,7 +1283,7 @@ function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSes
|
|
|
1173
1283
|
plan_uri: planUri,
|
|
1174
1284
|
});
|
|
1175
1285
|
}
|
|
1176
|
-
function buildSwitchModeContinuationFrame(pending, result
|
|
1286
|
+
function buildSwitchModeContinuationFrame(pending, result) {
|
|
1177
1287
|
const metadata = pending.resultMetadata ?? {};
|
|
1178
1288
|
const interactionId = metadata.interactionId;
|
|
1179
1289
|
if (typeof interactionId !== "number") {
|
|
@@ -1185,16 +1295,13 @@ function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
|
|
|
1185
1295
|
const answer = metadata.switchModeBridgeKind === "question"
|
|
1186
1296
|
? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
|
|
1187
1297
|
: switchModeResultFromToolOutput(result.output, result.error !== undefined);
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
return buildSwitchModeInteractionReply(interactionId, answer);
|
|
1298
|
+
const target = "approved" in answer && typeof metadata.switchModeTarget === "string"
|
|
1299
|
+
? metadata.switchModeTarget.trim()
|
|
1300
|
+
: "";
|
|
1301
|
+
return {
|
|
1302
|
+
frame: buildSwitchModeInteractionReply(interactionId, answer),
|
|
1303
|
+
...(target ? { approvedTarget: target, bridgeKind: metadata.switchModeBridgeKind } : {}),
|
|
1304
|
+
};
|
|
1198
1305
|
}
|
|
1199
1306
|
/**
|
|
1200
1307
|
* Deliver trailing tool results onto a live continuation session.
|
|
@@ -1222,6 +1329,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
1222
1329
|
}
|
|
1223
1330
|
const pending = claim.pending;
|
|
1224
1331
|
let frames = [];
|
|
1332
|
+
let deliveredSwitchMode;
|
|
1225
1333
|
if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
|
|
1226
1334
|
// A bridged Cursor AskQuestion. The host tool result carries the user's
|
|
1227
1335
|
// choices; translate them back into the Cursor result the interaction is
|
|
@@ -1241,7 +1349,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
1241
1349
|
// approved{} or rejected{reason} on the still-open InteractionQuery.
|
|
1242
1350
|
// Approval also arms the CLI-shaped mode reminder for the next Run.
|
|
1243
1351
|
try {
|
|
1244
|
-
|
|
1352
|
+
const built = buildSwitchModeContinuationFrame(pending, r);
|
|
1353
|
+
frames = [built.frame];
|
|
1354
|
+
if (built.approvedTarget) {
|
|
1355
|
+
deliveredSwitchMode = {
|
|
1356
|
+
target: built.approvedTarget,
|
|
1357
|
+
bridgeKind: built.bridgeKind,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1245
1360
|
}
|
|
1246
1361
|
catch (error) {
|
|
1247
1362
|
trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
|
|
@@ -1362,6 +1477,32 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
1362
1477
|
continue;
|
|
1363
1478
|
return undefined;
|
|
1364
1479
|
}
|
|
1480
|
+
if (deliveredSwitchMode) {
|
|
1481
|
+
const normalized = deliveredSwitchMode.target.toLowerCase();
|
|
1482
|
+
setActiveCursorMode(session.openCodeSessionId, deliveredSwitchMode.target, {
|
|
1483
|
+
bridgedPlanEntered: normalized === "plan" || normalized === "spec",
|
|
1484
|
+
});
|
|
1485
|
+
// A question-emulated exit has no native host plan tool to perform the
|
|
1486
|
+
// actual primary-agent switch. Queue only after Cursor received the
|
|
1487
|
+
// approved response; a failed write must not mutate host mode.
|
|
1488
|
+
if (deliveredSwitchMode.bridgeKind === "question" && session.openCodeSessionId) {
|
|
1489
|
+
queueHostAgentModeSwitch({
|
|
1490
|
+
sessionID: session.openCodeSessionId,
|
|
1491
|
+
targetModeID: deliveredSwitchMode.target,
|
|
1492
|
+
cursorSessionID: session.sessionId,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
// A host `todoread` result is the authoritative list. Refresh the mirrored
|
|
1497
|
+
// snapshot (direct reads and bridged native reads alike) so later merges
|
|
1498
|
+
// apply onto host truth, not a stale write.
|
|
1499
|
+
if (pending.toolName === "todoread" && r.error === undefined) {
|
|
1500
|
+
const snapshot = snapshotMirroredTodosFromReadOutput(r.output);
|
|
1501
|
+
if (snapshot !== undefined) {
|
|
1502
|
+
storeMirroredTodos(session, snapshot);
|
|
1503
|
+
trace(`continuation: mirrored todoread snapshot items=${snapshot.length}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1365
1506
|
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
1366
1507
|
if (pending.bridged) {
|
|
1367
1508
|
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
@@ -1633,7 +1774,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1633
1774
|
const display = parseDisplayToolCall(displayCallId, stored);
|
|
1634
1775
|
if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
|
|
1635
1776
|
return false;
|
|
1636
|
-
const requestedPath =
|
|
1777
|
+
const requestedPath = opencodePathArg(parsed.args) ?? "";
|
|
1637
1778
|
const editPath = typeof display.args.path === "string" ? display.args.path : "";
|
|
1638
1779
|
if (!requestedPath || !editPath)
|
|
1639
1780
|
return false;
|
|
@@ -1731,7 +1872,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1731
1872
|
const rejectMissingReadTarget = (parsed) => {
|
|
1732
1873
|
if (parsed.toolName !== "read")
|
|
1733
1874
|
return false;
|
|
1734
|
-
const requested =
|
|
1875
|
+
const requested = opencodePathArg(parsed.args) ?? "";
|
|
1735
1876
|
if (!requested)
|
|
1736
1877
|
return false;
|
|
1737
1878
|
// A scheme-addressed target is the advertised host executor's to resolve,
|
|
@@ -2031,6 +2172,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2031
2172
|
requestContext: session.requestContext,
|
|
2032
2173
|
toolCatalog: session.toolCatalog ?? [],
|
|
2033
2174
|
postCompactionRebase: session.postCompactionRebase,
|
|
2175
|
+
hostAgent: session.hostAgent,
|
|
2176
|
+
systemPromptHash: session.stableSystemPromptHash,
|
|
2034
2177
|
}).catch((error) => {
|
|
2035
2178
|
trace(`conversation persistence: TurnEnded save failed ` +
|
|
2036
2179
|
`sessionKey=${session.openCodeSessionId}: ${String(error)}`);
|
|
@@ -2113,7 +2256,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2113
2256
|
trace(`display tool_call_completed: SKIPPED (allowTools=false) callId=${callId}`);
|
|
2114
2257
|
}
|
|
2115
2258
|
else {
|
|
2116
|
-
const display = parseDisplayToolCall(callId, toolCall);
|
|
2259
|
+
const display = parseDisplayToolCall(callId, toolCall, session.mirroredTodos);
|
|
2117
2260
|
const advertised = advertisedToolNamesFromDescriptors(session.toolDescriptors);
|
|
2118
2261
|
const bridged = display
|
|
2119
2262
|
? resolveBridgedOpenCodeToolCall(display, advertised)
|
|
@@ -2134,6 +2277,13 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2134
2277
|
`advertised=[${advertised.join(",")}]`);
|
|
2135
2278
|
}
|
|
2136
2279
|
else {
|
|
2280
|
+
// Snapshot only Cursor todo writes (not create_plan's synthetic
|
|
2281
|
+
// "plan" prepend) so later merge patches apply onto a real list.
|
|
2282
|
+
if (bridged.toolName === "todowrite" && bridged.variant === "update_todos_tool_call") {
|
|
2283
|
+
const snapshot = snapshotMirroredTodos(bridged.args.todos);
|
|
2284
|
+
if (snapshot !== undefined)
|
|
2285
|
+
storeMirroredTodos(session, snapshot);
|
|
2286
|
+
}
|
|
2137
2287
|
const execId = session.nextBridgedExecId++;
|
|
2138
2288
|
sessionManager.registerPending(execId, session, "bridged", bridged.toolName, true);
|
|
2139
2289
|
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
@@ -2252,7 +2402,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2252
2402
|
else {
|
|
2253
2403
|
replaySafety.markBarrier("non-control-exec");
|
|
2254
2404
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
2255
|
-
const parsed = parseExecServerMessage(esm);
|
|
2405
|
+
const parsed = parseExecServerMessage(esm, session.hostToolDialect);
|
|
2256
2406
|
if (parsed) {
|
|
2257
2407
|
const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
|
|
2258
2408
|
if (executableToolName !== parsed.toolName) {
|
|
@@ -2260,6 +2410,20 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2260
2410
|
parsed.toolName = executableToolName;
|
|
2261
2411
|
}
|
|
2262
2412
|
remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
|
|
2413
|
+
// SubagentArgs has no description; Cursor's real title lives on the
|
|
2414
|
+
// correlated display TaskToolCall. Prefer that over the 5-word prompt slice.
|
|
2415
|
+
if (displayCallId && (parsed.toolName === "task" || parsed.toolName === "subagent")) {
|
|
2416
|
+
const stored = session.displayToolCalls.get(displayCallId);
|
|
2417
|
+
const display = parseDisplayToolCall(displayCallId, stored);
|
|
2418
|
+
if (display?.variant === "task_tool_call") {
|
|
2419
|
+
const description = typeof display.args.description === "string" ? display.args.description : undefined;
|
|
2420
|
+
preferCorrelatedTaskDescription(parsed, description);
|
|
2421
|
+
if (description?.trim()) {
|
|
2422
|
+
trace(`exec: preferred TaskToolCall description callId=${displayCallId} ` +
|
|
2423
|
+
`description=${JSON.stringify(description.trim())}`);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2263
2427
|
const editCall = displayCallId
|
|
2264
2428
|
? session.editToolCalls?.get(displayCallId)
|
|
2265
2429
|
: undefined;
|
|
@@ -2396,6 +2560,16 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2396
2560
|
}
|
|
2397
2561
|
// Keep the stream open; the result arrives on the next doStream call.
|
|
2398
2562
|
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
|
|
2563
|
+
// A direct host `todowrite` is a replace-all snapshot: it is the new
|
|
2564
|
+
// truth for later Cursor merge patches, which otherwise apply onto a
|
|
2565
|
+
// stale (or empty) mirrored list.
|
|
2566
|
+
if (parsed.toolName === "todowrite") {
|
|
2567
|
+
const snapshot = snapshotMirroredTodos(parsed.args.todos);
|
|
2568
|
+
if (snapshot !== undefined) {
|
|
2569
|
+
storeMirroredTodos(session, snapshot);
|
|
2570
|
+
trace(`exec: mirrored todowrite snapshot items=${snapshot.length}`);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2399
2573
|
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
2400
2574
|
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
2401
2575
|
emittedHostTools++;
|
|
@@ -2535,9 +2709,17 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2535
2709
|
setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
|
|
2536
2710
|
bridgedPlanEntered: false,
|
|
2537
2711
|
});
|
|
2712
|
+
const hostAgentSwitchQueued = session.openCodeSessionId
|
|
2713
|
+
? queueHostAgentModeSwitch({
|
|
2714
|
+
sessionID: session.openCodeSessionId,
|
|
2715
|
+
targetModeID: sw.args.targetModeId,
|
|
2716
|
+
cursorSessionID: session.sessionId,
|
|
2717
|
+
})
|
|
2718
|
+
: false;
|
|
2538
2719
|
trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
|
|
2539
2720
|
`target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
|
|
2540
|
-
|
|
2721
|
+
`${hostAgentSwitchQueued ? "native host-agent switch queued" : "provider-owned fallback"}) ` +
|
|
2722
|
+
`cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2541
2723
|
continue;
|
|
2542
2724
|
}
|
|
2543
2725
|
if (handled.outcome === "bridged" && handled.switchMode) {
|
|
@@ -2781,12 +2963,18 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2781
2963
|
if (names.has("plan_enter")) {
|
|
2782
2964
|
instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool. Cursor-native SwitchMode requests for plan/spec are also accepted and answered through it.");
|
|
2783
2965
|
}
|
|
2784
|
-
else if (names.has("todowrite")) {
|
|
2785
|
-
instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
|
|
2786
|
-
}
|
|
2787
2966
|
if (names.has("plan_exit")) {
|
|
2788
2967
|
instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool. Cursor-native SwitchMode for any non-plan target (agent, build, chat, debug, edit, background, multitask, triage, project, …) is also accepted and answered through it; the provider then injects the Cursor CLI-shaped mode reminder for that target.");
|
|
2789
2968
|
}
|
|
2969
|
+
// Prefer host todos whenever advertised. Keep guidance terse — spelling out
|
|
2970
|
+
// Cursor TodoWrite vs TodoRead / MCP alias collisions teaches the model to
|
|
2971
|
+
// inventory catalogs instead of just calling the host tools.
|
|
2972
|
+
if (names.has("todowrite") || names.has("todoread")) {
|
|
2973
|
+
const write = names.has("todowrite") ? "`todowrite`" : undefined;
|
|
2974
|
+
const read = names.has("todoread") ? "`todoread`" : undefined;
|
|
2975
|
+
const tools = write && read ? `${write} / ${read}` : (write ?? read);
|
|
2976
|
+
instructions.push(`- For task-list create/update/complete/cancel${read ? "/read" : ""}, call OpenCode ${tools}; do not use Cursor TodoWrite, and do not narrate Cursor-vs-OpenCode todo-tool differences.`);
|
|
2977
|
+
}
|
|
2790
2978
|
if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
|
|
2791
2979
|
instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
|
|
2792
2980
|
}
|
|
@@ -2799,8 +2987,14 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2799
2987
|
if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
|
|
2800
2988
|
instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
|
|
2801
2989
|
}
|
|
2802
|
-
if (names.has("
|
|
2803
|
-
const
|
|
2990
|
+
if (names.has("execute")) {
|
|
2991
|
+
const shell = names.has("shell") ? "`shell`" : names.has("bash") ? "`bash`" : undefined;
|
|
2992
|
+
instructions.push(shell
|
|
2993
|
+
? `- OpenCode \`execute\` is Code Mode JavaScript (\`code\`); it is not a shell. For OS commands, call OpenCode ${shell}. Do not pass \`command\` to \`execute\`.`
|
|
2994
|
+
: "- OpenCode `execute` is Code Mode JavaScript (`code`); it is not a shell. Do not pass `command` to `execute`.");
|
|
2995
|
+
}
|
|
2996
|
+
if (names.has("task") || names.has("subagent")) {
|
|
2997
|
+
const target = names.has("task") ? "`task`" : "`subagent`";
|
|
2804
2998
|
const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
|
|
2805
2999
|
instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
|
|
2806
3000
|
"Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
|
|
@@ -2828,8 +3022,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2828
3022
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
2829
3023
|
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
|
2830
3024
|
subagents.executor
|
|
2831
|
-
? "Call only tools in that exact list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
|
|
2832
|
-
: "Call only tools in that exact list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
|
|
3025
|
+
? "Call only tools in that exact OpenCode list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
|
|
3026
|
+
: "Call only tools in that exact OpenCode list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
|
|
2833
3027
|
...(instructions.length > 0
|
|
2834
3028
|
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
2835
3029
|
: []),
|
|
@@ -3067,9 +3261,25 @@ export function resolveTurnConversationReset(input) {
|
|
|
3067
3261
|
rememberPostCompactionRebase(sessionKey);
|
|
3068
3262
|
return { reset: true, reason: "compaction" };
|
|
3069
3263
|
}
|
|
3264
|
+
let promptChange;
|
|
3265
|
+
if (sessionKey && input.promptIdentity) {
|
|
3266
|
+
const current = normalizePromptIdentity(input.promptIdentity);
|
|
3267
|
+
const previous = promptIdentityBySession.get(sessionKey);
|
|
3268
|
+
if (previous && promptIdentityChanged(previous, current)) {
|
|
3269
|
+
promptChange = current.hostAgent !== undefined && previous.hostAgent !== current.hostAgent
|
|
3270
|
+
? "agent-change"
|
|
3271
|
+
: "system-prompt-change";
|
|
3272
|
+
}
|
|
3273
|
+
rememberPromptIdentity(sessionKey, {
|
|
3274
|
+
...previous,
|
|
3275
|
+
...current,
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3070
3278
|
if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
|
|
3071
3279
|
return { reset: true, reason: "post-compaction-rebase" };
|
|
3072
3280
|
}
|
|
3281
|
+
if (promptChange)
|
|
3282
|
+
return { reset: true, reason: promptChange };
|
|
3073
3283
|
return { reset: false };
|
|
3074
3284
|
}
|
|
3075
3285
|
export function resetTurnStateForTests() {
|
|
@@ -3080,6 +3290,8 @@ export function resetTurnStateForTests() {
|
|
|
3080
3290
|
toolCatalogWaitersBySession.clear();
|
|
3081
3291
|
toolCatalogBySession.clear();
|
|
3082
3292
|
postCompactionRebaseBySession.clear();
|
|
3293
|
+
promptIdentityBySession.clear();
|
|
3294
|
+
mirroredTodosBySession.clear();
|
|
3083
3295
|
}
|
|
3084
3296
|
function extractUserText(lastUser) {
|
|
3085
3297
|
if (!lastUser)
|
package/dist/models.d.ts
CHANGED
|
@@ -84,9 +84,20 @@ export declare function fetchModels(token: string, options?: {
|
|
|
84
84
|
headers?: Record<string, string>;
|
|
85
85
|
timeoutMs?: number;
|
|
86
86
|
}): Promise<ModelInfo[]>;
|
|
87
|
-
export declare function refreshModelCache(cacheDir: string, fetcher: () => Promise<ModelInfo[]
|
|
87
|
+
export declare function refreshModelCache(cacheDir: string, fetcher: () => Promise<ModelInfo[]>, options?: {
|
|
88
|
+
forceAfterInflight?: boolean;
|
|
89
|
+
}): Promise<ModelInfo[]>;
|
|
88
90
|
export declare function discoverModels(token: string, cacheDir: string, options?: {
|
|
89
91
|
baseURL?: string;
|
|
90
92
|
headers?: Record<string, string>;
|
|
91
93
|
timeoutMs?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Bypass even a fresh cache and require an authoritative response.
|
|
96
|
+
*
|
|
97
|
+
* Model availability is account-scoped. Callers use this after a
|
|
98
|
+
* credential switch so a fresh cache produced by the previous account is
|
|
99
|
+
* never rebound to the new connection. A forced refresh rejects on fetch
|
|
100
|
+
* failure; the caller can keep the already-published inventory unchanged.
|
|
101
|
+
*/
|
|
102
|
+
forceRefresh?: boolean;
|
|
92
103
|
}): Promise<ModelInfo[]>;
|
package/dist/models.js
CHANGED
|
@@ -396,11 +396,18 @@ export async function fetchModels(token, options = {}) {
|
|
|
396
396
|
return mapAvailableModelsResponse(raw);
|
|
397
397
|
}
|
|
398
398
|
const refreshesByDirectory = new Map();
|
|
399
|
-
export async function refreshModelCache(cacheDir, fetcher) {
|
|
399
|
+
export async function refreshModelCache(cacheDir, fetcher, options = {}) {
|
|
400
400
|
const key = path.resolve(cacheDir);
|
|
401
401
|
const existing = refreshesByDirectory.get(key);
|
|
402
|
-
if (existing)
|
|
403
|
-
|
|
402
|
+
if (existing) {
|
|
403
|
+
if (!options.forceAfterInflight)
|
|
404
|
+
return existing;
|
|
405
|
+
// An account switch must not join a background refresh authenticated as
|
|
406
|
+
// the previous account. Wait for that write to finish, then run this
|
|
407
|
+
// caller's fetch so its account inventory is authoritative on disk.
|
|
408
|
+
await existing.catch(() => { });
|
|
409
|
+
return refreshModelCache(cacheDir, fetcher, options);
|
|
410
|
+
}
|
|
404
411
|
const refresh = (async () => {
|
|
405
412
|
const models = await fetcher();
|
|
406
413
|
await writeCache(cacheDir, {
|
|
@@ -420,10 +427,11 @@ export async function refreshModelCache(cacheDir, fetcher) {
|
|
|
420
427
|
}
|
|
421
428
|
}
|
|
422
429
|
export async function discoverModels(token, cacheDir, options = {}) {
|
|
430
|
+
const { forceRefresh = false, ...fetchOptions } = options;
|
|
423
431
|
const cached = await readCache(cacheDir);
|
|
424
|
-
const refresh = () => refreshModelCache(cacheDir, () => fetchModels(token,
|
|
432
|
+
const refresh = () => refreshModelCache(cacheDir, () => fetchModels(token, fetchOptions), { forceAfterInflight: forceRefresh });
|
|
425
433
|
// Cache is fresh → return it; refresh in background
|
|
426
|
-
if (cached && isCacheFresh(cached)) {
|
|
434
|
+
if (!forceRefresh && cached && isCacheFresh(cached)) {
|
|
427
435
|
// Background refresh (fire and forget)
|
|
428
436
|
void refresh()
|
|
429
437
|
.catch(() => {
|
|
@@ -436,7 +444,9 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
436
444
|
try {
|
|
437
445
|
return await refresh();
|
|
438
446
|
}
|
|
439
|
-
catch {
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (forceRefresh)
|
|
449
|
+
throw error;
|
|
440
450
|
return cached.models;
|
|
441
451
|
}
|
|
442
452
|
}
|