cursor-opencode-provider 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/models.d.ts CHANGED
@@ -31,6 +31,11 @@ export type ModelCache = {
31
31
  /** Absent or mismatched → treat cache as stale (forces AvailableModels refetch). */
32
32
  schemaVersion?: number;
33
33
  };
34
+ export declare class CursorVariantSelectionError extends Error {
35
+ constructor(message: string);
36
+ }
37
+ export declare function normalizeModelParameterValues(value: unknown): ModelParameterValue[] | null;
38
+ export declare function normalizeModelCache(value: unknown): ModelCache | null;
34
39
  /**
35
40
  * Read only the dedicated variant payload generated by the plugin. OpenCode
36
41
  * merges model, agent, and variant options into one providerOptions namespace;
@@ -41,6 +46,7 @@ export declare function extractCursorVariantParameters(providerOptions: Record<s
41
46
  export declare function resolveCursorWireModelId(providerOptions: Record<string, unknown> | undefined, fallback: string): string;
42
47
  /** True when resolved params select the long-context (1m) tier. */
43
48
  export declare function paramsImplyMaxMode(params: ModelParameterValue[]): boolean;
49
+ export declare function parseCursorContextLimit(value: unknown): number | undefined;
44
50
  export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
45
51
  export declare function cacheFilePath(cacheDir: string): string;
46
52
  export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
@@ -68,6 +74,7 @@ export declare function fetchModels(token: string, options?: {
68
74
  baseURL?: string;
69
75
  headers?: Record<string, string>;
70
76
  }): Promise<ModelInfo[]>;
77
+ export declare function refreshModelCache(cacheDir: string, fetcher: () => Promise<ModelInfo[]>): Promise<ModelInfo[]>;
71
78
  export declare function discoverModels(token: string, cacheDir: string, options?: {
72
79
  baseURL?: string;
73
80
  headers?: Record<string, string>;
package/dist/models.js CHANGED
@@ -1,13 +1,158 @@
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
3
  import { buildRequestedModelParams } from "./protocol/thinking.js";
4
- import { readFile, writeFile, mkdir } from "node:fs/promises";
5
- import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
6
5
  import path from "node:path";
7
6
  /** Dedicated OpenCode provider-option key for a selected Cursor variant. */
8
7
  export const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
9
8
  /** Real Cursor model id used when an OpenCode entry has a synthetic id. */
10
9
  export const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
10
+ export class CursorVariantSelectionError extends Error {
11
+ constructor(message) {
12
+ super(`Cursor variant selection ${message}`);
13
+ this.name = "CursorVariantSelectionError";
14
+ }
15
+ }
16
+ function isPlainRecord(value) {
17
+ if (value === null || typeof value !== "object" || Array.isArray(value))
18
+ return false;
19
+ const prototype = Object.getPrototypeOf(value);
20
+ return prototype === Object.prototype || prototype === null;
21
+ }
22
+ function isNonEmptyString(value) {
23
+ return typeof value === "string" && value.trim().length > 0;
24
+ }
25
+ function optionalString(record, names) {
26
+ for (const name of names) {
27
+ if (!Object.hasOwn(record, name))
28
+ continue;
29
+ if (record[name] === undefined)
30
+ return undefined;
31
+ return typeof record[name] === "string" ? record[name] : null;
32
+ }
33
+ return undefined;
34
+ }
35
+ function optionalBoolean(record, names) {
36
+ for (const name of names) {
37
+ if (!Object.hasOwn(record, name))
38
+ continue;
39
+ if (record[name] === undefined)
40
+ return undefined;
41
+ return typeof record[name] === "boolean" ? record[name] : null;
42
+ }
43
+ return undefined;
44
+ }
45
+ function optionalPositiveNumber(record, names) {
46
+ for (const name of names) {
47
+ if (!Object.hasOwn(record, name))
48
+ continue;
49
+ const value = record[name];
50
+ if (value === undefined)
51
+ return undefined;
52
+ return typeof value === "number" && Number.isFinite(value) && value > 0
53
+ ? value
54
+ : null;
55
+ }
56
+ return undefined;
57
+ }
58
+ export function normalizeModelParameterValues(value) {
59
+ if (!Array.isArray(value))
60
+ return null;
61
+ const parameters = [];
62
+ for (const parameter of value) {
63
+ if (!isPlainRecord(parameter))
64
+ return null;
65
+ if (!isNonEmptyString(parameter.id) || typeof parameter.value !== "string")
66
+ return null;
67
+ parameters.push({ id: parameter.id, value: parameter.value });
68
+ }
69
+ return parameters;
70
+ }
71
+ function normalizeVariant(value, fallbackName) {
72
+ if (!isPlainRecord(value))
73
+ return null;
74
+ const parameters = normalizeModelParameterValues(value.parameterValues ?? value.parameter_values ?? []);
75
+ if (!parameters)
76
+ return null;
77
+ const key = optionalString(value, ["key"]);
78
+ const displayName = optionalString(value, ["displayName", "display_name"]);
79
+ const isDefaultNonMax = optionalBoolean(value, ["isDefaultNonMax", "isDefaultNonMaxConfig", "is_default_non_max_config"]);
80
+ const isDefaultMax = optionalBoolean(value, ["isDefaultMax", "isDefaultMaxConfig", "is_default_max_config"]);
81
+ if (key === null ||
82
+ displayName === null ||
83
+ isDefaultNonMax === null ||
84
+ isDefaultMax === null) {
85
+ return null;
86
+ }
87
+ if ((key !== undefined && !isNonEmptyString(key)) ||
88
+ (displayName !== undefined && !isNonEmptyString(displayName))) {
89
+ return null;
90
+ }
91
+ return {
92
+ key: key ?? fallbackName,
93
+ displayName: displayName ?? fallbackName,
94
+ parameterValues: parameters,
95
+ isDefaultNonMax: isDefaultNonMax ?? false,
96
+ isDefaultMax: isDefaultMax ?? false,
97
+ };
98
+ }
99
+ function normalizeModelInfo(value) {
100
+ if (!isPlainRecord(value) || !isNonEmptyString(value.id))
101
+ return null;
102
+ const rawVariants = value.variants ?? [];
103
+ if (!Array.isArray(rawVariants))
104
+ return null;
105
+ const variants = rawVariants.map((variant) => normalizeVariant(variant, value.id));
106
+ if (variants.some((variant) => variant === null))
107
+ return null;
108
+ const displayName = optionalString(value, ["displayName", "display_name"]);
109
+ const family = optionalString(value, ["family"]);
110
+ const supportsThinking = optionalBoolean(value, ["supportsThinking", "supports_thinking"]);
111
+ const supportsAgent = optionalBoolean(value, ["supportsAgent", "supports_agent"]);
112
+ const supportsMaxMode = optionalBoolean(value, ["supportsMaxMode", "supports_max_mode"]);
113
+ const maxContext = optionalPositiveNumber(value, ["maxContext", "contextTokenLimit", "context_token_limit"]);
114
+ const maxContextForMaxMode = optionalPositiveNumber(value, ["maxContextForMaxMode", "contextTokenLimitForMaxMode", "context_token_limit_for_max_mode"]);
115
+ if (displayName === null ||
116
+ family === null ||
117
+ supportsThinking === null ||
118
+ supportsAgent === null ||
119
+ supportsMaxMode === null ||
120
+ maxContext === null ||
121
+ maxContextForMaxMode === null) {
122
+ return null;
123
+ }
124
+ return {
125
+ id: value.id,
126
+ ...(displayName === undefined ? {} : { displayName }),
127
+ ...(family === undefined ? {} : { family }),
128
+ ...(supportsThinking === undefined ? {} : { supportsThinking }),
129
+ ...(supportsAgent === undefined ? {} : { supportsAgent }),
130
+ ...(maxContext === undefined ? {} : { maxContext }),
131
+ ...(maxContextForMaxMode === undefined ? {} : { maxContextForMaxMode }),
132
+ ...(supportsMaxMode === undefined ? {} : { supportsMaxMode }),
133
+ variants: variants,
134
+ };
135
+ }
136
+ export function normalizeModelCache(value) {
137
+ if (!isPlainRecord(value) || !Array.isArray(value.models))
138
+ return null;
139
+ if (typeof value.fetchedAt !== "number" || !Number.isFinite(value.fetchedAt))
140
+ return null;
141
+ if (value.schemaVersion !== undefined &&
142
+ (!Number.isSafeInteger(value.schemaVersion) || value.schemaVersion < 0)) {
143
+ return null;
144
+ }
145
+ const models = value.models.map(normalizeModelInfo);
146
+ if (models.some((model) => model === null))
147
+ return null;
148
+ return {
149
+ models: models,
150
+ fetchedAt: value.fetchedAt,
151
+ ...(value.schemaVersion === undefined
152
+ ? {}
153
+ : { schemaVersion: value.schemaVersion }),
154
+ };
155
+ }
11
156
  /**
12
157
  * Read only the dedicated variant payload generated by the plugin. OpenCode
13
158
  * merges model, agent, and variant options into one providerOptions namespace;
@@ -15,17 +160,12 @@ export const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
15
160
  * onto the wire.
16
161
  */
17
162
  export function extractCursorVariantParameters(providerOptions) {
18
- const raw = providerOptions?.[CURSOR_VARIANT_PARAMETERS_KEY];
19
- if (!Array.isArray(raw) || raw.length === 0)
163
+ if (!providerOptions || !Object.hasOwn(providerOptions, CURSOR_VARIANT_PARAMETERS_KEY)) {
20
164
  return undefined;
21
- const params = [];
22
- for (const item of raw) {
23
- if (!item || typeof item !== "object" || Array.isArray(item))
24
- return undefined;
25
- const { id, value } = item;
26
- if (typeof id !== "string" || typeof value !== "string")
27
- return undefined;
28
- params.push({ id, value });
165
+ }
166
+ const params = normalizeModelParameterValues(providerOptions[CURSOR_VARIANT_PARAMETERS_KEY]);
167
+ if (!params) {
168
+ throw new CursorVariantSelectionError("is malformed: cursorVariantParameters must be a parameter array");
29
169
  }
30
170
  return params;
31
171
  }
@@ -35,7 +175,8 @@ export function resolveCursorWireModelId(providerOptions, fallback) {
35
175
  }
36
176
  /** True when resolved params select the long-context (1m) tier. */
37
177
  export function paramsImplyMaxMode(params) {
38
- return params.some((p) => p.id === "context" && p.value === "1m");
178
+ return params.some((parameter) => parameter.id === "context" &&
179
+ parseCursorContextLimit(parameter.value) === 1_000_000);
39
180
  }
40
181
  // Cursor encodes a model's context window as a variant parameter `id: "context"`
41
182
  // whose value is a tier string — the same 200k / 272k / 300k / 1m the IDE's
@@ -44,21 +185,30 @@ export function paramsImplyMaxMode(params) {
44
185
  // `context_token_limit` (#15) / `context_token_limit_for_max_mode` (#16) are
45
186
  // also defined on AvailableModel but the server often leaves them empty — the
46
187
  // variant param is the primary source (request flags may still populate them).
47
- const CONTEXT_TIER_TO_TOKENS = {
48
- "200k": 200_000,
49
- "272k": 272_000,
50
- "300k": 300_000,
51
- "1m": 1_000_000,
52
- };
188
+ export function parseCursorContextLimit(value) {
189
+ if (typeof value !== "string")
190
+ return undefined;
191
+ const text = value.trim();
192
+ const match = /^(\d+(?:\.\d+)?)\s*([km])$/i.exec(text);
193
+ if (match) {
194
+ const multiplier = match[2].toLowerCase() === "k" ? 1_000 : 1_000_000;
195
+ const limit = Number(match[1]) * multiplier;
196
+ return Number.isSafeInteger(limit) && limit > 0 ? limit : undefined;
197
+ }
198
+ const numeric = Number(text);
199
+ return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : undefined;
200
+ }
53
201
  function variantContextTokens(v) {
54
202
  const raw = v?.parameterValues.find((p) => p.id === "context")?.value;
55
- if (!raw)
56
- return undefined;
57
- const mapped = CONTEXT_TIER_TO_TOKENS[raw];
58
- if (mapped !== undefined)
59
- return mapped;
60
- const n = Number(raw);
61
- return Number.isFinite(n) && n > 0 ? n : undefined;
203
+ return parseCursorContextLimit(raw);
204
+ }
205
+ function isLongContextVariant(v) {
206
+ return variantContextTokens(v) === 1_000_000;
207
+ }
208
+ function positiveNumber(value) {
209
+ return typeof value === "number" && Number.isFinite(value) && value > 0
210
+ ? value
211
+ : undefined;
62
212
  }
63
213
  // ── Cache helpers ──
64
214
  export function isCacheFresh(cache, ttlMs = MODEL_CACHE_TTL_MS) {
@@ -72,43 +222,66 @@ export function cacheFilePath(cacheDir) {
72
222
  export async function readCache(cacheDir) {
73
223
  const filePath = cacheFilePath(cacheDir);
74
224
  try {
75
- if (!existsSync(filePath))
76
- return null;
77
225
  const data = await readFile(filePath, "utf-8");
78
- return JSON.parse(data);
226
+ return normalizeModelCache(JSON.parse(data));
79
227
  }
80
228
  catch {
81
229
  return null;
82
230
  }
83
231
  }
84
232
  export async function writeCache(cacheDir, cache) {
233
+ const normalized = normalizeModelCache(cache);
234
+ if (!normalized)
235
+ throw new Error("Refusing to write an invalid Cursor model cache");
85
236
  const filePath = cacheFilePath(cacheDir);
86
- await mkdir(path.dirname(filePath), { recursive: true });
87
- await writeFile(filePath, JSON.stringify(cache, null, 2), "utf-8");
237
+ const directory = path.dirname(filePath);
238
+ const tempPath = path.join(directory, `.${MODEL_CACHE_FILE}.${process.pid}.${crypto.randomUUID()}.tmp`);
239
+ await mkdir(directory, { recursive: true });
240
+ try {
241
+ await writeFile(tempPath, JSON.stringify(normalized, null, 2), "utf-8");
242
+ await rename(tempPath, filePath);
243
+ }
244
+ finally {
245
+ await unlink(tempPath).catch(() => { });
246
+ }
88
247
  }
89
248
  // ── Map live API response to ModelInfo[] ──
249
+ function apiBoolean(record, names) {
250
+ const value = optionalBoolean(record, names);
251
+ if (value === null)
252
+ throw new Error(`AvailableModels returned a non-boolean ${names[0]}`);
253
+ return value ?? false;
254
+ }
90
255
  export function mapAvailableModelsResponse(raw) {
91
256
  const entries = raw.models ?? [];
92
257
  const models = [];
93
258
  for (const entry of entries) {
259
+ if (!isPlainRecord(entry))
260
+ continue;
94
261
  const e = entry;
95
262
  const name = e.name;
96
- if (!name)
263
+ if (!isNonEmptyString(name))
97
264
  continue;
98
265
  const variants = [];
99
- const rawVariants = (e.variants ?? []);
266
+ const rawVariants = e.variants ?? [];
267
+ if (!Array.isArray(rawVariants)) {
268
+ throw new Error(`AvailableModels returned invalid variants for ${name}`);
269
+ }
100
270
  for (const v of rawVariants) {
271
+ if (!isPlainRecord(v)) {
272
+ throw new Error(`AvailableModels returned an invalid variant for ${name}`);
273
+ }
101
274
  const rawParams = (v.parameterValues ?? v.parameter_values ?? []);
102
- const parameterValues = rawParams.map((p) => ({
103
- id: p.id,
104
- value: String(p.value ?? ""),
105
- }));
275
+ const parameterValues = normalizeModelParameterValues(rawParams);
276
+ if (!parameterValues) {
277
+ throw new Error(`AvailableModels returned invalid parameter values for ${name}`);
278
+ }
106
279
  variants.push({
107
280
  key: name,
108
281
  parameterValues,
109
282
  displayName: (v.displayName ?? v.display_name ?? name),
110
- isDefaultNonMax: !!(v.isDefaultNonMaxConfig ?? v.is_default_non_max_config ?? false),
111
- isDefaultMax: !!(v.isDefaultMaxConfig ?? v.is_default_max_config ?? false),
283
+ isDefaultNonMax: apiBoolean(v, ["isDefaultNonMaxConfig", "is_default_non_max_config"]),
284
+ isDefaultMax: apiBoolean(v, ["isDefaultMaxConfig", "is_default_max_config"]),
112
285
  });
113
286
  }
114
287
  // Derive the context window from the variant `context` param (primary
@@ -117,17 +290,18 @@ export function mapAvailableModelsResponse(raw) {
117
290
  // Prefer a non-1m context when isDefaultNonMax is missing so a leading
118
291
  // max-mode variant cannot inflate the base window.
119
292
  const variantBaseContext = variantContextTokens(variants.find((v) => v.isDefaultNonMax)) ??
120
- variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context" && p.value !== "1m"))) ??
293
+ variantContextTokens(variants.find((v) => variantContextTokens(v) !== 1_000_000)) ??
121
294
  variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
122
295
  const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
123
296
  models.push({
124
297
  id: name,
125
298
  displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
126
- supportsThinking: !!(e.supportsThinking ?? e.supports_thinking ?? false),
127
- supportsAgent: !!(e.supportsAgent ?? e.supports_agent ?? false),
128
- maxContext: (variantBaseContext ?? e.contextTokenLimit ?? e.context_token_limit ?? undefined),
129
- maxContextForMaxMode: (variantMaxContext ?? e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode ?? undefined),
130
- supportsMaxMode: !!(e.supportsMaxMode ?? e.supports_max_mode ?? false),
299
+ supportsThinking: apiBoolean(e, ["supportsThinking", "supports_thinking"]),
300
+ supportsAgent: apiBoolean(e, ["supportsAgent", "supports_agent"]),
301
+ maxContext: variantBaseContext ?? positiveNumber(e.contextTokenLimit ?? e.context_token_limit),
302
+ maxContextForMaxMode: variantMaxContext ??
303
+ positiveNumber(e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode),
304
+ supportsMaxMode: apiBoolean(e, ["supportsMaxMode", "supports_max_mode"]),
131
305
  variants,
132
306
  });
133
307
  }
@@ -148,34 +322,34 @@ export function mapAvailableModelsResponse(raw) {
148
322
  * so the picked values are already isolated and can be matched verbatim.
149
323
  */
150
324
  export function resolveVariantParameters(model, opts = {}) {
151
- const picked = opts.picked?.length ? opts.picked.map((p) => ({ ...p })) : undefined;
325
+ const picked = opts.picked?.map((p) => ({ ...p }));
152
326
  if (!model || model.variants.length === 0) {
153
- if (picked?.length)
154
- return picked;
327
+ if (picked !== undefined) {
328
+ throw new CursorVariantSelectionError(`is stale: model ${JSON.stringify(model?.id ?? "unknown")} has no cached variants`);
329
+ }
155
330
  return opts.reasoningEffort ? [{ id: "effort", value: opts.reasoningEffort }] : [];
156
331
  }
157
332
  const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning")?.value;
158
333
  const isFast = (v) => v.parameterValues.find((p) => p.id === "fast")?.value === "true";
159
- const contextOf = (v) => v.parameterValues.find((p) => p.id === "context")?.value;
160
- const isMaxVariant = (v) => v.isDefaultMax || contextOf(v) === "1m";
334
+ const isMaxVariant = (v) => v.isDefaultMax || isLongContextVariant(v);
161
335
  const wantMax = opts.maxMode ?? false;
162
336
  // Non-fast pool for hint-based resolution only. Exact `picked` matching
163
337
  // searches all variants so Fast selections are not silently dropped.
164
338
  const pool = model.variants.filter((v) => !isFast(v));
165
339
  const scoped = pool.length > 0 ? pool : model.variants;
166
340
  // 1. Variant explicitly picked by opencode (verbatim — preserves context, fast, …).
167
- if (picked?.length) {
168
- const signature = new Set(picked.map((p) => `${p.id}=${p.value}`));
169
- const exact = model.variants.find((v) => v.parameterValues.every((p) => signature.has(`${p.id}=${p.value}`)) &&
170
- signature.size === v.parameterValues.length);
341
+ if (picked !== undefined) {
342
+ const pickedById = new Map(picked.map((parameter) => [parameter.id, parameter.value]));
343
+ const exact = model.variants.find((v) => v.parameterValues.length === picked.length &&
344
+ pickedById.size === picked.length &&
345
+ v.parameterValues.every((parameter) => pickedById.get(parameter.id) === parameter.value));
171
346
  if (exact) {
172
347
  return buildRequestedModelParams(exact.parameterValues, {
173
348
  reasoningEffort: opts.reasoningEffort,
174
349
  maxMode: wantMax,
175
350
  });
176
351
  }
177
- // Unmatched: forward the stripped pick so the TUI choice is not discarded.
178
- return picked;
352
+ throw new CursorVariantSelectionError(`is stale for model ${JSON.stringify(model.id)}: its exact parameter tuple is unavailable`);
179
353
  }
180
354
  // When maxMode is requested, prefer max-tier variants for effort matching
181
355
  // so "high + max" lands on 1m high rather than the first 300k high.
@@ -193,7 +367,7 @@ export function resolveVariantParameters(model, opts = {}) {
193
367
  }
194
368
  // 3. Max-mode hint → prefer the default max variant (1m context).
195
369
  if (wantMax) {
196
- const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find((v) => contextOf(v) === "1m");
370
+ const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find(isLongContextVariant);
197
371
  if (max)
198
372
  return buildRequestedModelParams(max.parameterValues, { maxMode: true });
199
373
  }
@@ -209,17 +383,37 @@ export async function fetchModels(token, options = {}) {
209
383
  const raw = await unaryAvailableModels(token, options);
210
384
  return mapAvailableModelsResponse(raw);
211
385
  }
386
+ const refreshesByDirectory = new Map();
387
+ export async function refreshModelCache(cacheDir, fetcher) {
388
+ const key = path.resolve(cacheDir);
389
+ const existing = refreshesByDirectory.get(key);
390
+ if (existing)
391
+ return existing;
392
+ const refresh = (async () => {
393
+ const models = await fetcher();
394
+ await writeCache(cacheDir, {
395
+ models,
396
+ fetchedAt: Date.now(),
397
+ schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
398
+ });
399
+ return models;
400
+ })();
401
+ refreshesByDirectory.set(key, refresh);
402
+ try {
403
+ return await refresh;
404
+ }
405
+ finally {
406
+ if (refreshesByDirectory.get(key) === refresh)
407
+ refreshesByDirectory.delete(key);
408
+ }
409
+ }
212
410
  export async function discoverModels(token, cacheDir, options = {}) {
213
411
  const cached = await readCache(cacheDir);
412
+ const refresh = () => refreshModelCache(cacheDir, () => fetchModels(token, options));
214
413
  // Cache is fresh → return it; refresh in background
215
414
  if (cached && isCacheFresh(cached)) {
216
415
  // Background refresh (fire and forget)
217
- fetchModels(token, options)
218
- .then((models) => writeCache(cacheDir, {
219
- models,
220
- fetchedAt: Date.now(),
221
- schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
222
- }))
416
+ void refresh()
223
417
  .catch(() => {
224
418
  /* background refresh failure is non-fatal */
225
419
  });
@@ -228,26 +422,12 @@ export async function discoverModels(token, cacheDir, options = {}) {
228
422
  // Cache exists but expired → try fetch, serve stale on failure
229
423
  if (cached) {
230
424
  try {
231
- const models = await fetchModels(token, options);
232
- const newCache = {
233
- models,
234
- fetchedAt: Date.now(),
235
- schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
236
- };
237
- await writeCache(cacheDir, newCache);
238
- return models;
425
+ return await refresh();
239
426
  }
240
427
  catch {
241
428
  return cached.models;
242
429
  }
243
430
  }
244
431
  // No cache → must fetch
245
- const models = await fetchModels(token, options);
246
- const newCache = {
247
- models,
248
- fetchedAt: Date.now(),
249
- schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
250
- };
251
- await writeCache(cacheDir, newCache);
252
- return models;
432
+ return refresh();
253
433
  }
package/dist/plugin.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
2
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
3
- import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh } from "./models.js";
3
+ import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
4
4
  import { opencodeGlobalCacheDir } from "./context/paths.js";
5
5
  import { readStoredAuth } from "./context/auth-store.js";
6
6
  import { resolveAgentUrl } from "./agent-url.js";
7
+ import { captureCursorShellResult, cursorShellOriginalCommand, prepareCursorShellArgs, } from "./shell-timeout.js";
8
+ import { sessionActivity } from "./activity.js";
7
9
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
8
10
  /**
9
11
  * Strip characters and markup that break rendering in the OpenCode TUI/GUI from
@@ -85,7 +87,7 @@ function modelInfoVariants(mi, variants) {
85
87
  return entries;
86
88
  }
87
89
  function isLongContextVariant(v) {
88
- return v.parameterValues.some((p) => p.id === "context" && p.value === "1m");
90
+ return v.parameterValues.some((p) => p.id === "context" && parseCursorContextLimit(p.value) === 1_000_000);
89
91
  }
90
92
  function variantsForTier(mi, tier) {
91
93
  return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
@@ -311,6 +313,37 @@ export async function CursorPlugin(input) {
311
313
  return cached?.models.length ? modelsToConfig(cached.models) : {};
312
314
  }
313
315
  return {
316
+ async event({ event }) {
317
+ switch (event.type) {
318
+ case "session.created":
319
+ sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
320
+ sessionActivity.recordActivity(event.properties.info.id);
321
+ break;
322
+ case "session.updated":
323
+ sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
324
+ break;
325
+ case "session.deleted":
326
+ sessionActivity.removeSession(event.properties.info.id);
327
+ break;
328
+ case "message.updated":
329
+ sessionActivity.recordActivity(event.properties.info.sessionID);
330
+ break;
331
+ case "message.part.updated":
332
+ sessionActivity.recordActivity(event.properties.part.sessionID);
333
+ break;
334
+ }
335
+ },
336
+ async "tool.execute.before"(hookInput, output) {
337
+ if (hookInput.tool !== "bash")
338
+ return;
339
+ prepareCursorShellArgs(hookInput.callID, output.args);
340
+ },
341
+ async "tool.execute.after"(hookInput, output) {
342
+ if (hookInput.tool !== "bash")
343
+ return;
344
+ output.title = cursorShellOriginalCommand(hookInput.callID) ?? output.title;
345
+ output.output = captureCursorShellResult(hookInput.callID, output.output, output.metadata);
346
+ },
314
347
  async "chat.params"(hookInput, output) {
315
348
  if (hookInput.model.providerID !== CURSOR_PROVIDER_ID)
316
349
  return;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Canonical Cursor agent.v1 exec request/result pairs.
3
+ *
4
+ * Source of truth: Cursor CLI generated `agent/v1/exec_pb.js` plus the
5
+ * registrations in `agent-exec/dist/index.js`. Most request/result variants
6
+ * share a field number; Pi requests deliberately use result field +1.
7
+ *
8
+ * `handling` describes this provider, not the Cursor CLI:
9
+ * - opencode-tool: emitted through an advertised OpenCode tool
10
+ * - provider-control: answered directly on the held-open Run stream
11
+ * - unsupported: known Cursor-native capability with no safe AI SDK bridge
12
+ */
13
+ export type CursorExecHandling = "opencode-tool" | "provider-control" | "unsupported";
14
+ export type CursorExecVariant = {
15
+ requestField: number;
16
+ requestName: string;
17
+ resultField: number;
18
+ resultName: string;
19
+ handling: CursorExecHandling;
20
+ };
21
+ export declare const CURSOR_EXEC_VARIANTS: readonly CursorExecVariant[];
22
+ export declare function cursorExecVariantByRequestField(field: number): CursorExecVariant | undefined;
23
+ export declare function cursorExecVariantByRequestName(name: string): CursorExecVariant | undefined;
24
+ export declare function describeCursorExecVariant(field: number | undefined): string;
@@ -0,0 +1,55 @@
1
+ export const CURSOR_EXEC_VARIANTS = [
2
+ { requestField: 2, requestName: "shell_args", resultField: 2, resultName: "shell_result", handling: "unsupported" },
3
+ { requestField: 3, requestName: "write_args", resultField: 3, resultName: "write_result", handling: "opencode-tool" },
4
+ { requestField: 4, requestName: "delete_args", resultField: 4, resultName: "delete_result", handling: "opencode-tool" },
5
+ { requestField: 5, requestName: "grep_args", resultField: 5, resultName: "grep_result", handling: "opencode-tool" },
6
+ { requestField: 7, requestName: "read_args", resultField: 7, resultName: "read_result", handling: "opencode-tool" },
7
+ { requestField: 8, requestName: "ls_args", resultField: 8, resultName: "ls_result", handling: "opencode-tool" },
8
+ { requestField: 9, requestName: "diagnostics_args", resultField: 9, resultName: "diagnostics_result", handling: "unsupported" },
9
+ { requestField: 10, requestName: "request_context_args", resultField: 10, resultName: "request_context_result", handling: "provider-control" },
10
+ { requestField: 11, requestName: "mcp_args", resultField: 11, resultName: "mcp_result", handling: "opencode-tool" },
11
+ { requestField: 14, requestName: "shell_stream_args", resultField: 14, resultName: "shell_stream", handling: "opencode-tool" },
12
+ { requestField: 16, requestName: "background_shell_spawn_args", resultField: 16, resultName: "background_shell_spawn_result", handling: "opencode-tool" },
13
+ { requestField: 17, requestName: "list_mcp_resources_exec_args", resultField: 17, resultName: "list_mcp_resources_exec_result", handling: "unsupported" },
14
+ { requestField: 18, requestName: "read_mcp_resource_exec_args", resultField: 18, resultName: "read_mcp_resource_exec_result", handling: "unsupported" },
15
+ { requestField: 20, requestName: "fetch_args", resultField: 20, resultName: "fetch_result", handling: "unsupported" },
16
+ { requestField: 21, requestName: "record_screen_args", resultField: 21, resultName: "record_screen_result", handling: "unsupported" },
17
+ { requestField: 22, requestName: "computer_use_args", resultField: 22, resultName: "computer_use_result", handling: "unsupported" },
18
+ { requestField: 23, requestName: "write_shell_stdin_args", resultField: 23, resultName: "write_shell_stdin_result", handling: "unsupported" },
19
+ { requestField: 27, requestName: "execute_hook_args", resultField: 27, resultName: "execute_hook_result", handling: "unsupported" },
20
+ { requestField: 28, requestName: "subagent_args", resultField: 28, resultName: "subagent_result", handling: "opencode-tool" },
21
+ { requestField: 29, requestName: "redacted_read_args", resultField: 29, resultName: "redacted_read_result", handling: "unsupported" },
22
+ { requestField: 30, requestName: "force_background_shell_args", resultField: 30, resultName: "force_background_shell_result", handling: "unsupported" },
23
+ { requestField: 31, requestName: "force_background_subagent_args", resultField: 31, resultName: "force_background_subagent_result", handling: "unsupported" },
24
+ { requestField: 36, requestName: "mcp_state_exec_args", resultField: 36, resultName: "mcp_state_exec_result", handling: "provider-control" },
25
+ { requestField: 37, requestName: "subagent_await_args", resultField: 37, resultName: "subagent_await_result", handling: "unsupported" },
26
+ { requestField: 38, requestName: "smart_mode_classifier_args", resultField: 38, resultName: "smart_mode_classifier_result", handling: "unsupported" },
27
+ { requestField: 40, requestName: "canvas_diagnostics_args", resultField: 40, resultName: "canvas_diagnostics_result", handling: "unsupported" },
28
+ { requestField: 41, requestName: "shell_allowlist_precheck_args", resultField: 41, resultName: "shell_allowlist_precheck_result", handling: "unsupported" },
29
+ { requestField: 42, requestName: "mcp_allowlist_precheck_args", resultField: 42, resultName: "mcp_allowlist_precheck_result", handling: "unsupported" },
30
+ { requestField: 43, requestName: "web_fetch_allowlist_precheck_args", resultField: 43, resultName: "web_fetch_allowlist_precheck_result", handling: "unsupported" },
31
+ { requestField: 44, requestName: "git_diff_request", resultField: 44, resultName: "git_diff_response", handling: "unsupported" },
32
+ { requestField: 45, requestName: "pi_read_args", resultField: 46, resultName: "pi_read_result", handling: "opencode-tool" },
33
+ { requestField: 46, requestName: "pi_bash_args", resultField: 47, resultName: "pi_bash_result", handling: "opencode-tool" },
34
+ { requestField: 47, requestName: "pi_edit_args", resultField: 48, resultName: "pi_edit_result", handling: "opencode-tool" },
35
+ { requestField: 48, requestName: "pi_write_args", resultField: 49, resultName: "pi_write_result", handling: "opencode-tool" },
36
+ { requestField: 49, requestName: "pi_grep_args", resultField: 50, resultName: "pi_grep_result", handling: "opencode-tool" },
37
+ { requestField: 50, requestName: "pi_find_args", resultField: 51, resultName: "pi_find_result", handling: "opencode-tool" },
38
+ { requestField: 51, requestName: "pi_ls_args", resultField: 52, resultName: "pi_ls_result", handling: "opencode-tool" },
39
+ ];
40
+ const BY_REQUEST_FIELD = new Map(CURSOR_EXEC_VARIANTS.map((variant) => [variant.requestField, variant]));
41
+ const BY_REQUEST_NAME = new Map(CURSOR_EXEC_VARIANTS.map((variant) => [variant.requestName, variant]));
42
+ export function cursorExecVariantByRequestField(field) {
43
+ return BY_REQUEST_FIELD.get(field);
44
+ }
45
+ export function cursorExecVariantByRequestName(name) {
46
+ return BY_REQUEST_NAME.get(name);
47
+ }
48
+ export function describeCursorExecVariant(field) {
49
+ if (field === undefined)
50
+ return "unknown request field";
51
+ const variant = cursorExecVariantByRequestField(field);
52
+ if (!variant)
53
+ return `unknown request field #${field}`;
54
+ return `${variant.requestName} (request field #${variant.requestField}, expected result ${variant.resultName} field #${variant.resultField}, handling=${variant.handling})`;
55
+ }