@rahularya01/pi-cursor 1.1.0 → 1.2.1

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/src/index.ts DELETED
@@ -1,1436 +0,0 @@
1
- /**
2
- * Cursor Provider Extension for pi
3
- *
4
- * Provides access to Cursor models (Claude, GPT, Gemini, etc.) via:
5
- * 1. Browser-based PKCE OAuth login to Cursor
6
- * 2. Native Pi streamSimple provider translating Pi context → Cursor gRPC protocol
7
- *
8
- * Usage:
9
- * /login cursor — authenticate via browser
10
- * /model — select any Cursor model
11
- *
12
- * Based on https://github.com/ephraimduncan/opencode-cursor by Ephraim Duncan.
13
- */
14
-
15
- import rawFallbackModels from "./models/catalog.json" with { type: "json" };
16
- import {
17
- readStoredCredential,
18
- type ExtensionAPI,
19
- type ExtensionCommandContext,
20
- type ExtensionContext,
21
- } from "@earendil-works/pi-coding-agent";
22
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
23
- import { appendFileSync } from "node:fs";
24
- import { createHash } from "node:crypto";
25
- import { tmpdir } from "node:os";
26
- import { join as pathJoin } from "node:path";
27
- import {
28
- generateCursorAuthParams,
29
- getTokenExpiry,
30
- pollCursorAuth,
31
- refreshCursorToken,
32
- } from "./auth/oauth.js";
33
- import { resolveSystemCursorAccessToken, type CredentialSource } from "./auth/cli-credentials.js";
34
- import { resolveSystemCredentialPolicy, systemCredentialsAllowed } from "./auth/consent.js";
35
- import {
36
- getLastDiagnostics,
37
- setLastAvailableModels,
38
- setLastClientVersion,
39
- setLastTokenSource,
40
- setSystemCredentialsPolicy,
41
- } from "./diagnostics/index.js";
42
- import { redactSecrets } from "./utils/security.js";
43
- import { formatCursorUsage, getCursorUsageSummary } from "./usage.js";
44
- import { getCursorClientVersion } from "./stream/config.js";
45
- import {
46
- cleanupSessionState,
47
- createCursorNativeStream,
48
- getCursorAgentUrl,
49
- getCursorModels,
50
- getCursorParameterizedModels,
51
- type CursorModel,
52
- type CursorModelParameter,
53
- type CursorParameterizedModel,
54
- type CursorParameterizedVariant,
55
- } from "./stream/native-core.js";
56
-
57
- // ── Cost estimation ──
58
-
59
- interface ModelCost {
60
- input: number;
61
- output: number;
62
- cacheRead: number;
63
- cacheWrite: number;
64
- }
65
-
66
- let extensionDebugLogFilePath: string | undefined;
67
-
68
- function isExtensionDebugEnabled(): boolean {
69
- const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
70
- return !!raw && raw !== "0" && raw !== "false" && raw !== "off";
71
- }
72
-
73
- function getExtensionDebugLogFilePath(): string {
74
- if (extensionDebugLogFilePath) return extensionDebugLogFilePath;
75
- const configured = process.env.PI_CURSOR_PROVIDER_EXTENSION_DEBUG_FILE?.trim();
76
- if (configured) {
77
- extensionDebugLogFilePath = configured;
78
- return extensionDebugLogFilePath;
79
- }
80
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
81
- extensionDebugLogFilePath = pathJoin(
82
- tmpdir(),
83
- `pi-cursor-provider-extension-debug-${stamp}-${process.pid}.log`,
84
- );
85
- return extensionDebugLogFilePath;
86
- }
87
-
88
- function truncateDebugValue(value: string, max = 240): string {
89
- return value.length > max
90
- ? `${value.slice(0, max)}…<truncated ${value.length - max} chars>`
91
- : value;
92
- }
93
-
94
- function summarizeBase64ImageData(data: string): {
95
- base64Length: number;
96
- byteLength?: number;
97
- sha256?: string;
98
- } {
99
- const summary: { base64Length: number; byteLength?: number; sha256?: string } = {
100
- base64Length: data.length,
101
- };
102
- try {
103
- const bytes = Buffer.from(data.replace(/\s/g, ""), "base64");
104
- if (bytes.length > 0) {
105
- summary.byteLength = bytes.length;
106
- summary.sha256 = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
107
- }
108
- } catch {
109
- // Invalid base64 — keep length-only summary.
110
- }
111
- return summary;
112
- }
113
-
114
- function summarizeImageBlock(type: unknown, mimeType: unknown, data: unknown): unknown {
115
- return {
116
- type,
117
- mimeType,
118
- ...(typeof data === "string"
119
- ? summarizeBase64ImageData(data)
120
- : { data: `<redacted base64 ${String(data ?? "").length} chars>` }),
121
- };
122
- }
123
-
124
- function summarizeDataImageUrl(url: string): unknown {
125
- const match = url.trim().match(/^data:([^;,]+)(?:;[^,]*)?;base64,(.*)$/is);
126
- if (!match)
127
- return {
128
- url: url.startsWith("data:image/")
129
- ? `<redacted data image ${url.length} chars>`
130
- : truncateDebugValue(url),
131
- };
132
- return {
133
- mimeType: match[1]?.toLowerCase(),
134
- ...summarizeBase64ImageData(match[2]!),
135
- };
136
- }
137
-
138
- function summarizeContent(content: unknown): unknown {
139
- if (typeof content === "string") return truncateDebugValue(content);
140
- if (!Array.isArray(content)) return content;
141
- return content.map((block) => {
142
- if (!block || typeof block !== "object") return block;
143
- const typed = block as Record<string, unknown>;
144
- switch (typed.type) {
145
- case "text":
146
- return { type: "text", text: truncateDebugValue(String(typed.text ?? "")) };
147
- case "thinking":
148
- return { type: "thinking", thinking: truncateDebugValue(String(typed.thinking ?? "")) };
149
- case "toolCall":
150
- return {
151
- type: "toolCall",
152
- id: typed.id,
153
- name: typed.name,
154
- arguments: typed.arguments,
155
- };
156
- case "image":
157
- return summarizeImageBlock("image", typed.mimeType, typed.data);
158
- case "image_url": {
159
- const url = (typed.image_url as Record<string, unknown> | undefined)?.url;
160
- const text = typeof url === "string" ? url : "";
161
- return { type: "image_url", image_url: summarizeDataImageUrl(text) };
162
- }
163
- default:
164
- return typed;
165
- }
166
- });
167
- }
168
-
169
- function summarizeMessage(message: unknown): unknown {
170
- if (!message || typeof message !== "object") return message;
171
- const typed = message as Record<string, unknown>;
172
- return {
173
- role: typed.role,
174
- stopReason: typed.stopReason,
175
- toolCallId: typed.toolCallId,
176
- toolName: typed.toolName,
177
- isError: typed.isError,
178
- errorMessage: typed.errorMessage,
179
- content: summarizeContent(typed.content),
180
- };
181
- }
182
-
183
- function summarizeBranchTail(
184
- ctx: {
185
- sessionManager?: {
186
- getBranch?: () => unknown[];
187
- getLeafId?: () => string | null;
188
- getSessionId?: () => string;
189
- };
190
- },
191
- limit = 6,
192
- ): unknown {
193
- try {
194
- const branch = ctx.sessionManager?.getBranch?.();
195
- if (!Array.isArray(branch)) return undefined;
196
- return {
197
- sessionId: ctx.sessionManager?.getSessionId?.(),
198
- leafId: ctx.sessionManager?.getLeafId?.(),
199
- size: branch.length,
200
- tail: branch.slice(-limit).map((entry) => {
201
- if (!entry || typeof entry !== "object") return entry;
202
- const typed = entry as Record<string, unknown>;
203
- return {
204
- type: typed.type,
205
- id: typed.id,
206
- parentId: typed.parentId,
207
- customType: typed.customType,
208
- message: summarizeMessage(typed.message),
209
- };
210
- }),
211
- };
212
- } catch (error) {
213
- return { error: error instanceof Error ? error.message : String(error) };
214
- }
215
- }
216
-
217
- export interface CursorToolResultImagePayload {
218
- toolCallId: string;
219
- images: Array<{ data: string; mimeType: string }>;
220
- }
221
-
222
- function payloadToolCallIds(payload: Record<string, unknown>): Set<string> {
223
- const ids = new Set<string>();
224
- const messages = Array.isArray(payload.messages) ? payload.messages : [];
225
- for (const message of messages) {
226
- if (!message || typeof message !== "object") continue;
227
- const typed = message as Record<string, unknown>;
228
- if (typed.role === "tool" && typeof typed.tool_call_id === "string" && typed.tool_call_id)
229
- ids.add(typed.tool_call_id);
230
- }
231
- return ids;
232
- }
233
-
234
- export function extractToolResultImagePayloads(
235
- ctx: { sessionManager?: { getBranch?: () => unknown[] } },
236
- payload: Record<string, unknown>,
237
- ): CursorToolResultImagePayload[] {
238
- const idsInPayload = payloadToolCallIds(payload);
239
- if (idsInPayload.size === 0) return [];
240
- const branch = ctx.sessionManager?.getBranch?.();
241
- if (!Array.isArray(branch)) return [];
242
-
243
- const byToolCallId = new Map<string, CursorToolResultImagePayload>();
244
- for (const entry of branch) {
245
- if (!entry || typeof entry !== "object") continue;
246
- const message = (entry as Record<string, unknown>).message;
247
- if (!message || typeof message !== "object") continue;
248
- const typed = message as Record<string, unknown>;
249
- const toolCallId = typeof typed.toolCallId === "string" ? typed.toolCallId : "";
250
- if (typed.role !== "toolResult" || !toolCallId || !idsInPayload.has(toolCallId)) continue;
251
- const content = Array.isArray(typed.content) ? typed.content : [];
252
- const images = content.flatMap((block) => {
253
- if (!block || typeof block !== "object") return [];
254
- const image = block as Record<string, unknown>;
255
- if (
256
- image.type !== "image" ||
257
- typeof image.data !== "string" ||
258
- typeof image.mimeType !== "string"
259
- )
260
- return [];
261
- return [{ data: image.data, mimeType: image.mimeType }];
262
- });
263
- if (images.length === 0) continue;
264
- const existing = byToolCallId.get(toolCallId);
265
- if (existing) existing.images.push(...images);
266
- else byToolCallId.set(toolCallId, { toolCallId, images });
267
- }
268
- return [...byToolCallId.values()];
269
- }
270
-
271
- function debugExtensionLog(event: string, data?: Record<string, unknown>): void {
272
- if (!isExtensionDebugEnabled()) return;
273
- const payload = JSON.stringify({
274
- ts: new Date().toISOString(),
275
- pid: process.pid,
276
- scope: "extension",
277
- event,
278
- ...data,
279
- });
280
- appendFileSync(getExtensionDebugLogFilePath(), `${payload}\n`, "utf8");
281
- }
282
-
283
- const MODEL_COST_TABLE: Record<string, ModelCost> = {
284
- "claude-4-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
285
- "claude-4.5-haiku": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
286
- "claude-4.5-opus": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
287
- "claude-4.5-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
288
- "claude-4.6-opus": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
289
- "claude-4.6-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
290
- "composer-1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
291
- "composer-1.5": { input: 3.5, output: 17.5, cacheRead: 0.35, cacheWrite: 0 },
292
- "composer-2": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
293
- "gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
294
- "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
295
- "gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
296
- "gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
297
- "gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
298
- "gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
299
- "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
300
- "gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
301
- "gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
302
- "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
303
- "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
304
- "gpt-5.5": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
305
- "grok-4.20": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
306
- "kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
307
- };
308
-
309
- const MODEL_COST_PATTERNS: Array<{ match: (id: string) => boolean; cost: ModelCost }> = [
310
- {
311
- match: (id) => /claude.*opus.*fast/i.test(id),
312
- cost: { input: 30, output: 150, cacheRead: 3, cacheWrite: 37.5 },
313
- },
314
- { match: (id) => /claude.*opus/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-opus"]! },
315
- { match: (id) => /claude.*haiku/i.test(id), cost: MODEL_COST_TABLE["claude-4.5-haiku"]! },
316
- { match: (id) => /claude.*sonnet/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-sonnet"]! },
317
- { match: (id) => /composer/i.test(id), cost: MODEL_COST_TABLE["composer-1"]! },
318
- { match: (id) => /gpt-5\.5/i.test(id), cost: MODEL_COST_TABLE["gpt-5.5"]! },
319
- { match: (id) => /gpt-5\.4.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4-mini"]! },
320
- { match: (id) => /gpt-5\.4/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4"]! },
321
- { match: (id) => /gpt-5\.3/i.test(id), cost: MODEL_COST_TABLE["gpt-5.3-codex"]! },
322
- { match: (id) => /gpt-5\.2/i.test(id), cost: MODEL_COST_TABLE["gpt-5.2"]! },
323
- { match: (id) => /gpt-5.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5-mini"]! },
324
- { match: (id) => /gpt-5/i.test(id), cost: MODEL_COST_TABLE["gpt-5"]! },
325
- { match: (id) => /gemini.*3\.1/i.test(id), cost: MODEL_COST_TABLE["gemini-3.1-pro"]! },
326
- { match: (id) => /gemini.*flash/i.test(id), cost: MODEL_COST_TABLE["gemini-2.5-flash"]! },
327
- { match: (id) => /gemini/i.test(id), cost: MODEL_COST_TABLE["gemini-3-pro"]! },
328
- { match: (id) => /grok/i.test(id), cost: MODEL_COST_TABLE["grok-4.20"]! },
329
- { match: (id) => /kimi/i.test(id), cost: MODEL_COST_TABLE["kimi-k2.5"]! },
330
- ];
331
-
332
- const DEFAULT_COST: ModelCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 };
333
-
334
- function estimateModelCost(modelId: string): ModelCost {
335
- const normalized = modelId.toLowerCase();
336
- const exact = MODEL_COST_TABLE[normalized];
337
- if (exact) return exact;
338
- const stripped = normalized.replace(
339
- /-(high|medium|low|preview|thinking|spark-preview|fast)$/g,
340
- "",
341
- );
342
- const strippedMatch = MODEL_COST_TABLE[stripped];
343
- if (strippedMatch) return strippedMatch;
344
- return MODEL_COST_PATTERNS.find((p) => p.match(normalized))?.cost ?? DEFAULT_COST;
345
- }
346
-
347
- // ── Effort-level dedup ──
348
-
349
- const CURSOR_EFFORT_SUFFIXES: Array<{ suffix: string; effort: string }> = [
350
- { suffix: "extra-high", effort: "xhigh" },
351
- { suffix: "minimal", effort: "minimal" },
352
- { suffix: "xhigh", effort: "xhigh" },
353
- { suffix: "medium", effort: "medium" },
354
- { suffix: "high", effort: "high" },
355
- { suffix: "low", effort: "low" },
356
- { suffix: "max", effort: "max" },
357
- { suffix: "none", effort: "none" },
358
- ];
359
-
360
- type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
361
- type CursorEffortMap = Record<PiThinkingLevel, string | null>;
362
-
363
- interface ParsedModelId {
364
- base: string; // model ID with effort stripped
365
- effort: string; // effort level, or "" if no effort suffix
366
- fast: boolean; // has -fast suffix
367
- thinking: boolean; // has -thinking suffix
368
- }
369
-
370
- function stripEffortSuffix(id: string): { remaining: string; effort: string } {
371
- for (const { suffix, effort } of CURSOR_EFFORT_SUFFIXES) {
372
- const marker = `-${suffix}`;
373
- if (id.endsWith(marker)) {
374
- return { remaining: id.slice(0, -marker.length), effort };
375
- }
376
- }
377
- return { remaining: id, effort: "" };
378
- }
379
-
380
- export function parseModelId(id: string): ParsedModelId {
381
- let remaining = id;
382
- let fast = false;
383
- let thinking = false;
384
-
385
- if (remaining.endsWith("-fast")) {
386
- fast = true;
387
- remaining = remaining.slice(0, -5);
388
- }
389
-
390
- // Cursor has used both orders for thinking effort variants:
391
- // claude-4.6-opus-max-thinking (effort before -thinking)
392
- // claude-opus-4-7-thinking-max (effort after -thinking)
393
- let effort: string;
394
- if (remaining.endsWith("-thinking")) {
395
- thinking = true;
396
- remaining = remaining.slice(0, -9);
397
- const parsed = stripEffortSuffix(remaining);
398
- remaining = parsed.remaining;
399
- effort = parsed.effort;
400
- } else {
401
- const parsed = stripEffortSuffix(remaining);
402
- remaining = parsed.remaining;
403
- effort = parsed.effort;
404
- if (remaining.endsWith("-thinking")) {
405
- thinking = true;
406
- remaining = remaining.slice(0, -9);
407
- }
408
- }
409
-
410
- return { base: remaining, effort, fast, thinking };
411
- }
412
-
413
- export interface CursorModelRouting {
414
- modelId: string;
415
- parameters?: CursorModelParameter[];
416
- requiresMaxMode?: boolean;
417
- requestedMaxMode?: boolean;
418
- }
419
-
420
- export interface ProcessedModel extends CursorModel {
421
- supportsEffort: boolean;
422
- effortMap?: CursorEffortMap;
423
- rawModelByEffort?: Record<string, string>;
424
- rawRoutingByEffort?: Record<string, CursorModelRouting>;
425
- }
426
-
427
- export function buildNoReasoningEffortLookup(models: ProcessedModel[]): Map<string, string> {
428
- const lookup = new Map<string, string>();
429
- for (const model of models) {
430
- if (
431
- model.supportsEffort &&
432
- model.effortMap &&
433
- Object.values(model.effortMap).includes("none")
434
- ) {
435
- lookup.set(model.id, "none");
436
- }
437
- }
438
- return lookup;
439
- }
440
-
441
- function routingForModel(model: CursorModel): CursorModelRouting | undefined {
442
- if (
443
- !model.requestedModelId &&
444
- !model.parameters?.length &&
445
- !model.requiresMaxMode &&
446
- typeof model.requestedMaxMode !== "boolean"
447
- ) {
448
- return undefined;
449
- }
450
- return {
451
- modelId: model.requestedModelId ?? model.id,
452
- ...(model.parameters?.length ? { parameters: model.parameters } : {}),
453
- ...(model.requiresMaxMode ? { requiresMaxMode: true } : {}),
454
- ...(typeof model.requestedMaxMode === "boolean"
455
- ? { requestedMaxMode: model.requestedMaxMode }
456
- : {}),
457
- };
458
- }
459
-
460
- function defaultRoutingEffort(model: ProcessedModel): string | undefined {
461
- const routes = model.rawRoutingByEffort;
462
- if (!routes) return undefined;
463
- const mappedMedium = model.effortMap?.medium;
464
- for (const effort of [mappedMedium, "medium", "", "low", "high", "none", "xhigh", "max"]) {
465
- if (typeof effort === "string" && routes[effort]) return effort;
466
- }
467
- return Object.keys(routes)[0];
468
- }
469
-
470
- export function buildRawModelLookup(
471
- models: ProcessedModel[],
472
- ): Map<string, Record<string, CursorModelRouting>> {
473
- const lookup = new Map<string, Record<string, CursorModelRouting>>();
474
- for (const model of models) {
475
- if (model.supportsEffort && model.rawRoutingByEffort) {
476
- const routes = { ...model.rawRoutingByEffort };
477
- if (model.effortMap) {
478
- for (const [piEffort, cursorEffort] of Object.entries(model.effortMap)) {
479
- if (typeof cursorEffort === "string" && !routes[piEffort] && routes[cursorEffort]) {
480
- routes[piEffort] = routes[cursorEffort];
481
- }
482
- }
483
- }
484
- const defaultEffort = defaultRoutingEffort(model);
485
- if (defaultEffort !== undefined && !routes[""])
486
- routes[""] = model.rawRoutingByEffort[defaultEffort]!;
487
- lookup.set(model.id, routes);
488
- continue;
489
- }
490
-
491
- const routing = routingForModel(model);
492
- if (routing) lookup.set(model.id, { "": routing });
493
- }
494
- return lookup;
495
- }
496
-
497
- export function applyRawCursorModelId(
498
- payload: Record<string, unknown>,
499
- rawRoutingByEffortByModelId: Map<string, Record<string, CursorModelRouting>>,
500
- ): void {
501
- if (typeof payload.model !== "string") return;
502
- const rawRoutingByEffort = rawRoutingByEffortByModelId.get(payload.model);
503
- const effort = typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : "";
504
- const routing = rawRoutingByEffort?.[effort];
505
- if (!routing) return;
506
- payload.cursor_model_id = routing.modelId;
507
- if (routing.parameters?.length) payload.cursor_model_parameters = routing.parameters;
508
- if (routing.requiresMaxMode) payload.cursor_requires_max_mode = true;
509
- if (typeof routing.requestedMaxMode === "boolean")
510
- payload.cursor_model_max_mode = routing.requestedMaxMode;
511
- }
512
-
513
- export function applyNoReasoningEffort(
514
- payload: Record<string, unknown>,
515
- thinkingLevel: string,
516
- noReasoningEffortByModelId: Map<string, string>,
517
- ): void {
518
- if (
519
- thinkingLevel !== "off" ||
520
- payload.reasoning_effort !== undefined ||
521
- typeof payload.model !== "string"
522
- )
523
- return;
524
- const noReasoningEffort = noReasoningEffortByModelId.get(payload.model);
525
- if (noReasoningEffort) payload.reasoning_effort = noReasoningEffort;
526
- }
527
-
528
- export function supportsReasoningModelId(id: string): boolean {
529
- const { base, effort, thinking } = parseModelId(id);
530
- if (effort || thinking) return true;
531
- if (base === "default" || base === "auto") return true;
532
- return /^(claude|composer|gemini|gpt|grok|kimi)(-|$)/i.test(base);
533
- }
534
-
535
- /**
536
- * Map only controls Cursor explicitly advertised. Null hides unsupported Pi
537
- * levels instead of silently routing them to a different Cursor effort.
538
- */
539
- export function buildEffortMap(efforts: Set<string>): CursorEffortMap {
540
- const supported = (effort: string): string | null => (efforts.has(effort) ? effort : null);
541
- return {
542
- off: supported("none"),
543
- minimal: supported("minimal"),
544
- low: supported("low"),
545
- // A bare Cursor model ID is the provider's default effort, equivalent to Pi medium.
546
- medium: efforts.has("medium") ? "medium" : supported(""),
547
- high: supported("high"),
548
- xhigh: supported("xhigh"),
549
- max: supported("max"),
550
- };
551
- }
552
-
553
- /** Dedup raw models: collapse effort variants into one entry with supportsReasoningEffort. */
554
- export function processModels(raw: CursorModel[]): ProcessedModel[] {
555
- // Group by (base, fast, thinking)
556
- const groups = new Map<
557
- string,
558
- {
559
- base: string;
560
- fast: boolean;
561
- thinking: boolean;
562
- efforts: Map<string, CursorModel>;
563
- }
564
- >();
565
-
566
- for (const model of raw) {
567
- const p = parseModelId(model.id);
568
- const key = `${p.base}|${p.fast}|${p.thinking}`;
569
- let g = groups.get(key);
570
- if (!g) {
571
- g = { base: p.base, fast: p.fast, thinking: p.thinking, efforts: new Map() };
572
- groups.set(key, g);
573
- }
574
- g.efforts.set(p.effort, model);
575
- }
576
-
577
- const result: ProcessedModel[] = [];
578
-
579
- for (const g of groups.values()) {
580
- const effortNames = new Set(g.efforts.keys());
581
-
582
- // Dedup when there are multiple effort variants, OR a single variant
583
- // whose effort is non-empty (e.g. claude-4.5-opus-high — strip the
584
- // mandatory effort suffix so the model appears as claude-4.5-opus
585
- // with effort mapping).
586
- const hasOnlyEffortVariants = g.efforts.size === 1 && !g.efforts.has("");
587
- const shouldDedup = effortNames.size >= 2 || hasOnlyEffortVariants;
588
- if (shouldDedup) {
589
- // Pick representative: prefer "medium" or default ("") for name/metadata
590
- const rep = g.efforts.get("medium") ?? g.efforts.get("") ?? [...g.efforts.values()][0]!;
591
-
592
- // Build deduped model ID: base + thinking/fast suffix (no effort)
593
- let id = g.base;
594
- if (g.thinking) id += "-thinking";
595
- if (g.fast) id += "-fast";
596
-
597
- const effortMap = buildEffortMap(effortNames);
598
- const rawModelByEffort = Object.fromEntries(
599
- [...g.efforts.entries()].map(([effort, model]) => [effort, model.id]),
600
- );
601
- const rawRoutingByEffort = Object.fromEntries(
602
- [...g.efforts.entries()].map(([effort, model]) => [
603
- effort,
604
- {
605
- modelId: model.requestedModelId ?? model.id,
606
- ...(model.parameters?.length ? { parameters: model.parameters } : {}),
607
- ...(model.requiresMaxMode ? { requiresMaxMode: true } : {}),
608
- ...(typeof model.requestedMaxMode === "boolean"
609
- ? { requestedMaxMode: model.requestedMaxMode }
610
- : {}),
611
- },
612
- ]),
613
- );
614
-
615
- result.push({
616
- ...rep,
617
- id,
618
- supportsEffort: true,
619
- effortMap,
620
- rawModelByEffort,
621
- rawRoutingByEffort,
622
- });
623
- } else {
624
- // Keep single entries as-is (base model without effort variants)
625
- for (const model of g.efforts.values()) {
626
- result.push({ ...model, supportsEffort: false });
627
- }
628
- }
629
- }
630
-
631
- return result.sort((a, b) => a.id.localeCompare(b.id));
632
- }
633
-
634
- export function modelConfig(m: ProcessedModel) {
635
- const input = (m.supportsImages === false ? ["text"] : ["text", "image"]) as ("text" | "image")[];
636
- return {
637
- id: m.id,
638
- name: m.name,
639
- // Pi's thinking control must only appear when Cursor exposed selectable
640
- // effort variants. A model name alone is not evidence of a controllable level.
641
- reasoning: m.supportsEffort,
642
- ...(m.supportsEffort &&
643
- m.effortMap && {
644
- thinkingLevelMap: m.effortMap,
645
- }),
646
- input,
647
- cost: estimateModelCost(m.id),
648
- contextWindow: m.contextWindow,
649
- maxTokens: m.maxTokens,
650
- };
651
- }
652
-
653
- const GPT55_VARIANTS = [
654
- {
655
- idPart: "",
656
- label: "272K",
657
- context: "272k",
658
- contextWindow: 272_000,
659
- requestedMaxMode: false,
660
- fastOptions: [false, true],
661
- },
662
- {
663
- idPart: "-max",
664
- label: "272K Max",
665
- context: "272k",
666
- contextWindow: 272_000,
667
- requestedMaxMode: true,
668
- fastOptions: [false, true],
669
- },
670
- {
671
- idPart: "-1m",
672
- label: "1M",
673
- context: "1m",
674
- contextWindow: 1_000_000,
675
- requestedMaxMode: true,
676
- fastOptions: [false],
677
- },
678
- ] as const;
679
-
680
- const GPT55_REASONING_LEVELS = [
681
- { suffix: "none", label: "None", value: "none" },
682
- { suffix: "low", label: "Low", value: "low" },
683
- { suffix: "medium", label: "", value: "medium" },
684
- { suffix: "high", label: "High", value: "high" },
685
- { suffix: "extra-high", label: "Extra High", value: "extra-high" },
686
- ] as const;
687
-
688
- function gpt55ParameterizedModels(): CursorModel[] {
689
- const models: CursorModel[] = [];
690
- for (const variant of GPT55_VARIANTS) {
691
- // Cursor treats maxMode as an orthogonal request flag. The model picker
692
- // cannot toggle Cursor-specific flags, so expose useful maxMode states as
693
- // explicit rows. Cursor's metadata does not include context=1m + fast=true,
694
- // so the 1M variant intentionally has fast=false only.
695
- for (const fast of variant.fastOptions) {
696
- for (const reasoning of GPT55_REASONING_LEVELS) {
697
- const id = `gpt-5.5${variant.idPart}-${reasoning.suffix}${fast ? "-fast" : ""}`;
698
- const nameParts = ["GPT-5.5", variant.label, reasoning.label, fast ? "Fast" : ""].filter(
699
- Boolean,
700
- );
701
- models.push({
702
- id,
703
- name: nameParts.join(" "),
704
- reasoning: true,
705
- contextWindow: variant.contextWindow,
706
- maxTokens: 64_000,
707
- requestedModelId: "gpt-5.5",
708
- requiresMaxMode: variant.context === "1m",
709
- requestedMaxMode: variant.requestedMaxMode,
710
- parameters: [
711
- { id: "context", value: variant.context },
712
- { id: "reasoning", value: reasoning.value },
713
- { id: "fast", value: String(fast) },
714
- ],
715
- });
716
- }
717
- }
718
- }
719
- return models;
720
- }
721
-
722
- function parameterValue(parameters: CursorModelParameter[], id: string): string | undefined {
723
- return parameters.find((parameter) => parameter.id === id)?.value;
724
- }
725
-
726
- function contextWindowFromParameter(context: string | undefined, fallback = 200_000): number {
727
- if (context === "272k") return 272_000;
728
- if (context === "1m") return 1_000_000;
729
- const k = context?.match(/^(\d+)k$/i)?.[1];
730
- if (k) return Number(k) * 1_000;
731
- const m = context?.match(/^(\d+)m$/i)?.[1];
732
- if (m) return Number(m) * 1_000_000;
733
- return fallback;
734
- }
735
-
736
- function cursorEffortSuffix(value: string): string {
737
- return value;
738
- }
739
-
740
- function cursorEffortLabel(value: string): string {
741
- return (
742
- GPT55_REASONING_LEVELS.find((level) => level.value === value)?.label ||
743
- ({ xhigh: "Extra High", max: "Max", none: "None" } as Record<string, string>)[value] ||
744
- value.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
745
- );
746
- }
747
-
748
- function metadataEffortParameterId(
749
- variant: CursorParameterizedVariant,
750
- ): "reasoning" | "effort" | undefined {
751
- if (variant.parameters.some((parameter) => parameter.id === "reasoning")) return "reasoning";
752
- if (variant.parameters.some((parameter) => parameter.id === "effort")) return "effort";
753
- return undefined;
754
- }
755
-
756
- function isDefaultContext(context: string | undefined): boolean {
757
- if (!context) return true;
758
- return context === "200k" || context === "272k" || context === "300k";
759
- }
760
-
761
- function contextIdPart(context: string | undefined): string {
762
- return context && !isDefaultContext(context) ? `-${context.toLowerCase()}` : "";
763
- }
764
-
765
- function contextLabel(context: string | undefined): string | undefined {
766
- if (!context || isDefaultContext(context)) return undefined;
767
- return context.toUpperCase();
768
- }
769
-
770
- function maxModeIdPart(
771
- modelName: string,
772
- context: string | undefined,
773
- requestedMaxMode: boolean,
774
- hasEffortParameter: boolean,
775
- ): string {
776
- // 1M context already names the Max/extended-context selection. For default
777
- // context windows, expose maxMode as an explicit row suffix. If the Cursor
778
- // model ID already contains "max" (for example gpt-5.1-codex-max), or if
779
- // this row has no effort parameter, use a clearer suffix so the model parser
780
- // does not confuse Max Mode with a Cursor effort value.
781
- if (!requestedMaxMode || context === "1m") return "";
782
- return !hasEffortParameter || /(^|-)max($|-)/i.test(modelName) ? "-max-mode" : "-max";
783
- }
784
-
785
- function maxModeLabel(
786
- modelName: string,
787
- context: string | undefined,
788
- requestedMaxMode: boolean,
789
- hasEffortParameter: boolean,
790
- ): string | undefined {
791
- const idPart = maxModeIdPart(modelName, context, requestedMaxMode, hasEffortParameter);
792
- if (!idPart) return undefined;
793
- return idPart === "-max-mode" ? "Max Mode" : "Max";
794
- }
795
-
796
- function parameterizedBaseId(
797
- modelName: string,
798
- variant: CursorParameterizedVariant,
799
- requestedMaxMode: boolean,
800
- hasEffortParameter: boolean,
801
- ): string {
802
- const context = parameterValue(variant.parameters, "context");
803
- return `${modelName}${contextIdPart(context)}${maxModeIdPart(modelName, context, requestedMaxMode, hasEffortParameter)}`;
804
- }
805
-
806
- function parameterizedBaseLabel(
807
- model: CursorParameterizedModel,
808
- variant: CursorParameterizedVariant,
809
- requestedMaxMode: boolean,
810
- hasEffortParameter: boolean,
811
- ): string[] {
812
- const context = parameterValue(variant.parameters, "context");
813
- return [
814
- model.clientDisplayName || model.name,
815
- contextLabel(context),
816
- maxModeLabel(model.name, context, requestedMaxMode, hasEffortParameter),
817
- ].filter(Boolean) as string[];
818
- }
819
-
820
- function hasVariantParameterSet(
821
- model: CursorParameterizedModel,
822
- parameters: CursorModelParameter[],
823
- ): boolean {
824
- const normalized = normalizeParameterValues(parameters);
825
- return model.variants.some(
826
- (variant) => normalizeParameterValues(variant.parameters) === normalized,
827
- );
828
- }
829
-
830
- function normalizeParameterValues(parameters: CursorModelParameter[]): string {
831
- return parameters
832
- .map((parameter) => `${parameter.id}=${parameter.value}`)
833
- .sort()
834
- .join(";");
835
- }
836
-
837
- function buildParameterizedRowsFromGroup(options: {
838
- model: CursorParameterizedModel;
839
- variants: CursorParameterizedVariant[];
840
- requestedMaxMode: boolean;
841
- effortParameterId?: "reasoning" | "effort";
842
- }): CursorModel[] {
843
- const first = options.variants[0];
844
- if (!first) return [];
845
- if (options.requestedMaxMode && !first.isMaxMode && !options.model.supportsMaxMode) return [];
846
-
847
- const context = parameterValue(first.parameters, "context");
848
- const fast = parameterValue(first.parameters, "fast") === "true";
849
- const thinking = parameterValue(first.parameters, "thinking") === "true";
850
- const hasEffortParameter = Boolean(options.effortParameterId);
851
- const baseId = parameterizedBaseId(
852
- options.model.name,
853
- first,
854
- options.requestedMaxMode,
855
- hasEffortParameter,
856
- );
857
- const baseLabelParts = parameterizedBaseLabel(
858
- options.model,
859
- first,
860
- options.requestedMaxMode,
861
- hasEffortParameter,
862
- );
863
- const contextWindow = contextWindowFromParameter(
864
- context,
865
- options.requestedMaxMode
866
- ? (options.model.contextTokenLimitForMaxMode ?? options.model.contextTokenLimit ?? 200_000)
867
- : (options.model.contextTokenLimit ?? 200_000),
868
- );
869
-
870
- return options.variants.flatMap((variant) => {
871
- const parameters = variant.parameters.map((parameter) => ({
872
- id: parameter.id,
873
- value: parameter.value,
874
- }));
875
- if (!hasVariantParameterSet(options.model, parameters)) return [];
876
-
877
- const effort = options.effortParameterId
878
- ? parameterValue(variant.parameters, options.effortParameterId)
879
- : undefined;
880
- const id = options.effortParameterId
881
- ? `${baseId}-${cursorEffortSuffix(effort!)}${thinking ? "-thinking" : ""}${fast ? "-fast" : ""}`
882
- : `${baseId}${thinking ? "-thinking" : ""}${fast ? "-fast" : ""}`;
883
- const name = [
884
- ...baseLabelParts,
885
- effort ? cursorEffortLabel(effort) : undefined,
886
- thinking ? "Thinking" : undefined,
887
- fast ? "Fast" : undefined,
888
- ]
889
- .filter(Boolean)
890
- .join(" ");
891
-
892
- return [
893
- {
894
- id,
895
- name,
896
- reasoning: Boolean(options.effortParameterId) || thinking,
897
- contextWindow,
898
- maxTokens: 64_000,
899
- requestedModelId: options.model.name,
900
- requiresMaxMode: variant.isMaxMode,
901
- requestedMaxMode: options.requestedMaxMode,
902
- supportsImages: options.model.supportsImages,
903
- parameters,
904
- } satisfies CursorModel,
905
- ];
906
- });
907
- }
908
-
909
- function parameterGroupKey(
910
- variant: CursorParameterizedVariant,
911
- effortParameterId?: string,
912
- ): string {
913
- const params = variant.parameters
914
- .filter((parameter) => parameter.id !== effortParameterId)
915
- .map((parameter) => `${parameter.id}=${parameter.value}`)
916
- .sort()
917
- .join(";");
918
- return `${variant.isMaxMode ? "max" : "nonmax"}|${params}`;
919
- }
920
-
921
- function shouldGenerateSyntheticMaxRows(
922
- model: CursorParameterizedModel,
923
- variant: CursorParameterizedVariant,
924
- ): boolean {
925
- // Cursor's metadata has both per-variant isMaxMode and model-level
926
- // supportsMaxMode. Some supported Max Mode combinations are represented only
927
- // by supportsMaxMode=true over a non-Max parameter set, so expose explicit
928
- // max-mode rows for every such advertised parameter set.
929
- return model.supportsMaxMode === true && !variant.isMaxMode;
930
- }
931
-
932
- export function modelsFromParameterizedMetadata(
933
- parameterizedModels: CursorParameterizedModel[],
934
- ): CursorModel[] {
935
- const rows: CursorModel[] = [];
936
- for (const model of parameterizedModels) {
937
- const groups = new Map<
938
- string,
939
- { effortParameterId?: "reasoning" | "effort"; variants: CursorParameterizedVariant[] }
940
- >();
941
- for (const variant of model.variants) {
942
- if (variant.parameters.length === 0) continue;
943
- const effortParameterId = metadataEffortParameterId(variant);
944
- const key = parameterGroupKey(variant, effortParameterId);
945
- const group = groups.get(key) ?? { effortParameterId, variants: [] };
946
- group.variants.push(variant);
947
- groups.set(key, group);
948
- }
949
-
950
- for (const group of groups.values()) {
951
- const first = group.variants[0];
952
- if (!first) continue;
953
- rows.push(
954
- ...buildParameterizedRowsFromGroup({
955
- model,
956
- variants: group.variants,
957
- requestedMaxMode: first.isMaxMode,
958
- effortParameterId: group.effortParameterId,
959
- }),
960
- );
961
- if (shouldGenerateSyntheticMaxRows(model, first)) {
962
- rows.push(
963
- ...buildParameterizedRowsFromGroup({
964
- model,
965
- variants: group.variants,
966
- requestedMaxMode: true,
967
- effortParameterId: group.effortParameterId,
968
- }),
969
- );
970
- }
971
- }
972
- }
973
- return rows;
974
- }
975
-
976
- function normalizeDisplayModel(model: CursorModel): CursorModel {
977
- if (model.id !== "default") return model;
978
- return {
979
- ...model,
980
- id: "auto",
981
- name: model.name && model.name !== "default" ? model.name : "Auto",
982
- requestedModelId: model.requestedModelId ?? "default",
983
- };
984
- }
985
-
986
- export function augmentCursorModels(
987
- raw: CursorModel[],
988
- parameterizedModels: CursorParameterizedModel[] = [],
989
- ): CursorModel[] {
990
- const byId = new Map<string, CursorModel>();
991
- const imageSupportByModelId = new Map(
992
- parameterizedModels
993
- .filter((model) => typeof model.supportsImages === "boolean")
994
- .map((model) => [model.name, model.supportsImages!]),
995
- );
996
- for (const model of raw.map(normalizeDisplayModel)) {
997
- const lookupId = model.requestedModelId ?? model.id;
998
- const metadataSupportsImages = imageSupportByModelId.get(lookupId);
999
- byId.set(model.id, {
1000
- ...model,
1001
- ...(model.supportsImages === undefined && metadataSupportsImages !== undefined
1002
- ? { supportsImages: metadataSupportsImages }
1003
- : {}),
1004
- });
1005
- }
1006
-
1007
- const metadataRows =
1008
- modelsFromParameterizedMetadata(parameterizedModels).map(normalizeDisplayModel);
1009
- for (const model of metadataRows) byId.set(model.id, model);
1010
-
1011
- // Fallback for static/offline discovery. Cursor exposes GPT-5.5 context as
1012
- // parameters (272K vs 1M), not distinct backend model IDs.
1013
- if (metadataRows.length === 0 && raw.some((model) => /^gpt-5\.5(?:-|$)/.test(model.id))) {
1014
- for (const model of gpt55ParameterizedModels()) byId.set(model.id, model);
1015
- }
1016
-
1017
- return [...byId.values()];
1018
- }
1019
-
1020
- export const FALLBACK_MODELS: CursorModel[] = augmentCursorModels(
1021
- rawFallbackModels as CursorModel[],
1022
- ).map((model) => ({
1023
- ...model,
1024
- reasoning: supportsReasoningModelId(model.id),
1025
- }));
1026
-
1027
- // ── Extension ──
1028
-
1029
- const CURSOR_PROVIDER_ID = "cursor";
1030
-
1031
- async function getStoredCursorOAuthAccessToken(): Promise<
1032
- { accessToken: string; source: CredentialSource } | undefined
1033
- > {
1034
- const credential = readStoredCredential(CURSOR_PROVIDER_ID);
1035
- if (!credential || credential.type !== "oauth") return undefined;
1036
-
1037
- if (Date.now() < credential.expires && credential.access) {
1038
- return { accessToken: credential.access, source: "pi_oauth" };
1039
- }
1040
-
1041
- // Refresh is handled by Pi's OAuth runtime on demand; avoid writing auth.json here.
1042
- if (credential.refresh) {
1043
- try {
1044
- const refreshed = await refreshCursorToken(credential.refresh);
1045
- return { accessToken: refreshed.access, source: "pi_oauth_refresh" };
1046
- } catch {
1047
- return undefined;
1048
- }
1049
- }
1050
- return undefined;
1051
- }
1052
-
1053
- async function getStartupCursorAccessToken(options?: {
1054
- forceRefresh?: boolean;
1055
- }): Promise<{ accessToken: string; source: CredentialSource } | undefined> {
1056
- const systemToken = await resolveSystemCursorAccessToken(options);
1057
- if (systemToken) return systemToken;
1058
- return getStoredCursorOAuthAccessToken();
1059
- }
1060
-
1061
- function isTokenNearExpiry(token: string, skewMs = 60_000): boolean {
1062
- try {
1063
- return Date.now() >= getTokenExpiry(token) - skewMs;
1064
- } catch {
1065
- return true;
1066
- }
1067
- }
1068
-
1069
- export function registerSessionLifecycleCleanup(pi: ExtensionAPI) {
1070
- const cleanupCurrentSession = (_event: unknown, ctx: ExtensionContext) => {
1071
- debugExtensionLog("session.cleanup_hook", {
1072
- sessionId: ctx.sessionManager.getSessionId(),
1073
- leafId: ctx.sessionManager.getLeafId?.(),
1074
- });
1075
- cleanupSessionState(ctx.sessionManager.getSessionId());
1076
- };
1077
-
1078
- pi.on("session_before_switch", cleanupCurrentSession);
1079
- pi.on("session_before_fork", cleanupCurrentSession);
1080
- pi.on("session_before_tree", cleanupCurrentSession);
1081
- pi.on("session_shutdown", cleanupCurrentSession);
1082
- }
1083
-
1084
- function registerExtensionDebugHooks(pi: ExtensionAPI) {
1085
- if (!isExtensionDebugEnabled()) return;
1086
-
1087
- pi.on("message_start", async (event, ctx) => {
1088
- if (ctx.model?.provider !== "cursor") return;
1089
- debugExtensionLog("message.start", {
1090
- sessionId: ctx.sessionManager.getSessionId(),
1091
- leafId: ctx.sessionManager.getLeafId?.(),
1092
- model: ctx.model?.id,
1093
- message: summarizeMessage((event as { message?: unknown }).message),
1094
- });
1095
- });
1096
-
1097
- pi.on("message_update", async (event, ctx) => {
1098
- if (ctx.model?.provider !== "cursor") return;
1099
- const typedEvent = event as {
1100
- message?: unknown;
1101
- assistantMessageEvent?: Record<string, unknown>;
1102
- };
1103
- debugExtensionLog("message.update", {
1104
- sessionId: ctx.sessionManager.getSessionId(),
1105
- leafId: ctx.sessionManager.getLeafId?.(),
1106
- model: ctx.model?.id,
1107
- assistantMessageEvent: typedEvent.assistantMessageEvent
1108
- ? {
1109
- type: typedEvent.assistantMessageEvent.type,
1110
- delta: truncateDebugValue(
1111
- String(
1112
- (typedEvent.assistantMessageEvent as Record<string, unknown>).delta ??
1113
- (typedEvent.assistantMessageEvent as Record<string, unknown>).content ??
1114
- "",
1115
- ),
1116
- ),
1117
- }
1118
- : undefined,
1119
- message: summarizeMessage(typedEvent.message),
1120
- });
1121
- });
1122
-
1123
- pi.on("message_end", async (event, ctx) => {
1124
- if (ctx.model?.provider !== "cursor") return;
1125
- debugExtensionLog("message.end", {
1126
- sessionId: ctx.sessionManager.getSessionId(),
1127
- leafId: ctx.sessionManager.getLeafId?.(),
1128
- model: ctx.model?.id,
1129
- message: summarizeMessage((event as { message?: unknown }).message),
1130
- branch: summarizeBranchTail(ctx),
1131
- });
1132
- });
1133
-
1134
- pi.on("context", async (event, ctx) => {
1135
- if (ctx.model?.provider !== "cursor") return;
1136
- const typedEvent = event as { messages?: unknown[] };
1137
- debugExtensionLog("context", {
1138
- sessionId: ctx.sessionManager.getSessionId(),
1139
- leafId: ctx.sessionManager.getLeafId?.(),
1140
- model: ctx.model?.id,
1141
- messageCount: Array.isArray(typedEvent.messages) ? typedEvent.messages.length : undefined,
1142
- messages: Array.isArray(typedEvent.messages)
1143
- ? typedEvent.messages.slice(-8).map((message) => summarizeMessage(message))
1144
- : undefined,
1145
- branch: summarizeBranchTail(ctx),
1146
- });
1147
- });
1148
-
1149
- pi.on("turn_end", async (event, ctx) => {
1150
- if (ctx.model?.provider !== "cursor") return;
1151
- const typedEvent = event as { turnIndex?: number; message?: unknown; toolResults?: unknown[] };
1152
- debugExtensionLog("turn.end", {
1153
- sessionId: ctx.sessionManager.getSessionId(),
1154
- leafId: ctx.sessionManager.getLeafId?.(),
1155
- model: ctx.model?.id,
1156
- turnIndex: typedEvent.turnIndex,
1157
- message: summarizeMessage(typedEvent.message),
1158
- toolResults: Array.isArray(typedEvent.toolResults)
1159
- ? typedEvent.toolResults.map((message) => summarizeMessage(message))
1160
- : undefined,
1161
- branch: summarizeBranchTail(ctx),
1162
- });
1163
- });
1164
-
1165
- debugExtensionLog("extension.debug_hooks_registered", {
1166
- logFile: getExtensionDebugLogFilePath(),
1167
- });
1168
- }
1169
-
1170
- export default async function (pi: ExtensionAPI) {
1171
- // Current access token, updated by login/refresh/getApiKey
1172
- let currentToken = "";
1173
- let currentTokenSource: CredentialSource | "none" = "none";
1174
- let noReasoningEffortByModelId = new Map<string, string>();
1175
- let rawModelByEffortByModelId = new Map<string, Record<string, CursorModelRouting>>();
1176
- let lastRegisteredModels: ProcessedModel[] = [];
1177
-
1178
- setSystemCredentialsPolicy(resolveSystemCredentialPolicy());
1179
- setLastClientVersion(getCursorClientVersion());
1180
-
1181
- const getAccessToken = async (options?: { forceRefresh?: boolean }) => {
1182
- const needsRefresh =
1183
- options?.forceRefresh ||
1184
- !currentToken ||
1185
- (currentTokenSource !== "env" && isTokenNearExpiry(currentToken));
1186
-
1187
- if (needsRefresh) {
1188
- const resolved = await getStartupCursorAccessToken({
1189
- forceRefresh: options?.forceRefresh || isTokenNearExpiry(currentToken || "x"),
1190
- });
1191
- if (resolved) {
1192
- currentToken = resolved.accessToken;
1193
- currentTokenSource = resolved.source;
1194
- setLastTokenSource(resolved.source);
1195
- } else if (options?.forceRefresh) {
1196
- // Keep previous token only if force refresh failed and we still have one.
1197
- setLastTokenSource(currentTokenSource || "none");
1198
- }
1199
- }
1200
-
1201
- if (!currentToken) {
1202
- const consentHint = systemCredentialsAllowed()
1203
- ? ""
1204
- : " System credential reuse is disabled (PI_CURSOR_SYSTEM_CREDENTIALS=0).";
1205
- throw new Error(
1206
- `Not logged in to Cursor. Run /login cursor or log in via Cursor CLI.${consentHint}`,
1207
- );
1208
- }
1209
- setLastTokenSource(currentTokenSource);
1210
- return currentToken;
1211
- };
1212
-
1213
- const skipDedup = !!process.env.PI_CURSOR_RAW_MODELS;
1214
-
1215
- registerSessionLifecycleCleanup(pi);
1216
- registerExtensionDebugHooks(pi);
1217
- debugExtensionLog("extension.start", {
1218
- mode: "native-streamSimple",
1219
- debugLogFile: isExtensionDebugEnabled() ? getExtensionDebugLogFilePath() : undefined,
1220
- });
1221
-
1222
- const startupModels = await discoverStartupModels();
1223
- register(pi, startupModels.rawModels, startupModels.parameterizedModels);
1224
-
1225
- pi.registerCommand("cursor.models", {
1226
- description: "List Cursor runtime models registered by this provider",
1227
- handler: async (args, ctx: ExtensionCommandContext) => {
1228
- const all = /\ball\b/i.test(args || "");
1229
- const rows = all
1230
- ? lastRegisteredModels
1231
- : lastRegisteredModels.filter((m) => !/tab_|chat_/i.test(m.id));
1232
- const lines = [
1233
- `Cursor models (${rows.length}${all ? " all" : ""})`,
1234
- `endpoint=${getCursorAgentUrl()}`,
1235
- "",
1236
- ];
1237
- const maxId = Math.max(8, ...rows.map((m) => m.id.length));
1238
- for (const m of rows) {
1239
- const processed = m as ProcessedModel;
1240
- const levels = Object.entries(processed.effortMap ?? {})
1241
- .filter(([, cursorEffort]) => typeof cursorEffort === "string")
1242
- .map(([level]) => level)
1243
- .join("/");
1244
- const flags = [
1245
- processed.supportsEffort ? "thinking" : "",
1246
- levels ? `levels=${levels}` : "",
1247
- m.supportsImages ? "images" : "",
1248
- ]
1249
- .filter(Boolean)
1250
- .join(",");
1251
- lines.push(
1252
- `${m.id.padEnd(maxId)} ctx ${String(m.contextWindow ?? "?").padStart(7)} ${m.name || ""}${flags ? ` [${flags}]` : ""}`,
1253
- );
1254
- }
1255
- if (!rows.length) lines.push("No models registered. Run /login cursor first.");
1256
- const text = lines.join("\n");
1257
- if (ctx.hasUI) ctx.ui.notify(text, "info");
1258
- console.log(text);
1259
- },
1260
- });
1261
-
1262
- pi.registerCommand("cursor.usage", {
1263
- description: "Show Cursor plan quota and on-demand spend",
1264
- handler: async (_args, ctx: ExtensionCommandContext) => {
1265
- try {
1266
- const text = formatCursorUsage(await getCursorUsageSummary(getAccessToken));
1267
- if (ctx.hasUI) ctx.ui.notify(text, "info");
1268
- console.log(text);
1269
- } catch (error) {
1270
- const text = `Cursor usage unavailable: ${redactSecrets(
1271
- error instanceof Error ? error.message : String(error),
1272
- )}`;
1273
- if (ctx.hasUI) ctx.ui.notify(text, "error");
1274
- console.error(text);
1275
- }
1276
- },
1277
- });
1278
-
1279
- pi.registerCommand("cursor.doctor", {
1280
- description: "Show sanitized Cursor provider diagnostics",
1281
- handler: async (_args, ctx: ExtensionCommandContext) => {
1282
- const d = getLastDiagnostics();
1283
- const lines = [
1284
- `provider=${CURSOR_PROVIDER_ID}`,
1285
- `agentUrl=${getCursorAgentUrl()}`,
1286
- `clientVersion=${d.clientVersion || getCursorClientVersion()}`,
1287
- `tokenSource=${d.tokenSource || currentTokenSource || "none"}`,
1288
- `systemCredentials=${d.systemCredentials || resolveSystemCredentialPolicy()}`,
1289
- `lastResolvedRuntimeModel=${d.resolvedRuntimeModel || "none"}`,
1290
- `availableModels=${d.availableModels || lastRegisteredModels.length || "none"}`,
1291
- `matchedModel=${d.matchedModelDebug || "none"}`,
1292
- `lastEndpoint=${d.endpoint || "none"}`,
1293
- `lastStatus=${d.status ?? "none"}`,
1294
- `lastRpc=${d.lastRpc || "none"}`,
1295
- `lastRecoverySkipReason=${d.lastRecoverySkipReason || "none"}`,
1296
- `lastError=${d.error ? redactSecrets(d.error) : "none"}`,
1297
- "transport=native-streamSimple",
1298
- "runtimeCli=not-used",
1299
- "proxyPath=quarantined-internal",
1300
- "commands=/cursor.models /cursor.usage /cursor.doctor",
1301
- "hint=On wire errors set PI_CURSOR_CLIENT_VERSION or re-login; opt out of Keychain/IDE scrape with PI_CURSOR_SYSTEM_CREDENTIALS=0",
1302
- ];
1303
- const text = lines.join("\n");
1304
- if (ctx.hasUI) ctx.ui.notify(`Cursor doctor\n${text}`, "info");
1305
- console.log(text);
1306
- },
1307
- });
1308
-
1309
- async function discoverStartupModels(): Promise<{
1310
- rawModels: CursorModel[];
1311
- parameterizedModels: CursorParameterizedModel[];
1312
- }> {
1313
- if (process.env.PI_OFFLINE) return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
1314
-
1315
- let startupToken: { accessToken: string; source: CredentialSource } | undefined;
1316
- try {
1317
- startupToken = await getStartupCursorAccessToken();
1318
- } catch (err) {
1319
- debugExtensionLog("model_discovery.startup.token_failed", {
1320
- message: err instanceof Error ? err.message : String(err),
1321
- });
1322
- }
1323
-
1324
- if (!startupToken) {
1325
- debugExtensionLog("model_discovery.startup.skipped", { reason: "no_cursor_oauth_token" });
1326
- return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
1327
- }
1328
-
1329
- try {
1330
- currentToken = startupToken.accessToken;
1331
- currentTokenSource = startupToken.source;
1332
- const [discovered, parameterized] = await Promise.all([
1333
- getCursorModels(startupToken.accessToken),
1334
- getCursorParameterizedModels(startupToken.accessToken),
1335
- ]);
1336
- debugExtensionLog("model_discovery.startup", {
1337
- tokenSource: startupToken.source,
1338
- discoveredCount: discovered.length,
1339
- parameterizedCount: parameterized.length,
1340
- });
1341
- if (discovered.length > 0 || parameterized.length > 0) {
1342
- return {
1343
- rawModels: discovered.length > 0 ? discovered : FALLBACK_MODELS,
1344
- parameterizedModels: parameterized,
1345
- };
1346
- }
1347
- } catch (err) {
1348
- debugExtensionLog("model_discovery.startup.failed", {
1349
- tokenSource: startupToken.source,
1350
- message: err instanceof Error ? err.message : String(err),
1351
- });
1352
- }
1353
-
1354
- return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
1355
- }
1356
-
1357
- function register(
1358
- pi: ExtensionAPI,
1359
- rawModels: CursorModel[],
1360
- parameterizedModels: CursorParameterizedModel[] = [],
1361
- ) {
1362
- const augmentedModels = augmentCursorModels(rawModels, parameterizedModels);
1363
- const processed = skipDedup
1364
- ? augmentedModels.map((m) => ({ ...m, supportsEffort: false }) as ProcessedModel)
1365
- : processModels(augmentedModels);
1366
- lastRegisteredModels = processed;
1367
- setLastAvailableModels(
1368
- processed
1369
- .map((m) => m.id)
1370
- .slice(0, 24)
1371
- .join(","),
1372
- );
1373
- noReasoningEffortByModelId = buildNoReasoningEffortLookup(processed);
1374
- rawModelByEffortByModelId = buildRawModelLookup(processed);
1375
-
1376
- pi.registerProvider("cursor", {
1377
- baseUrl: getCursorAgentUrl(),
1378
- api: "cursor-native",
1379
- streamSimple: createCursorNativeStream({
1380
- getAccessToken,
1381
- getNoReasoningEffortByModelId: () => noReasoningEffortByModelId,
1382
- getRawModelRoutingByModelId: () => rawModelByEffortByModelId,
1383
- }),
1384
- models: processed.map(modelConfig),
1385
- oauth: {
1386
- name: "Cursor",
1387
-
1388
- async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
1389
- const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
1390
- callbacks.onAuth({ url: loginUrl });
1391
- const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier);
1392
- currentToken = accessToken;
1393
- currentTokenSource = "pi_oauth";
1394
-
1395
- // Discover real models and re-register
1396
- const [discovered, parameterized] = await Promise.all([
1397
- getCursorModels(accessToken),
1398
- getCursorParameterizedModels(accessToken),
1399
- ]);
1400
- if (discovered.length > 0 || parameterized.length > 0) {
1401
- register(pi, discovered.length > 0 ? discovered : FALLBACK_MODELS, parameterized);
1402
- }
1403
-
1404
- return {
1405
- refresh: refreshToken,
1406
- access: accessToken,
1407
- expires: getTokenExpiry(accessToken),
1408
- };
1409
- },
1410
-
1411
- async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
1412
- const refreshed = await refreshCursorToken(credentials.refresh);
1413
- currentToken = refreshed.access;
1414
- currentTokenSource = "pi_oauth_refresh";
1415
-
1416
- // Discover real models on refresh too
1417
- const [discovered, parameterized] = await Promise.all([
1418
- getCursorModels(refreshed.access),
1419
- getCursorParameterizedModels(refreshed.access),
1420
- ]);
1421
- if (discovered.length > 0 || parameterized.length > 0) {
1422
- register(pi, discovered.length > 0 ? discovered : FALLBACK_MODELS, parameterized);
1423
- }
1424
-
1425
- return refreshed as OAuthCredentials;
1426
- },
1427
-
1428
- getApiKey(credentials: OAuthCredentials): string {
1429
- currentToken = credentials.access;
1430
- currentTokenSource = "pi_oauth";
1431
- return "cursor-native";
1432
- },
1433
- },
1434
- });
1435
- }
1436
- }