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.
- package/CHANGELOG.md +27 -0
- package/README.md +19 -7
- package/docs/cursor-live-smoke-checklist.md +271 -0
- package/docs/cursor-model-ux-spec.md +12 -3
- package/docs/cursor-native-tool-replay.md +16 -5
- package/package.json +2 -1
- package/src/context.ts +180 -5
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-native-tool-display.ts +14 -5
- package/src/cursor-pi-tool-bridge.ts +565 -28
- package/src/cursor-provider.ts +200 -128
- package/src/cursor-question-tool.ts +7 -2
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +14 -19
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +20 -3
|
@@ -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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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:
|
|
25
|
-
pi
|
|
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:
|
|
32
|
-
reset:
|
|
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
|
+
};
|
package/src/cursor-state.ts
CHANGED
|
@@ -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
|
|
78
|
-
|
|
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:
|
|
115
|
+
function getCurrentCursorMetadata(ctx: { model: CursorFastControlsModel }) {
|
|
92
116
|
const model = ctx.model;
|
|
93
|
-
if (model
|
|
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:
|
|
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
|
+
}
|