cursor-opencode-provider 0.6.8 → 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.
Files changed (39) hide show
  1. package/README.md +50 -180
  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 +6 -1
  7. package/dist/language-model.js +170 -34
  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 +30 -4
  29. package/dist/protocol/tools.js +185 -58
  30. package/dist/session.d.ts +7 -1
  31. package/dist/shared.d.ts +3 -1
  32. package/dist/shared.js +3 -1
  33. package/dist/shell-timeout.d.ts +13 -0
  34. package/dist/shell-timeout.js +38 -0
  35. package/dist/todo-store.d.ts +29 -0
  36. package/dist/todo-store.js +78 -0
  37. package/dist/web-tools.d.ts +13 -0
  38. package/dist/web-tools.js +83 -5
  39. package/package.json +6 -3
@@ -6,7 +6,7 @@ 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";
@@ -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, {
@@ -781,18 +847,13 @@ async function startSession(modelId, token, callOptions, options, startOptions)
781
847
  const 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
+ const activeMode = getActiveCursorMode(sessionKey);
854
+ const nativePlanPromptOwnsMode = hostAgent === "plan"
855
+ && (activeMode === "plan" || activeMode === "spec");
856
+ const modeReminder = isCompaction || startedWithCheckpoint || lifecycle || nativePlanPromptOwnsMode
796
857
  ? undefined
797
858
  : takeActiveCursorModeReminder(sessionKey, {
798
859
  advertisedTools: cursorTools.map((tool) => tool.name),
@@ -983,6 +1044,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
983
1044
  stream.destroy();
984
1045
  throw error;
985
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(",")}]`);
986
1050
  const session = {
987
1051
  sessionId: crypto.randomUUID(),
988
1052
  conversationId,
@@ -1009,6 +1073,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1009
1073
  execRequests: 0,
1010
1074
  },
1011
1075
  openCodeSessionId: lifecycle ? undefined : sessionKey,
1076
+ hostAgent,
1077
+ stableSystemPromptHash,
1012
1078
  postCompactionRebase: isCompaction,
1013
1079
  toolCatalog: snapshotToolCatalog(sessionKey),
1014
1080
  stream,
@@ -1024,6 +1090,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1024
1090
  blobs: new Map(),
1025
1091
  toolDescriptors,
1026
1092
  toolAliases: webToolAliases.aliases,
1093
+ hostToolDialect,
1027
1094
  subagentCatalog,
1028
1095
  requestContext,
1029
1096
  allowTools,
@@ -1216,7 +1283,7 @@ function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSes
1216
1283
  plan_uri: planUri,
1217
1284
  });
1218
1285
  }
1219
- function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1286
+ function buildSwitchModeContinuationFrame(pending, result) {
1220
1287
  const metadata = pending.resultMetadata ?? {};
1221
1288
  const interactionId = metadata.interactionId;
1222
1289
  if (typeof interactionId !== "number") {
@@ -1228,16 +1295,13 @@ function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1228
1295
  const answer = metadata.switchModeBridgeKind === "question"
1229
1296
  ? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
1230
1297
  : 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);
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
+ };
1241
1305
  }
1242
1306
  /**
1243
1307
  * Deliver trailing tool results onto a live continuation session.
@@ -1265,6 +1329,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
1265
1329
  }
1266
1330
  const pending = claim.pending;
1267
1331
  let frames = [];
1332
+ let deliveredSwitchMode;
1268
1333
  if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
1269
1334
  // A bridged Cursor AskQuestion. The host tool result carries the user's
1270
1335
  // choices; translate them back into the Cursor result the interaction is
@@ -1284,7 +1349,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
1284
1349
  // approved{} or rejected{reason} on the still-open InteractionQuery.
1285
1350
  // Approval also arms the CLI-shaped mode reminder for the next Run.
1286
1351
  try {
1287
- frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
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
+ }
1288
1360
  }
1289
1361
  catch (error) {
1290
1362
  trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
@@ -1405,6 +1477,22 @@ export function deliverContinuationResults(session, trailingToolResults) {
1405
1477
  continue;
1406
1478
  return undefined;
1407
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
+ }
1408
1496
  // A host `todoread` result is the authoritative list. Refresh the mirrored
1409
1497
  // snapshot (direct reads and bridged native reads alike) so later merges
1410
1498
  // apply onto host truth, not a stale write.
@@ -1686,7 +1774,7 @@ export async function pump(session, controller, ids, abortSignal) {
1686
1774
  const display = parseDisplayToolCall(displayCallId, stored);
1687
1775
  if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
1688
1776
  return false;
1689
- const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1777
+ const requestedPath = opencodePathArg(parsed.args) ?? "";
1690
1778
  const editPath = typeof display.args.path === "string" ? display.args.path : "";
1691
1779
  if (!requestedPath || !editPath)
1692
1780
  return false;
@@ -1784,7 +1872,7 @@ export async function pump(session, controller, ids, abortSignal) {
1784
1872
  const rejectMissingReadTarget = (parsed) => {
1785
1873
  if (parsed.toolName !== "read")
1786
1874
  return false;
1787
- const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1875
+ const requested = opencodePathArg(parsed.args) ?? "";
1788
1876
  if (!requested)
1789
1877
  return false;
1790
1878
  // A scheme-addressed target is the advertised host executor's to resolve,
@@ -2084,6 +2172,8 @@ export async function pump(session, controller, ids, abortSignal) {
2084
2172
  requestContext: session.requestContext,
2085
2173
  toolCatalog: session.toolCatalog ?? [],
2086
2174
  postCompactionRebase: session.postCompactionRebase,
2175
+ hostAgent: session.hostAgent,
2176
+ systemPromptHash: session.stableSystemPromptHash,
2087
2177
  }).catch((error) => {
2088
2178
  trace(`conversation persistence: TurnEnded save failed ` +
2089
2179
  `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
@@ -2312,7 +2402,7 @@ export async function pump(session, controller, ids, abortSignal) {
2312
2402
  else {
2313
2403
  replaySafety.markBarrier("non-control-exec");
2314
2404
  const displayCallId = extractExecDisplayCallId(esm);
2315
- const parsed = parseExecServerMessage(esm);
2405
+ const parsed = parseExecServerMessage(esm, session.hostToolDialect);
2316
2406
  if (parsed) {
2317
2407
  const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
2318
2408
  if (executableToolName !== parsed.toolName) {
@@ -2320,6 +2410,20 @@ export async function pump(session, controller, ids, abortSignal) {
2320
2410
  parsed.toolName = executableToolName;
2321
2411
  }
2322
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
+ }
2323
2427
  const editCall = displayCallId
2324
2428
  ? session.editToolCalls?.get(displayCallId)
2325
2429
  : undefined;
@@ -2605,9 +2709,17 @@ export async function pump(session, controller, ids, abortSignal) {
2605
2709
  setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
2606
2710
  bridgedPlanEntered: false,
2607
2711
  });
2712
+ const hostAgentSwitchQueued = session.openCodeSessionId
2713
+ ? queueHostAgentModeSwitch({
2714
+ sessionID: session.openCodeSessionId,
2715
+ targetModeID: sw.args.targetModeId,
2716
+ cursorSessionID: session.sessionId,
2717
+ })
2718
+ : false;
2608
2719
  trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
2609
2720
  `target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
2610
- `provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
2721
+ `${hostAgentSwitchQueued ? "native host-agent switch queued" : "provider-owned fallback"}) ` +
2722
+ `cursorToolCallId=${sw.toolCallId || "(none)"}`);
2611
2723
  continue;
2612
2724
  }
2613
2725
  if (handled.outcome === "bridged" && handled.switchMode) {
@@ -2875,8 +2987,14 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2875
2987
  if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
2876
2988
  instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
2877
2989
  }
2878
- if (names.has("task")) {
2879
- const target = "`task`";
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`";
2880
2998
  const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
2881
2999
  instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
2882
3000
  "Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
@@ -3143,9 +3261,25 @@ export function resolveTurnConversationReset(input) {
3143
3261
  rememberPostCompactionRebase(sessionKey);
3144
3262
  return { reset: true, reason: "compaction" };
3145
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
+ }
3146
3278
  if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
3147
3279
  return { reset: true, reason: "post-compaction-rebase" };
3148
3280
  }
3281
+ if (promptChange)
3282
+ return { reset: true, reason: promptChange };
3149
3283
  return { reset: false };
3150
3284
  }
3151
3285
  export function resetTurnStateForTests() {
@@ -3156,6 +3290,8 @@ export function resetTurnStateForTests() {
3156
3290
  toolCatalogWaitersBySession.clear();
3157
3291
  toolCatalogBySession.clear();
3158
3292
  postCompactionRebaseBySession.clear();
3293
+ promptIdentityBySession.clear();
3294
+ mirroredTodosBySession.clear();
3159
3295
  }
3160
3296
  function extractUserText(lastUser) {
3161
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[]>): 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;