pi-cursor-sdk 0.1.7 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -1,6 +1,30 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.1.9 - 2026-05-14
4
+
5
+ ### Fixed
6
+
7
+ - Clean up recorded native Cursor tool replay outputs when abandoned replay runs are disposed, avoiding retained file or command output in process memory.
8
+ - Restore `/cursor-fast` state when session persistence fails during command handling.
9
+ - Preserve distinct same-payload Cursor tool completions while deduplicating duplicate SDK completion surfaces.
10
+ - Respect exact `model@context` context-window cache overrides before falling back to parsed base-model context values.
11
+ - Emit native replay text block endings with saved content indexes instead of searching by object identity.
12
+ - Redact discovery failure details with the same secret patterns used for stream errors.
13
+
14
+ ### Changed
15
+
16
+ - Update fallback Sonnet 4.6 context variants from `300k` to the current `200k` catalog variant.
17
+ - Skip ambiguous Cursor SDK aliases shared by multiple base models or colliding with base model IDs, preventing misleading pi model rows.
18
+ - Reduce context-window cache reloads during model catalog registration.
19
+ - Document image carry-forward as a product decision rather than silently changing current latest-user-message image forwarding behavior.
20
+
21
+ ## 0.1.8 - 2026-05-14
22
+
23
+ ### Changed
24
+
25
+ - Update the verified dependency baseline to `@cursor/sdk` 1.0.13 and Vitest 4.1.6.
26
+ - Register latest-style Cursor SDK model aliases returned by `Cursor.models.list()` as pi-selectable Cursor model IDs, including context-qualified alias variants where applicable.
27
+ - Clarify Max Mode behavior against current Cursor SDK docs: Cursor may enable required Max Mode automatically, but the extension still only advertises catalog-exposed context variants.
4
28
 
5
29
  ## 0.1.7 - 2026-05-10
6
30
 
package/README.md CHANGED
@@ -137,6 +137,7 @@ How to read model IDs:
137
137
  - `cursor/...` is the Cursor provider registered by this extension
138
138
  - `@1m`, `@272k`, and `@300k` are context-window variants
139
139
  - `:medium`, `:high`, and `:xhigh` are pi thinking-level suffixes for models where the Cursor SDK exposes a pi-controllable thinking parameter
140
+ - unambiguous latest-style Cursor aliases returned by `Cursor.models.list()` are registered too, using the same context suffixes when the target model has context variants; aliases shared by multiple base models or colliding with a base model ID are skipped because their SDK resolution and displayed metadata can diverge
140
141
 
141
142
  Examples with pi thinking controls:
142
143
 
@@ -146,7 +147,7 @@ pi --model cursor/gpt-5.5@272k:xhigh
146
147
  pi --model cursor/gpt-5.5@1m --thinking medium
147
148
  ```
148
149
 
149
- Cursor-only parameters are not encoded into pi model IDs. Cursor `context` becomes a pi-visible model variant because it changes pi's native `contextWindow`; Cursor `fast` is extension state, not model identity.
150
+ Cursor-only parameters are not encoded into pi model IDs. Cursor `context` becomes a pi-visible model variant because it changes pi's native `contextWindow`; Cursor `fast` is extension state, not model identity. Alias model IDs still share Cursor-only state, such as fast defaults, with their underlying Cursor base model.
150
151
 
151
152
  ## Thinking support
152
153
 
@@ -202,7 +203,7 @@ If no key is available from `/login`, `CURSOR_API_KEY`, or `--api-key`, model di
202
203
 
203
204
  - `composer-2`
204
205
  - `gpt-5.5@1m`, `gpt-5.5@272k`
205
- - `claude-sonnet-4-6@1m`, `claude-sonnet-4-6@300k`
206
+ - `claude-sonnet-4-6@1m`, `claude-sonnet-4-6@200k`
206
207
  - `claude-opus-4-7@1m`, `claude-opus-4-7@300k`
207
208
 
208
209
  Fallback models are a conservative startup model list. Actual Cursor runs still need a key from `/login`, `CURSOR_API_KEY`, or `--api-key`. If you add auth after startup, run `/reload` or restart pi to refresh the full live Cursor model catalog.
@@ -214,7 +215,7 @@ Fallback models are a conservative startup model list. Actual Cursor runs still
214
215
  - **Pi tool schemas are not passed through to Cursor.** This extension is a Cursor provider, not a bridge that forwards pi's tool system into Cursor.
215
216
  - **One fresh Cursor agent is created per provider call.** Cursor agent state is not reused between pi provider calls.
216
217
  - **Ambient Cursor setting/rule layers are not loaded by default.** The current Cursor SDK writes setting/rule loading logs directly to terminal output, which corrupts pi's TUI, so the extension leaves those layers out.
217
- - **Max Mode is not exposed for these local runs.** The extension only advertises exact context windows supported by the SDK path it uses.
218
+ - **Max Mode is not a manual pi variant.** Cursor's SDK may enable Max Mode automatically for models that require it. This extension only advertises exact context-window variants that the SDK catalog exposes and otherwise uses conservative SDK-derived default/non-Max context windows.
218
219
  - **Output token limits are conservative.** Cursor SDK model metadata does not currently expose output token limits directly.
219
220
  - **Token usage is approximate in pi.** Cursor SDK usage events include internal agent/tool/cache work, so the extension reports an approximate replayable pi prompt/output size for context display and compaction decisions.
220
221
 
@@ -13,6 +13,7 @@ Current implementation notes:
13
13
  - Cursor `fast` is extension state, not model identity.
14
14
  - Cursor fast status uses `ctx.ui.setStatus()`; the default pi footer remains intact.
15
15
  - Installed `@cursor/sdk` user messages accept images, and Cursor models are treated as image-capable; registered input metadata is `text` plus `image`.
16
+ - Product decision pending: image payload forwarding currently sends images only from the latest user message. If the latest user turn is plain text after an earlier image turn, the transcript keeps an `[image omitted from transcript]` placeholder but no image bytes are sent to Cursor. Changing this to carry images forward across turns requires a deliberate product decision about token cost, privacy, stale visual context, and expected multimodal follow-up behavior.
16
17
  - `@cursor/sdk` is a package dependency of this extension; users should not need a global SDK install.
17
18
  - Cursor auth uses pi-native API-key resolution for provider `cursor`: CLI `--api-key`, stored `~/.pi/agent/auth.json` API key from `/login`, then `CURSOR_API_KEY`. The extension config file stores only non-secret Cursor-only state such as fast defaults.
18
19
  - Local agents do not pass `settingSources` by default because the current Cursor SDK writes setting/rule loading INFO logs directly to terminal output, which corrupts pi's TUI.
@@ -20,7 +21,8 @@ Current implementation notes:
20
21
  - Cursor-side thinking remains visible. Cursor internal tool activity is recorded from SDK events and scrubbed. In interactive TTY sessions, supported completed `read`, `bash`, and `ls` activity is replayed through pi's native tool-call rendering path with recorded Cursor results, so the TUI can show native green cards without forcing Cursor to call pi tools or rerunning Cursor's reads/shell commands. Native replay wrappers are registered only for tool names not already owned by another extension; conflicting tools use the bounded scrubbed transcript fallback. `PI_CURSOR_NATIVE_TOOL_DISPLAY=0` disables native replay, and `PI_CURSOR_REGISTER_NATIVE_TOOLS=0` is a registration-only opt-out that keeps the transcript fallback without shadowing pi tool names. When these native cards are emitted, the provider mirrors Codex's turn shape as Cursor SDK completions arrive: assistant `toolUse`, pi `toolResult`s, live post-tool Cursor thinking/text, any later Cursor tool batches as further `toolUse` turns, then Cursor's final assistant answer. Non-interactive runs keep bounded scrubbed transcript output instead, preserving `pi -p` assistant text output. Cursor text deltas stream live when native tool replay is not active.
21
22
  - Cursor SDK usage events report cumulative internal agent/tool/cache work, not the replayable pi prompt context. The extension reports approximate prompt/output usage for pi context display and compaction decisions instead of copying raw Cursor SDK usage. When native replay splits one Cursor SDK run into multiple pi turns, prompt input is counted once for the run; later synthetic replay turns report `input: 0` and only their own output estimate.
22
23
  - For models without a catalog `context` parameter, context windows are not hardcoded. The extension ships a bundled SDK-derived default/non-Max cache generated from `createAgentPlatform().checkpointStore.loadLatest(agentId).tokenDetails.maxTokens`. Successful runs can update a local override cache, but model discovery does not probe models at startup.
23
- - Max Mode context windows are distinct from default/non-Max context windows. `@cursor/sdk` 1.0.12 exposes internal protobuf fields named `maxMode`/`max_mode`, but the public `ModelSelection` type and the local executor path do not pass a Max Mode selector for local agent runs. Do not advertise Max Mode context windows unless the SDK catalog exposes an exact parameter/variant or the SDK public API adds a Max Mode selector that the extension actually sends.
24
+ - Max Mode context windows are distinct from default/non-Max context windows. `@cursor/sdk` 1.0.13 documentation says the SDK may enable Max Mode automatically when a selected model requires it, but the public local-agent `ModelSelection` path still does not expose a manual Max Mode selector. Do not advertise Max Mode context windows unless the SDK catalog exposes an exact parameter/variant or the SDK public API adds a Max Mode selector that the extension actually sends.
25
+ - `@cursor/sdk` 1.0.13 adds latest-style `ModelListItem.aliases`. The extension registers only unambiguous aliases as pi model IDs (with the same context suffixes when applicable) and sends the alias back in `ModelSelection.id`, while sharing Cursor-only state such as fast defaults with the underlying catalog `id`. Aliases shared by multiple base models, such as generic family aliases, are skipped because the pi row metadata would otherwise imply one base model while Cursor may resolve the alias to another.
24
26
 
25
27
  ## Goal
26
28
 
@@ -69,6 +71,7 @@ Users can persist the stored key through `/login` -> `Use an API key` -> `Cursor
69
71
  For each model, use:
70
72
 
71
73
  - `model.id`
74
+ - `model.aliases`
72
75
  - `model.displayName`
73
76
  - `model.parameters`
74
77
  - `model.variants`
@@ -135,8 +138,9 @@ Register a `cursor` provider with `pi.registerProvider()`.
135
138
 
136
139
  Rules:
137
140
 
138
- - Register one pi model for each Cursor base model when there is no Cursor `context` parameter.
139
- - Register one pi model per Cursor `context` value when the model exposes a `context` parameter.
141
+ - Register one pi model for each Cursor base model and each unambiguous SDK alias when there is no Cursor `context` parameter.
142
+ - Register one pi model per Cursor `context` value for each Cursor base model and each unambiguous SDK alias when the model exposes a `context` parameter.
143
+ - Skip SDK aliases that collide with another base model ID or are shared by multiple base models; those aliases can resolve differently from the pi row metadata.
140
144
  - Do not encode `reasoning`, `effort`, `thinking`, or `fast` into pi model IDs.
141
145
  - Prefer stable, readable `@<context>` suffixes that do not conflict with pi's final `:<thinking>` suffix parser.
142
146
  - Sort Cursor models by base ID, then context value in Cursor SDK order before calling `pi.registerProvider()`. Registration order matters for `/model` display and model cycling; `--list-models` sorts output separately.
@@ -177,7 +181,7 @@ Reason:
177
181
 
178
182
  Each registered model must set:
179
183
 
180
- - `id`: context-qualified pi model ID when needed.
184
+ - `id`: context-qualified pi model ID when needed. For SDK aliases, this uses the alias as the pi-visible ID and the alias is sent back to Cursor as `ModelSelection.id`.
181
185
  - `name`: human-readable Cursor display name plus context when useful.
182
186
  - `reasoning`: `true` only if a Cursor `reasoning`, `effort`, or `thinking` parameter can map to pi thinking. This controls pi's thinking UI and `pi --list-models` `thinking` column; it must not be used to claim whether the Cursor model can think internally. Cursor SDK models are thinking-capable even when this is `false`.
183
187
  - `thinkingLevelMap`: model-specific pi-to-Cursor mapping for pi UI, clamping, persistence, and footer display.
@@ -186,7 +190,7 @@ Each registered model must set:
186
190
  - `input`: supported input types. The installed Cursor SDK accepts `SDKUserMessage.images`, and Cursor models are expected to support image input, so advertise `["text", "image"]`.
187
191
  - `cost`: zeroed unless reliable Cursor costs are available.
188
192
 
189
- The extension stores runtime metadata in an internal map keyed by registered pi model ID. That map records the Cursor base model ID, selected context param, default params, and discovered capabilities. `ProviderModelConfig` has no dedicated metadata field, so do not rely on hidden custom fields for this state.
193
+ The extension stores runtime metadata in an internal map keyed by registered pi model ID. That map records the Cursor base catalog model ID, the Cursor selection model ID (base ID or alias), selected context param, default params, and discovered capabilities. `ProviderModelConfig` has no dedicated metadata field, so do not rely on hidden custom fields for this state.
190
194
 
191
195
  ## Dynamic Capabilities
192
196
 
@@ -484,42 +488,22 @@ Fast flag example:
484
488
  pi --model cursor/gpt-5.5@1m --cursor-fast -p "Say ok only"
485
489
  ```
486
490
 
487
- ## Current Discovered Model Capability Examples
491
+ ## Discovered Model Capability Examples
488
492
 
489
- Current live Cursor data says:
493
+ These examples document the capability shapes the extension handles, not an exhaustive live catalog. The exact Cursor catalog changes over time; use `pi -e . --list-models cursor` or `Cursor.models.list()` for the current model surface. When the SDK reports aliases, only unambiguous aliases are registered; shared generic aliases are skipped.
490
494
 
491
- | Model | Cursor controls | Pi representation |
495
+ | Example model shape | Cursor controls | Pi representation |
492
496
  |---|---|---|
493
- | `default` | none | plain model |
494
- | `composer-2` | fast | plain model + fast extension state |
495
- | `composer-1.5` | none | plain model |
496
- | `gpt-5.5` | context, reasoning, fast | context variants + native thinking + fast state |
497
- | `gpt-5.4` | context, reasoning, fast | context variants + native thinking + fast state |
498
- | `gpt-5.4-mini` | reasoning | plain model + native thinking |
499
- | `gpt-5.4-nano` | reasoning | plain model + native thinking |
500
- | `gpt-5.3-codex` | reasoning, fast | plain model + native thinking + fast state |
501
- | `gpt-5.3-codex-spark` | reasoning | plain model + native thinking |
502
- | `gpt-5.2` | reasoning, fast | plain model + native thinking + fast state |
503
- | `gpt-5.2-codex` | reasoning, fast | plain model + native thinking + fast state |
504
- | `gpt-5.1-codex-max` | reasoning, fast | plain model + native thinking + fast state |
505
- | `gpt-5.1-codex-mini` | reasoning | plain model + native thinking |
506
- | `gpt-5.1` | reasoning | plain model + native thinking |
507
- | `claude-opus-4-7` | thinking, context, effort | context variants + native thinking |
508
- | `claude-opus-4-6` | thinking, context, effort, fast | context variants + native thinking + fast state |
509
- | `claude-opus-4-5` | thinking | plain model + native thinking |
510
- | `claude-sonnet-4-6` | thinking, context, effort | context variants + native thinking |
511
- | `claude-sonnet-4-5` | thinking, context | context-qualified model + native thinking |
512
- | `claude-sonnet-4` | thinking, context | context-qualified model + native thinking |
513
- | `claude-haiku-4-5` | thinking | plain model + native thinking |
514
- | `grok-4.3` | context | context variants |
515
- | `grok-4-20` | thinking | plain model + native thinking |
516
- | `gemini-3.1-pro` | none | plain model |
517
- | `gemini-3-flash` | none | plain model |
518
- | `gemini-2.5-flash` | none | plain model |
519
- | `gpt-5-mini` | none | plain model |
520
- | `kimi-k2.5` | none | plain model |
521
-
522
- If Cursor later adds `fast`, `context`, `reasoning`, or `effort` to a model, the extension picks it up dynamically.
497
+ | plain model, such as `default` or models with no exposed controls | none | plain model |
498
+ | `composer-2`-style model | fast | plain model + fast extension state |
499
+ | GPT-style reasoning model with context variants | context, reasoning, fast when exposed | context variants + native thinking + optional fast state |
500
+ | Claude-style thinking model with context variants | thinking, context, effort when exposed | context variants + native thinking + optional fast state |
501
+ | Claude-style thinking model without context variants | thinking and/or effort | plain model + native thinking |
502
+ | context-only model | context | context variants |
503
+ | unique latest alias for any shape | aliases | same pi rows as the base model shape, using the alias as `ModelSelection.id` |
504
+ | shared generic alias across multiple base models | aliases | skipped to avoid misleading pi rows |
505
+
506
+ If Cursor later adds `fast`, `context`, `reasoning`, `effort`, or aliases to a model, the extension picks up unambiguous capability changes dynamically.
523
507
 
524
508
  ## Detailed Examples
525
509
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "pi provider extension backed by @cursor/sdk local agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "test:watch": "vitest"
39
39
  },
40
40
  "dependencies": {
41
- "@cursor/sdk": "^1.0.12"
41
+ "@cursor/sdk": "^1.0.13"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@earendil-works/pi-ai": "*",
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-ai": "^0.74.0",
49
49
  "@earendil-works/pi-coding-agent": "^0.74.0",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.5"
51
+ "vitest": "^4.1.6"
52
52
  },
53
53
  "pi": {
54
54
  "extensions": [
@@ -4,6 +4,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { BUNDLED_CONTEXT_WINDOWS } from "./bundled-context-windows.js";
5
5
 
6
6
  const CONTEXT_WINDOW_CACHE_FILE = "cursor-sdk-context-windows.json";
7
+ let userContextWindowOverrideLoadCount = 0;
7
8
 
8
9
  interface ContextWindowCacheFile {
9
10
  contextWindows?: Record<string, number>;
@@ -18,6 +19,7 @@ function isPositiveInteger(value: unknown): value is number {
18
19
  }
19
20
 
20
21
  function loadUserContextWindowOverrides(): Map<string, number> {
22
+ userContextWindowOverrideLoadCount += 1;
21
23
  const path = getCachePath();
22
24
  const overrides = new Map<string, number>();
23
25
  if (!existsSync(path)) return overrides;
@@ -40,6 +42,10 @@ export function loadContextWindowCache(): Map<string, number> {
40
42
  return cache;
41
43
  }
42
44
 
45
+ export function getCachedContextWindowExact(modelId: string): number | undefined {
46
+ return loadContextWindowCache().get(modelId);
47
+ }
48
+
43
49
  export function getCachedContextWindow(modelId: string): number | undefined {
44
50
  const cache = loadContextWindowCache();
45
51
  return cache.get(modelId) ?? cache.get("default");
@@ -76,4 +82,8 @@ export function saveCachedContextWindow(modelId: string, contextWindow: number):
76
82
 
77
83
  export const __testUtils = {
78
84
  getCachePath,
85
+ getUserContextWindowOverrideLoadCount: () => userContextWindowOverrideLoadCount,
86
+ resetUserContextWindowOverrideLoadCount: () => {
87
+ userContextWindowOverrideLoadCount = 0;
88
+ },
79
89
  };
@@ -56,9 +56,14 @@ export function canRenderCursorToolNatively(toolName: string): boolean {
56
56
  return isNativeCursorToolName(toolName) && registeredNativeToolNames.has(toolName);
57
57
  }
58
58
 
59
- export function recordCursorNativeToolDisplay(item: CursorNativeToolDisplayItem): void {
60
- if (!canRenderCursorToolNatively(item.toolName)) return;
59
+ export function recordCursorNativeToolDisplay(item: CursorNativeToolDisplayItem): boolean {
60
+ if (!canRenderCursorToolNatively(item.toolName)) return false;
61
61
  nativeToolResults.set(item.id, item);
62
+ return true;
63
+ }
64
+
65
+ export function deleteCursorNativeToolDisplay(id: string): void {
66
+ nativeToolResults.delete(id);
62
67
  }
63
68
 
64
69
  function consumeCursorNativeToolDisplay(id: string): CursorNativeToolDisplayItem | undefined {
@@ -68,6 +73,7 @@ function consumeCursorNativeToolDisplay(id: string): CursorNativeToolDisplayItem
68
73
  }
69
74
 
70
75
  export const __testUtils = {
76
+ nativeToolResultCount: () => nativeToolResults.size,
71
77
  reset(): void {
72
78
  registeredNativeToolNames.clear();
73
79
  nativeToolResults.clear();
@@ -17,6 +17,7 @@ import { buildCursorPiToolDisplay, formatCursorToolTranscript, mergeCursorToolCa
17
17
  import {
18
18
  canRenderCursorToolNatively,
19
19
  isCursorNativeToolDisplayRuntimeEnabled,
20
+ deleteCursorNativeToolDisplay,
20
21
  recordCursorNativeToolDisplay,
21
22
  type CursorNativeToolDisplayItem,
22
23
  } from "./cursor-native-tool-display.js";
@@ -58,6 +59,7 @@ const AUTH_CURSOR_SDK_ERROR_MESSAGE =
58
59
  const APPROX_CHARS_PER_TOKEN = 4;
59
60
  const IMAGE_TOKEN_ESTIMATE = 1200;
60
61
  const CURSOR_ACTIVITY_TRACE_MAX_CHARS = 50000;
62
+ const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
61
63
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
62
64
 
63
65
  type CursorNativeQueuedEvent =
@@ -73,10 +75,13 @@ interface CursorNativeLiveRun {
73
75
  promptInputTokensReported: boolean;
74
76
  pendingEvents: CursorNativeQueuedEvent[];
75
77
  textDeltas: string[];
78
+ recordedToolDisplayIds: string[];
76
79
  finalText?: string;
77
80
  done: boolean;
78
81
  cancelled: boolean;
82
+ disposed: boolean;
79
83
  errorMessage?: string;
84
+ idleDisposeTimer?: ReturnType<typeof setTimeout>;
80
85
  waiters: Set<() => void>;
81
86
  }
82
87
 
@@ -88,6 +93,7 @@ interface CursorNativeTurnState {
88
93
  }
89
94
 
90
95
  let cursorNativeReplayCounter = 0;
96
+ let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
91
97
  const pendingCursorNativeRuns = new Map<string, CursorNativeLiveRun>();
92
98
 
93
99
  function escapeRegExp(value: string): string {
@@ -264,6 +270,21 @@ function queueCursorNativeEvent(run: CursorNativeLiveRun, event: CursorNativeQue
264
270
  notifyCursorNativeRun(run);
265
271
  }
266
272
 
273
+ function clearCursorNativeRunIdleDispose(run: CursorNativeLiveRun): void {
274
+ if (!run.idleDisposeTimer) return;
275
+ clearTimeout(run.idleDisposeTimer);
276
+ run.idleDisposeTimer = undefined;
277
+ }
278
+
279
+ function scheduleCursorNativeRunIdleDispose(run: CursorNativeLiveRun): void {
280
+ if (run.disposed) return;
281
+ clearCursorNativeRunIdleDispose(run);
282
+ run.idleDisposeTimer = setTimeout(() => {
283
+ void disposeCursorNativeRun(run);
284
+ }, cursorNativeReplayIdleDisposeMs);
285
+ run.idleDisposeTimer.unref?.();
286
+ }
287
+
267
288
  function isCursorNativeRunReady(run: CursorNativeLiveRun): boolean {
268
289
  return run.pendingEvents.length > 0 || run.done || run.cancelled || run.errorMessage !== undefined;
269
290
  }
@@ -310,12 +331,13 @@ function closeCursorNativeThinkingBlock(turn: CursorNativeTurnState): void {
310
331
 
311
332
  function closeCursorNativeTextBlock(turn: CursorNativeTurnState): string {
312
333
  if (turn.textContentIndex < 0) return "";
313
- const block = turn.partial.content[turn.textContentIndex];
334
+ const contentIndex = turn.textContentIndex;
335
+ const block = turn.partial.content[contentIndex];
314
336
  turn.textContentIndex = -1;
315
337
  if (block.type !== "text") return "";
316
338
  turn.stream.push({
317
339
  type: "text_end",
318
- contentIndex: turn.partial.content.indexOf(block),
340
+ contentIndex,
319
341
  content: block.text,
320
342
  partial: turn.partial,
321
343
  });
@@ -402,15 +424,24 @@ function emitCursorNativeToolUseTurn(
402
424
  stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(tool.args), partial });
403
425
  const block = partial.content[contentIndex];
404
426
  if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
405
- recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate });
427
+ if (recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate })) {
428
+ run.recordedToolDisplayIds.push(tool.id);
429
+ }
406
430
  }
407
431
  setApproximateUsage(partial, takeCursorNativePromptInputTokens(run), outputText);
408
432
  partial.stopReason = "toolUse";
409
433
  stream.push({ type: "done", reason: "toolUse", message: partial });
434
+ scheduleCursorNativeRunIdleDispose(run);
410
435
  }
411
436
 
412
437
  async function disposeCursorNativeRun(run: CursorNativeLiveRun): Promise<void> {
438
+ if (run.disposed) return;
439
+ run.disposed = true;
413
440
  pendingCursorNativeRuns.delete(run.id);
441
+ clearCursorNativeRunIdleDispose(run);
442
+ for (const toolDisplayId of run.recordedToolDisplayIds) deleteCursorNativeToolDisplay(toolDisplayId);
443
+ run.recordedToolDisplayIds = [];
444
+ run.waiters.clear();
414
445
  try {
415
446
  await run.agent[Symbol.asyncDispose]();
416
447
  } catch {
@@ -484,7 +515,13 @@ async function replayPendingCursorNativeRun(
484
515
  if (!replayId) return false;
485
516
  const run = pendingCursorNativeRuns.get(replayId);
486
517
  if (!run) return false;
487
- await emitCursorNativeRunNextTurn(stream, partial, run, signal);
518
+ clearCursorNativeRunIdleDispose(run);
519
+ try {
520
+ await emitCursorNativeRunNextTurn(stream, partial, run, signal);
521
+ } catch (error) {
522
+ if (error instanceof CursorAbortError) await disposeCursorNativeRun(run);
523
+ throw error;
524
+ }
488
525
  return true;
489
526
  }
490
527
 
@@ -498,6 +535,7 @@ export function streamCursor(
498
535
  (async () => {
499
536
  const partial = makeInitialMessage(model);
500
537
  let agent: SDKAgent | null = null;
538
+ let activeNativeRun: CursorNativeLiveRun | undefined;
501
539
  let resolvedApiKey: string | undefined;
502
540
  let abortSignal: AbortSignal | undefined;
503
541
  let abortListener: (() => void) | undefined;
@@ -551,14 +589,21 @@ export function streamCursor(
551
589
  promptInputTokensReported: false,
552
590
  pendingEvents: [],
553
591
  textDeltas,
592
+ recordedToolDisplayIds: [],
554
593
  done: false,
555
594
  cancelled: false,
595
+ disposed: false,
556
596
  waiters: new Set(),
557
597
  }
558
598
  : undefined;
559
- if (liveRun) pendingCursorNativeRuns.set(liveRun.id, liveRun);
599
+ if (liveRun) {
600
+ pendingCursorNativeRuns.set(liveRun.id, liveRun);
601
+ activeNativeRun = liveRun;
602
+ }
560
603
  const startedToolCalls = new Map<string, unknown>();
561
- const completedToolFingerprints = new Set<string>();
604
+ const completedToolIdentities = new Set<string>();
605
+ const completedStartedToolFingerprints = new Set<string>();
606
+ const completedFallbackToolFingerprints = new Set<string>();
562
607
 
563
608
  const appendLiveTextDelta = (text: string): void => {
564
609
  if (textContentIndex < 0) {
@@ -652,12 +697,25 @@ export function streamCursor(
652
697
  }
653
698
  };
654
699
 
655
- const handleCompletedToolCall = (toolCall: unknown): void => {
700
+ const handleCompletedToolCall = (
701
+ toolCall: unknown,
702
+ options: { identity?: string; source?: "started" | "fallback" } = {},
703
+ ): void => {
656
704
  const transcript = scrubSensitiveText(formatCursorToolTranscript(toolCall, { cwd }), resolvedApiKey);
657
705
  const display = buildCursorPiToolDisplay(toolCall, { cwd });
658
706
  const fingerprint = getToolFingerprint({ toolName: display.toolName, args: display.args, result: display.result });
659
- if (completedToolFingerprints.has(fingerprint)) return;
660
- completedToolFingerprints.add(fingerprint);
707
+ if (options.identity && completedToolIdentities.has(options.identity)) return;
708
+ if (options.source === "started") {
709
+ if (completedFallbackToolFingerprints.has(fingerprint)) return;
710
+ } else if (completedStartedToolFingerprints.has(fingerprint) || completedFallbackToolFingerprints.has(fingerprint)) {
711
+ return;
712
+ }
713
+ if (options.identity) completedToolIdentities.add(options.identity);
714
+ if (options.source === "started") {
715
+ completedStartedToolFingerprints.add(fingerprint);
716
+ } else {
717
+ completedFallbackToolFingerprints.add(fingerprint);
718
+ }
661
719
 
662
720
  if (useNativeToolReplay && canRenderCursorToolNatively(display.toolName) && liveRun) {
663
721
  nativeToolReplayStarted = true;
@@ -704,7 +762,11 @@ export function streamCursor(
704
762
  } else if (update.type === "tool-call-completed") {
705
763
  const mergedToolCall = mergeCursorToolCalls(startedToolCalls.get(update.callId), update.toolCall);
706
764
  startedToolCalls.delete(update.callId);
707
- handleCompletedToolCall(mergedToolCall);
765
+ const identity = typeof update.callId === "string" ? `cursor-tool:${update.callId}` : undefined;
766
+ handleCompletedToolCall(mergedToolCall, {
767
+ identity,
768
+ source: identity ? "started" : "fallback",
769
+ });
708
770
  } else if (update.type === "summary") {
709
771
  const summary = `Cursor summary: ${truncateSingleLine(update.summary)}\n`;
710
772
  if (liveRun && nativeToolReplayStarted) {
@@ -723,7 +785,12 @@ export function streamCursor(
723
785
  const step = getObjectField(args.step, "message") ? args.step : undefined;
724
786
  if (getObjectField(args.step, "type") !== "toolCall") return;
725
787
  const toolCall = getObjectField(step, "message");
726
- if (toolCall) handleCompletedToolCall(toolCall);
788
+ const stepId = getObjectField(args.step, "id") ?? getObjectField(toolCall, "id") ?? getObjectField(toolCall, "callId");
789
+ if (toolCall) {
790
+ handleCompletedToolCall(toolCall, {
791
+ identity: typeof stepId === "string" ? `cursor-tool:${stepId}` : undefined,
792
+ });
793
+ }
727
794
  };
728
795
 
729
796
  // Handle abort signal
@@ -750,21 +817,31 @@ export function streamCursor(
750
817
  void run
751
818
  .wait()
752
819
  .then(async (result) => {
820
+ if (liveRun.disposed) return;
753
821
  await cacheSdkContextWindow(liveRun.agent.agentId, model.id);
822
+ if (liveRun.disposed) return;
754
823
  liveRun.cancelled = result.status === "cancelled";
755
824
  liveRun.finalText = hasUsableText(result.result) ? result.result : liveRun.textDeltas.join("");
756
825
  liveRun.done = true;
757
826
  notifyCursorNativeRun(liveRun);
827
+ scheduleCursorNativeRunIdleDispose(liveRun);
758
828
  })
759
829
  .catch((error: unknown) => {
830
+ if (liveRun.disposed) return;
760
831
  liveRun.errorMessage = sanitizeError(error, resolvedApiKey ?? options?.apiKey);
761
832
  notifyCursorNativeRun(liveRun);
833
+ scheduleCursorNativeRunIdleDispose(liveRun);
762
834
  });
763
835
 
764
- await waitForCursorNativeRunProgress(liveRun, options?.signal);
765
- await settleCursorNativeToolBatch(liveRun);
766
- closeTraceBlock();
767
- await emitCursorNativeRunNextTurn(stream, partial, liveRun, options?.signal);
836
+ try {
837
+ await waitForCursorNativeRunProgress(liveRun, options?.signal);
838
+ await settleCursorNativeToolBatch(liveRun);
839
+ closeTraceBlock();
840
+ await emitCursorNativeRunNextTurn(stream, partial, liveRun, options?.signal);
841
+ } catch (error) {
842
+ if (error instanceof CursorAbortError) await disposeCursorNativeRun(liveRun);
843
+ throw error;
844
+ }
768
845
  agent = null;
769
846
  return;
770
847
  }
@@ -794,6 +871,8 @@ export function streamCursor(
794
871
  stream.push({ type: "error", reason: "error", error: partial });
795
872
  }
796
873
  } finally {
874
+ if (activeNativeRun?.disposed) agent = null;
875
+
797
876
  if (abortSignal && abortListener) {
798
877
  abortSignal.removeEventListener("abort", abortListener);
799
878
  }
@@ -813,3 +892,14 @@ export function streamCursor(
813
892
 
814
893
  return stream;
815
894
  }
895
+
896
+ export const __testUtils = {
897
+ DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS,
898
+ pendingCursorNativeRunCount: () => pendingCursorNativeRuns.size,
899
+ setCursorNativeReplayIdleDisposeMs: (value: number) => {
900
+ cursorNativeReplayIdleDisposeMs = value;
901
+ },
902
+ resetCursorNativeReplayIdleDisposeMs: () => {
903
+ cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
904
+ },
905
+ };
@@ -105,16 +105,25 @@ function restoreMapValue(map: Map<string, boolean>, key: string, previous: boole
105
105
  function persistFastPreference(pi: ExtensionAPI, baseModelId: string, fast: boolean): void {
106
106
  const previousSession = sessionFastPreferences.get(baseModelId);
107
107
  const previousGlobal = globalFastPreferences.get(baseModelId);
108
+ let savedGlobal = false;
108
109
  sessionFastPreferences.set(baseModelId, fast);
109
110
  globalFastPreferences.set(baseModelId, fast);
110
111
  try {
111
112
  saveGlobalFastPreferences();
113
+ savedGlobal = true;
114
+ pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
112
115
  } catch (error) {
113
116
  restoreMapValue(sessionFastPreferences, baseModelId, previousSession);
114
117
  restoreMapValue(globalFastPreferences, baseModelId, previousGlobal);
118
+ if (savedGlobal) {
119
+ try {
120
+ saveGlobalFastPreferences();
121
+ } catch {
122
+ // Preserve the original append failure reported to the user.
123
+ }
124
+ }
115
125
  throw error;
116
126
  }
117
- pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
118
127
  }
119
128
 
120
129
  export function getEffectiveFastForModelId(modelId: string): boolean | undefined {
@@ -281,15 +281,18 @@ function renderTreeNode(node: unknown, depth = 0, lines: string[] = []): string[
281
281
  return lines;
282
282
  }
283
283
 
284
- function formatLs(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
285
- const path = formatPathArg(args, options) ?? ".";
286
- if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
287
-
284
+ function getLsBody(result: NormalizedResult, options: TranscriptOptions): string {
288
285
  const value = asRecord(result.value);
289
286
  const root = value?.directoryTreeRoot ?? result.value;
290
287
  const treeLines = renderTreeNode(root);
291
288
  const body = treeLines.length > 0 ? treeLines.join("\n") : stringifyUnknown(result.value);
292
- return joinSections(`ls ${path}`, limitText(body, options));
289
+ return limitText(body, options);
290
+ }
291
+
292
+ function formatLs(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
293
+ const path = formatPathArg(args, options) ?? ".";
294
+ if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
295
+ return joinSections(`ls ${path}`, getLsBody(result, options));
293
296
  }
294
297
 
295
298
  function formatGlob(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
@@ -528,7 +531,7 @@ export function buildCursorPiToolDisplay(toolCall: unknown, options: TranscriptO
528
531
  return {
529
532
  toolName: "ls",
530
533
  args,
531
- result: textToolResult(result.status === "error" ? formatError(result.error) : formatLs(args, result, options).split("\n\n").slice(1).join("\n\n").trim()),
534
+ result: textToolResult(result.status === "error" ? formatError(result.error) : getLsBody(result, options).trim()),
532
535
  isError: result.status === "error",
533
536
  };
534
537
  }
@@ -7,7 +7,7 @@ import type {
7
7
  } from "@cursor/sdk";
8
8
  import { AuthStorage, type ProviderModelConfig } from "@earendil-works/pi-coding-agent";
9
9
  import type { ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
10
- import { getCachedContextWindow } from "./context-window-cache.js";
10
+ import { loadContextWindowCache } from "./context-window-cache.js";
11
11
 
12
12
  const CURSOR_PROVIDER_ID = "cursor";
13
13
  const CURSOR_API_KEY_ENV_VAR = "CURSOR_API_KEY";
@@ -88,7 +88,7 @@ const FALLBACK_MODEL_ITEMS: ModelListItem[] = [
88
88
  {
89
89
  id: "context",
90
90
  displayName: "Context",
91
- values: [{ value: "1m" }, { value: "300k" }],
91
+ values: [{ value: "1m" }, { value: "200k" }],
92
92
  },
93
93
  {
94
94
  id: "effort",
@@ -165,6 +165,7 @@ export type CursorModelFallbackReason = "missing-api-key" | "discovery-failed" |
165
165
  export interface CursorModelFallbackIssue {
166
166
  reason: CursorModelFallbackReason;
167
167
  message: string;
168
+ errorMessage?: string;
168
169
  }
169
170
 
170
171
  export interface DiscoverModelsOptions {
@@ -217,6 +218,7 @@ async function getDiscoveryApiKey(): Promise<string | undefined> {
217
218
  export interface CursorModelMetadata {
218
219
  piModelId: string;
219
220
  baseModelId: string;
221
+ selectionModelId: string;
220
222
  displayName: string;
221
223
  defaultParams: ModelParameterValue[];
222
224
  context?: string;
@@ -340,35 +342,44 @@ function getParamValue(params: ModelParameterValue[], id: string): string | unde
340
342
  return params.find((param) => param.id === id)?.value;
341
343
  }
342
344
 
343
- function encodePiModelId(baseModelId: string, context?: string): string {
344
- return context ? `${baseModelId}@${context}` : baseModelId;
345
+ function encodePiModelId(modelId: string, context?: string): string {
346
+ return context ? `${modelId}@${context}` : modelId;
345
347
  }
346
348
 
347
- function getModelName(item: ModelListItem, context?: string): string {
349
+ function getModelName(item: ModelListItem, context?: string, alias?: string): string {
348
350
  const displayName = item.displayName || item.id;
349
- return context ? `${displayName} @ ${context}` : displayName;
351
+ const baseName = alias ? `${displayName} (${alias})` : displayName;
352
+ return context ? `${baseName} @ ${context}` : baseName;
350
353
  }
351
354
 
352
- function getContextWindow(piModelId: string, context?: string): number {
353
- if (context) return parseContextWindow(context) ?? FALLBACK_CONTEXT_WINDOW;
354
- return getCachedContextWindow(piModelId) ?? FALLBACK_CONTEXT_WINDOW;
355
+ function getContextWindow(contextWindowCache: Map<string, number>, piModelId: string, context?: string, baseModelId?: string): number {
356
+ return (
357
+ contextWindowCache.get(piModelId) ??
358
+ (context ? parseContextWindow(context) : undefined) ??
359
+ (baseModelId ? contextWindowCache.get(baseModelId) : undefined) ??
360
+ contextWindowCache.get("default") ??
361
+ FALLBACK_CONTEXT_WINDOW
362
+ );
355
363
  }
356
364
 
357
365
  function toMetadata(
358
366
  item: ModelListItem,
359
367
  piModelId: string,
368
+ selectionModelId: string,
360
369
  defaultParams: ModelParameterValue[],
361
370
  context: string | undefined,
371
+ contextWindowCache: Map<string, number>,
362
372
  ): CursorModelMetadata {
363
373
  const thinkingLevelMap = getThinkingLevelMap(item);
364
374
  const fastValue = getParamValue(defaultParams, "fast")?.toLowerCase();
365
375
  return {
366
376
  piModelId,
367
377
  baseModelId: item.id,
378
+ selectionModelId,
368
379
  displayName: item.displayName || item.id,
369
380
  defaultParams: cloneParams(defaultParams),
370
381
  ...(context ? { context } : {}),
371
- contextWindow: getContextWindow(piModelId, context),
382
+ contextWindow: getContextWindow(contextWindowCache, piModelId, context, item.id),
372
383
  supportsFast: getParameter(item, "fast") !== undefined,
373
384
  defaultFast: fastValue === "true",
374
385
  supportsReasoning: thinkingLevelMap !== undefined,
@@ -400,18 +411,56 @@ function getContextValues(item: ModelListItem): string[] {
400
411
  return getParameter(item, "context")?.values.map((value) => value.value) ?? [];
401
412
  }
402
413
 
403
- function toModelConfigs(item: ModelListItem): ProviderModelConfig[] {
414
+ function getAmbiguousAliases(items: ModelListItem[]): Set<string> {
415
+ const aliasOwners = new Map<string, Set<string>>();
416
+ for (const item of items) {
417
+ for (const rawAlias of item.aliases ?? []) {
418
+ const alias = rawAlias.trim();
419
+ if (!alias || alias === item.id) continue;
420
+ const owners = aliasOwners.get(alias) ?? new Set<string>();
421
+ owners.add(item.id);
422
+ aliasOwners.set(alias, owners);
423
+ }
424
+ }
425
+ return new Set([...aliasOwners.entries()].filter(([, owners]) => owners.size > 1).map(([alias]) => alias));
426
+ }
427
+
428
+ function getModelIds(item: ModelListItem, reservedBaseModelIds: Set<string>, ambiguousAliases: Set<string>): string[] {
429
+ const ids = [item.id];
430
+ for (const rawAlias of item.aliases ?? []) {
431
+ const alias = rawAlias.trim();
432
+ if (!alias || alias === item.id || ids.includes(alias) || reservedBaseModelIds.has(alias) || ambiguousAliases.has(alias)) continue;
433
+ ids.push(alias);
434
+ }
435
+ return ids;
436
+ }
437
+
438
+ function toModelConfigs(
439
+ item: ModelListItem,
440
+ usedPiModelIds: Set<string>,
441
+ reservedBaseModelIds: Set<string>,
442
+ ambiguousAliases: Set<string>,
443
+ contextWindowCache: Map<string, number>,
444
+ ): ProviderModelConfig[] {
404
445
  const defaultParams = getDefaultParams(item);
405
446
  const contextValues = getContextValues(item);
406
447
  const contexts = contextValues.length > 0 ? contextValues : [undefined];
448
+ const configs: ProviderModelConfig[] = [];
449
+
450
+ for (const selectionModelId of getModelIds(item, reservedBaseModelIds, ambiguousAliases)) {
451
+ const alias = selectionModelId === item.id ? undefined : selectionModelId;
452
+ for (const context of contexts) {
453
+ const params = context ? replaceParam(defaultParams, "context", context) : defaultParams;
454
+ const piModelId = encodePiModelId(selectionModelId, context);
455
+ if (usedPiModelIds.has(piModelId)) continue;
456
+ usedPiModelIds.add(piModelId);
457
+ const metadata = toMetadata(item, piModelId, selectionModelId, params, context, contextWindowCache);
458
+ metadataByPiModelId.set(piModelId, metadata);
459
+ configs.push(toModelConfig(metadata, getModelName(item, context, alias)));
460
+ }
461
+ }
407
462
 
408
- return contexts.map((context) => {
409
- const params = context ? replaceParam(defaultParams, "context", context) : defaultParams;
410
- const piModelId = encodePiModelId(item.id, context);
411
- const metadata = toMetadata(item, piModelId, params, context);
412
- metadataByPiModelId.set(piModelId, metadata);
413
- return toModelConfig(metadata, getModelName(item, context));
414
- });
463
+ return configs;
415
464
  }
416
465
 
417
466
  function sortModelsByBaseId(items: ModelListItem[]): ModelListItem[] {
@@ -420,7 +469,11 @@ function sortModelsByBaseId(items: ModelListItem[]): ModelListItem[] {
420
469
 
421
470
  function registerModelItems(items: ModelListItem[]): ProviderModelConfig[] {
422
471
  metadataByPiModelId.clear();
423
- return sortModelsByBaseId(items).flatMap(toModelConfigs);
472
+ const usedPiModelIds = new Set<string>();
473
+ const reservedBaseModelIds = new Set(items.map((item) => item.id));
474
+ const ambiguousAliases = getAmbiguousAliases(items);
475
+ const contextWindowCache = loadContextWindowCache();
476
+ return sortModelsByBaseId(items).flatMap((item) => toModelConfigs(item, usedPiModelIds, reservedBaseModelIds, ambiguousAliases, contextWindowCache));
424
477
  }
425
478
 
426
479
  export function getCursorModelMetadata(modelId: string): CursorModelMetadata | undefined {
@@ -501,7 +554,25 @@ export function buildCursorModelSelection(
501
554
  setParam(params, "fast", fastEnabled ? "true" : "false");
502
555
  }
503
556
 
504
- return params.length > 0 ? { id: metadata.baseModelId, params } : { id: metadata.baseModelId };
557
+ return params.length > 0 ? { id: metadata.selectionModelId, params } : { id: metadata.selectionModelId };
558
+ }
559
+
560
+ function scrubDiscoveryErrorText(text: string, apiKey: string): string {
561
+ let scrubbed = text.replace(new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "[redacted]");
562
+ return scrubbed
563
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
564
+ .replace(/((?:^|[\s,{])cookie["']?\s*[:=]\s*["']?)[^\n]+/gi, "$1[redacted]")
565
+ .replace(
566
+ /((?:authorization|api[_-]?key|apiKey|token|session(?:[_-]?id)?)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/gi,
567
+ "$1[redacted]",
568
+ )
569
+ .trim();
570
+ }
571
+
572
+ function sanitizeDiscoveryError(error: unknown, apiKey: string): string | undefined {
573
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
574
+ const scrubbed = scrubDiscoveryErrorText(message, apiKey);
575
+ return scrubbed || undefined;
505
576
  }
506
577
 
507
578
  function useFallbackModels(options: DiscoverModelsOptions, issue: CursorModelFallbackIssue): ProviderModelConfig[] {
@@ -527,10 +598,12 @@ export async function discoverModels(options: DiscoverModelsOptions = {}): Promi
527
598
  reason: "empty-model-list",
528
599
  message: `Cursor model discovery returned no models. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
529
600
  });
530
- } catch {
601
+ } catch (error) {
602
+ const errorMessage = sanitizeDiscoveryError(error, apiKey);
531
603
  return useFallbackModels(options, {
532
604
  reason: "discovery-failed",
533
- message: `Cursor model discovery failed. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
605
+ message: `Cursor model discovery failed${errorMessage ? `: ${errorMessage}` : ""}. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
606
+ ...(errorMessage ? { errorMessage } : {}),
534
607
  });
535
608
  }
536
609
  }