pi-cursor-sdk 0.1.18 → 0.1.20

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +59 -1
  3. package/docs/cursor-live-smoke-checklist.md +4 -1
  4. package/docs/cursor-model-ux-spec.md +7 -5
  5. package/docs/cursor-native-tool-replay.md +99 -3
  6. package/docs/cursor-testing-lessons.md +234 -5
  7. package/package.json +10 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  11. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  12. package/scripts/probe-mcp-coldstart.mjs +244 -0
  13. package/scripts/validate-smoke-jsonl.mjs +27 -3
  14. package/src/context.ts +45 -32
  15. package/src/cursor-agent-message-web-tools.ts +172 -0
  16. package/src/cursor-agents-context.ts +176 -0
  17. package/src/cursor-incomplete-tool-visibility.ts +124 -0
  18. package/src/cursor-live-run-coordinator.ts +18 -7
  19. package/src/cursor-mcp-timeout-override.ts +66 -11
  20. package/src/cursor-model.ts +12 -0
  21. package/src/cursor-native-tool-display-registration.ts +1 -4
  22. package/src/cursor-native-tool-display-replay.ts +65 -6
  23. package/src/cursor-native-tool-display-tools.ts +20 -0
  24. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  25. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  26. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  27. package/src/cursor-provider-errors.ts +96 -0
  28. package/src/cursor-provider-live-run-drain.ts +181 -62
  29. package/src/cursor-provider-turn-coordinator.ts +220 -33
  30. package/src/cursor-provider.ts +302 -93
  31. package/src/cursor-question-tool.ts +1 -4
  32. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  33. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  34. package/src/cursor-sdk-event-debug-session.ts +163 -0
  35. package/src/cursor-sdk-event-debug.ts +602 -0
  36. package/src/cursor-sensitive-text.ts +27 -7
  37. package/src/cursor-session-agent.ts +279 -82
  38. package/src/cursor-session-send-policy.ts +43 -0
  39. package/src/cursor-setting-sources.ts +29 -0
  40. package/src/cursor-state.ts +1 -5
  41. package/src/cursor-tool-lifecycle.ts +85 -0
  42. package/src/cursor-tool-names.ts +39 -0
  43. package/src/cursor-tool-transcript.ts +4 -2
  44. package/src/cursor-tool-visibility.ts +63 -0
  45. package/src/cursor-transcript-tool-formatters.ts +228 -5
  46. package/src/cursor-transcript-tool-specs.ts +135 -24
  47. package/src/cursor-transcript-utils.ts +12 -0
  48. package/src/cursor-web-tool-activity.ts +84 -0
  49. package/src/index.ts +4 -1
@@ -16,30 +16,63 @@ import {
16
16
  } from "./cursor-pi-tool-bridge.js";
17
17
  import { computeCursorContextFingerprint } from "./context.js";
18
18
  import { getCursorSessionScopeKey, onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
19
+ import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
19
20
 
20
21
  export interface SessionCursorAgentSendState {
21
22
  bootstrapped: boolean;
22
23
  contextFingerprint: string;
24
+ incrementalSendCount: number;
23
25
  }
24
26
 
25
27
  export interface SessionCursorAgentLease {
26
28
  scopeKey: string;
29
+ poolKey: string;
30
+ instanceId: number;
27
31
  agent: SDKAgent;
28
32
  bridgeRun?: CursorPiToolBridgeRun;
29
33
  sendState: SessionCursorAgentSendState;
30
34
  created: boolean;
35
+ commitSend(context: Context, bootstrapped: boolean): void;
36
+ trackRunCompletion(completion: Promise<unknown>): void;
31
37
  }
32
38
 
33
- interface SessionCursorAgentPoolEntry {
39
+ interface SessionCursorAgentPoolEntryBase {
34
40
  poolKey: string;
41
+ instanceId: number;
35
42
  scopeKey: string;
36
- agent?: SDKAgent;
37
- bridgeRun?: CursorPiToolBridgeRun;
38
43
  sendState: SessionCursorAgentSendState;
39
- creating?: Promise<SessionCursorAgentPoolEntry>;
40
- creationGeneration?: number;
41
44
  }
42
45
 
46
+ interface SessionCursorAgentCreatingEntry extends SessionCursorAgentPoolEntryBase {
47
+ status: "creating";
48
+ creating: Promise<SessionCursorAgentReadyEntry>;
49
+ creationGeneration: number;
50
+ }
51
+
52
+ interface SessionCursorAgentReadyEntry extends SessionCursorAgentPoolEntryBase {
53
+ status: "ready";
54
+ agent: SDKAgent;
55
+ bridgeRun?: CursorPiToolBridgeRun;
56
+ }
57
+
58
+ interface SessionCursorAgentBusyEntry extends SessionCursorAgentPoolEntryBase {
59
+ status: "busy";
60
+ agent: SDKAgent;
61
+ bridgeRun?: CursorPiToolBridgeRun;
62
+ completionSettled: Promise<void>;
63
+ pendingCompletion: Promise<void>;
64
+ releaseBusyWait: () => void;
65
+ busyGeneration: number;
66
+ }
67
+
68
+ type SessionCursorAgentActiveEntry = SessionCursorAgentReadyEntry | SessionCursorAgentBusyEntry;
69
+ type SessionCursorAgentPoolEntry =
70
+ | SessionCursorAgentCreatingEntry
71
+ | SessionCursorAgentReadyEntry
72
+ | SessionCursorAgentBusyEntry;
73
+
74
+ type SessionCursorAgentPoolState = { status: "empty" } | SessionCursorAgentPoolEntry;
75
+
43
76
  class SessionCursorAgentCreationSupersededError extends Error {
44
77
  constructor() {
45
78
  super("Cursor session agent creation was superseded");
@@ -74,6 +107,7 @@ interface SessionCursorAgentCreateParams {
74
107
  modelSelection: ModelSelection;
75
108
  settingSources?: SettingSource[];
76
109
  onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void;
110
+ debugRecorder?: CursorSdkEventDebugRecorder;
77
111
  createAgent?: typeof Agent.create;
78
112
  }
79
113
 
@@ -89,6 +123,20 @@ const sessionAgentsByScope = new Map<string, SessionCursorAgentPoolEntry>();
89
123
  const invalidatedScopeKeys = new Set<string>();
90
124
  const terminalDisposedScopeKeys = new Set<string>();
91
125
  const scopeCreationGenerations = new Map<string, number>();
126
+ const EMPTY_POOL_STATE: SessionCursorAgentPoolState = { status: "empty" };
127
+ let nextSessionAgentInstanceId = 1;
128
+
129
+ function allocateSessionAgentInstanceId(): number {
130
+ return nextSessionAgentInstanceId++;
131
+ }
132
+
133
+ function getSessionCursorAgentPoolState(scopeKey: string): SessionCursorAgentPoolState {
134
+ return sessionAgentsByScope.get(scopeKey) ?? EMPTY_POOL_STATE;
135
+ }
136
+
137
+ function isActivePoolEntry(entry: SessionCursorAgentPoolEntry | undefined): entry is SessionCursorAgentActiveEntry {
138
+ return entry?.status === "ready" || entry?.status === "busy";
139
+ }
92
140
 
93
141
  function getScopeCreationGeneration(scopeKey: string): number {
94
142
  return scopeCreationGenerations.get(scopeKey) ?? 0;
@@ -128,13 +176,13 @@ function buildSessionAgentPoolKey(scopeKey: string, params: SessionCursorAgentCr
128
176
  }
129
177
 
130
178
  async function disposePoolEntry(entry: SessionCursorAgentPoolEntry): Promise<void> {
179
+ if (!isActivePoolEntry(entry)) return;
131
180
  entry.bridgeRun?.cancel("Cursor session agent disposed");
132
181
  try {
133
182
  await entry.bridgeRun?.dispose();
134
183
  } catch {
135
184
  // disposal failure should not block session replacement
136
185
  }
137
- if (!entry.agent) return;
138
186
  try {
139
187
  await entry.agent[Symbol.asyncDispose]();
140
188
  } catch {
@@ -151,47 +199,175 @@ async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?:
151
199
  invalidatedScopeKeys.delete(scopeKey);
152
200
  if (!entry) return;
153
201
  sessionAgentsByScope.delete(scopeKey);
154
- if (entry.creating || !entry.agent) return;
202
+ if (entry.status === "busy") {
203
+ entry.releaseBusyWait();
204
+ }
205
+ if (entry.status === "creating") {
206
+ entry.creating.catch(() => {
207
+ // In-flight Agent.create was orphaned by scope disposal; active waiters surface errors elsewhere.
208
+ });
209
+ return;
210
+ }
155
211
  await disposePoolEntry(entry);
156
212
  }
157
213
 
158
214
  function createInitialSendState(): SessionCursorAgentSendState {
159
- return { bootstrapped: false, contextFingerprint: "" };
215
+ return { bootstrapped: false, contextFingerprint: "", incrementalSendCount: 0 };
160
216
  }
161
217
 
162
218
  function bindBridgeToolRequest(
163
- entry: SessionCursorAgentPoolEntry,
219
+ entry: SessionCursorAgentActiveEntry,
164
220
  onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void,
165
221
  ): void {
166
222
  entry.bridgeRun?.setOnToolRequest(onBridgeToolRequest);
167
223
  }
168
224
 
225
+ function commitSessionAgentSendForLease(
226
+ scopeKey: string,
227
+ poolKey: string,
228
+ instanceId: number,
229
+ context: Context,
230
+ bootstrapped: boolean,
231
+ ): void {
232
+ const entry = sessionAgentsByScope.get(scopeKey);
233
+ if (!isActivePoolEntry(entry)) return;
234
+ if (entry.poolKey !== poolKey || entry.instanceId !== instanceId) return;
235
+ entry.sendState.bootstrapped = bootstrapped || entry.sendState.bootstrapped;
236
+ entry.sendState.contextFingerprint = computeCursorContextFingerprint(context);
237
+ if (bootstrapped) {
238
+ entry.sendState.incrementalSendCount = 0;
239
+ return;
240
+ }
241
+ entry.sendState.incrementalSendCount += 1;
242
+ }
243
+
244
+ function normalizeRunCompletion(completion: Promise<unknown>): Promise<void> {
245
+ return Promise.resolve(completion).then(
246
+ () => undefined,
247
+ () => undefined,
248
+ );
249
+ }
250
+
251
+ function buildBusyPoolEntry(
252
+ entry: SessionCursorAgentActiveEntry,
253
+ completionSettled: Promise<void>,
254
+ ): SessionCursorAgentBusyEntry {
255
+ let releaseBusyWait = (): void => {};
256
+ const releaseSignal = new Promise<"released">((resolve) => {
257
+ releaseBusyWait = () => resolve("released");
258
+ });
259
+ const pendingCompletion = Promise.race([
260
+ completionSettled.then(() => "completed" as const),
261
+ releaseSignal,
262
+ ]).then((outcome) => {
263
+ const current = sessionAgentsByScope.get(entry.scopeKey);
264
+ if (
265
+ outcome === "completed" &&
266
+ current?.status === "busy" &&
267
+ current.poolKey === entry.poolKey &&
268
+ current.instanceId === entry.instanceId &&
269
+ current.pendingCompletion === pendingCompletion
270
+ ) {
271
+ sessionAgentsByScope.set(entry.scopeKey, { ...current, status: "ready" });
272
+ }
273
+ });
274
+
275
+ return {
276
+ ...entry,
277
+ status: "busy",
278
+ completionSettled,
279
+ pendingCompletion,
280
+ releaseBusyWait,
281
+ busyGeneration: getScopeCreationGeneration(entry.scopeKey),
282
+ };
283
+ }
284
+
285
+ function trackSessionAgentRunCompletionForLease(
286
+ scopeKey: string,
287
+ poolKey: string,
288
+ instanceId: number,
289
+ completion: Promise<unknown>,
290
+ ): void {
291
+ const entry = sessionAgentsByScope.get(scopeKey);
292
+ if (!isActivePoolEntry(entry)) return;
293
+ if (entry.poolKey !== poolKey || entry.instanceId !== instanceId) return;
294
+
295
+ const completionToTrack = normalizeRunCompletion(completion);
296
+ const completionSettled = (entry.status === "busy"
297
+ ? Promise.all([entry.completionSettled, completionToTrack]).then(() => undefined)
298
+ : completionToTrack
299
+ );
300
+ if (entry.status === "busy") {
301
+ entry.releaseBusyWait();
302
+ }
303
+
304
+ sessionAgentsByScope.set(scopeKey, buildBusyPoolEntry(entry, completionSettled));
305
+ }
306
+
169
307
  function leaseFromEntry(
170
- entry: SessionCursorAgentPoolEntry,
308
+ entry: SessionCursorAgentReadyEntry,
171
309
  scopeKey: string,
172
310
  params: SessionCursorAgentCreateParams,
173
311
  created: boolean,
174
312
  ): SessionCursorAgentLease {
175
313
  bindBridgeToolRequest(entry, params.onBridgeToolRequest);
314
+ entry.bridgeRun?.setDebugRecorder(params.debugRecorder);
176
315
  return {
177
316
  scopeKey,
178
- agent: entry.agent!,
317
+ poolKey: entry.poolKey,
318
+ instanceId: entry.instanceId,
319
+ agent: entry.agent,
179
320
  bridgeRun: entry.bridgeRun,
180
321
  sendState: entry.sendState,
181
322
  created,
323
+ commitSend: (context, bootstrapped) => {
324
+ commitSessionAgentSendForLease(scopeKey, entry.poolKey, entry.instanceId, context, bootstrapped);
325
+ },
326
+ trackRunCompletion: (completion) => {
327
+ trackSessionAgentRunCompletionForLease(scopeKey, entry.poolKey, entry.instanceId, completion);
328
+ },
182
329
  };
183
330
  }
184
331
 
185
- async function createSessionAgentEntry(
332
+ function getCurrentReadyPoolEntry(scopeKey: string, poolKey: string): SessionCursorAgentReadyEntry | undefined {
333
+ const current = sessionAgentsByScope.get(scopeKey);
334
+ if (current?.status !== "ready") return undefined;
335
+ if (current.poolKey !== poolKey) return undefined;
336
+ return current;
337
+ }
338
+
339
+ async function tryLeaseReadyEntry(
340
+ entry: SessionCursorAgentActiveEntry,
186
341
  scopeKey: string,
342
+ params: SessionCursorAgentCreateParams,
187
343
  poolKey: string,
344
+ created: boolean,
345
+ ): Promise<SessionCursorAgentLease | undefined> {
346
+ if (entry.status === "busy") {
347
+ await entry.pendingCompletion;
348
+ }
349
+ assertScopeAcceptsAcquire(scopeKey);
350
+ if (invalidatedScopeKeys.has(scopeKey)) {
351
+ await disposePoolEntryForScope(scopeKey);
352
+ return undefined;
353
+ }
354
+ const readyEntry = getCurrentReadyPoolEntry(scopeKey, poolKey);
355
+ if (!readyEntry) return undefined;
356
+ return leaseFromEntry(readyEntry, scopeKey, params, created);
357
+ }
358
+
359
+ async function createSessionAgentEntry(
360
+ scopeKey: string,
361
+ instanceId: number,
362
+ sendState: SessionCursorAgentSendState,
188
363
  params: SessionCursorAgentCreateParams,
189
- ): Promise<SessionCursorAgentPoolEntry> {
364
+ ): Promise<SessionCursorAgentReadyEntry> {
190
365
  const registeredBridge = getRegisteredCursorPiToolBridge();
191
366
  let bridgeRun: CursorPiToolBridgeRun | undefined;
192
367
  if (registeredBridge) {
193
368
  bridgeRun = await registeredBridge.createRun({
194
369
  onToolRequest: params.onBridgeToolRequest,
370
+ debugRecorder: params.debugRecorder,
195
371
  });
196
372
  if (!bridgeRun.enabled || !bridgeRun.mcpServers) {
197
373
  await bridgeRun.dispose();
@@ -222,22 +398,23 @@ async function createSessionAgentEntry(
222
398
  }
223
399
 
224
400
  return {
401
+ status: "ready",
225
402
  poolKey: resolvedPoolKey,
403
+ instanceId,
226
404
  scopeKey,
227
405
  agent,
228
406
  bridgeRun,
229
- sendState: createInitialSendState(),
407
+ sendState,
230
408
  };
231
409
  }
232
410
 
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
- }
411
+ export {
412
+ buildCursorSessionSendPrompt,
413
+ MAX_COMPLETED_INCREMENTAL_SENDS_BEFORE_REBOOTSTRAP,
414
+ planCursorSessionSend,
415
+ type CursorSessionSendPlan,
416
+ } from "./cursor-session-send-policy.js";
417
+ export { shouldBootstrapCursorContext, shouldBootstrapCursorSend } from "./context.js";
241
418
 
242
419
  export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeKey()): void {
243
420
  invalidatedScopeKeys.add(scopeKey);
@@ -245,77 +422,96 @@ export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeK
245
422
 
246
423
  export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise<SessionCursorAgentLease> {
247
424
  const scopeKey = getCursorSessionScopeKey();
248
- assertScopeAcceptsAcquire(scopeKey);
249
- if (invalidatedScopeKeys.has(scopeKey)) {
250
- await disposePoolEntryForScope(scopeKey);
251
- }
252
425
 
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
- }
426
+ while (true) {
427
+ assertScopeAcceptsAcquire(scopeKey);
428
+ if (invalidatedScopeKeys.has(scopeKey)) {
429
+ await disposePoolEntryForScope(scopeKey);
430
+ }
258
431
 
259
- if (existing && existing.poolKey !== poolKey) {
260
- await disposePoolEntryForScope(scopeKey);
261
- }
432
+ const poolKey = buildSessionAgentPoolKey(scopeKey, params);
433
+ const state = getSessionCursorAgentPoolState(scopeKey);
262
434
 
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 {
435
+ if ((state.status === "ready" || state.status === "busy") && state.poolKey !== poolKey) {
436
+ await disposePoolEntryForScope(scopeKey);
437
+ continue;
438
+ }
439
+
440
+ if (state.status === "ready") {
441
+ return leaseFromEntry(state, scopeKey, params, false);
442
+ }
443
+
444
+ if (state.status === "busy") {
445
+ const busyGeneration = state.busyGeneration;
446
+ await state.pendingCompletion;
447
+ if (busyGeneration !== getScopeCreationGeneration(scopeKey)) continue;
448
+ continue;
449
+ }
450
+
451
+ if (state.status === "creating") {
452
+ if (state.poolKey !== poolKey) {
453
+ await disposePoolEntryForScope(scopeKey);
454
+ continue;
455
+ }
456
+ try {
457
+ await state.creating;
458
+ } catch (error) {
459
+ if (error instanceof SessionCursorAgentCreationSupersededError) {
460
+ assertScopeAcceptsAcquire(scopeKey);
461
+ rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey, poolKey, error);
462
+ continue;
463
+ }
272
464
  throw error;
273
465
  }
466
+ continue;
274
467
  }
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
468
 
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);
469
+ assertScopeAcceptsAcquire(scopeKey);
470
+ const creationGeneration = getScopeCreationGeneration(scopeKey);
471
+ const instanceId = allocateSessionAgentInstanceId();
472
+ const sendState = createInitialSendState();
473
+ let placeholder: SessionCursorAgentCreatingEntry;
474
+ const creating = createSessionAgentEntry(scopeKey, instanceId, sendState, params).then(async (createdEntry) => {
475
+ const stillCurrent =
476
+ sessionAgentsByScope.get(scopeKey) === placeholder &&
477
+ getScopeCreationGeneration(scopeKey) === placeholder.creationGeneration;
478
+ if (!stillCurrent) {
479
+ await disposePoolEntry(createdEntry);
480
+ if (sessionAgentsByScope.get(scopeKey) === placeholder) {
481
+ sessionAgentsByScope.delete(scopeKey);
482
+ }
483
+ throw new SessionCursorAgentCreationSupersededError();
484
+ }
485
+ sessionAgentsByScope.set(scopeKey, createdEntry);
486
+ return createdEntry;
487
+ });
488
+ placeholder = {
489
+ status: "creating",
490
+ poolKey,
491
+ instanceId,
492
+ scopeKey,
493
+ sendState,
494
+ creationGeneration,
495
+ creating,
496
+ };
497
+ sessionAgentsByScope.set(scopeKey, placeholder);
498
+
499
+ try {
500
+ const createdEntry = await creating;
501
+ const lease = await tryLeaseReadyEntry(createdEntry, scopeKey, params, poolKey, true);
502
+ if (lease) return lease;
503
+ continue;
504
+ } catch (error) {
295
505
  if (sessionAgentsByScope.get(scopeKey) === placeholder) {
296
506
  sessionAgentsByScope.delete(scopeKey);
297
507
  }
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);
508
+ if (error instanceof SessionCursorAgentCreationSupersededError) {
509
+ assertScopeAcceptsAcquire(scopeKey);
510
+ rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey, poolKey, error);
511
+ continue;
512
+ }
513
+ throw error;
317
514
  }
318
- throw error;
319
515
  }
320
516
  }
321
517
 
@@ -361,6 +557,7 @@ export function registerCursorSessionAgent(_pi: CursorSessionAgentExtensionApi):
361
557
 
362
558
  export const __testUtils = {
363
559
  sessionAgentsByScope,
560
+ getSessionCursorAgentPoolState,
364
561
  invalidateSessionAgent,
365
562
  disposeSessionCursorAgent,
366
563
  resetSessionCursorAgent,
@@ -0,0 +1,43 @@
1
+ import type { Context } from "@earendil-works/pi-ai";
2
+ import {
3
+ buildCursorIncrementalPrompt,
4
+ buildCursorPrompt,
5
+ shouldBootstrapCursorContext,
6
+ type CursorPrompt,
7
+ type CursorPromptOptions,
8
+ } from "./context.js";
9
+ import type { SessionCursorAgentSendState } from "./cursor-session-agent.js";
10
+
11
+ // Long-lived SDK session agents can drift tool-call behavior; recreate the agent after this many successful incremental sends.
12
+ export const MAX_COMPLETED_INCREMENTAL_SENDS_BEFORE_REBOOTSTRAP = 20;
13
+
14
+ export type CursorSessionSendMode = "bootstrap" | "incremental";
15
+
16
+ export type CursorSessionSendReason = "initial" | "context_divergence" | "incremental_threshold" | "incremental";
17
+
18
+ export interface CursorSessionSendPlan {
19
+ mode: CursorSessionSendMode;
20
+ resetAgent: boolean;
21
+ reason: CursorSessionSendReason;
22
+ }
23
+
24
+ export function planCursorSessionSend(sendState: SessionCursorAgentSendState, context: Context): CursorSessionSendPlan {
25
+ if (!sendState.bootstrapped) {
26
+ return { mode: "bootstrap", resetAgent: false, reason: "initial" };
27
+ }
28
+ if (sendState.incrementalSendCount >= MAX_COMPLETED_INCREMENTAL_SENDS_BEFORE_REBOOTSTRAP) {
29
+ return { mode: "bootstrap", resetAgent: true, reason: "incremental_threshold" };
30
+ }
31
+ if (shouldBootstrapCursorContext(sendState, context)) {
32
+ return { mode: "bootstrap", resetAgent: true, reason: "context_divergence" };
33
+ }
34
+ return { mode: "incremental", resetAgent: false, reason: "incremental" };
35
+ }
36
+
37
+ export function buildCursorSessionSendPrompt(
38
+ context: Context,
39
+ options: CursorPromptOptions,
40
+ plan: CursorSessionSendPlan,
41
+ ): CursorPrompt {
42
+ return plan.mode === "bootstrap" ? buildCursorPrompt(context, options) : buildCursorIncrementalPrompt(context, options);
43
+ }
@@ -0,0 +1,29 @@
1
+ import type { SettingSource } from "@cursor/sdk";
2
+
3
+ export const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
4
+
5
+ export function resolveCursorSettingSources(raw?: string): SettingSource[] | undefined {
6
+ const trimmed = raw?.trim();
7
+ if (!trimmed) return ["all"];
8
+ const normalized = trimmed.toLowerCase();
9
+ if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
10
+ if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
11
+ return trimmed
12
+ .split(",")
13
+ .map((entry) => entry.trim())
14
+ .filter((entry): entry is SettingSource => Boolean(entry));
15
+ }
16
+
17
+ export function getEffectiveCursorSettingSources(raw: string | undefined = process.env[CURSOR_SETTING_SOURCES_ENV]): SettingSource[] | undefined {
18
+ return resolveCursorSettingSources(raw);
19
+ }
20
+
21
+ export function cursorSettingSourcesLoadUserAgentsRules(settingSources: SettingSource[] | undefined): boolean {
22
+ if (!settingSources?.length) return false;
23
+ return settingSources.includes("all") || settingSources.includes("user");
24
+ }
25
+
26
+ export function cursorSettingSourcesLoadProjectAgentsRules(settingSources: SettingSource[] | undefined): boolean {
27
+ if (!settingSources?.length) return false;
28
+ return settingSources.includes("all") || settingSources.includes("project");
29
+ }
@@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { isCursorModel } from "./cursor-model.js";
5
6
  import { getCursorModelMetadata } from "./model-discovery.js";
6
7
 
7
- const CURSOR_PROVIDER = "cursor";
8
8
  const FAST_ENTRY_TYPE = "cursor-fast-state";
9
9
  const GLOBAL_CONFIG_FILE = "cursor-sdk.json";
10
10
 
@@ -94,10 +94,6 @@ function getEffectiveFast(baseModelId: string, modelId: string): boolean | undef
94
94
  return sessionFastPreferences.get(baseModelId) ?? globalFastPreferences.get(baseModelId) ?? metadata.defaultFast;
95
95
  }
96
96
 
97
- function isCursorModel(model: CursorFastControlsModel): boolean {
98
- return model?.provider === CURSOR_PROVIDER || model?.api === "cursor-sdk";
99
- }
100
-
101
97
  function updateCursorStatus(ctx: { model: CursorFastControlsModel; ui: Pick<ExtensionContext["ui"], "setStatus"> }, model = ctx.model): void {
102
98
  if (!model || !isCursorModel(model)) {
103
99
  ctx.ui.setStatus("cursor", undefined);
@@ -0,0 +1,85 @@
1
+ import { truncateCursorDisplayLine } from "./cursor-display-text.js";
2
+ import { scrubSensitiveText } from "./cursor-sensitive-text.js";
3
+ import { extractWebSearchQuery } from "./cursor-web-tool-activity.js";
4
+ import { firstNonEmptyLine, getArray, getString, truncateArg } from "./cursor-transcript-utils.js";
5
+ import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js";
6
+
7
+ /** Defer pending lifecycle lines so fast start+complete pairs coalesce into the completed replay card only. */
8
+ export const CURSOR_TOOL_LIFECYCLE_DEFER_MS = 75;
9
+
10
+ export function isCursorToolLifecycleEligible(toolCall: unknown): boolean {
11
+ return classifyCursorToolVisibility(toolCall).lifecycleEligible;
12
+ }
13
+
14
+ function getCursorToolLifecycleTitle(toolCall: unknown): string {
15
+ const visibility = classifyCursorToolVisibility(toolCall);
16
+ return visibility.lifecycleTitle ?? `Cursor ${visibility.normalizedName}`;
17
+ }
18
+
19
+ /** Prefixes that commonly introduce path/URI values in free-text pending lifecycle details. */
20
+ const LIFECYCLE_DETAIL_PATH_PREFIX = String.raw`(?:^|[\s'"({=,:;\[\]{}])`;
21
+
22
+ function containsCursorLifecycleUnsafeDetail(text: string): boolean {
23
+ if (/\b[a-z][a-z0-9+.-]*:\/\//i.test(text)) return true;
24
+ if (/\bwww\.\S+/i.test(text)) return true;
25
+ if (new RegExp(`${LIFECYCLE_DETAIL_PATH_PREFIX}~\\/\\S*`).test(text)) return true;
26
+ if (new RegExp(`${LIFECYCLE_DETAIL_PATH_PREFIX}\\/\\S+`).test(text)) return true;
27
+ if (new RegExp(`${LIFECYCLE_DETAIL_PATH_PREFIX}[A-Za-z]:[\\\\/]`).test(text)) return true;
28
+ return false;
29
+ }
30
+
31
+ function scrubLifecycleDetail(value: string | undefined, apiKey?: string): string | undefined {
32
+ if (!value?.trim()) return undefined;
33
+ const scrubbed = truncateCursorDisplayLine(scrubSensitiveText(value, apiKey));
34
+ if (containsCursorLifecycleUnsafeDetail(scrubbed)) return undefined;
35
+ return scrubbed;
36
+ }
37
+
38
+ export function buildCursorToolLifecycleLabel(toolCall: unknown, apiKey?: string): string | undefined {
39
+ const visibility = classifyCursorToolVisibility(toolCall);
40
+ const args = visibility.args;
41
+
42
+ switch (visibility.normalizedKey) {
43
+ case "task": {
44
+ return scrubLifecycleDetail(getString(args, "description"), apiKey) ?? "task";
45
+ }
46
+ case "shell": {
47
+ return "shell";
48
+ }
49
+ case "mcp": {
50
+ return scrubLifecycleDetail(getString(args, "toolName"), apiKey) ?? "mcp";
51
+ }
52
+ case "generateimage": {
53
+ return scrubLifecycleDetail(getString(args, "prompt") ?? getString(args, "description"), apiKey) ?? "image generation";
54
+ }
55
+ case "recordscreen": {
56
+ return scrubLifecycleDetail(getString(args, "mode"), apiKey) ?? "screen recording";
57
+ }
58
+ case "semsearch": {
59
+ return scrubLifecycleDetail(getString(args, "query"), apiKey) ?? "semantic search";
60
+ }
61
+ case "websearch": {
62
+ return scrubLifecycleDetail(extractWebSearchQuery(args), apiKey) ?? "web search";
63
+ }
64
+ case "webfetch": {
65
+ return "web fetch";
66
+ }
67
+ case "createplan": {
68
+ const plan = getString(args, "plan");
69
+ return scrubLifecycleDetail(plan ? firstNonEmptyLine(plan) ?? plan : undefined, apiKey) ?? "plan";
70
+ }
71
+ case "updatetodos": {
72
+ const todos = getArray(args, "todos") ?? getArray(args, "items");
73
+ if (todos && todos.length > 0) return truncateArg(`${todos.length} item${todos.length === 1 ? "" : "s"}`);
74
+ return "todos";
75
+ }
76
+ default:
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ export function formatCursorToolLifecycleProgressText(toolCall: unknown, apiKey?: string): string | undefined {
82
+ const label = buildCursorToolLifecycleLabel(toolCall, apiKey);
83
+ if (!label) return undefined;
84
+ return `${getCursorToolLifecycleTitle(toolCall)}: ${label}\n`;
85
+ }