cursor-opencode-provider 0.5.0 → 0.6.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 (48) hide show
  1. package/README.md +18 -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/rules.js +1 -1
  12. package/dist/image-save-tool.d.ts +19 -0
  13. package/dist/image-save-tool.js +25 -0
  14. package/dist/image-save.d.ts +58 -0
  15. package/dist/image-save.js +189 -0
  16. package/dist/image-staging.d.ts +59 -0
  17. package/dist/image-staging.js +91 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/language-model.d.ts +3 -2
  20. package/dist/language-model.js +572 -34
  21. package/dist/plan-execution-kickoff.d.ts +55 -0
  22. package/dist/plan-execution-kickoff.js +162 -0
  23. package/dist/plugin-opencode2.js +52 -3
  24. package/dist/plugin-v2.d.ts +11 -9
  25. package/dist/plugin-v2.js +6 -13
  26. package/dist/plugin.d.ts +9 -0
  27. package/dist/plugin.js +61 -5
  28. package/dist/pricing-data.d.ts +16 -4
  29. package/dist/pricing-data.js +16 -4
  30. package/dist/protocol/ask-question.d.ts +133 -0
  31. package/dist/protocol/ask-question.js +320 -0
  32. package/dist/protocol/create-plan.d.ts +141 -0
  33. package/dist/protocol/create-plan.js +373 -0
  34. package/dist/protocol/generate-image.d.ts +54 -0
  35. package/dist/protocol/generate-image.js +101 -0
  36. package/dist/protocol/interactions.d.ts +110 -8
  37. package/dist/protocol/interactions.js +295 -35
  38. package/dist/protocol/messages.js +118 -6
  39. package/dist/protocol/switch-mode.d.ts +178 -0
  40. package/dist/protocol/switch-mode.js +417 -0
  41. package/dist/protocol/tool-call-bridge.js +10 -5
  42. package/dist/protocol/tools.d.ts +27 -11
  43. package/dist/protocol/tools.js +95 -41
  44. package/dist/usage.d.ts +2 -2
  45. package/dist/usage.js +2 -2
  46. package/dist/web-search-tool.d.ts +22 -26
  47. package/dist/web-search-tool.js +25 -15
  48. 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);
@@ -606,9 +683,24 @@ async function startSession(modelId, token, callOptions, options, startOptions)
606
683
  const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
607
684
  const baseSystemPrompt = extractSystemPrompt(prompt);
608
685
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
609
- const systemPrompt = interactionGuidance
610
- ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
611
- : baseSystemPrompt;
686
+ // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
687
+ // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
688
+ const modeReminder = isCompaction
689
+ ? undefined
690
+ : takeActiveCursorModeReminder(sessionKey, {
691
+ advertisedTools: cursorTools.map((tool) => tool.name),
692
+ });
693
+ const kickoffWarning = isCompaction
694
+ ? undefined
695
+ : takePlanExecutionKickoffWarning(sessionKey);
696
+ const systemPrompt = [
697
+ baseSystemPrompt,
698
+ interactionGuidance,
699
+ modeReminder,
700
+ kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
701
+ ]
702
+ .filter(Boolean)
703
+ .join("\n\n");
612
704
  const history = extractPromptHistory(prompt, {
613
705
  preserveTrailingUser: recovery?.kind === "rebase",
614
706
  toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
@@ -886,6 +978,124 @@ export function findContinuationSession(toolResults) {
886
978
  }
887
979
  return undefined;
888
980
  }
981
+ /**
982
+ * Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
983
+ *
984
+ * A synchronous AskQuestion still has its InteractionQuery open, so the answer
985
+ * goes back as the InteractionResponse for that query id. A `run_async` one was
986
+ * already released with `async{}`, so — exactly as Cursor CLI does — the answer
987
+ * travels as a ConversationAction keyed by the originating tool call id, with
988
+ * the server's own AskQuestionArgs bytes echoed back untouched.
989
+ */
990
+ function buildAskQuestionContinuationFrame(pending, result) {
991
+ const metadata = pending.resultMetadata ?? {};
992
+ const args = metadata.askQuestionArgs;
993
+ if (!args)
994
+ throw new CursorProtocolError("Bridged AskQuestion lost its decoded arguments");
995
+ const answer = askQuestionResultFromToolOutput(args, result.output, result.error !== undefined);
996
+ if (!args.runAsync) {
997
+ const interactionId = metadata.interactionId;
998
+ if (typeof interactionId !== "number") {
999
+ throw new CursorProtocolError("Bridged AskQuestion lost its interaction id");
1000
+ }
1001
+ return buildAskQuestionInteractionReply(interactionId, answer);
1002
+ }
1003
+ const rawArgs = metadata.askQuestionRawArgs;
1004
+ if (!(rawArgs instanceof Uint8Array)) {
1005
+ throw new CursorProtocolError("Bridged async AskQuestion lost its original arguments");
1006
+ }
1007
+ const toolCallId = metadata.askQuestionToolCallId;
1008
+ return buildAsyncAskQuestionCompletion(typeof toolCallId === "string" ? toolCallId : "", rawArgs, answer);
1009
+ }
1010
+ /**
1011
+ * Turn the host's plan-approval outcome into the CreatePlan reply Cursor is
1012
+ * still blocking on. One contract across every channel: `success` means the
1013
+ * user approved execution, `error` means the plan was written but not accepted,
1014
+ * so the model keeps planning instead of starting work.
1015
+ *
1016
+ * A host plan-stage tool reports that outcome as tool success/failure. The
1017
+ * emulated path asks through `question`, so only an explicit "Yes" approves.
1018
+ * On approval the session leaves plan mode, which arms the agent-mode
1019
+ * `<system_reminder>` for the next Run.
1020
+ */
1021
+ function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSessionID) {
1022
+ const metadata = pending.resultMetadata ?? {};
1023
+ const interactionId = metadata.interactionId;
1024
+ if (typeof interactionId !== "number") {
1025
+ throw new CursorProtocolError("Bridged CreatePlan lost its interaction id");
1026
+ }
1027
+ const planUri = metadata.planUri;
1028
+ if (typeof planUri !== "string" || !planUri.trim()) {
1029
+ throw new CursorProtocolError("Bridged CreatePlan lost its plan URI");
1030
+ }
1031
+ const approved = metadata.createPlanBridgeKind === "approve"
1032
+ ? createPlanApproved(result.output, result.error !== undefined, typeof metadata.createPlanQuestion === "string" ? metadata.createPlanQuestion : "")
1033
+ : result.error === undefined;
1034
+ if (!approved) {
1035
+ const reason = result.error?.trim();
1036
+ return buildCreatePlanInteractionReply(interactionId, {
1037
+ error: { error: reason || CREATE_PLAN_NOT_APPROVED_REASON },
1038
+ plan_uri: "",
1039
+ });
1040
+ }
1041
+ // The question-emulated path can report execution approval only when a host
1042
+ // kickoff exists. Otherwise keep planning mode active and return an error.
1043
+ if (metadata.createPlanBridgeKind === "approve" && !hasPlanExecutionKickoff()) {
1044
+ return buildCreatePlanInteractionReply(interactionId, {
1045
+ error: { error: "The plan was approved, but this host cannot start its execution turn." },
1046
+ plan_uri: planUri,
1047
+ });
1048
+ }
1049
+ // Approved: planning is over for this plan, so drop the plan-mode reminder
1050
+ // and hand the next Run the agent-mode contract, exactly as an approved
1051
+ // plan_exit does.
1052
+ setActiveCursorMode(sessionKey, "agent");
1053
+ // Only the question-emulated path needs the provider's synthetic build turn.
1054
+ // A host stage tool owns both approval and execution handoff itself.
1055
+ if (metadata.createPlanBridgeKind === "approve" && sessionKey) {
1056
+ const absolute = typeof metadata.planPath === "string" && metadata.planPath.trim()
1057
+ ? metadata.planPath.trim()
1058
+ : planPathFromUri(planUri);
1059
+ const workspaceRoot = typeof metadata.workspaceRoot === "string" ? metadata.workspaceRoot : undefined;
1060
+ if (!queuePlanExecutionKickoff({
1061
+ sessionID: sessionKey,
1062
+ planPath: formatPlanKickoffPath(absolute, workspaceRoot),
1063
+ cursorSessionID,
1064
+ })) {
1065
+ return buildCreatePlanInteractionReply(interactionId, {
1066
+ error: { error: "The plan was approved, but this host cannot start its execution turn." },
1067
+ plan_uri: planUri,
1068
+ });
1069
+ }
1070
+ }
1071
+ return buildCreatePlanInteractionReply(interactionId, {
1072
+ success: {},
1073
+ plan_uri: planUri,
1074
+ });
1075
+ }
1076
+ function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
1077
+ const metadata = pending.resultMetadata ?? {};
1078
+ const interactionId = metadata.interactionId;
1079
+ if (typeof interactionId !== "number") {
1080
+ throw new CursorProtocolError("Bridged SwitchMode lost its interaction id");
1081
+ }
1082
+ // A question-emulated exit carries the user's Yes/No as prose, not a plan
1083
+ // tool's success/failure, so it needs the answer parser rather than the
1084
+ // error-shaped mapping.
1085
+ const answer = metadata.switchModeBridgeKind === "question"
1086
+ ? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
1087
+ : switchModeResultFromToolOutput(result.output, result.error !== undefined);
1088
+ if ("approved" in answer) {
1089
+ const target = metadata.switchModeTarget;
1090
+ if (typeof target === "string" && target.trim()) {
1091
+ const normalized = target.trim().toLowerCase();
1092
+ setActiveCursorMode(sessionKey, target, {
1093
+ bridgedPlanEntered: normalized === "plan" || normalized === "spec",
1094
+ });
1095
+ }
1096
+ }
1097
+ return buildSwitchModeInteractionReply(interactionId, answer);
1098
+ }
889
1099
  /**
890
1100
  * Deliver trailing tool results onto a live continuation session.
891
1101
  * Returns the same session when writes succeed (or only bridged results were
@@ -912,7 +1122,44 @@ export function deliverContinuationResults(session, trailingToolResults) {
912
1122
  }
913
1123
  const pending = claim.pending;
914
1124
  let frames = [];
915
- if (!pending.bridged) {
1125
+ if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
1126
+ // A bridged Cursor AskQuestion. The host tool result carries the user's
1127
+ // choices; translate them back into the Cursor result the interaction is
1128
+ // waiting for (or into the deferred completion, when Cursor was already
1129
+ // released with `async`).
1130
+ try {
1131
+ frames = [buildAskQuestionContinuationFrame(pending, r)];
1132
+ }
1133
+ catch (error) {
1134
+ trace(`continuation: ask_question encode FAILED execId=${r.execId} err=${error.message}`);
1135
+ sessionManager.close(session, "result-write-failed");
1136
+ return undefined;
1137
+ }
1138
+ }
1139
+ else if (pending.resultField === SWITCH_MODE_RESULT_FIELD) {
1140
+ // Bridged SwitchMode: host plan_enter / plan_exit outcome becomes
1141
+ // approved{} or rejected{reason} on the still-open InteractionQuery.
1142
+ // Approval also arms the CLI-shaped mode reminder for the next Run.
1143
+ try {
1144
+ frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
1145
+ }
1146
+ catch (error) {
1147
+ trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
1148
+ sessionManager.close(session, "result-write-failed");
1149
+ return undefined;
1150
+ }
1151
+ }
1152
+ else if (pending.resultField === CREATE_PLAN_RESULT_FIELD) {
1153
+ try {
1154
+ frames = [buildCreatePlanContinuationFrame(pending, r, session.openCodeSessionId, session.sessionId)];
1155
+ }
1156
+ catch (error) {
1157
+ trace(`continuation: create_plan encode FAILED execId=${r.execId} err=${error.message}`);
1158
+ sessionManager.close(session, "result-write-failed");
1159
+ return undefined;
1160
+ }
1161
+ }
1162
+ else if (!pending.bridged) {
916
1163
  try {
917
1164
  const shellResult = pending.resultField === "shell_stream"
918
1165
  || pending.resultField === "background_shell_spawn_result"
@@ -939,6 +1186,55 @@ export function deliverContinuationResults(session, trailingToolResults) {
939
1186
  }
940
1187
  }
941
1188
  }
1189
+ // A refused image commit is Cursor's own `permission_denied` variant,
1190
+ // not a generic write error: its agent branches on that case. The
1191
+ // marker is set by this provider's own tool, so matching it is a
1192
+ // contract rather than a guess at someone else's message text.
1193
+ if (pending.toolName === CURSOR_IMAGE_SAVE_TOOL
1194
+ && pending.resultField === "write_result"
1195
+ && r.error?.includes(IMAGE_PERMISSION_DENIED_PREFIX)) {
1196
+ const deniedPath = typeof pending.resultMetadata?.path === "string"
1197
+ ? pending.resultMetadata.path
1198
+ : "";
1199
+ frames = [encodeMessage("AgentClientMessage", {
1200
+ exec_client_message: {
1201
+ id: r.execId,
1202
+ write_result: {
1203
+ permission_denied: {
1204
+ path: deniedPath,
1205
+ directory: deniedPath ? path.dirname(deniedPath) : "",
1206
+ operation: "write",
1207
+ error: r.error.replace(`${IMAGE_PERMISSION_DENIED_PREFIX} `, ""),
1208
+ is_readonly: false,
1209
+ },
1210
+ },
1211
+ },
1212
+ })];
1213
+ }
1214
+ // A committed image reports the file Cursor asked about, not the tool's
1215
+ // prose. `buildExecClientMessages` would otherwise put the message's
1216
+ // character count in `WriteSuccess.file_size`.
1217
+ if (frames.length === 0
1218
+ && !r.error
1219
+ && pending.toolName === CURSOR_IMAGE_SAVE_TOOL
1220
+ && pending.resultField === "write_result") {
1221
+ frames = [encodeMessage("AgentClientMessage", {
1222
+ exec_client_message: {
1223
+ id: r.execId,
1224
+ write_result: {
1225
+ success: {
1226
+ path: typeof pending.resultMetadata?.path === "string"
1227
+ ? pending.resultMetadata.path
1228
+ : "",
1229
+ lines_created: 0,
1230
+ file_size: typeof pending.resultMetadata?.imageByteLength === "number"
1231
+ ? pending.resultMetadata.imageByteLength
1232
+ : 0,
1233
+ },
1234
+ },
1235
+ },
1236
+ })];
1237
+ }
942
1238
  if (frames.length === 0) {
943
1239
  frames = buildExecClientMessages({
944
1240
  execId: r.execId,
@@ -970,7 +1266,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
970
1266
  trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
971
1267
  continue;
972
1268
  }
973
- trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
1269
+ const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
1270
+ ? "ask_question answer"
1271
+ : pending.resultField === SWITCH_MODE_RESULT_FIELD
1272
+ ? "switch_mode answer"
1273
+ : pending.resultField === CREATE_PLAN_RESULT_FIELD
1274
+ ? "create_plan answer"
1275
+ : "exec result";
1276
+ trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
974
1277
  `frames=${outcome.framesWritten} outLen=${r.output.length}`);
975
1278
  }
976
1279
  return session;
@@ -1315,10 +1618,9 @@ export async function pump(session, controller, ids, abortSignal) {
1315
1618
  const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1316
1619
  if (!requested)
1317
1620
  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.
1621
+ // A scheme-addressed target is the advertised host executor's to resolve,
1622
+ // not a local file to stat. Rejecting it here would make any URI-backed
1623
+ // capability permanently unreachable through this provider.
1322
1624
  if (isUriReadTarget(requested)) {
1323
1625
  trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
1324
1626
  return false;
@@ -1811,6 +2113,63 @@ export async function pump(session, controller, ids, abortSignal) {
1811
2113
  return;
1812
2114
  continue;
1813
2115
  }
2116
+ // Cursor writes a generated image with an ordinary write exec whose
2117
+ // `file_bytes` are binary (Cursor CLI's agent does exactly this, then
2118
+ // reads the client's WriteResult back). OpenCode's `write` takes a
2119
+ // string, so those bytes are staged and committed by the host tool
2120
+ // that can raise the `edit` permission before anything hits disk.
2121
+ const binaryWrite = binaryWritePayload(parsed);
2122
+ if (binaryWrite) {
2123
+ if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
2124
+ const reason = "This OpenCode agent cannot write binary file content. "
2125
+ + "Do not retry this write with the same bytes.";
2126
+ if (!rejectExec(parsed, reason, "binary write unsupported"))
2127
+ return;
2128
+ continue;
2129
+ }
2130
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
2131
+ const projectDir = ensureOpencodeProjectDir(workspaceRoot);
2132
+ const target = remapCursorImageWritePath(binaryWrite.path, {
2133
+ workspaceRoot,
2134
+ projectDir,
2135
+ });
2136
+ let imageId;
2137
+ try {
2138
+ imageId = stageCursorImage({
2139
+ path: target,
2140
+ projectDir,
2141
+ mime: imageMimeForPath(target),
2142
+ data: binaryWrite.data,
2143
+ sessionId: session.openCodeSessionId,
2144
+ });
2145
+ }
2146
+ catch (error) {
2147
+ if (!rejectExec(parsed, error.message, "binary write too large"))
2148
+ return;
2149
+ continue;
2150
+ }
2151
+ if (displayCallId)
2152
+ session.displayToolCalls.delete(displayCallId);
2153
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, CURSOR_IMAGE_SAVE_TOOL, false, {
2154
+ ...parsed.resultMetadata,
2155
+ path: target,
2156
+ binaryWriteBytes: undefined,
2157
+ imageByteLength: binaryWrite.data.length,
2158
+ });
2159
+ const toolCallId = `cursor_${session.sessionId}_${parsed.id}`;
2160
+ trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
2161
+ `requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
2162
+ `bytes=${binaryWrite.data.length}`);
2163
+ closeOpenSpans();
2164
+ safeEnqueue({
2165
+ type: "tool-call",
2166
+ toolCallId,
2167
+ toolName: CURSOR_IMAGE_SAVE_TOOL,
2168
+ input: JSON.stringify({ image_id: imageId }),
2169
+ });
2170
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2171
+ return;
2172
+ }
1814
2173
  // Cursor has native capabilities (Task, filesystem, shell, etc.) in
1815
2174
  // addition to the MCP descriptors sent by this provider. The model
1816
2175
  // can request one even when the current OpenCode agent omitted its
@@ -1877,11 +2236,25 @@ export async function pump(session, controller, ids, abortSignal) {
1877
2236
  else if (interactionQuery) {
1878
2237
  // InteractionQuery is a must-reply channel, just like exec and KV. AI
1879
2238
  // 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).
2239
+ // policy from protocol/interactions.ts (AskQuestion bridge, CreatePlan
2240
+ // write under OpenCode defaults, GenerateImage approve when saveable).
1882
2241
  const handled = (() => {
1883
2242
  try {
1884
- return handleInteractionQuery(interactionQuery, payload);
2243
+ return handleInteractionQuery(interactionQuery, payload, {
2244
+ canBridgeAskQuestion: session.allowTools && advertisedToolNameSet.has("question"),
2245
+ allowTools: session.allowTools,
2246
+ advertisedTools: advertisedToolNameSet,
2247
+ canSaveGeneratedImage: session.allowTools && advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL),
2248
+ canBridgeCreatePlan: session.allowTools
2249
+ && isBridgedCursorPlanModeActive(session.openCodeSessionId)
2250
+ && advertisedToolNameSet.has(CURSOR_PLAN_STAGE_TOOL)
2251
+ && advertisedToolNameSet.has("write"),
2252
+ planModeActive: isCursorPlanModeActive(session.openCodeSessionId),
2253
+ ...(getActiveCursorMode(session.openCodeSessionId)
2254
+ ? { activeCursorModeId: getActiveCursorMode(session.openCodeSessionId) }
2255
+ : {}),
2256
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2257
+ });
1885
2258
  }
1886
2259
  catch {
1887
2260
  return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
@@ -1891,14 +2264,139 @@ export async function pump(session, controller, ids, abortSignal) {
1891
2264
  if (handled.outcome === "acknowledged") {
1892
2265
  replaySafety.markBarrier("stateful-interaction");
1893
2266
  }
1894
- await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
2267
+ if (handled.reply) {
2268
+ await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
2269
+ }
1895
2270
  trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
1896
- `field=${handled.variantField} outcome=${handled.outcome}`);
2271
+ `field=${handled.variantField} outcome=${handled.outcome}` +
2272
+ (handled.reply ? "" : " (deferred to host tool result)"));
2273
+ if (handled.generateImage) {
2274
+ trace(`interaction_query: approved generate_image target=` +
2275
+ `${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
2276
+ `refs=${handled.generateImage.referenceImagePaths.length} ` +
2277
+ `cursorToolCallId=${handled.generateImage.toolCallId || "(none)"}`);
2278
+ }
1897
2279
  }
1898
2280
  catch (error) {
1899
2281
  rethrowTransportWriteFailure(error);
1900
2282
  failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
1901
2283
  }
2284
+ if (handled.outcome === "bridged" && handled.askQuestion) {
2285
+ // Cursor raised AskQuestion; OpenCode owns the tool loop, so the prompt
2286
+ // leaves as a `question` tool call and this doStream ends. The Run stays
2287
+ // open on the pending entry, and the answer is written back on the next
2288
+ // continuation (InteractionResponse when Cursor is blocking on it,
2289
+ // ConversationAction when it was already acknowledged with `async`).
2290
+ const ask = handled.askQuestion;
2291
+ const execId = session.nextBridgedExecId++;
2292
+ sessionManager.registerPending(execId, session, ASK_QUESTION_RESULT_FIELD, "question", false, {
2293
+ interactionId: handled.id,
2294
+ askQuestionArgs: ask.args,
2295
+ askQuestionRawArgs: ask.rawArgs,
2296
+ askQuestionToolCallId: ask.toolCallId,
2297
+ });
2298
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
2299
+ const input = JSON.stringify(askQuestionToolInput(ask.args));
2300
+ trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
2301
+ `questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
2302
+ `cursorToolCallId=${ask.toolCallId || "(none)"}`);
2303
+ closeOpenSpans();
2304
+ safeEnqueue({
2305
+ type: "tool-call",
2306
+ toolCallId,
2307
+ toolName: "question",
2308
+ input,
2309
+ });
2310
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2311
+ return;
2312
+ }
2313
+ if (handled.outcome === "approved" && handled.switchMode) {
2314
+ // Entering plan mode without a host plan tool: Cursor was already
2315
+ // released with approved{} above, and the behavioural contract travels
2316
+ // as the <system_reminder> injected on subsequent Runs. Nothing is
2317
+ // pending, so keep pumping this Run rather than ending doStream.
2318
+ const sw = handled.switchMode;
2319
+ setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
2320
+ bridgedPlanEntered: false,
2321
+ });
2322
+ trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
2323
+ `target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
2324
+ `provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
2325
+ continue;
2326
+ }
2327
+ if (handled.outcome === "bridged" && handled.switchMode) {
2328
+ // Cursor raised SwitchMode; the host owns the outcome, so the switch
2329
+ // leaves as a tool call (native plan_enter / plan_exit, or the emulated
2330
+ // `question` approval prompt) and this doStream ends. The Run stays open
2331
+ // on the pending entry; approved/rejected is written back on the next
2332
+ // continuation while Cursor is still blocking on the query.
2333
+ const sw = handled.switchMode;
2334
+ const bridgeKind = sw.bridge.kind;
2335
+ const toolName = sw.toolName ?? "plan_exit";
2336
+ const execId = session.nextBridgedExecId++;
2337
+ sessionManager.registerPending(execId, session, SWITCH_MODE_RESULT_FIELD, toolName, false, {
2338
+ interactionId: handled.id,
2339
+ switchModeTarget: sw.args.targetModeId,
2340
+ switchModeToolCallId: sw.toolCallId,
2341
+ switchModeBridgeKind: bridgeKind,
2342
+ });
2343
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
2344
+ const input = JSON.stringify(sw.bridge.kind === "question" ? sw.bridge.input : switchModeToolInput());
2345
+ trace(`interaction_query: BRIDGED switch_mode id=${handled.id} toolCallId=${toolCallId} ` +
2346
+ `bridge=${bridgeKind} hostTool=${toolName} ` +
2347
+ `target=${JSON.stringify(sw.args.targetModeId)} ` +
2348
+ `cursorToolCallId=${sw.toolCallId || "(none)"}`);
2349
+ closeOpenSpans();
2350
+ safeEnqueue({
2351
+ type: "tool-call",
2352
+ toolCallId,
2353
+ toolName,
2354
+ input,
2355
+ });
2356
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2357
+ return;
2358
+ }
2359
+ if (handled.outcome === "bridged" && handled.createPlan) {
2360
+ // Either a host plan-stage tool (writes the plan and runs its own
2361
+ // approval UI) or the emulated prompt after the provider already wrote
2362
+ // the plan. Both hold Cursor's query open until the user decides.
2363
+ const plan = handled.createPlan;
2364
+ const staged = plan.bridge.kind === "stage" ? createPlanStageInput(plan.args) : undefined;
2365
+ const planUri = staged?.plan_uri ?? plan.planUri ?? "";
2366
+ const input = staged ?? plan.questionInput ?? {};
2367
+ const stageExecId = session.nextBridgedExecId++;
2368
+ sessionManager.registerPending(stageExecId, session, CREATE_PLAN_RESULT_FIELD, plan.toolName, false, {
2369
+ interactionId: handled.id,
2370
+ createPlanToolCallId: plan.toolCallId,
2371
+ createPlanBridgeKind: plan.bridge.kind,
2372
+ // The host echoes the prompt verbatim; it anchors the answer parse.
2373
+ createPlanQuestion: plan.questionInput?.questions[0]?.question ?? "",
2374
+ planUri,
2375
+ // Absolute path for the post-Yes OpenCode kickoff (plan_exit shape).
2376
+ ...(typeof plan.planPath === "string" && plan.planPath.trim()
2377
+ ? { planPath: plan.planPath.trim() }
2378
+ : {}),
2379
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2380
+ });
2381
+ const stageToolCallId = `cursor_${session.sessionId}_${stageExecId}`;
2382
+ trace(`interaction_query: BRIDGED create_plan id=${handled.id} bridge=${plan.bridge.kind} ` +
2383
+ `hostTool=${plan.toolName} stageToolCallId=${stageToolCallId} ` +
2384
+ `planUri=${planUri || "(none)"} cursorToolCallId=${plan.toolCallId || "(none)"}`);
2385
+ // Cursor sends the plan body through the interaction query, not the
2386
+ // text stream, so nothing has shown it yet. Put it in the transcript
2387
+ // before asking — approving a plan you cannot read is not approval.
2388
+ if (plan.planReview)
2389
+ emitText(plan.planReview);
2390
+ closeOpenSpans();
2391
+ safeEnqueue({
2392
+ type: "tool-call",
2393
+ toolCallId: stageToolCallId,
2394
+ toolName: plan.toolName,
2395
+ input: JSON.stringify(input),
2396
+ });
2397
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2398
+ return;
2399
+ }
1902
2400
  }
1903
2401
  else if (kv) {
1904
2402
  // KV blob channel: ack set_blob / answer get_blob, then keep pumping.
@@ -2050,16 +2548,26 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2050
2548
  const instructions = [];
2051
2549
  const subagents = extractHostSubagentCatalog(tools);
2052
2550
  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
- }
2551
+ // Cursor-native AskQuestion is translated into this tool (see
2552
+ // protocol/ask-question.ts), so both routes reach the user. Saying so stops
2553
+ // the model from treating a question as impossible when it reaches for its
2554
+ // native interaction first.
2555
+ instructions.push("- When user input is required, call the OpenCode `question` tool. Cursor-native AskQuestion requests are also accepted and answered through it.");
2556
+ }
2557
+ // CreatePlan is a Cursor InteractionQuery (#7), not an OpenCode/MCP catalog
2558
+ // tool. Always say so when we advertise any tools — otherwise the model sees
2559
+ // CreatePlan in Cursor's function definitions, notices it is absent from the
2560
+ // OpenCode list, and narrates "No CreatePlan MCP tool is available" even
2561
+ // though the provider bridges the native interaction.
2562
+ 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
2563
  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.");
2564
+ 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
2565
  }
2058
2566
  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.");
2567
+ instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
2060
2568
  }
2061
2569
  if (names.has("plan_exit")) {
2062
- instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
2570
+ 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
2571
  }
2064
2572
  if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
2065
2573
  instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
@@ -2073,8 +2581,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2073
2581
  if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
2074
2582
  instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
2075
2583
  }
2076
- if (names.has("task") || names.has("actor")) {
2077
- const target = names.has("actor") ? "`actor`" : "`task`";
2584
+ if (names.has("task")) {
2585
+ const target = "`task`";
2078
2586
  const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
2079
2587
  instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
2080
2588
  "Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
@@ -2102,8 +2610,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2102
2610
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
2103
2611
  `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
2612
  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.",
2613
+ ? "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."
2614
+ : "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
2615
  ...(instructions.length > 0
2108
2616
  ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
2109
2617
  : []),
@@ -2286,18 +2794,43 @@ export function spanEndParts(opts) {
2286
2794
  export function computeAllowTools(toolCount, toolChoice) {
2287
2795
  return toolCount > 0 && toolChoice?.type !== "none";
2288
2796
  }
2289
- export function resolveTurnToolState(input) {
2797
+ export async function resolveTurnToolState(input) {
2290
2798
  const { sessionKey, incomingTools, isCompaction } = input;
2291
2799
  if (sessionKey && incomingTools.length > 0) {
2292
2800
  rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
2293
2801
  }
2294
- const cached = sessionKey ? toolCatalogBySession.get(sessionKey) : undefined;
2295
- if (sessionKey && cached && incomingTools.length === 0) {
2296
- rememberToolCatalog(sessionKey, cached);
2802
+ // Advertisement and permission are deliberately independent.
2803
+ //
2804
+ // Advertisement must stay byte-stable across every Run of a conversation or
2805
+ // the RequestContext changes shape and Cursor's prompt cache goes cold. A
2806
+ // zero-tool call is never a smaller catalog — it is a lifecycle turn
2807
+ // (compaction, title generation, summarization) that OpenCode opens alongside
2808
+ // the real one — so it re-advertises the last real catalog rather than
2809
+ // collapsing the context to tools=0. A genuinely restricted turn still sends
2810
+ // a non-empty set and is advertised verbatim.
2811
+ //
2812
+ // On cold start the lifecycle Run may arrive before any catalog exists. For a
2813
+ // valid session key, wait until a sibling doStream publishes the first real
2814
+ // catalog; cancellation is the only escape. Never time out to tools=[] and
2815
+ // never invent or filter the enabled set.
2816
+ //
2817
+ // Permission is computed from what actually arrived, so those lifecycle turns
2818
+ // still refuse execution and cannot duplicate the real turn's side effects.
2819
+ let advertisedTools;
2820
+ if (incomingTools.length > 0) {
2821
+ advertisedTools = incomingTools;
2822
+ }
2823
+ else if (sessionKey) {
2824
+ const cached = toolCatalogBySession.get(sessionKey)
2825
+ ?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
2826
+ advertisedTools = cached.map((tool) => ({ ...tool }));
2827
+ }
2828
+ else {
2829
+ // Without a session key there is no safe sibling-correlation key. Preserve
2830
+ // the host's literal no-tool call rather than borrowing another session's
2831
+ // catalog.
2832
+ advertisedTools = [];
2297
2833
  }
2298
- const advertisedTools = isCompaction && incomingTools.length === 0
2299
- ? (cached?.map((tool) => ({ ...tool })) ?? [])
2300
- : incomingTools;
2301
2834
  return {
2302
2835
  advertisedTools,
2303
2836
  allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
@@ -2316,6 +2849,11 @@ export function resolveTurnConversationReset(input) {
2316
2849
  return { reset: false };
2317
2850
  }
2318
2851
  export function resetTurnStateForTests() {
2852
+ for (const waiters of toolCatalogWaitersBySession.values()) {
2853
+ for (const waiter of waiters)
2854
+ waiter.cancel();
2855
+ }
2856
+ toolCatalogWaitersBySession.clear();
2319
2857
  toolCatalogBySession.clear();
2320
2858
  postCompactionRebaseBySession.clear();
2321
2859
  }