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.
Files changed (42) hide show
  1. package/README.md +52 -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 +10 -1
  7. package/dist/language-model.js +253 -41
  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/messages.js +14 -1
  26. package/dist/protocol/switch-mode.d.ts +5 -4
  27. package/dist/protocol/switch-mode.js +12 -4
  28. package/dist/protocol/tool-call-bridge.d.ts +29 -1
  29. package/dist/protocol/tool-call-bridge.js +187 -7
  30. package/dist/protocol/tools.d.ts +30 -4
  31. package/dist/protocol/tools.js +185 -58
  32. package/dist/session.d.ts +13 -1
  33. package/dist/session.js +1 -0
  34. package/dist/shared.d.ts +3 -1
  35. package/dist/shared.js +3 -1
  36. package/dist/shell-timeout.d.ts +13 -0
  37. package/dist/shell-timeout.js +38 -0
  38. package/dist/todo-store.d.ts +29 -0
  39. package/dist/todo-store.js +78 -0
  40. package/dist/web-tools.d.ts +13 -0
  41. package/dist/web-tools.js +83 -5
  42. package/package.json +6 -3
@@ -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 records the same fact against
661
- // 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.
662
749
  // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
663
- 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));
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 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
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, sessionKey) {
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
- if ("approved" in answer) {
1189
- const target = metadata.switchModeTarget;
1190
- if (typeof target === "string" && target.trim()) {
1191
- const normalized = target.trim().toLowerCase();
1192
- setActiveCursorMode(sessionKey, target, {
1193
- bridgedPlanEntered: normalized === "plan" || normalized === "spec",
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
- 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
+ }
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 = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
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 = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
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
- `provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
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("task")) {
2803
- 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`";
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[]>): 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
  }