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
@@ -0,0 +1,141 @@
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 { type OpencodeQuestionInput } from "./ask-question.js";
19
+ export type CursorPlanTodo = {
20
+ id: string;
21
+ content: string;
22
+ status: string;
23
+ };
24
+ export type CursorCreatePlanArgs = {
25
+ plan: string;
26
+ overview: string;
27
+ name: string;
28
+ isProject: boolean;
29
+ todos: CursorPlanTodo[];
30
+ };
31
+ export type DecodedCreatePlanQuery = {
32
+ args: CursorCreatePlanArgs;
33
+ toolCallId: string;
34
+ };
35
+ export type CreatePlanWriteResult = {
36
+ ok: true;
37
+ planPath: string;
38
+ planUri: string;
39
+ markdown: string;
40
+ } | {
41
+ ok: false;
42
+ error: string;
43
+ };
44
+ /** Optional host plan-stage tool advertised by a compatible host. */
45
+ export declare const CURSOR_PLAN_STAGE_TOOL = "cursor_plan_stage";
46
+ /** Held InteractionQuery continuation field for a native host plan stage. */
47
+ export declare const CREATE_PLAN_RESULT_FIELD = "create_plan_request_response";
48
+ export type CursorPlanStageInput = {
49
+ plan_uri: string;
50
+ content: string;
51
+ title: string;
52
+ };
53
+ /**
54
+ * How a CreatePlan query is satisfied this turn. Keyed only on the advertised
55
+ * catalog and the provider's own plan-mode state — never on host identity.
56
+ *
57
+ * - `stage` a host plan-stage tool is advertised; the host writes the plan
58
+ * *and* owns the execution-approval prompt, and its tool result is
59
+ * the approval outcome (success = approved, error = not approved).
60
+ * - `approve` no such tool, but the host advertises `question`: the provider
61
+ * writes the plan itself and then asks the same thing with upstream
62
+ * `PlanExitTool`'s own prompt.
63
+ * - `ack` nothing to ask with (or a lifecycle turn): write, or for a
64
+ * lifecycle turn skip the write, and acknowledge the CLI way.
65
+ *
66
+ * The plan is always persisted before any prompt: writing needs no approval,
67
+ * executing does.
68
+ */
69
+ export type CreatePlanBridge = {
70
+ kind: "stage";
71
+ } | {
72
+ kind: "approve";
73
+ } | {
74
+ kind: "ack";
75
+ };
76
+ /** Upstream `PlanExitTool` wording, with the plan the provider just wrote. */
77
+ export declare function createPlanApprovalQuestion(planLabel: string): string;
78
+ export declare const CREATE_PLAN_APPROVAL_HEADER = "Build Agent";
79
+ /** Cursor-visible reason when the user wants the plan revised instead of run. */
80
+ export declare const CREATE_PLAN_NOT_APPROVED_REASON: string;
81
+ /**
82
+ * Resolve how this CreatePlan is satisfied. `planModeActive` is the provider's
83
+ * own record of an approved Cursor plan/spec mode: the approval prompt guards
84
+ * the transition *out of* planning, so a CreatePlan raised outside plan mode is
85
+ * written and acknowledged without one.
86
+ */
87
+ export declare function resolveCreatePlanBridge(options: {
88
+ allowTools: boolean;
89
+ canStage?: boolean;
90
+ planModeActive?: boolean;
91
+ advertised: ReadonlySet<string> | Iterable<string>;
92
+ }): CreatePlanBridge;
93
+ /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
94
+ export declare function createPlanApprovalQuestionInput(planLabel: string): OpencodeQuestionInput;
95
+ /**
96
+ * True when the emulated approval prompt came back as an explicit "Yes".
97
+ * An unanswered, dismissed, or failed prompt keeps the model planning rather
98
+ * than silently starting execution.
99
+ *
100
+ * `question` must be the exact prompt that was asked — the host echoes it back
101
+ * verbatim, and it is the anchor the answer is parsed from.
102
+ */
103
+ export declare function createPlanApproved(output: string, isError: boolean, question: string): boolean;
104
+ /** Decode a `create_plan_request_query` body, or undefined when unusable. */
105
+ export declare function decodeCreatePlanQuery(queryBytes: Uint8Array): DecodedCreatePlanQuery | undefined;
106
+ /** OpenCode-style adjective-noun slug (core/src/util/slug.ts). */
107
+ export declare function randomPlanSlug(seed?: number): string;
108
+ /** Slugify a human title into a filesystem-safe token. */
109
+ export declare function slugifyPlanName(name: string): string;
110
+ /** Build the advertised host plan-stage artifact payload. */
111
+ export declare function createPlanStageInput(args: CursorCreatePlanArgs): CursorPlanStageInput;
112
+ /**
113
+ * Resolve the absolute plan file path via {@link hostPlansDir}.
114
+ * Filename shape matches OpenCode Session.plan: `<created>-<slug>.md`.
115
+ */
116
+ export declare function resolveHostPlanPath(workspaceRoot: string, name?: string, created?: number): string;
117
+ /** @deprecated Prefer {@link resolveHostPlanPath}. */
118
+ export declare const resolveOpencodePlanPath: typeof resolveHostPlanPath;
119
+ /**
120
+ * Render plain markdown for the plan file. No Cursor YAML frontmatter.
121
+ * Prefer `args.plan` as the body; prepend a title/overview when useful; append
122
+ * a markdown checklist for todos.
123
+ */
124
+ export declare function renderOpencodePlanMarkdown(args: CursorCreatePlanArgs): string;
125
+ /**
126
+ * Write the plan under {@link hostPlansDir} and return a `file://` URI.
127
+ * Empty / missing content is the caller's responsibility (empty plan_uri ack).
128
+ */
129
+ export declare function writeOpencodePlanFile(args: CursorCreatePlanArgs, workspaceRoot: string, created?: number): CreatePlanWriteResult;
130
+ /**
131
+ * The plan as the user reads it before approving execution.
132
+ *
133
+ * Cursor routes the plan body through the interaction query rather than the
134
+ * text stream, so without this the user is asked to approve a plan they were
135
+ * never shown. It goes into the assistant message — the host renders markdown
136
+ * there, it scrolls, and it survives answering the prompt. It must not go into
137
+ * the question itself: OpenCode renders the question dock outside its
138
+ * scrollbox with `flexShrink={0}`, so a full plan there would push the
139
+ * conversation off screen.
140
+ */
141
+ export declare function renderPlanReviewMessage(markdown: string, planPath: string): string;
@@ -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
+ }