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
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Cursor-native CreatePlan → host-default plan file.
3
+ *
4
+ * Cursor raises `create_plan_request_query` (#7) with CreatePlanArgs and expects
5
+ * a CreatePlanResult carrying `plan_uri`. Cursor CLI writes under
6
+ * `~/.cursor/plans/*.plan.md` with YAML frontmatter; this provider deliberately
7
+ * does **not** — plans must stay host-portable so switching models does not
8
+ * strand users on Cursor-specific paths or frontmatter.
9
+ *
10
+ * Location mirrors OpenCode Session.plan shape, but the project-config segment
11
+ * comes from {@link hostPlansDir} / {@link opencodeProjectConfigDirs}
12
+ * (the active host's project config dir via the path bridge), never a
13
+ * hardcoded OpenCode-only directory name.
14
+ *
15
+ * Body is plain markdown (the same shape a plan-mode model would write with
16
+ * `write`). Cursor YAML frontmatter is never emitted.
17
+ */
18
+ import { mkdirSync, writeFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { hostPlansDir } from "../context/paths.js";
22
+ import { parseAnswerSegments, } from "./ask-question.js";
23
+ import { decodeMessageSparse } from "./messages.js";
24
+ const PLAN_ADJECTIVES = [
25
+ "brave",
26
+ "calm",
27
+ "clever",
28
+ "cosmic",
29
+ "crisp",
30
+ "curious",
31
+ "eager",
32
+ "gentle",
33
+ "glowing",
34
+ "happy",
35
+ "hidden",
36
+ "jolly",
37
+ "kind",
38
+ "lucky",
39
+ "mighty",
40
+ "misty",
41
+ "neon",
42
+ "nimble",
43
+ "playful",
44
+ "proud",
45
+ "quick",
46
+ "quiet",
47
+ "shiny",
48
+ "silent",
49
+ "stellar",
50
+ "sunny",
51
+ "swift",
52
+ "tidy",
53
+ "witty",
54
+ ];
55
+ const PLAN_NOUNS = [
56
+ "cabin",
57
+ "cactus",
58
+ "canyon",
59
+ "circuit",
60
+ "comet",
61
+ "eagle",
62
+ "engine",
63
+ "falcon",
64
+ "forest",
65
+ "garden",
66
+ "harbor",
67
+ "island",
68
+ "knight",
69
+ "lagoon",
70
+ "meadow",
71
+ "moon",
72
+ "mountain",
73
+ "nebula",
74
+ "orchid",
75
+ "otter",
76
+ "panda",
77
+ "pixel",
78
+ "planet",
79
+ "river",
80
+ "rocket",
81
+ "sailor",
82
+ "squid",
83
+ "star",
84
+ "tiger",
85
+ "wizard",
86
+ "wolf",
87
+ ];
88
+ /** Optional host plan-stage tool advertised by a compatible host. */
89
+ export const CURSOR_PLAN_STAGE_TOOL = "cursor_plan_stage";
90
+ /** Held InteractionQuery continuation field for a native host plan stage. */
91
+ export const CREATE_PLAN_RESULT_FIELD = "create_plan_request_response";
92
+ /** Upstream `PlanExitTool` wording, with the plan the provider just wrote. */
93
+ export function createPlanApprovalQuestion(planLabel) {
94
+ const where = planLabel.trim();
95
+ return where
96
+ ? `Plan at ${where} is complete. Would you like to switch to the build agent and start implementing?`
97
+ : "The plan is complete. Would you like to switch to the build agent and start implementing?";
98
+ }
99
+ export const CREATE_PLAN_APPROVAL_HEADER = "Build Agent";
100
+ const CREATE_PLAN_APPROVAL_YES = "Yes";
101
+ const CREATE_PLAN_APPROVAL_NO = "No";
102
+ /** Cursor-visible reason when the user wants the plan revised instead of run. */
103
+ export const CREATE_PLAN_NOT_APPROVED_REASON = "The user did not approve executing this plan. Keep planning: refine the plan and "
104
+ + "propose it again when it is ready.";
105
+ /**
106
+ * Resolve how this CreatePlan is satisfied. `planModeActive` is the provider's
107
+ * own record of an approved Cursor plan/spec mode: the approval prompt guards
108
+ * the transition *out of* planning, so a CreatePlan raised outside plan mode is
109
+ * written and acknowledged without one.
110
+ */
111
+ export function resolveCreatePlanBridge(options) {
112
+ // A lifecycle turn (title generation, compaction) runs alongside the real one
113
+ // and must not write a second plan file or raise a second prompt.
114
+ if (!options.allowTools)
115
+ return { kind: "ack" };
116
+ if (options.canStage)
117
+ return { kind: "stage" };
118
+ const names = options.advertised instanceof Set ? options.advertised : new Set(options.advertised);
119
+ if (options.planModeActive && names.has("question"))
120
+ return { kind: "approve" };
121
+ return { kind: "ack" };
122
+ }
123
+ /** The synthetic AskQuestion item backing the emulated approval prompt. */
124
+ function createPlanApprovalItem(question) {
125
+ return {
126
+ id: "create_plan_approval",
127
+ prompt: question,
128
+ options: [
129
+ { id: "yes", label: CREATE_PLAN_APPROVAL_YES },
130
+ { id: "no", label: CREATE_PLAN_APPROVAL_NO },
131
+ ],
132
+ allowMultiple: false,
133
+ };
134
+ }
135
+ /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
136
+ export function createPlanApprovalQuestionInput(planLabel) {
137
+ return {
138
+ questions: [
139
+ {
140
+ question: createPlanApprovalQuestion(planLabel),
141
+ header: CREATE_PLAN_APPROVAL_HEADER,
142
+ options: [
143
+ {
144
+ label: CREATE_PLAN_APPROVAL_YES,
145
+ description: "Switch to build agent and start implementing the plan",
146
+ },
147
+ {
148
+ label: CREATE_PLAN_APPROVAL_NO,
149
+ description: "Stay with plan agent to continue refining the plan",
150
+ },
151
+ ],
152
+ },
153
+ ],
154
+ };
155
+ }
156
+ /**
157
+ * True when the emulated approval prompt came back as an explicit "Yes".
158
+ * An unanswered, dismissed, or failed prompt keeps the model planning rather
159
+ * than silently starting execution.
160
+ *
161
+ * `question` must be the exact prompt that was asked — the host echoes it back
162
+ * verbatim, and it is the anchor the answer is parsed from.
163
+ */
164
+ export function createPlanApproved(output, isError, question) {
165
+ if (isError)
166
+ return false;
167
+ const [segment] = parseAnswerSegments([createPlanApprovalItem(question)], output);
168
+ return (segment ?? "").trim().toLowerCase() === CREATE_PLAN_APPROVAL_YES.toLowerCase();
169
+ }
170
+ function asRecord(value) {
171
+ return value && typeof value === "object" && !Array.isArray(value)
172
+ ? value
173
+ : undefined;
174
+ }
175
+ function str(value) {
176
+ return typeof value === "string" ? value : "";
177
+ }
178
+ function mapTodoStatus(status) {
179
+ if (typeof status === "string")
180
+ return status.toLowerCase();
181
+ if (typeof status === "number") {
182
+ // TodoStatus enum: PENDING=1, IN_PROGRESS=2, COMPLETED=3, CANCELLED=4
183
+ switch (status) {
184
+ case 2:
185
+ return "in_progress";
186
+ case 3:
187
+ return "completed";
188
+ case 4:
189
+ return "cancelled";
190
+ default:
191
+ return "pending";
192
+ }
193
+ }
194
+ return "pending";
195
+ }
196
+ function todoCheckbox(status) {
197
+ const s = status.toLowerCase();
198
+ if (s === "completed" || s === "complete" || s === "done")
199
+ return "[x]";
200
+ if (s === "cancelled" || s === "canceled")
201
+ return "[~]";
202
+ return "[ ]";
203
+ }
204
+ /** Decode a `create_plan_request_query` body, or undefined when unusable. */
205
+ export function decodeCreatePlanQuery(queryBytes) {
206
+ let decoded;
207
+ try {
208
+ decoded = decodeMessageSparse("CreatePlanRequestQuery", queryBytes);
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ const args = asRecord(decoded.args);
214
+ if (!args)
215
+ return undefined;
216
+ const plan = str(args.plan);
217
+ const overview = str(args.overview);
218
+ const name = str(args.name);
219
+ const todos = Array.isArray(args.todos)
220
+ ? args.todos.flatMap((raw) => {
221
+ const item = asRecord(raw);
222
+ if (!item)
223
+ return [];
224
+ const content = str(item.content);
225
+ if (!content)
226
+ return [];
227
+ return [
228
+ {
229
+ id: str(item.id),
230
+ content,
231
+ status: mapTodoStatus(item.status),
232
+ },
233
+ ];
234
+ })
235
+ : [];
236
+ // Empty args: CLI acknowledges with empty plan_uri; treat as no write.
237
+ if (!plan && !overview && !name && todos.length === 0)
238
+ return undefined;
239
+ return {
240
+ args: {
241
+ plan,
242
+ overview,
243
+ name,
244
+ isProject: args.is_project === true,
245
+ todos,
246
+ },
247
+ toolCallId: str(decoded.tool_call_id),
248
+ };
249
+ }
250
+ /** OpenCode-style adjective-noun slug (core/src/util/slug.ts). */
251
+ export function randomPlanSlug(seed = Date.now()) {
252
+ const adj = PLAN_ADJECTIVES[seed % PLAN_ADJECTIVES.length];
253
+ const noun = PLAN_NOUNS[Math.floor(seed / PLAN_ADJECTIVES.length) % PLAN_NOUNS.length];
254
+ return `${adj}-${noun}`;
255
+ }
256
+ /** Slugify a human title into a filesystem-safe token. */
257
+ export function slugifyPlanName(name) {
258
+ const slug = name
259
+ .trim()
260
+ .toLowerCase()
261
+ .replace(/[^a-z0-9]+/g, "-")
262
+ .replace(/^-+|-+$/g, "")
263
+ .slice(0, 64);
264
+ return slug || randomPlanSlug();
265
+ }
266
+ /** Build the advertised host plan-stage artifact payload. */
267
+ export function createPlanStageInput(args) {
268
+ const slug = slugifyPlanName(args.name);
269
+ return {
270
+ plan_uri: `local://${slug}-plan.md`,
271
+ content: renderOpencodePlanMarkdown(args),
272
+ title: slug,
273
+ };
274
+ }
275
+ /**
276
+ * Resolve the absolute plan file path via {@link hostPlansDir}.
277
+ * Filename shape matches OpenCode Session.plan: `<created>-<slug>.md`.
278
+ */
279
+ export function resolveHostPlanPath(workspaceRoot, name, created = Date.now()) {
280
+ const slug = name?.trim() ? slugifyPlanName(name) : randomPlanSlug(created);
281
+ return path.join(hostPlansDir(workspaceRoot), `${created}-${slug}.md`);
282
+ }
283
+ /** @deprecated Prefer {@link resolveHostPlanPath}. */
284
+ export const resolveOpencodePlanPath = resolveHostPlanPath;
285
+ /**
286
+ * Render plain markdown for the plan file. No Cursor YAML frontmatter.
287
+ * Prefer `args.plan` as the body; prepend a title/overview when useful; append
288
+ * a markdown checklist for todos.
289
+ */
290
+ export function renderOpencodePlanMarkdown(args) {
291
+ const parts = [];
292
+ const name = args.name.trim();
293
+ const overview = args.overview.trim();
294
+ const plan = args.plan.trim();
295
+ // Cursor usually repeats the plan name as the body's own leading H1. Strip it
296
+ // so the document keeps one title in the right place instead of opening with
297
+ // the same heading twice (or with the overview stranded above it).
298
+ const planLeadHeading = /^#\s+(.+?)\s*$/.exec(plan.split("\n", 1)[0] ?? "")?.[1];
299
+ const planBody = name
300
+ && planLeadHeading !== undefined
301
+ && planLeadHeading.trim().toLowerCase() === name.toLowerCase()
302
+ ? plan.slice(plan.indexOf("\n") + 1).trimStart()
303
+ : plan;
304
+ if (name) {
305
+ parts.push(`# ${name}`);
306
+ parts.push("");
307
+ }
308
+ if (overview) {
309
+ // Avoid duplicating overview when it already leads the plan body.
310
+ if (!planBody || !planBody.startsWith(overview)) {
311
+ parts.push(overview);
312
+ parts.push("");
313
+ }
314
+ }
315
+ if (planBody) {
316
+ parts.push(planBody);
317
+ if (!planBody.endsWith("\n"))
318
+ parts.push("");
319
+ }
320
+ if (args.todos.length > 0) {
321
+ if (parts.length > 0 && parts[parts.length - 1] !== "")
322
+ parts.push("");
323
+ parts.push("## Todos");
324
+ parts.push("");
325
+ for (const todo of args.todos) {
326
+ parts.push(`- ${todoCheckbox(todo.status)} ${todo.content}`);
327
+ }
328
+ parts.push("");
329
+ }
330
+ const body = parts.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
331
+ return body ? `${body}\n` : "";
332
+ }
333
+ /**
334
+ * Write the plan under {@link hostPlansDir} and return a `file://` URI.
335
+ * Empty / missing content is the caller's responsibility (empty plan_uri ack).
336
+ */
337
+ export function writeOpencodePlanFile(args, workspaceRoot, created = Date.now()) {
338
+ const markdown = renderOpencodePlanMarkdown(args);
339
+ if (!markdown.trim()) {
340
+ return { ok: false, error: "CreatePlan produced no plan content to write" };
341
+ }
342
+ const planPath = resolveHostPlanPath(workspaceRoot, args.name, created);
343
+ try {
344
+ mkdirSync(path.dirname(planPath), { recursive: true });
345
+ writeFileSync(planPath, markdown, "utf-8");
346
+ }
347
+ catch (err) {
348
+ const message = err instanceof Error ? err.message : String(err);
349
+ return { ok: false, error: `Failed to write plan file: ${message}` };
350
+ }
351
+ return {
352
+ ok: true,
353
+ planPath,
354
+ planUri: pathToFileURL(planPath).href,
355
+ markdown,
356
+ };
357
+ }
358
+ /**
359
+ * The plan as the user reads it before approving execution.
360
+ *
361
+ * Cursor routes the plan body through the interaction query rather than the
362
+ * text stream, so without this the user is asked to approve a plan they were
363
+ * never shown. It goes into the assistant message — the host renders markdown
364
+ * there, it scrolls, and it survives answering the prompt. It must not go into
365
+ * the question itself: OpenCode renders the question dock outside its
366
+ * scrollbox with `flexShrink={0}`, so a full plan there would push the
367
+ * conversation off screen.
368
+ */
369
+ export function renderPlanReviewMessage(markdown, planPath) {
370
+ const body = markdown.trim();
371
+ const saved = `_Plan saved to ${planPath}_`;
372
+ return body ? `${body}\n\n${saved}\n` : `${saved}\n`;
373
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Cursor-native GenerateImage.
3
+ *
4
+ * Cursor generates the image on its own servers, then writes it to the client
5
+ * exactly like any other file — Cursor CLI's agent issues
6
+ * `WriteArgs{path, file_bytes, return_file_content_after_write: false}` to
7
+ * `<artifactsFolder|projectFolder>/assets/<basename(file_path)>` and reads the
8
+ * client's `WriteResult` back, checking for `permission_denied`
9
+ * (`agent-cli-local/src/index.ts:84752-84776`).
10
+ *
11
+ * The display `generate_image_tool_call.result.image_data` is **not** the write
12
+ * channel: the CLI feeds it to its terminal preview cache and falls back to
13
+ * reading the path off disk, so the file already exists by then. Never write
14
+ * from that frame — the bytes would be written twice.
15
+ *
16
+ * So the client-side work is: approve the interaction, then honour a binary
17
+ * write. OpenCode's `write` tool cannot carry bytes (string content, BOM split,
18
+ * diff, `Format.file()`), so the bytes are staged (`../image-staging.ts`) and
19
+ * committed by the `cursor_image_save` plugin tool, which raises OpenCode's
20
+ * `edit` permission. A refusal returns Cursor's own `permission_denied`.
21
+ */
22
+ /** Host tool id that commits a staged image. Registered by the classic plugin. */
23
+ export declare const CURSOR_IMAGE_SAVE_TOOL = "cursor_image_save";
24
+ /** Cursor's agent always writes generated images into this subdirectory. */
25
+ export declare const CURSOR_IMAGE_ASSETS_DIR = "assets";
26
+ export type DecodedGenerateImageQuery = {
27
+ description: string;
28
+ /** Target path as Cursor asked for it; may be relative or empty. */
29
+ filePath: string;
30
+ referenceImagePaths: string[];
31
+ toolCallId: string;
32
+ };
33
+ /** Cursor names the target file; its extension is the only mime signal sent. */
34
+ export declare function imageMimeForPath(filePath: string): string;
35
+ /** Decode a `generate_image_request_query` body, or undefined when unusable. */
36
+ export declare function decodeGenerateImageQuery(queryBytes: Uint8Array): DecodedGenerateImageQuery | undefined;
37
+ export type ImageWriteRoots = {
38
+ /** Session workspace root (`request_context.env.workspace_paths[0]`). */
39
+ workspaceRoot: string;
40
+ /** What this provider advertises as `project_folder` / `workspace_project_dir`. */
41
+ projectDir: string;
42
+ };
43
+ /**
44
+ * Map Cursor's intended write target onto a real location under this host.
45
+ *
46
+ * Cursor composes the path from the `project_folder` this provider advertised,
47
+ * so an untouched target already resolves under the host cache project
48
+ * directory and is kept as-is. A target inside the workspace is also honoured
49
+ * verbatim — that is a location the user can see. Anything else (a path built
50
+ * from a folder we never advertised, or an attempted escape) is rebased onto
51
+ * `<projectDir>/assets/<basename>` rather than refused, so the model still gets
52
+ * a file where it expects one instead of a failed generation.
53
+ */
54
+ export declare function remapCursorImageWritePath(target: string, roots: ImageWriteRoots): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Cursor-native GenerateImage.
3
+ *
4
+ * Cursor generates the image on its own servers, then writes it to the client
5
+ * exactly like any other file — Cursor CLI's agent issues
6
+ * `WriteArgs{path, file_bytes, return_file_content_after_write: false}` to
7
+ * `<artifactsFolder|projectFolder>/assets/<basename(file_path)>` and reads the
8
+ * client's `WriteResult` back, checking for `permission_denied`
9
+ * (`agent-cli-local/src/index.ts:84752-84776`).
10
+ *
11
+ * The display `generate_image_tool_call.result.image_data` is **not** the write
12
+ * channel: the CLI feeds it to its terminal preview cache and falls back to
13
+ * reading the path off disk, so the file already exists by then. Never write
14
+ * from that frame — the bytes would be written twice.
15
+ *
16
+ * So the client-side work is: approve the interaction, then honour a binary
17
+ * write. OpenCode's `write` tool cannot carry bytes (string content, BOM split,
18
+ * diff, `Format.file()`), so the bytes are staged (`../image-staging.ts`) and
19
+ * committed by the `cursor_image_save` plugin tool, which raises OpenCode's
20
+ * `edit` permission. A refusal returns Cursor's own `permission_denied`.
21
+ */
22
+ import path from "node:path";
23
+ import { decodeMessageSparse } from "./messages.js";
24
+ /** Host tool id that commits a staged image. Registered by the classic plugin. */
25
+ export const CURSOR_IMAGE_SAVE_TOOL = "cursor_image_save";
26
+ /** Cursor's agent always writes generated images into this subdirectory. */
27
+ export const CURSOR_IMAGE_ASSETS_DIR = "assets";
28
+ const IMAGE_MIME_BY_EXTENSION = {
29
+ ".png": "image/png",
30
+ ".jpg": "image/jpeg",
31
+ ".jpeg": "image/jpeg",
32
+ ".gif": "image/gif",
33
+ ".webp": "image/webp",
34
+ };
35
+ /** Cursor names the target file; its extension is the only mime signal sent. */
36
+ export function imageMimeForPath(filePath) {
37
+ const dot = path.basename(filePath).lastIndexOf(".");
38
+ if (dot <= 0)
39
+ return "image/png";
40
+ return IMAGE_MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? "image/png";
41
+ }
42
+ function asRecord(value) {
43
+ return value && typeof value === "object" && !Array.isArray(value)
44
+ ? value
45
+ : undefined;
46
+ }
47
+ function str(value) {
48
+ return typeof value === "string" ? value : "";
49
+ }
50
+ /** Decode a `generate_image_request_query` body, or undefined when unusable. */
51
+ export function decodeGenerateImageQuery(queryBytes) {
52
+ let decoded;
53
+ try {
54
+ decoded = decodeMessageSparse("GenerateImageRequestQuery", queryBytes);
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ const args = asRecord(decoded.args);
60
+ if (!args)
61
+ return undefined;
62
+ const description = str(args.description);
63
+ if (!description)
64
+ return undefined;
65
+ return {
66
+ description,
67
+ filePath: str(args.file_path),
68
+ referenceImagePaths: Array.isArray(args.reference_image_paths)
69
+ ? args.reference_image_paths.filter((item) => typeof item === "string")
70
+ : [],
71
+ toolCallId: str(decoded.tool_call_id),
72
+ };
73
+ }
74
+ function isInside(root, target) {
75
+ const relative = path.relative(path.resolve(root), path.resolve(target));
76
+ return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
77
+ }
78
+ /**
79
+ * Map Cursor's intended write target onto a real location under this host.
80
+ *
81
+ * Cursor composes the path from the `project_folder` this provider advertised,
82
+ * so an untouched target already resolves under the host cache project
83
+ * directory and is kept as-is. A target inside the workspace is also honoured
84
+ * verbatim — that is a location the user can see. Anything else (a path built
85
+ * from a folder we never advertised, or an attempted escape) is rebased onto
86
+ * `<projectDir>/assets/<basename>` rather than refused, so the model still gets
87
+ * a file where it expects one instead of a failed generation.
88
+ */
89
+ export function remapCursorImageWritePath(target, roots) {
90
+ const projectDir = path.resolve(roots.projectDir);
91
+ const assets = path.join(projectDir, CURSOR_IMAGE_ASSETS_DIR);
92
+ const fallback = path.join(assets, path.basename(target) || "generated-image.png");
93
+ if (!target)
94
+ return fallback;
95
+ const absolute = path.isAbsolute(target) ? target : path.resolve(projectDir, target);
96
+ if (isInside(projectDir, absolute))
97
+ return path.resolve(absolute);
98
+ if (roots.workspaceRoot && isInside(roots.workspaceRoot, absolute))
99
+ return path.resolve(absolute);
100
+ return fallback;
101
+ }
@@ -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;