cursor-opencode-provider 0.8.0 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +28 -9
  3. package/SECURITY.md +43 -7
  4. package/dist/context/build.d.ts +22 -4
  5. package/dist/context/build.js +33 -5
  6. package/dist/context/dynamic-catalog.js +6 -1
  7. package/dist/context/frozen.js +6 -2
  8. package/dist/context/index.d.ts +2 -0
  9. package/dist/context/index.js +2 -0
  10. package/dist/context/paths.d.ts +44 -17
  11. package/dist/context/paths.js +65 -18
  12. package/dist/context/skills-bridge.d.ts +22 -0
  13. package/dist/context/skills-bridge.js +20 -0
  14. package/dist/context/skills.d.ts +34 -0
  15. package/dist/context/skills.js +211 -0
  16. package/dist/host-agent-mode.d.ts +53 -4
  17. package/dist/host-agent-mode.js +117 -13
  18. package/dist/host-plan-file.d.ts +28 -0
  19. package/dist/host-plan-file.js +76 -0
  20. package/dist/image-input.d.ts +18 -0
  21. package/dist/image-input.js +119 -28
  22. package/dist/image-save.d.ts +10 -2
  23. package/dist/image-save.js +33 -21
  24. package/dist/language-model.d.ts +45 -17
  25. package/dist/language-model.js +925 -292
  26. package/dist/model-config.d.ts +9 -0
  27. package/dist/model-config.js +28 -1
  28. package/dist/opencode2/catalog.d.ts +1 -0
  29. package/dist/opencode2/catalog.js +2 -0
  30. package/dist/opencode2/image-save-tool.d.ts +8 -0
  31. package/dist/opencode2/image-save-tool.js +84 -0
  32. package/dist/opencode2/types.d.ts +15 -0
  33. package/dist/parallel-step.d.ts +65 -0
  34. package/dist/parallel-step.js +102 -0
  35. package/dist/plugin-opencode2.js +48 -40
  36. package/dist/plugin.js +69 -18
  37. package/dist/pricing-data.d.ts +14 -1
  38. package/dist/pricing-data.js +14 -1
  39. package/dist/protocol/ask-question.d.ts +12 -1
  40. package/dist/protocol/ask-question.js +77 -3
  41. package/dist/protocol/create-plan.d.ts +47 -25
  42. package/dist/protocol/create-plan.js +33 -23
  43. package/dist/protocol/interactions.d.ts +26 -17
  44. package/dist/protocol/interactions.js +80 -31
  45. package/dist/protocol/messages.js +74 -14
  46. package/dist/protocol/request.d.ts +5 -0
  47. package/dist/protocol/request.js +2 -0
  48. package/dist/protocol/switch-mode.d.ts +66 -27
  49. package/dist/protocol/switch-mode.js +147 -73
  50. package/dist/protocol/token-details.d.ts +2 -0
  51. package/dist/protocol/token-details.js +11 -1
  52. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  53. package/dist/protocol/tool-call-bridge.js +14 -0
  54. package/dist/protocol/tools.d.ts +16 -1
  55. package/dist/protocol/tools.js +113 -11
  56. package/dist/replay-safety.d.ts +6 -0
  57. package/dist/replay-safety.js +56 -6
  58. package/dist/session.d.ts +35 -1
  59. package/dist/session.js +45 -1
  60. package/dist/todo-store.d.ts +2 -0
  61. package/dist/todo-store.js +19 -0
  62. package/dist/transport/connect.d.ts +2 -0
  63. package/dist/transport/connect.js +23 -6
  64. package/dist/usage.js +23 -6
  65. package/package.json +3 -2
  66. package/dist/plan-execution-kickoff.d.ts +0 -57
  67. package/dist/plan-execution-kickoff.js +0 -186
@@ -17,18 +17,18 @@
17
17
  * through the Question service and, on "Yes", injects a synthetic user message
18
18
  * carrying `agent: "plan" | "build"`, which `createUserMessage` turns into a
19
19
  * `setAgentModel` primary-agent switch (opencode `tool/plan.ts`,
20
- * `session/prompt.ts`). A provider cannot reach `setAgentModel`, but the two
21
- * observable halves — the approval gate and the behavioural contract — are both
22
- * reachable, so the protocol bridge degrades instead of refusing when the host
23
- * tool is missing. OpenCode 2 additionally exposes `session.switchAgent`; its
20
+ * `session/prompt.ts`). OpenCode 2 exposes `session.switchAgent`; its
24
21
  * entrypoint installs a structural callback that selects the native `plan` or
25
22
  * `build` primary agent after the owning Cursor Run becomes terminal:
26
23
  *
27
24
  * - entering plan/spec needs no host tool at all — approve immediately and let
28
25
  * the injected <system_reminder> carry the contract, exactly as Cursor CLI's
29
26
  * own plan mode is prompt-enforced.
30
- * - leaving plan mode is the gate the user must actually see, so it falls back
31
- * to the host `question` tool and only rejects when that is absent too.
27
+ * - leaving the host's own `plan` agent without its `plan_exit` goes through
28
+ * CreatePlan, whose `question` prompt is the execution approval, so the
29
+ * request is rejected with a pointer there (or, with no `question`, to the
30
+ * user's agent switch); a Cursor-only plan mode has nothing on the host to
31
+ * leave and is approved.
32
32
  *
33
33
  * Resolution is keyed solely on the advertised catalog under canonical
34
34
  * OpenCode tool names — never on any external identity or model id.
@@ -36,7 +36,7 @@
36
36
  * CLI reject reason for user declines: "Mode switch rejected by user"
37
37
  * (chunk-7076/dist/ui.js onSwitchModeReject).
38
38
  */
39
- import { parseAnswerSegments, } from "./ask-question.js";
39
+ import { CURSOR_PLAN_STAGE_TOOL } from "./create-plan.js";
40
40
  import { decodeMessageSparse } from "./messages.js";
41
41
  /**
42
42
  * `PendingExec.resultField` for a held-open SwitchMode InteractionQuery.
@@ -59,13 +59,18 @@ export const MISSING_TARGET_REASON = "Missing targetModeId";
59
59
  * even though the next Run will succeed. CreatePlan already soft-acks the same
60
60
  * case; SwitchMode must match.
61
61
  */
62
- export const PLAN_EXIT_UNAVAILABLE_REASON = "Neither the OpenCode `plan_exit` tool nor the `question` tool is available to the " +
63
- "current agent, so leaving plan mode cannot be approved this turn.";
64
- /** Upstream `PlanExitTool` wording, minus a plan path the provider cannot verify. */
65
- export const SWITCH_MODE_EXIT_QUESTION = "Planning is complete. Would you like to switch to the build agent and start implementing?";
66
- export const SWITCH_MODE_EXIT_HEADER = "Build Agent";
67
- const SWITCH_MODE_EXIT_YES = "Yes";
68
- const SWITCH_MODE_EXIT_NO = "No";
62
+ /**
63
+ * The host's plan agent is active and offers no `plan_exit`: on such a host
64
+ * the user ends plan mode by switching agents, so the model cannot.
65
+ */
66
+ export const PLAN_EXIT_BY_USER_REASON = "This host leaves plan mode when the user switches from the plan agent to the build " +
67
+ "agent. Ask the user to switch agents when the plan is ready; do not implement before then.";
68
+ /**
69
+ * The host's plan agent is active, offers no `plan_exit`, but can ask: leaving
70
+ * plan mode goes through CreatePlan, whose `question` prompt is the approval.
71
+ */
72
+ export const PLAN_EXIT_VIA_CREATE_PLAN_REASON = "Leave plan mode by recording the finished plan with Cursor CreatePlan: the user is then " +
73
+ "asked whether to switch to the build agent and start implementing. Do not implement before they approve.";
69
74
  const activeCursorModeBySession = new Map();
70
75
  const MAX_ACTIVE_CURSOR_MODES = 256;
71
76
  function asRecord(value) {
@@ -97,46 +102,14 @@ export function mapSwitchModeTarget(targetModeId) {
97
102
  }
98
103
  return { ok: true, toolName: "plan_exit" };
99
104
  }
100
- /** The synthetic AskQuestion item backing a question-emulated plan exit. */
101
- function switchModeExitQuestionItem() {
102
- return {
103
- id: "switch_mode_exit",
104
- prompt: SWITCH_MODE_EXIT_QUESTION,
105
- options: [
106
- { id: "yes", label: SWITCH_MODE_EXIT_YES },
107
- { id: "no", label: SWITCH_MODE_EXIT_NO },
108
- ],
109
- allowMultiple: false,
110
- };
111
- }
112
- /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
113
- export function switchModeExitQuestionInput() {
114
- return {
115
- questions: [
116
- {
117
- question: SWITCH_MODE_EXIT_QUESTION,
118
- header: SWITCH_MODE_EXIT_HEADER,
119
- options: [
120
- {
121
- label: SWITCH_MODE_EXIT_YES,
122
- description: "Switch to build agent and start implementing the plan",
123
- },
124
- {
125
- label: SWITCH_MODE_EXIT_NO,
126
- description: "Stay with plan agent to continue refining the plan",
127
- },
128
- ],
129
- },
130
- ],
131
- };
132
- }
133
105
  /**
134
106
  * Resolve how this SwitchMode target is satisfied, keyed only on the advertised
135
107
  * catalog under canonical tool names.
136
108
  *
137
- * A missing plan tool is not a refusal: entering plan mode needs no host tool,
138
- * and leaving it degrades to the `question` prompt so the user still approves
139
- * before any execution starts.
109
+ * A missing plan tool is not a refusal: entering plan mode needs no host tool.
110
+ * Leaving the host's own plan agent without `plan_exit` is approved through
111
+ * CreatePlan (or the user's agent switch when nothing can ask), and a
112
+ * Cursor-only plan mode (no host plan agent) has nothing on the host to leave.
140
113
  */
141
114
  export function resolveSwitchModeBridge(targetModeId, options) {
142
115
  const mapped = mapSwitchModeTarget(targetModeId);
@@ -162,11 +135,13 @@ export function resolveSwitchModeBridge(targetModeId, options) {
162
135
  // whole contract, exactly as Cursor CLI's own plan mode is prompt-enforced.
163
136
  if (mapped.toolName === "plan_enter")
164
137
  return { kind: "approve" };
165
- // Leaving plan mode starts real work, so it keeps a user-visible gate.
166
- if (names.has("question")) {
167
- return { kind: "question", input: switchModeExitQuestionInput() };
138
+ if (options.hostAgent === "plan") {
139
+ return {
140
+ kind: "reject",
141
+ reason: names.has("question") ? PLAN_EXIT_VIA_CREATE_PLAN_REASON : PLAN_EXIT_BY_USER_REASON,
142
+ };
168
143
  }
169
- return { kind: "reject", reason: PLAN_EXIT_UNAVAILABLE_REASON };
144
+ return { kind: "approve" };
170
145
  }
171
146
  /** Decode a `switch_mode_request_query` body, or undefined when unusable. */
172
147
  export function decodeSwitchModeQuery(queryBytes) {
@@ -222,20 +197,6 @@ export function switchModeResultFromToolOutput(output, isError) {
222
197
  }
223
198
  return switchModeRejectedResult(trimmed);
224
199
  }
225
- /**
226
- * Host `question` outcome → Cursor SwitchModeRequestResponse, for the emulated
227
- * plan exit. Only an explicit "Yes" approves; an unanswered or dismissed prompt
228
- * keeps the model in plan mode rather than silently starting execution.
229
- */
230
- export function switchModeResultFromQuestionOutput(output, isError) {
231
- if (isError)
232
- return switchModeResultFromToolOutput(output, true);
233
- const [segment] = parseAnswerSegments([switchModeExitQuestionItem()], output);
234
- const answer = (segment ?? "").trim().toLowerCase();
235
- if (answer === SWITCH_MODE_EXIT_YES.toLowerCase())
236
- return switchModeApprovedResult();
237
- return switchModeRejectedResult(USER_REJECTED_REASON);
238
- }
239
200
  /** Record the approved Cursor mode for later system-reminder injection. */
240
201
  export function setActiveCursorMode(sessionKey, targetModeId, options = {}) {
241
202
  if (!sessionKey)
@@ -265,13 +226,121 @@ export function isCursorPlanModeActive(sessionKey) {
265
226
  const mode = getActiveCursorMode(sessionKey);
266
227
  return mode === "plan" || mode === "spec";
267
228
  }
268
- /** True after a bridged plan/spec Run has observed plan_enter removed by the host. */
229
+ /** True while Cursor plan/spec mode is one the host enforces (see `bridgedPlanEntered`). */
269
230
  export function isBridgedCursorPlanModeActive(sessionKey) {
270
231
  if (!sessionKey)
271
232
  return false;
272
233
  const state = activeCursorModeBySession.get(sessionKey);
273
234
  return (state?.modeId === "plan" || state?.modeId === "spec") && state.bridgedPlanEntered;
274
235
  }
236
+ /**
237
+ * Cursor plan mode follows the host's `plan` primary agent, as Cursor CLI's
238
+ * mode follows its own mode switch (Shift+Tab, `/plan`).
239
+ *
240
+ * - A turn under the host plan agent puts Cursor in plan mode, however the
241
+ * host got there (its agent picker, a plan_enter approval).
242
+ * - The host's own exit from that agent (a plan_exit approval, or an agent
243
+ * switch in its UI) ends Cursor plan mode. Only an observed `plan` → other
244
+ * transition counts, so Cursor-only modes (debug, ask, …) under the host's
245
+ * ordinary agent are kept; the next Run then carries the agent-mode
246
+ * reminder instead of a stale "plan mode is still active" one.
247
+ */
248
+ export function followHostPlanAgent(sessionKey, previousHostAgent, hostAgent) {
249
+ if (!sessionKey || !hostAgent)
250
+ return undefined;
251
+ if (hostAgent === "plan") {
252
+ if (isBridgedCursorPlanModeActive(sessionKey))
253
+ return undefined;
254
+ // The host runs its own plan agent, so it enforces this plan mode.
255
+ setActiveCursorMode(sessionKey, "plan", { bridgedPlanEntered: true });
256
+ return "entered";
257
+ }
258
+ if (previousHostAgent !== "plan" || !isCursorPlanModeActive(sessionKey))
259
+ return undefined;
260
+ setActiveCursorMode(sessionKey, "agent");
261
+ return "left";
262
+ }
263
+ /**
264
+ * `agent.v1.AgentMode` for a Cursor mode id, as Cursor CLI derives
265
+ * `UserMessage.mode` from its current mode: plan, ask, and debug are their own
266
+ * modes; every other mode (agent, spec, triage, …) runs as agent.
267
+ */
268
+ export function cursorAgentModeWireValue(modeId) {
269
+ switch (normalizeSwitchModeId(modeId ?? "")) {
270
+ case "plan":
271
+ return 3;
272
+ case "chat":
273
+ case "ask":
274
+ case "search":
275
+ return 2;
276
+ case "debug":
277
+ return 4;
278
+ default:
279
+ return 1;
280
+ }
281
+ }
282
+ /** Conversation that already received the host plan-exit call shape, per session. */
283
+ const hostPlanExitNoteBySession = new Map();
284
+ function describeToolArguments(schema) {
285
+ const record = asRecord(schema);
286
+ const properties = asRecord(record?.properties);
287
+ const names = properties ? Object.keys(properties) : [];
288
+ if (names.length === 0)
289
+ return "It takes no arguments: call it with `{}`.";
290
+ const required = new Set(Array.isArray(record?.required) ? record.required.filter((name) => typeof name === "string") : []);
291
+ const lines = names.map((name) => {
292
+ const property = asRecord(properties[name]);
293
+ const type = str(property?.type) || "value";
294
+ const description = str(property?.description).trim();
295
+ return `- \`${name}\` (${type}, ${required.has(name) ? "required" : "optional"})` +
296
+ (description ? `: ${description}` : "");
297
+ });
298
+ return `Its arguments (use exactly these names):\n${lines.join("\n")}`;
299
+ }
300
+ /**
301
+ * One-shot translation of the host plan agent's workflow into Cursor terms,
302
+ * delivered once per Cursor conversation while that agent owns the turn.
303
+ *
304
+ * With the host's plan file known, Cursor's native CreatePlan carries out the
305
+ * host instruction "write the plan file, then call plan_exit" (see the `exit`
306
+ * CreatePlan bridge), so the model needs no host tool at all. Without it, the
307
+ * model is given `plan_exit`'s exact call shape from the live schema: the
308
+ * RequestContext overlay is names-only, and otherwise it must look the tool up
309
+ * (or guess its arguments) before it can submit the plan.
310
+ */
311
+ export function takeHostPlanAgentNote(sessionKey, conversationId, hostAgent, tools, options = {}) {
312
+ if (!sessionKey)
313
+ return undefined;
314
+ if (hostAgent !== "plan") {
315
+ hostPlanExitNoteBySession.delete(sessionKey);
316
+ return undefined;
317
+ }
318
+ const planExit = tools.find((tool) => tool.name === "plan_exit");
319
+ // A host plan-stage tool carries the plan to the host review itself.
320
+ if (tools.some((tool) => tool.name === CURSOR_PLAN_STAGE_TOOL))
321
+ return undefined;
322
+ if (!planExit || hostPlanExitNoteBySession.get(sessionKey) === conversationId)
323
+ return undefined;
324
+ hostPlanExitNoteBySession.delete(sessionKey);
325
+ hostPlanExitNoteBySession.set(sessionKey, conversationId);
326
+ while (hostPlanExitNoteBySession.size > MAX_ACTIVE_CURSOR_MODES) {
327
+ const oldest = hostPlanExitNoteBySession.keys().next().value;
328
+ if (!oldest)
329
+ break;
330
+ hostPlanExitNoteBySession.delete(oldest);
331
+ }
332
+ if (options.hostPlanFile) {
333
+ return wrapReminder("The host plan agent is active. When the plan is ready, record it with CreatePlan. " +
334
+ `CreatePlan saves it as the host's plan file (${options.hostPlanFile}) and submits it ` +
335
+ "to the host's plan review, which asks the user; this carries out the host " +
336
+ "instructions to write the plan file and call plan_exit, so do neither yourself. " +
337
+ "Do not implement until the user approves.");
338
+ }
339
+ return wrapReminder("The host plan agent is active. When the plan file is written and ready for review, " +
340
+ `submit it by calling MCP tool \`plan_exit\` on server \`${options.server ?? "opencode"}\` directly. ` +
341
+ "Its full definition is below, so do not look it up with GetMcpTools first. " +
342
+ describeToolArguments(planExit.inputSchema));
343
+ }
275
344
  export function clearActiveCursorMode(sessionKey) {
276
345
  if (!sessionKey)
277
346
  return;
@@ -279,6 +348,7 @@ export function clearActiveCursorMode(sessionKey) {
279
348
  }
280
349
  export function resetActiveCursorModesForTests() {
281
350
  activeCursorModeBySession.clear();
351
+ hostPlanExitNoteBySession.clear();
282
352
  }
283
353
  function wrapReminder(body) {
284
354
  return `<system_reminder>\n${body.trim()}\n</system_reminder>`;
@@ -295,12 +365,15 @@ export function cursorModeSystemReminder(targetModeId, options = {}) {
295
365
  const first = options.firstTurn !== false;
296
366
  // A host plan-stage tool writes the artifact and its result names the
297
367
  // follow-up that requests approval. plan_exit only leaves plan mode.
298
- // Without either tool, recording the plan is the gate and the provider asks
299
- // whether to start implementing. Naming an unavailable tool would strand the model.
368
+ // Without either, CreatePlan asks through `question` whether to implement;
369
+ // with no `question` either, the user leaves plan mode by switching agents.
370
+ // Naming an unavailable tool would strand the model.
300
371
  const leavePlan = options.planStageAdvertised
301
372
  ? "record the finished plan with Cursor CreatePlan. The host stage tool waits for the host plan review and does not return until the user accepts or declines. Do not call `plan_exit` to submit or skip that review, and do not implement until the tool returns success"
302
373
  : options.planExitAdvertised === false
303
- ? "record the finished plan (Cursor CreatePlan). Writing it needs no approval, and the user is then asked whether to start implementing; if they decline, refine the plan and record it again"
374
+ ? options.questionAdvertised
375
+ ? "record the finished plan with Cursor CreatePlan. Writing it needs no approval; the user is then asked whether to switch to the build agent and start implementing. If they decline, refine the plan and record it again"
376
+ : "record the finished plan (Cursor CreatePlan), then tell the user it is ready: they switch to the build agent to have it implemented"
304
377
  : "record the finished plan, then call OpenCode `plan_exit` so the user can approve leaving plan mode";
305
378
  if (id === "plan" || id === "spec") {
306
379
  return wrapReminder(first
@@ -413,7 +486,7 @@ export function takeActiveCursorModeReminder(sessionKey, options = {}) {
413
486
  : new Set(options.advertisedTools)
414
487
  : undefined;
415
488
  const isPlanMode = state.modeId === "plan" || state.modeId === "spec";
416
- if (isPlanMode && state.bridgedPlanEntered && advertised) {
489
+ if (isPlanMode && state.bridgedPlanEntered && advertised && !options.hostAgent) {
417
490
  if (advertised.has("plan_enter")) {
418
491
  activeCursorModeBySession.delete(sessionKey);
419
492
  return cursorModeSystemReminder("agent", { firstTurn: true });
@@ -425,6 +498,7 @@ export function takeActiveCursorModeReminder(sessionKey, options = {}) {
425
498
  ? {
426
499
  planExitAdvertised: advertised.has("plan_exit"),
427
500
  planStageAdvertised: advertised.has("cursor_plan_stage"),
501
+ questionAdvertised: advertised.has("question"),
428
502
  }
429
503
  : {}),
430
504
  });
@@ -15,6 +15,8 @@ export type CursorConversationTokenDetails = {
15
15
  breakdown?: CursorPromptTokenBreakdown;
16
16
  };
17
17
  export type CursorContextUsageSource = "checkpoint-current-run" | "checkpoint-previous-turn";
18
+ /** Cursor can update occupancy while retaining categories from an older checkpoint. */
19
+ export declare function currentCursorTokenBreakdown(details: CursorConversationTokenDetails | undefined): CursorPromptTokenBreakdown | undefined;
18
20
  /**
19
21
  * Read field #5 (`ConversationTokenDetails`) from Cursor's opaque
20
22
  * `ConversationStateStructure` checkpoint without decoding or re-encoding the
@@ -1,4 +1,13 @@
1
1
  import protobuf from "protobufjs";
2
+ /** Cursor can update occupancy while retaining categories from an older checkpoint. */
3
+ export function currentCursorTokenBreakdown(details) {
4
+ const breakdown = details?.breakdown;
5
+ return breakdown
6
+ && breakdown.totalUsedTokens === details.usedTokens
7
+ && breakdown.maxTokens === details.maxTokens
8
+ ? breakdown
9
+ : undefined;
10
+ }
2
11
  const MAX_BREAKDOWN_CATEGORIES = 128;
3
12
  function requireWireType(actual, expected, field) {
4
13
  if (actual !== expected)
@@ -142,6 +151,7 @@ export function cursorContextUsageMetadata(details, source = "checkpoint-current
142
151
  const usedPercent = details.maxTokens > 0
143
152
  ? Math.max(0, Math.min(100, Math.round(details.usedTokens / details.maxTokens * 1_000) / 10))
144
153
  : undefined;
154
+ const breakdown = currentCursorTokenBreakdown(details);
145
155
  return {
146
156
  contextUsageVersion: 2,
147
157
  source,
@@ -150,6 +160,6 @@ export function cursorContextUsageMetadata(details, source = "checkpoint-current
150
160
  maxTokens: details.maxTokens,
151
161
  remainingTokens: Math.max(0, details.maxTokens - details.usedTokens),
152
162
  ...(usedPercent === undefined ? {} : { usedPercent }),
153
- ...(details.breakdown ? { breakdown: details.breakdown } : {}),
163
+ ...(breakdown ? { breakdown } : {}),
154
164
  };
155
165
  }
@@ -22,6 +22,10 @@ export type BridgedOpenCodeToolCall = {
22
22
  };
23
23
  /** Display variants Cursor executes itself (mcp_state + optional write spill). */
24
24
  export declare function isNativeDisplayToolCall(variant: string): boolean;
25
+ /** Server-side dynamic-call failures may complete without a started/exec frame. */
26
+ export declare function displayMcpToolError(toolCall: Record<string, unknown> | undefined): string | undefined;
27
+ /** Native discovery errors are server-side results, never host execution. */
28
+ export declare function displayNativeDiscoveryError(toolCall: Record<string, unknown> | undefined): string | undefined;
25
29
  /**
26
30
  * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
27
31
  * undefined when the input is not a todo array (nothing to learn). Drops the
@@ -69,6 +69,20 @@ export function isNativeDisplayToolCall(variant) {
69
69
  function asRecord(v) {
70
70
  return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
71
71
  }
72
+ /** Server-side dynamic-call failures may complete without a started/exec frame. */
73
+ export function displayMcpToolError(toolCall) {
74
+ const call = asRecord(toolCall?.mcp_tool_call);
75
+ const result = asRecord(call?.result);
76
+ const error = asRecord(result?.error);
77
+ const parts = [error?.error, error?.detail].filter((value) => typeof value === "string" && value.length > 0);
78
+ return parts.length > 0 ? parts.join(": ") : undefined;
79
+ }
80
+ /** Native discovery errors are server-side results, never host execution. */
81
+ export function displayNativeDiscoveryError(toolCall) {
82
+ const call = asRecord(toolCall?.get_mcp_tools_tool_call);
83
+ const error = asRecord(asRecord(call?.result)?.error)?.error;
84
+ return typeof error === "string" && error.length > 0 ? error : undefined;
85
+ }
72
86
  function findToolVariant(toolCall) {
73
87
  for (const key of TOOL_CALL_VARIANTS) {
74
88
  if (toolCall[key] != null)
@@ -1,3 +1,4 @@
1
+ import type { CursorImageInput } from "../image-input.js";
1
2
  import { type CursorExecVariant } from "./exec-variants.js";
2
3
  import { type CursorShellOutcome } from "../shell-timeout.js";
3
4
  export declare const REQUEST_CONTEXT_RESULT_FIELD = 10;
@@ -8,6 +9,10 @@ export type OpencodeToolDef = {
8
9
  /** Original flattened identity when `name` is a Cursor-facing alias. */
9
10
  sourceName?: string;
10
11
  };
12
+ /** Check explicit required keys after normalization, before host execution. */
13
+ export declare function missingRequiredToolArguments(tool: OpencodeToolDef | undefined, args: Record<string, unknown>): string[];
14
+ /** Canonical file/search tools must not silently ignore a misplaced shell command. */
15
+ export declare function misplacedShellCommand(tool: OpencodeToolDef | undefined, args: Record<string, unknown>): boolean;
11
16
  /** Host file-tool argument key. OpenCode 1.x uses `filePath`; 2.0 uses `path`. */
12
17
  export type HostFilePathKey = "path" | "filePath";
13
18
  /** Host shell tool id. OpenCode 1.x uses `bash`; 2.0 uses `shell`. */
@@ -18,6 +23,7 @@ export type HostToolDialect = {
18
23
  filePathKey: HostFilePathKey;
19
24
  shellTool: HostShellTool;
20
25
  skillArgKey: HostSkillArgKey;
26
+ shellDescription?: "optional" | "required";
21
27
  };
22
28
  export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
23
29
  export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
@@ -280,7 +286,16 @@ export type ToolResultInput = {
280
286
  * often $HOME and would re-advertise the wrong folder to the model.
281
287
  */
282
288
  workspaceRoot?: string;
289
+ /** Images the host tool returned; see `execResultImages` for which results carry them. */
290
+ images?: readonly CursorImageInput[];
283
291
  };
292
+ /**
293
+ * The leading tool-result images an exec result can carry on a held Run.
294
+ * Cursor's own read executor answers an image file with its bytes in
295
+ * `ReadSuccess.data` (#5) and no text; its MCP executor appends each MCP image
296
+ * as an `McpImageContent` item (#2). Other result shapes have no image field.
297
+ */
298
+ export declare function execResultImages(resultField: string, images: readonly CursorImageInput[] | undefined): CursorImageInput[];
284
299
  /**
285
300
  * Build one or more ExecClientMessage frames for a tool result.
286
301
  * Shell replies are a sequence of ShellStream oneofs under the same id —
@@ -321,7 +336,7 @@ export declare function unwrapReadOutput(output: string): string;
321
336
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
322
337
  * server accepts (verified against agent.v1 wire captures).
323
338
  */
324
- export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string, resultMetadata?: Record<string, unknown>, shellOutcome?: CursorShellOutcome, workspaceRoot?: string): Record<string, unknown>;
339
+ export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string, resultMetadata?: Record<string, unknown>, shellOutcome?: CursorShellOutcome, workspaceRoot?: string, images?: readonly CursorImageInput[]): Record<string, unknown>;
325
340
  export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
326
341
  toolCallId: string;
327
342
  toolName: string;
@@ -9,10 +9,36 @@ import { trace, traceRequestContextPaths } from "../debug.js";
9
9
  import { cursorExecVariantByRequestName, FORCE_BACKGROUND_STATUS_ERROR, } from "./exec-variants.js";
10
10
  import { APPLY_PATCH_TOOL, buildAddFilePatch, buildUpdateFilePatch, planSubstringEdit, } from "./apply-patch.js";
11
11
  import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
12
+ import { normalizeOpencodeQuestionArgs } from "./ask-question.js";
13
+ import { normalizeOpencodeTodoArgs } from "../todo-store.js";
12
14
  // Exec variant field number whose reply is the server-initiated request_context
13
15
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
14
16
  // field number for every exec variant, so this is also the result field.
15
17
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
18
+ /** Check explicit required keys after normalization, before host execution. */
19
+ export function missingRequiredToolArguments(tool, args) {
20
+ const schema = tool?.inputSchema;
21
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
22
+ return [];
23
+ const required = schema.required;
24
+ if (!Array.isArray(required))
25
+ return [];
26
+ return required.filter((key) => typeof key === "string" && (!Object.hasOwn(args, key) || args[key] === undefined));
27
+ }
28
+ /** Canonical file/search tools must not silently ignore a misplaced shell command. */
29
+ export function misplacedShellCommand(tool, args) {
30
+ if (!tool || args.command === undefined)
31
+ return false;
32
+ if (!["read", "write", "edit", "grep", "glob", "ls"].includes(tool.name))
33
+ return false;
34
+ const schema = tool.inputSchema;
35
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
36
+ return false;
37
+ const properties = schema.properties;
38
+ if (!properties || typeof properties !== "object" || Array.isArray(properties))
39
+ return false;
40
+ return !Object.hasOwn(properties, "command");
41
+ }
16
42
  export const OPENCODE_1_TOOL_DIALECT = {
17
43
  filePathKey: "filePath",
18
44
  shellTool: "bash",
@@ -88,7 +114,20 @@ export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL
88
114
  ? "id"
89
115
  : defaultDialect.skillArgKey;
90
116
  }
91
- return { filePathKey, shellTool, skillArgKey };
117
+ const shellSchema = tools.find((tool) => tool.name === shellTool)?.inputSchema;
118
+ const shellProps = jsonSchemaProperties(shellSchema);
119
+ const description = shellProps?.description;
120
+ const required = shellSchema && typeof shellSchema === "object" && !Array.isArray(shellSchema)
121
+ ? shellSchema.required
122
+ : undefined;
123
+ return {
124
+ filePathKey, shellTool, skillArgKey,
125
+ ...(description && typeof description === "object"
126
+ && description.type === "string"
127
+ ? { shellDescription: Array.isArray(required) && required.includes("description")
128
+ ? "required" : "optional" }
129
+ : {}),
130
+ };
92
131
  }
93
132
  function assignHostFilePath(args, filePath, dialect) {
94
133
  delete args.filePath;
@@ -1163,10 +1202,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1163
1202
  // as any other bash tool call.
1164
1203
  if (execVariant === "delete_args") {
1165
1204
  const target = str(cleaned.path) ?? str(cleaned.filePath);
1166
- return {
1167
- toolName: dialect.shellTool,
1168
- args: target ? { command: `rm -f -- ${shellQuote(target)}` } : { command: "true" },
1169
- };
1205
+ return mapCursorArgsToOpencode(dialect.shellTool, {
1206
+ command: target ? `rm -f -- ${shellQuote(target)}` : "true",
1207
+ }, undefined, dialect);
1170
1208
  }
1171
1209
  switch (toolName) {
1172
1210
  case "read": {
@@ -1237,6 +1275,14 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1237
1275
  const command = str(cleaned.command);
1238
1276
  if (command)
1239
1277
  args.command = command;
1278
+ if (dialect.shellDescription) {
1279
+ const description = str(cleaned.description)
1280
+ ?? (dialect.shellDescription === "required" && command
1281
+ ? `Run: ${command.length > 60 ? `${command.slice(0, 57)}...` : command}`
1282
+ : undefined);
1283
+ if (description)
1284
+ args.description = description;
1285
+ }
1240
1286
  const workdir = str(cleaned.workdir) ?? str(cleaned.working_directory);
1241
1287
  if (workdir)
1242
1288
  args.workdir = workdir;
@@ -1288,6 +1334,22 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1288
1334
  args[dialect.skillArgKey] = value;
1289
1335
  return { toolName: "skill", args };
1290
1336
  }
1337
+ case "todowrite": {
1338
+ // OpenCode requires `priority` on every todo item. Cursor TodoWrite /
1339
+ // model MCP calls often omit it; fill host defaults before validation.
1340
+ return {
1341
+ toolName: "todowrite",
1342
+ args: normalizeOpencodeTodoArgs(cleaned),
1343
+ };
1344
+ }
1345
+ case "question": {
1346
+ // OpenCode requires `header` on every question. Model MCP calls often
1347
+ // omit it (AskQuestion bridge already fills it); default before validation.
1348
+ return {
1349
+ toolName: "question",
1350
+ args: normalizeOpencodeQuestionArgs(cleaned),
1351
+ };
1352
+ }
1291
1353
  default:
1292
1354
  return { toolName, args: cleaned };
1293
1355
  }
@@ -1539,6 +1601,24 @@ export function buildReadRejectionMessages(execId, readResult) {
1539
1601
  buildExecStreamClose(execId),
1540
1602
  ];
1541
1603
  }
1604
+ /**
1605
+ * The leading tool-result images an exec result can carry on a held Run.
1606
+ * Cursor's own read executor answers an image file with its bytes in
1607
+ * `ReadSuccess.data` (#5) and no text; its MCP executor appends each MCP image
1608
+ * as an `McpImageContent` item (#2). Other result shapes have no image field.
1609
+ */
1610
+ export function execResultImages(resultField, images) {
1611
+ if (!images?.length)
1612
+ return [];
1613
+ if (resultField === "read_result")
1614
+ return [images[0]];
1615
+ if (resultField === "mcp_result")
1616
+ return [...images];
1617
+ return [];
1618
+ }
1619
+ function mcpImageItems(images) {
1620
+ return images.map((image) => ({ image: { data: image.data, mime_type: image.mimeType } }));
1621
+ }
1542
1622
  /**
1543
1623
  * Build one or more ExecClientMessage frames for a tool result.
1544
1624
  * Shell replies are a sequence of ShellStream oneofs under the same id —
@@ -1594,7 +1674,7 @@ export function buildExecClientMessages(input) {
1594
1674
  id: input.execId,
1595
1675
  local_execution_time_ms: input.executionTimeMs ?? 0,
1596
1676
  };
1597
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot);
1677
+ clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot, execResultImages(resultField, input.images));
1598
1678
  frames.push(encodeMessage("AgentClientMessage", {
1599
1679
  exec_client_message: clientMsg,
1600
1680
  }));
@@ -2463,7 +2543,7 @@ export function unwrapReadOutput(output) {
2463
2543
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
2464
2544
  * server accepts (verified against agent.v1 wire captures).
2465
2545
  */
2466
- export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
2546
+ export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot, images = []) {
2467
2547
  // Prefer the session workspace; never advertise the host process cwd (daemon
2468
2548
  // often starts in $HOME) as the path Cursor shows the model for glob/ls.
2469
2549
  const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
@@ -2483,6 +2563,18 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2483
2563
  const readPath = resolveToolPath(rawPath, resultRoot);
2484
2564
  if (error)
2485
2565
  return { error: { path: readPath, error } };
2566
+ const image = images[0];
2567
+ if (image) {
2568
+ return {
2569
+ success: {
2570
+ path: readPath,
2571
+ data: image.data,
2572
+ total_lines: 0,
2573
+ file_size: readFileSize(readPath) || image.data.length,
2574
+ truncated: false,
2575
+ },
2576
+ };
2577
+ }
2486
2578
  // The host may have resolved an alternate spelling after the request
2487
2579
  // (for example a Unicode-space filename). Use the result's own path for
2488
2580
  // local stat/newline recovery while preserving the requested path in the
@@ -2750,18 +2842,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2750
2842
  if (toolName === "grep" || toolName === "glob") {
2751
2843
  return {
2752
2844
  success: {
2753
- content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
2845
+ content: [{ text: { text: groundSearchOutput(output, resultRoot) } }, ...mcpImageItems(images)],
2754
2846
  is_error: false,
2755
2847
  },
2756
2848
  };
2757
2849
  }
2758
2850
  if (toolName !== "read") {
2759
- return { success: { content: [{ text: { text: output } }], is_error: false } };
2851
+ return {
2852
+ success: { content: [{ text: { text: output } }, ...mcpImageItems(images)], is_error: false },
2853
+ };
2760
2854
  }
2761
2855
  const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2762
2856
  if (listing) {
2763
2857
  return {
2764
- success: { content: [{ text: { text: listing.text } }], is_error: false },
2858
+ success: { content: [{ text: { text: listing.text } }, ...mcpImageItems(images)], is_error: false },
2765
2859
  };
2766
2860
  }
2767
2861
  // Carry the truncation notice as its own content item: the file content
@@ -2775,6 +2869,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
2775
2869
  content: [
2776
2870
  { text: { text: unwrapReadOutput(output) } },
2777
2871
  ...notices.map((notice) => ({ text: { text: notice } })),
2872
+ ...mcpImageItems(images),
2778
2873
  ],
2779
2874
  is_error: false,
2780
2875
  },
@@ -3226,7 +3321,14 @@ export function buildMcpStateResult(execId, args, toolDescriptors) {
3226
3321
  }
3227
3322
  list.push({
3228
3323
  name: stringValue(tool.name) ?? `${server}-${toolName}`,
3229
- description: stringValue(tool.description) ?? "",
3324
+ // GetDynamicTools exposes the inner schema without the outer call identity.
3325
+ // Keep the exact identity in the definition, including truncated searches.
3326
+ description: `CallDynamicTool identity: ${JSON.stringify({ namespace: server, toolName })}. `
3327
+ + `Complete outer envelope: ${JSON.stringify({ namespace: server, toolName, arguments: {} })}. `
3328
+ + "Replace arguments with this inputSchema's object; keep both identity fields outside it. "
3329
+ + "Write namespace and toolName FIRST, then arguments containing this inputSchema's object. "
3330
+ + "All three outer fields are required on every invocation.\n\n"
3331
+ + (stringValue(tool.description) ?? ""),
3230
3332
  input_schema: tool.input_schema,
3231
3333
  provider_identifier: server,
3232
3334
  tool_name: toolName,