pi-cursor-sdk 0.1.30 → 0.1.32

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
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.32 - 2026-06-02
6
+
7
+ ### Added
8
+
9
+ - Add a production typing-safety regression test that blocks broad TypeScript escape hatches such as `as unknown as`, `as any`, `as never`, explicit `any`, and production `@ts-ignore` / `@ts-expect-error` usage.
10
+
11
+ ### Changed
12
+
13
+ - Replace repeated native replay render-test `as never` casts with typed test render fixture helpers.
14
+ - Use the maintained Homebrew Crabbox binary on `PATH` for platform smoke with a `0.24.0` minimum version, keeping `PLATFORM_SMOKE_CRABBOX` as an explicit override only.
15
+
16
+ ### Fixed
17
+
18
+ - Harden local Cursor cache/config JSON parsing so model-list, context-window, and fast-default files are validated from `unknown` before trusted values are used.
19
+
20
+ ## 0.1.31 - 2026-06-01
21
+
22
+ ### Added
23
+
24
+ - Add Cursor `:fast` and `:slow` virtual model aliases for models with a Cursor SDK `fast` parameter so subagents and workflow-spawned agents can choose fast/slow independently of saved `/cursor-fast` defaults (#112).
25
+
5
26
  ## 0.1.30 - 2026-06-01
6
27
 
7
28
  ### Added
package/README.md CHANGED
@@ -168,7 +168,7 @@ pi --model cursor/gpt-5.5@272k:xhigh
168
168
  pi --model cursor/gpt-5.5@1m --thinking medium
169
169
  ```
170
170
 
171
- 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` and Cursor SDK conversation mode are extension state, not model identity. Alias model IDs use their selected SDK ID for Cursor-only state such as fast defaults, with read fallback for older defaults keyed by the underlying Cursor base model.
171
+ Cursor `context` becomes a pi-visible model variant because it changes pi's native `contextWindow`. For models that expose Cursor's boolean `fast` parameter, the extension also registers virtual `:fast` and `:slow` model aliases such as `cursor/composer-2-5:slow` and `cursor/gpt-5.5@1m:fast`. Those aliases are selection-only controls for subagents and workflow-spawned agents: they send the same Cursor SDK model ID plus an explicit `fast=true` or `fast=false` param, and they take precedence over saved `/cursor-fast` session/global defaults. Cursor SDK conversation mode remains extension state, not model identity. Alias model IDs use their selected SDK ID for Cursor-only state such as fast defaults, with read fallback for older defaults keyed by the underlying Cursor base model.
172
172
 
173
173
  ## Thinking support
174
174
 
@@ -190,7 +190,7 @@ Some Cursor SDK models do not expose a `reasoning`, `effort`, or `thinking` para
190
190
 
191
191
  ## Fast mode
192
192
 
193
- Use `/cursor-fast` to persistently toggle fast mode for the selected Cursor model when the model supports Cursor's `fast` parameter.
193
+ Use `/cursor-fast` to persistently toggle fast mode for the selected unsuffixed Cursor model when the model supports Cursor's `fast` parameter.
194
194
 
195
195
  Fast preferences are remembered per selected Cursor SDK model ID or alias and stored:
196
196
 
@@ -204,7 +204,16 @@ pi --model cursor/gpt-5.5@1m --cursor-fast -p "Say ok only"
204
204
  pi --model cursor/composer-2-5 --cursor-no-fast -p "Say ok only"
205
205
  ```
206
206
 
207
- Composer 2 and Composer 2.5 can default to fast. Use `--cursor-no-fast` for a one-shot no-fast Composer run. In print mode (`-p`), `--cursor-no-fast` is silent and does not write `~/.pi/agent/cursor-sdk.json`.
207
+ For per-agent control, select the virtual model alias instead of mutating the shared saved default:
208
+
209
+ ```bash
210
+ pi --model cursor/composer-2-5:slow -p "Say ok only"
211
+ pi --model cursor/gpt-5.5@1m:fast -p "Say ok only"
212
+ ```
213
+
214
+ The `:fast` and `:slow` aliases are available only for Cursor models whose catalog exposes a `fast` parameter. They override saved `/cursor-fast` session/global defaults while leaving `--cursor-fast` and `--cursor-no-fast` as explicit process-level force flags. `/cursor-fast` does not persist a new default while a virtual fast/slow alias is selected; switch to the unsuffixed model first.
215
+
216
+ Composer 2 and Composer 2.5 can default to fast. Use `--cursor-no-fast` or a `:slow` virtual alias for a one-shot no-fast Composer run. In print mode (`-p`), `--cursor-no-fast` is silent and does not write `~/.pi/agent/cursor-sdk.json`.
208
217
 
209
218
  In interactive mode, the footer only shows fast mode when fast is enabled and Cursor mode when it is non-default. Fast and plan mode share one Cursor status value, so they do not overwrite each other:
210
219
 
@@ -218,7 +227,7 @@ If you do not see `cursor fast`, fast mode is off. If you do not see `cursor pla
218
227
 
219
228
  ## Cursor SDK mode
220
229
 
221
- Cursor SDK conversation mode is Cursor-only extension state. It is not a pi model variant, not pi thinking/reasoning, not Cursor `fast`, and not pi's separate read-only plan-mode extension.
230
+ Cursor SDK conversation mode is Cursor-only extension state. It is not a pi model variant, not pi thinking/reasoning, not a `:fast`/`:slow` virtual fast alias, and not pi's separate read-only plan-mode extension.
222
231
 
223
232
  Default mode is `agent`. Start a one-shot run in a specific mode:
224
233
 
@@ -154,7 +154,7 @@ PLATFORM_SMOKE_WINDOWS_USER=<windows-ssh-user>
154
154
  PLATFORM_SMOKE_WINDOWS_WORK_ROOT=C:\crabbox\<project>
155
155
  ```
156
156
 
157
- Pin Crabbox deliberately. Exact pins are best for release-critical harnesses whose parsing depends on CLI output. Minimum version checks are fine for simpler gates. Current local baseline is Crabbox `0.24.0`.
157
+ Pin Crabbox deliberately. Exact pins are best for release-critical harnesses whose parsing depends on CLI output. Minimum version checks are fine for simpler gates. Current local baseline is Crabbox `0.24.0` or newer from the Homebrew package.
158
158
 
159
159
  ## Target setup best practices
160
160
 
@@ -10,7 +10,7 @@ Current implementation notes:
10
10
 
11
11
  - Cursor context variants use `base@context` pi model IDs.
12
12
  - Cursor `reasoning`, `effort`, and boolean `thinking` parameters are driven by pi native thinking when the Cursor SDK exposes those controls.
13
- - Cursor `fast` is extension state, not model identity.
13
+ - Cursor `fast` is extension state by default; models that expose `fast` also get selection-only `:fast` / `:slow` virtual aliases for per-agent overrides.
14
14
  - Cursor SDK `mode` (`agent` or `plan`) is extension session state, not model identity, pi thinking, Cursor `fast`, or pi's separate plan-mode extension.
15
15
  - Cursor status uses one coordinated `ctx.ui.setStatus("cursor", ...)` value for fast and non-default plan mode; the default pi footer remains intact.
16
16
  - Installed `@cursor/sdk` user messages accept images, and Cursor models are treated as image-capable; registered input metadata is `text` plus `image`.
@@ -136,7 +136,7 @@ Use native pi abstractions wherever possible:
136
136
  | Cursor `reasoning` | pi native thinking via `thinkingLevelMap` |
137
137
  | Cursor `effort` | pi native thinking via `thinkingLevelMap` |
138
138
  | Cursor `thinking=false` | pi native `off` |
139
- | Cursor `fast` | extension state, not model identity |
139
+ | Cursor `fast` | extension state plus `:fast` / `:slow` virtual aliases for per-agent overrides |
140
140
  | Cursor SDK `mode` | extension session state; `agent` by default, `plan` via SDK-native mode |
141
141
  | Footer | default pi footer plus optional extension status |
142
142
 
@@ -156,7 +156,7 @@ Rules:
156
156
  - Register one pi model for each Cursor base model and each unambiguous SDK alias when there is no Cursor `context` parameter.
157
157
  - 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.
158
158
  - 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.
159
- - Do not encode `reasoning`, `effort`, `thinking`, `fast`, or Cursor SDK `mode` into pi model IDs.
159
+ - Do not encode `reasoning`, `effort`, `thinking`, or Cursor SDK `mode` into pi model IDs. For models with a Cursor `fast` parameter, also register selection-only `:fast` and `:slow` virtual model aliases that do not change pi-native metadata.
160
160
  - Prefer stable, readable `@<context>` suffixes that do not conflict with pi's final `:<thinking>` suffix parser.
161
161
  - 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.
162
162
 
@@ -168,6 +168,9 @@ cursor/gpt-5.5@272k
168
168
  cursor/claude-opus-4-8@1m
169
169
  cursor/claude-opus-4-8@300k
170
170
  cursor/composer-2-5
171
+ cursor/composer-2-5:fast
172
+ cursor/composer-2-5:slow
173
+ cursor/gpt-5.5@1m:fast
171
174
  ```
172
175
 
173
176
  Avoid colon-based context IDs in the first implementation unless this spec is intentionally changed:
@@ -190,7 +193,7 @@ Reason:
190
193
 
191
194
  - `@1m` keeps context visually separate from pi's native `:medium` thinking suffix.
192
195
  - Context variants make `contextWindow` accurate in `--list-models`, the native footer, context overflow checks, and compaction logic.
193
- - `fast` is intentionally not a model variant because it does not affect pi model metadata and would double list noise.
196
+ - `:fast` / `:slow` are virtual aliases, not separate Cursor SDK base models: they keep the same context/thinking metadata and only force the outgoing Cursor `fast` param. They exist so subagents and workflow-spawned agents can choose fast/slow without mutating shared `/cursor-fast` defaults.
194
197
 
195
198
  ### Metadata Per Registered Model
196
199
 
@@ -362,17 +365,18 @@ fast=false <-> fast=true
362
365
 
363
366
  Rules:
364
367
 
365
- - `fast` is extension state, not pi model identity.
366
- - Toggle with `/cursor-fast`.
367
- - Store per-session and global per-base-model preferences.
368
- - When calling `Agent.create()`, include the selected `fast` value in Cursor model params.
368
+ - Unsuffixed models use extension state from `/cursor-fast`, per-session entries, and global defaults.
369
+ - `:fast` / `:slow` virtual model aliases force fast on/off for that selected agent and override saved defaults without writing state.
370
+ - Toggle unsuffixed models with `/cursor-fast`; do not persist a new default while a virtual fast alias is selected.
371
+ - Store per-session and global per-base-model preferences for unsuffixed models.
372
+ - When calling `Agent.create()` or `agent.send()`, include the selected `fast` value in Cursor model params.
369
373
  - Show `fast` through `ctx.ui.setStatus()` when enabled.
370
- - Support a first-pass CLI flag, `--cursor-fast`, to force fast mode for one run when the selected model supports it.
374
+ - Keep `--cursor-fast` and `--cursor-no-fast` as explicit process-level force flags.
371
375
 
372
376
  Reason:
373
377
 
374
378
  - `fast` does not affect pi `contextWindow`, thinking levels, or input support.
375
- - Registering fast/non-fast variants would make `--list-models` noisy without improving native pi behavior.
379
+ - The virtual aliases trade small `--list-models` noise for per-agent selection that works with subagents and dynamic workflows, where mutating a shared global fast default is the wrong abstraction.
376
380
 
377
381
  Status example:
378
382
 
@@ -521,7 +525,7 @@ Reason:
521
525
  - pi supports one final `:<thinking>` suffix.
522
526
  - Cursor-only parameters are not generic pi CLI parameters.
523
527
  - Context is already represented by the registered pi model ID.
524
- - `fast` is controlled by saved extension defaults or the first-pass `--cursor-fast` extension flag.
528
+ - `fast` is controlled by saved extension defaults, `:fast` / `:slow` virtual model aliases, or the `--cursor-fast` / `--cursor-no-fast` extension flags.
525
529
  - Cursor SDK `mode` is controlled by `/cursor-mode` session state or the first-pass `--cursor-mode` extension flag; it is never encoded in `--model`.
526
530
 
527
531
  For print mode:
@@ -529,7 +533,7 @@ For print mode:
529
533
  - no keybindings,
530
534
  - use selected context model variant,
531
535
  - use `--thinking` or `:medium` for reasoning/effort,
532
- - use saved global `fast` defaults unless `--cursor-fast` is present,
536
+ - use saved global `fast` defaults unless a virtual `:fast` / `:slow` model alias or force flag is present,
533
537
  - use Cursor SDK `agent` mode unless `/cursor-mode` session state or `--cursor-mode` overrides it.
534
538
 
535
539
  Fast flag example:
@@ -52,12 +52,12 @@ The runner uses one supported Crabbox build.
52
52
  Current baseline:
53
53
 
54
54
  ```text
55
- install: brew install crabbox
56
- version: 0.24.0
57
- binary: /opt/homebrew/bin/crabbox on Apple Silicon Homebrew installs
55
+ install: brew install openclaw/tap/crabbox
56
+ version: 0.24.0 or newer
57
+ binary: Homebrew `crabbox` on PATH (`/opt/homebrew/bin/crabbox` on Apple Silicon Homebrew installs)
58
58
  ```
59
59
 
60
- Keep this exact version or replace it with another exact released Crabbox version when updating the gate. `smoke:platform:doctor` verifies the configured Crabbox binary and fails on mismatch.
60
+ Use the Homebrew Crabbox binary on PATH for normal release gates. `PLATFORM_SMOKE_CRABBOX=/path/to/crabbox` is only an explicit override for testing a non-default binary. `smoke:platform:doctor` verifies the configured binary and fails when it is older than the configured minimum version.
61
61
 
62
62
  Required Crabbox providers:
63
63
 
@@ -188,8 +188,8 @@ export default {
188
188
  "cursor-abort-cleanup",
189
189
  ],
190
190
  requiredCrabbox: {
191
- install: "brew install crabbox",
192
- version: "0.24.0",
191
+ install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
192
+ minVersion: "0.24.0",
193
193
  },
194
194
  ubuntuContainerImage: "cimg/node:24.16",
195
195
  nodeValidationMajor: 24,
@@ -203,6 +203,7 @@ export default {
203
203
  The doctor fails if any required value is missing.
204
204
 
205
205
  ```bash
206
+ # Optional override; by default the gate uses Homebrew `crabbox` from PATH.
206
207
  PLATFORM_SMOKE_CRABBOX=/opt/homebrew/bin/crabbox
207
208
 
208
209
  PLATFORM_SMOKE_MAC_HOST=localhost
@@ -313,7 +314,7 @@ tar --version
313
314
  Doctor checks:
314
315
 
315
316
  1. Required env vars exist.
316
- 2. `PLATFORM_SMOKE_CRABBOX` exists and is executable.
317
+ 2. Homebrew `crabbox` is available on PATH, or `PLATFORM_SMOKE_CRABBOX` points at an executable override.
317
318
  3. Crabbox build matches the configured baseline.
318
319
  4. Crabbox provider registry includes `local-container`, `ssh`, and `parallels`.
319
320
  5. `crabbox doctor --provider local-container --json` passes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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",
@@ -13,8 +13,8 @@ export default {
13
13
  "cursor-abort-cleanup",
14
14
  ],
15
15
  requiredCrabbox: {
16
- install: "brew install crabbox",
17
- version: "0.24.0",
16
+ install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
17
+ minVersion: "0.24.0",
18
18
  },
19
19
  ubuntuContainerImage: "cimg/node:24.16",
20
20
  nodeValidationMajor: 24,
@@ -45,6 +45,10 @@ function shell(cmd, opts = {}) {
45
45
  catch { return null; }
46
46
  }
47
47
 
48
+ function commandPath(command) {
49
+ return shell(`command -v ${command}`);
50
+ }
51
+
48
52
  function parseLeaseId(output) {
49
53
  return output.match(/\bleased\s+(\S+)/)?.[1]
50
54
  ?? output.match(/\blease=(\S+)/)?.[1]
@@ -126,7 +130,6 @@ function runChecks(config) {
126
130
  // ── Phase 1: environment variables ──
127
131
  console.log("\n── Environment variables ──");
128
132
  const requiredVars = [
129
- "PLATFORM_SMOKE_CRABBOX",
130
133
  "CURSOR_API_KEY",
131
134
  "PLATFORM_SMOKE_WINDOWS_VM",
132
135
  "PLATFORM_SMOKE_WINDOWS_SNAPSHOT",
@@ -134,6 +137,7 @@ function runChecks(config) {
134
137
  "PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT",
135
138
  ];
136
139
  const optionalVars = [
140
+ "PLATFORM_SMOKE_CRABBOX",
137
141
  "PLATFORM_SMOKE_MAC_HOST",
138
142
  "PLATFORM_SMOKE_MAC_USER",
139
143
  "PLATFORM_SMOKE_MAC_WORK_ROOT",
@@ -151,12 +155,17 @@ function runChecks(config) {
151
155
 
152
156
  // ── Phase 2: Crabbox binary ──
153
157
  console.log("\n── Crabbox binary ──");
154
- const cbox = env("PLATFORM_SMOKE_CRABBOX");
155
- if (!cbox) {
156
- fail("PLATFORM_SMOKE_CRABBOX not set");
158
+ const cbox = env("PLATFORM_SMOKE_CRABBOX") || "crabbox";
159
+ const cboxPath = env("PLATFORM_SMOKE_CRABBOX") || commandPath("crabbox");
160
+ if (!cboxPath) {
161
+ fail(`crabbox not found on PATH; install with ${config.requiredCrabbox?.install ?? "Homebrew"} or set PLATFORM_SMOKE_CRABBOX`);
157
162
  } else {
158
- try { accessSync(cbox, constants.X_OK); ok(`binary: ${cbox}`); }
159
- catch { fail(`${cbox} not executable`); }
163
+ if (env("PLATFORM_SMOKE_CRABBOX")) {
164
+ try { accessSync(cboxPath, constants.X_OK); ok(`binary: ${cboxPath} (env override)`); }
165
+ catch { fail(`${cboxPath} not executable`); }
166
+ } else {
167
+ ok(`binary: ${cboxPath} (PATH)`);
168
+ }
160
169
  const ver = silent(cbox, ["--version"]);
161
170
  const actualVersion = ver?.split("\n")[0]?.trim();
162
171
  if (actualVersion) ok(`version: ${actualVersion}`);
@@ -174,9 +183,9 @@ function runChecks(config) {
174
183
  }
175
184
  const requiredCommit = config.requiredCrabbox?.commit;
176
185
  if (!requiredVersion && !minimumVersion && requiredCommit) {
177
- const gitRoot = findGitRoot(dirname(cbox));
186
+ const gitRoot = findGitRoot(dirname(cboxPath));
178
187
  const actualCommit = gitRoot ? silent("git", ["-C", gitRoot, "rev-parse", "HEAD"]) : null;
179
- if (!actualCommit) fail(`could not verify Crabbox source commit for ${cbox}`);
188
+ if (!actualCommit) fail(`could not verify Crabbox source commit for ${cboxPath}`);
180
189
  else if (actualCommit !== requiredCommit) fail(`Crabbox commit mismatch: expected ${requiredCommit}, got ${actualCommit}`);
181
190
  else ok(`commit: ${actualCommit}`);
182
191
  }
@@ -184,7 +193,7 @@ function runChecks(config) {
184
193
 
185
194
  // ── Phase 3: Crabbox providers ──
186
195
  console.log("\n── Crabbox providers ──");
187
- if (cbox) {
196
+ if (cboxPath) {
188
197
  const providerList = silent(cbox, ["providers"]);
189
198
  if (providerList) {
190
199
  for (const provider of ["ssh", "local-container", "parallels"]) {
@@ -18,15 +18,31 @@ function isPositiveInteger(value: unknown): value is number {
18
18
  return typeof value === "number" && Number.isInteger(value) && value > 0;
19
19
  }
20
20
 
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
23
+ }
24
+
25
+ function parseContextWindowCacheFile(value: unknown): ContextWindowCacheFile | undefined {
26
+ if (!isRecord(value)) return undefined;
27
+ const { contextWindows } = value;
28
+ if (contextWindows === undefined) return {};
29
+ if (!isRecord(contextWindows)) return undefined;
30
+ return {
31
+ contextWindows: Object.fromEntries(
32
+ Object.entries(contextWindows).filter((entry): entry is [string, number] => isPositiveInteger(entry[1])),
33
+ ),
34
+ };
35
+ }
36
+
21
37
  function loadUserContextWindowOverrides(): Map<string, number> {
22
38
  userContextWindowOverrideLoadCount += 1;
23
39
  const path = getCachePath();
24
40
  const overrides = new Map<string, number>();
25
41
  if (!existsSync(path)) return overrides;
26
42
  try {
27
- const parsed = JSON.parse(readFileSync(path, "utf-8")) as ContextWindowCacheFile;
28
- for (const [modelId, contextWindow] of Object.entries(parsed.contextWindows ?? {})) {
29
- if (isPositiveInteger(contextWindow)) overrides.set(modelId, contextWindow);
43
+ const parsed = parseContextWindowCacheFile(JSON.parse(readFileSync(path, "utf-8")));
44
+ for (const [modelId, contextWindow] of Object.entries(parsed?.contextWindows ?? {})) {
45
+ overrides.set(modelId, contextWindow);
30
46
  }
31
47
  } catch {
32
48
  return overrides;
@@ -52,10 +68,10 @@ export function getCachedContextWindow(modelId: string): number | undefined {
52
68
  }
53
69
 
54
70
  export function getCheckpointContextWindow(checkpoint: unknown): number | undefined {
55
- if (checkpoint === null || typeof checkpoint !== "object") return undefined;
56
- const tokenDetails = (checkpoint as Record<PropertyKey, unknown>).tokenDetails;
57
- if (tokenDetails === null || typeof tokenDetails !== "object") return undefined;
58
- const maxTokens = (tokenDetails as Record<PropertyKey, unknown>).maxTokens;
71
+ if (!isRecord(checkpoint)) return undefined;
72
+ const { tokenDetails } = checkpoint;
73
+ if (!isRecord(tokenDetails)) return undefined;
74
+ const { maxTokens } = tokenDetails;
59
75
  if (!isPositiveInteger(maxTokens)) return undefined;
60
76
  return maxTokens;
61
77
  }
@@ -69,10 +69,13 @@ export function parseCursorAgentMode(raw: unknown): AgentModeOption | undefined
69
69
  return isCursorAgentMode(mode) ? mode : undefined;
70
70
  }
71
71
 
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
+ }
75
+
72
76
  function isCursorFastEntryData(value: unknown): value is CursorFastEntryData {
73
- if (!value || typeof value !== "object") return false;
74
- const data = value as Record<string, unknown>;
75
- return (typeof data.modelId === "string" || typeof data.baseModelId === "string") && typeof data.fast === "boolean";
77
+ if (!isRecord(value)) return false;
78
+ return (typeof value.modelId === "string" || typeof value.baseModelId === "string") && typeof value.fast === "boolean";
76
79
  }
77
80
 
78
81
  function getCursorFastEntryModelId(data: CursorFastEntryData): string {
@@ -80,9 +83,19 @@ function getCursorFastEntryModelId(data: CursorFastEntryData): string {
80
83
  }
81
84
 
82
85
  function isCursorModeEntryData(value: unknown): value is CursorModeEntryData {
83
- if (!value || typeof value !== "object") return false;
84
- const data = value as Record<string, unknown>;
85
- return isCursorAgentMode(data.mode);
86
+ return isRecord(value) && isCursorAgentMode(value.mode);
87
+ }
88
+
89
+ function parseCursorGlobalConfig(value: unknown): CursorGlobalConfig | undefined {
90
+ if (!isRecord(value)) return undefined;
91
+ const { fastDefaults } = value;
92
+ if (fastDefaults === undefined) return {};
93
+ if (!isRecord(fastDefaults)) return undefined;
94
+ return {
95
+ fastDefaults: Object.fromEntries(
96
+ Object.entries(fastDefaults).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"),
97
+ ),
98
+ };
86
99
  }
87
100
 
88
101
  function getConfigPath(): string {
@@ -93,12 +106,8 @@ function loadGlobalFastPreferences(): Map<string, boolean> {
93
106
  const path = getConfigPath();
94
107
  if (!existsSync(path)) return new Map();
95
108
  try {
96
- const parsed = JSON.parse(readFileSync(path, "utf-8")) as CursorGlobalConfig;
97
- return new Map(
98
- Object.entries(parsed.fastDefaults ?? {}).filter(
99
- (entry): entry is [string, boolean] => typeof entry[1] === "boolean",
100
- ),
101
- );
109
+ const parsed = parseCursorGlobalConfig(JSON.parse(readFileSync(path, "utf-8")));
110
+ return new Map(Object.entries(parsed?.fastDefaults ?? {}));
102
111
  } catch {
103
112
  return new Map();
104
113
  }
@@ -138,6 +147,10 @@ function getFastPreferenceModelId(metadata: NonNullable<ReturnType<typeof getCur
138
147
  return metadata.selectionModelId || metadata.baseModelId;
139
148
  }
140
149
 
150
+ function getVirtualFastBaseModelId(modelId: string): string {
151
+ return modelId.replace(/:(?:fast|slow)$/, "");
152
+ }
153
+
141
154
  function getStoredFastPreference(metadata: NonNullable<ReturnType<typeof getCursorModelMetadata>>): boolean | undefined {
142
155
  const preferenceModelId = getFastPreferenceModelId(metadata);
143
156
  return (
@@ -153,6 +166,7 @@ function getEffectiveFast(modelId: string): boolean | undefined {
153
166
  if (!metadata?.supportsFast) return undefined;
154
167
  if (cliForceNoFast) return false;
155
168
  if (cliForceFast) return true;
169
+ if (metadata.fastOverride !== undefined) return metadata.fastOverride;
156
170
  return getStoredFastPreference(metadata) ?? metadata.defaultFast;
157
171
  }
158
172
 
@@ -344,6 +358,14 @@ export function registerCursorRuntimeControls(pi: CursorRuntimeControlsExtension
344
358
  ctx.ui.notify("Cursor fast is forced by --cursor-fast", "info");
345
359
  return;
346
360
  }
361
+ if (metadata.fastOverride !== undefined) {
362
+ const state = metadata.fastOverride ? "enabled" : "disabled";
363
+ ctx.ui.notify(
364
+ `Cursor fast is fixed ${state} by selected model ${metadata.piModelId}; choose ${getVirtualFastBaseModelId(metadata.piModelId)} to use /cursor-fast preferences`,
365
+ "info",
366
+ );
367
+ return;
368
+ }
347
369
 
348
370
  const preferenceModelId = getFastPreferenceModelId(metadata);
349
371
  const current = getEffectiveFast(metadata.piModelId) ?? false;
@@ -88,6 +88,7 @@ export interface CursorModelMetadata {
88
88
  contextWindow: number;
89
89
  supportsFast: boolean;
90
90
  defaultFast: boolean;
91
+ fastOverride?: boolean;
91
92
  supportsReasoning: boolean;
92
93
  thinkingLevelMap?: ThinkingLevelMap;
93
94
  parameterIds: {
@@ -205,19 +206,35 @@ function getParamValue(params: ModelParameterValue[], id: string): string | unde
205
206
  return params.find((param) => param.id === id)?.value;
206
207
  }
207
208
 
208
- function encodePiModelId(modelId: string, context?: string): string {
209
- return context ? `${modelId}@${context}` : modelId;
209
+ function encodePiModelId(modelId: string, context?: string, fastOverride?: boolean): string {
210
+ const contextQualified = context ? `${modelId}@${context}` : modelId;
211
+ if (fastOverride === true) return `${contextQualified}:fast`;
212
+ if (fastOverride === false) return `${contextQualified}:slow`;
213
+ return contextQualified;
210
214
  }
211
215
 
212
- function getModelName(item: ModelListItem, context?: string, alias?: string): string {
216
+ function getModelName(item: ModelListItem, context?: string, alias?: string, fastOverride?: boolean): string {
213
217
  const displayName = item.displayName || item.id;
214
- const baseName = alias ? `${displayName} (${alias})` : displayName;
218
+ const qualifiers: string[] = [];
219
+ if (alias) qualifiers.push(alias);
220
+ if (fastOverride === true) qualifiers.push("fast");
221
+ if (fastOverride === false) qualifiers.push("slow");
222
+ const baseName = qualifiers.length > 0 ? `${displayName} (${qualifiers.join(", ")})` : displayName;
215
223
  return context ? `${baseName} @ ${context}` : baseName;
216
224
  }
217
225
 
226
+ function getFastOverrideBasePiModelId(piModelId: string): string {
227
+ return piModelId.replace(/:(?:fast|slow)$/, "");
228
+ }
229
+
218
230
  function getContextWindow(contextWindowCache: Map<string, number>, piModelId: string, context?: string, baseModelId?: string): number {
219
- return (
231
+ const fastOverrideBasePiModelId = getFastOverrideBasePiModelId(piModelId);
232
+ const contextWindowOverride =
220
233
  contextWindowCache.get(piModelId) ??
234
+ (fastOverrideBasePiModelId !== piModelId ? contextWindowCache.get(fastOverrideBasePiModelId) : undefined);
235
+
236
+ return (
237
+ contextWindowOverride ??
221
238
  (context ? parseContextWindow(context) : undefined) ??
222
239
  (baseModelId ? contextWindowCache.get(baseModelId) : undefined) ??
223
240
  contextWindowCache.get("default") ??
@@ -232,6 +249,7 @@ function toMetadata(
232
249
  defaultParams: ModelParameterValue[],
233
250
  context: string | undefined,
234
251
  contextWindowCache: Map<string, number>,
252
+ fastOverride?: boolean,
235
253
  ): CursorModelMetadata {
236
254
  const thinkingLevelMap = getThinkingLevelMap(item);
237
255
  const fastValue = getParamValue(defaultParams, "fast")?.toLowerCase();
@@ -245,6 +263,7 @@ function toMetadata(
245
263
  contextWindow: getContextWindow(contextWindowCache, piModelId, context, item.id),
246
264
  supportsFast: getParameter(item, "fast") !== undefined,
247
265
  defaultFast: fastValue === "true",
266
+ ...(fastOverride !== undefined ? { fastOverride } : {}),
248
267
  supportsReasoning: thinkingLevelMap !== undefined,
249
268
  ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
250
269
  parameterIds: {
@@ -310,16 +329,21 @@ function toModelConfigs(
310
329
  const contexts = contextValues.length > 0 ? contextValues : [undefined];
311
330
  const configs: ProviderModelConfig[] = [];
312
331
 
332
+ const fastOverrides = getParameter(item, "fast") === undefined ? [undefined] : [undefined, true, false];
333
+
313
334
  for (const selectionModelId of getModelIds(item, reservedBaseModelIds, ambiguousAliases)) {
314
335
  const alias = selectionModelId === item.id ? undefined : selectionModelId;
315
336
  for (const context of contexts) {
316
- const params = context ? replaceParam(defaultParams, "context", context) : defaultParams;
317
- const piModelId = encodePiModelId(selectionModelId, context);
318
- if (usedPiModelIds.has(piModelId)) continue;
319
- usedPiModelIds.add(piModelId);
320
- const metadata = toMetadata(item, piModelId, selectionModelId, params, context, contextWindowCache);
321
- metadataByPiModelId.set(piModelId, metadata);
322
- configs.push(toModelConfig(metadata, getModelName(item, context, alias)));
337
+ const contextParams = context ? replaceParam(defaultParams, "context", context) : defaultParams;
338
+ for (const fastOverride of fastOverrides) {
339
+ const params = fastOverride === undefined ? contextParams : replaceParam(contextParams, "fast", fastOverride ? "true" : "false");
340
+ const piModelId = encodePiModelId(selectionModelId, context, fastOverride);
341
+ if (usedPiModelIds.has(piModelId)) continue;
342
+ usedPiModelIds.add(piModelId);
343
+ const metadata = toMetadata(item, piModelId, selectionModelId, params, context, contextWindowCache, fastOverride);
344
+ metadataByPiModelId.set(piModelId, metadata);
345
+ configs.push(toModelConfig(metadata, getModelName(item, context, alias, fastOverride)));
346
+ }
323
347
  }
324
348
  }
325
349
 
@@ -8,6 +8,7 @@ import { parseEnvBoolean } from "./cursor-env-boolean.js";
8
8
  const MODEL_LIST_CACHE_FILE = "cursor-sdk-model-list.json";
9
9
  const MODEL_LIST_CACHE_VERSION = 1;
10
10
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const MAX_CACHE_CLOCK_SKEW_MS = 5 * 60 * 1000;
11
12
  const DISABLE_ENV_VAR = "PI_CURSOR_SDK_DISABLE_MODEL_CACHE";
12
13
  const TTL_ENV_VAR = "PI_CURSOR_SDK_MODEL_CACHE_TTL_MS";
13
14
 
@@ -45,20 +46,83 @@ export function fingerprintApiKey(apiKey: string): string {
45
46
  return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
46
47
  }
47
48
 
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
53
+ function isStringArray(value: unknown): value is string[] {
54
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
55
+ }
56
+
57
+ function isModelParameterValue(value: unknown): value is NonNullable<ModelListItem["variants"]>[number]["params"][number] {
58
+ return isRecord(value) && typeof value.id === "string" && typeof value.value === "string";
59
+ }
60
+
61
+ function isModelParameterDefinitionValue(value: unknown): value is NonNullable<ModelListItem["parameters"]>[number]["values"][number] {
62
+ return isRecord(value) && typeof value.value === "string" && (value.displayName === undefined || typeof value.displayName === "string");
63
+ }
64
+
65
+ function isModelParameterDefinition(value: unknown): value is NonNullable<ModelListItem["parameters"]>[number] {
66
+ if (!isRecord(value)) return false;
67
+ return (
68
+ typeof value.id === "string" &&
69
+ (value.displayName === undefined || typeof value.displayName === "string") &&
70
+ Array.isArray(value.values) &&
71
+ value.values.every(isModelParameterDefinitionValue)
72
+ );
73
+ }
74
+
75
+ function isModelVariant(value: unknown): value is NonNullable<ModelListItem["variants"]>[number] {
76
+ if (!isRecord(value)) return false;
77
+ return (
78
+ Array.isArray(value.params) &&
79
+ value.params.every(isModelParameterValue) &&
80
+ typeof value.displayName === "string" &&
81
+ (value.description === undefined || typeof value.description === "string") &&
82
+ (value.isDefault === undefined || typeof value.isDefault === "boolean")
83
+ );
84
+ }
85
+
86
+ function isModelListItem(value: unknown): value is ModelListItem {
87
+ if (!isRecord(value)) return false;
88
+ return (
89
+ typeof value.id === "string" &&
90
+ typeof value.displayName === "string" &&
91
+ (value.description === undefined || typeof value.description === "string") &&
92
+ (value.aliases === undefined || isStringArray(value.aliases)) &&
93
+ (value.parameters === undefined || (Array.isArray(value.parameters) && value.parameters.every(isModelParameterDefinition))) &&
94
+ (value.variants === undefined || (Array.isArray(value.variants) && value.variants.every(isModelVariant)))
95
+ );
96
+ }
97
+
98
+ function isValidFetchedAt(value: unknown): value is number {
99
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= Date.now() + MAX_CACHE_CLOCK_SKEW_MS;
100
+ }
101
+
102
+ function parseModelListCacheFile(value: unknown): ModelListCacheFile | undefined {
103
+ if (!isRecord(value)) return undefined;
104
+ if (
105
+ value.version !== MODEL_LIST_CACHE_VERSION ||
106
+ !isValidFetchedAt(value.fetchedAt) ||
107
+ typeof value.keyFingerprint !== "string" ||
108
+ !Array.isArray(value.models) ||
109
+ !value.models.every(isModelListItem)
110
+ ) {
111
+ return undefined;
112
+ }
113
+ return {
114
+ version: value.version,
115
+ fetchedAt: value.fetchedAt,
116
+ keyFingerprint: value.keyFingerprint,
117
+ models: value.models,
118
+ };
119
+ }
120
+
48
121
  function readCacheFile(): ModelListCacheFile | undefined {
49
122
  const path = getCachePath();
50
123
  if (!existsSync(path)) return undefined;
51
124
  try {
52
- const parsed = JSON.parse(readFileSync(path, "utf-8")) as ModelListCacheFile;
53
- if (
54
- parsed.version !== MODEL_LIST_CACHE_VERSION ||
55
- typeof parsed.fetchedAt !== "number" ||
56
- typeof parsed.keyFingerprint !== "string" ||
57
- !Array.isArray(parsed.models)
58
- ) {
59
- return undefined;
60
- }
61
- return parsed;
125
+ return parseModelListCacheFile(JSON.parse(readFileSync(path, "utf-8")));
62
126
  } catch {
63
127
  return undefined;
64
128
  }