cursor-opencode-provider 0.3.2 → 0.4.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.
@@ -6,7 +6,7 @@ import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
8
  import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
@@ -24,6 +24,8 @@ import { resolveAgentUrl } from "./agent-url.js";
24
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
25
25
  import { isCompactionSession } from "./compaction-marker.js";
26
26
  import { getSessionDirectory } from "./session-directory.js";
27
+ import { assertCursorUserImageSupport, extractCursorPromptImages } from "./image-input.js";
28
+ import { resolveCursorModelSupportsImages } from "./model-metadata.js";
27
29
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
28
30
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
29
31
  import { readAllFieldsStrict } from "./protocol/struct.js";
@@ -36,12 +38,14 @@ let _availableModelsMtimeMs = -1;
36
38
  // session so the new Cursor conversation is still born with tool definitions;
37
39
  // execution remains disabled for the summary turn itself.
38
40
  const toolCatalogBySession = new Map();
41
+ const sentHistoryImageHashesBySession = new Map();
39
42
  // A compaction Run uses its own summary-agent system prompt. Its opaque Cursor
40
43
  // checkpoint must never become the base for the resumed normal agent: doing so
41
44
  // suppresses OpenCode's compacted prompt/system seed and makes Cursor narrate
42
45
  // tool use instead of emitting exec requests. Rebase once on the next turn.
43
46
  const postCompactionRebaseBySession = new Set();
44
47
  export const MAX_TURN_STATE_SESSIONS = 256;
48
+ const MAX_SENT_HISTORY_IMAGES_PER_SESSION = 256;
45
49
  const DEFAULT_RETRY_POLICY = {
46
50
  maxAttempts: 3,
47
51
  baseDelayMs: 500,
@@ -251,6 +255,39 @@ function rememberPostCompactionRebase(sessionKey) {
251
255
  postCompactionRebaseBySession.delete(oldest);
252
256
  }
253
257
  }
258
+ function sentHistoryImageHashes(sessionKey) {
259
+ if (!sessionKey)
260
+ return undefined;
261
+ const hashes = sentHistoryImageHashesBySession.get(sessionKey);
262
+ if (!hashes)
263
+ return undefined;
264
+ sentHistoryImageHashesBySession.delete(sessionKey);
265
+ sentHistoryImageHashesBySession.set(sessionKey, hashes);
266
+ return hashes;
267
+ }
268
+ function rememberSentHistoryImageHashes(sessionKey, hashes) {
269
+ if (!sessionKey || hashes.length === 0)
270
+ return;
271
+ const remembered = sentHistoryImageHashesBySession.get(sessionKey) ?? new Set();
272
+ for (const hash of hashes) {
273
+ remembered.delete(hash);
274
+ remembered.add(hash);
275
+ while (remembered.size > MAX_SENT_HISTORY_IMAGES_PER_SESSION) {
276
+ const oldest = remembered.values().next().value;
277
+ if (!oldest)
278
+ break;
279
+ remembered.delete(oldest);
280
+ }
281
+ }
282
+ sentHistoryImageHashesBySession.delete(sessionKey);
283
+ sentHistoryImageHashesBySession.set(sessionKey, remembered);
284
+ while (sentHistoryImageHashesBySession.size > MAX_TURN_STATE_SESSIONS) {
285
+ const oldest = sentHistoryImageHashesBySession.keys().next().value;
286
+ if (!oldest)
287
+ break;
288
+ sentHistoryImageHashesBySession.delete(oldest);
289
+ }
290
+ }
254
291
  export function createCursorLanguageModel(modelId, providerId, options) {
255
292
  // Host Path.cache / explicit override wins over XDG heuristics for this process.
256
293
  if (options.cacheDir)
@@ -427,13 +464,19 @@ export async function pumpWithRecovery(input) {
427
464
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
428
465
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
429
466
  await sleepForRetry(delayMs, input.abortSignal);
430
- session = await input.recover(checkpoint
467
+ const recovery = checkpoint
431
468
  ? {
432
469
  kind: "resume",
433
470
  conversationId: pumpedSession.conversationId,
434
471
  checkpoint: Uint8Array.from(checkpoint),
435
472
  }
436
- : { kind: "rebase" });
473
+ : { kind: "rebase" };
474
+ session = await input.recover(recovery);
475
+ if (recovery.kind === "resume") {
476
+ session.hadToolCallBoundary = pumpedSession.hadToolCallBoundary;
477
+ session.usageEstimate = { ...pumpedSession.usageEstimate };
478
+ session.editToolCalls = new Map(pumpedSession.editToolCalls);
479
+ }
437
480
  input.onSession?.(session);
438
481
  }
439
482
  finally {
@@ -484,9 +527,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
484
527
  `sessionKey=${sessionKey ?? "(none)"} ` +
485
528
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
486
529
  }
530
+ const lastUser = [...prompt].reverse().find((message) => message.role === "user");
487
531
  const userText = recovery?.kind === "rebase"
488
532
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
489
- : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
533
+ : (extractUserText(lastUser) || ".");
490
534
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
491
535
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
492
536
  // projects, so its static `options.workspaceRoot` is only a last-resort
@@ -521,6 +565,30 @@ async function startSession(modelId, token, callOptions, options, startOptions)
521
565
  : undefined;
522
566
  const hintMaxMode = !!(providerOptions?.maxMode ?? false);
523
567
  const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
568
+ const supportsImages = resolveCursorModelSupportsImages(cursorModelId, modelInfo?.supportsImages);
569
+ if (!resuming) {
570
+ assertCursorUserImageSupport(lastUser, supportsImages, cursorModelId);
571
+ }
572
+ const imageExtraction = resuming
573
+ ? { images: [], hashes: [], candidateCount: 0, duplicateCount: 0, userImageCount: 0 }
574
+ : await extractCursorPromptImages(prompt, lastUser, {
575
+ supportsImages,
576
+ // Content hashes are retained for the OpenCode session so growing
577
+ // history does not re-upload old screenshots. A recovery rebase opens
578
+ // a new Cursor conversation, so it must resend the same payload.
579
+ seenHistoryHashes: recovery?.kind === "rebase"
580
+ ? undefined
581
+ : sentHistoryImageHashes(sessionKey),
582
+ signal: callOptions.abortSignal,
583
+ });
584
+ if (!supportsImages && imageExtraction.candidateCount > 0) {
585
+ trace(`image input: dropped ${imageExtraction.candidateCount} tool/assistant history image(s); ` +
586
+ `model=${cursorModelId} does not support images`);
587
+ }
588
+ if (imageExtraction.duplicateCount > 0) {
589
+ trace(`image input: skipped ${imageExtraction.duplicateCount} previously sent history image(s)`);
590
+ }
591
+ const images = imageExtraction.images;
524
592
  const parameterValues = resolveVariantParameters(modelInfo, {
525
593
  reasoningEffort,
526
594
  maxMode: hintMaxMode,
@@ -566,6 +634,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
566
634
  : (bound.reset ? undefined : getCheckpoint(conversationId));
567
635
  const reqBytes = buildRunRequest({
568
636
  text: userText,
637
+ images,
569
638
  modelId: cursorModelId,
570
639
  conversationId,
571
640
  systemPrompt: conversationState ? undefined : systemPrompt,
@@ -601,6 +670,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
601
670
  `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
602
671
  `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
603
672
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
673
+ `images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
604
674
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
605
675
  `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
606
676
  `resume=${resuming} ` +
@@ -614,6 +684,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
614
684
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
615
685
  try {
616
686
  await writeWithBackpressure(stream, reqBytes, "initial Run request");
687
+ rememberSentHistoryImageHashes(sessionKey, imageExtraction.hashes);
617
688
  }
618
689
  catch (error) {
619
690
  stream.destroy();
@@ -628,6 +699,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
628
699
  frames: stream.frames()[Symbol.asyncIterator](),
629
700
  pending: new Map(),
630
701
  displayToolCalls: new Map(),
702
+ editToolCalls: new Map(),
631
703
  nextBridgedExecId: 900_000,
632
704
  blobs: new Map(),
633
705
  toolDescriptors,
@@ -1080,8 +1152,12 @@ export async function pump(session, controller, ids, abortSignal) {
1080
1152
  };
1081
1153
  const emitFinish = (te, reason) => {
1082
1154
  closeOpenSpans();
1083
- if (te) {
1084
- // Authoritative TurnEnded counts — replace the running estimate.
1155
+ // TurnEnded is request-local only when this Run did not previously stop at
1156
+ // a tool boundary. On a held multi-step Run Cursor aggregates every model
1157
+ // invocation into these counters; treating that billed total as the final
1158
+ // request's prompt size makes OpenCode compact after ordinary tasks.
1159
+ const cumulativeTurnEnded = !!te && session.hadToolCallBoundary === true;
1160
+ if (te && !cumulativeTurnEnded) {
1085
1161
  session.usageEstimate = {
1086
1162
  inputTokens: turnEndedCounter(te, "input_tokens"),
1087
1163
  outputTokens: turnEndedCounter(te, "output_tokens"),
@@ -1091,9 +1167,11 @@ export async function pump(session, controller, ids, abortSignal) {
1091
1167
  };
1092
1168
  }
1093
1169
  const est = session.usageEstimate;
1094
- const usage = te
1170
+ const usage = te && !cumulativeTurnEnded
1095
1171
  ? buildLanguageModelV3UsageFromTurnEnded(te)
1096
1172
  : buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
1173
+ if (!te)
1174
+ session.hadToolCallBoundary = true;
1097
1175
  const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
1098
1176
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1099
1177
  ? String(reason.unified ?? "unknown")
@@ -1104,9 +1182,11 @@ export async function pump(session, controller, ids, abortSignal) {
1104
1182
  `v3In=${inTotal} v3Out=${outTotal} ` +
1105
1183
  `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
1106
1184
  `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
1107
- `rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
1108
- `rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
1109
- `source=${te ? "turn_ended" : "estimate"}`);
1185
+ `rawIn=${te ? turnEndedCounter(te, "input_tokens") : est.inputTokens} ` +
1186
+ `rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
1187
+ `rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
1188
+ `rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
1189
+ `source=${te ? (cumulativeTurnEnded ? "estimate-cumulative-turn-ended" : "turn_ended") : "estimate"}`);
1110
1190
  safeEnqueue({
1111
1191
  type: "finish",
1112
1192
  usage,
@@ -1262,6 +1342,15 @@ export async function pump(session, controller, ids, abortSignal) {
1262
1342
  if (callId && toolCall) {
1263
1343
  session.displayToolCalls.set(callId, toolCall);
1264
1344
  const variant = Object.keys(toolCall).find((k) => k.endsWith("_tool_call")) ?? "?";
1345
+ const display = parseDisplayToolCall(callId, toolCall);
1346
+ const editPath = display?.variant === "edit_tool_call"
1347
+ && typeof display.args.path === "string"
1348
+ ? display.args.path
1349
+ : undefined;
1350
+ if (editPath) {
1351
+ const editToolCalls = session.editToolCalls ?? (session.editToolCalls = new Map());
1352
+ editToolCalls.set(callId, { path: editPath });
1353
+ }
1265
1354
  const callIdLog = callId.replace(/\r?\n/g, "\\n");
1266
1355
  let wireFields = "";
1267
1356
  if (variant === "?") {
@@ -1276,6 +1365,8 @@ export async function pump(session, controller, ids, abortSignal) {
1276
1365
  else if (iu?.tool_call_completed) {
1277
1366
  const completed = iu.tool_call_completed;
1278
1367
  const callId = typeof completed.call_id === "string" ? completed.call_id : "";
1368
+ if (callId)
1369
+ session.editToolCalls?.delete(callId);
1279
1370
  // If exec already claimed this call_id, display map entry is gone — skip.
1280
1371
  if (!callId || !session.displayToolCalls.has(callId)) {
1281
1372
  if (callId) {
@@ -1371,6 +1462,7 @@ export async function pump(session, controller, ids, abortSignal) {
1371
1462
  }
1372
1463
  else {
1373
1464
  replaySafety.markBarrier("non-control-exec");
1465
+ const displayCallId = extractExecDisplayCallId(esm);
1374
1466
  const parsed = parseExecServerMessage(esm);
1375
1467
  if (parsed) {
1376
1468
  const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
@@ -1379,12 +1471,24 @@ export async function pump(session, controller, ids, abortSignal) {
1379
1471
  parsed.toolName = executableToolName;
1380
1472
  }
1381
1473
  remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
1474
+ const editCall = displayCallId
1475
+ ? session.editToolCalls?.get(displayCallId)
1476
+ : undefined;
1477
+ if (editCall) {
1478
+ const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
1479
+ if (parsed.resultField === "write_result" && displayCallId) {
1480
+ session.editToolCalls?.delete(displayCallId);
1481
+ }
1482
+ if (remapped) {
1483
+ trace(`exec: preserved edit intent callId=${displayCallId} ` +
1484
+ `path=${JSON.stringify(editCall.path)}`);
1485
+ }
1486
+ }
1382
1487
  // Hosts that advertise `apply_patch` in place of `edit`/`write` (see
1383
1488
  // protocol/apply-patch.ts) still receive Cursor's native write/edit
1384
1489
  // exec requests. Translate before the unavailable-tool check below.
1385
1490
  remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
1386
1491
  }
1387
- const displayCallId = extractExecDisplayCallId(esm);
1388
1492
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
1389
1493
  if (parsed) {
1390
1494
  if (parsed.localError) {
@@ -1604,6 +1708,8 @@ function toolResultOutputToText(output) {
1604
1708
  return { text: JSON.stringify(o.value ?? null), isError };
1605
1709
  }
1606
1710
  if (o.type === "content" && Array.isArray(o.value)) {
1711
+ // Held-open continuation exec frames are text-only. Media is intentionally
1712
+ // omitted here and harvested from the full prompt when a fresh Run opens.
1607
1713
  const text = o.value
1608
1714
  .map((c) => {
1609
1715
  const cp = c;
@@ -1703,7 +1809,7 @@ export function estimatePromptTokens(prompt) {
1703
1809
  .join("\n");
1704
1810
  return estimateTokens(serializedContent.length);
1705
1811
  }
1706
- /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
1812
+ /** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
1707
1813
  export function cursorTurnEndedProviderMetadata(te) {
1708
1814
  return {
1709
1815
  cursor: {
@@ -1,5 +1,6 @@
1
1
  import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorContextLimit, } from "./models.js";
2
2
  import { applyCursorModelCost } from "./pricing.js";
3
+ import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages, } from "./model-metadata.js";
3
4
  /**
4
5
  * Cursor `ModelInfo` → OpenCode model-config mapping.
5
6
  *
@@ -147,19 +148,26 @@ export function modelInfoToConfig(mi, options = {}) {
147
148
  // OpenCode's context limit is static per model entry, while Cursor's context
148
149
  // tier is a variant parameter. Long-context choices are therefore emitted as
149
150
  // separate OpenCode entries by modelsToConfig.
151
+ const documentedContext = getDocumentedCursorModelContext(mi.id);
150
152
  const context = contextTier === "long"
151
- ? (mi.maxContextForMaxMode ?? 1_000_000)
152
- : (mi.maxContext ?? 200_000);
153
+ ? (mi.maxContextForMaxMode ?? documentedContext?.maxContextForMaxMode ?? 1_000_000)
154
+ : (mi.maxContext ?? documentedContext?.maxContext ?? 200_000);
153
155
  // OpenCode's overflow/compaction/UI use limit.context; generation and
154
156
  // thinking budgets use limit.output. models.dev 1M peers advertise
155
157
  // 64k–128k output — a tiny cap makes long-context sessions feel broken
156
158
  // even when the 1M input window is correct.
157
159
  const output = contextTier === "long" ? 128_000 : 32_000;
160
+ const supportsImages = resolveCursorModelSupportsImages(mi.id, mi.supportsImages);
158
161
  const config = {
159
162
  name,
163
+ attachment: supportsImages,
160
164
  reasoning: mi.supportsThinking ?? false,
161
165
  tool_call: mi.supportsAgent ?? true,
162
166
  temperature: false,
167
+ modalities: {
168
+ input: supportsImages ? ["text", "image"] : ["text"],
169
+ output: ["text"],
170
+ },
163
171
  limit: {
164
172
  context,
165
173
  output,
@@ -0,0 +1,10 @@
1
+ export type CursorModelContext = {
2
+ maxContext?: number;
3
+ maxContextForMaxMode?: number;
4
+ };
5
+ export type CursorModelCapabilities = {
6
+ supportsImages: boolean;
7
+ };
8
+ export declare function getDocumentedCursorModelContext(modelId: string): CursorModelContext | undefined;
9
+ export declare function getDocumentedCursorModelCapabilities(modelId: string): CursorModelCapabilities | undefined;
10
+ export declare function resolveCursorModelSupportsImages(modelId: string, availableModelsValue?: boolean): boolean;
@@ -0,0 +1,12 @@
1
+ import { CURSOR_MODEL_CAPABILITIES, CURSOR_MODEL_CONTEXTS } from "./pricing-data.js";
2
+ export function getDocumentedCursorModelContext(modelId) {
3
+ const context = CURSOR_MODEL_CONTEXTS[modelId];
4
+ return context ? { ...context } : undefined;
5
+ }
6
+ export function getDocumentedCursorModelCapabilities(modelId) {
7
+ const capabilities = CURSOR_MODEL_CAPABILITIES[modelId];
8
+ return capabilities ? { ...capabilities } : undefined;
9
+ }
10
+ export function resolveCursorModelSupportsImages(modelId, availableModelsValue) {
11
+ return availableModelsValue ?? getDocumentedCursorModelCapabilities(modelId)?.supportsImages ?? false;
12
+ }
package/dist/models.d.ts CHANGED
@@ -19,6 +19,7 @@ export type ModelInfo = {
19
19
  family?: string;
20
20
  supportsThinking?: boolean;
21
21
  supportsAgent?: boolean;
22
+ supportsImages?: boolean;
22
23
  maxContext?: number;
23
24
  /** Context window when max-mode is on (proto field 16). */
24
25
  maxContextForMaxMode?: number;
package/dist/models.js CHANGED
@@ -109,6 +109,7 @@ function normalizeModelInfo(value) {
109
109
  const family = optionalString(value, ["family"]);
110
110
  const supportsThinking = optionalBoolean(value, ["supportsThinking", "supports_thinking"]);
111
111
  const supportsAgent = optionalBoolean(value, ["supportsAgent", "supports_agent"]);
112
+ const supportsImages = optionalBoolean(value, ["supportsImages", "supports_images"]);
112
113
  const supportsMaxMode = optionalBoolean(value, ["supportsMaxMode", "supports_max_mode"]);
113
114
  const maxContext = optionalPositiveNumber(value, ["maxContext", "contextTokenLimit", "context_token_limit"]);
114
115
  const maxContextForMaxMode = optionalPositiveNumber(value, ["maxContextForMaxMode", "contextTokenLimitForMaxMode", "context_token_limit_for_max_mode"]);
@@ -116,6 +117,7 @@ function normalizeModelInfo(value) {
116
117
  family === null ||
117
118
  supportsThinking === null ||
118
119
  supportsAgent === null ||
120
+ supportsImages === null ||
119
121
  supportsMaxMode === null ||
120
122
  maxContext === null ||
121
123
  maxContextForMaxMode === null) {
@@ -127,6 +129,7 @@ function normalizeModelInfo(value) {
127
129
  ...(family === undefined ? {} : { family }),
128
130
  ...(supportsThinking === undefined ? {} : { supportsThinking }),
129
131
  ...(supportsAgent === undefined ? {} : { supportsAgent }),
132
+ ...(supportsImages === undefined ? {} : { supportsImages }),
130
133
  ...(maxContext === undefined ? {} : { maxContext }),
131
134
  ...(maxContextForMaxMode === undefined ? {} : { maxContextForMaxMode }),
132
135
  ...(supportsMaxMode === undefined ? {} : { supportsMaxMode }),
@@ -293,11 +296,16 @@ export function mapAvailableModelsResponse(raw) {
293
296
  variantContextTokens(variants.find((v) => variantContextTokens(v) !== 1_000_000)) ??
294
297
  variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
295
298
  const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
299
+ const supportsImages = optionalBoolean(e, ["supportsImages", "supports_images"]);
300
+ if (supportsImages === null) {
301
+ throw new Error(`AvailableModels returned invalid supportsImages for ${name}`);
302
+ }
296
303
  models.push({
297
304
  id: name,
298
305
  displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
299
306
  supportsThinking: apiBoolean(e, ["supportsThinking", "supports_thinking"]),
300
307
  supportsAgent: apiBoolean(e, ["supportsAgent", "supports_agent"]),
308
+ ...(supportsImages === undefined ? {} : { supportsImages }),
301
309
  maxContext: variantBaseContext ?? positiveNumber(e.contextTokenLimit ?? e.context_token_limit),
302
310
  maxContextForMaxMode: variantMaxContext ??
303
311
  positiveNumber(e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode),
@@ -58,12 +58,16 @@ function applyModelEntry(draft, id, entry) {
58
58
  model.modelID = wireId;
59
59
  model.providerID = CURSOR_PROVIDER_ID;
60
60
  model.name = entry.name;
61
+ const inputModalities = Array.isArray(entry.modalities?.input)
62
+ ? entry.modalities.input.filter((modality) => typeof modality === "string")
63
+ : ["text"];
64
+ const outputModalities = Array.isArray(entry.modalities?.output)
65
+ ? entry.modalities.output.filter((modality) => typeof modality === "string")
66
+ : ["text"];
61
67
  model.capabilities = {
62
68
  tools: entry.tool_call !== false,
63
- // The provider does not convert file/image prompt parts, so text-only is
64
- // the honest declaration. Revisit alongside Cursor's `supports_images`.
65
- input: ["text"],
66
- output: ["text"],
69
+ input: inputModalities,
70
+ output: outputModalities,
67
71
  };
68
72
  model.limit = { context: entry.limit.context, output: entry.limit.output };
69
73
  model.variants = variants;
@@ -9,7 +9,7 @@
9
9
  * we touch is more stable than pinning a moving target.
10
10
  *
11
11
  * These mirror `@opencode-ai/plugin@next` `dist/promise/*.d.ts` as of
12
- * `0.0.0-next-16281`. `test/opencode2-conformance.test-d.ts` asserts our
12
+ * `0.0.0-next-17155`. `test/opencode2-conformance.types.ts` asserts our
13
13
  * default export still satisfies the real 2.0 `Plugin` interface.
14
14
  *
15
15
  * Effect `Schema` brands (Provider.ID, Model.ID, …) are modelled as plain
@@ -228,13 +228,19 @@ export type ToolDefinition = {
228
228
  export type ToolDraft = {
229
229
  add(tool: ToolDefinition): void;
230
230
  };
231
- export type ToolHookBase = {
231
+ export type ToolHookBaseFields = {
232
232
  readonly tool: string;
233
233
  readonly sessionID: string;
234
234
  readonly agent: string;
235
235
  readonly messageID: string;
236
- readonly callID: string;
237
236
  };
237
+ export type ToolHookBase = ToolHookBaseFields & ({
238
+ readonly id: string;
239
+ readonly callID?: string;
240
+ } | {
241
+ readonly id?: string;
242
+ readonly callID: string;
243
+ });
238
244
  export type ToolHooks = {
239
245
  readonly "execute.before": ToolHookBase & {
240
246
  input: unknown;
@@ -9,7 +9,7 @@
9
9
  * we touch is more stable than pinning a moving target.
10
10
  *
11
11
  * These mirror `@opencode-ai/plugin@next` `dist/promise/*.d.ts` as of
12
- * `0.0.0-next-16281`. `test/opencode2-conformance.test-d.ts` asserts our
12
+ * `0.0.0-next-17155`. `test/opencode2-conformance.types.ts` asserts our
13
13
  * default export still satisfies the real 2.0 `Plugin` interface.
14
14
  *
15
15
  * Effect `Schema` brands (Provider.ID, Model.ID, …) are modelled as plain
@@ -35,6 +35,12 @@ async function loadModels(cacheDir, accessToken) {
35
35
  // Preserve offline / stale-cache behavior rather than emptying the picker.
36
36
  return cached?.models ?? [];
37
37
  }
38
+ function toolExecutionID(event) {
39
+ const id = event.id ?? event.callID;
40
+ if (!id)
41
+ throw new Error("OpenCode 2.0 tool hook did not provide an execution id");
42
+ return id;
43
+ }
38
44
  const plugin = {
39
45
  id: "cursor.provider",
40
46
  setup: async (ctx) => {
@@ -142,29 +148,31 @@ const plugin = {
142
148
  await track(ctx.tool.hook("execute.before", (event) => {
143
149
  if (event.tool !== "bash")
144
150
  return;
151
+ const executionID = toolExecutionID(event);
145
152
  // No `shell.env` in 2.0 → always use the wrapper-file command form.
146
- prepareCursorShellArgs(event.callID, event.input, {
153
+ prepareCursorShellArgs(executionID, event.input, {
147
154
  preferWrapperCommand: true,
148
155
  });
149
156
  }));
150
157
  await track(ctx.tool.hook("execute.after", (event) => {
151
158
  if (event.tool !== "bash")
152
159
  return;
160
+ const executionID = toolExecutionID(event);
153
161
  try {
154
162
  if (event.status !== "completed")
155
163
  return;
156
164
  const result = event.result;
157
- result.title = cursorShellOriginalCommand(event.callID) ?? result.title;
158
- result.output = captureCursorShellResult(event.callID, result.output, result.metadata);
165
+ result.title = cursorShellOriginalCommand(executionID) ?? result.title;
166
+ result.output = captureCursorShellResult(executionID, result.output, result.metadata);
159
167
  if (result.metadata && typeof result.metadata === "object") {
160
168
  const metadata = result.metadata;
161
169
  if (typeof metadata.output === "string") {
162
- metadata.output = sanitizeRegisteredCursorShellOutput(event.callID, metadata.output);
170
+ metadata.output = sanitizeRegisteredCursorShellOutput(executionID, metadata.output);
163
171
  }
164
172
  }
165
173
  }
166
174
  finally {
167
- releaseCursorShellEnv(event.callID);
175
+ releaseCursorShellEnv(executionID);
168
176
  }
169
177
  }));
170
178
  // ── Compaction marker + session directory ───────────────────────────────