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.
- package/README.md +18 -13
- package/dist/context/agents.d.ts +1 -1
- package/dist/context/agents.js +1 -1
- package/dist/context/auth-store.js +2 -2
- package/dist/context/build.d.ts +3 -1
- package/dist/context/build.js +4 -2
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/paths.d.ts +34 -53
- package/dist/context/paths.js +54 -118
- package/dist/context/rules.js +1 -1
- package/dist/image-save-tool.d.ts +19 -0
- package/dist/image-save-tool.js +25 -0
- package/dist/image-save.d.ts +58 -0
- package/dist/image-save.js +189 -0
- package/dist/image-staging.d.ts +59 -0
- package/dist/image-staging.js +91 -0
- package/dist/index.d.ts +1 -1
- package/dist/language-model.d.ts +3 -2
- package/dist/language-model.js +572 -34
- package/dist/plan-execution-kickoff.d.ts +55 -0
- package/dist/plan-execution-kickoff.js +162 -0
- package/dist/plugin-opencode2.js +52 -3
- package/dist/plugin-v2.d.ts +11 -9
- package/dist/plugin-v2.js +6 -13
- package/dist/plugin.d.ts +9 -0
- package/dist/plugin.js +61 -5
- package/dist/pricing-data.d.ts +16 -4
- package/dist/pricing-data.js +16 -4
- package/dist/protocol/ask-question.d.ts +133 -0
- package/dist/protocol/ask-question.js +320 -0
- package/dist/protocol/create-plan.d.ts +141 -0
- package/dist/protocol/create-plan.js +373 -0
- package/dist/protocol/generate-image.d.ts +54 -0
- package/dist/protocol/generate-image.js +101 -0
- package/dist/protocol/interactions.d.ts +110 -8
- package/dist/protocol/interactions.js +295 -35
- package/dist/protocol/messages.js +118 -6
- package/dist/protocol/switch-mode.d.ts +178 -0
- package/dist/protocol/switch-mode.js +417 -0
- package/dist/protocol/tool-call-bridge.js +10 -5
- package/dist/protocol/tools.d.ts +27 -11
- package/dist/protocol/tools.js +95 -41
- package/dist/usage.d.ts +2 -2
- package/dist/usage.js +2 -2
- package/dist/web-search-tool.d.ts +22 -26
- package/dist/web-search-tool.js +25 -15
- 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
|
-
|
|
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
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
132
|
+
export declare function buildAsyncAskQuestionCompletion(originalToolCallId: string, originalArgs: Uint8Array, result: AskQuestionResultMessage): Uint8Array;
|