cursor-opencode-provider 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
package/dist/models.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { MODEL_CACHE_FILE, MODEL_CACHE_SCHEMA_VERSION, MODEL_CACHE_TTL_MS } from "./shared.js";
2
2
  import { unaryAvailableModels } from "./transport/connect.js";
3
- import { buildRequestedModelParams } from "./protocol/thinking.js";
4
3
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
5
4
  import path from "node:path";
6
5
  /** Dedicated OpenCode provider-option key for a selected Cursor variant. */
@@ -59,11 +58,15 @@ export function normalizeModelParameterValues(value) {
59
58
  if (!Array.isArray(value))
60
59
  return null;
61
60
  const parameters = [];
61
+ const ids = new Set();
62
62
  for (const parameter of value) {
63
63
  if (!isPlainRecord(parameter))
64
64
  return null;
65
65
  if (!isNonEmptyString(parameter.id) || typeof parameter.value !== "string")
66
66
  return null;
67
+ if (ids.has(parameter.id))
68
+ return null;
69
+ ids.add(parameter.id);
67
70
  parameters.push({ id: parameter.id, value: parameter.value });
68
71
  }
69
72
  return parameters;
@@ -78,10 +81,12 @@ function normalizeVariant(value, fallbackName) {
78
81
  const displayName = optionalString(value, ["displayName", "display_name"]);
79
82
  const isDefaultNonMax = optionalBoolean(value, ["isDefaultNonMax", "isDefaultNonMaxConfig", "is_default_non_max_config"]);
80
83
  const isDefaultMax = optionalBoolean(value, ["isDefaultMax", "isDefaultMaxConfig", "is_default_max_config"]);
84
+ const isMaxMode = optionalBoolean(value, ["isMaxMode", "is_max_mode"]);
81
85
  if (key === null ||
82
86
  displayName === null ||
83
87
  isDefaultNonMax === null ||
84
- isDefaultMax === null) {
88
+ isDefaultMax === null ||
89
+ isMaxMode === null) {
85
90
  return null;
86
91
  }
87
92
  if ((key !== undefined && !isNonEmptyString(key)) ||
@@ -94,6 +99,7 @@ function normalizeVariant(value, fallbackName) {
94
99
  parameterValues: parameters,
95
100
  isDefaultNonMax: isDefaultNonMax ?? false,
96
101
  isDefaultMax: isDefaultMax ?? false,
102
+ ...(isMaxMode === undefined ? {} : { isMaxMode }),
97
103
  };
98
104
  }
99
105
  function normalizeModelInfo(value) {
@@ -111,6 +117,7 @@ function normalizeModelInfo(value) {
111
117
  const supportsAgent = optionalBoolean(value, ["supportsAgent", "supports_agent"]);
112
118
  const supportsImages = optionalBoolean(value, ["supportsImages", "supports_images"]);
113
119
  const supportsMaxMode = optionalBoolean(value, ["supportsMaxMode", "supports_max_mode"]);
120
+ const supportsNonMaxMode = optionalBoolean(value, ["supportsNonMaxMode", "supports_non_max_mode"]);
114
121
  const maxContext = optionalPositiveNumber(value, ["maxContext", "contextTokenLimit", "context_token_limit"]);
115
122
  const maxContextForMaxMode = optionalPositiveNumber(value, ["maxContextForMaxMode", "contextTokenLimitForMaxMode", "context_token_limit_for_max_mode"]);
116
123
  if (displayName === null ||
@@ -119,6 +126,7 @@ function normalizeModelInfo(value) {
119
126
  supportsAgent === null ||
120
127
  supportsImages === null ||
121
128
  supportsMaxMode === null ||
129
+ supportsNonMaxMode === null ||
122
130
  maxContext === null ||
123
131
  maxContextForMaxMode === null) {
124
132
  return null;
@@ -133,6 +141,7 @@ function normalizeModelInfo(value) {
133
141
  ...(maxContext === undefined ? {} : { maxContext }),
134
142
  ...(maxContextForMaxMode === undefined ? {} : { maxContextForMaxMode }),
135
143
  ...(supportsMaxMode === undefined ? {} : { supportsMaxMode }),
144
+ ...(supportsNonMaxMode === undefined ? {} : { supportsNonMaxMode }),
136
145
  variants: variants,
137
146
  };
138
147
  }
@@ -176,17 +185,37 @@ export function resolveCursorWireModelId(providerOptions, fallback) {
176
185
  const value = providerOptions?.[CURSOR_WIRE_MODEL_ID_KEY];
177
186
  return typeof value === "string" && value.trim() ? value : fallback;
178
187
  }
179
- /** True when resolved params select the long-context (1m) tier. */
188
+ /**
189
+ * True when resolved params select the long-context (1m) tier. Fallback for
190
+ * variants without Cursor's `isMaxMode` marker.
191
+ */
180
192
  export function paramsImplyMaxMode(params) {
181
193
  return params.some((parameter) => parameter.id === "context" &&
182
194
  parseCursorContextLimit(parameter.value) === 1_000_000);
183
195
  }
196
+ function sameParameterTuple(a, b) {
197
+ const byId = new Map(b.map((parameter) => [parameter.id, parameter.value]));
198
+ return (a.length === b.length &&
199
+ new Set(a.map((parameter) => parameter.id)).size === a.length &&
200
+ byId.size === b.length &&
201
+ a.every((parameter) => byId.get(parameter.id) === parameter.value));
202
+ }
203
+ /** Cursor's per-variant max-mode marker, else the 1m-context fallback. */
204
+ function variantIsMaxMode(v) {
205
+ return v.isMaxMode ?? paramsImplyMaxMode(v.parameterValues);
206
+ }
184
207
  /**
185
- * Resolve the request-level max_mode bit. An explicit variant pick owns the
186
- * context tier; otherwise a standalone maxMode hint may select max mode.
208
+ * Resolve the request-level max_mode bit. Like Cursor CLI, a cataloged variant
209
+ * requires max mode when Cursor marks it `isMaxMode` (Grok 4.7's 500k tier as
210
+ * well as 1m tiers) or the model does not support non-max mode. An explicit
211
+ * variant pick owns the context tier; otherwise a standalone maxMode hint may
212
+ * select max mode.
187
213
  */
188
214
  export function resolveVariantMaxMode(params, opts = {}) {
189
- return paramsImplyMaxMode(params) || (opts.picked === undefined && opts.maxMode === true);
215
+ if (opts.model?.supportsNonMaxMode === false)
216
+ return true;
217
+ const marked = opts.model?.variants.find((v) => sameParameterTuple(v.parameterValues, params))?.isMaxMode;
218
+ return (marked ?? paramsImplyMaxMode(params)) || (opts.picked === undefined && opts.maxMode === true);
190
219
  }
191
220
  // Cursor encodes a model's context window as a variant parameter `id: "context"`
192
221
  // whose value is a tier string — the same 200k / 272k / 300k / 1m the IDE's
@@ -212,8 +241,28 @@ function variantContextTokens(v) {
212
241
  const raw = v?.parameterValues.find((p) => p.id === "context")?.value;
213
242
  return parseCursorContextLimit(raw);
214
243
  }
215
- function isLongContextVariant(v) {
216
- return variantContextTokens(v) === 1_000_000;
244
+ /** Same parameters as `target` except the ignored ids. */
245
+ function isTwinOf(v, target, ignored) {
246
+ return (v.parameterValues.length === target.parameterValues.length &&
247
+ sameParameterTuple(v.parameterValues.filter((p) => !ignored.includes(p.id)), target.parameterValues.filter((p) => !ignored.includes(p.id))));
248
+ }
249
+ /**
250
+ * Find Cursor's declared default within a catalog subset, retaining its effort:
251
+ * the declared variant, else its speed twin, else its twin in another context
252
+ * tier (a 500k entry when the declared max default is 1m).
253
+ */
254
+ export function findDefaultVariant(model, candidates, maxMode) {
255
+ const isDeclared = (v) => maxMode ? v.isDefaultMax : v.isDefaultNonMax;
256
+ const declared = model.variants.find(isDeclared);
257
+ return candidates.find(isDeclared) ??
258
+ (declared === undefined
259
+ ? undefined
260
+ : candidates.find((v) => isTwinOf(v, declared, ["fast"])) ??
261
+ candidates.find((v) => isTwinOf(v, declared, ["fast", "context"])));
262
+ }
263
+ /** Send an advertised tuple verbatim; never rewrite one of its values. */
264
+ function copyParameters(parameters) {
265
+ return parameters.map((parameter) => ({ ...parameter }));
217
266
  }
218
267
  function positiveNumber(value) {
219
268
  return typeof value === "number" && Number.isFinite(value) && value > 0
@@ -286,27 +335,38 @@ export function mapAvailableModelsResponse(raw) {
286
335
  if (!parameterValues) {
287
336
  throw new Error(`AvailableModels returned invalid parameter values for ${name}`);
288
337
  }
338
+ const isMaxMode = optionalBoolean(v, ["isMaxMode", "is_max_mode"]);
339
+ if (isMaxMode === null) {
340
+ throw new Error(`AvailableModels returned a non-boolean isMaxMode for ${name}`);
341
+ }
289
342
  variants.push({
290
343
  key: name,
291
344
  parameterValues,
292
345
  displayName: (v.displayName ?? v.display_name ?? name),
293
346
  isDefaultNonMax: apiBoolean(v, ["isDefaultNonMaxConfig", "is_default_non_max_config"]),
294
347
  isDefaultMax: apiBoolean(v, ["isDefaultMaxConfig", "is_default_max_config"]),
348
+ ...(isMaxMode === undefined ? {} : { isMaxMode }),
295
349
  });
296
350
  }
297
351
  // Derive the context window from the variant `context` param (primary
298
352
  // source). Base tier ← default non-max variant; max tier ← default max
299
353
  // variant. Fall back to the (often empty) proto fields, then undefined.
300
- // Prefer a non-1m context when isDefaultNonMax is missing so a leading
354
+ // Prefer a non-max context when isDefaultNonMax is missing so a leading
301
355
  // max-mode variant cannot inflate the base window.
302
356
  const variantBaseContext = variantContextTokens(variants.find((v) => v.isDefaultNonMax)) ??
303
- variantContextTokens(variants.find((v) => variantContextTokens(v) !== 1_000_000)) ??
357
+ variantContextTokens(variants.find((v) => !variantIsMaxMode(v) && variantContextTokens(v) !== undefined &&
358
+ variantContextTokens(v) !== 1_000_000)) ??
304
359
  variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
305
- const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
360
+ const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax)) ??
361
+ variantContextTokens(variants.find((v) => variantIsMaxMode(v) && variantContextTokens(v) !== undefined));
306
362
  const supportsImages = optionalBoolean(e, ["supportsImages", "supports_images"]);
307
363
  if (supportsImages === null) {
308
364
  throw new Error(`AvailableModels returned invalid supportsImages for ${name}`);
309
365
  }
366
+ const supportsNonMaxMode = optionalBoolean(e, ["supportsNonMaxMode", "supports_non_max_mode"]);
367
+ if (supportsNonMaxMode === null) {
368
+ throw new Error(`AvailableModels returned invalid supportsNonMaxMode for ${name}`);
369
+ }
310
370
  models.push({
311
371
  id: name,
312
372
  displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
@@ -317,6 +377,7 @@ export function mapAvailableModelsResponse(raw) {
317
377
  maxContextForMaxMode: variantMaxContext ??
318
378
  positiveNumber(e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode),
319
379
  supportsMaxMode: apiBoolean(e, ["supportsMaxMode", "supports_max_mode"]),
380
+ ...(supportsNonMaxMode === undefined ? {} : { supportsNonMaxMode }),
320
381
  variants,
321
382
  });
322
383
  }
@@ -344,51 +405,46 @@ export function resolveVariantParameters(model, opts = {}) {
344
405
  }
345
406
  return opts.reasoningEffort ? [{ id: "effort", value: opts.reasoningEffort }] : [];
346
407
  }
347
- const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning")?.value;
408
+ const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning" || p.id === "reasoning_effort")?.value;
348
409
  const isFast = (v) => v.parameterValues.find((p) => p.id === "fast")?.value === "true";
349
- const isMaxVariant = (v) => v.isDefaultMax || isLongContextVariant(v);
350
- const wantMax = opts.maxMode ?? false;
351
- // Non-fast pool for hint-based resolution only. Exact `picked` matching
352
- // searches all variants so Fast selections are not silently dropped.
353
- const pool = model.variants.filter((v) => !isFast(v));
354
- const scoped = pool.length > 0 ? pool : model.variants;
410
+ const wantMax = (opts.maxMode ?? false) || model.supportsNonMaxMode === false;
355
411
  // 1. Variant explicitly picked by opencode (verbatim — preserves context, fast, …).
356
412
  if (picked !== undefined) {
357
- const pickedById = new Map(picked.map((parameter) => [parameter.id, parameter.value]));
358
- const exact = model.variants.find((v) => v.parameterValues.length === picked.length &&
359
- pickedById.size === picked.length &&
360
- v.parameterValues.every((parameter) => pickedById.get(parameter.id) === parameter.value));
413
+ const exact = model.variants.find((v) => sameParameterTuple(v.parameterValues, picked));
361
414
  if (exact) {
362
- return buildRequestedModelParams(exact.parameterValues);
415
+ return copyParameters(exact.parameterValues);
363
416
  }
364
417
  throw new CursorVariantSelectionError(`is stale for model ${JSON.stringify(model.id)}: its exact parameter tuple is unavailable`);
365
418
  }
366
- // When maxMode is requested, prefer max-tier variants for effort matching
367
- // so "high + max" lands on 1m high rather than the first 300k high.
368
- const maxScoped = wantMax ? scoped.filter(isMaxVariant) : [];
369
- const effortPool = maxScoped.length > 0 ? maxScoped : scoped;
370
- // 2. Effort hint (e.g. user passed reasoningEffort through the CLI).
419
+ // Choose the tier before preferring non-Fast variants. Otherwise a Fast-only
420
+ // max tier disappears, or a leading max variant steals an effort-only hint.
421
+ // isDefaultMax is a preference, not a requirement to run in max mode.
422
+ const tier = model.variants.filter((v) => variantIsMaxMode(v) === wantMax);
423
+ const candidates = tier.length > 0 ? tier : model.variants;
424
+ const nonFast = candidates.filter((v) => !isFast(v));
425
+ const scoped = nonFast.length > 0 ? nonFast : candidates;
426
+ // Cursor can declare a Fast variant as a default (Grok, GPT max defaults);
427
+ // the tier default retains the declared default's effort when its speed
428
+ // twin is needed (same parameters except `fast`).
429
+ const byDefault = (wantMax
430
+ ? findDefaultVariant(model, scoped, true) ?? scoped.find(variantIsMaxMode)
431
+ : undefined) ?? findDefaultVariant(model, scoped, false) ?? scoped[0];
432
+ // 2. Effort hint (e.g. user passed reasoningEffort through the CLI). Like
433
+ // Cursor CLI, break ties toward the tier default so the hint changes only
434
+ // effort, not thinking or other parameters (Claude lists thinking=false first).
371
435
  if (opts.reasoningEffort) {
372
- const match = effortPool.find((v) => effortOf(v) === opts.reasoningEffort);
436
+ const shared = (v) => v.parameterValues.filter((parameter) => byDefault.parameterValues.some((d) => d.id === parameter.id && d.value === parameter.value)).length;
437
+ const match = scoped
438
+ .filter((v) => effortOf(v) === opts.reasoningEffort)
439
+ .reduce((best, v) => best && shared(best) >= shared(v) ? best : v, undefined);
373
440
  if (match) {
374
- return buildRequestedModelParams(match.parameterValues, {
375
- reasoningEffort: opts.reasoningEffort,
376
- maxMode: wantMax,
377
- });
441
+ return copyParameters(match.parameterValues);
378
442
  }
379
443
  }
380
- // 3. Max-mode hint → prefer the default max variant (1m context).
381
- if (wantMax) {
382
- const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find(isLongContextVariant);
383
- if (max)
384
- return buildRequestedModelParams(max.parameterValues, { maxMode: true });
385
- }
386
- // 4. Default non-max variant.
387
- const byDefault = scoped.find((v) => v.isDefaultNonMax) ?? scoped[0];
388
- return buildRequestedModelParams(byDefault.parameterValues, {
389
- reasoningEffort: opts.reasoningEffort,
390
- maxMode: false,
391
- });
444
+ // 3. Max-mode hint → the default max variant (the max tier).
445
+ // 4. Otherwise the default non-max variant, else the first candidate.
446
+ // An unsupported effort must not synthesize a tuple Cursor never advertised.
447
+ return copyParameters(byDefault.parameterValues);
392
448
  }
393
449
  // ── Fetch + cache orchestration ──
394
450
  export async function fetchModels(token, options = {}) {
@@ -36,6 +36,7 @@ export type CatalogModelInfo = {
36
36
  modelID: string;
37
37
  providerID: string;
38
38
  name: string;
39
+ family?: string;
39
40
  capabilities: {
40
41
  tools: boolean;
41
42
  input: string[];
@@ -69,6 +69,8 @@ export function modelConfigEntryToInfo(id, entry) {
69
69
  time: { released: 0 },
70
70
  cost: toOpenCode2Costs(entry.cost),
71
71
  };
72
+ if (typeof entry.family === "string" && entry.family.trim())
73
+ info.family = entry.family.trim();
72
74
  if (options)
73
75
  info.settings = { ...options };
74
76
  return info;
@@ -0,0 +1,8 @@
1
+ import type { PluginContext, ToolDraft } from "./types.js";
2
+ /**
3
+ * Register the handle-only image commit tool on the OpenCode 2.0 direct
4
+ * catalog. Catalog `permission: "edit"` hides it when the host agent denies
5
+ * edits. Per-call `ask` is used when the runtime context provides it;
6
+ * otherwise containment still gates the write (opaque `image_id` only).
7
+ */
8
+ export declare function registerCursorImageSaveTool(draft: ToolDraft, ctx: PluginContext): void;
@@ -0,0 +1,84 @@
1
+ import { executeCursorImageSave } from "../image-save.js";
2
+ import { cursorImageSaveTool } from "../image-save-tool.js";
3
+ import { CURSOR_IMAGE_SAVE_TOOL } from "../protocol/generate-image.js";
4
+ import { getSessionDirectory } from "../session-directory.js";
5
+ import { hostHasTool, OPENCODE2_DIRECT_TOOL_OPTIONS } from "./todo-tools.js";
6
+ const IMAGE_SAVE_INPUT_SCHEMA = {
7
+ type: "object",
8
+ additionalProperties: false,
9
+ properties: {
10
+ image_id: {
11
+ type: "string",
12
+ description: "Id of the pending Cursor-generated image to save",
13
+ },
14
+ },
15
+ required: ["image_id"],
16
+ };
17
+ const IMAGE_SAVE_OUTPUT_SCHEMA = {
18
+ type: "object",
19
+ additionalProperties: false,
20
+ properties: {
21
+ path: { type: "string" },
22
+ bytes: { type: "number" },
23
+ message: { type: "string" },
24
+ },
25
+ required: ["path", "bytes", "message"],
26
+ };
27
+ function toolAsk(context) {
28
+ if (!context || typeof context !== "object")
29
+ return undefined;
30
+ const ask = context.ask;
31
+ return typeof ask === "function"
32
+ ? ask
33
+ : undefined;
34
+ }
35
+ async function workspaceForSession(ctx, sessionID) {
36
+ const marked = getSessionDirectory(sessionID);
37
+ if (marked)
38
+ return marked;
39
+ try {
40
+ return (await ctx.session.get({ sessionID })).location.directory;
41
+ }
42
+ catch {
43
+ return ctx.location?.directory || process.cwd();
44
+ }
45
+ }
46
+ /**
47
+ * Register the handle-only image commit tool on the OpenCode 2.0 direct
48
+ * catalog. Catalog `permission: "edit"` hides it when the host agent denies
49
+ * edits. Per-call `ask` is used when the runtime context provides it;
50
+ * otherwise containment still gates the write (opaque `image_id` only).
51
+ */
52
+ export function registerCursorImageSaveTool(draft, ctx) {
53
+ if (hostHasTool(draft, CURSOR_IMAGE_SAVE_TOOL))
54
+ return;
55
+ draft.add({
56
+ name: CURSOR_IMAGE_SAVE_TOOL,
57
+ description: cursorImageSaveTool.description,
58
+ input: IMAGE_SAVE_INPUT_SCHEMA,
59
+ output: IMAGE_SAVE_OUTPUT_SCHEMA,
60
+ options: { ...OPENCODE2_DIRECT_TOOL_OPTIONS, permission: "edit" },
61
+ execute: async (input, context) => {
62
+ const sessionID = context.sessionID;
63
+ if (typeof sessionID !== "string" || !sessionID) {
64
+ throw new Error("OpenCode 2.0 image-save context did not provide a sessionID");
65
+ }
66
+ const directory = await workspaceForSession(ctx, sessionID);
67
+ const result = await executeCursorImageSave({ image_id: input?.image_id }, {
68
+ worktree: directory,
69
+ directory,
70
+ // The public 2.0 ToolContext has no permission prompt; catalog
71
+ // `permission: "edit"` and containment gate the write instead.
72
+ ask: toolAsk(context) ?? null,
73
+ });
74
+ return {
75
+ output: {
76
+ path: result.title,
77
+ bytes: result.bytes,
78
+ message: result.output,
79
+ },
80
+ content: result.output,
81
+ };
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Preserve native result status before OpenCode's AI SDK lowers errors to text.
3
+ * Upstream: https://github.com/anomalyco/opencode/blob/v2.0.27/packages/core/src/aisdk.ts#L652-L685
4
+ * Native part.providerMetadata becomes SDK part.providerOptions (same file, L717-L720).
5
+ */
6
+ export type ToolRefusal = "permission_denied" | "rejected";
7
+ export type NativeToolFailure = {
8
+ type: string;
9
+ text: string;
10
+ refusal?: ToolRefusal;
11
+ };
12
+ export declare const TOOL_RESULT_STATUS_KEY = "opencodeToolResult";
13
+ export declare function nativeToolFailure(value: unknown, refusal?: ToolRefusal): NativeToolFailure;
14
+ /** The native tool hook preserves the underlying typed permission cause. */
15
+ export declare function nativeToolRefusal(error: unknown): ToolRefusal | undefined;
16
+ /** Metadata travels with each part, including history and independently loaded SDK copies. */
17
+ export declare function preserveNativeToolResults(messages: readonly unknown[] | undefined, refusals?: ReadonlyMap<string, ToolRefusal>): any[];
18
+ export declare function toolResultFailure(options: unknown, callID: string): NativeToolFailure | undefined;
@@ -0,0 +1,66 @@
1
+ export const TOOL_RESULT_STATUS_KEY = "opencodeToolResult";
2
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
3
+ ? value : undefined;
4
+ export function nativeToolFailure(value, refusal) {
5
+ const envelope = record(value);
6
+ const error = record(envelope?.error);
7
+ const type = typeof error?.type === "string" && error.type ? error.type : "tool.execution";
8
+ const message = typeof error?.message === "string" && error.message
9
+ ? error.message : typeof value === "string" && value ? value : `OpenCode tool failed (${type})`;
10
+ const content = Array.isArray(envelope?.content) ? envelope.content.flatMap(item => {
11
+ const part = record(item);
12
+ return part?.type === "text" && typeof part.text === "string" && part.text ? [part.text] : [];
13
+ }).join("\n") : "";
14
+ return { type, text: content ? `${message}\n\n${content}` : message,
15
+ ...(refusal ? { refusal } : type === "permission.rejected" ? { refusal: "rejected" } : {}) };
16
+ }
17
+ /** The native tool hook preserves the underlying typed permission cause. */
18
+ export function nativeToolRefusal(error) {
19
+ const seen = new Set();
20
+ for (let depth = 0; depth < 16 && !seen.has(error); depth++) {
21
+ seen.add(error);
22
+ const cause = record(error);
23
+ if (!cause)
24
+ return undefined;
25
+ if (cause._tag === "Permission.BlockedError")
26
+ return "permission_denied";
27
+ if (cause._tag === "Permission.CorrectedError" || cause._tag === "Permission.DeclinedError")
28
+ return "rejected";
29
+ error = cause.error;
30
+ }
31
+ return undefined;
32
+ }
33
+ /** Metadata travels with each part, including history and independently loaded SDK copies. */
34
+ export function preserveNativeToolResults(messages, refusals = new Map()) {
35
+ if (!Array.isArray(messages))
36
+ return [];
37
+ return messages.map(message => {
38
+ const msg = record(message);
39
+ if (msg?.role !== "tool" || !Array.isArray(msg.content))
40
+ return message;
41
+ return { ...msg, content: msg.content.map(item => {
42
+ const part = record(item);
43
+ const result = record(part?.result);
44
+ if (part?.type !== "tool-result" || typeof part.id !== "string" || !result
45
+ || typeof result.type !== "string" || !["error", "text", "json", "content"].includes(result.type))
46
+ return item;
47
+ const metadata = record(part.providerMetadata);
48
+ const cursor = record(metadata?.cursor);
49
+ const status = result.type === "error"
50
+ ? { callID: part.id, status: "error", failure: nativeToolFailure(result.value, refusals.get(part.id)) }
51
+ : { callID: part.id, status: "completed" };
52
+ return { ...part, providerMetadata: { ...metadata, cursor: { ...cursor, [TOOL_RESULT_STATUS_KEY]: status } } };
53
+ }) };
54
+ });
55
+ }
56
+ export function toolResultFailure(options, callID) {
57
+ const status = record(record(record(options)?.cursor)?.[TOOL_RESULT_STATUS_KEY]);
58
+ if (status?.callID !== callID || status.status !== "error")
59
+ return undefined;
60
+ const failure = record(status.failure);
61
+ if (!failure || typeof failure.type !== "string" || typeof failure.text !== "string")
62
+ return undefined;
63
+ const refusal = failure.refusal === "permission_denied" || failure.refusal === "rejected" ? failure.refusal : undefined;
64
+ return { type: failure.type, text: failure.text || `OpenCode tool failed (${failure.type || "tool.execution"})`,
65
+ ...(refusal ? { refusal } : {}) };
66
+ }
@@ -43,6 +43,7 @@ export type ModelInfo2 = {
43
43
  modelID: string;
44
44
  providerID: string;
45
45
  name: string;
46
+ family?: string;
46
47
  capabilities: {
47
48
  tools: boolean;
48
49
  input: readonly string[];
@@ -415,6 +416,19 @@ export type McpEditor = {
415
416
  export type McpDomain = {
416
417
  readonly transform: Transform<McpEditor>;
417
418
  };
419
+ /** Only the fields this plugin reads from a registered skill. */
420
+ export type SkillInfo = {
421
+ readonly id: string;
422
+ readonly path: string;
423
+ };
424
+ export type SkillDomain = {
425
+ readonly list: () => Promise<{
426
+ readonly location: {
427
+ readonly directory: string;
428
+ };
429
+ readonly data: readonly SkillInfo[];
430
+ }>;
431
+ };
418
432
  export type PluginLocation = {
419
433
  readonly directory: string;
420
434
  };
@@ -429,6 +443,7 @@ export type PluginContext = {
429
443
  readonly shell?: ShellDomain;
430
444
  readonly websearch?: WebSearchDomain;
431
445
  readonly mcp?: McpDomain;
446
+ readonly skill?: SkillDomain;
432
447
  };
433
448
  export type Cleanup = () => Promise<void> | void;
434
449
  export type Plugin2 = {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Parallel tool-call step boundary for OpenCode's AI SDK step model.
3
+ *
4
+ * Cursor fires execs as they are generated; CLI demuxes them concurrently and
5
+ * has no step boundary. OpenCode needs one `finish: tool-calls` after every
6
+ * call of the generation. Cursor's `tool_requests_listed` (InteractionUpdate
7
+ * field 27) carries that count. See tasks/plans/parallel-tool-calls-cli-parity.md.
8
+ */
9
+ /** Idle without a progress frame while holding → close the step (ms). */
10
+ export declare const PARALLEL_STEP_IDLE_MS = 1500;
11
+ export type ParallelStepState = {
12
+ /** From field 27; undefined until Cursor lists the count for this step. */
13
+ listedCount?: number;
14
+ /** Model tool_call_ids seen this step. */
15
+ callIds: Set<string>;
16
+ /** Ids with a final client disposition this step. */
17
+ resolved: Set<string>;
18
+ /** Host tool-call parts emitted this step. */
19
+ emitted: number;
20
+ /** Wall time of the last progress frame (for the liveness guard). */
21
+ lastProgressAt: number;
22
+ };
23
+ export declare function noteToolRequestsListedSeen(): void;
24
+ export declare function hasSeenToolRequestsListed(): boolean;
25
+ /** Test-only: reset the process capability gate. */
26
+ export declare function resetToolRequestsListedSeenForTests(): void;
27
+ export declare function createParallelStep(now?: number): ParallelStepState;
28
+ export declare function noteParallelStepProgress(step: ParallelStepState, now?: number): void;
29
+ export declare function noteListedCallCount(step: ParallelStepState, callCount: number, now?: number): void;
30
+ /**
31
+ * Record a final disposition for `callId`. Returns true when newly resolved.
32
+ * Empty ids are ignored. Prior-step ids in `prior` are ignored.
33
+ */
34
+ export declare function resolveParallelStepCall(step: ParallelStepState, callId: string | undefined, prior?: ReadonlySet<string>, now?: number): boolean;
35
+ export declare function recordParallelStepEmission(step: ParallelStepState, now?: number): void;
36
+ /** True when the listed count is known and every listed call is resolved. */
37
+ export declare function parallelStepCountMet(step: ParallelStepState): boolean;
38
+ /**
39
+ * Whether to keep pumping for more calls of this step instead of finishing.
40
+ *
41
+ * - Gate off and no count yet → do not hold (today's one-call finish).
42
+ * - Human-gated interaction with unknown count → do not hold (prompt must show).
43
+ * - Otherwise hold until the count is met (or the liveness guard fires).
44
+ */
45
+ export declare function shouldHoldParallelStep(step: ParallelStepState, options?: {
46
+ fieldSeen?: boolean;
47
+ humanGatedWithoutCount?: boolean;
48
+ }): boolean;
49
+ /** True when the hold has made no progress for `idleMs`. */
50
+ export declare function shouldGuardCloseParallelStep(step: ParallelStepState, now?: number, idleMs?: number): boolean;
51
+ /**
52
+ * Frames that reset the liveness guard while holding. Heartbeat, KV, and
53
+ * checkpoint are not progress — they say nothing about further tool calls.
54
+ */
55
+ export declare function isParallelStepProgressFrame(kinds: {
56
+ toolRequestsListed?: boolean;
57
+ partialToolCall?: boolean;
58
+ toolCallDelta?: boolean;
59
+ toolCallStarted?: boolean;
60
+ toolCallCompleted?: boolean;
61
+ exec?: boolean;
62
+ interactionQuery?: boolean;
63
+ textDelta?: boolean;
64
+ thinkingDelta?: boolean;
65
+ }): boolean;