pi-cursor-sdk 0.1.15 → 0.1.16

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.
@@ -0,0 +1,372 @@
1
+ import type {
2
+ ExtensionHandler,
3
+ SessionBeforeTreeEvent,
4
+ SessionCompactEvent,
5
+ SessionShutdownEvent,
6
+ SessionTreeEvent,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { createHash } from "node:crypto";
9
+ import { Agent } from "@cursor/sdk";
10
+ import type { ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk";
11
+ import type { Context } from "@earendil-works/pi-ai";
12
+ import {
13
+ getRegisteredCursorPiToolBridge,
14
+ type CursorPiBridgeToolRequest,
15
+ type CursorPiToolBridgeRun,
16
+ } from "./cursor-pi-tool-bridge.js";
17
+ import { computeCursorContextFingerprint } from "./context.js";
18
+ import { getCursorSessionScopeKey, onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
19
+
20
+ export interface SessionCursorAgentSendState {
21
+ bootstrapped: boolean;
22
+ contextFingerprint: string;
23
+ }
24
+
25
+ export interface SessionCursorAgentLease {
26
+ scopeKey: string;
27
+ agent: SDKAgent;
28
+ bridgeRun?: CursorPiToolBridgeRun;
29
+ sendState: SessionCursorAgentSendState;
30
+ created: boolean;
31
+ }
32
+
33
+ interface SessionCursorAgentPoolEntry {
34
+ poolKey: string;
35
+ scopeKey: string;
36
+ agent?: SDKAgent;
37
+ bridgeRun?: CursorPiToolBridgeRun;
38
+ sendState: SessionCursorAgentSendState;
39
+ creating?: Promise<SessionCursorAgentPoolEntry>;
40
+ creationGeneration?: number;
41
+ }
42
+
43
+ class SessionCursorAgentCreationSupersededError extends Error {
44
+ constructor() {
45
+ super("Cursor session agent creation was superseded");
46
+ this.name = "SessionCursorAgentCreationSupersededError";
47
+ }
48
+ }
49
+
50
+ export class SessionCursorAgentScopeClosedError extends Error {
51
+ constructor() {
52
+ super("Cursor session agent scope is closed");
53
+ this.name = "SessionCursorAgentScopeClosedError";
54
+ }
55
+ }
56
+
57
+ function assertScopeAcceptsAcquire(scopeKey: string): void {
58
+ if (terminalDisposedScopeKeys.has(scopeKey)) {
59
+ throw new SessionCursorAgentScopeClosedError();
60
+ }
61
+ }
62
+
63
+ function rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey: string, poolKey: string, error: unknown): void {
64
+ if (!(error instanceof SessionCursorAgentCreationSupersededError)) return;
65
+ const replacement = sessionAgentsByScope.get(scopeKey);
66
+ if (replacement && replacement.poolKey !== poolKey) {
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ interface SessionCursorAgentCreateParams {
72
+ apiKey: string;
73
+ cwd: string;
74
+ modelSelection: ModelSelection;
75
+ settingSources?: SettingSource[];
76
+ onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void;
77
+ createAgent?: typeof Agent.create;
78
+ }
79
+
80
+ interface CursorSessionAgentExtensionApi {
81
+ on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
82
+ on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
83
+ on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent>): void;
84
+ on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
85
+ on(event: "model_select", handler: ExtensionHandler<{ model: unknown }>): void;
86
+ }
87
+
88
+ const sessionAgentsByScope = new Map<string, SessionCursorAgentPoolEntry>();
89
+ const invalidatedScopeKeys = new Set<string>();
90
+ const terminalDisposedScopeKeys = new Set<string>();
91
+ const scopeCreationGenerations = new Map<string, number>();
92
+
93
+ function getScopeCreationGeneration(scopeKey: string): number {
94
+ return scopeCreationGenerations.get(scopeKey) ?? 0;
95
+ }
96
+
97
+ function invalidateScopeCreations(scopeKey: string): void {
98
+ scopeCreationGenerations.set(scopeKey, getScopeCreationGeneration(scopeKey) + 1);
99
+ }
100
+
101
+ function buildModelPoolKey(modelSelection: ModelSelection): string {
102
+ return JSON.stringify(modelSelection);
103
+ }
104
+
105
+ function buildSettingSourcesPoolKey(settingSources?: SettingSource[]): string {
106
+ return settingSources?.join(",") ?? "";
107
+ }
108
+
109
+ function buildApiKeyPoolKeyFingerprint(apiKey: string): string {
110
+ return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
111
+ }
112
+
113
+ function buildBridgePoolKeySuffix(): string {
114
+ const registeredBridge = getRegisteredCursorPiToolBridge();
115
+ if (!registeredBridge) return "bridge:absent";
116
+ return registeredBridge.getToolSurfaceSignature();
117
+ }
118
+
119
+ function buildSessionAgentPoolKey(scopeKey: string, params: SessionCursorAgentCreateParams): string {
120
+ return [
121
+ scopeKey,
122
+ params.cwd,
123
+ buildModelPoolKey(params.modelSelection),
124
+ buildSettingSourcesPoolKey(params.settingSources),
125
+ buildApiKeyPoolKeyFingerprint(params.apiKey),
126
+ buildBridgePoolKeySuffix(),
127
+ ].join("\0");
128
+ }
129
+
130
+ async function disposePoolEntry(entry: SessionCursorAgentPoolEntry): Promise<void> {
131
+ entry.bridgeRun?.cancel("Cursor session agent disposed");
132
+ try {
133
+ await entry.bridgeRun?.dispose();
134
+ } catch {
135
+ // disposal failure should not block session replacement
136
+ }
137
+ if (!entry.agent) return;
138
+ try {
139
+ await entry.agent[Symbol.asyncDispose]();
140
+ } catch {
141
+ // disposal failure should not block session replacement
142
+ }
143
+ }
144
+
145
+ async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?: boolean }): Promise<void> {
146
+ invalidateScopeCreations(scopeKey);
147
+ if (options?.terminal) {
148
+ terminalDisposedScopeKeys.add(scopeKey);
149
+ }
150
+ const entry = sessionAgentsByScope.get(scopeKey);
151
+ invalidatedScopeKeys.delete(scopeKey);
152
+ if (!entry) return;
153
+ sessionAgentsByScope.delete(scopeKey);
154
+ if (entry.creating || !entry.agent) return;
155
+ await disposePoolEntry(entry);
156
+ }
157
+
158
+ function createInitialSendState(): SessionCursorAgentSendState {
159
+ return { bootstrapped: false, contextFingerprint: "" };
160
+ }
161
+
162
+ function bindBridgeToolRequest(
163
+ entry: SessionCursorAgentPoolEntry,
164
+ onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void,
165
+ ): void {
166
+ entry.bridgeRun?.setOnToolRequest(onBridgeToolRequest);
167
+ }
168
+
169
+ function leaseFromEntry(
170
+ entry: SessionCursorAgentPoolEntry,
171
+ scopeKey: string,
172
+ params: SessionCursorAgentCreateParams,
173
+ created: boolean,
174
+ ): SessionCursorAgentLease {
175
+ bindBridgeToolRequest(entry, params.onBridgeToolRequest);
176
+ return {
177
+ scopeKey,
178
+ agent: entry.agent!,
179
+ bridgeRun: entry.bridgeRun,
180
+ sendState: entry.sendState,
181
+ created,
182
+ };
183
+ }
184
+
185
+ async function createSessionAgentEntry(
186
+ scopeKey: string,
187
+ poolKey: string,
188
+ params: SessionCursorAgentCreateParams,
189
+ ): Promise<SessionCursorAgentPoolEntry> {
190
+ const registeredBridge = getRegisteredCursorPiToolBridge();
191
+ let bridgeRun: CursorPiToolBridgeRun | undefined;
192
+ if (registeredBridge) {
193
+ bridgeRun = await registeredBridge.createRun({
194
+ onToolRequest: params.onBridgeToolRequest,
195
+ });
196
+ if (!bridgeRun.enabled || !bridgeRun.mcpServers) {
197
+ await bridgeRun.dispose();
198
+ bridgeRun = undefined;
199
+ }
200
+ }
201
+
202
+ const resolvedPoolKey = buildSessionAgentPoolKey(scopeKey, params);
203
+ const createAgent = params.createAgent ?? Agent.create;
204
+ let agent: SDKAgent;
205
+ try {
206
+ agent = await createAgent({
207
+ apiKey: params.apiKey,
208
+ model: params.modelSelection,
209
+ local: params.settingSources ? { cwd: params.cwd, settingSources: params.settingSources } : { cwd: params.cwd },
210
+ ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}),
211
+ });
212
+ } catch (error) {
213
+ if (bridgeRun) {
214
+ bridgeRun.cancel("Cursor session agent create failed");
215
+ try {
216
+ await bridgeRun.dispose();
217
+ } catch {
218
+ // bridge disposal failure should not mask agent create failure
219
+ }
220
+ }
221
+ throw error;
222
+ }
223
+
224
+ return {
225
+ poolKey: resolvedPoolKey,
226
+ scopeKey,
227
+ agent,
228
+ bridgeRun,
229
+ sendState: createInitialSendState(),
230
+ };
231
+ }
232
+
233
+ export { shouldBootstrapCursorSend } from "./context.js";
234
+
235
+ export function commitSessionAgentSend(scopeKey: string, context: Context, bootstrapped: boolean): void {
236
+ const entry = sessionAgentsByScope.get(scopeKey);
237
+ if (!entry) return;
238
+ entry.sendState.bootstrapped = bootstrapped || entry.sendState.bootstrapped;
239
+ entry.sendState.contextFingerprint = computeCursorContextFingerprint(context);
240
+ }
241
+
242
+ export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeKey()): void {
243
+ invalidatedScopeKeys.add(scopeKey);
244
+ }
245
+
246
+ export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise<SessionCursorAgentLease> {
247
+ const scopeKey = getCursorSessionScopeKey();
248
+ assertScopeAcceptsAcquire(scopeKey);
249
+ if (invalidatedScopeKeys.has(scopeKey)) {
250
+ await disposePoolEntryForScope(scopeKey);
251
+ }
252
+
253
+ const poolKey = buildSessionAgentPoolKey(scopeKey, params);
254
+ const existing = sessionAgentsByScope.get(scopeKey);
255
+ if (existing?.poolKey === poolKey && !existing.creating) {
256
+ return leaseFromEntry(existing, scopeKey, params, false);
257
+ }
258
+
259
+ if (existing && existing.poolKey !== poolKey) {
260
+ await disposePoolEntryForScope(scopeKey);
261
+ }
262
+
263
+ let entry = sessionAgentsByScope.get(scopeKey);
264
+ if (entry?.creating) {
265
+ try {
266
+ await entry.creating;
267
+ } catch (error) {
268
+ if (error instanceof SessionCursorAgentCreationSupersededError) {
269
+ assertScopeAcceptsAcquire(scopeKey);
270
+ rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey, poolKey, error);
271
+ } else {
272
+ throw error;
273
+ }
274
+ }
275
+ entry = sessionAgentsByScope.get(scopeKey);
276
+ if (entry && entry.poolKey === poolKey && entry.agent && !entry.creating) {
277
+ return leaseFromEntry(entry, scopeKey, params, false);
278
+ }
279
+ }
280
+
281
+ assertScopeAcceptsAcquire(scopeKey);
282
+ const creationGeneration = getScopeCreationGeneration(scopeKey);
283
+ const placeholder: SessionCursorAgentPoolEntry = {
284
+ poolKey,
285
+ scopeKey,
286
+ sendState: createInitialSendState(),
287
+ creationGeneration,
288
+ };
289
+ const creating = createSessionAgentEntry(scopeKey, poolKey, params).then(async (createdEntry) => {
290
+ const stillCurrent =
291
+ sessionAgentsByScope.get(scopeKey) === placeholder &&
292
+ getScopeCreationGeneration(scopeKey) === placeholder.creationGeneration;
293
+ if (!stillCurrent) {
294
+ await disposePoolEntry(createdEntry);
295
+ if (sessionAgentsByScope.get(scopeKey) === placeholder) {
296
+ sessionAgentsByScope.delete(scopeKey);
297
+ }
298
+ throw new SessionCursorAgentCreationSupersededError();
299
+ }
300
+ sessionAgentsByScope.set(scopeKey, createdEntry);
301
+ return createdEntry;
302
+ });
303
+ placeholder.creating = creating;
304
+ sessionAgentsByScope.set(scopeKey, placeholder);
305
+
306
+ try {
307
+ const createdEntry = await creating;
308
+ return leaseFromEntry(createdEntry, scopeKey, params, true);
309
+ } catch (error) {
310
+ if (sessionAgentsByScope.get(scopeKey) === placeholder) {
311
+ sessionAgentsByScope.delete(scopeKey);
312
+ }
313
+ if (error instanceof SessionCursorAgentCreationSupersededError) {
314
+ assertScopeAcceptsAcquire(scopeKey);
315
+ rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey, poolKey, error);
316
+ return acquireSessionCursorAgent(params);
317
+ }
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ export async function resetSessionCursorAgent(scopeKey: string = getCursorSessionScopeKey()): Promise<void> {
323
+ await disposePoolEntryForScope(scopeKey);
324
+ }
325
+
326
+ export async function disposeSessionCursorAgent(scopeKey: string = getCursorSessionScopeKey()): Promise<void> {
327
+ await disposePoolEntryForScope(scopeKey, { terminal: true });
328
+ }
329
+
330
+ export async function disposeAllSessionCursorAgents(): Promise<void> {
331
+ const scopeKeys = [...new Set([...sessionAgentsByScope.keys(), ...terminalDisposedScopeKeys])];
332
+ await Promise.all(scopeKeys.map((scopeKey) => disposePoolEntryForScope(scopeKey, { terminal: true })));
333
+ invalidatedScopeKeys.clear();
334
+ terminalDisposedScopeKeys.clear();
335
+ }
336
+
337
+ export function registerCursorSessionAgent(_pi: CursorSessionAgentExtensionApi): void {
338
+ onCursorSessionScopeKeyChange((previousScopeKey) => {
339
+ void disposePoolEntryForScope(previousScopeKey, { terminal: true });
340
+ });
341
+ _pi.on("session_shutdown", async (event) => {
342
+ if (event.reason === "reload") {
343
+ await resetSessionCursorAgent();
344
+ return;
345
+ }
346
+ await disposeSessionCursorAgent();
347
+ });
348
+ _pi.on("session_compact", () => {
349
+ invalidateSessionAgent();
350
+ });
351
+ _pi.on("session_before_tree", () => {
352
+ invalidateSessionAgent();
353
+ });
354
+ _pi.on("session_tree", async () => {
355
+ await resetSessionCursorAgent();
356
+ });
357
+ _pi.on("model_select", () => {
358
+ invalidateSessionAgent();
359
+ });
360
+ }
361
+
362
+ export const __testUtils = {
363
+ sessionAgentsByScope,
364
+ invalidateSessionAgent,
365
+ disposeSessionCursorAgent,
366
+ resetSessionCursorAgent,
367
+ disposeAllSessionCursorAgents,
368
+ buildApiKeyPoolKeyFingerprint,
369
+ buildSessionAgentPoolKey,
370
+ SessionCursorAgentCreationSupersededError,
371
+ SessionCursorAgentScopeClosedError,
372
+ };
@@ -1,8 +1,13 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ getCursorSessionCwdFromScope,
3
+ registerCursorSessionScope,
4
+ __testUtils as cursorSessionScopeTestUtils,
5
+ } from "./cursor-session-scope.js";
6
+ import type { ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
2
7
 
3
- const state = {
4
- sessionCwd: process.cwd(),
5
- };
8
+ interface CursorSessionCwdExtensionApi {
9
+ on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
10
+ }
6
11
 
7
12
  /**
8
13
  * Pi session cwd when known; falls back to process.cwd() before session_start.
@@ -10,24 +15,14 @@ const state = {
10
15
  * changes without a new session_start event are not reflected here.
11
16
  */
12
17
  export function getCursorSessionCwd(): string {
13
- return state.sessionCwd;
14
- }
15
-
16
- function setCursorSessionCwd(cwd: string): void {
17
- state.sessionCwd = cwd;
18
- }
19
-
20
- function resetCursorSessionCwd(): void {
21
- state.sessionCwd = process.cwd();
18
+ return getCursorSessionCwdFromScope();
22
19
  }
23
20
 
24
- export function registerCursorSessionCwd(pi: ExtensionAPI): void {
25
- pi.on("session_start", (_event, ctx) => {
26
- setCursorSessionCwd(ctx.cwd);
27
- });
21
+ export function registerCursorSessionCwd(pi: CursorSessionCwdExtensionApi): void {
22
+ registerCursorSessionScope(pi);
28
23
  }
29
24
 
30
25
  export const __testUtils = {
31
- set: setCursorSessionCwd,
32
- reset: resetCursorSessionCwd,
26
+ set: cursorSessionScopeTestUtils.set,
27
+ reset: cursorSessionScopeTestUtils.reset,
33
28
  };
@@ -0,0 +1,65 @@
1
+ import type { ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ interface CursorSessionScopeExtensionApi {
4
+ on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
5
+ }
6
+
7
+ const ANONYMOUS_SESSION_SCOPE_KEY = "__anonymous__";
8
+
9
+ type CursorSessionScopeChangeHandler = (previousScopeKey: string) => void;
10
+
11
+ const state = {
12
+ sessionCwd: process.cwd(),
13
+ sessionFile: undefined as string | undefined,
14
+ };
15
+
16
+ let scopeChangeHandler: CursorSessionScopeChangeHandler | undefined;
17
+
18
+ /**
19
+ * Pi session file when known; used to scope reused Cursor SDK agents to one pi session.
20
+ */
21
+ export function getCursorSessionFile(): string | undefined {
22
+ return state.sessionFile;
23
+ }
24
+
25
+ /**
26
+ * Stable scope key for session-agent pooling. Falls back to a process-local anonymous key
27
+ * before the first session_start (tests and early startup).
28
+ */
29
+ export function getCursorSessionScopeKey(): string {
30
+ return state.sessionFile ?? ANONYMOUS_SESSION_SCOPE_KEY;
31
+ }
32
+
33
+ export function getCursorSessionCwdFromScope(): string {
34
+ return state.sessionCwd;
35
+ }
36
+
37
+ function setCursorSessionScope(cwd: string, sessionFile: string | undefined): void {
38
+ state.sessionCwd = cwd;
39
+ state.sessionFile = sessionFile;
40
+ }
41
+
42
+ function resetCursorSessionScope(): void {
43
+ state.sessionCwd = process.cwd();
44
+ state.sessionFile = undefined;
45
+ }
46
+
47
+ export function onCursorSessionScopeKeyChange(handler: CursorSessionScopeChangeHandler): void {
48
+ scopeChangeHandler = handler;
49
+ }
50
+
51
+ export function registerCursorSessionScope(pi: CursorSessionScopeExtensionApi): void {
52
+ pi.on("session_start", (_event, ctx) => {
53
+ const previousScopeKey = getCursorSessionScopeKey();
54
+ setCursorSessionScope(ctx.cwd, ctx.sessionManager?.getSessionFile?.() ?? undefined);
55
+ if (previousScopeKey !== getCursorSessionScopeKey()) {
56
+ scopeChangeHandler?.(previousScopeKey);
57
+ }
58
+ });
59
+ }
60
+
61
+ export const __testUtils = {
62
+ ANONYMOUS_SESSION_SCOPE_KEY,
63
+ set: setCursorSessionScope,
64
+ reset: resetCursorSessionScope,
65
+ };
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { getCursorModelMetadata } from "./model-discovery.js";
6
6
 
@@ -17,6 +17,26 @@ interface CursorGlobalConfig {
17
17
  fastDefaults?: Record<string, boolean>;
18
18
  }
19
19
 
20
+ type CursorFastControlsModel =
21
+ | Pick<NonNullable<ExtensionContext["model"]>, "id" | "provider" | "api">
22
+ | undefined;
23
+
24
+ type CursorFastControlsContext = {
25
+ model: CursorFastControlsModel;
26
+ ui: Pick<ExtensionContext["ui"], "notify" | "setStatus">;
27
+ sessionManager: Pick<ExtensionContext["sessionManager"], "getBranch">;
28
+ };
29
+
30
+ interface CursorFastControlsExtensionApi extends Pick<ExtensionAPI, "appendEntry" | "getFlag" | "registerFlag"> {
31
+ registerCommand(name: string, options: {
32
+ description?: string;
33
+ handler: (args: string, ctx: CursorFastControlsContext) => Promise<void> | void;
34
+ }): void;
35
+ on(event: "session_start", handler: (event: SessionStartEvent, ctx: CursorFastControlsContext) => Promise<void> | void): void;
36
+ on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: CursorFastControlsContext) => Promise<void> | void): void;
37
+ on(event: "turn_start", handler: (event: unknown, ctx: CursorFastControlsContext) => Promise<void> | void): void;
38
+ }
39
+
20
40
  const sessionFastPreferences = new Map<string, boolean>();
21
41
  let globalFastPreferences = new Map<string, boolean>();
22
42
  let cliForceFast = false;
@@ -56,7 +76,7 @@ function saveGlobalFastPreferences(): void {
56
76
  writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
57
77
  }
58
78
 
59
- function restoreSessionFastPreferences(ctx: ExtensionContext): void {
79
+ function restoreSessionFastPreferences(ctx: { sessionManager: Pick<ExtensionContext["sessionManager"], "getBranch"> }): void {
60
80
  sessionFastPreferences.clear();
61
81
  for (const entry of ctx.sessionManager.getBranch()) {
62
82
  if (entry.type !== "custom" || entry.customType !== FAST_ENTRY_TYPE) continue;
@@ -74,23 +94,27 @@ function getEffectiveFast(baseModelId: string, modelId: string): boolean | undef
74
94
  return sessionFastPreferences.get(baseModelId) ?? globalFastPreferences.get(baseModelId) ?? metadata.defaultFast;
75
95
  }
76
96
 
77
- function updateCursorStatus(ctx: ExtensionContext, model = ctx.model): void {
78
- if (model?.provider !== CURSOR_PROVIDER) {
97
+ function isCursorModel(model: CursorFastControlsModel): boolean {
98
+ return model?.provider === CURSOR_PROVIDER || model?.api === "cursor-sdk";
99
+ }
100
+
101
+ function updateCursorStatus(ctx: { model: CursorFastControlsModel; ui: Pick<ExtensionContext["ui"], "setStatus"> }, model = ctx.model): void {
102
+ if (!model || !isCursorModel(model)) {
79
103
  ctx.ui.setStatus("cursor", undefined);
80
104
  return;
81
105
  }
82
106
  const metadata = getCursorModelMetadata(model.id);
83
- if (!metadata) {
107
+ if (!metadata?.supportsFast) {
84
108
  ctx.ui.setStatus("cursor", undefined);
85
109
  return;
86
110
  }
87
111
  const fast = getEffectiveFast(metadata.baseModelId, model.id);
88
- ctx.ui.setStatus("cursor", fast ? "cursor fast" : undefined);
112
+ ctx.ui.setStatus("cursor", fast === true ? "cursor fast" : undefined);
89
113
  }
90
114
 
91
- function getCurrentCursorMetadata(ctx: ExtensionContext) {
115
+ function getCurrentCursorMetadata(ctx: { model: CursorFastControlsModel }) {
92
116
  const model = ctx.model;
93
- if (model?.provider !== CURSOR_PROVIDER) return undefined;
117
+ if (!model || !isCursorModel(model)) return undefined;
94
118
  return getCursorModelMetadata(model.id);
95
119
  }
96
120
 
@@ -102,7 +126,7 @@ function restoreMapValue(map: Map<string, boolean>, key: string, previous: boole
102
126
  }
103
127
  }
104
128
 
105
- function persistFastPreference(pi: ExtensionAPI, baseModelId: string, fast: boolean): void {
129
+ function persistFastPreference(pi: Pick<ExtensionAPI, "appendEntry">, baseModelId: string, fast: boolean): void {
106
130
  const previousSession = sessionFastPreferences.get(baseModelId);
107
131
  const previousGlobal = globalFastPreferences.get(baseModelId);
108
132
  let savedGlobal = false;
@@ -132,7 +156,7 @@ export function getEffectiveFastForModelId(modelId: string): boolean | undefined
132
156
  return getEffectiveFast(metadata.baseModelId, modelId);
133
157
  }
134
158
 
135
- export function registerCursorFastControls(pi: ExtensionAPI): void {
159
+ export function registerCursorFastControls(pi: CursorFastControlsExtensionApi): void {
136
160
  pi.registerFlag("cursor-fast", {
137
161
  description: "Force Cursor fast mode for this run when the selected Cursor model supports it",
138
162
  type: "boolean",
@@ -188,6 +212,10 @@ export function registerCursorFastControls(pi: ExtensionAPI): void {
188
212
  pi.on("model_select", async (event, ctx) => {
189
213
  updateCursorStatus(ctx, event.model);
190
214
  });
215
+
216
+ pi.on("turn_start", async (_event, ctx) => {
217
+ updateCursorStatus(ctx);
218
+ });
191
219
  }
192
220
 
193
221
  export const __testUtils = {
@@ -0,0 +1,71 @@
1
+ import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
2
+ import {
3
+ CURSOR_APPROX_CHARS_PER_TOKEN,
4
+ CURSOR_IMAGE_TOKEN_ESTIMATE,
5
+ estimateCursorContextTokens,
6
+ estimateCursorPromptTokens,
7
+ estimateCursorTextTokens,
8
+ type CursorPrompt,
9
+ type CursorPromptOptions,
10
+ } from "./context.js";
11
+
12
+ export interface CursorUsagePromptOptions extends CursorPromptOptions {
13
+ maxInputTokens: number;
14
+ charsPerToken: number;
15
+ imageTokenEstimate: number;
16
+ }
17
+
18
+ function getPromptInputTokenBudget(model: Model<Api>): number {
19
+ const outputReserveTokens = Math.min(model.maxTokens, Math.max(1, Math.floor(model.contextWindow * 0.2)));
20
+ return Math.max(1, model.contextWindow - outputReserveTokens);
21
+ }
22
+
23
+ export function getCursorPromptOptions(model: Model<Api>): CursorUsagePromptOptions {
24
+ return {
25
+ maxInputTokens: getPromptInputTokenBudget(model),
26
+ charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN,
27
+ imageTokenEstimate: CURSOR_IMAGE_TOKEN_ESTIMATE,
28
+ };
29
+ }
30
+
31
+ export function estimateCursorPromptInputTokens(prompt: CursorPrompt, options: Pick<CursorPromptOptions, "charsPerToken" | "imageTokenEstimate">): number {
32
+ return estimateCursorPromptTokens(prompt, options);
33
+ }
34
+
35
+ function stringifyUsageValue(value: unknown): string {
36
+ try {
37
+ return JSON.stringify(value) ?? "";
38
+ } catch {
39
+ return String(value);
40
+ }
41
+ }
42
+
43
+ export function estimateCursorAssistantSessionOutputTokens(message: AssistantMessage): number {
44
+ const parts = message.content
45
+ .map((block) => {
46
+ if (block.type === "text") return block.text;
47
+ if (block.type === "thinking") return block.thinking;
48
+ if (block.type === "toolCall") {
49
+ return `Tool call (${block.name}, call ${block.id}): ${stringifyUsageValue(block.arguments)}`;
50
+ }
51
+ return "";
52
+ })
53
+ .filter(Boolean);
54
+ return estimateCursorTextTokens(parts.join("\n"), { charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN });
55
+ }
56
+
57
+ function withAssistantMessage(context: Context, partial: AssistantMessage): Context {
58
+ return { ...context, messages: [...context.messages, partial] };
59
+ }
60
+
61
+ export function estimateCursorContextTotalTokens(partial: AssistantMessage, model: Model<Api>, context: Context): number {
62
+ return estimateCursorContextTokens(withAssistantMessage(context, partial), getCursorPromptOptions(model));
63
+ }
64
+
65
+ export function applyCursorApproximateUsage(partial: AssistantMessage, model: Model<Api>, context: Context, sessionInputTokens: number): void {
66
+ partial.usage.input = sessionInputTokens;
67
+ partial.usage.output = estimateCursorAssistantSessionOutputTokens(partial);
68
+ partial.usage.cacheRead = 0;
69
+ partial.usage.cacheWrite = 0;
70
+ partial.usage.totalTokens = estimateCursorContextTotalTokens(partial, model, context);
71
+ }