cursor-opencode-provider 0.5.0 → 0.6.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 (53) hide show
  1. package/README.md +22 -13
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +3 -1
  6. package/dist/context/build.js +4 -2
  7. package/dist/context/index.d.ts +1 -1
  8. package/dist/context/index.js +1 -1
  9. package/dist/context/paths.d.ts +34 -53
  10. package/dist/context/paths.js +54 -118
  11. package/dist/context/plugins.js +1 -0
  12. package/dist/context/rules.js +2 -1
  13. package/dist/context/skills.js +1 -0
  14. package/dist/image-save-tool.d.ts +19 -0
  15. package/dist/image-save-tool.js +25 -0
  16. package/dist/image-save.d.ts +58 -0
  17. package/dist/image-save.js +189 -0
  18. package/dist/image-staging.d.ts +59 -0
  19. package/dist/image-staging.js +91 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/language-model.d.ts +3 -2
  22. package/dist/language-model.js +589 -41
  23. package/dist/plan-execution-kickoff.d.ts +55 -0
  24. package/dist/plan-execution-kickoff.js +162 -0
  25. package/dist/plugin-opencode2.js +52 -3
  26. package/dist/plugin-v2.d.ts +11 -9
  27. package/dist/plugin-v2.js +6 -13
  28. package/dist/plugin.d.ts +9 -0
  29. package/dist/plugin.js +61 -5
  30. package/dist/pricing-data.d.ts +16 -4
  31. package/dist/pricing-data.js +16 -4
  32. package/dist/protocol/ask-question.d.ts +133 -0
  33. package/dist/protocol/ask-question.js +320 -0
  34. package/dist/protocol/conversation-bind.d.ts +1 -0
  35. package/dist/protocol/conversation-bind.js +3 -0
  36. package/dist/protocol/create-plan.d.ts +141 -0
  37. package/dist/protocol/create-plan.js +373 -0
  38. package/dist/protocol/generate-image.d.ts +54 -0
  39. package/dist/protocol/generate-image.js +101 -0
  40. package/dist/protocol/interactions.d.ts +110 -8
  41. package/dist/protocol/interactions.js +295 -35
  42. package/dist/protocol/messages.js +118 -6
  43. package/dist/protocol/struct.js +1 -1
  44. package/dist/protocol/switch-mode.d.ts +178 -0
  45. package/dist/protocol/switch-mode.js +417 -0
  46. package/dist/protocol/tool-call-bridge.js +10 -5
  47. package/dist/protocol/tools.d.ts +27 -11
  48. package/dist/protocol/tools.js +98 -42
  49. package/dist/usage.d.ts +2 -2
  50. package/dist/usage.js +2 -2
  51. package/dist/web-search-tool.d.ts +22 -26
  52. package/dist/web-search-tool.js +25 -15
  53. package/package.json +7 -4
@@ -6,11 +6,18 @@ 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, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, 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, 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";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
13
- import { handleInteractionQuery } from "./protocol/interactions.js";
13
+ import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
14
+ import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
15
+ import { SWITCH_MODE_RESULT_FIELD, getActiveCursorMode, isBridgedCursorPlanModeActive, isCursorPlanModeActive, setActiveCursorMode, switchModeResultFromQuestionOutput, switchModeResultFromToolOutput, switchModeToolInput, takeActiveCursorModeReminder, } from "./protocol/switch-mode.js";
16
+ import { CREATE_PLAN_NOT_APPROVED_REASON, CREATE_PLAN_RESULT_FIELD, CURSOR_PLAN_STAGE_TOOL, createPlanApproved, createPlanStageInput, } from "./protocol/create-plan.js";
17
+ import { flushPlanExecutionKickoff, formatPlanKickoffPath, hasPlanExecutionKickoff, planExecutionKickoffState, planPathFromUri, queuePlanExecutionKickoff, takePlanExecutionKickoffWarning, } from "./plan-execution-kickoff.js";
18
+ import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
19
+ import { stageCursorImage } from "./image-staging.js";
20
+ import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
14
21
  import { clearCheckpoint, getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
15
22
  import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
16
23
  import { clearConversationBlobs, conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
@@ -22,7 +29,7 @@ import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, Cur
22
29
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
23
30
  import { getOrBuildRequestContext } from "./context/frozen.js";
24
31
  import { workspaceRootFromRequestContext } from "./context/env.js";
25
- import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
32
+ import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
26
33
  import { resolveAgentUrl } from "./agent-url.js";
27
34
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
28
35
  import { isCompactionSession } from "./compaction-marker.js";
@@ -243,9 +250,61 @@ export function connectFrameError(payload) {
243
250
  return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
244
251
  }
245
252
  }
253
+ const toolCatalogWaitersBySession = new Map();
254
+ function publishToolCatalogWaiters(sessionKey, tools) {
255
+ const waiters = toolCatalogWaitersBySession.get(sessionKey);
256
+ if (!waiters || waiters.size === 0)
257
+ return;
258
+ toolCatalogWaitersBySession.delete(sessionKey);
259
+ for (const waiter of waiters)
260
+ waiter.resolve(tools);
261
+ }
262
+ function waitForSiblingToolCatalog(sessionKey, signal) {
263
+ const existing = toolCatalogBySession.get(sessionKey);
264
+ if (existing && existing.length > 0) {
265
+ return Promise.resolve(structuredClone(existing));
266
+ }
267
+ if (signal?.aborted) {
268
+ return Promise.reject(new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
269
+ }
270
+ return new Promise((resolve, reject) => {
271
+ let settled = false;
272
+ let set = toolCatalogWaitersBySession.get(sessionKey);
273
+ const finish = (tools, error) => {
274
+ if (settled)
275
+ return;
276
+ settled = true;
277
+ set?.delete(waiter);
278
+ if (set?.size === 0)
279
+ toolCatalogWaitersBySession.delete(sessionKey);
280
+ signal?.removeEventListener("abort", onAbort);
281
+ if (error)
282
+ reject(error);
283
+ else
284
+ resolve(structuredClone(tools));
285
+ };
286
+ const onAbort = () => finish(undefined, new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
287
+ const waiter = {
288
+ resolve: (tools) => finish(tools),
289
+ cancel: onAbort,
290
+ };
291
+ if (!set) {
292
+ set = new Set();
293
+ toolCatalogWaitersBySession.set(sessionKey, set);
294
+ }
295
+ set.add(waiter);
296
+ signal?.addEventListener("abort", onAbort, { once: true });
297
+ // Re-check after enqueue: a sibling may have published between the initial
298
+ // map lookup and waiter registration.
299
+ const raced = toolCatalogBySession.get(sessionKey);
300
+ if (raced && raced.length > 0)
301
+ finish(raced);
302
+ });
303
+ }
246
304
  function rememberToolCatalog(sessionKey, tools) {
247
305
  toolCatalogBySession.delete(sessionKey);
248
306
  toolCatalogBySession.set(sessionKey, structuredClone(tools));
307
+ publishToolCatalogWaiters(sessionKey, tools);
249
308
  while (toolCatalogBySession.size > MAX_TURN_STATE_SESSIONS) {
250
309
  const oldest = toolCatalogBySession.keys().next().value;
251
310
  if (!oldest)
@@ -422,6 +481,23 @@ async function doStreamImpl(modelId, options, callOptions) {
422
481
  catch (e) {
423
482
  trace(`pull: close failed (already closed/cancelled) err=${e.message}`);
424
483
  }
484
+ // pumpWithRecovery has returned only after the Run reached terminal
485
+ // turn_ended and endPump cleared ownership. This is the first safe
486
+ // point to start a second OpenCode generation. A failed prior handoff
487
+ // is retried only because this explicit new provider turn reached its
488
+ // own terminal boundary — never from a timer or tight loop.
489
+ const kickoff = await flushPlanExecutionKickoff(activeSession.openCodeSessionId, {
490
+ cursorSessionID: activeSession.sessionId,
491
+ terminal: activeSession.closed,
492
+ pumpActive: activeSession.pumpActive || activeSession.pumpOwner != null,
493
+ pendingExecs: activeSession.pending.size,
494
+ });
495
+ if (!kickoff) {
496
+ const state = planExecutionKickoffState(activeSession.openCodeSessionId);
497
+ if (state?.status === "failed") {
498
+ setActiveCursorMode(activeSession.openCodeSessionId, "plan");
499
+ }
500
+ }
425
501
  }
426
502
  catch (e) {
427
503
  activeSession.pumpActive = false;
@@ -524,11 +600,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
524
600
  // the session id from session.hook("context") instead; consult both.
525
601
  // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
526
602
  const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
527
- const toolState = resolveTurnToolState({
603
+ const toolState = await resolveTurnToolState({
528
604
  sessionKey,
529
605
  incomingTools,
530
606
  toolChoice: callOptions.toolChoice,
531
607
  isCompaction,
608
+ abortSignal: callOptions.abortSignal,
532
609
  });
533
610
  const tools = toolState.advertisedTools;
534
611
  const webToolAliases = buildCustomWebToolAliases(tools);
@@ -548,12 +625,18 @@ async function startSession(modelId, token, callOptions, options, startOptions)
548
625
  // Compaction must not reuse the prior conversation; its first normal turn
549
626
  // must also rebase so the summary-agent checkpoint cannot replace the normal
550
627
  // system prompt and OpenCode's newly compacted history.
628
+ const lifecycle = !allowTools && !isCompaction && !recovery;
551
629
  let bound = resuming
552
630
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
553
- : bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
554
- let conversationState = resuming
555
- ? resumeRecovery.checkpoint
556
- : (bound.reset ? undefined : getCheckpoint(bound.conversationId));
631
+ : bindConversationId(sessionKey, {
632
+ reset: resetState.reset || recovery?.kind === "rebase",
633
+ ephemeral: lifecycle,
634
+ });
635
+ let conversationState = lifecycle
636
+ ? undefined
637
+ : resuming
638
+ ? resumeRecovery.checkpoint
639
+ : (bound.reset ? undefined : getCheckpoint(bound.conversationId));
557
640
  let checkpointGraph = conversationState
558
641
  ? inspectConversationBlobGraph(bound.conversationId, conversationState)
559
642
  : { count: 0, bytes: 0, complete: true };
@@ -606,9 +689,25 @@ async function startSession(modelId, token, callOptions, options, startOptions)
606
689
  const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
607
690
  const baseSystemPrompt = extractSystemPrompt(prompt);
608
691
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
609
- const systemPrompt = interactionGuidance
610
- ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
611
- : baseSystemPrompt;
692
+ // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
693
+ // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
694
+ const startedWithCheckpoint = !!conversationState;
695
+ const modeReminder = isCompaction || startedWithCheckpoint || lifecycle
696
+ ? undefined
697
+ : takeActiveCursorModeReminder(sessionKey, {
698
+ advertisedTools: cursorTools.map((tool) => tool.name),
699
+ });
700
+ const kickoffWarning = isCompaction || startedWithCheckpoint || lifecycle
701
+ ? undefined
702
+ : takePlanExecutionKickoffWarning(sessionKey);
703
+ const systemPrompt = [
704
+ baseSystemPrompt,
705
+ interactionGuidance,
706
+ modeReminder,
707
+ kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
708
+ ]
709
+ .filter(Boolean)
710
+ .join("\n\n");
612
711
  const history = extractPromptHistory(prompt, {
613
712
  preserveTrailingUser: recovery?.kind === "rebase",
614
713
  toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
@@ -806,7 +905,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
806
905
  displayToolCalls: 0,
807
906
  execRequests: 0,
808
907
  },
809
- openCodeSessionId: sessionKey,
908
+ openCodeSessionId: lifecycle ? undefined : sessionKey,
810
909
  postCompactionRebase: isCompaction,
811
910
  toolCatalog: snapshotToolCatalog(sessionKey),
812
911
  stream,
@@ -886,6 +985,124 @@ export function findContinuationSession(toolResults) {
886
985
  }
887
986
  return undefined;
888
987
  }
988
+ /**
989
+ * Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
990
+ *
991
+ * A synchronous AskQuestion still has its InteractionQuery open, so the answer
992
+ * goes back as the InteractionResponse for that query id. A `run_async` one was
993
+ * already released with `async{}`, so — exactly as Cursor CLI does — the answer
994
+ * travels as a ConversationAction keyed by the originating tool call id, with
995
+ * the server's own AskQuestionArgs bytes echoed back untouched.
996
+ */
997
+ function buildAskQuestionContinuationFrame(pending, result) {
998
+ const metadata = pending.resultMetadata ?? {};
999
+ const args = metadata.askQuestionArgs;
1000
+ if (!args)
1001
+ throw new CursorProtocolError("Bridged AskQuestion lost its decoded arguments");
1002
+ const answer = askQuestionResultFromToolOutput(args, result.output, result.error !== undefined);
1003
+ if (!args.runAsync) {
1004
+ const interactionId = metadata.interactionId;
1005
+ if (typeof interactionId !== "number") {
1006
+ throw new CursorProtocolError("Bridged AskQuestion lost its interaction id");
1007
+ }
1008
+ return buildAskQuestionInteractionReply(interactionId, answer);
1009
+ }
1010
+ const rawArgs = metadata.askQuestionRawArgs;
1011
+ if (!(rawArgs instanceof Uint8Array)) {
1012
+ throw new CursorProtocolError("Bridged async AskQuestion lost its original arguments");
1013
+ }
1014
+ const toolCallId = metadata.askQuestionToolCallId;
1015
+ return buildAsyncAskQuestionCompletion(typeof toolCallId === "string" ? toolCallId : "", rawArgs, answer);
1016
+ }
1017
+ /**
1018
+ * Turn the host's plan-approval outcome into the CreatePlan reply Cursor is
1019
+ * still blocking on. One contract across every channel: `success` means the
1020
+ * user approved execution, `error` means the plan was written but not accepted,
1021
+ * so the model keeps planning instead of starting work.
1022
+ *
1023
+ * A host plan-stage tool reports that outcome as tool success/failure. The
1024
+ * emulated path asks through `question`, so only an explicit "Yes" approves.
1025
+ * On approval the session leaves plan mode, which arms the agent-mode
1026
+ * `<system_reminder>` for the next Run.
1027
+ */
1028
+ function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSessionID) {
1029
+ const metadata = pending.resultMetadata ?? {};
1030
+ const interactionId = metadata.interactionId;
1031
+ if (typeof interactionId !== "number") {
1032
+ throw new CursorProtocolError("Bridged CreatePlan lost its interaction id");
1033
+ }
1034
+ const planUri = metadata.planUri;
1035
+ if (typeof planUri !== "string" || !planUri.trim()) {
1036
+ throw new CursorProtocolError("Bridged CreatePlan lost its plan URI");
1037
+ }
1038
+ const approved = metadata.createPlanBridgeKind === "approve"
1039
+ ? createPlanApproved(result.output, result.error !== undefined, typeof metadata.createPlanQuestion === "string" ? metadata.createPlanQuestion : "")
1040
+ : result.error === undefined;
1041
+ if (!approved) {
1042
+ const reason = result.error?.trim();
1043
+ return buildCreatePlanInteractionReply(interactionId, {
1044
+ error: { error: reason || CREATE_PLAN_NOT_APPROVED_REASON },
1045
+ plan_uri: "",
1046
+ });
1047
+ }
1048
+ // The question-emulated path can report execution approval only when a host
1049
+ // kickoff exists. Otherwise keep planning mode active and return an error.
1050
+ if (metadata.createPlanBridgeKind === "approve" && !hasPlanExecutionKickoff()) {
1051
+ return buildCreatePlanInteractionReply(interactionId, {
1052
+ error: { error: "The plan was approved, but this host cannot start its execution turn." },
1053
+ plan_uri: planUri,
1054
+ });
1055
+ }
1056
+ // Approved: planning is over for this plan, so drop the plan-mode reminder
1057
+ // and hand the next Run the agent-mode contract, exactly as an approved
1058
+ // plan_exit does.
1059
+ setActiveCursorMode(sessionKey, "agent");
1060
+ // Only the question-emulated path needs the provider's synthetic build turn.
1061
+ // A host stage tool owns both approval and execution handoff itself.
1062
+ if (metadata.createPlanBridgeKind === "approve" && sessionKey) {
1063
+ const absolute = typeof metadata.planPath === "string" && metadata.planPath.trim()
1064
+ ? metadata.planPath.trim()
1065
+ : planPathFromUri(planUri);
1066
+ const workspaceRoot = typeof metadata.workspaceRoot === "string" ? metadata.workspaceRoot : undefined;
1067
+ if (!queuePlanExecutionKickoff({
1068
+ sessionID: sessionKey,
1069
+ planPath: formatPlanKickoffPath(absolute, workspaceRoot),
1070
+ cursorSessionID,
1071
+ })) {
1072
+ return buildCreatePlanInteractionReply(interactionId, {
1073
+ error: { error: "The plan was approved, but this host cannot start its execution turn." },
1074
+ plan_uri: planUri,
1075
+ });
1076
+ }
1077
+ }
1078
+ return buildCreatePlanInteractionReply(interactionId, {
1079
+ success: {},
1080
+ plan_uri: planUri,
1081
+ });
1082
+ }
1083
+ function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1084
+ const metadata = pending.resultMetadata ?? {};
1085
+ const interactionId = metadata.interactionId;
1086
+ if (typeof interactionId !== "number") {
1087
+ throw new CursorProtocolError("Bridged SwitchMode lost its interaction id");
1088
+ }
1089
+ // A question-emulated exit carries the user's Yes/No as prose, not a plan
1090
+ // tool's success/failure, so it needs the answer parser rather than the
1091
+ // error-shaped mapping.
1092
+ const answer = metadata.switchModeBridgeKind === "question"
1093
+ ? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
1094
+ : switchModeResultFromToolOutput(result.output, result.error !== undefined);
1095
+ if ("approved" in answer) {
1096
+ const target = metadata.switchModeTarget;
1097
+ if (typeof target === "string" && target.trim()) {
1098
+ const normalized = target.trim().toLowerCase();
1099
+ setActiveCursorMode(sessionKey, target, {
1100
+ bridgedPlanEntered: normalized === "plan" || normalized === "spec",
1101
+ });
1102
+ }
1103
+ }
1104
+ return buildSwitchModeInteractionReply(interactionId, answer);
1105
+ }
889
1106
  /**
890
1107
  * Deliver trailing tool results onto a live continuation session.
891
1108
  * Returns the same session when writes succeed (or only bridged results were
@@ -912,7 +1129,44 @@ export function deliverContinuationResults(session, trailingToolResults) {
912
1129
  }
913
1130
  const pending = claim.pending;
914
1131
  let frames = [];
915
- if (!pending.bridged) {
1132
+ if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
1133
+ // A bridged Cursor AskQuestion. The host tool result carries the user's
1134
+ // choices; translate them back into the Cursor result the interaction is
1135
+ // waiting for (or into the deferred completion, when Cursor was already
1136
+ // released with `async`).
1137
+ try {
1138
+ frames = [buildAskQuestionContinuationFrame(pending, r)];
1139
+ }
1140
+ catch (error) {
1141
+ trace(`continuation: ask_question encode FAILED execId=${r.execId} err=${error.message}`);
1142
+ sessionManager.close(session, "result-write-failed");
1143
+ return undefined;
1144
+ }
1145
+ }
1146
+ else if (pending.resultField === SWITCH_MODE_RESULT_FIELD) {
1147
+ // Bridged SwitchMode: host plan_enter / plan_exit outcome becomes
1148
+ // approved{} or rejected{reason} on the still-open InteractionQuery.
1149
+ // Approval also arms the CLI-shaped mode reminder for the next Run.
1150
+ try {
1151
+ frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
1152
+ }
1153
+ catch (error) {
1154
+ trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
1155
+ sessionManager.close(session, "result-write-failed");
1156
+ return undefined;
1157
+ }
1158
+ }
1159
+ else if (pending.resultField === CREATE_PLAN_RESULT_FIELD) {
1160
+ try {
1161
+ frames = [buildCreatePlanContinuationFrame(pending, r, session.openCodeSessionId, session.sessionId)];
1162
+ }
1163
+ catch (error) {
1164
+ trace(`continuation: create_plan encode FAILED execId=${r.execId} err=${error.message}`);
1165
+ sessionManager.close(session, "result-write-failed");
1166
+ return undefined;
1167
+ }
1168
+ }
1169
+ else if (!pending.bridged) {
916
1170
  try {
917
1171
  const shellResult = pending.resultField === "shell_stream"
918
1172
  || pending.resultField === "background_shell_spawn_result"
@@ -939,6 +1193,55 @@ export function deliverContinuationResults(session, trailingToolResults) {
939
1193
  }
940
1194
  }
941
1195
  }
1196
+ // A refused image commit is Cursor's own `permission_denied` variant,
1197
+ // not a generic write error: its agent branches on that case. The
1198
+ // marker is set by this provider's own tool, so matching it is a
1199
+ // contract rather than a guess at someone else's message text.
1200
+ if (pending.toolName === CURSOR_IMAGE_SAVE_TOOL
1201
+ && pending.resultField === "write_result"
1202
+ && r.error?.includes(IMAGE_PERMISSION_DENIED_PREFIX)) {
1203
+ const deniedPath = typeof pending.resultMetadata?.path === "string"
1204
+ ? pending.resultMetadata.path
1205
+ : "";
1206
+ frames = [encodeMessage("AgentClientMessage", {
1207
+ exec_client_message: {
1208
+ id: r.execId,
1209
+ write_result: {
1210
+ permission_denied: {
1211
+ path: deniedPath,
1212
+ directory: deniedPath ? path.dirname(deniedPath) : "",
1213
+ operation: "write",
1214
+ error: r.error.replace(`${IMAGE_PERMISSION_DENIED_PREFIX} `, ""),
1215
+ is_readonly: false,
1216
+ },
1217
+ },
1218
+ },
1219
+ })];
1220
+ }
1221
+ // A committed image reports the file Cursor asked about, not the tool's
1222
+ // prose. `buildExecClientMessages` would otherwise put the message's
1223
+ // character count in `WriteSuccess.file_size`.
1224
+ if (frames.length === 0
1225
+ && !r.error
1226
+ && pending.toolName === CURSOR_IMAGE_SAVE_TOOL
1227
+ && pending.resultField === "write_result") {
1228
+ frames = [encodeMessage("AgentClientMessage", {
1229
+ exec_client_message: {
1230
+ id: r.execId,
1231
+ write_result: {
1232
+ success: {
1233
+ path: typeof pending.resultMetadata?.path === "string"
1234
+ ? pending.resultMetadata.path
1235
+ : "",
1236
+ lines_created: 0,
1237
+ file_size: typeof pending.resultMetadata?.imageByteLength === "number"
1238
+ ? pending.resultMetadata.imageByteLength
1239
+ : 0,
1240
+ },
1241
+ },
1242
+ },
1243
+ })];
1244
+ }
942
1245
  if (frames.length === 0) {
943
1246
  frames = buildExecClientMessages({
944
1247
  execId: r.execId,
@@ -970,7 +1273,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
970
1273
  trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
971
1274
  continue;
972
1275
  }
973
- trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
1276
+ const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
1277
+ ? "ask_question answer"
1278
+ : pending.resultField === SWITCH_MODE_RESULT_FIELD
1279
+ ? "switch_mode answer"
1280
+ : pending.resultField === CREATE_PLAN_RESULT_FIELD
1281
+ ? "create_plan answer"
1282
+ : "exec result";
1283
+ trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
974
1284
  `frames=${outcome.framesWritten} outLen=${r.output.length}`);
975
1285
  }
976
1286
  return session;
@@ -1315,10 +1625,9 @@ export async function pump(session, controller, ids, abortSignal) {
1315
1625
  const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1316
1626
  if (!requested)
1317
1627
  return false;
1318
- // A scheme-addressed target is the host's to resolve, not a local file to
1319
- // stat. Rejecting it here makes host-mounted capabilities (oh-my-pi's
1320
- // `xd://` device URLs, which is how every MCP tool is exposed by default)
1321
- // permanently unreachable through this provider.
1628
+ // A scheme-addressed target is the advertised host executor's to resolve,
1629
+ // not a local file to stat. Rejecting it here would make any URI-backed
1630
+ // capability permanently unreachable through this provider.
1322
1631
  if (isUriReadTarget(requested)) {
1323
1632
  trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
1324
1633
  return false;
@@ -1811,6 +2120,63 @@ export async function pump(session, controller, ids, abortSignal) {
1811
2120
  return;
1812
2121
  continue;
1813
2122
  }
2123
+ // Cursor writes a generated image with an ordinary write exec whose
2124
+ // `file_bytes` are binary (Cursor CLI's agent does exactly this, then
2125
+ // reads the client's WriteResult back). OpenCode's `write` takes a
2126
+ // string, so those bytes are staged and committed by the host tool
2127
+ // that can raise the `edit` permission before anything hits disk.
2128
+ const binaryWrite = binaryWritePayload(parsed);
2129
+ if (binaryWrite) {
2130
+ if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
2131
+ const reason = "This OpenCode agent cannot write binary file content. "
2132
+ + "Do not retry this write with the same bytes.";
2133
+ if (!rejectExec(parsed, reason, "binary write unsupported"))
2134
+ return;
2135
+ continue;
2136
+ }
2137
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
2138
+ const projectDir = ensureOpencodeProjectDir(workspaceRoot);
2139
+ const target = remapCursorImageWritePath(binaryWrite.path, {
2140
+ workspaceRoot,
2141
+ projectDir,
2142
+ });
2143
+ let imageId;
2144
+ try {
2145
+ imageId = stageCursorImage({
2146
+ path: target,
2147
+ projectDir,
2148
+ mime: imageMimeForPath(target),
2149
+ data: binaryWrite.data,
2150
+ sessionId: session.openCodeSessionId,
2151
+ });
2152
+ }
2153
+ catch (error) {
2154
+ if (!rejectExec(parsed, error.message, "binary write too large"))
2155
+ return;
2156
+ continue;
2157
+ }
2158
+ if (displayCallId)
2159
+ session.displayToolCalls.delete(displayCallId);
2160
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, CURSOR_IMAGE_SAVE_TOOL, false, {
2161
+ ...parsed.resultMetadata,
2162
+ path: target,
2163
+ binaryWriteBytes: undefined,
2164
+ imageByteLength: binaryWrite.data.length,
2165
+ });
2166
+ const toolCallId = `cursor_${session.sessionId}_${parsed.id}`;
2167
+ trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
2168
+ `requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
2169
+ `bytes=${binaryWrite.data.length}`);
2170
+ closeOpenSpans();
2171
+ safeEnqueue({
2172
+ type: "tool-call",
2173
+ toolCallId,
2174
+ toolName: CURSOR_IMAGE_SAVE_TOOL,
2175
+ input: JSON.stringify({ image_id: imageId }),
2176
+ });
2177
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2178
+ return;
2179
+ }
1814
2180
  // Cursor has native capabilities (Task, filesystem, shell, etc.) in
1815
2181
  // addition to the MCP descriptors sent by this provider. The model
1816
2182
  // can request one even when the current OpenCode agent omitted its
@@ -1877,11 +2243,25 @@ export async function pump(session, controller, ids, abortSignal) {
1877
2243
  else if (interactionQuery) {
1878
2244
  // InteractionQuery is a must-reply channel, just like exec and KV. AI
1879
2245
  // SDK has no Cursor-specific UI callback, so answer immediately with the
1880
- // conservative headless policy from protocol/interactions.ts (including
1881
- // F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
2246
+ // policy from protocol/interactions.ts (AskQuestion bridge, CreatePlan
2247
+ // write under OpenCode defaults, GenerateImage approve when saveable).
1882
2248
  const handled = (() => {
1883
2249
  try {
1884
- return handleInteractionQuery(interactionQuery, payload);
2250
+ return handleInteractionQuery(interactionQuery, payload, {
2251
+ canBridgeAskQuestion: session.allowTools && advertisedToolNameSet.has("question"),
2252
+ allowTools: session.allowTools,
2253
+ advertisedTools: advertisedToolNameSet,
2254
+ canSaveGeneratedImage: session.allowTools && advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL),
2255
+ canBridgeCreatePlan: session.allowTools
2256
+ && isBridgedCursorPlanModeActive(session.openCodeSessionId)
2257
+ && advertisedToolNameSet.has(CURSOR_PLAN_STAGE_TOOL)
2258
+ && advertisedToolNameSet.has("write"),
2259
+ planModeActive: isCursorPlanModeActive(session.openCodeSessionId),
2260
+ ...(getActiveCursorMode(session.openCodeSessionId)
2261
+ ? { activeCursorModeId: getActiveCursorMode(session.openCodeSessionId) }
2262
+ : {}),
2263
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2264
+ });
1885
2265
  }
1886
2266
  catch {
1887
2267
  return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
@@ -1891,14 +2271,139 @@ export async function pump(session, controller, ids, abortSignal) {
1891
2271
  if (handled.outcome === "acknowledged") {
1892
2272
  replaySafety.markBarrier("stateful-interaction");
1893
2273
  }
1894
- await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
2274
+ if (handled.reply) {
2275
+ await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
2276
+ }
1895
2277
  trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
1896
- `field=${handled.variantField} outcome=${handled.outcome}`);
2278
+ `field=${handled.variantField} outcome=${handled.outcome}` +
2279
+ (handled.reply ? "" : " (deferred to host tool result)"));
2280
+ if (handled.generateImage) {
2281
+ trace(`interaction_query: approved generate_image target=` +
2282
+ `${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
2283
+ `refs=${handled.generateImage.referenceImagePaths.length} ` +
2284
+ `cursorToolCallId=${handled.generateImage.toolCallId || "(none)"}`);
2285
+ }
1897
2286
  }
1898
2287
  catch (error) {
1899
2288
  rethrowTransportWriteFailure(error);
1900
2289
  failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
1901
2290
  }
2291
+ if (handled.outcome === "bridged" && handled.askQuestion) {
2292
+ // Cursor raised AskQuestion; OpenCode owns the tool loop, so the prompt
2293
+ // leaves as a `question` tool call and this doStream ends. The Run stays
2294
+ // open on the pending entry, and the answer is written back on the next
2295
+ // continuation (InteractionResponse when Cursor is blocking on it,
2296
+ // ConversationAction when it was already acknowledged with `async`).
2297
+ const ask = handled.askQuestion;
2298
+ const execId = session.nextBridgedExecId++;
2299
+ sessionManager.registerPending(execId, session, ASK_QUESTION_RESULT_FIELD, "question", false, {
2300
+ interactionId: handled.id,
2301
+ askQuestionArgs: ask.args,
2302
+ askQuestionRawArgs: ask.rawArgs,
2303
+ askQuestionToolCallId: ask.toolCallId,
2304
+ });
2305
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
2306
+ const input = JSON.stringify(askQuestionToolInput(ask.args));
2307
+ trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
2308
+ `questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
2309
+ `cursorToolCallId=${ask.toolCallId || "(none)"}`);
2310
+ closeOpenSpans();
2311
+ safeEnqueue({
2312
+ type: "tool-call",
2313
+ toolCallId,
2314
+ toolName: "question",
2315
+ input,
2316
+ });
2317
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2318
+ return;
2319
+ }
2320
+ if (handled.outcome === "approved" && handled.switchMode) {
2321
+ // Entering plan mode without a host plan tool: Cursor was already
2322
+ // released with approved{} above, and the behavioural contract travels
2323
+ // as the <system_reminder> injected on subsequent Runs. Nothing is
2324
+ // pending, so keep pumping this Run rather than ending doStream.
2325
+ const sw = handled.switchMode;
2326
+ setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
2327
+ bridgedPlanEntered: false,
2328
+ });
2329
+ trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
2330
+ `target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
2331
+ `provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
2332
+ continue;
2333
+ }
2334
+ if (handled.outcome === "bridged" && handled.switchMode) {
2335
+ // Cursor raised SwitchMode; the host owns the outcome, so the switch
2336
+ // leaves as a tool call (native plan_enter / plan_exit, or the emulated
2337
+ // `question` approval prompt) and this doStream ends. The Run stays open
2338
+ // on the pending entry; approved/rejected is written back on the next
2339
+ // continuation while Cursor is still blocking on the query.
2340
+ const sw = handled.switchMode;
2341
+ const bridgeKind = sw.bridge.kind;
2342
+ const toolName = sw.toolName ?? "plan_exit";
2343
+ const execId = session.nextBridgedExecId++;
2344
+ sessionManager.registerPending(execId, session, SWITCH_MODE_RESULT_FIELD, toolName, false, {
2345
+ interactionId: handled.id,
2346
+ switchModeTarget: sw.args.targetModeId,
2347
+ switchModeToolCallId: sw.toolCallId,
2348
+ switchModeBridgeKind: bridgeKind,
2349
+ });
2350
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
2351
+ const input = JSON.stringify(sw.bridge.kind === "question" ? sw.bridge.input : switchModeToolInput());
2352
+ trace(`interaction_query: BRIDGED switch_mode id=${handled.id} toolCallId=${toolCallId} ` +
2353
+ `bridge=${bridgeKind} hostTool=${toolName} ` +
2354
+ `target=${JSON.stringify(sw.args.targetModeId)} ` +
2355
+ `cursorToolCallId=${sw.toolCallId || "(none)"}`);
2356
+ closeOpenSpans();
2357
+ safeEnqueue({
2358
+ type: "tool-call",
2359
+ toolCallId,
2360
+ toolName,
2361
+ input,
2362
+ });
2363
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2364
+ return;
2365
+ }
2366
+ if (handled.outcome === "bridged" && handled.createPlan) {
2367
+ // Either a host plan-stage tool (writes the plan and runs its own
2368
+ // approval UI) or the emulated prompt after the provider already wrote
2369
+ // the plan. Both hold Cursor's query open until the user decides.
2370
+ const plan = handled.createPlan;
2371
+ const staged = plan.bridge.kind === "stage" ? createPlanStageInput(plan.args) : undefined;
2372
+ const planUri = staged?.plan_uri ?? plan.planUri ?? "";
2373
+ const input = staged ?? plan.questionInput ?? {};
2374
+ const stageExecId = session.nextBridgedExecId++;
2375
+ sessionManager.registerPending(stageExecId, session, CREATE_PLAN_RESULT_FIELD, plan.toolName, false, {
2376
+ interactionId: handled.id,
2377
+ createPlanToolCallId: plan.toolCallId,
2378
+ createPlanBridgeKind: plan.bridge.kind,
2379
+ // The host echoes the prompt verbatim; it anchors the answer parse.
2380
+ createPlanQuestion: plan.questionInput?.questions[0]?.question ?? "",
2381
+ planUri,
2382
+ // Absolute path for the post-Yes OpenCode kickoff (plan_exit shape).
2383
+ ...(typeof plan.planPath === "string" && plan.planPath.trim()
2384
+ ? { planPath: plan.planPath.trim() }
2385
+ : {}),
2386
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2387
+ });
2388
+ const stageToolCallId = `cursor_${session.sessionId}_${stageExecId}`;
2389
+ trace(`interaction_query: BRIDGED create_plan id=${handled.id} bridge=${plan.bridge.kind} ` +
2390
+ `hostTool=${plan.toolName} stageToolCallId=${stageToolCallId} ` +
2391
+ `planUri=${planUri || "(none)"} cursorToolCallId=${plan.toolCallId || "(none)"}`);
2392
+ // Cursor sends the plan body through the interaction query, not the
2393
+ // text stream, so nothing has shown it yet. Put it in the transcript
2394
+ // before asking — approving a plan you cannot read is not approval.
2395
+ if (plan.planReview)
2396
+ emitText(plan.planReview);
2397
+ closeOpenSpans();
2398
+ safeEnqueue({
2399
+ type: "tool-call",
2400
+ toolCallId: stageToolCallId,
2401
+ toolName: plan.toolName,
2402
+ input: JSON.stringify(input),
2403
+ });
2404
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2405
+ return;
2406
+ }
1902
2407
  }
1903
2408
  else if (kv) {
1904
2409
  // KV blob channel: ack set_blob / answer get_blob, then keep pumping.
@@ -2050,16 +2555,26 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2050
2555
  const instructions = [];
2051
2556
  const subagents = extractHostSubagentCatalog(tools);
2052
2557
  if (names.has("question")) {
2053
- instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
2054
- }
2558
+ // Cursor-native AskQuestion is translated into this tool (see
2559
+ // protocol/ask-question.ts), so both routes reach the user. Saying so stops
2560
+ // the model from treating a question as impossible when it reaches for its
2561
+ // native interaction first.
2562
+ instructions.push("- When user input is required, call the OpenCode `question` tool. Cursor-native AskQuestion requests are also accepted and answered through it.");
2563
+ }
2564
+ // CreatePlan is a Cursor InteractionQuery (#7), not an OpenCode/MCP catalog
2565
+ // tool. Always say so when we advertise any tools — otherwise the model sees
2566
+ // CreatePlan in Cursor's function definitions, notices it is absent from the
2567
+ // OpenCode list, and narrates "No CreatePlan MCP tool is available" even
2568
+ // though the provider bridges the native interaction.
2569
+ instructions.push("- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally; the provider writes the plan under the host's calculated plans directory and handles execution approval. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool.");
2055
2570
  if (names.has("plan_enter")) {
2056
- instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool; do not use Cursor's native SwitchMode or CreatePlan interactions.");
2571
+ 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.");
2057
2572
  }
2058
2573
  else if (names.has("todowrite")) {
2059
- instructions.push("- For planning, call the OpenCode `todowrite` tool and explain the plan in normal text; do not use Cursor's native SwitchMode or CreatePlan interactions.");
2574
+ instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
2060
2575
  }
2061
2576
  if (names.has("plan_exit")) {
2062
- instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
2577
+ 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.");
2063
2578
  }
2064
2579
  if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
2065
2580
  instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
@@ -2073,8 +2588,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2073
2588
  if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
2074
2589
  instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
2075
2590
  }
2076
- if (names.has("task") || names.has("actor")) {
2077
- const target = names.has("actor") ? "`actor`" : "`task`";
2591
+ if (names.has("task")) {
2592
+ const target = "`task`";
2078
2593
  const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
2079
2594
  instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
2080
2595
  "Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
@@ -2102,8 +2617,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2102
2617
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
2103
2618
  `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.`,
2104
2619
  subagents.executor
2105
- ? "Call only tools in that exact list. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed; other unlisted Cursor-native tools remain unavailable."
2106
- : "Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
2620
+ ? "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."
2621
+ : "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.",
2107
2622
  ...(instructions.length > 0
2108
2623
  ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
2109
2624
  : []),
@@ -2282,22 +2797,50 @@ export function spanEndParts(opts) {
2282
2797
  out.push({ type: "text-end", id: opts.textId });
2283
2798
  return out;
2284
2799
  }
2800
+ function toolsInFixedOrder(tools) {
2801
+ return tools.map((tool) => ({ ...tool })).sort((left, right) => (left.name ?? "").localeCompare(right.name ?? ""));
2802
+ }
2285
2803
  /** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
2286
2804
  export function computeAllowTools(toolCount, toolChoice) {
2287
2805
  return toolCount > 0 && toolChoice?.type !== "none";
2288
2806
  }
2289
- export function resolveTurnToolState(input) {
2807
+ export async function resolveTurnToolState(input) {
2290
2808
  const { sessionKey, incomingTools, isCompaction } = input;
2291
2809
  if (sessionKey && incomingTools.length > 0) {
2292
- rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
2810
+ rememberToolCatalog(sessionKey, toolsInFixedOrder(incomingTools));
2811
+ }
2812
+ // Advertisement and permission are deliberately independent.
2813
+ //
2814
+ // Advertisement must stay byte-stable across every Run of a conversation or
2815
+ // the RequestContext changes shape and Cursor's prompt cache goes cold. A
2816
+ // zero-tool call is never a smaller catalog — it is a lifecycle turn
2817
+ // (compaction, title generation, summarization) that OpenCode opens alongside
2818
+ // the real one — so it re-advertises the last real catalog rather than
2819
+ // collapsing the context to tools=0. A genuinely restricted turn still sends
2820
+ // a non-empty set and is advertised verbatim.
2821
+ //
2822
+ // On cold start the lifecycle Run may arrive before any catalog exists. For a
2823
+ // valid session key, wait until a sibling doStream publishes the first real
2824
+ // catalog; cancellation is the only escape. Never time out to tools=[] and
2825
+ // never invent or filter the enabled set.
2826
+ //
2827
+ // Permission is computed from what actually arrived, so those lifecycle turns
2828
+ // still refuse execution and cannot duplicate the real turn's side effects.
2829
+ let advertisedTools;
2830
+ if (incomingTools.length > 0) {
2831
+ advertisedTools = toolsInFixedOrder(incomingTools);
2832
+ }
2833
+ else if (sessionKey) {
2834
+ const cached = toolCatalogBySession.get(sessionKey)
2835
+ ?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
2836
+ advertisedTools = toolsInFixedOrder(cached);
2837
+ }
2838
+ else {
2839
+ // Without a session key there is no safe sibling-correlation key. Preserve
2840
+ // the host's literal no-tool call rather than borrowing another session's
2841
+ // catalog.
2842
+ advertisedTools = [];
2293
2843
  }
2294
- const cached = sessionKey ? toolCatalogBySession.get(sessionKey) : undefined;
2295
- if (sessionKey && cached && incomingTools.length === 0) {
2296
- rememberToolCatalog(sessionKey, cached);
2297
- }
2298
- const advertisedTools = isCompaction && incomingTools.length === 0
2299
- ? (cached?.map((tool) => ({ ...tool })) ?? [])
2300
- : incomingTools;
2301
2844
  return {
2302
2845
  advertisedTools,
2303
2846
  allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
@@ -2316,6 +2859,11 @@ export function resolveTurnConversationReset(input) {
2316
2859
  return { reset: false };
2317
2860
  }
2318
2861
  export function resetTurnStateForTests() {
2862
+ for (const waiters of toolCatalogWaitersBySession.values()) {
2863
+ for (const waiter of waiters)
2864
+ waiter.cancel();
2865
+ }
2866
+ toolCatalogWaitersBySession.clear();
2319
2867
  toolCatalogBySession.clear();
2320
2868
  postCompactionRebaseBySession.clear();
2321
2869
  }