pi-cursor-sdk 0.1.8 → 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,5 +1,23 @@
1
1
  # Changelog
2
2
 
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
+
3
21
  ## 0.1.8 - 2026-05-14
4
22
 
5
23
  ### Changed
package/README.md CHANGED
@@ -137,7 +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
- - latest-style Cursor aliases returned by `Cursor.models.list()` are registered too, using the same context suffixes when the target model has context variants
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
141
141
 
142
142
  Examples with pi thinking controls:
143
143
 
@@ -203,7 +203,7 @@ If no key is available from `/login`, `CURSOR_API_KEY`, or `--api-key`, model di
203
203
 
204
204
  - `composer-2`
205
205
  - `gpt-5.5@1m`, `gpt-5.5@272k`
206
- - `claude-sonnet-4-6@1m`, `claude-sonnet-4-6@300k`
206
+ - `claude-sonnet-4-6@1m`, `claude-sonnet-4-6@200k`
207
207
  - `claude-opus-4-7@1m`, `claude-opus-4-7@300k`
208
208
 
209
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.
@@ -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.
@@ -21,7 +22,7 @@ Current implementation notes:
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
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.
24
- - `@cursor/sdk` 1.0.13 adds latest-style `ModelListItem.aliases`. The extension registers those 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`.
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.
25
26
 
26
27
  ## Goal
27
28
 
@@ -137,8 +138,9 @@ Register a `cursor` provider with `pi.registerProvider()`.
137
138
 
138
139
  Rules:
139
140
 
140
- - Register one pi model for each Cursor base model and SDK alias when there is no Cursor `context` parameter.
141
- - Register one pi model per Cursor `context` value for each Cursor base model and SDK alias 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.
142
144
  - Do not encode `reasoning`, `effort`, `thinking`, or `fast` into pi model IDs.
143
145
  - Prefer stable, readable `@<context>` suffixes that do not conflict with pi's final `:<thinking>` suffix parser.
144
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.
@@ -486,42 +488,22 @@ Fast flag example:
486
488
  pi --model cursor/gpt-5.5@1m --cursor-fast -p "Say ok only"
487
489
  ```
488
490
 
489
- ## Current Discovered Model Capability Examples
491
+ ## Discovered Model Capability Examples
490
492
 
491
- 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.
492
494
 
493
- | Model | Cursor controls | Pi representation |
495
+ | Example model shape | Cursor controls | Pi representation |
494
496
  |---|---|---|
495
- | `default` | none | plain model |
496
- | `composer-2` | fast | plain model + fast extension state |
497
- | `composer-1.5` | none | plain model |
498
- | `gpt-5.5` | context, reasoning, fast | context variants + native thinking + fast state |
499
- | `gpt-5.4` | context, reasoning, fast | context variants + native thinking + fast state |
500
- | `gpt-5.4-mini` | reasoning | plain model + native thinking |
501
- | `gpt-5.4-nano` | reasoning | plain model + native thinking |
502
- | `gpt-5.3-codex` | reasoning, fast | plain model + native thinking + fast state |
503
- | `gpt-5.3-codex-spark` | reasoning | plain model + native thinking |
504
- | `gpt-5.2` | reasoning, fast | plain model + native thinking + fast state |
505
- | `gpt-5.2-codex` | reasoning, fast | plain model + native thinking + fast state |
506
- | `gpt-5.1-codex-max` | reasoning, fast | plain model + native thinking + fast state |
507
- | `gpt-5.1-codex-mini` | reasoning | plain model + native thinking |
508
- | `gpt-5.1` | reasoning | plain model + native thinking |
509
- | `claude-opus-4-7` | thinking, context, effort | context variants + native thinking |
510
- | `claude-opus-4-6` | thinking, context, effort, fast | context variants + native thinking + fast state |
511
- | `claude-opus-4-5` | thinking | plain model + native thinking |
512
- | `claude-sonnet-4-6` | thinking, context, effort | context variants + native thinking |
513
- | `claude-sonnet-4-5` | thinking, context | context-qualified model + native thinking |
514
- | `claude-sonnet-4` | thinking, context | context-qualified model + native thinking |
515
- | `claude-haiku-4-5` | thinking | plain model + native thinking |
516
- | `grok-4.3` | context | context variants |
517
- | `grok-4-20` | thinking | plain model + native thinking |
518
- | `gemini-3.1-pro` | none | plain model |
519
- | `gemini-3-flash` | none | plain model |
520
- | `gemini-2.5-flash` | none | plain model |
521
- | `gpt-5-mini` | none | plain model |
522
- | `kimi-k2.5` | none | plain model |
523
-
524
- 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.
525
507
 
526
508
  ## Detailed Examples
527
509
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.8",
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",
@@ -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;
@@ -80,4 +82,8 @@ export function saveCachedContextWindow(modelId: string, contextWindow: number):
80
82
 
81
83
  export const __testUtils = {
82
84
  getCachePath,
85
+ getUserContextWindowOverrideLoadCount: () => userContextWindowOverrideLoadCount,
86
+ resetUserContextWindowOverrideLoadCount: () => {
87
+ userContextWindowOverrideLoadCount = 0;
88
+ },
83
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, getCachedContextWindowExact } 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 {
@@ -351,9 +352,14 @@ function getModelName(item: ModelListItem, context?: string, alias?: string): st
351
352
  return context ? `${baseName} @ ${context}` : baseName;
352
353
  }
353
354
 
354
- function getContextWindow(piModelId: string, context?: string, baseModelId?: string): number {
355
- if (context) return parseContextWindow(context) ?? FALLBACK_CONTEXT_WINDOW;
356
- return getCachedContextWindowExact(piModelId) ?? (baseModelId ? getCachedContextWindow(baseModelId) : undefined) ?? 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
+ );
357
363
  }
358
364
 
359
365
  function toMetadata(
@@ -362,6 +368,7 @@ function toMetadata(
362
368
  selectionModelId: string,
363
369
  defaultParams: ModelParameterValue[],
364
370
  context: string | undefined,
371
+ contextWindowCache: Map<string, number>,
365
372
  ): CursorModelMetadata {
366
373
  const thinkingLevelMap = getThinkingLevelMap(item);
367
374
  const fastValue = getParamValue(defaultParams, "fast")?.toLowerCase();
@@ -372,7 +379,7 @@ function toMetadata(
372
379
  displayName: item.displayName || item.id,
373
380
  defaultParams: cloneParams(defaultParams),
374
381
  ...(context ? { context } : {}),
375
- contextWindow: getContextWindow(piModelId, context, item.id),
382
+ contextWindow: getContextWindow(contextWindowCache, piModelId, context, item.id),
376
383
  supportsFast: getParameter(item, "fast") !== undefined,
377
384
  defaultFast: fastValue === "true",
378
385
  supportsReasoning: thinkingLevelMap !== undefined,
@@ -404,11 +411,25 @@ function getContextValues(item: ModelListItem): string[] {
404
411
  return getParameter(item, "context")?.values.map((value) => value.value) ?? [];
405
412
  }
406
413
 
407
- function getModelIds(item: ModelListItem, reservedBaseModelIds: Set<string>): string[] {
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[] {
408
429
  const ids = [item.id];
409
430
  for (const rawAlias of item.aliases ?? []) {
410
431
  const alias = rawAlias.trim();
411
- if (!alias || alias === item.id || ids.includes(alias) || reservedBaseModelIds.has(alias)) continue;
432
+ if (!alias || alias === item.id || ids.includes(alias) || reservedBaseModelIds.has(alias) || ambiguousAliases.has(alias)) continue;
412
433
  ids.push(alias);
413
434
  }
414
435
  return ids;
@@ -418,20 +439,22 @@ function toModelConfigs(
418
439
  item: ModelListItem,
419
440
  usedPiModelIds: Set<string>,
420
441
  reservedBaseModelIds: Set<string>,
442
+ ambiguousAliases: Set<string>,
443
+ contextWindowCache: Map<string, number>,
421
444
  ): ProviderModelConfig[] {
422
445
  const defaultParams = getDefaultParams(item);
423
446
  const contextValues = getContextValues(item);
424
447
  const contexts = contextValues.length > 0 ? contextValues : [undefined];
425
448
  const configs: ProviderModelConfig[] = [];
426
449
 
427
- for (const selectionModelId of getModelIds(item, reservedBaseModelIds)) {
450
+ for (const selectionModelId of getModelIds(item, reservedBaseModelIds, ambiguousAliases)) {
428
451
  const alias = selectionModelId === item.id ? undefined : selectionModelId;
429
452
  for (const context of contexts) {
430
453
  const params = context ? replaceParam(defaultParams, "context", context) : defaultParams;
431
454
  const piModelId = encodePiModelId(selectionModelId, context);
432
455
  if (usedPiModelIds.has(piModelId)) continue;
433
456
  usedPiModelIds.add(piModelId);
434
- const metadata = toMetadata(item, piModelId, selectionModelId, params, context);
457
+ const metadata = toMetadata(item, piModelId, selectionModelId, params, context, contextWindowCache);
435
458
  metadataByPiModelId.set(piModelId, metadata);
436
459
  configs.push(toModelConfig(metadata, getModelName(item, context, alias)));
437
460
  }
@@ -448,7 +471,9 @@ function registerModelItems(items: ModelListItem[]): ProviderModelConfig[] {
448
471
  metadataByPiModelId.clear();
449
472
  const usedPiModelIds = new Set<string>();
450
473
  const reservedBaseModelIds = new Set(items.map((item) => item.id));
451
- return sortModelsByBaseId(items).flatMap((item) => toModelConfigs(item, usedPiModelIds, reservedBaseModelIds));
474
+ const ambiguousAliases = getAmbiguousAliases(items);
475
+ const contextWindowCache = loadContextWindowCache();
476
+ return sortModelsByBaseId(items).flatMap((item) => toModelConfigs(item, usedPiModelIds, reservedBaseModelIds, ambiguousAliases, contextWindowCache));
452
477
  }
453
478
 
454
479
  export function getCursorModelMetadata(modelId: string): CursorModelMetadata | undefined {
@@ -532,6 +557,24 @@ export function buildCursorModelSelection(
532
557
  return params.length > 0 ? { id: metadata.selectionModelId, params } : { id: metadata.selectionModelId };
533
558
  }
534
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;
576
+ }
577
+
535
578
  function useFallbackModels(options: DiscoverModelsOptions, issue: CursorModelFallbackIssue): ProviderModelConfig[] {
536
579
  options.onFallback?.(issue);
537
580
  return registerModelItems(FALLBACK_MODEL_ITEMS);
@@ -555,10 +598,12 @@ export async function discoverModels(options: DiscoverModelsOptions = {}): Promi
555
598
  reason: "empty-model-list",
556
599
  message: `Cursor model discovery returned no models. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
557
600
  });
558
- } catch {
601
+ } catch (error) {
602
+ const errorMessage = sanitizeDiscoveryError(error, apiKey);
559
603
  return useFallbackModels(options, {
560
604
  reason: "discovery-failed",
561
- 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 } : {}),
562
607
  });
563
608
  }
564
609
  }