cursor-opencode-provider 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +28 -9
  3. package/SECURITY.md +43 -7
  4. package/dist/context/build.d.ts +22 -4
  5. package/dist/context/build.js +33 -5
  6. package/dist/context/dynamic-catalog.js +6 -1
  7. package/dist/context/frozen.js +6 -2
  8. package/dist/context/index.d.ts +2 -0
  9. package/dist/context/index.js +2 -0
  10. package/dist/context/paths.d.ts +44 -17
  11. package/dist/context/paths.js +65 -18
  12. package/dist/context/skills-bridge.d.ts +22 -0
  13. package/dist/context/skills-bridge.js +20 -0
  14. package/dist/context/skills.d.ts +34 -0
  15. package/dist/context/skills.js +211 -0
  16. package/dist/host-agent-mode.d.ts +53 -4
  17. package/dist/host-agent-mode.js +117 -13
  18. package/dist/host-plan-file.d.ts +28 -0
  19. package/dist/host-plan-file.js +76 -0
  20. package/dist/image-input.d.ts +18 -0
  21. package/dist/image-input.js +119 -28
  22. package/dist/image-save.d.ts +10 -2
  23. package/dist/image-save.js +33 -21
  24. package/dist/language-model.d.ts +45 -17
  25. package/dist/language-model.js +925 -292
  26. package/dist/model-config.d.ts +9 -0
  27. package/dist/model-config.js +28 -1
  28. package/dist/opencode2/catalog.d.ts +1 -0
  29. package/dist/opencode2/catalog.js +2 -0
  30. package/dist/opencode2/image-save-tool.d.ts +8 -0
  31. package/dist/opencode2/image-save-tool.js +84 -0
  32. package/dist/opencode2/types.d.ts +15 -0
  33. package/dist/parallel-step.d.ts +65 -0
  34. package/dist/parallel-step.js +102 -0
  35. package/dist/plugin-opencode2.js +48 -40
  36. package/dist/plugin.js +69 -18
  37. package/dist/pricing-data.d.ts +14 -1
  38. package/dist/pricing-data.js +14 -1
  39. package/dist/protocol/ask-question.d.ts +12 -1
  40. package/dist/protocol/ask-question.js +77 -3
  41. package/dist/protocol/create-plan.d.ts +47 -25
  42. package/dist/protocol/create-plan.js +33 -23
  43. package/dist/protocol/interactions.d.ts +26 -17
  44. package/dist/protocol/interactions.js +80 -31
  45. package/dist/protocol/messages.js +74 -14
  46. package/dist/protocol/request.d.ts +5 -0
  47. package/dist/protocol/request.js +2 -0
  48. package/dist/protocol/switch-mode.d.ts +66 -27
  49. package/dist/protocol/switch-mode.js +147 -73
  50. package/dist/protocol/token-details.d.ts +2 -0
  51. package/dist/protocol/token-details.js +11 -1
  52. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  53. package/dist/protocol/tool-call-bridge.js +14 -0
  54. package/dist/protocol/tools.d.ts +16 -1
  55. package/dist/protocol/tools.js +113 -11
  56. package/dist/replay-safety.d.ts +6 -0
  57. package/dist/replay-safety.js +56 -6
  58. package/dist/session.d.ts +35 -1
  59. package/dist/session.js +45 -1
  60. package/dist/todo-store.d.ts +2 -0
  61. package/dist/todo-store.js +19 -0
  62. package/dist/transport/connect.d.ts +2 -0
  63. package/dist/transport/connect.js +23 -6
  64. package/dist/usage.js +23 -6
  65. package/package.json +3 -2
  66. package/dist/plan-execution-kickoff.d.ts +0 -57
  67. package/dist/plan-execution-kickoff.js +0 -186
@@ -0,0 +1,28 @@
1
+ /**
2
+ * OpenCode's own plan file per session, resolved while the session runs under
3
+ * the `plan` agent. CreatePlan records the plan there so the `plan_exit` review
4
+ * reads exactly the file the user approves.
5
+ *
6
+ * Resolution is structural: session `slug` / `time.created` from the classic
7
+ * client, project VCS and worktree from the plugin input, and the location
8
+ * from OpenCode's `Session.plan` rule (or an injected host path bridge).
9
+ */
10
+ export declare function hostPlanFileFor(sessionID: string | undefined): string | undefined;
11
+ /** True once the session's plan file is known, including known to be absent. */
12
+ export declare function isHostPlanFileResolved(sessionID: string | undefined): boolean;
13
+ /**
14
+ * Record a session's plan file. `null` records that the host defines none, so
15
+ * it is not looked up again; `undefined` forgets it, so the next request retries.
16
+ */
17
+ export declare function setHostPlanFile(sessionID: string, file: string | null | undefined): void;
18
+ export declare function resetHostPlanFilesForTests(): void;
19
+ /**
20
+ * Resolve and remember the host plan file for one session. A session without
21
+ * one is remembered as such; a failed lookup is not, so a later request retries.
22
+ */
23
+ export declare function resolveHostPlanFile(input: {
24
+ sessionID: string;
25
+ getSession: (sessionID: string) => Promise<unknown>;
26
+ worktree: string | undefined;
27
+ vcs: boolean;
28
+ }): Promise<string | undefined>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * OpenCode's own plan file per session, resolved while the session runs under
3
+ * the `plan` agent. CreatePlan records the plan there so the `plan_exit` review
4
+ * reads exactly the file the user approves.
5
+ *
6
+ * Resolution is structural: session `slug` / `time.created` from the classic
7
+ * client, project VCS and worktree from the plugin input, and the location
8
+ * from OpenCode's `Session.plan` rule (or an injected host path bridge).
9
+ */
10
+ import { hostPlanFilePath } from "./context/paths.js";
11
+ import { errorMessage, trace } from "./debug.js";
12
+ /** Session → its plan file, or `null` when the host defines none for it. */
13
+ const hostPlanFiles = new Map();
14
+ const MAX_HOST_PLAN_FILES = 256;
15
+ export function hostPlanFileFor(sessionID) {
16
+ const key = sessionID?.trim();
17
+ return (key ? hostPlanFiles.get(key) : undefined) ?? undefined;
18
+ }
19
+ /** True once the session's plan file is known, including known to be absent. */
20
+ export function isHostPlanFileResolved(sessionID) {
21
+ const key = sessionID?.trim();
22
+ return !!key && hostPlanFiles.has(key);
23
+ }
24
+ /**
25
+ * Record a session's plan file. `null` records that the host defines none, so
26
+ * it is not looked up again; `undefined` forgets it, so the next request retries.
27
+ */
28
+ export function setHostPlanFile(sessionID, file) {
29
+ const key = sessionID.trim();
30
+ if (!key)
31
+ return;
32
+ hostPlanFiles.delete(key);
33
+ if (file === undefined || file === "")
34
+ return;
35
+ hostPlanFiles.set(key, file);
36
+ while (hostPlanFiles.size > MAX_HOST_PLAN_FILES) {
37
+ const oldest = hostPlanFiles.keys().next().value;
38
+ if (!oldest)
39
+ break;
40
+ hostPlanFiles.delete(oldest);
41
+ }
42
+ }
43
+ export function resetHostPlanFilesForTests() {
44
+ hostPlanFiles.clear();
45
+ }
46
+ function record(value) {
47
+ return value && typeof value === "object" && !Array.isArray(value)
48
+ ? value
49
+ : undefined;
50
+ }
51
+ /**
52
+ * Resolve and remember the host plan file for one session. A session without
53
+ * one is remembered as such; a failed lookup is not, so a later request retries.
54
+ */
55
+ export async function resolveHostPlanFile(input) {
56
+ try {
57
+ const response = record(await input.getSession(input.sessionID));
58
+ const info = record(response?.data) ?? response;
59
+ const slug = typeof info?.slug === "string" ? info.slug : "";
60
+ const created = record(info?.time)?.created;
61
+ if (!slug || typeof created !== "number" || !input.worktree) {
62
+ setHostPlanFile(input.sessionID, null);
63
+ return undefined;
64
+ }
65
+ const file = hostPlanFilePath({ worktree: input.worktree, vcs: input.vcs, created, slug });
66
+ setHostPlanFile(input.sessionID, file ?? null);
67
+ if (file)
68
+ trace(`host-plan-file: sessionID=${input.sessionID} file=${file}`);
69
+ return file;
70
+ }
71
+ catch (error) {
72
+ trace(`host-plan-file: unresolved sessionID=${input.sessionID} err=${errorMessage(error)}`);
73
+ setHostPlanFile(input.sessionID, undefined);
74
+ return undefined;
75
+ }
76
+ }
@@ -9,6 +9,8 @@ export type CursorHistoryImageExtraction = {
9
9
  hashes: string[];
10
10
  candidateCount: number;
11
11
  duplicateCount: number;
12
+ /** Unreadable optional tool media; explicit user attachments still fail loudly. */
13
+ omittedCount?: number;
12
14
  };
13
15
  export type CursorPromptImageExtraction = CursorHistoryImageExtraction & {
14
16
  userImageCount: number;
@@ -23,6 +25,22 @@ export declare function extractCursorHistoryImages(prompt: readonly unknown[], o
23
25
  maxBytes?: number;
24
26
  filenameOffset?: number;
25
27
  }): Promise<CursorHistoryImageExtraction>;
28
+ /**
29
+ * Decode the images a host tool returned (its own `file-data` / `image-data`
30
+ * parts, or the `file` parts OpenCode moves into the trailing
31
+ * `Attached media from tool result:` message) for a held-Run exec result.
32
+ * Non-image parts are skipped; a part that cannot be decoded is dropped so the
33
+ * text result is still delivered.
34
+ */
35
+ export declare function extractCursorToolResultImages(parts: readonly unknown[], options?: {
36
+ signal?: AbortSignal;
37
+ maxBytes?: number;
38
+ maxImages?: number;
39
+ }): Promise<{
40
+ images: CursorImageInput[];
41
+ hashes: string[];
42
+ omittedCount: number;
43
+ }>;
26
44
  export declare function extractCursorPromptImages(prompt: readonly unknown[], lastUser: Record<string, unknown> | undefined, options: {
27
45
  supportsImages: boolean;
28
46
  seenHistoryHashes?: ReadonlySet<string>;
@@ -4,8 +4,14 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { UnsupportedFunctionalityError } from "@ai-sdk/provider";
6
6
  export const MAX_CURSOR_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
7
+ function isToolMediaCaption(message) {
8
+ if (!message || !Array.isArray(message.content))
9
+ return false;
10
+ const first = message.content[0];
11
+ return first?.type === "text" && first.text === "Attached media from tool result:";
12
+ }
7
13
  export function hasCursorUserImages(lastUser) {
8
- return !!lastUser && Array.isArray(lastUser.content) && lastUser.content.some((part) => {
14
+ return !isToolMediaCaption(lastUser) && !!lastUser && Array.isArray(lastUser.content) && lastUser.content.some((part) => {
9
15
  if (!part || typeof part !== "object")
10
16
  return false;
11
17
  const file = part;
@@ -20,18 +26,20 @@ export function assertCursorUserImageSupport(lastUser, supportsImages, modelId)
20
26
  return;
21
27
  unsupported("image input", `Cursor model ${JSON.stringify(modelId)} does not support image input`);
22
28
  }
23
- function decodeBase64(value) {
29
+ function decodeBase64(value, remaining) {
24
30
  const normalized = value.replace(/\s/g, "");
25
31
  if (!normalized || !/^[A-Za-z0-9+/_-]*={0,2}$/.test(normalized)) {
26
32
  return unsupported("image input", "Cursor provider received invalid base64 image data");
27
33
  }
34
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
35
+ assertImageSize(Math.floor((normalized.length - padding) * 3 / 4), remaining);
28
36
  const data = Uint8Array.from(Buffer.from(normalized, "base64"));
29
37
  if (data.length === 0) {
30
38
  return unsupported("image input", "Cursor provider received an empty image");
31
39
  }
32
40
  return data;
33
41
  }
34
- function decodeDataUrl(value) {
42
+ function decodeDataUrl(value, remaining) {
35
43
  if (!value.startsWith("data:")) {
36
44
  return unsupported("image input", "Cursor provider supports base64-encoded image data URLs only");
37
45
  }
@@ -47,7 +55,7 @@ function decodeDataUrl(value) {
47
55
  const firstSeparator = metadata.indexOf(";");
48
56
  const mimeType = metadata.slice(0, firstSeparator);
49
57
  return {
50
- data: decodeBase64(value.slice(commaIndex + 1)),
58
+ data: decodeBase64(value.slice(commaIndex + 1), remaining),
51
59
  mimeType: mimeType || undefined,
52
60
  };
53
61
  }
@@ -121,13 +129,15 @@ async function readResponseBytes(response, remaining) {
121
129
  return data;
122
130
  }
123
131
  async function resolveImageData(value, remaining, signal) {
124
- if (value instanceof Uint8Array)
132
+ if (value instanceof Uint8Array) {
133
+ assertImageSize(value.length, remaining);
125
134
  return { data: Uint8Array.from(value) };
135
+ }
126
136
  if (typeof value === "string") {
127
- return value.startsWith("data:") ? decodeDataUrl(value) : { data: decodeBase64(value) };
137
+ return value.startsWith("data:") ? decodeDataUrl(value, remaining) : { data: decodeBase64(value, remaining) };
128
138
  }
129
139
  if (value.protocol === "data:")
130
- return decodeDataUrl(value.href);
140
+ return decodeDataUrl(value.href, remaining);
131
141
  if (value.protocol === "file:") {
132
142
  const filePath = fileURLToPath(value);
133
143
  const info = await stat(filePath);
@@ -183,21 +193,33 @@ export async function extractCursorUserImages(lastUser, signal, maxBytes = MAX_C
183
193
  const file = part;
184
194
  if (file.type !== "file")
185
195
  continue;
186
- const image = await decodeCursorImagePart(file, byteBudget - totalBytes, signal, `image-${images.length + 1}`);
196
+ const image = await decodeCursorImagePart(toolImagePart(file) ?? file, byteBudget - totalBytes, signal, `image-${images.length + 1}`);
187
197
  totalBytes += image.data.length;
188
198
  images.push(image);
189
199
  }
190
200
  return images;
191
201
  }
192
- function pushImageFileParts(parts, content) {
202
+ /** Normalize OpenCode / AI SDK attachment shapes before either delivery path. */
203
+ function toolImagePart(part) {
204
+ if (!part || typeof part !== "object")
205
+ return undefined;
206
+ const file = part;
207
+ if (!["file", "file-data", "image-data", "image-url", "media", "image"].includes(String(file.type)))
208
+ return undefined;
209
+ const mediaType = typeof file.mediaType === "string" ? file.mediaType
210
+ : file.type === "image-url" ? "image/*" : file.mime;
211
+ if (typeof mediaType !== "string" || !mediaType.startsWith("image/"))
212
+ return undefined;
213
+ const source = file.url ?? file.uri;
214
+ const data = file.data ?? (typeof source === "string" && /^(?:https?|file):/.test(source) && URL.canParse(source)
215
+ ? new URL(source) : source);
216
+ return { ...file, mediaType, data };
217
+ }
218
+ function pushImageFileParts(parts, content, toolResult = false) {
193
219
  for (const part of content) {
194
- if (!part || typeof part !== "object")
195
- continue;
196
- const file = part;
197
- if (file.type === "file" &&
198
- typeof file.mediaType === "string" &&
199
- file.mediaType.startsWith("image/"))
200
- parts.push(file);
220
+ const file = toolImagePart(part);
221
+ if (file?.type === "file")
222
+ parts.push({ file, toolResult });
201
223
  }
202
224
  }
203
225
  function cursorHistoryImageParts(prompt) {
@@ -222,7 +244,7 @@ function cursorHistoryImageParts(prompt) {
222
244
  if (record.role === "user") {
223
245
  if (i === lastUserIndex)
224
246
  continue;
225
- pushImageFileParts(parts, record.content);
247
+ pushImageFileParts(parts, record.content, isToolMediaCaption(record));
226
248
  continue;
227
249
  }
228
250
  if (record.role === "assistant") {
@@ -244,11 +266,9 @@ function cursorHistoryImageParts(prompt) {
244
266
  for (const value of output.value) {
245
267
  if (!value || typeof value !== "object")
246
268
  continue;
247
- const file = value;
248
- if (file.type === "file-data" &&
249
- typeof file.mediaType === "string" &&
250
- file.mediaType.startsWith("image/"))
251
- parts.push(file);
269
+ const file = toolImagePart(value);
270
+ if (file)
271
+ parts.push({ file, toolResult: true });
252
272
  }
253
273
  }
254
274
  }
@@ -264,11 +284,22 @@ export async function extractCursorHistoryImages(prompt, options) {
264
284
  const hashes = [];
265
285
  const hashesThisTurn = new Set();
266
286
  let duplicateCount = 0;
287
+ let omittedCount = 0;
267
288
  let totalBytes = 0;
268
- for (const file of candidates) {
289
+ for (const { file, toolResult } of candidates) {
269
290
  // Resolve against the per-image cap first so a previously sent duplicate
270
291
  // does not fail merely because little combined budget remains this turn.
271
- const image = await decodeCursorImagePart(file, MAX_CURSOR_IMAGE_INPUT_BYTES, options.signal, `image-${(options.filenameOffset ?? 0) + images.length + 1}`, MAX_CURSOR_IMAGE_INPUT_BYTES);
292
+ let image;
293
+ try {
294
+ image = await decodeCursorImagePart(file, MAX_CURSOR_IMAGE_INPUT_BYTES, options.signal, `image-${(options.filenameOffset ?? 0) + images.length + 1}`);
295
+ }
296
+ catch (error) {
297
+ options.signal?.throwIfAborted();
298
+ if (!toolResult || (error instanceof Error && error.name === "AbortError"))
299
+ throw error;
300
+ omittedCount++;
301
+ continue;
302
+ }
272
303
  const hash = imageContentHash(image.data);
273
304
  if (options.seenHashes?.has(hash) || hashesThisTurn.has(hash)) {
274
305
  duplicateCount++;
@@ -280,15 +311,71 @@ export async function extractCursorHistoryImages(prompt, options) {
280
311
  hashes.push(hash);
281
312
  hashesThisTurn.add(hash);
282
313
  }
283
- return { images, hashes, candidateCount: candidates.length, duplicateCount };
314
+ return { images, hashes, candidateCount: candidates.length, duplicateCount, ...(omittedCount > 0 ? { omittedCount } : {}) };
315
+ }
316
+ /**
317
+ * Decode the images a host tool returned (its own `file-data` / `image-data`
318
+ * parts, or the `file` parts OpenCode moves into the trailing
319
+ * `Attached media from tool result:` message) for a held-Run exec result.
320
+ * Non-image parts are skipped; a part that cannot be decoded is dropped so the
321
+ * text result is still delivered.
322
+ */
323
+ export async function extractCursorToolResultImages(parts, options = {}) {
324
+ const maxBytes = cursorImageBudget(options.maxBytes ?? MAX_CURSOR_IMAGE_INPUT_BYTES);
325
+ const images = [];
326
+ const hashes = [];
327
+ let totalBytes = 0;
328
+ let omittedCount = 0;
329
+ options.signal?.throwIfAborted();
330
+ for (const part of parts) {
331
+ options.signal?.throwIfAborted();
332
+ const file = toolImagePart(part);
333
+ if (!file)
334
+ continue;
335
+ if (images.length >= (options.maxImages ?? Infinity) || totalBytes >= maxBytes) {
336
+ omittedCount++;
337
+ continue;
338
+ }
339
+ try {
340
+ const image = await decodeCursorImagePart(file, maxBytes - totalBytes, options.signal, `image-${images.length + 1}`);
341
+ options.signal?.throwIfAborted();
342
+ totalBytes += image.data.length;
343
+ images.push(image);
344
+ hashes.push(imageContentHash(image.data));
345
+ }
346
+ catch (error) {
347
+ // Tool media is optional: a missing file or failed download must not
348
+ // strand every pending exec. Cancellation still belongs to the caller.
349
+ options.signal?.throwIfAborted();
350
+ if (error instanceof Error && error.name === "AbortError")
351
+ throw error;
352
+ omittedCount++;
353
+ }
354
+ }
355
+ return { images, hashes, omittedCount };
284
356
  }
285
357
  export async function extractCursorPromptImages(prompt, lastUser, options) {
286
358
  const maxBytes = cursorImageBudget(options.maxBytes ?? MAX_CURSOR_IMAGE_INPUT_BYTES);
287
- const userImages = await extractCursorUserImages(lastUser, options.signal, maxBytes);
359
+ const toolCaption = isToolMediaCaption(lastUser);
360
+ const caption = toolCaption && options.supportsImages
361
+ ? await extractCursorToolResultImages(lastUser.content, { signal: options.signal, maxBytes })
362
+ : undefined;
363
+ const captionHashes = [];
364
+ const seenHashes = new Set(options.seenHistoryHashes);
365
+ let captionDuplicates = 0;
366
+ const userImages = toolCaption ? (caption?.images ?? []).filter((_image, index) => {
367
+ const hash = caption.hashes[index];
368
+ if (seenHashes.has(hash)) {
369
+ captionDuplicates++;
370
+ return false;
371
+ }
372
+ seenHashes.add(hash);
373
+ captionHashes.push(hash);
374
+ return true;
375
+ }) : await extractCursorUserImages(lastUser, options.signal, maxBytes);
288
376
  const userBytes = userImages.reduce((total, image) => total + image.data.length, 0);
289
377
  // Seed history dedupe with this-turn last-user hashes so the same bytes on an
290
378
  // earlier user/assistant/tool message are not attached twice in one Run.
291
- const seenHashes = new Set(options.seenHistoryHashes);
292
379
  for (const image of userImages)
293
380
  seenHashes.add(imageContentHash(image.data));
294
381
  const history = await extractCursorHistoryImages(prompt, {
@@ -300,7 +387,11 @@ export async function extractCursorPromptImages(prompt, lastUser, options) {
300
387
  });
301
388
  return {
302
389
  ...history,
390
+ hashes: [...captionHashes, ...history.hashes],
391
+ duplicateCount: history.duplicateCount + captionDuplicates,
303
392
  images: [...userImages, ...history.images],
304
- userImageCount: userImages.length,
393
+ userImageCount: toolCaption ? 0 : userImages.length,
394
+ ...((history.omittedCount ?? 0) + (caption?.omittedCount ?? 0) > 0
395
+ ? { omittedCount: (history.omittedCount ?? 0) + (caption?.omittedCount ?? 0) } : {}),
305
396
  };
306
397
  }
@@ -17,16 +17,24 @@ export declare const IMAGE_PERMISSION_DENIED_PREFIX = "CursorImagePermissionDeni
17
17
  export type ImageSaveToolContext = {
18
18
  worktree: string;
19
19
  directory: string;
20
- ask(input: {
20
+ /**
21
+ * Classic OpenCode 1.x `ToolContext.ask`. A host whose tool context has no
22
+ * permission prompt (OpenCode 2.0's public `ToolContext`) passes `null`
23
+ * explicitly: the commit then writes after containment, gated only by the
24
+ * tool's catalog permission. A missing `ask` is refused, never treated as
25
+ * permission.
26
+ */
27
+ ask: ((input: {
21
28
  permission: string;
22
29
  patterns: string[];
23
30
  always: string[];
24
31
  metadata: Record<string, unknown>;
25
- }): Promise<void>;
32
+ }) => Promise<void>) | null;
26
33
  };
27
34
  export type ImageSaveResult = {
28
35
  title: string;
29
36
  output: string;
37
+ bytes: number;
30
38
  attachments?: Array<{
31
39
  type: "file";
32
40
  mime: string;
@@ -126,31 +126,42 @@ export async function executeCursorImageSave(args, ctx) {
126
126
  // case `assertExternalDirectory` exists for. Asking only for `edit` would
127
127
  // quietly skip a boundary the host enforces for every one of its own writes.
128
128
  const editPattern = path.relative(realRoot(workspace), contained.path);
129
- try {
130
- if (!isInsideProject(contained.path, workspace, ctx.directory)) {
131
- const parentDir = path.dirname(contained.path);
132
- const glob = path.join(parentDir, "*").replaceAll("\\", "/");
129
+ if (typeof ctx.ask === "function") {
130
+ try {
131
+ if (!isInsideProject(contained.path, workspace, ctx.directory)) {
132
+ const parentDir = path.dirname(contained.path);
133
+ const glob = path.join(parentDir, "*").replaceAll("\\", "/");
134
+ await ctx.ask({
135
+ permission: "external_directory",
136
+ patterns: [glob],
137
+ always: [glob],
138
+ metadata: { filepath: contained.path, parentDir },
139
+ });
140
+ }
133
141
  await ctx.ask({
134
- permission: "external_directory",
135
- patterns: [glob],
136
- always: [glob],
137
- metadata: { filepath: contained.path, parentDir },
142
+ permission: "edit",
143
+ patterns: [editPattern],
144
+ always: ["*"],
145
+ metadata: {
146
+ filepath: contained.path,
147
+ mime: image.mime,
148
+ bytes: image.data.length,
149
+ source: "cursor-generate-image",
150
+ },
138
151
  });
139
152
  }
140
- await ctx.ask({
141
- permission: "edit",
142
- patterns: [editPattern],
143
- always: ["*"],
144
- metadata: {
145
- filepath: contained.path,
146
- mime: image.mime,
147
- bytes: image.data.length,
148
- source: "cursor-generate-image",
149
- },
150
- });
153
+ catch (error) {
154
+ const reason = errorMessage(error);
155
+ trace(`image save: permission refused path=${JSON.stringify(contained.path)} reason=${reason}`);
156
+ throw new Error(`${IMAGE_PERMISSION_DENIED_PREFIX} ${reason}`);
157
+ }
151
158
  }
152
- catch (error) {
153
- const reason = errorMessage(error);
159
+ else if (ctx.ask === null) {
160
+ trace(`image save: host cannot prompt; writing after containment ` +
161
+ `path=${JSON.stringify(contained.path)}`);
162
+ }
163
+ else {
164
+ const reason = "this host did not provide a permission prompt for the write";
154
165
  trace(`image save: permission refused path=${JSON.stringify(contained.path)} reason=${reason}`);
155
166
  throw new Error(`${IMAGE_PERMISSION_DENIED_PREFIX} ${reason}`);
156
167
  }
@@ -185,5 +196,6 @@ export async function executeCursorImageSave(args, ctx) {
185
196
  return {
186
197
  title: editPattern,
187
198
  output: `Saved the generated image to ${contained.path} (${image.data.length} bytes).`,
199
+ bytes: image.data.length,
188
200
  };
189
201
  }
@@ -7,6 +7,7 @@ import { type CursorSession } from "./session.js";
7
7
  import { CursorProviderError } from "./errors.js";
8
8
  import { type ModelInfo } from "./models.js";
9
9
  import type { SeedHistoryMessage } from "./protocol/request.js";
10
+ import { type CursorImageInput } from "./image-input.js";
10
11
  type PromptIdentity = {
11
12
  hostAgent?: string;
12
13
  systemPromptHash?: string;
@@ -36,7 +37,7 @@ export type CursorRetryPolicy = {
36
37
  };
37
38
  export declare function resolveRetryPolicy(options: CursorRetryOptions | undefined): CursorRetryPolicy;
38
39
  export declare function connectFrameError(payload: string): CursorProviderError;
39
- /** Restore the last real catalog solely for lifecycle turns such as compaction. */
40
+ /** Restore the epoch catalog for the next nonempty host turn. */
40
41
  export declare function restoreTurnToolCatalog(sessionKey: string, tools: OpencodeToolDef[]): void;
41
42
  /** Record the authoritative host todo list for an OpenCode session. */
42
43
  export declare function rememberMirroredTodos(sessionKey: string | undefined, todos: ReadonlyArray<Record<string, unknown>>): void;
@@ -48,6 +49,8 @@ export declare function snapshotMirroredTodosBySession(sessionKey: string | unde
48
49
  * churn have a single no-op definition.
49
50
  */
50
51
  export declare function promptIdentityWouldRemint(_previous: PromptIdentity, _current: PromptIdentity): boolean;
52
+ /** Test helper: content hashes recorded after a successful held-Run image write. */
53
+ export declare function snapshotSentHistoryImageHashesForTests(sessionKey: string): string[];
51
54
  type V3Part = LanguageModelV3StreamPart;
52
55
  export declare function createCursorLanguageModel(modelId: string, providerId: string, options: CreateCursorOptions): LanguageModelV3;
53
56
  export declare function pumpWithRecovery(input: {
@@ -96,12 +99,14 @@ export declare function isProperCatalogSubset(incoming: ReadonlyArray<{
96
99
  }>): boolean;
97
100
  /**
98
101
  * An open parent Run for this OpenCode session whose catalog strictly contains
99
- * the incoming tools is treated as an in-session helper. Those calls must not
100
- * cancel/supersede the parent (that remints on the trailing tool result).
102
+ * the incoming tools is treated as an in-session helper unless the request
103
+ * carries a result for that parent's pending call. A result can precede a new
104
+ * user message or agent-change reminder and still needs to reach the parent.
105
+ * Helper calls must not cancel/supersede the parent.
101
106
  */
102
107
  export declare function shouldIsolateInSessionHelper(openCodeSessionId: string | undefined, incomingTools: ReadonlyArray<{
103
108
  name?: string;
104
- }>): boolean;
109
+ }>, historicalResults?: ExtractedToolResult[]): boolean;
105
110
  /** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
106
111
  export declare const FRESH_TURN_DRAIN_TIMEOUT_MS = 8000;
107
112
  /**
@@ -119,6 +124,9 @@ export declare const FRESH_TURN_PENDING_CANCEL_REASON = "Host started a new user
119
124
  */
120
125
  export declare function preparePriorSessionForFreshTurn(openCodeSessionId: string | undefined, opts?: {
121
126
  timeoutMs?: number;
127
+ /** Tool results already in this prompt; deliver any that match held pendings before cancel. */
128
+ toolResults?: ExtractedToolResult[];
129
+ hostAgent?: string;
122
130
  }): Promise<"drained" | "settled-only" | "busy" | "none">;
123
131
  /**
124
132
  * Write error/reject results for every still-open non-bridged pending on the
@@ -135,33 +143,46 @@ export declare function cancelPendingExecsForFreshTurn(session: CursorSession):
135
143
  export declare function drainSessionUntilTurnEnded(session: CursorSession, opts?: {
136
144
  timeoutMs?: number;
137
145
  }): Promise<"turn-ended" | "busy" | "timeout" | "interrupted" | "skipped">;
138
- /**
139
- * Deliver trailing tool results onto a live continuation session.
140
- * Returns the same session when writes succeed (or only bridged results were
141
- * cleared). Returns undefined after closing the session when a write fails, so
142
- * the caller can rebase onto a fresh Run instead of pumping a dead stream.
143
- */
144
- export declare function deliverContinuationResults(session: CursorSession, trailingToolResults: ExtractedToolResult[]): CursorSession | undefined;
146
+ export declare function deliverContinuationResults(session: CursorSession, trailingToolResults: ExtractedToolResult[],
147
+ /** Host primary agent of the request that carries these results. */
148
+ delivery?: {
149
+ hostAgent?: string;
150
+ }): CursorSession | undefined;
145
151
  /**
146
152
  * Read the held-open stream, emitting stream parts, until the turn boundary:
147
- * - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
148
- * and KEEP the session open for the result on the next doStream call;
153
+ * - tool call(s) for one Cursor generation → emit each tool-call as it
154
+ * arrives; finish "tool-calls" once Cursor's listed count (field 27) is
155
+ * met (or immediately when the process has never seen field 27); KEEP the
156
+ * session open for results on the next doStream call;
149
157
  * - turn_ended → finish "stop" and close the session;
150
- * - transport EOF before turn_ended → throw for one fresh-Run recovery.
158
+ * - transport EOF before turn_ended → throw for one fresh-Run recovery
159
+ * (after host tools were already emitted, finish "tool-calls" instead and
160
+ * let the continuation rebase).
151
161
  */
152
162
  export declare function pump(session: CursorSession, controller: ReadableStreamDefaultController<V3Part>, ids: {
153
163
  textId: string;
154
164
  reasoningId: string;
155
165
  }, abortSignal?: AbortSignal): Promise<void>;
156
- type ExtractedToolResult = {
166
+ type ToolResultContent = {
157
167
  toolCallId: string;
158
- sessionId: string;
159
- execId: number;
160
168
  toolName: string;
161
169
  output: string;
162
170
  error?: string;
171
+ /** Host media parts of this result (own content, or the trailing media message). */
172
+ media?: unknown[];
173
+ /** `media` decoded for the exec result, with content hashes in the same order. */
174
+ images?: CursorImageInput[];
175
+ imageHashes?: string[];
176
+ /** Host updates that a binary read cannot carry in its output oneof. */
177
+ notes?: string;
178
+ };
179
+ type ExtractedToolResult = ToolResultContent & {
180
+ sessionId: string;
181
+ execId: number;
163
182
  };
164
183
  export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
184
+ /** Decode only image-bearing pending execs, under one continuation-wide budget. */
185
+ export declare function decodeTrailingToolImages(session: CursorSession, results: ExtractedToolResult[], signal?: AbortSignal, maxBytes?: number): Promise<ExtractedToolResult[]>;
165
186
  /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
166
187
  export declare function hasApprovedUncorrelatedPlanStageResult(prompt: LanguageModelV3CallOptions["prompt"]): boolean;
167
188
  /**
@@ -179,6 +200,7 @@ export declare function groundCheckpointTurnText(userText: string, checkpoint: b
179
200
  */
180
201
  export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string, options?: {
181
202
  knownMcpServers?: Iterable<string>;
203
+ allowTools?: boolean;
182
204
  }): string | undefined;
183
205
  /** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
184
206
  export declare function estimateTokens(chars: number): number;
@@ -250,6 +272,12 @@ export declare function spanEndParts(opts: {
250
272
  }>;
251
273
  /** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
252
274
  export declare function computeAllowTools(toolCount: number, toolChoice: LanguageModelV3CallOptions["toolChoice"] | undefined): boolean;
275
+ /**
276
+ * OpenCode title/summary take the first non-empty line of the answer. After a
277
+ * lifecycle refuse, Cursor often narrates that tools are unavailable — drop
278
+ * that so it does not become the session title.
279
+ */
280
+ export declare function shouldDropLifecycleToollessText(text: string, refusedExec: boolean): boolean;
253
281
  export declare function resolveTurnToolState(input: {
254
282
  sessionKey?: string;
255
283
  incomingTools: OpencodeToolDef[];