cursor-opencode-provider 0.4.3 → 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 (71) hide show
  1. package/README.md +33 -23
  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 +11 -1
  6. package/dist/context/build.js +76 -25
  7. package/dist/context/frozen.d.ts +29 -0
  8. package/dist/context/frozen.js +168 -0
  9. package/dist/context/index.d.ts +3 -2
  10. package/dist/context/index.js +3 -2
  11. package/dist/context/paths.d.ts +34 -53
  12. package/dist/context/paths.js +54 -118
  13. package/dist/context/rules.d.ts +1 -0
  14. package/dist/context/rules.js +2 -2
  15. package/dist/image-save-tool.d.ts +19 -0
  16. package/dist/image-save-tool.js +25 -0
  17. package/dist/image-save.d.ts +58 -0
  18. package/dist/image-save.js +189 -0
  19. package/dist/image-staging.d.ts +59 -0
  20. package/dist/image-staging.js +91 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/language-model.d.ts +13 -11
  23. package/dist/language-model.js +957 -150
  24. package/dist/plan-execution-kickoff.d.ts +55 -0
  25. package/dist/plan-execution-kickoff.js +162 -0
  26. package/dist/plugin-opencode2.js +52 -3
  27. package/dist/plugin-v2.d.ts +11 -9
  28. package/dist/plugin-v2.js +6 -13
  29. package/dist/plugin.d.ts +9 -0
  30. package/dist/plugin.js +61 -5
  31. package/dist/pricing-data.d.ts +16 -4
  32. package/dist/pricing-data.js +16 -4
  33. package/dist/protocol/ask-question.d.ts +133 -0
  34. package/dist/protocol/ask-question.js +320 -0
  35. package/dist/protocol/blob-reachability.d.ts +15 -0
  36. package/dist/protocol/blob-reachability.js +140 -0
  37. package/dist/protocol/blob-store.d.ts +37 -0
  38. package/dist/protocol/blob-store.js +115 -0
  39. package/dist/protocol/conversation-bind.d.ts +13 -0
  40. package/dist/protocol/conversation-bind.js +27 -1
  41. package/dist/protocol/conversation-persistence.d.ts +32 -0
  42. package/dist/protocol/conversation-persistence.js +504 -0
  43. package/dist/protocol/conversation-state.d.ts +16 -0
  44. package/dist/protocol/conversation-state.js +62 -0
  45. package/dist/protocol/create-plan.d.ts +141 -0
  46. package/dist/protocol/create-plan.js +373 -0
  47. package/dist/protocol/generate-image.d.ts +54 -0
  48. package/dist/protocol/generate-image.js +101 -0
  49. package/dist/protocol/interactions.d.ts +110 -8
  50. package/dist/protocol/interactions.js +295 -35
  51. package/dist/protocol/kv.d.ts +1 -0
  52. package/dist/protocol/kv.js +2 -0
  53. package/dist/protocol/messages.d.ts +2 -0
  54. package/dist/protocol/messages.js +148 -8
  55. package/dist/protocol/request.d.ts +2 -0
  56. package/dist/protocol/request.js +2 -0
  57. package/dist/protocol/switch-mode.d.ts +178 -0
  58. package/dist/protocol/switch-mode.js +417 -0
  59. package/dist/protocol/token-details.d.ts +33 -0
  60. package/dist/protocol/token-details.js +155 -0
  61. package/dist/protocol/tool-call-bridge.js +10 -5
  62. package/dist/protocol/tools.d.ts +44 -11
  63. package/dist/protocol/tools.js +171 -41
  64. package/dist/session.d.ts +39 -11
  65. package/dist/shared.d.ts +3 -0
  66. package/dist/shared.js +3 -0
  67. package/dist/usage.d.ts +42 -37
  68. package/dist/usage.js +201 -70
  69. package/dist/web-search-tool.d.ts +22 -26
  70. package/dist/web-search-tool.js +25 -15
  71. package/package.json +7 -4
@@ -1,14 +1,98 @@
1
+ import { type AskQuestionResultMessage, type DecodedAskQuestionQuery } from "./ask-question.js";
2
+ import { type DecodedGenerateImageQuery } from "./generate-image.js";
3
+ import { CURSOR_PLAN_STAGE_TOOL, type CreatePlanBridge, type DecodedCreatePlanQuery, createPlanApprovalQuestionInput } from "./create-plan.js";
4
+ import { type DecodedSwitchModeQuery, type SwitchModeBridge, type SwitchModeHostTool } from "./switch-mode.js";
1
5
  export type InteractionQueryWireInfo = {
2
6
  id?: number;
3
7
  variantField?: number;
4
8
  variantName?: string;
9
+ /** Raw bytes of the selected variant sub-message, when it is length-delimited. */
10
+ variantBytes?: Uint8Array;
5
11
  };
6
12
  export type HandledInteraction = {
7
13
  id: number;
8
14
  variantField: number;
9
15
  variantName: string;
10
- outcome: "rejected" | "acknowledged" | "failed";
11
- reply: Uint8Array;
16
+ outcome: "rejected" | "acknowledged" | "failed" | "bridged" | "approved";
17
+ /**
18
+ * Immediate reply for this query. Absent for bridged *synchronous* AskQuestion
19
+ * or SwitchMode: Cursor blocks until the host tool returns, exactly as its own
20
+ * CLI does while the user is choosing / approving the mode switch.
21
+ */
22
+ reply?: Uint8Array;
23
+ /** Present when `outcome === "bridged"`: the question set to hand OpenCode. */
24
+ askQuestion?: DecodedAskQuestionQuery;
25
+ /**
26
+ * Present when SwitchMode should mutate session mode — bridged to a host
27
+ * tool, or approved outright when entering plan mode needs none. Absent on
28
+ * lifecycle soft-acks (`ack`): those reply `approved{}` without recording
29
+ * a mode. The caller reads `bridge` to decide whether a tool call is emitted,
30
+ * and `args.targetModeId` to record the active Cursor mode when present.
31
+ */
32
+ switchMode?: DecodedSwitchModeQuery & {
33
+ bridge: SwitchModeBridge;
34
+ toolName?: SwitchModeHostTool | "question";
35
+ };
36
+ /**
37
+ * Present when CreatePlan needs a host tool: either a native plan stage that
38
+ * owns the write *and* the approval, or the emulated `question` prompt the
39
+ * provider raises after writing the plan itself. `planUri` is set only for
40
+ * the emulated path, where the provider already knows the file it wrote.
41
+ */
42
+ createPlan?: DecodedCreatePlanQuery & {
43
+ bridge: CreatePlanBridge;
44
+ toolName: typeof CURSOR_PLAN_STAGE_TOOL | "question";
45
+ planUri?: string;
46
+ /** Filesystem path of the plan the provider just wrote (emulated path). */
47
+ planPath?: string;
48
+ questionInput?: ReturnType<typeof createPlanApprovalQuestionInput>;
49
+ /** The plan to show the user before they approve it (emulated path only). */
50
+ planReview?: string;
51
+ };
52
+ /** Present when an image generation was approved: the target to expect. */
53
+ generateImage?: DecodedGenerateImageQuery;
54
+ };
55
+ export type HandleInteractionQueryOptions = {
56
+ /**
57
+ * True when the host advertises `question` on this turn and tool calls are
58
+ * allowed, i.e. the provider can actually surface the prompt to the user.
59
+ */
60
+ canBridgeAskQuestion?: boolean;
61
+ /**
62
+ * True when tool calls are allowed this turn (needed before advertising can
63
+ * be checked for plan_enter / plan_exit).
64
+ */
65
+ allowTools?: boolean;
66
+ /**
67
+ * Host tool names advertised this turn. Used to resolve SwitchMode →
68
+ * plan_enter / plan_exit only when the matching tool is present.
69
+ */
70
+ advertisedTools?: ReadonlySet<string> | Iterable<string>;
71
+ /**
72
+ * True when `cursor_image_save` is advertised, i.e. a generated image can be
73
+ * committed to disk under OpenCode's permission. Approving generation the
74
+ * provider cannot then save would spend the user's Cursor quota for nothing.
75
+ */
76
+ canSaveGeneratedImage?: boolean;
77
+ /**
78
+ * Session workspace root used to place CreatePlan files via {@link hostPlansDir}
79
+ * (host project-config `plans/` in a git worktree, else host global data/plans).
80
+ */
81
+ workspaceRoot?: string;
82
+ /** True when the advertised host plan-stage tool is available. */
83
+ canBridgeCreatePlan?: boolean;
84
+ /**
85
+ * True when the provider has recorded an approved Cursor plan/spec mode for
86
+ * this session. A written plan then ends with an execution-approval prompt,
87
+ * which is the transition out of planning; outside plan mode there is none.
88
+ */
89
+ planModeActive?: boolean;
90
+ /**
91
+ * Cursor unified mode currently recorded for this session. A SwitchMode to
92
+ * the mode already in effect needs no approval, matching Cursor's own IDE
93
+ * handler and keeping the plan-approval prompt from firing twice.
94
+ */
95
+ activeCursorModeId?: string;
12
96
  };
13
97
  export declare class UnsupportedInteractionQueryError extends Error {
14
98
  constructor(info: InteractionQueryWireInfo);
@@ -20,11 +104,29 @@ export declare class UnsupportedInteractionQueryError extends Error {
20
104
  */
21
105
  export declare function inspectInteractionQueryWire(agentServerPayload: Uint8Array): InteractionQueryWireInfo;
22
106
  /**
23
- * Build the immediate typed response required by Cursor's Run RPC.
107
+ * Build the typed response required by Cursor's Run RPC.
108
+ *
109
+ * OpenCode has no Cursor UI callbacks, so UI-bound queries get a conservative
110
+ * headless policy (reject; ack a few no-UI cases).
24
111
  *
25
- * OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
26
- * here with a conservative headless policy (reject UI-bound prompts; ack a few
27
- * no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
28
- * CLI headless mode.
112
+ * Bridged / persisted exceptions:
113
+ * - AskQuestion (#3) → OpenCode `question` tool
114
+ * - SwitchMode (#4) → OpenCode `plan_enter` / `plan_exit` when advertised
115
+ * - CreatePlan (#7) → host-calculated plan file via hostPlansDir (project-config
116
+ * `plans/` in a git worktree, else host global data/plans); empty args still
117
+ * get the CLI empty-`plan_uri` success ack
118
+ * - GenerateImage (#12) → approve when `cursor_image_save` is advertised
119
+ */
120
+ export declare function handleInteractionQuery(query: Record<string, unknown>, agentServerPayload: Uint8Array, options?: HandleInteractionQueryOptions): HandledInteraction;
121
+ /** `AgentClientMessage{interaction_response{ask_question_interaction_response}}`. */
122
+ export declare function buildAskQuestionInteractionReply(id: number, result: AskQuestionResultMessage): Uint8Array;
123
+ /** `AgentClientMessage{interaction_response{switch_mode_request_response}}`. */
124
+ export declare function buildSwitchModeInteractionReply(id: number, result: Record<string, unknown>): Uint8Array;
125
+ /** `AgentClientMessage{interaction_response{create_plan_request_response}}`. */
126
+ export declare function buildCreatePlanInteractionReply(id: number, result: Record<string, unknown>): Uint8Array;
127
+ /**
128
+ * Deferred answers for a query already acknowledged with `async`.
129
+ * `originalArgs` must be the server's own `AskQuestionArgs` bytes so Cursor can
130
+ * correlate the completion with the tool call it raised.
29
131
  */
30
- export declare function handleInteractionQuery(query: Record<string, unknown>, agentServerPayload: Uint8Array): HandledInteraction;
132
+ export declare function buildAsyncAskQuestionCompletion(originalToolCallId: string, originalArgs: Uint8Array, result: AskQuestionResultMessage): Uint8Array;
@@ -1,6 +1,18 @@
1
1
  import { encodeMessage } from "./messages.js";
2
2
  import { readAllFields } from "./struct.js";
3
+ import { decodeAskQuestionQuery, MISSING_ARGS_REASON, MISSING_QUERY_REASON, } from "./ask-question.js";
4
+ import { decodeGenerateImageQuery, } from "./generate-image.js";
5
+ import { CURSOR_PLAN_STAGE_TOOL, createPlanApprovalQuestionInput, decodeCreatePlanQuery, renderPlanReviewMessage, resolveCreatePlanBridge, writeOpencodePlanFile, } from "./create-plan.js";
6
+ import { decodeSwitchModeQuery, MISSING_ARGS_REASON as SWITCH_MODE_MISSING_ARGS_REASON, MISSING_QUERY_REASON as SWITCH_MODE_MISSING_QUERY_REASON, resolveSwitchModeBridge, switchModeApprovedResult, } from "./switch-mode.js";
3
7
  const HEADLESS_REASON = "This Cursor interaction requires UI approval and is not available through the OpenCode provider.";
8
+ /**
9
+ * Rejection used when AskQuestion cannot be bridged because the current
10
+ * OpenCode agent does not advertise `question` (subagents deny it by default,
11
+ * and compaction turns advertise no tools at all). Naming the actual reason
12
+ * keeps the model from concluding that asking is impossible in general.
13
+ */
14
+ const ASK_QUESTION_UNAVAILABLE_REASON = "The OpenCode `question` tool is not available to the current agent, so questions "
15
+ + "cannot be shown to the user this turn. State the question in your reply instead.";
4
16
  const variantNames = {
5
17
  2: "web_search_request_query",
6
18
  3: "ask_question_interaction_query",
@@ -39,27 +51,37 @@ export function inspectInteractionQueryWire(agentServerPayload) {
39
51
  // zero, not a malformed/missing correlation id.
40
52
  let id = 0;
41
53
  let variantField;
54
+ let variantBytes;
42
55
  for (const field of readAllFields(queryBytes)) {
43
56
  if (field.fn === 1 && field.wt === 0)
44
57
  id = field.varint;
45
- else if (field.wt === 2 && variantField === undefined)
58
+ else if (field.wt === 2 && variantField === undefined) {
46
59
  variantField = field.fn;
60
+ variantBytes = field.bytes;
61
+ }
47
62
  }
48
63
  return {
49
64
  id,
50
65
  variantField,
51
66
  variantName: variantField === undefined ? undefined : variantNames[variantField],
67
+ variantBytes,
52
68
  };
53
69
  }
54
70
  /**
55
- * Build the immediate typed response required by Cursor's Run RPC.
71
+ * Build the typed response required by Cursor's Run RPC.
56
72
  *
57
- * OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
58
- * here with a conservative headless policy (reject UI-bound prompts; ack a few
59
- * no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
60
- * CLI headless mode.
73
+ * OpenCode has no Cursor UI callbacks, so UI-bound queries get a conservative
74
+ * headless policy (reject; ack a few no-UI cases).
75
+ *
76
+ * Bridged / persisted exceptions:
77
+ * - AskQuestion (#3) → OpenCode `question` tool
78
+ * - SwitchMode (#4) → OpenCode `plan_enter` / `plan_exit` when advertised
79
+ * - CreatePlan (#7) → host-calculated plan file via hostPlansDir (project-config
80
+ * `plans/` in a git worktree, else host global data/plans); empty args still
81
+ * get the CLI empty-`plan_uri` success ack
82
+ * - GenerateImage (#12) → approve when `cursor_image_save` is advertised
61
83
  */
62
- export function handleInteractionQuery(query, agentServerPayload) {
84
+ export function handleInteractionQuery(query, agentServerPayload, options = {}) {
63
85
  const info = inspectInteractionQueryWire(agentServerPayload);
64
86
  if (info.id === undefined || info.variantField === undefined || !info.variantName) {
65
87
  throw new UnsupportedInteractionQueryError(info);
@@ -67,6 +89,18 @@ export function handleInteractionQuery(query, agentServerPayload) {
67
89
  if (typeof query.id === "number" && query.id !== info.id) {
68
90
  throw new Error(`Cursor interaction query id mismatch: decoded=${query.id} wire=${info.id}`);
69
91
  }
92
+ if (info.variantField === 3) {
93
+ return handleAskQuestionQuery(info.id, info.variantBytes, options);
94
+ }
95
+ if (info.variantField === 4) {
96
+ return handleSwitchModeQuery(info.id, info.variantBytes, options);
97
+ }
98
+ if (info.variantField === 7) {
99
+ return handleCreatePlanQuery(info.id, info.variantBytes, options);
100
+ }
101
+ if (info.variantField === 12) {
102
+ return handleGenerateImageQuery(info.id, info.variantBytes, options);
103
+ }
70
104
  let response;
71
105
  let outcome;
72
106
  switch (info.variantField) {
@@ -74,30 +108,6 @@ export function handleInteractionQuery(query, agentServerPayload) {
74
108
  response = { web_search_request_response: { rejected: { reason: HEADLESS_REASON } } };
75
109
  outcome = "rejected";
76
110
  break;
77
- case 3:
78
- response = {
79
- ask_question_interaction_response: {
80
- result: { rejected: { reason: "Questions must be asked through OpenCode's question tool." } },
81
- },
82
- };
83
- outcome = "rejected";
84
- break;
85
- case 4:
86
- response = { switch_mode_request_response: { rejected: { reason: HEADLESS_REASON } } };
87
- outcome = "rejected";
88
- break;
89
- case 7:
90
- // F14: create_plan_request_query auto-ack (Cursor CLI headless parity).
91
- // Cursor CLI's headless fallback acknowledges plan creation without a
92
- // client-side URI (`success` + empty `plan_uri`); the plan remains in
93
- // conversation state / checkpoints. This provider mirrors that reply so
94
- // the Run RPC does not deadlock waiting for UI approval. Impact: Cursor
95
- // may treat the plan as accepted without an OpenCode UI confirm; local
96
- // tool execution is still gated by OpenCode permissions. Do not change
97
- // this to reject/prompt without CLI parity evidence.
98
- response = { create_plan_request_response: { result: { success: {}, plan_uri: "" } } };
99
- outcome = "acknowledged";
100
- break;
101
111
  case 8:
102
112
  // OpenCode owns the local environment; there is no Cursor VM to create.
103
113
  response = { setup_vm_environment_result: { success: {} } };
@@ -115,10 +125,6 @@ export function handleInteractionQuery(query, agentServerPayload) {
115
125
  response = { mcp_auth_request_response: { rejected: { reason: HEADLESS_REASON } } };
116
126
  outcome = "rejected";
117
127
  break;
118
- case 12:
119
- response = { generate_image_request_response: { rejected: { reason: HEADLESS_REASON } } };
120
- outcome = "rejected";
121
- break;
122
128
  case 13:
123
129
  response = {
124
130
  replace_env_result: {
@@ -147,3 +153,257 @@ export function handleInteractionQuery(query, agentServerPayload) {
147
153
  }),
148
154
  };
149
155
  }
156
+ /**
157
+ * AskQuestion is the one interaction OpenCode can genuinely satisfy, so it is
158
+ * translated instead of refused. Cursor CLI's own handler is mirrored: reject
159
+ * a malformed query, answer `async` immediately when the server asked for a
160
+ * non-blocking prompt, and otherwise hold the query open until the user picks.
161
+ */
162
+ function handleAskQuestionQuery(id, variantBytes, options) {
163
+ const base = { id, variantField: 3, variantName: "ask_question_interaction_query" };
164
+ const reject = (reason) => ({
165
+ ...base,
166
+ outcome: "rejected",
167
+ reply: buildAskQuestionInteractionReply(id, { rejected: { reason } }),
168
+ });
169
+ if (!variantBytes)
170
+ return reject(MISSING_QUERY_REASON);
171
+ const decoded = decodeAskQuestionQuery(variantBytes);
172
+ if (!decoded)
173
+ return reject(MISSING_ARGS_REASON);
174
+ if (!options.canBridgeAskQuestion)
175
+ return reject(ASK_QUESTION_UNAVAILABLE_REASON);
176
+ return {
177
+ ...base,
178
+ outcome: "bridged",
179
+ askQuestion: decoded,
180
+ // Async queries are unblocked now and answered later through a
181
+ // ConversationAction; synchronous ones keep Cursor waiting, which is what
182
+ // its CLI does while the user is still choosing.
183
+ reply: decoded.args.runAsync
184
+ ? buildAskQuestionInteractionReply(id, { async: {} })
185
+ : undefined,
186
+ };
187
+ }
188
+ /** `AgentClientMessage{interaction_response{ask_question_interaction_response}}`. */
189
+ export function buildAskQuestionInteractionReply(id, result) {
190
+ return encodeMessage("AgentClientMessage", {
191
+ interaction_response: {
192
+ id,
193
+ ask_question_interaction_response: { result },
194
+ },
195
+ });
196
+ }
197
+ /**
198
+ * SwitchMode is bridged to OpenCode `plan_enter` / `plan_exit` when advertised.
199
+ * Cursor CLI blocks until the user approves or rejects; we hold the query open
200
+ * until the host tool returns, then reply approved{} or rejected{reason}.
201
+ */
202
+ function handleSwitchModeQuery(id, variantBytes, options) {
203
+ const base = { id, variantField: 4, variantName: "switch_mode_request_query" };
204
+ const reject = (reason) => ({
205
+ ...base,
206
+ outcome: "rejected",
207
+ reply: buildSwitchModeInteractionReply(id, { rejected: { reason } }),
208
+ });
209
+ if (!variantBytes)
210
+ return reject(SWITCH_MODE_MISSING_QUERY_REASON);
211
+ const decoded = decodeSwitchModeQuery(variantBytes);
212
+ if (!decoded)
213
+ return reject(SWITCH_MODE_MISSING_ARGS_REASON);
214
+ const bridge = resolveSwitchModeBridge(decoded.args.targetModeId, {
215
+ allowTools: options.allowTools === true,
216
+ advertised: options.advertisedTools ?? [],
217
+ ...(options.activeCursorModeId ? { activeModeId: options.activeCursorModeId } : {}),
218
+ });
219
+ if (bridge.kind === "reject")
220
+ return reject(bridge.reason);
221
+ // Lifecycle turn: approve on the wire so the model never sees a rejection, but
222
+ // do not attach switchMode — the real turn owns session mode.
223
+ if (bridge.kind === "ack") {
224
+ return {
225
+ ...base,
226
+ outcome: "acknowledged",
227
+ reply: buildSwitchModeInteractionReply(id, switchModeApprovedResult()),
228
+ };
229
+ }
230
+ if (bridge.kind === "approve") {
231
+ // Entering plan mode needs no host tool, so Cursor is released at once and
232
+ // the caller records the mode for the reminder injected on later Runs.
233
+ return {
234
+ ...base,
235
+ outcome: "approved",
236
+ switchMode: { ...decoded, bridge },
237
+ reply: buildSwitchModeInteractionReply(id, switchModeApprovedResult()),
238
+ };
239
+ }
240
+ return {
241
+ ...base,
242
+ outcome: "bridged",
243
+ switchMode: {
244
+ ...decoded,
245
+ bridge,
246
+ toolName: bridge.kind === "native" ? bridge.toolName : "question",
247
+ },
248
+ // Keep Cursor waiting until the host tool returns, matching CLI blocking on
249
+ // the mode-switch approval prompt.
250
+ reply: undefined,
251
+ };
252
+ }
253
+ /** `AgentClientMessage{interaction_response{switch_mode_request_response}}`. */
254
+ export function buildSwitchModeInteractionReply(id, result) {
255
+ return encodeMessage("AgentClientMessage", {
256
+ interaction_response: {
257
+ id,
258
+ switch_mode_request_response: result,
259
+ },
260
+ });
261
+ }
262
+ /** `AgentClientMessage{interaction_response{create_plan_request_response}}`. */
263
+ export function buildCreatePlanInteractionReply(id, result) {
264
+ return encodeMessage("AgentClientMessage", {
265
+ interaction_response: {
266
+ id,
267
+ create_plan_request_response: { result },
268
+ },
269
+ });
270
+ }
271
+ /**
272
+ * Deferred answers for a query already acknowledged with `async`.
273
+ * `originalArgs` must be the server's own `AskQuestionArgs` bytes so Cursor can
274
+ * correlate the completion with the tool call it raised.
275
+ */
276
+ export function buildAsyncAskQuestionCompletion(originalToolCallId, originalArgs, result) {
277
+ return encodeMessage("AgentClientMessage", {
278
+ conversation_action: {
279
+ async_ask_question_completion_action: {
280
+ original_tool_call_id: originalToolCallId,
281
+ original_args: originalArgs,
282
+ result,
283
+ },
284
+ },
285
+ });
286
+ }
287
+ /**
288
+ * CreatePlan persistence and execution approval. Cursor CLI writes
289
+ * `~/.cursor/plans/*.plan.md` with YAML frontmatter; this provider writes plain
290
+ * markdown under the host's calculated plans dir (`hostPlansDir` / path bridge)
291
+ * so switching models and hosts stays coherent. Empty / missing args keep the
292
+ * CLI empty-`plan_uri` success ack (nothing to write).
293
+ *
294
+ * Writing the plan never asks; executing it always does. Which channel raises
295
+ * that prompt is resolved from the advertised catalog by
296
+ * {@link resolveCreatePlanBridge}, and every channel reports the same outcome:
297
+ * `success` means the user approved execution, `error` means the plan was
298
+ * written but not accepted, so the model keeps planning.
299
+ */
300
+ function handleCreatePlanQuery(id, variantBytes, options) {
301
+ const base = { id, variantField: 7, variantName: "create_plan_request_query" };
302
+ const reply = (result) => ({
303
+ ...base,
304
+ outcome: result.error ? "failed" : "acknowledged",
305
+ reply: encodeMessage("AgentClientMessage", {
306
+ interaction_response: {
307
+ id,
308
+ create_plan_request_response: { result },
309
+ },
310
+ }),
311
+ });
312
+ // Missing / empty args: CLI headless fallback — success with empty plan_uri.
313
+ if (!variantBytes)
314
+ return reply({ success: {}, plan_uri: "" });
315
+ const decoded = decodeCreatePlanQuery(variantBytes);
316
+ if (!decoded)
317
+ return reply({ success: {}, plan_uri: "" });
318
+ const bridge = resolveCreatePlanBridge({
319
+ allowTools: options.allowTools === true,
320
+ canStage: options.canBridgeCreatePlan === true,
321
+ planModeActive: options.planModeActive === true,
322
+ advertised: options.advertisedTools ?? [],
323
+ });
324
+ // The host plan-stage tool owns the write and the approval prompt together.
325
+ if (bridge.kind === "stage") {
326
+ return {
327
+ ...base,
328
+ outcome: "bridged",
329
+ createPlan: { ...decoded, bridge, toolName: CURSOR_PLAN_STAGE_TOOL },
330
+ reply: undefined,
331
+ };
332
+ }
333
+ // A lifecycle turn (title generation, compaction) runs alongside the real one
334
+ // and would otherwise write a second, throwaway plan file for the same
335
+ // request. Ack it the CLI way instead of persisting anything.
336
+ if (options.allowTools !== true)
337
+ return reply({ success: {}, plan_uri: "" });
338
+ const workspaceRoot = options.workspaceRoot?.trim();
339
+ if (!workspaceRoot) {
340
+ return reply({
341
+ error: { error: "CreatePlan requires a workspace root to write the plan file" },
342
+ plan_uri: "",
343
+ });
344
+ }
345
+ const written = writeOpencodePlanFile(decoded.args, workspaceRoot);
346
+ if (!written.ok) {
347
+ return reply({ error: { error: written.error }, plan_uri: "" });
348
+ }
349
+ // The plan is on disk either way; only execution needs the user. Hold Cursor's
350
+ // query open while the host asks, exactly as the CLI blocks on its own prompt.
351
+ if (bridge.kind === "approve") {
352
+ return {
353
+ ...base,
354
+ outcome: "bridged",
355
+ createPlan: {
356
+ ...decoded,
357
+ bridge,
358
+ toolName: "question",
359
+ planUri: written.planUri,
360
+ planPath: written.planPath,
361
+ questionInput: createPlanApprovalQuestionInput(written.planPath),
362
+ planReview: renderPlanReviewMessage(written.markdown, written.planPath),
363
+ },
364
+ reply: undefined,
365
+ };
366
+ }
367
+ return reply({ success: {}, plan_uri: written.planUri });
368
+ }
369
+ /**
370
+ * GenerateImage approval. Cursor renders its own confirmation in the CLI; here
371
+ * the meaningful gate is OpenCode's `edit` permission raised by
372
+ * `cursor_image_save` when the finished bytes are written, so generation itself
373
+ * is approved whenever the provider can actually commit the result. Approving
374
+ * without a commit path would spend Cursor quota on an image that then has
375
+ * nowhere to go.
376
+ */
377
+ function handleGenerateImageQuery(id, variantBytes, options) {
378
+ const base = { id, variantField: 12, variantName: "generate_image_request_query" };
379
+ const reject = (reason) => ({
380
+ ...base,
381
+ outcome: "rejected",
382
+ reply: encodeMessage("AgentClientMessage", {
383
+ interaction_response: { id, generate_image_request_response: { rejected: { reason } } },
384
+ }),
385
+ });
386
+ if (!variantBytes)
387
+ return reject("Missing generate image query");
388
+ const decoded = decodeGenerateImageQuery(variantBytes);
389
+ if (!decoded)
390
+ return reject("Missing generate image arguments");
391
+ if (!options.canSaveGeneratedImage) {
392
+ return reject("This OpenCode agent cannot save a generated image, so generating one would "
393
+ + "produce nothing. Describe the image in your reply instead.");
394
+ }
395
+ return {
396
+ ...base,
397
+ outcome: "acknowledged",
398
+ generateImage: decoded,
399
+ reply: encodeMessage("AgentClientMessage", {
400
+ interaction_response: {
401
+ id,
402
+ // Cursor's CLI returns the description the user may have edited at the
403
+ // prompt. There is no text field on OpenCode's permission, so the
404
+ // model's own description is passed through unchanged.
405
+ generate_image_request_response: { approved: { description: decoded.description } },
406
+ },
407
+ }),
408
+ };
409
+ }
@@ -16,6 +16,7 @@ import type { CursorSession } from "../session.js";
16
16
  */
17
17
  export declare function handleKvServerMessage(ksm: Record<string, unknown>, session: CursorSession): {
18
18
  reply: Uint8Array;
19
+ replyBlobBytes: number;
19
20
  kind: "set" | "get";
20
21
  id: number;
21
22
  blobIdHex: string;
@@ -38,6 +38,7 @@ export function handleKvServerMessage(ksm, session) {
38
38
  id,
39
39
  blobIdHex: key,
40
40
  found: true,
41
+ replyBlobBytes: 0,
41
42
  reply: encodeMessage("AgentClientMessage", {
42
43
  kv_client_message: { id, set_blob_result: {} },
43
44
  }),
@@ -72,6 +73,7 @@ export function handleKvServerMessage(ksm, session) {
72
73
  blobIdHex: key,
73
74
  found,
74
75
  echoed,
76
+ replyBlobBytes: blobData.length,
75
77
  reply: encodeMessage("AgentClientMessage", {
76
78
  kv_client_message: { id, get_blob_result: { blob_data: blobData } },
77
79
  }),
@@ -3,6 +3,8 @@ export declare function createMessageTypes(): protobuf.Root;
3
3
  export declare function getMessageTypes(): protobuf.Root;
4
4
  export declare function encodeMessage(typeName: string, message: Record<string, unknown>): Uint8Array;
5
5
  export declare function decodeMessage<T = Record<string, unknown>>(typeName: string, data: Uint8Array): T;
6
+ /** Decode without inventing absent proto defaults (used for byte-stable snapshots). */
7
+ export declare function decodeMessageSparse<T = Record<string, unknown>>(typeName: string, data: Uint8Array): T;
6
8
  /**
7
9
  * Diagnostic-only: walk AgentServerMessage.interaction_update(1).turn_ended(14)
8
10
  * without going through our hand-written TurnEnded schema, so a `cache_write`/