@offbynan/pi-cursor-provider 0.2.0

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/index.ts ADDED
@@ -0,0 +1,714 @@
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. Local proxy translating OpenAI format → 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 "./cursor-models-raw.json";
16
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
+ import type {
18
+ OAuthCredentials,
19
+ OAuthLoginCallbacks,
20
+ } from "@mariozechner/pi-ai";
21
+ import { appendFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join as pathJoin } from "node:path";
24
+ import {
25
+ generateCursorAuthParams,
26
+ getTokenExpiry,
27
+ pollCursorAuth,
28
+ refreshCursorToken,
29
+ } from "./auth.js";
30
+ import {
31
+ cleanupSessionState,
32
+ getCursorModels,
33
+ startProxy,
34
+ type CursorModel,
35
+ } from "./proxy.js";
36
+
37
+ // ── Cost estimation ──
38
+
39
+ interface ModelCost {
40
+ input: number;
41
+ output: number;
42
+ cacheRead: number;
43
+ cacheWrite: number;
44
+ }
45
+
46
+ let extensionDebugLogFilePath: string | undefined;
47
+
48
+ function isExtensionDebugEnabled(): boolean {
49
+ const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
50
+ return !!raw && raw !== "0" && raw !== "false" && raw !== "off";
51
+ }
52
+
53
+ function getExtensionDebugLogFilePath(): string {
54
+ if (extensionDebugLogFilePath) return extensionDebugLogFilePath;
55
+ const configured =
56
+ process.env.PI_CURSOR_PROVIDER_EXTENSION_DEBUG_FILE?.trim();
57
+ if (configured) {
58
+ extensionDebugLogFilePath = configured;
59
+ return extensionDebugLogFilePath;
60
+ }
61
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
62
+ extensionDebugLogFilePath = pathJoin(
63
+ tmpdir(),
64
+ `pi-cursor-provider-extension-debug-${stamp}-${process.pid}.log`,
65
+ );
66
+ return extensionDebugLogFilePath;
67
+ }
68
+
69
+ function truncateDebugValue(value: string, max = 240): string {
70
+ return value.length > max
71
+ ? `${value.slice(0, max)}…<truncated ${value.length - max} chars>`
72
+ : value;
73
+ }
74
+
75
+ function summarizeContent(content: unknown): unknown {
76
+ if (typeof content === "string") return truncateDebugValue(content);
77
+ if (!Array.isArray(content)) return content;
78
+ return content.map((block) => {
79
+ if (!block || typeof block !== "object") return block;
80
+ const typed = block as Record<string, unknown>;
81
+ switch (typed.type) {
82
+ case "text":
83
+ return {
84
+ type: "text",
85
+ text: truncateDebugValue(String(typed.text ?? "")),
86
+ };
87
+ case "thinking":
88
+ return {
89
+ type: "thinking",
90
+ thinking: truncateDebugValue(String(typed.thinking ?? "")),
91
+ };
92
+ case "toolCall":
93
+ return {
94
+ type: "toolCall",
95
+ id: typed.id,
96
+ name: typed.name,
97
+ arguments: typed.arguments,
98
+ };
99
+ case "image":
100
+ return {
101
+ type: "image",
102
+ mimeType: typed.mimeType,
103
+ data: `<redacted base64 ${String(typed.data ?? "").length} chars>`,
104
+ };
105
+ default:
106
+ return typed;
107
+ }
108
+ });
109
+ }
110
+
111
+ function summarizeMessage(message: unknown): unknown {
112
+ if (!message || typeof message !== "object") return message;
113
+ const typed = message as Record<string, unknown>;
114
+ return {
115
+ role: typed.role,
116
+ stopReason: typed.stopReason,
117
+ toolCallId: typed.toolCallId,
118
+ toolName: typed.toolName,
119
+ isError: typed.isError,
120
+ errorMessage: typed.errorMessage,
121
+ content: summarizeContent(typed.content),
122
+ };
123
+ }
124
+
125
+ function summarizeBranchTail(
126
+ ctx: {
127
+ sessionManager?: {
128
+ getBranch?: () => unknown[];
129
+ getLeafId?: () => string;
130
+ getSessionId?: () => string;
131
+ };
132
+ },
133
+ limit = 6,
134
+ ): unknown {
135
+ try {
136
+ const branch = ctx.sessionManager?.getBranch?.();
137
+ if (!Array.isArray(branch)) return undefined;
138
+ return {
139
+ sessionId: ctx.sessionManager?.getSessionId?.(),
140
+ leafId: ctx.sessionManager?.getLeafId?.(),
141
+ size: branch.length,
142
+ tail: branch.slice(-limit).map((entry) => {
143
+ if (!entry || typeof entry !== "object") return entry;
144
+ const typed = entry as Record<string, unknown>;
145
+ return {
146
+ type: typed.type,
147
+ id: typed.id,
148
+ parentId: typed.parentId,
149
+ customType: typed.customType,
150
+ message: summarizeMessage(typed.message),
151
+ };
152
+ }),
153
+ };
154
+ } catch (error) {
155
+ return { error: error instanceof Error ? error.message : String(error) };
156
+ }
157
+ }
158
+
159
+ function summarizeProviderPayload(payload: unknown): unknown {
160
+ if (!payload || typeof payload !== "object") return payload;
161
+ const typed = payload as Record<string, unknown>;
162
+ const messages = Array.isArray(typed.messages)
163
+ ? typed.messages.map((message) => summarizeMessage(message)).slice(-8)
164
+ : undefined;
165
+ return {
166
+ model: typed.model,
167
+ stream: typed.stream,
168
+ pi_session_id: typed.pi_session_id,
169
+ messageCount: Array.isArray(typed.messages)
170
+ ? typed.messages.length
171
+ : undefined,
172
+ messages,
173
+ toolCount: Array.isArray(typed.tools) ? typed.tools.length : undefined,
174
+ };
175
+ }
176
+
177
+ function debugExtensionLog(
178
+ event: string,
179
+ data?: Record<string, unknown>,
180
+ ): void {
181
+ if (!isExtensionDebugEnabled()) return;
182
+ const payload = JSON.stringify({
183
+ ts: new Date().toISOString(),
184
+ pid: process.pid,
185
+ scope: "extension",
186
+ event,
187
+ ...data,
188
+ });
189
+ appendFileSync(getExtensionDebugLogFilePath(), `${payload}\n`, "utf8");
190
+ }
191
+
192
+ const MODEL_COST_TABLE: Record<string, ModelCost> = {
193
+ "claude-4-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
194
+ "claude-4.5-haiku": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
195
+ "claude-4.5-opus": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
196
+ "claude-4.5-sonnet": {
197
+ input: 3,
198
+ output: 15,
199
+ cacheRead: 0.3,
200
+ cacheWrite: 3.75,
201
+ },
202
+ "claude-4.6-opus": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
203
+ "claude-4.6-sonnet": {
204
+ input: 3,
205
+ output: 15,
206
+ cacheRead: 0.3,
207
+ cacheWrite: 3.75,
208
+ },
209
+ "composer-1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
210
+ "composer-1.5": { input: 3.5, output: 17.5, cacheRead: 0.35, cacheWrite: 0 },
211
+ "composer-2": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
212
+ "gemini-2.5-flash": {
213
+ input: 0.3,
214
+ output: 2.5,
215
+ cacheRead: 0.03,
216
+ cacheWrite: 0,
217
+ },
218
+ "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
219
+ "gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
220
+ "gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
221
+ "gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
222
+ "gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
223
+ "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
224
+ "gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
225
+ "gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
226
+ "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
227
+ "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
228
+ "grok-4.20": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
229
+ "kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
230
+ };
231
+
232
+ const MODEL_COST_PATTERNS: Array<{
233
+ match: (id: string) => boolean;
234
+ cost: ModelCost;
235
+ }> = [
236
+ {
237
+ match: (id) => /claude.*opus.*fast/i.test(id),
238
+ cost: { input: 30, output: 150, cacheRead: 3, cacheWrite: 37.5 },
239
+ },
240
+ {
241
+ match: (id) => /claude.*opus/i.test(id),
242
+ cost: MODEL_COST_TABLE["claude-4.6-opus"]!,
243
+ },
244
+ {
245
+ match: (id) => /claude.*haiku/i.test(id),
246
+ cost: MODEL_COST_TABLE["claude-4.5-haiku"]!,
247
+ },
248
+ {
249
+ match: (id) => /claude.*sonnet/i.test(id),
250
+ cost: MODEL_COST_TABLE["claude-4.6-sonnet"]!,
251
+ },
252
+ {
253
+ match: (id) => /composer/i.test(id),
254
+ cost: MODEL_COST_TABLE["composer-1"]!,
255
+ },
256
+ {
257
+ match: (id) => /gpt-5\.4.*mini/i.test(id),
258
+ cost: MODEL_COST_TABLE["gpt-5.4-mini"]!,
259
+ },
260
+ { match: (id) => /gpt-5\.4/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4"]! },
261
+ {
262
+ match: (id) => /gpt-5\.3/i.test(id),
263
+ cost: MODEL_COST_TABLE["gpt-5.3-codex"]!,
264
+ },
265
+ { match: (id) => /gpt-5\.2/i.test(id), cost: MODEL_COST_TABLE["gpt-5.2"]! },
266
+ {
267
+ match: (id) => /gpt-5.*mini/i.test(id),
268
+ cost: MODEL_COST_TABLE["gpt-5-mini"]!,
269
+ },
270
+ { match: (id) => /gpt-5/i.test(id), cost: MODEL_COST_TABLE["gpt-5"]! },
271
+ {
272
+ match: (id) => /gemini.*3\.1/i.test(id),
273
+ cost: MODEL_COST_TABLE["gemini-3.1-pro"]!,
274
+ },
275
+ {
276
+ match: (id) => /gemini.*flash/i.test(id),
277
+ cost: MODEL_COST_TABLE["gemini-2.5-flash"]!,
278
+ },
279
+ {
280
+ match: (id) => /gemini/i.test(id),
281
+ cost: MODEL_COST_TABLE["gemini-3-pro"]!,
282
+ },
283
+ { match: (id) => /grok/i.test(id), cost: MODEL_COST_TABLE["grok-4.20"]! },
284
+ { match: (id) => /kimi/i.test(id), cost: MODEL_COST_TABLE["kimi-k2.5"]! },
285
+ ];
286
+
287
+ const DEFAULT_COST: ModelCost = {
288
+ input: 3,
289
+ output: 15,
290
+ cacheRead: 0.3,
291
+ cacheWrite: 0,
292
+ };
293
+
294
+ function estimateModelCost(modelId: string): ModelCost {
295
+ const normalized = modelId.toLowerCase();
296
+ const exact = MODEL_COST_TABLE[normalized];
297
+ if (exact) return exact;
298
+ const stripped = normalized.replace(
299
+ /-(high|medium|low|preview|thinking|spark-preview|fast)$/g,
300
+ "",
301
+ );
302
+ const strippedMatch = MODEL_COST_TABLE[stripped];
303
+ if (strippedMatch) return strippedMatch;
304
+ return (
305
+ MODEL_COST_PATTERNS.find((p) => p.match(normalized))?.cost ?? DEFAULT_COST
306
+ );
307
+ }
308
+
309
+ // ── Effort-level dedup ──
310
+
311
+ const EFFORT_LEVELS = new Set([
312
+ "low",
313
+ "medium",
314
+ "high",
315
+ "xhigh",
316
+ "max",
317
+ "none",
318
+ ]);
319
+
320
+ interface ParsedModelId {
321
+ base: string; // model ID with effort stripped
322
+ effort: string; // effort level, or "" if no effort suffix
323
+ fast: boolean; // has -fast suffix
324
+ thinking: boolean; // has -thinking suffix
325
+ }
326
+
327
+ export function parseModelId(id: string): ParsedModelId {
328
+ let remaining = id;
329
+ let fast = false;
330
+ let thinking = false;
331
+
332
+ if (remaining.endsWith("-fast")) {
333
+ fast = true;
334
+ remaining = remaining.slice(0, -5);
335
+ }
336
+ if (remaining.endsWith("-thinking")) {
337
+ thinking = true;
338
+ remaining = remaining.slice(0, -9);
339
+ }
340
+
341
+ const lastDash = remaining.lastIndexOf("-");
342
+ if (lastDash >= 0) {
343
+ const suffix = remaining.slice(lastDash + 1);
344
+ if (EFFORT_LEVELS.has(suffix)) {
345
+ return {
346
+ base: remaining.slice(0, lastDash),
347
+ effort: suffix,
348
+ fast,
349
+ thinking,
350
+ };
351
+ }
352
+ }
353
+
354
+ return { base: remaining, effort: "", fast, thinking };
355
+ }
356
+
357
+ interface ProcessedModel extends CursorModel {
358
+ supportsEffort: boolean;
359
+ effortMap?: Record<string, string>;
360
+ }
361
+
362
+ export function supportsReasoningModelId(id: string): boolean {
363
+ const { base, effort, thinking } = parseModelId(id);
364
+ if (effort || thinking) return true;
365
+ if (base === "default") return true;
366
+ return /^(claude|composer|gemini|gpt|grok|kimi)(-|$)/i.test(base);
367
+ }
368
+
369
+ /**
370
+ * Ordered effort levels from lowest to highest.
371
+ * "" = default (no effort suffix in model ID).
372
+ */
373
+ const EFFORT_ORDER = [
374
+ "none",
375
+ "low",
376
+ "",
377
+ "medium",
378
+ "high",
379
+ "xhigh",
380
+ "max",
381
+ ] as const;
382
+
383
+ /**
384
+ * Build a reasoning-effort map from the set of available effort suffixes.
385
+ * For each pi effort level (minimal/low/medium/high/xhigh), picks the closest
386
+ * available cursor effort, falling back to the lowest available.
387
+ */
388
+ export function buildEffortMap(efforts: Set<string>): Record<string, string> {
389
+ const sorted = EFFORT_ORDER.filter((e) => efforts.has(e));
390
+ if (sorted.length === 0) return {};
391
+ const lowest = sorted[0]!;
392
+
393
+ const pick = (...targets: string[]) => {
394
+ for (const t of targets) if (efforts.has(t)) return t;
395
+ return lowest;
396
+ };
397
+
398
+ return {
399
+ minimal: pick("none", "low", ""),
400
+ low: pick("low", "none", ""),
401
+ medium: pick("medium", "", "low"),
402
+ high: pick("high", "medium", ""),
403
+ xhigh: pick("max", "xhigh", "high"),
404
+ };
405
+ }
406
+
407
+ /** Dedup raw models: collapse effort variants into one entry with supportsReasoningEffort. */
408
+ export function processModels(raw: CursorModel[]): ProcessedModel[] {
409
+ // Group by (base, fast, thinking)
410
+ const groups = new Map<
411
+ string,
412
+ {
413
+ base: string;
414
+ fast: boolean;
415
+ thinking: boolean;
416
+ efforts: Map<string, CursorModel>;
417
+ }
418
+ >();
419
+
420
+ for (const model of raw) {
421
+ const p = parseModelId(model.id);
422
+ const key = `${p.base}|${p.fast}|${p.thinking}`;
423
+ let g = groups.get(key);
424
+ if (!g) {
425
+ g = {
426
+ base: p.base,
427
+ fast: p.fast,
428
+ thinking: p.thinking,
429
+ efforts: new Map(),
430
+ };
431
+ groups.set(key, g);
432
+ }
433
+ g.efforts.set(p.effort, model);
434
+ }
435
+
436
+ const result: ProcessedModel[] = [];
437
+
438
+ for (const g of groups.values()) {
439
+ // Dedup when there are multiple effort variants, OR a single variant
440
+ // whose effort is non-empty (e.g. claude-4.5-opus-high — strip the
441
+ // mandatory effort suffix so the model appears as claude-4.5-opus
442
+ // with effort mapping).
443
+ const hasOnlyEffortVariants = g.efforts.size === 1 && !g.efforts.has("");
444
+ if (g.efforts.size >= 2 || hasOnlyEffortVariants) {
445
+ // Pick representative: prefer "medium" or default ("") for name/metadata
446
+ const rep =
447
+ g.efforts.get("medium") ??
448
+ g.efforts.get("") ??
449
+ [...g.efforts.values()][0]!;
450
+
451
+ // Build deduped model ID: base + thinking/fast suffix (no effort)
452
+ let id = g.base;
453
+ if (g.thinking) id += "-thinking";
454
+ if (g.fast) id += "-fast";
455
+
456
+ const effortMap = buildEffortMap(new Set(g.efforts.keys()));
457
+
458
+ result.push({ ...rep, id, supportsEffort: true, effortMap });
459
+ } else {
460
+ // Keep single entries as-is (base model without effort variants)
461
+ for (const model of g.efforts.values()) {
462
+ result.push({ ...model, supportsEffort: false });
463
+ }
464
+ }
465
+ }
466
+
467
+ return result.sort((a, b) => a.id.localeCompare(b.id));
468
+ }
469
+
470
+ function modelConfig(m: ProcessedModel) {
471
+ return {
472
+ id: m.id,
473
+ name: m.name,
474
+ reasoning: supportsReasoningModelId(m.id),
475
+ input: ["text", "image"] as ("text" | "image")[],
476
+ cost: estimateModelCost(m.id),
477
+ contextWindow: m.contextWindow,
478
+ maxTokens: m.maxTokens,
479
+ compat: {
480
+ supportsDeveloperRole: false,
481
+ supportsReasoningEffort: m.supportsEffort,
482
+ ...(m.supportsEffort &&
483
+ m.effortMap && {
484
+ reasoningEffortMap: m.effortMap,
485
+ }),
486
+ maxTokensField: "max_tokens" as const,
487
+ },
488
+ };
489
+ }
490
+
491
+ export const FALLBACK_MODELS: CursorModel[] = (
492
+ rawFallbackModels as CursorModel[]
493
+ ).map((model) => ({
494
+ ...model,
495
+ reasoning: supportsReasoningModelId(model.id),
496
+ }));
497
+
498
+ // ── Extension ──
499
+
500
+ export function registerSessionLifecycleCleanup(pi: ExtensionAPI) {
501
+ const cleanupCurrentSession = (
502
+ _event: unknown,
503
+ ctx: {
504
+ sessionManager: { getSessionId(): string; getLeafId?: () => string };
505
+ },
506
+ ) => {
507
+ debugExtensionLog("session.cleanup_hook", {
508
+ sessionId: ctx.sessionManager.getSessionId(),
509
+ leafId: ctx.sessionManager.getLeafId?.(),
510
+ });
511
+ cleanupSessionState(ctx.sessionManager.getSessionId());
512
+ };
513
+
514
+ pi.on("session_before_switch", cleanupCurrentSession);
515
+ pi.on("session_before_fork", cleanupCurrentSession);
516
+ pi.on("session_before_tree", cleanupCurrentSession);
517
+ pi.on("session_shutdown", cleanupCurrentSession);
518
+ }
519
+
520
+ function registerExtensionDebugHooks(pi: ExtensionAPI) {
521
+ if (!isExtensionDebugEnabled()) return;
522
+
523
+ pi.on("message_start", async (event, ctx) => {
524
+ if (ctx.model?.provider !== "cursor") return;
525
+ debugExtensionLog("message.start", {
526
+ sessionId: ctx.sessionManager.getSessionId(),
527
+ leafId: ctx.sessionManager.getLeafId?.(),
528
+ model: ctx.model?.id,
529
+ message: summarizeMessage((event as { message?: unknown }).message),
530
+ });
531
+ });
532
+
533
+ pi.on("message_update", async (event, ctx) => {
534
+ if (ctx.model?.provider !== "cursor") return;
535
+ const typedEvent = event as {
536
+ message?: unknown;
537
+ assistantMessageEvent?: Record<string, unknown>;
538
+ };
539
+ debugExtensionLog("message.update", {
540
+ sessionId: ctx.sessionManager.getSessionId(),
541
+ leafId: ctx.sessionManager.getLeafId?.(),
542
+ model: ctx.model?.id,
543
+ assistantMessageEvent: typedEvent.assistantMessageEvent
544
+ ? {
545
+ type: typedEvent.assistantMessageEvent.type,
546
+ delta: truncateDebugValue(
547
+ String(
548
+ (typedEvent.assistantMessageEvent as Record<string, unknown>)
549
+ .delta ??
550
+ (typedEvent.assistantMessageEvent as Record<string, unknown>)
551
+ .content ??
552
+ "",
553
+ ),
554
+ ),
555
+ }
556
+ : undefined,
557
+ message: summarizeMessage(typedEvent.message),
558
+ });
559
+ });
560
+
561
+ pi.on("message_end", async (event, ctx) => {
562
+ if (ctx.model?.provider !== "cursor") return;
563
+ debugExtensionLog("message.end", {
564
+ sessionId: ctx.sessionManager.getSessionId(),
565
+ leafId: ctx.sessionManager.getLeafId?.(),
566
+ model: ctx.model?.id,
567
+ message: summarizeMessage((event as { message?: unknown }).message),
568
+ branch: summarizeBranchTail(ctx),
569
+ });
570
+ });
571
+
572
+ pi.on("context", async (event, ctx) => {
573
+ if (ctx.model?.provider !== "cursor") return;
574
+ const typedEvent = event as { messages?: unknown[] };
575
+ debugExtensionLog("context", {
576
+ sessionId: ctx.sessionManager.getSessionId(),
577
+ leafId: ctx.sessionManager.getLeafId?.(),
578
+ model: ctx.model?.id,
579
+ messageCount: Array.isArray(typedEvent.messages)
580
+ ? typedEvent.messages.length
581
+ : undefined,
582
+ messages: Array.isArray(typedEvent.messages)
583
+ ? typedEvent.messages
584
+ .slice(-8)
585
+ .map((message) => summarizeMessage(message))
586
+ : undefined,
587
+ branch: summarizeBranchTail(ctx),
588
+ });
589
+ });
590
+
591
+ pi.on("turn_end", async (event, ctx) => {
592
+ if (ctx.model?.provider !== "cursor") return;
593
+ const typedEvent = event as {
594
+ turnIndex?: number;
595
+ message?: unknown;
596
+ toolResults?: unknown[];
597
+ };
598
+ debugExtensionLog("turn.end", {
599
+ sessionId: ctx.sessionManager.getSessionId(),
600
+ leafId: ctx.sessionManager.getLeafId?.(),
601
+ model: ctx.model?.id,
602
+ turnIndex: typedEvent.turnIndex,
603
+ message: summarizeMessage(typedEvent.message),
604
+ toolResults: Array.isArray(typedEvent.toolResults)
605
+ ? typedEvent.toolResults.map((message) => summarizeMessage(message))
606
+ : undefined,
607
+ branch: summarizeBranchTail(ctx),
608
+ });
609
+ });
610
+
611
+ debugExtensionLog("extension.debug_hooks_registered", {
612
+ logFile: getExtensionDebugLogFilePath(),
613
+ });
614
+ }
615
+
616
+ export default async function (pi: ExtensionAPI) {
617
+ // Current access token, updated by login/refresh/getApiKey
618
+ let currentToken = "";
619
+
620
+ // Start proxy eagerly — it just binds a port, no auth needed until a request arrives.
621
+ // The getAccessToken callback reads currentToken at request time.
622
+ const proxyReady = startProxy(async () => {
623
+ if (!currentToken)
624
+ throw new Error("Not logged in to Cursor. Run /login cursor");
625
+ return currentToken;
626
+ });
627
+
628
+ const skipDedup = !!process.env.PI_CURSOR_RAW_MODELS;
629
+
630
+ registerSessionLifecycleCleanup(pi);
631
+ registerExtensionDebugHooks(pi);
632
+ debugExtensionLog("extension.start", {
633
+ debugLogFile: isExtensionDebugEnabled()
634
+ ? getExtensionDebugLogFilePath()
635
+ : undefined,
636
+ });
637
+
638
+ pi.on("before_provider_request", (event, ctx) => {
639
+ const payload = event.payload as Record<string, unknown> | undefined;
640
+ if (payload && ctx.model?.provider === "cursor") {
641
+ payload.pi_session_id = ctx.sessionManager.getSessionId();
642
+ debugExtensionLog("before_provider_request", {
643
+ sessionId: ctx.sessionManager.getSessionId(),
644
+ leafId: ctx.sessionManager.getLeafId?.(),
645
+ model: ctx.model?.id,
646
+ payload: summarizeProviderPayload(payload),
647
+ branch: summarizeBranchTail(ctx),
648
+ });
649
+ }
650
+ return payload;
651
+ });
652
+
653
+ // Await proxy so models are registered before pi proceeds with model resolution.
654
+ const port = await proxyReady;
655
+ register(pi, port, FALLBACK_MODELS);
656
+
657
+ function register(pi: ExtensionAPI, port: number, rawModels: CursorModel[]) {
658
+ const baseUrl = `http://127.0.0.1:${port}/v1`;
659
+ const processed = skipDedup
660
+ ? rawModels.map(
661
+ (m) => ({ ...m, supportsEffort: false }) as ProcessedModel,
662
+ )
663
+ : processModels(rawModels);
664
+
665
+ pi.registerProvider("cursor", {
666
+ baseUrl,
667
+ api: "openai-completions",
668
+ models: processed.map(modelConfig),
669
+ oauth: {
670
+ name: "Cursor",
671
+
672
+ async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
673
+ const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
674
+ callbacks.onAuth({ url: loginUrl });
675
+ const { accessToken, refreshToken } = await pollCursorAuth(
676
+ uuid,
677
+ verifier,
678
+ );
679
+ currentToken = accessToken;
680
+
681
+ // Discover real models and re-register
682
+ const realPort = await proxyReady;
683
+ const discovered = await getCursorModels(accessToken);
684
+ if (discovered.length > 0) register(pi, realPort, discovered);
685
+
686
+ return {
687
+ refresh: refreshToken,
688
+ access: accessToken,
689
+ expires: getTokenExpiry(accessToken),
690
+ };
691
+ },
692
+
693
+ async refreshToken(
694
+ credentials: OAuthCredentials,
695
+ ): Promise<OAuthCredentials> {
696
+ const refreshed = await refreshCursorToken(credentials.refresh);
697
+ currentToken = refreshed.access;
698
+
699
+ // Discover real models on refresh too
700
+ const realPort = await proxyReady;
701
+ const discovered = await getCursorModels(refreshed.access);
702
+ if (discovered.length > 0) register(pi, realPort, discovered);
703
+
704
+ return refreshed;
705
+ },
706
+
707
+ getApiKey(credentials: OAuthCredentials): string {
708
+ currentToken = credentials.access;
709
+ return "cursor-proxy";
710
+ },
711
+ },
712
+ });
713
+ }
714
+ }