pi-cursor-sdk 0.4.0 → 0.5.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +65 -13
  3. package/dist/context-window-cache.js +0 -6
  4. package/dist/context.js +8 -0
  5. package/dist/cursor-cloud-lifecycle.js +122 -98
  6. package/dist/cursor-durable-fs.js +97 -3
  7. package/dist/cursor-fallback-models.generated.js +288 -1
  8. package/dist/cursor-fallback-warning.js +2 -2
  9. package/dist/cursor-http1.js +8 -8
  10. package/dist/cursor-live-run-coordinator.js +9 -13
  11. package/dist/cursor-native-replay-routing.js +1 -1
  12. package/dist/cursor-native-tool-display-registration.js +46 -22
  13. package/dist/cursor-native-tool-display-state.js +57 -22
  14. package/dist/cursor-native-tool-display-tools.js +5 -6
  15. package/dist/cursor-pi-tool-bridge-constants.js +23 -2
  16. package/dist/cursor-pi-tool-bridge-run.js +7 -3
  17. package/dist/cursor-pi-tool-bridge-snapshot.js +1 -1
  18. package/dist/cursor-pi-tool-bridge.js +31 -9
  19. package/dist/cursor-provider-binding.js +58 -0
  20. package/dist/cursor-provider-errors.js +28 -7
  21. package/dist/cursor-provider-lazy.js +23 -11
  22. package/dist/cursor-provider-live-run-drain.js +15 -12
  23. package/dist/cursor-provider-run-finalizer.js +19 -8
  24. package/dist/cursor-provider-turn-coordinator.js +1 -0
  25. package/dist/cursor-provider-turn-display-router.js +4 -0
  26. package/dist/cursor-provider-turn-emit.js +1 -0
  27. package/dist/cursor-provider-turn-finalize.js +29 -20
  28. package/dist/cursor-provider-turn-prepare.js +24 -11
  29. package/dist/cursor-provider-turn-runner.js +37 -11
  30. package/dist/cursor-provider-turn-send.js +22 -9
  31. package/dist/cursor-provider-turn-summary.js +89 -0
  32. package/dist/cursor-provider.js +9 -3
  33. package/dist/cursor-request-provenance.js +42 -0
  34. package/dist/cursor-ripgrep-path.js +14 -3
  35. package/dist/cursor-runtime-state.js +50 -43
  36. package/dist/cursor-sdk-billed-usage.js +54 -75
  37. package/dist/cursor-sdk-event-debug-session.js +5 -6
  38. package/dist/cursor-sdk-event-debug.js +9 -6
  39. package/dist/cursor-sdk-process-error-guard.js +8 -7
  40. package/dist/cursor-sdk-runtime.js +13 -0
  41. package/dist/cursor-session-agent-cleanup.js +2 -2
  42. package/dist/cursor-session-agent-lifecycle.js +18 -9
  43. package/dist/cursor-session-agent-lineage.js +28 -12
  44. package/dist/cursor-session-agent-resume.js +51 -35
  45. package/dist/cursor-session-agent.js +9 -9
  46. package/dist/cursor-session-compaction-prep.js +2 -7
  47. package/dist/cursor-session-scope.js +71 -39
  48. package/dist/cursor-session-settings.js +61 -0
  49. package/dist/cursor-skill-tool.js +32 -49
  50. package/dist/cursor-state.js +72 -62
  51. package/dist/cursor-usage-accounting.js +13 -70
  52. package/dist/cursor-usage-command.js +89 -0
  53. package/dist/cursor-usage-ledger.js +512 -0
  54. package/dist/index.js +11 -19
  55. package/dist/model-discovery.js +11 -19
  56. package/docs/cursor-dogfood-checklist.md +3 -3
  57. package/docs/cursor-live-smoke-checklist.md +15 -15
  58. package/docs/cursor-model-ux-spec.md +59 -18
  59. package/docs/cursor-native-tool-replay.md +5 -3
  60. package/docs/cursor-native-tool-visual-audit.md +8 -8
  61. package/docs/cursor-testing-lessons.md +47 -23
  62. package/docs/cursor-tool-surfaces.md +1 -1
  63. package/docs/platform-smoke-implementation.md +10 -6
  64. package/docs/platform-smoke.md +35 -46
  65. package/package.json +6 -6
  66. package/scripts/cloud-runtime-smoke.mjs +4 -2
  67. package/scripts/local-resume-smoke.mjs +1 -1
  68. package/scripts/platform-smoke/card-detect.mjs +2 -8
  69. package/scripts/platform-smoke/targets.mjs +8 -15
  70. package/scripts/platform-smoke/visual-evidence.mjs +7 -2
  71. package/scripts/tmux-live-smoke.sh +57 -90
  72. package/src/context-window-cache.ts +0 -6
  73. package/src/context.ts +8 -0
  74. package/src/cursor-cloud-lifecycle.ts +124 -98
  75. package/src/cursor-durable-fs.ts +65 -3
  76. package/src/cursor-fallback-models.generated.ts +288 -1
  77. package/src/cursor-fallback-warning.ts +2 -2
  78. package/src/cursor-http1.ts +8 -8
  79. package/src/cursor-live-run-coordinator.ts +12 -13
  80. package/src/cursor-native-replay-routing.ts +2 -1
  81. package/src/cursor-native-tool-display-registration.ts +48 -24
  82. package/src/cursor-native-tool-display-state.ts +65 -21
  83. package/src/cursor-native-tool-display-tools.ts +7 -4
  84. package/src/cursor-pi-tool-bridge-constants.ts +27 -2
  85. package/src/cursor-pi-tool-bridge-run.ts +11 -3
  86. package/src/cursor-pi-tool-bridge-snapshot.ts +1 -1
  87. package/src/cursor-pi-tool-bridge.ts +26 -9
  88. package/src/cursor-provider-binding.ts +62 -0
  89. package/src/cursor-provider-errors.ts +27 -7
  90. package/src/cursor-provider-lazy.ts +21 -15
  91. package/src/cursor-provider-live-run-drain.ts +20 -13
  92. package/src/cursor-provider-run-finalizer.ts +24 -15
  93. package/src/cursor-provider-turn-coordinator.ts +2 -0
  94. package/src/cursor-provider-turn-display-router.ts +5 -0
  95. package/src/cursor-provider-turn-emit.ts +3 -1
  96. package/src/cursor-provider-turn-finalize.ts +38 -23
  97. package/src/cursor-provider-turn-prepare.ts +23 -14
  98. package/src/cursor-provider-turn-runner.ts +40 -13
  99. package/src/cursor-provider-turn-send.ts +23 -15
  100. package/src/cursor-provider-turn-summary.ts +94 -0
  101. package/src/cursor-provider-turn-types.ts +37 -4
  102. package/src/cursor-provider.ts +25 -3
  103. package/src/cursor-request-provenance.ts +64 -0
  104. package/src/cursor-ripgrep-path.ts +15 -5
  105. package/src/cursor-runtime-state.ts +53 -48
  106. package/src/cursor-sdk-billed-usage.ts +54 -102
  107. package/src/cursor-sdk-event-debug-session.ts +5 -5
  108. package/src/cursor-sdk-event-debug.ts +10 -6
  109. package/src/cursor-sdk-process-error-guard.ts +7 -7
  110. package/src/cursor-sdk-runtime.ts +14 -0
  111. package/src/cursor-session-agent-cleanup.ts +2 -2
  112. package/src/cursor-session-agent-lifecycle.ts +14 -9
  113. package/src/cursor-session-agent-lineage.ts +26 -12
  114. package/src/cursor-session-agent-resume.ts +49 -36
  115. package/src/cursor-session-agent.ts +13 -10
  116. package/src/cursor-session-compaction-prep.ts +2 -7
  117. package/src/cursor-session-scope.ts +87 -39
  118. package/src/cursor-session-settings.ts +72 -0
  119. package/src/cursor-skill-tool.ts +37 -61
  120. package/src/cursor-state.ts +74 -67
  121. package/src/cursor-usage-accounting.ts +17 -82
  122. package/src/cursor-usage-command.ts +76 -0
  123. package/src/cursor-usage-ledger.ts +500 -0
  124. package/src/index.ts +14 -23
  125. package/src/model-discovery.ts +14 -22
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import { isCursorLocalAgentId } from "./cursor-session-agent-resume.js";
3
- import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
3
+ import { getCursorSessionScopeSnapshot } from "./cursor-session-scope.js";
4
4
  import { asRecord } from "./cursor-record-utils.js";
5
5
 
6
6
  export const CURSOR_SESSION_AGENT_LINEAGE_ENTRY_TYPE = "cursor-sdk-agent-lineage";
@@ -33,10 +33,12 @@ interface CursorSessionAgentLineageState {
33
33
  recordedAgentIds: Set<string>;
34
34
  }
35
35
 
36
- const state: CursorSessionAgentLineageState = {
36
+ let state: CursorSessionAgentLineageState = {
37
37
  recordedAgentIds: new Set(),
38
38
  };
39
39
 
40
+ const statesByScope = new Map<string, CursorSessionAgentLineageState>();
41
+
40
42
  export function parseCursorSessionAgentLineageEntryData(value: unknown): CursorSessionAgentLineageEntryData | undefined {
41
43
  const record = asRecord(value);
42
44
  if (
@@ -77,10 +79,14 @@ function readRecordedAgentIds(entries: readonly SessionEntry[], sessionId: strin
77
79
  }
78
80
 
79
81
  /** Best-effort forensic lineage at the local Agent.send() boundary. Independent of resume. */
80
- export function recordCursorSessionAgentLineage(agentId: string): void {
81
- const { appendEntry, sessionId, sessionFile, scopeKey, cwd } = state;
82
+ export function recordCursorSessionAgentLineage(agentId: string, turnScopeKey?: string): void {
83
+ const sessionState = turnScopeKey === undefined
84
+ ? state
85
+ : statesByScope.get(turnScopeKey) ?? (state.scopeKey === turnScopeKey ? state : undefined);
86
+ if (!sessionState) return;
87
+ const { appendEntry, sessionId, sessionFile, scopeKey, cwd } = sessionState;
82
88
  if (!appendEntry || !sessionId || !scopeKey || !cwd) return;
83
- if (!isCursorLocalAgentId(agentId) || state.recordedAgentIds.has(agentId)) return;
89
+ if (!isCursorLocalAgentId(agentId) || sessionState.recordedAgentIds.has(agentId)) return;
84
90
  const data: CursorSessionAgentLineageEntryData = {
85
91
  version: LINEAGE_ENTRY_VERSION,
86
92
  runtime: "local",
@@ -93,7 +99,7 @@ export function recordCursorSessionAgentLineage(agentId: string): void {
93
99
  };
94
100
  try {
95
101
  appendEntry<CursorSessionAgentLineageEntryData>(CURSOR_SESSION_AGENT_LINEAGE_ENTRY_TYPE, data);
96
- state.recordedAgentIds.add(agentId);
102
+ sessionState.recordedAgentIds.add(agentId);
97
103
  } catch {
98
104
  // Lineage is forensic metadata; a failed stock pi append must not affect the session.
99
105
  }
@@ -105,15 +111,22 @@ interface CursorSessionAgentLineageExtensionApi {
105
111
  }
106
112
 
107
113
  export function registerCursorSessionAgentLineage(pi: CursorSessionAgentLineageExtensionApi): void {
114
+ const sessionState: CursorSessionAgentLineageState = { recordedAgentIds: new Set() };
108
115
  pi.on("session_start", (_event, ctx) => {
109
- state.appendEntry = pi.appendEntry;
110
- state.sessionId = ctx.sessionManager.getSessionId();
111
- state.sessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
112
- state.scopeKey = getCursorSessionScopeKey();
113
- state.cwd = ctx.cwd;
114
- state.recordedAgentIds = readRecordedAgentIds(ctx.sessionManager.getEntries(), state.sessionId);
116
+ state = sessionState;
117
+ const record = sessionState;
118
+ if (record.scopeKey && statesByScope.get(record.scopeKey) === record) statesByScope.delete(record.scopeKey);
119
+ record.appendEntry = pi.appendEntry;
120
+ record.sessionId = ctx.sessionManager.getSessionId();
121
+ record.sessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
122
+ record.scopeKey = getCursorSessionScopeSnapshot(pi).scopeKey;
123
+ statesByScope.set(record.scopeKey, record);
124
+ record.cwd = ctx.cwd;
125
+ record.recordedAgentIds = readRecordedAgentIds(ctx.sessionManager.getEntries(), record.sessionId);
115
126
  });
116
127
  pi.on("session_shutdown", () => {
128
+ const state = sessionState;
129
+ if (state.scopeKey && statesByScope.get(state.scopeKey) === state) statesByScope.delete(state.scopeKey);
117
130
  state.appendEntry = undefined;
118
131
  state.sessionId = undefined;
119
132
  state.sessionFile = undefined;
@@ -124,6 +137,7 @@ export function registerCursorSessionAgentLineage(pi: CursorSessionAgentLineageE
124
137
  }
125
138
 
126
139
  function resetStateForTests(): void {
140
+ statesByScope.clear();
127
141
  state.appendEntry = undefined;
128
142
  state.sessionId = undefined;
129
143
  state.sessionFile = undefined;
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
4
4
  import type { SessionCursorAgentSendState } from "./cursor-session-agent.js";
5
5
  import { asRecord } from "./cursor-record-utils.js";
6
- import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
6
+ import { getCursorSessionScopeSnapshot, getCursorSessionScopeKey } from "./cursor-session-scope.js";
7
7
  import type { CursorSessionStoreIdentity } from "./cursor-session-store.js";
8
8
 
9
9
  export const CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE = "cursor-sdk-agent-resume";
@@ -73,7 +73,7 @@ interface CursorSessionResumeState {
73
73
  unownedUserEntryIds: Set<string>;
74
74
  }
75
75
 
76
- const state: CursorSessionResumeState = {
76
+ let lastBoundState: CursorSessionResumeState = {
77
77
  scopeKey: getCursorSessionScopeKey(),
78
78
  cwd: process.cwd(),
79
79
  branchPathHash: EMPTY_BRANCH_HASH,
@@ -81,17 +81,13 @@ const state: CursorSessionResumeState = {
81
81
  unownedUserEntryIds: new Set(),
82
82
  };
83
83
 
84
- // Compaction summarizer sends commit a one-message resume handle. Drop it so the
85
- // next normal turn_end cannot persist that lineage (#223).
86
- let resumeHandlePersistSuppressed = false;
84
+ const statesByScope = new Map<string, CursorSessionResumeState>();
87
85
 
88
- export function suppressCursorSessionAgentResumeHandlePersist(): void {
89
- resumeHandlePersistSuppressed = true;
90
- state.pendingHandle = undefined;
91
- }
92
-
93
- export function allowCursorSessionAgentResumeHandlePersist(): void {
94
- resumeHandlePersistSuppressed = false;
86
+ function getResumeState(scopeKey?: string): CursorSessionResumeState {
87
+ if (scopeKey !== undefined) {
88
+ return statesByScope.get(scopeKey) ?? lastBoundState;
89
+ }
90
+ return lastBoundState;
95
91
  }
96
92
 
97
93
  function hashParts(parts: readonly string[]): string {
@@ -211,7 +207,8 @@ function matchesResumeScope(data: CursorSessionAgentResumeEntryData, scope: Curs
211
207
  function matchesCurrentSession(
212
208
  data: CursorSessionAgentResumeEntryData,
213
209
  branchPathHash: string,
214
- compactionGeneration = state.compactionGeneration,
210
+ compactionGeneration: number,
211
+ state: CursorSessionResumeState,
215
212
  ): boolean {
216
213
  return matchesResumeScope(data, state) &&
217
214
  data.compactionGeneration === compactionGeneration &&
@@ -333,19 +330,19 @@ export function readResumableCursorSessionAgentIds(
333
330
  return [...agentIds].sort((a, b) => a.localeCompare(b));
334
331
  }
335
332
 
336
- function canRestoreHandleSpanEntry(entry: SessionEntry): boolean {
333
+ function canRestoreHandleSpanEntry(entry: SessionEntry, state: CursorSessionResumeState): boolean {
337
334
  if (entry.type === "message" && entry.message.role === "user") return !state.unownedUserEntryIds.has(entry.id);
338
335
  return canResumeHandleSpanEntry(entry);
339
336
  }
340
337
 
341
- function restoreFromBranch(branch: readonly SessionEntry[], allEntries: readonly SessionEntry[] = branch): void {
338
+ function restoreFromBranch(branch: readonly SessionEntry[], allEntries: readonly SessionEntry[], state: CursorSessionResumeState): void {
342
339
  const resumeIndex = indexLatestResumeEntries(allEntries);
343
340
  let fold: ResumeBranchFoldState = { branchPathHash: EMPTY_BRANCH_HASH, compactionGeneration: 0 };
344
341
  let lastBranchHandle: CursorSessionAgentResumeEntryData | undefined;
345
342
  for (const entry of branch) {
346
343
  const next = advanceResumeBranchState(entry, fold, resumeIndex, {
347
- matchesEntry: matchesCurrentSession,
348
- canSpanEntry: canRestoreHandleSpanEntry,
344
+ matchesEntry: (data, hash, generation) => matchesCurrentSession(data, hash, generation, state),
345
+ canSpanEntry: (entry) => canRestoreHandleSpanEntry(entry, state),
349
346
  });
350
347
  if (next.activeHandle && next.activeHandle !== fold.activeHandle) lastBranchHandle = next.activeHandle;
351
348
  fold = next;
@@ -356,7 +353,9 @@ function restoreFromBranch(branch: readonly SessionEntry[], allEntries: readonly
356
353
  state.lastBranchHandle = lastBranchHandle;
357
354
  }
358
355
 
359
- export function getMatchingCursorSessionAgentResumeHandle(poolKey: string): CursorSessionAgentResumeEntryData | undefined {
356
+ export function getMatchingCursorSessionAgentResumeHandle(poolKey: string, scopeKey?: string): CursorSessionAgentResumeEntryData | undefined {
357
+ const state = getResumeState(scopeKey);
358
+ if (scopeKey !== undefined && state.scopeKey !== scopeKey) return undefined;
360
359
  const handle = state.activeHandle;
361
360
  if (!handle || !isCursorLocalAgentId(handle.agentId)) return undefined;
362
361
  if (handle.poolKey !== poolKey) return undefined;
@@ -372,8 +371,9 @@ export function getMatchingCursorSessionAgentResumeHandle(poolKey: string): Curs
372
371
  };
373
372
  }
374
373
 
375
- export function persistCursorSessionAgentResumeHandle(input: PendingCursorSessionAgentResumeHandle): void {
376
- if (resumeHandlePersistSuppressed) return;
374
+ export function persistCursorSessionAgentResumeHandle(input: PendingCursorSessionAgentResumeHandle, scopeKey?: string): void {
375
+ const state = getResumeState(scopeKey);
376
+ if (scopeKey !== undefined && state.scopeKey !== scopeKey) return;
377
377
  if (!isCursorLocalAgentId(input.agentId)) return;
378
378
  state.pendingHandle = {
379
379
  runtime: input.runtime,
@@ -384,13 +384,8 @@ export function persistCursorSessionAgentResumeHandle(input: PendingCursorSessio
384
384
  };
385
385
  }
386
386
 
387
- function flushPendingCursorSessionAgentResumeHandle(branch: readonly SessionEntry[]): void {
388
- if (resumeHandlePersistSuppressed) {
389
- state.pendingHandle = undefined;
390
- restoreFromBranch(branch);
391
- return;
392
- }
393
- restoreFromBranch(branch);
387
+ function flushPendingCursorSessionAgentResumeHandle(branch: readonly SessionEntry[], state: CursorSessionResumeState): void {
388
+ restoreFromBranch(branch, branch, state);
394
389
  const pending = state.pendingHandle;
395
390
  state.pendingHandle = undefined;
396
391
  if (!pending || !state.appendEntry) return;
@@ -432,14 +427,22 @@ interface CursorSessionAgentResumeExtensionApi {
432
427
  }
433
428
 
434
429
  export function registerCursorSessionAgentResume(pi: CursorSessionAgentResumeExtensionApi): void {
435
- state.appendEntry = pi.appendEntry;
430
+ const sessionState: CursorSessionResumeState = {
431
+ appendEntry: pi.appendEntry, scopeKey: getCursorSessionScopeSnapshot(pi).scopeKey,
432
+ cwd: process.cwd(), branchPathHash: EMPTY_BRANCH_HASH,
433
+ compactionGeneration: 0, unownedUserEntryIds: new Set(),
434
+ };
435
+ const state = sessionState;
436
436
  const restoreFromSessionManager = (sessionManager: { getBranch(): SessionEntry[]; getEntries(): SessionEntry[] }): void => {
437
437
  const branch = sessionManager.getBranch();
438
438
  const entries = sessionManager.getEntries();
439
- restoreFromBranch(branch, entries.length > 0 ? entries : branch);
439
+ restoreFromBranch(branch, entries.length > 0 ? entries : branch, state);
440
440
  };
441
441
  pi.on("session_start", (_event, ctx) => {
442
- state.scopeKey = getCursorSessionScopeKey();
442
+ if (statesByScope.get(state.scopeKey) === state) statesByScope.delete(state.scopeKey);
443
+ state.scopeKey = getCursorSessionScopeSnapshot(pi).scopeKey;
444
+ statesByScope.set(state.scopeKey, state);
445
+ lastBoundState = state;
443
446
  state.sessionFile = ctx.sessionManager.getSessionFile?.() ?? undefined;
444
447
  state.sessionId = ctx.sessionManager.getSessionId?.() ?? undefined;
445
448
  state.cwd = ctx.cwd;
@@ -452,7 +455,7 @@ export function registerCursorSessionAgentResume(pi: CursorSessionAgentResumeExt
452
455
  restoreFromSessionManager(ctx.sessionManager);
453
456
  });
454
457
  pi.on("turn_end", (_event, ctx) => {
455
- flushPendingCursorSessionAgentResumeHandle(ctx.sessionManager.getBranch());
458
+ flushPendingCursorSessionAgentResumeHandle(ctx.sessionManager.getBranch(), state);
456
459
  });
457
460
  pi.on("session_tree", (_event, ctx) => {
458
461
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -460,9 +463,14 @@ export function registerCursorSessionAgentResume(pi: CursorSessionAgentResumeExt
460
463
  }
461
464
  restoreFromSessionManager(ctx.sessionManager);
462
465
  });
466
+ pi.on("session_before_compact", () => {
467
+ state.pendingHandle = undefined;
468
+ });
469
+ pi.on("session_compact_failed", () => {
470
+ state.pendingHandle = undefined;
471
+ });
463
472
  pi.on("session_compact", (event, ctx) => {
464
473
  state.pendingHandle = undefined;
465
- resumeHandlePersistSuppressed = false;
466
474
  const branch = ctx.sessionManager.getBranch();
467
475
  if (branch.length > 0) {
468
476
  restoreFromSessionManager(ctx.sessionManager);
@@ -473,13 +481,19 @@ export function registerCursorSessionAgentResume(pi: CursorSessionAgentResumeExt
473
481
  state.compactionGeneration += 1;
474
482
  state.branchPathHash = hashBranchStep(state.branchPathHash, event.compactionEntry);
475
483
  });
484
+ pi.on("session_shutdown", () => {
485
+ if (statesByScope.get(state.scopeKey) === state) statesByScope.delete(state.scopeKey);
486
+ state.appendEntry = undefined;
487
+ state.pendingHandle = undefined;
488
+ });
476
489
  }
477
490
 
478
491
  function setStateForTests(next: Partial<CursorSessionResumeState>): void {
479
- Object.assign(state, next);
492
+ Object.assign(getResumeState(), next);
480
493
  }
481
494
 
482
495
  function resetStateForTests(): void {
496
+ const state = lastBoundState;
483
497
  state.appendEntry = undefined;
484
498
  state.scopeKey = getCursorSessionScopeKey();
485
499
  state.sessionFile = undefined;
@@ -492,7 +506,7 @@ function resetStateForTests(): void {
492
506
  state.lastBranchHandle = undefined;
493
507
  state.pendingHandle = undefined;
494
508
  state.unownedUserEntryIds = new Set();
495
- resumeHandlePersistSuppressed = false;
509
+ statesByScope.clear();
496
510
  }
497
511
 
498
512
  export const __testUtils = {
@@ -500,6 +514,5 @@ export const __testUtils = {
500
514
  hashBranchStep,
501
515
  reset: resetStateForTests,
502
516
  set: setStateForTests,
503
- state,
504
- isResumeHandlePersistSuppressed: () => resumeHandlePersistSuppressed,
517
+ get state() { return getResumeState(); },
505
518
  };
@@ -3,11 +3,12 @@ import type { AgentModeOption, LocalAgentOptions, LocalAgentStore, ModelSelectio
3
3
  import type { Context } from "@earendil-works/pi-ai";
4
4
  import {
5
5
  getRegisteredCursorPiToolBridge,
6
+ type CursorPiToolBridge,
6
7
  type CursorPiBridgeToolRequest,
7
8
  type CursorPiToolBridgeRun,
8
9
  } from "./cursor-pi-tool-bridge.js";
9
10
  import { computeCursorContextFingerprint } from "./context.js";
10
- import { getCursorSessionFile, getCursorSessionScopeGeneration, getCursorSessionScopeKey } from "./cursor-session-scope.js";
11
+ import { getCursorSessionFile, getCursorSessionScopeGeneration, getCursorSessionScopeKey, type CursorTurnScope } from "./cursor-session-scope.js";
11
12
  import {
12
13
  getMatchingCursorSessionAgentResumeHandle,
13
14
  persistCursorSessionAgentResumeHandle,
@@ -121,6 +122,8 @@ function rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey: string, poolK
121
122
  }
122
123
 
123
124
  interface SessionCursorAgentCreateParams {
125
+ scope?: CursorTurnScope;
126
+ bridge?: CursorPiToolBridge;
124
127
  apiKey: string;
125
128
  agentMode: AgentModeOption;
126
129
  cwd: string;
@@ -205,8 +208,7 @@ function buildApiKeyPoolKeyFingerprint(apiKey: string): string {
205
208
  return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
206
209
  }
207
210
 
208
- function buildBridgePoolKeySuffix(): string {
209
- const registeredBridge = getRegisteredCursorPiToolBridge();
211
+ function buildBridgePoolKeySuffix(registeredBridge: CursorPiToolBridge | undefined): string {
210
212
  if (!registeredBridge) return "bridge:absent";
211
213
  return registeredBridge.getToolSurfaceSignature();
212
214
  }
@@ -224,7 +226,7 @@ function buildSessionAgentPoolKey(scopeKey: string, params: SessionCursorAgentCr
224
226
  ? "http1:on"
225
227
  : "http1:off",
226
228
  buildApiKeyPoolKeyFingerprint(params.apiKey),
227
- buildBridgePoolKeySuffix(),
229
+ buildBridgePoolKeySuffix("bridge" in params ? params.bridge : getRegisteredCursorPiToolBridge()),
228
230
  ].join("\0");
229
231
  }
230
232
 
@@ -309,7 +311,7 @@ function commitSessionAgentSendForLease(
309
311
  poolKey: entry.poolKey,
310
312
  sendState: entry.sendState,
311
313
  storeIdentity: entry.sessionStore.identity,
312
- });
314
+ }, scopeKey);
313
315
  }
314
316
  }
315
317
 
@@ -445,7 +447,7 @@ async function createSessionAgentEntry(
445
447
  let bridgeRun: CursorPiToolBridgeRun | undefined;
446
448
  let sessionStore: OpenCursorSessionStore | undefined;
447
449
  try {
448
- const registeredBridge = getRegisteredCursorPiToolBridge();
450
+ const registeredBridge = params.bridge;
449
451
  if (registeredBridge) {
450
452
  bridgeRun = await registeredBridge.createRun({
451
453
  onToolRequest: params.onBridgeToolRequest,
@@ -466,7 +468,7 @@ async function createSessionAgentEntry(
466
468
  createAgent ??= sdk.Agent.create;
467
469
  resumeAgent ??= sdk.Agent.resume;
468
470
  }
469
- const resumeHandle = resumeEligible ? getMatchingCursorSessionAgentResumeHandle(resolvedPoolKey) : undefined;
471
+ const resumeHandle = resumeEligible ? getMatchingCursorSessionAgentResumeHandle(resolvedPoolKey, scopeKey) : undefined;
470
472
  const storeSelection = await openCursorSessionStoreForScope({
471
473
  cwd: params.cwd,
472
474
  scopeKey,
@@ -546,9 +548,10 @@ export function invalidateSessionAgent(
546
548
  if (options?.deadTransport) deadTransportScopeKeys.add(scopeKey);
547
549
  }
548
550
 
549
- export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise<SessionCursorAgentLease> {
550
- const scopeKey = getCursorSessionScopeKey();
551
- const persistentStore = getCursorSessionFile() !== undefined;
551
+ export async function acquireSessionCursorAgent(input: SessionCursorAgentCreateParams): Promise<SessionCursorAgentLease> {
552
+ const params = { ...input, bridge: "bridge" in input ? input.bridge : getRegisteredCursorPiToolBridge() };
553
+ const scopeKey = params.scope?.scopeKey ?? getCursorSessionScopeKey();
554
+ const persistentStore = (params.scope ? params.scope.sessionFile : getCursorSessionFile()) !== undefined;
552
555
 
553
556
  while (true) {
554
557
  assertScopeAcceptsAcquire(scopeKey);
@@ -1,19 +1,14 @@
1
1
  import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
2
2
  import { resetSessionCursorAgent } from "./cursor-session-agent.js";
3
- import { suppressCursorSessionAgentResumeHandlePersist } from "./cursor-session-agent-resume.js";
4
3
  import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
5
4
 
6
5
  /**
7
- * Prepare the pooled Cursor session agent for pi compaction summarization.
8
- * Releases any scoped live-run drain state still tied to the pooled agent, then
9
- * disposes the pool entry so summarization acquires a clean SDK agent.
10
- * Suppresses local-resume handle persist so the summarizer send cannot leak
11
- * a one-message handle into the next normal turn (#223).
6
+ * Release scoped conversation runs and reset the pool before compaction.
7
+ * Summary execution owns a separate temporary agent, never this pool.
12
8
  */
13
9
  export async function prepareCursorSessionForCompaction(
14
10
  scopeKey: string = getCursorSessionScopeKey(),
15
11
  ): Promise<void> {
16
- suppressCursorSessionAgentResumeHandlePersist();
17
12
  while (true) {
18
13
  const run = cursorLiveRuns.getActiveForScope(scopeKey);
19
14
  if (!run || run.disposed) break;
@@ -1,12 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { parseArgs } from "@earendil-works/pi-coding-agent";
3
- import type { ExtensionHandler, ProjectTrustHandler, SessionInfoChangedEvent, SessionStartEvent } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionHandler, ProjectTrustHandler, SessionInfoChangedEvent, SessionStartEvent, SessionShutdownEvent } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
5
5
 
6
6
  interface CursorSessionScopeExtensionApi {
7
7
  on(event: "project_trust", handler: ProjectTrustHandler): void;
8
8
  on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
9
9
  on(event: "session_info_changed", handler: ExtensionHandler<SessionInfoChangedEvent>): void;
10
+ on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
10
11
  }
11
12
 
12
13
  const ANONYMOUS_SESSION_SCOPE_KEY = "__anonymous__";
@@ -15,6 +16,16 @@ export const MAX_CURSOR_SESSION_NAME_LENGTH = 100;
15
16
 
16
17
  type CursorSessionScopeChangeHandler = (previousScopeKey: string) => Promise<void> | void;
17
18
 
19
+ export interface CursorTurnScope {
20
+ readonly cwd: string;
21
+ readonly scopeKey: string;
22
+ readonly sessionFile: string | undefined;
23
+ readonly sessionId: string | undefined;
24
+ readonly sessionName: string | undefined;
25
+ readonly projectTrusted: boolean;
26
+ readonly generation: number;
27
+ }
28
+
18
29
  const state = {
19
30
  sessionCwd: process.cwd(),
20
31
  sessionFile: undefined as string | undefined,
@@ -23,44 +34,67 @@ const state = {
23
34
  projectTrusted: false,
24
35
  sessionGeneration: 0,
25
36
  };
26
-
27
- const scopeGenerations = new Map<string, number>([[ANONYMOUS_SESSION_SCOPE_KEY, state.sessionGeneration]]);
37
+ type ScopeRecord = typeof state;
38
+ const scopes = new Map<object, ScopeRecord>();
39
+ const scopeChangeHandlers = new Map<object, CursorSessionScopeChangeHandler>();
40
+ const scopeGenerations = new Map<string, number>([[ANONYMOUS_SESSION_SCOPE_KEY, 0]]);
28
41
  const projectTrustResolutionCwds = new Set<string>();
29
42
  let nextSessionGeneration = 1;
30
- let scopeChangeHandler: CursorSessionScopeChangeHandler | undefined;
31
43
 
32
- /**
33
- * Pi session file when known; used to scope reused Cursor SDK agents to one pi session.
34
- */
44
+ function scopeKeyFor(record: ScopeRecord): string {
45
+ if (record.sessionFile) return record.sessionFile;
46
+ if (record.sessionId) return `${EPHEMERAL_SESSION_SCOPE_PREFIX}${record.sessionId}`;
47
+ return ANONYMOUS_SESSION_SCOPE_KEY;
48
+ }
49
+
50
+ export function cursorSessionScopeKeyForManager(manager: {
51
+ getSessionFile?(): string | undefined | null;
52
+ getSessionId?(): string | undefined;
53
+ }): string {
54
+ return manager.getSessionFile?.() ?? (manager.getSessionId?.()
55
+ ? `${EPHEMERAL_SESSION_SCOPE_PREFIX}${manager.getSessionId()}`
56
+ : ANONYMOUS_SESSION_SCOPE_KEY);
57
+ }
58
+
59
+ export function getCursorSessionScopeSnapshot(pi?: object): CursorTurnScope {
60
+ if (!pi && scopes.size > 1) throw new Error("Cursor provider ownership requires a session-bound provider closure when multiple extensions are loaded.");
61
+ const record = pi ? scopes.get(pi) : state;
62
+ if (!record) throw new Error("Cursor session binding is no longer active.");
63
+ const scopeKey = scopeKeyFor(record);
64
+ return Object.freeze({
65
+ cwd: record.sessionCwd,
66
+ scopeKey,
67
+ sessionFile: record.sessionFile,
68
+ sessionId: record.sessionId,
69
+ sessionName: record.sessionName,
70
+ projectTrusted: record.projectTrusted,
71
+ generation: getCursorSessionScopeGeneration(scopeKey),
72
+ });
73
+ }
74
+
75
+ export function hasLiveCursorSessions(): boolean {
76
+ return scopes.size > 0;
77
+ }
78
+
35
79
  export function getCursorSessionFile(): string | undefined {
36
80
  return state.sessionFile;
37
81
  }
38
82
 
39
- /**
40
- * Stable scope key for session-agent pooling. Falls back to a process-local anonymous key
41
- * before the first session_start (tests and early startup).
42
- */
83
+ /** Stable pool key, with process-local anonymous fallback before session_start. */
43
84
  export function getCursorSessionScopeKey(): string {
44
- if (state.sessionFile) return state.sessionFile;
45
- if (state.sessionId) return `${EPHEMERAL_SESSION_SCOPE_PREFIX}${state.sessionId}`;
46
- return ANONYMOUS_SESSION_SCOPE_KEY;
85
+ return scopeKeyFor(state);
47
86
  }
48
87
 
49
88
  export function getCursorSessionScopeGeneration(scopeKey: string = getCursorSessionScopeKey()): number {
50
89
  return scopeGenerations.get(scopeKey) ?? 0;
51
90
  }
52
91
 
53
- /**
54
- * Pi session cwd when known; falls back to process.cwd() before session_start.
55
- * Updated on session_start only until pi threads cwd into streamSimple—mid-session cwd
56
- * changes without a new session_start event are not reflected here.
57
- */
58
92
  export function getCursorSessionCwd(): string {
59
93
  return state.sessionCwd;
60
94
  }
61
95
 
62
- export function getCursorSessionProjectTrusted(): boolean {
63
- return state.projectTrusted;
96
+ export function getCursorSessionProjectTrusted(pi?: object): boolean {
97
+ return pi ? scopes.get(pi)?.projectTrusted === true : state.projectTrusted;
64
98
  }
65
99
 
66
100
  export function getCursorSessionName(): string | undefined {
@@ -84,9 +118,8 @@ function setCursorSessionScope(
84
118
  state.sessionId = sessionId;
85
119
  state.sessionName = normalizeCursorSessionName(sessionName);
86
120
  state.projectTrusted = projectTrusted;
87
- state.sessionGeneration = nextSessionGeneration;
88
- nextSessionGeneration += 1;
89
- scopeGenerations.set(getCursorSessionScopeKey(), state.sessionGeneration);
121
+ state.sessionGeneration = nextSessionGeneration++;
122
+ scopeGenerations.set(scopeKeyFor(state), state.sessionGeneration);
90
123
  }
91
124
 
92
125
  function recordProjectTrustResolution(cwd: string): void {
@@ -98,43 +131,58 @@ function isCliProjectTrustApproved(args = process.argv.slice(2)): boolean {
98
131
  }
99
132
 
100
133
  function resetCursorSessionScope(): void {
101
- state.sessionCwd = process.cwd();
102
- state.sessionFile = undefined;
103
- state.sessionId = undefined;
104
- state.sessionName = undefined;
105
- state.projectTrusted = false;
106
- state.sessionGeneration = 0;
134
+ Object.assign(state, {
135
+ sessionCwd: process.cwd(), sessionFile: undefined, sessionId: undefined,
136
+ sessionName: undefined, projectTrusted: false, sessionGeneration: 0,
137
+ });
107
138
  nextSessionGeneration = 1;
108
139
  scopeGenerations.clear();
109
- scopeGenerations.set(ANONYMOUS_SESSION_SCOPE_KEY, state.sessionGeneration);
140
+ scopeGenerations.set(ANONYMOUS_SESSION_SCOPE_KEY, 0);
110
141
  projectTrustResolutionCwds.clear();
142
+ scopes.clear();
143
+ scopeChangeHandlers.clear();
111
144
  }
112
145
 
113
- export function onCursorSessionScopeKeyChange(handler: CursorSessionScopeChangeHandler): void {
114
- scopeChangeHandler = handler;
146
+ export function onCursorSessionScopeKeyChange(pi: object, handler: CursorSessionScopeChangeHandler): void {
147
+ scopeChangeHandlers.set(pi, handler);
115
148
  }
116
149
 
117
150
  export function registerCursorSessionScope(pi: CursorSessionScopeExtensionApi): void {
151
+ // A fresh binding has no previous pooled scope to steal from the last-bound mirror.
152
+ const record: ScopeRecord = {
153
+ sessionCwd: process.cwd(), sessionFile: undefined, sessionId: undefined,
154
+ sessionName: undefined, projectTrusted: false, sessionGeneration: 0,
155
+ };
156
+ scopes.set(pi, record);
157
+ const trustedCwds = new Set<string>();
118
158
  pi.on("project_trust", (event) => {
119
- recordProjectTrustResolution(event.cwd);
159
+ trustedCwds.add(resolve(event.cwd));
120
160
  return { trusted: "undecided" };
121
161
  });
122
162
  pi.on("session_start", async (_event, ctx) => {
123
- const previousScopeKey = getCursorSessionScopeKey();
163
+ const previousScopeKey = scopeKeyFor(record);
164
+ const wasBound = record.sessionGeneration > 0;
124
165
  setCursorSessionScope(
125
166
  ctx.cwd,
126
167
  ctx.sessionManager?.getSessionFile?.() ?? undefined,
127
168
  ctx.sessionManager?.getSessionId?.() ?? undefined,
128
169
  ctx.isProjectTrusted?.() === true
129
- && (projectTrustResolutionCwds.has(resolve(ctx.cwd)) || isCliProjectTrustApproved()),
170
+ && (trustedCwds.has(resolve(ctx.cwd)) || projectTrustResolutionCwds.has(resolve(ctx.cwd)) || isCliProjectTrustApproved()),
130
171
  ctx.sessionManager?.getSessionName?.() ?? undefined,
131
172
  );
132
- if (previousScopeKey !== getCursorSessionScopeKey()) {
133
- await scopeChangeHandler?.(previousScopeKey);
173
+ Object.assign(record, state);
174
+ scopes.set(pi, record);
175
+ if (wasBound && previousScopeKey !== scopeKeyFor(record)) {
176
+ await scopeChangeHandlers.get(pi)?.(previousScopeKey);
134
177
  }
135
178
  });
136
179
  pi.on("session_info_changed", (event) => {
137
- state.sessionName = normalizeCursorSessionName(event.name);
180
+ record.sessionName = normalizeCursorSessionName(event.name);
181
+ Object.assign(state, record);
182
+ });
183
+ pi.on("session_shutdown", () => {
184
+ scopes.delete(pi);
185
+ scopeChangeHandlers.delete(pi);
138
186
  });
139
187
  }
140
188
 
@@ -0,0 +1,72 @@
1
+ import type { AgentModeOption } from "@cursor/sdk";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { CursorExplicitSdkConfig, CursorRuntime } from "./cursor-config.js";
4
+ import { cursorSessionScopeKeyForManager, getCursorSessionProjectTrusted } from "./cursor-session-scope.js";
5
+
6
+ export type CursorCliModeState =
7
+ | { kind: "unset" }
8
+ | { kind: "valid"; mode: AgentModeOption }
9
+ | { kind: "invalid"; raw: string; message: string };
10
+
11
+ function createCliSettings() {
12
+ return {
13
+ forceFast: false, forceNoFast: false,
14
+ mode: { kind: "unset" } as CursorCliModeState,
15
+ config: {} as CursorExplicitSdkConfig,
16
+ cloudEnvNames: undefined as string | undefined,
17
+ localForceConsumed: false,
18
+ };
19
+ }
20
+
21
+ function createSettings() {
22
+ return {
23
+ fast: new Map<string, boolean>(),
24
+ mode: undefined as AgentModeOption | undefined,
25
+ runtime: undefined as CursorRuntime | undefined,
26
+ cloudAcknowledged: false,
27
+ http1: undefined as boolean | undefined,
28
+ projectTrusted: false,
29
+ cli: createCliSettings(),
30
+ };
31
+ }
32
+ const settings = new Map<string, ReturnType<typeof createSettings>>();
33
+ let cliOwners = new WeakMap<object, ReturnType<typeof createCliSettings>>();
34
+ let legacySettings = createSettings();
35
+
36
+ export function getCursorSessionSettings(scopeKey?: string) {
37
+ if (scopeKey === undefined) return legacySettings;
38
+ let value = settings.get(scopeKey);
39
+ if (!value) {
40
+ value = createSettings();
41
+ settings.set(scopeKey, value);
42
+ }
43
+ return value;
44
+ }
45
+
46
+ export function cursorSettingsScopeForContext(ctx: Partial<Pick<ExtensionContext, "sessionManager">>): string | undefined {
47
+ const manager = ctx.sessionManager;
48
+ return manager ? cursorSessionScopeKeyForManager(manager) : undefined;
49
+ }
50
+
51
+ export function registerCursorSessionSettings(pi: Pick<ExtensionAPI, "on">): void {
52
+ let scopeKey: string | undefined;
53
+ let cli = createCliSettings();
54
+ pi.on("session_start", (_event, ctx) => {
55
+ if (scopeKey) settings.delete(scopeKey);
56
+ scopeKey = cursorSessionScopeKeyForManager(ctx.sessionManager);
57
+ legacySettings = getCursorSessionSettings(scopeKey);
58
+ // Reload creates a new ExtensionAPI but retains the same public manager.
59
+ // Keep the run's one-shot CLI consumption without retaining dead managers.
60
+ cli = cliOwners.get(ctx.sessionManager) ?? cli;
61
+ cliOwners.set(ctx.sessionManager, cli);
62
+ legacySettings.cli = cli;
63
+ legacySettings.projectTrusted = getCursorSessionProjectTrusted(pi);
64
+ });
65
+ pi.on("session_shutdown", () => { if (scopeKey) settings.delete(scopeKey); });
66
+ }
67
+
68
+ export function resetCursorSessionSettingsForTests(): void {
69
+ settings.clear();
70
+ cliOwners = new WeakMap();
71
+ legacySettings = createSettings();
72
+ }