cursor-opencode-provider 0.6.8 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +51 -181
  2. package/dist/compaction-marker.d.ts +3 -2
  3. package/dist/compaction-marker.js +3 -2
  4. package/dist/host-agent-mode.d.ts +32 -0
  5. package/dist/host-agent-mode.js +92 -0
  6. package/dist/language-model.d.ts +15 -1
  7. package/dist/language-model.js +190 -36
  8. package/dist/models.d.ts +12 -1
  9. package/dist/models.js +16 -6
  10. package/dist/opencode2/catalog.d.ts +46 -11
  11. package/dist/opencode2/catalog.js +60 -45
  12. package/dist/opencode2/todo-tools.d.ts +64 -0
  13. package/dist/opencode2/todo-tools.js +165 -0
  14. package/dist/opencode2/types.d.ts +138 -83
  15. package/dist/opencode2/types.js +8 -10
  16. package/dist/plan-execution-kickoff.d.ts +2 -0
  17. package/dist/plan-execution-kickoff.js +25 -1
  18. package/dist/plugin-opencode2.d.ts +4 -1
  19. package/dist/plugin-opencode2.js +335 -154
  20. package/dist/plugin.js +5 -1
  21. package/dist/protocol/conversation-persistence.d.ts +2 -0
  22. package/dist/protocol/conversation-persistence.js +19 -1
  23. package/dist/protocol/conversation-state.d.ts +4 -0
  24. package/dist/protocol/conversation-state.js +4 -0
  25. package/dist/protocol/switch-mode.d.ts +5 -4
  26. package/dist/protocol/switch-mode.js +12 -4
  27. package/dist/protocol/tool-call-bridge.js +1 -1
  28. package/dist/protocol/tools.d.ts +36 -11
  29. package/dist/protocol/tools.js +747 -117
  30. package/dist/protocol/workspace-grounding.d.ts +17 -1
  31. package/dist/protocol/workspace-grounding.js +34 -5
  32. package/dist/session.d.ts +7 -1
  33. package/dist/shared.d.ts +3 -1
  34. package/dist/shared.js +3 -1
  35. package/dist/shell-timeout.d.ts +13 -0
  36. package/dist/shell-timeout.js +38 -0
  37. package/dist/todo-store.d.ts +29 -0
  38. package/dist/todo-store.js +78 -0
  39. package/dist/web-tools.d.ts +13 -0
  40. package/dist/web-tools.js +83 -5
  41. package/package.json +6 -3
@@ -6,10 +6,10 @@ 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
- import { appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
12
+ import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
13
13
  import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
14
14
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
15
15
  import { handleKvServerMessage } from "./protocol/kv.js";
@@ -18,6 +18,7 @@ import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestion
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 = {
@@ -373,6 +375,35 @@ function rememberPostCompactionRebase(sessionKey) {
373
375
  postCompactionRebaseBySession.delete(oldest);
374
376
  }
375
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
+ }
376
407
  function sentHistoryImageHashes(sessionKey) {
377
408
  if (!sessionKey)
378
409
  return undefined;
@@ -538,6 +569,12 @@ async function doStreamImpl(modelId, options, callOptions) {
538
569
  setActiveCursorMode(activeSession.openCodeSessionId, "plan");
539
570
  }
540
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
+ });
541
578
  }
542
579
  catch (e) {
543
580
  activeSession.pumpActive = false;
@@ -693,13 +730,25 @@ async function startSession(modelId, token, callOptions, options, startOptions)
693
730
  rememberPostCompactionRebase(sessionKey);
694
731
  if (restored?.toolCatalog.length)
695
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
+ }
696
739
  }
697
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;
698
744
  // The classic plugin marks OpenCode's agent="compaction" through chat.params.
699
- // OpenCode 2.0 removed that hook, so its plugin records the same fact against
700
- // the session id from session.hook("context") instead; consult both.
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.
701
749
  // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
702
- const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
750
+ const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
751
+ const isCompaction = compactionOption === true || (compactionOption === undefined && isCompactionSession(sessionKey));
703
752
  const toolState = await resolveTurnToolState({
704
753
  sessionKey,
705
754
  incomingTools,
@@ -718,14 +767,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
718
767
  }
719
768
  const allowTools = toolState.allowTools;
720
769
  const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
721
- const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
722
770
  let recovery = startOptions?.recovery;
723
771
  let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
724
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
+ });
725
792
  // Compaction must not reuse the prior conversation; its first normal turn
726
793
  // must also rebase so the summary-agent checkpoint cannot replace the normal
727
794
  // system prompt and OpenCode's newly compacted history.
728
- const lifecycle = !allowTools && !isCompaction && !recovery;
729
795
  let bound = resuming
730
796
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
731
797
  : bindConversationId(sessionKey, {
@@ -778,21 +844,19 @@ async function startSession(modelId, token, callOptions, options, startOptions)
778
844
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
779
845
  }
780
846
  const lastUser = [...prompt].reverse().find((message) => message.role === "user");
781
- const userText = recovery?.kind === "rebase"
847
+ let userText = recovery?.kind === "rebase"
782
848
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
783
849
  : (extractUserText(lastUser) || ".");
784
- // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
785
- // one plugin instance per project). OpenCode 2.0 runs one daemon across many
786
- // projects, so its static `options.workspaceRoot` is only a last-resort
787
- // fallback; `getSessionDirectory` carries the real per-session directory
788
- // recorded from `session.hook("context")` in `plugin-opencode2.ts`.
789
- const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
790
- const baseSystemPrompt = extractSystemPrompt(prompt);
791
- const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
792
850
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
793
851
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
794
852
  const startedWithCheckpoint = !!conversationState;
795
- const modeReminder = isCompaction || startedWithCheckpoint || lifecycle
853
+ if (startedWithCheckpoint) {
854
+ userText = groundCheckpointTurnText(userText, true, workspaceRoot, cursorTools);
855
+ }
856
+ const activeMode = getActiveCursorMode(sessionKey);
857
+ const nativePlanPromptOwnsMode = hostAgent === "plan"
858
+ && (activeMode === "plan" || activeMode === "spec");
859
+ const modeReminder = isCompaction || startedWithCheckpoint || lifecycle || nativePlanPromptOwnsMode
796
860
  ? undefined
797
861
  : takeActiveCursorModeReminder(sessionKey, {
798
862
  advertisedTools: cursorTools.map((tool) => tool.name),
@@ -983,6 +1047,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
983
1047
  stream.destroy();
984
1048
  throw error;
985
1049
  }
1050
+ const hostToolDialect = hostToolDialectFromTools(tools);
1051
+ trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
1052
+ `tools=[${tools.map((t) => t.name).join(",")}]`);
986
1053
  const session = {
987
1054
  sessionId: crypto.randomUUID(),
988
1055
  conversationId,
@@ -1009,6 +1076,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1009
1076
  execRequests: 0,
1010
1077
  },
1011
1078
  openCodeSessionId: lifecycle ? undefined : sessionKey,
1079
+ hostAgent,
1080
+ stableSystemPromptHash,
1012
1081
  postCompactionRebase: isCompaction,
1013
1082
  toolCatalog: snapshotToolCatalog(sessionKey),
1014
1083
  stream,
@@ -1024,6 +1093,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1024
1093
  blobs: new Map(),
1025
1094
  toolDescriptors,
1026
1095
  toolAliases: webToolAliases.aliases,
1096
+ hostToolDialect,
1027
1097
  subagentCatalog,
1028
1098
  requestContext,
1029
1099
  allowTools,
@@ -1216,7 +1286,7 @@ function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSes
1216
1286
  plan_uri: planUri,
1217
1287
  });
1218
1288
  }
1219
- function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1289
+ function buildSwitchModeContinuationFrame(pending, result) {
1220
1290
  const metadata = pending.resultMetadata ?? {};
1221
1291
  const interactionId = metadata.interactionId;
1222
1292
  if (typeof interactionId !== "number") {
@@ -1228,16 +1298,13 @@ function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1228
1298
  const answer = metadata.switchModeBridgeKind === "question"
1229
1299
  ? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
1230
1300
  : switchModeResultFromToolOutput(result.output, result.error !== undefined);
1231
- if ("approved" in answer) {
1232
- const target = metadata.switchModeTarget;
1233
- if (typeof target === "string" && target.trim()) {
1234
- const normalized = target.trim().toLowerCase();
1235
- setActiveCursorMode(sessionKey, target, {
1236
- bridgedPlanEntered: normalized === "plan" || normalized === "spec",
1237
- });
1238
- }
1239
- }
1240
- return buildSwitchModeInteractionReply(interactionId, answer);
1301
+ const target = "approved" in answer && typeof metadata.switchModeTarget === "string"
1302
+ ? metadata.switchModeTarget.trim()
1303
+ : "";
1304
+ return {
1305
+ frame: buildSwitchModeInteractionReply(interactionId, answer),
1306
+ ...(target ? { approvedTarget: target, bridgeKind: metadata.switchModeBridgeKind } : {}),
1307
+ };
1241
1308
  }
1242
1309
  /**
1243
1310
  * Deliver trailing tool results onto a live continuation session.
@@ -1265,6 +1332,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
1265
1332
  }
1266
1333
  const pending = claim.pending;
1267
1334
  let frames = [];
1335
+ let deliveredSwitchMode;
1268
1336
  if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
1269
1337
  // A bridged Cursor AskQuestion. The host tool result carries the user's
1270
1338
  // choices; translate them back into the Cursor result the interaction is
@@ -1284,7 +1352,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
1284
1352
  // approved{} or rejected{reason} on the still-open InteractionQuery.
1285
1353
  // Approval also arms the CLI-shaped mode reminder for the next Run.
1286
1354
  try {
1287
- frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
1355
+ const built = buildSwitchModeContinuationFrame(pending, r);
1356
+ frames = [built.frame];
1357
+ if (built.approvedTarget) {
1358
+ deliveredSwitchMode = {
1359
+ target: built.approvedTarget,
1360
+ bridgeKind: built.bridgeKind,
1361
+ };
1362
+ }
1288
1363
  }
1289
1364
  catch (error) {
1290
1365
  trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
@@ -1405,6 +1480,22 @@ export function deliverContinuationResults(session, trailingToolResults) {
1405
1480
  continue;
1406
1481
  return undefined;
1407
1482
  }
1483
+ if (deliveredSwitchMode) {
1484
+ const normalized = deliveredSwitchMode.target.toLowerCase();
1485
+ setActiveCursorMode(session.openCodeSessionId, deliveredSwitchMode.target, {
1486
+ bridgedPlanEntered: normalized === "plan" || normalized === "spec",
1487
+ });
1488
+ // A question-emulated exit has no native host plan tool to perform the
1489
+ // actual primary-agent switch. Queue only after Cursor received the
1490
+ // approved response; a failed write must not mutate host mode.
1491
+ if (deliveredSwitchMode.bridgeKind === "question" && session.openCodeSessionId) {
1492
+ queueHostAgentModeSwitch({
1493
+ sessionID: session.openCodeSessionId,
1494
+ targetModeID: deliveredSwitchMode.target,
1495
+ cursorSessionID: session.sessionId,
1496
+ });
1497
+ }
1498
+ }
1408
1499
  // A host `todoread` result is the authoritative list. Refresh the mirrored
1409
1500
  // snapshot (direct reads and bridged native reads alike) so later merges
1410
1501
  // apply onto host truth, not a stale write.
@@ -1686,7 +1777,7 @@ export async function pump(session, controller, ids, abortSignal) {
1686
1777
  const display = parseDisplayToolCall(displayCallId, stored);
1687
1778
  if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
1688
1779
  return false;
1689
- const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1780
+ const requestedPath = opencodePathArg(parsed.args) ?? "";
1690
1781
  const editPath = typeof display.args.path === "string" ? display.args.path : "";
1691
1782
  if (!requestedPath || !editPath)
1692
1783
  return false;
@@ -1784,7 +1875,7 @@ export async function pump(session, controller, ids, abortSignal) {
1784
1875
  const rejectMissingReadTarget = (parsed) => {
1785
1876
  if (parsed.toolName !== "read")
1786
1877
  return false;
1787
- const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1878
+ const requested = opencodePathArg(parsed.args) ?? "";
1788
1879
  if (!requested)
1789
1880
  return false;
1790
1881
  // A scheme-addressed target is the advertised host executor's to resolve,
@@ -2084,6 +2175,8 @@ export async function pump(session, controller, ids, abortSignal) {
2084
2175
  requestContext: session.requestContext,
2085
2176
  toolCatalog: session.toolCatalog ?? [],
2086
2177
  postCompactionRebase: session.postCompactionRebase,
2178
+ hostAgent: session.hostAgent,
2179
+ systemPromptHash: session.stableSystemPromptHash,
2087
2180
  }).catch((error) => {
2088
2181
  trace(`conversation persistence: TurnEnded save failed ` +
2089
2182
  `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
@@ -2312,7 +2405,7 @@ export async function pump(session, controller, ids, abortSignal) {
2312
2405
  else {
2313
2406
  replaySafety.markBarrier("non-control-exec");
2314
2407
  const displayCallId = extractExecDisplayCallId(esm);
2315
- const parsed = parseExecServerMessage(esm);
2408
+ const parsed = parseExecServerMessage(esm, session.hostToolDialect);
2316
2409
  if (parsed) {
2317
2410
  const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
2318
2411
  if (executableToolName !== parsed.toolName) {
@@ -2320,6 +2413,20 @@ export async function pump(session, controller, ids, abortSignal) {
2320
2413
  parsed.toolName = executableToolName;
2321
2414
  }
2322
2415
  remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
2416
+ // SubagentArgs has no description; Cursor's real title lives on the
2417
+ // correlated display TaskToolCall. Prefer that over the 5-word prompt slice.
2418
+ if (displayCallId && (parsed.toolName === "task" || parsed.toolName === "subagent")) {
2419
+ const stored = session.displayToolCalls.get(displayCallId);
2420
+ const display = parseDisplayToolCall(displayCallId, stored);
2421
+ if (display?.variant === "task_tool_call") {
2422
+ const description = typeof display.args.description === "string" ? display.args.description : undefined;
2423
+ preferCorrelatedTaskDescription(parsed, description);
2424
+ if (description?.trim()) {
2425
+ trace(`exec: preferred TaskToolCall description callId=${displayCallId} ` +
2426
+ `description=${JSON.stringify(description.trim())}`);
2427
+ }
2428
+ }
2429
+ }
2323
2430
  const editCall = displayCallId
2324
2431
  ? session.editToolCalls?.get(displayCallId)
2325
2432
  : undefined;
@@ -2605,9 +2712,17 @@ export async function pump(session, controller, ids, abortSignal) {
2605
2712
  setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
2606
2713
  bridgedPlanEntered: false,
2607
2714
  });
2715
+ const hostAgentSwitchQueued = session.openCodeSessionId
2716
+ ? queueHostAgentModeSwitch({
2717
+ sessionID: session.openCodeSessionId,
2718
+ targetModeID: sw.args.targetModeId,
2719
+ cursorSessionID: session.sessionId,
2720
+ })
2721
+ : false;
2608
2722
  trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
2609
2723
  `target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
2610
- `provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
2724
+ `${hostAgentSwitchQueued ? "native host-agent switch queued" : "provider-owned fallback"}) ` +
2725
+ `cursorToolCallId=${sw.toolCallId || "(none)"}`);
2611
2726
  continue;
2612
2727
  }
2613
2728
  if (handled.outcome === "bridged" && handled.switchMode) {
@@ -2822,6 +2937,18 @@ function extractSystemPrompt(prompt) {
2822
2937
  }
2823
2938
  return parts.length > 0 ? parts.join("\n\n") : undefined;
2824
2939
  }
2940
+ /**
2941
+ * Checkpointed Runs do not resend the system prompt. Keep the workspace root
2942
+ * on the live user message, and require absolute `path` arguments when that is
2943
+ * the host's file-tool dialect.
2944
+ */
2945
+ export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, tools) {
2946
+ if (!checkpoint)
2947
+ return userText;
2948
+ return appendCheckpointUserGrounding(userText, workspaceRoot, {
2949
+ requireAbsolutePathArg: hostToolDialectFromTools(tools).filePathKey === "path",
2950
+ });
2951
+ }
2825
2952
  /**
2826
2953
  * Cursor's native UI interactions cannot be surfaced through the AI SDK.
2827
2954
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
@@ -2875,8 +3002,14 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2875
3002
  if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
2876
3003
  instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
2877
3004
  }
2878
- if (names.has("task")) {
2879
- const target = "`task`";
3005
+ if (names.has("execute")) {
3006
+ const shell = names.has("shell") ? "`shell`" : names.has("bash") ? "`bash`" : undefined;
3007
+ instructions.push(shell
3008
+ ? `- OpenCode \`execute\` is Code Mode JavaScript (\`code\`); it is not a shell. For OS commands, call OpenCode ${shell}. Do not pass \`command\` to \`execute\`.`
3009
+ : "- OpenCode `execute` is Code Mode JavaScript (`code`); it is not a shell. Do not pass `command` to `execute`.");
3010
+ }
3011
+ if (names.has("task") || names.has("subagent")) {
3012
+ const target = names.has("task") ? "`task`" : "`subagent`";
2880
3013
  const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
2881
3014
  instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
2882
3015
  "Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
@@ -2897,6 +3030,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2897
3030
  // leaving the turn with no file-editing guidance at all.
2898
3031
  instructions.push("- Use OpenCode `apply_patch` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to `apply_patch` automatically.");
2899
3032
  }
3033
+ if (hostToolDialectFromTools(tools).filePathKey === "path") {
3034
+ instructions.push("- OpenCode file tools take `path` as an absolute path under the workspace root above. Do not pass a project-relative path, and do not invent a different absolute prefix.");
3035
+ }
2900
3036
  if (names.has("edit") || names.has("write") || names.has("apply_patch")) {
2901
3037
  instructions.push("- Never use a read result as complete file content when it says the output is capped, partial, or requires another offset. Read the remaining ranges first, or make a targeted edit/patch from complete context; do not pass a partial read back as a whole-file replacement.");
2902
3038
  }
@@ -3143,9 +3279,25 @@ export function resolveTurnConversationReset(input) {
3143
3279
  rememberPostCompactionRebase(sessionKey);
3144
3280
  return { reset: true, reason: "compaction" };
3145
3281
  }
3282
+ let promptChange;
3283
+ if (sessionKey && input.promptIdentity) {
3284
+ const current = normalizePromptIdentity(input.promptIdentity);
3285
+ const previous = promptIdentityBySession.get(sessionKey);
3286
+ if (previous && promptIdentityChanged(previous, current)) {
3287
+ promptChange = current.hostAgent !== undefined && previous.hostAgent !== current.hostAgent
3288
+ ? "agent-change"
3289
+ : "system-prompt-change";
3290
+ }
3291
+ rememberPromptIdentity(sessionKey, {
3292
+ ...previous,
3293
+ ...current,
3294
+ });
3295
+ }
3146
3296
  if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
3147
3297
  return { reset: true, reason: "post-compaction-rebase" };
3148
3298
  }
3299
+ if (promptChange)
3300
+ return { reset: true, reason: promptChange };
3149
3301
  return { reset: false };
3150
3302
  }
3151
3303
  export function resetTurnStateForTests() {
@@ -3156,6 +3308,8 @@ export function resetTurnStateForTests() {
3156
3308
  toolCatalogWaitersBySession.clear();
3157
3309
  toolCatalogBySession.clear();
3158
3310
  postCompactionRebaseBySession.clear();
3311
+ promptIdentityBySession.clear();
3312
+ mirroredTodosBySession.clear();
3159
3313
  }
3160
3314
  function extractUserText(lastUser) {
3161
3315
  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[]>): 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
- return existing;
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, options));
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
  }
@@ -1,8 +1,9 @@
1
1
  import { type ModelInfo } from "../models.js";
2
- import type { CatalogDraft } from "./types.js";
2
+ import { type OpenCode2ModelCost } from "../pricing.js";
3
+ import type { ConnectionInfo, ModelVariantInfo, ProviderEditor } from "./types.js";
3
4
  /**
4
- * Catalog registration for the OpenCode 2.0 plugin — the replacement for the
5
- * classic plugin's `config` hook.
5
+ * In-memory provider registration for the OpenCode 2.0 plugin — the replacement
6
+ * for the classic plugin's `config` hook.
6
7
  *
7
8
  * Model naming, thinking suffixes, and long-context tiering are NOT reimplemented
8
9
  * here: we run the shared `modelsToConfig` and translate its output into the 2.0
@@ -14,10 +15,10 @@ export declare const CURSOR_INTEGRATION_ID = "cursor";
14
15
  * `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the
15
16
  * `aisdk.hook("sdk")` / `("language")` extension points we supply the provider
16
17
  * through. The suffix is this package's npm name so the host's built-in
17
- * `DynamicProviderPlugin` can still resolve it if our own hook is ever
18
- * bypassed — that fallback runs `npm.add(pkg)` against the *published*
19
- * registry into `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring
20
- * any local `file://` plugin path this process was loaded from.
18
+ * fallback can still resolve it if our own hook is ever bypassed — that
19
+ * fallback runs `npm.add(pkg)` against the *published* registry into
20
+ * `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring any local
21
+ * `file://` plugin path this process was loaded from.
21
22
  *
22
23
  * `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…`
23
24
  * spec instead, pointed at a local built entry file (e.g. `dist/index.js`,
@@ -27,7 +28,41 @@ export declare const CURSOR_INTEGRATION_ID = "cursor";
27
28
  * Unset in production; only meant for local `opencode2 run` testing.
28
29
  */
29
30
  export declare const CURSOR_AISDK_PACKAGE: string;
30
- /** Register (or update) the Cursor provider entry. `update` is an upsert. */
31
- export declare function applyCursorProvider(draft: CatalogDraft): void;
32
- /** Register every discovered Cursor model. Safe to re-run; `update` upserts. */
33
- export declare function applyCursorModels(draft: CatalogDraft, models: ModelInfo[]): void;
31
+ /**
32
+ * Plain-object `Model.Info` equivalent used by `ctx.provider.transform`.
33
+ */
34
+ export type CatalogModelInfo = {
35
+ id: string;
36
+ modelID: string;
37
+ providerID: string;
38
+ name: string;
39
+ capabilities: {
40
+ tools: boolean;
41
+ input: string[];
42
+ output: string[];
43
+ };
44
+ limit: {
45
+ context: number;
46
+ output: number;
47
+ };
48
+ variants: ModelVariantInfo[];
49
+ status: "active";
50
+ enabled: true;
51
+ time: {
52
+ released: number;
53
+ };
54
+ cost: OpenCode2ModelCost[];
55
+ settings?: Record<string, unknown>;
56
+ };
57
+ /** Translate one `modelsToConfig` entry into the 2.0 `Model.Info` shape. */
58
+ export declare function modelConfigEntryToInfo(id: string, entry: Record<string, any>): CatalogModelInfo;
59
+ /** Full model map for the in-memory provider inventory. */
60
+ export declare function modelsToCatalogModelMap(models: ModelInfo[]): Record<string, CatalogModelInfo>;
61
+ /**
62
+ * Publish discovered Cursor models into the live provider inventory.
63
+ *
64
+ * Skip while empty (keeps the last successful inventory through a no-op
65
+ * transform on first register), replace the definition with `editor.add`,
66
+ * then `ctx.provider.reload()`.
67
+ */
68
+ export declare function applyCursorProviderInventory(editor: ProviderEditor, models: ModelInfo[], sourceConnection?: ConnectionInfo): void;