cursor-opencode-provider 0.7.6 → 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 (101) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +36 -15
  3. package/SECURITY.md +43 -7
  4. package/dist/agent-url.js +2 -2
  5. package/dist/auth-renewal.d.ts +85 -0
  6. package/dist/auth-renewal.js +284 -0
  7. package/dist/auth.d.ts +38 -22
  8. package/dist/auth.js +79 -96
  9. package/dist/classic-tools.d.ts +15 -0
  10. package/dist/classic-tools.js +45 -0
  11. package/dist/context/build.d.ts +62 -7
  12. package/dist/context/build.js +127 -95
  13. package/dist/context/dynamic-catalog.d.ts +2 -58
  14. package/dist/context/dynamic-catalog.js +16 -161
  15. package/dist/context/epoch.d.ts +6 -4
  16. package/dist/context/epoch.js +22 -10
  17. package/dist/context/frozen.js +42 -15
  18. package/dist/context/index.d.ts +3 -1
  19. package/dist/context/index.js +3 -1
  20. package/dist/context/overlay.d.ts +2 -23
  21. package/dist/context/overlay.js +6 -30
  22. package/dist/context/paths.d.ts +51 -21
  23. package/dist/context/paths.js +78 -27
  24. package/dist/context/rules.d.ts +3 -15
  25. package/dist/context/rules.js +10 -186
  26. package/dist/context/skills-bridge.d.ts +22 -0
  27. package/dist/context/skills-bridge.js +20 -0
  28. package/dist/context/skills.d.ts +31 -8
  29. package/dist/context/skills.js +187 -86
  30. package/dist/debug.d.ts +2 -0
  31. package/dist/debug.js +4 -0
  32. package/dist/errors.d.ts +13 -0
  33. package/dist/errors.js +27 -3
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +119 -15
  36. package/dist/host-plan-file.d.ts +28 -0
  37. package/dist/host-plan-file.js +76 -0
  38. package/dist/image-input.d.ts +18 -0
  39. package/dist/image-input.js +119 -28
  40. package/dist/image-save.d.ts +10 -2
  41. package/dist/image-save.js +35 -23
  42. package/dist/index.d.ts +10 -0
  43. package/dist/language-model.d.ts +86 -25
  44. package/dist/language-model.js +1295 -369
  45. package/dist/model-config.d.ts +9 -0
  46. package/dist/model-config.js +28 -1
  47. package/dist/opencode2/catalog.d.ts +1 -0
  48. package/dist/opencode2/catalog.js +2 -0
  49. package/dist/opencode2/image-save-tool.d.ts +8 -0
  50. package/dist/opencode2/image-save-tool.js +84 -0
  51. package/dist/opencode2/integration.d.ts +30 -7
  52. package/dist/opencode2/integration.js +75 -46
  53. package/dist/opencode2/types.d.ts +15 -0
  54. package/dist/parallel-step.d.ts +65 -0
  55. package/dist/parallel-step.js +102 -0
  56. package/dist/plugin-opencode2.js +58 -56
  57. package/dist/plugin.d.ts +0 -10
  58. package/dist/plugin.js +207 -127
  59. package/dist/pricing-data.d.ts +14 -1
  60. package/dist/pricing-data.js +14 -1
  61. package/dist/protocol/ask-question.d.ts +12 -1
  62. package/dist/protocol/ask-question.js +77 -3
  63. package/dist/protocol/blob-store.js +3 -2
  64. package/dist/protocol/conversation-bind.js +1 -1
  65. package/dist/protocol/conversation-persistence.d.ts +2 -0
  66. package/dist/protocol/conversation-persistence.js +11 -1
  67. package/dist/protocol/conversation-state.d.ts +2 -0
  68. package/dist/protocol/conversation-state.js +19 -0
  69. package/dist/protocol/create-plan.d.ts +47 -25
  70. package/dist/protocol/create-plan.js +35 -24
  71. package/dist/protocol/interactions.d.ts +26 -17
  72. package/dist/protocol/interactions.js +80 -31
  73. package/dist/protocol/messages.js +96 -18
  74. package/dist/protocol/request.d.ts +16 -9
  75. package/dist/protocol/request.js +17 -25
  76. package/dist/protocol/switch-mode.d.ts +66 -27
  77. package/dist/protocol/switch-mode.js +147 -73
  78. package/dist/protocol/token-details.d.ts +2 -0
  79. package/dist/protocol/token-details.js +11 -1
  80. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  81. package/dist/protocol/tool-call-bridge.js +14 -0
  82. package/dist/protocol/tools.d.ts +33 -11
  83. package/dist/protocol/tools.js +165 -103
  84. package/dist/protocol/turn-provenance.d.ts +52 -0
  85. package/dist/protocol/turn-provenance.js +174 -0
  86. package/dist/replay-safety.d.ts +6 -0
  87. package/dist/replay-safety.js +56 -6
  88. package/dist/session.d.ts +44 -2
  89. package/dist/session.js +45 -1
  90. package/dist/shared.d.ts +7 -0
  91. package/dist/shared.js +7 -0
  92. package/dist/todo-store.d.ts +2 -0
  93. package/dist/todo-store.js +19 -0
  94. package/dist/transport/connect.d.ts +2 -0
  95. package/dist/transport/connect.js +23 -6
  96. package/dist/usage.js +23 -6
  97. package/package.json +3 -2
  98. package/dist/context/agents.d.ts +0 -12
  99. package/dist/context/agents.js +0 -136
  100. package/dist/plan-execution-kickoff.d.ts +0 -57
  101. package/dist/plan-execution-kickoff.js +0 -186
@@ -4,28 +4,111 @@
4
4
  * The language model stays host-neutral: it records a canonical Cursor mode
5
5
  * (`plan`, `spec`, `agent`, ...). A host entrypoint may install this structural
6
6
  * callback when its public API can select the corresponding primary agent.
7
- * OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents;
8
- * OpenCode 1.x continues to use advertised `plan_enter` / `plan_exit` tools.
7
+ * OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
8
+ * and continues the turn via `session.synthetic` (OpenCode 2 `switchAgent`
9
+ * only publishes the agent and would otherwise idle).
10
+ * On OpenCode 1.x hosts, advertised `plan_enter` / `plan_exit` tools stay
11
+ * authoritative; without `plan_enter`, the classic entrypoint selects the
12
+ * `plan` agent the 1.x way, with a synthetic user message for that agent.
9
13
  */
10
- import { trace } from "./debug.js";
11
- let switchHostAgent;
14
+ import { errorMessage, trace } from "./debug.js";
15
+ /**
16
+ * Live switches, newest last. A host may set the plugin up more than once in
17
+ * one process (one setup per location instance, re-setup on plugin reload) and
18
+ * dispose an older setup after a newer one installed its switch.
19
+ */
20
+ const registrations = [];
21
+ function activeSwitch() {
22
+ return registrations[registrations.length - 1];
23
+ }
12
24
  const pending = new Map();
25
+ const applying = new Map();
13
26
  const MAX_PENDING_HOST_AGENT_SWITCHES = 256;
14
- export function setHostAgentModeSwitch(fn) {
15
- switchHostAgent = fn;
16
- if (!fn)
27
+ /** True for Cursor mode ids served by the host's `plan` primary agent. */
28
+ function isPlanModeID(targetModeID) {
29
+ const mode = targetModeID.trim().toLowerCase();
30
+ return mode === "plan" || mode === "spec";
31
+ }
32
+ /** Synthetic user text that carries an OpenCode 1.x primary-agent switch. */
33
+ export function hostAgentSwitchPromptText(agent) {
34
+ return agent === "plan"
35
+ ? "Plan mode is now active. Continue planning the request above under the plan-mode " +
36
+ "instructions, and do not implement anything until the plan is approved."
37
+ : "Plan mode has ended. Continue with the approved work.";
38
+ }
39
+ /**
40
+ * OpenCode 1.x shape of the switch: a 1.x session runs each turn under the
41
+ * agent of its latest user message, which is how the host's own plan_enter /
42
+ * plan_exit change agents.
43
+ *
44
+ * It accepts only a real transition of a session running one of the host's
45
+ * own primary agents: entering an existing `plan` agent from another primary
46
+ * agent, or leaving the observed `plan` agent. Subagent and hidden internal
47
+ * sessions (title, summary, compaction, task children) never get a user turn
48
+ * injected.
49
+ */
50
+ export function createPromptHostAgentModeSwitch(prompt, primaryAgents) {
51
+ return {
52
+ accepts: ({ targetModeID, hostAgent }) => {
53
+ const agents = primaryAgents();
54
+ const current = hostAgent?.trim();
55
+ if (!agents || !current || !agents.has(current))
56
+ return false;
57
+ return isPlanModeID(targetModeID) ? current !== "plan" && agents.has("plan") : current === "plan";
58
+ },
59
+ apply: async ({ sessionID, targetModeID }) => {
60
+ const agent = isPlanModeID(targetModeID) ? "plan" : "build";
61
+ await prompt({ sessionID, agent, text: hostAgentSwitchPromptText(agent) });
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Install this host's native switch; it is used until a newer one is
67
+ * installed. The returned disposer removes only this switch, so disposing an
68
+ * older setup never removes a newer setup's switch. `undefined` removes all.
69
+ */
70
+ export function setHostAgentModeSwitch(fn, options = {}) {
71
+ if (!fn) {
72
+ registrations.length = 0;
17
73
  pending.clear();
74
+ return () => { };
75
+ }
76
+ const registration = { fn, options };
77
+ registrations.push(registration);
78
+ return () => {
79
+ const index = registrations.lastIndexOf(registration);
80
+ if (index >= 0)
81
+ registrations.splice(index, 1);
82
+ if (registrations.length === 0)
83
+ pending.clear();
84
+ };
18
85
  }
19
- /** Queue only when this host installed a native primary-agent switch. */
86
+ /**
87
+ * How this host applies a Cursor mode switch to its primary agents: `resumes`
88
+ * when the switch also starts the next host turn, `next-turn` when the user's
89
+ * next message runs under the new agent, undefined without a native switch.
90
+ */
91
+ export function hostAgentModeSwitchKind() {
92
+ const active = activeSwitch();
93
+ if (!active)
94
+ return undefined;
95
+ return active.options.resumesTurn === true ? "resumes" : "next-turn";
96
+ }
97
+ /** Queue only when this host installed a native primary-agent switch that accepts the request. */
20
98
  export function queueHostAgentModeSwitch(input) {
21
99
  const sessionID = input.sessionID.trim();
22
100
  const targetModeID = input.targetModeID.trim();
23
- if (!switchHostAgent || !sessionID || !targetModeID)
101
+ const active = activeSwitch();
102
+ if (!active || !sessionID || !targetModeID)
24
103
  return false;
104
+ if (active.options.accepts && !active.options.accepts({ ...input, sessionID, targetModeID }))
105
+ return false;
106
+ const hostAgent = input.hostAgent?.trim();
25
107
  pending.set(sessionID, {
26
108
  sessionID,
27
109
  targetModeID,
28
110
  ...(input.cursorSessionID ? { cursorSessionID: input.cursorSessionID } : {}),
111
+ ...(hostAgent ? { hostAgent } : {}),
29
112
  attempts: 0,
30
113
  });
31
114
  while (pending.size > MAX_PENDING_HOST_AGENT_SWITCHES) {
@@ -38,6 +121,17 @@ export function queueHostAgentModeSwitch(input) {
38
121
  `cursorSessionID=${input.cursorSessionID ?? ""} target=${targetModeID}`);
39
122
  return true;
40
123
  }
124
+ /**
125
+ * True while an approved switch into the host `plan` agent waits for its Run to
126
+ * end and will itself start the plan-agent turn that continues the work.
127
+ */
128
+ export function isHostPlanEntryPending(sessionID) {
129
+ if (activeSwitch()?.options.resumesTurn !== true)
130
+ return false;
131
+ const key = sessionID?.trim();
132
+ const target = key ? pending.get(key)?.targetModeID : undefined;
133
+ return target !== undefined && isPlanModeID(target);
134
+ }
41
135
  export function cancelHostAgentModeSwitch(sessionID) {
42
136
  const key = sessionID?.trim();
43
137
  if (key)
@@ -53,7 +147,8 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
53
147
  if (!key)
54
148
  return false;
55
149
  const state = pending.get(key);
56
- if (!state || !switchHostAgent)
150
+ const apply = activeSwitch()?.fn;
151
+ if (!state || !apply || applying.has(key))
57
152
  return false;
58
153
  if (options.terminal !== true || options.pumpActive || (options.pendingExecs ?? 0) > 0) {
59
154
  return false;
@@ -70,9 +165,12 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
70
165
  return false;
71
166
  }
72
167
  state.attempts += 1;
168
+ applying.set(key, state);
73
169
  try {
74
- await switchHostAgent(state);
75
- pending.delete(key);
170
+ await apply(state);
171
+ // A request queued during the callback belongs to a later transition.
172
+ if (pending.get(key) === state)
173
+ pending.delete(key);
76
174
  trace(`host-agent-mode: switched sessionID=${key} target=${state.targetModeID}`);
77
175
  return true;
78
176
  }
@@ -80,13 +178,19 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
80
178
  // Keep the request pending so a later explicit provider turn can retry at
81
179
  // its own terminal boundary. The provider-owned mode reminder remains the
82
180
  // behavioral fallback until the native host switch succeeds.
83
- delete state.cursorSessionID;
181
+ if (pending.get(key) === state)
182
+ delete state.cursorSessionID;
84
183
  trace(`host-agent-mode: FAILED sessionID=${key} target=${state.targetModeID} ` +
85
- `attempts=${state.attempts} err=${error instanceof Error ? error.message : String(error)}`);
184
+ `attempts=${state.attempts} err=${errorMessage(error)}`);
86
185
  return false;
87
186
  }
187
+ finally {
188
+ if (applying.get(key) === state)
189
+ applying.delete(key);
190
+ }
88
191
  }
89
192
  export function resetHostAgentModeSwitchForTests() {
90
- switchHostAgent = undefined;
193
+ registrations.length = 0;
91
194
  pending.clear();
195
+ applying.clear();
92
196
  }
@@ -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;