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,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
2
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
5
5
  import { resolveCursorApiKey } from "./cursor-api-key.js";
@@ -12,7 +12,7 @@ import {
12
12
  CLOUD_LIFECYCLE_ENTRY_TYPE,
13
13
  CLOUD_LIFECYCLE_JOURNAL_PREFIX,
14
14
  } from "../shared/cursor-cloud-lifecycle-constants.mjs";
15
- import { fsyncExistingRegularFile, noFollowFlag, openExistingRegularFileNoFollow } from "./cursor-durable-fs.js";
15
+ import { createRegularFileExclusive, fsyncExistingRegularFile, openExistingRegularFileNoFollow } from "./cursor-durable-fs.js";
16
16
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
17
17
  import { asRecord, getString } from "./cursor-record-utils.js";
18
18
  import { scrubSensitiveText } from "./cursor-sensitive-text.js";
@@ -83,6 +83,7 @@ interface CloudLifecycleSessionState {
83
83
  getBranch?: () => SessionEntry[];
84
84
  }
85
85
 
86
+ const cloudBindings = new Map<object, { api: CloudLifecycleApi; session: CloudLifecycleSessionState }>();
86
87
  let cloudLifecycleApi: CloudLifecycleApi | undefined;
87
88
  let cloudLifecycleSession: CloudLifecycleSessionState = {};
88
89
  let durableWriterForTests: ((data: CursorCloudLifecycleEntryData) => boolean) | undefined;
@@ -178,11 +179,12 @@ function buildBaseEntry(agentId: string, action: CloudLifecycleAction): CursorCl
178
179
  };
179
180
  }
180
181
 
181
- function captureCloudLifecycleSession(ctx: CloudLifecycleSessionContext): void {
182
- cloudLifecycleSession = {
183
- sessionFile: ctx.sessionManager.getSessionFile?.() ?? undefined,
184
- sessionId: ctx.sessionManager.getSessionId?.() ?? undefined,
185
- getBranch: () => ctx.sessionManager.getBranch(),
182
+ function captureCloudLifecycleSession(ctx: CloudLifecycleSessionContext): CloudLifecycleSessionState {
183
+ const manager = ctx.sessionManager;
184
+ return {
185
+ sessionFile: manager.getSessionFile() ?? undefined,
186
+ sessionId: manager.getSessionId(),
187
+ getBranch: () => manager.getBranch(),
186
188
  };
187
189
  }
188
190
 
@@ -191,38 +193,16 @@ function durableLedgerPath(sessionFile: string, sessionId: string): string {
191
193
  return join(dirname(sessionFile), `${DURABLE_LEDGER_PREFIX}-${sessionHash}.journal`);
192
194
  }
193
195
 
194
- function sameFileIdentity(left: { dev: number; ino: number }, right: { dev: number; ino: number }): boolean {
195
- return left.dev === right.dev && left.ino === right.ino;
196
- }
197
-
198
- function createRegularFileExclusive(path: string, flags: number, mode: number): number {
199
- let fd: number | undefined;
200
- try {
201
- fd = openSync(path, flags | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), mode);
202
- const opened = fstatSync(fd);
203
- const after = lstatSync(path);
204
- if (!opened.isFile() || !after.isFile() || !sameFileIdentity(opened, after)) {
205
- throw new Error("created journal path changed while opening");
206
- }
207
- return fd;
208
- } catch (error) {
209
- if (fd !== undefined) {
210
- try { closeSync(fd); } catch {}
211
- }
212
- throw error;
213
- }
214
- }
215
-
216
- function fsyncCloudLifecycleSessionFile(): boolean {
196
+ function fsyncCloudLifecycleSessionFile(session: CloudLifecycleSessionState): boolean {
217
197
  if (sessionFsyncForTests) return sessionFsyncForTests();
218
- const sessionFile = cloudLifecycleSession.sessionFile;
198
+ const sessionFile = session.sessionFile;
219
199
  if (!sessionFile || !existsSync(sessionFile)) return true;
220
200
  return fsyncExistingRegularFile(sessionFile);
221
201
  }
222
202
 
223
- function appendDurableCloudLifecycleEntry(data: CursorCloudLifecycleEntryData, anchorEntryId: string): boolean {
203
+ function appendDurableCloudLifecycleEntry(data: CursorCloudLifecycleEntryData, anchorEntryId: string, session: CloudLifecycleSessionState): boolean {
224
204
  if (durableWriterForTests) return durableWriterForTests(data);
225
- const { sessionFile, sessionId } = cloudLifecycleSession;
205
+ const { sessionFile, sessionId } = session;
226
206
  if (!sessionFile || !sessionId) return false;
227
207
  const entry: DurableCloudLifecycleEntry = {
228
208
  ...data,
@@ -269,13 +249,13 @@ function appendDurableCloudLifecycleEntry(data: CursorCloudLifecycleEntryData, a
269
249
  }
270
250
  }
271
251
 
272
- function appendCloudLifecycleEntry(pi: CloudLifecycleApi, data: CursorCloudLifecycleEntryData | undefined): boolean {
273
- if (!data || (!durableWriterForTests && (!cloudLifecycleSession.sessionFile || !cloudLifecycleSession.sessionId))) return false;
252
+ function appendCloudLifecycleEntry(pi: CloudLifecycleApi, data: CursorCloudLifecycleEntryData | undefined, session: CloudLifecycleSessionState): boolean {
253
+ if (!data || (!durableWriterForTests && (!session.sessionFile || !session.sessionId))) return false;
274
254
  let anchorEntryId: string | undefined;
275
255
  try {
276
- const previousEntryId = durableWriterForTests ? undefined : cloudLifecycleSession.getBranch?.().at(-1)?.id;
256
+ const previousEntryId = durableWriterForTests ? undefined : session.getBranch?.().at(-1)?.id;
277
257
  pi.appendEntry<CursorCloudLifecycleEntryData>(CLOUD_LIFECYCLE_ENTRY_TYPE, data);
278
- const anchor = cloudLifecycleSession.getBranch?.().at(-1);
258
+ const anchor = session.getBranch?.().at(-1);
279
259
  anchorEntryId = durableWriterForTests
280
260
  ? "test-cloud-lifecycle-entry"
281
261
  : anchor?.type === "custom" &&
@@ -287,19 +267,19 @@ function appendCloudLifecycleEntry(pi: CloudLifecycleApi, data: CursorCloudLifec
287
267
  return false;
288
268
  }
289
269
  return anchorEntryId !== undefined &&
290
- fsyncCloudLifecycleSessionFile() &&
291
- appendDurableCloudLifecycleEntry(data, anchorEntryId);
270
+ fsyncCloudLifecycleSessionFile(session) &&
271
+ appendDurableCloudLifecycleEntry(data, anchorEntryId, session);
292
272
  }
293
273
 
294
274
  function appendCloudLifecycleMutationEntry(
295
- pi: CloudLifecycleApi,
296
- ctx: CloudLifecycleCommandContext,
275
+ pi: CloudLifecycleApi | undefined,
297
276
  data: CursorCloudLifecycleEntryData | undefined,
277
+ session: CloudLifecycleSessionState,
278
+ anchorEntryId: string | undefined,
298
279
  ): boolean {
299
- const anchorEntryId = ctx.sessionManager.getBranch().at(-1)?.id;
300
- if (!data || !anchorEntryId || !fsyncCloudLifecycleSessionFile() || !appendDurableCloudLifecycleEntry(data, anchorEntryId)) return false;
280
+ if (!data || !anchorEntryId || !fsyncCloudLifecycleSessionFile(session) || !appendDurableCloudLifecycleEntry(data, anchorEntryId, session)) return false;
301
281
  try {
302
- pi.appendEntry<CursorCloudLifecycleEntryData>(CLOUD_LIFECYCLE_ENTRY_TYPE, data);
282
+ pi?.appendEntry<CursorCloudLifecycleEntryData>(CLOUD_LIFECYCLE_ENTRY_TYPE, data);
303
283
  } catch {
304
284
  // The fsynced mutation journal is authoritative.
305
285
  }
@@ -308,38 +288,54 @@ function appendCloudLifecycleMutationEntry(
308
288
 
309
289
  export function registerCursorCloudLifecycleLedger(pi: CloudLifecycleApi): void {
310
290
  cloudLifecycleApi = pi;
311
- pi.on("session_start", (_event, ctx) => captureCloudLifecycleSession(ctx));
312
- pi.on("before_agent_start", (_event, ctx) => captureCloudLifecycleSession(ctx));
313
- pi.on("session_tree", (_event, ctx) => captureCloudLifecycleSession(ctx));
291
+ const binding = { api: pi, session: {} as CloudLifecycleSessionState };
292
+ cloudBindings.set(pi, binding);
293
+ const capture = (ctx: CloudLifecycleSessionContext) => {
294
+ binding.session = captureCloudLifecycleSession(ctx);
295
+ cloudLifecycleSession = binding.session;
296
+ };
297
+ pi.on("session_start", (_event, ctx) => capture(ctx));
298
+ pi.on("before_agent_start", (_event, ctx) => capture(ctx));
299
+ pi.on("session_tree", (_event, ctx) => capture(ctx));
300
+ pi.on("session_shutdown", () => { cloudBindings.delete(pi); });
314
301
  }
315
302
 
316
303
  export function recordCursorCloudLifecycleRun(
317
304
  report: Omit<CursorCloudRunReport, "runId"> & { runId?: string },
318
- options: { apiKey?: string } = {},
305
+ options: { apiKey?: string; binding?: { api: CloudLifecycleApi; session: CloudLifecycleSessionState } } = {},
319
306
  ): boolean {
320
- if (!cloudLifecycleApi) return false;
307
+ const api = options.binding?.api ?? cloudLifecycleApi;
308
+ if (!api) return false;
321
309
  const baseEntry = buildBaseEntry(report.agentId, "record");
322
310
  if (!baseEntry) return false;
323
311
  const branches = buildBranchEntries(report, options.apiKey);
324
312
  const runId = typeof report.runId === "string"
325
313
  ? sanitizeLedgerString(report.runId, MAX_LEDGER_RUN_ID_LENGTH, options.apiKey)
326
314
  : "";
327
- return appendCloudLifecycleEntry(cloudLifecycleApi, {
315
+ return appendCloudLifecycleEntry(api, {
328
316
  ...baseEntry,
329
317
  ...(runId ? { runId } : {}),
330
318
  ...(branches ? { branches } : {}),
331
- });
319
+ }, options.binding?.session ?? cloudLifecycleSession);
332
320
  }
333
321
 
334
- export function recordCursorCloudLifecycleSafely(
335
- report: { agentId: string; runId?: string },
322
+ export type CursorCloudLifecycleRecorder = (
323
+ report: { agentId: string; runId?: string; branches?: CursorCloudRunReport["branches"] },
336
324
  apiKey: string | undefined,
337
- ): boolean {
338
- try {
339
- return recordCursorCloudLifecycleRun({ ...report, branches: [] }, { apiKey });
340
- } catch {
341
- return false;
342
- }
325
+ ) => boolean;
326
+
327
+ export function captureCursorCloudLifecycleRecorder(owner?: object): CursorCloudLifecycleRecorder {
328
+ const current = owner ? cloudBindings.get(owner) : undefined;
329
+ const api = current?.api ?? (owner ? undefined : cloudLifecycleApi);
330
+ const session = { ...(current?.session ?? (owner ? {} : cloudLifecycleSession)) };
331
+ return (report, apiKey) => {
332
+ if (!api) return false;
333
+ try {
334
+ return recordCursorCloudLifecycleRun({ ...report, branches: report.branches ?? [] }, { apiKey, binding: { api, session } });
335
+ } catch {
336
+ return false;
337
+ }
338
+ };
343
339
  }
344
340
 
345
341
  export function createCursorCloudLifecyclePersistenceError(
@@ -444,9 +440,8 @@ export function readCursorCloudLifecycleAgents(entries: readonly SessionEntry[])
444
440
  return reduceCloudLifecycleEntries(readCursorCloudLifecycleEntries(entries));
445
441
  }
446
442
 
447
- function readDurableCloudLifecycleJournal(ctx: CloudLifecycleCommandContext): ParsedDurableCloudLifecycleEntry[] {
448
- const sessionFile = ctx.sessionManager.getSessionFile?.();
449
- const sessionId = ctx.sessionManager.getSessionId?.();
443
+ function readDurableCloudLifecycleJournal(session: CloudLifecycleSessionState): ParsedDurableCloudLifecycleEntry[] {
444
+ const { sessionFile, sessionId } = session;
450
445
  if (!sessionFile || !sessionId) return [];
451
446
  let fd: number | undefined;
452
447
  let lines: string[];
@@ -478,12 +473,12 @@ function readDurableCloudLifecycleJournal(ctx: CloudLifecycleCommandContext): Pa
478
473
  return entries;
479
474
  }
480
475
 
481
- function readDurableCloudLifecycleEntries(ctx: CloudLifecycleCommandContext): CursorCloudLifecycleEntryData[] {
482
- const sessionFile = ctx.sessionManager.getSessionFile?.();
476
+ function readDurableCloudLifecycleEntries(session: CloudLifecycleSessionState): CursorCloudLifecycleEntryData[] {
477
+ const { sessionFile } = session;
483
478
  if (!sessionFile) return [];
484
479
  const fileless = !existsSync(sessionFile);
485
- const branchIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
486
- return readDurableCloudLifecycleJournal(ctx).flatMap((entry) => {
480
+ const branchIds = new Set((session.getBranch?.() ?? []).map((entry) => entry.id));
481
+ return readDurableCloudLifecycleJournal(session).flatMap((entry) => {
487
482
  if (entry.data.action !== "record") return [entry.data];
488
483
  if (fileless) return entry.anchorEntryId === null ? [entry.data] : [];
489
484
  return entry.anchorEntryId !== null && branchIds.has(entry.anchorEntryId) ? [entry.data] : [];
@@ -494,17 +489,17 @@ function cloudLifecycleEntryKey(data: CursorCloudLifecycleEntryData): string {
494
489
  return JSON.stringify([data.action, data.agentId, data.runId, data.timestamp, data.branches]);
495
490
  }
496
491
 
497
- function reconcileDurableCloudLifecycleOrphans(pi: CloudLifecycleApi, ctx: CloudLifecycleCommandContext): boolean {
498
- const sessionFile = ctx.sessionManager.getSessionFile?.();
492
+ function reconcileDurableCloudLifecycleOrphans(pi: CloudLifecycleApi, session: CloudLifecycleSessionState): boolean {
493
+ const { sessionFile } = session;
499
494
  if (!sessionFile || !existsSync(sessionFile)) return true;
500
- const journal = readDurableCloudLifecycleJournal(ctx);
495
+ const journal = readDurableCloudLifecycleJournal(session);
501
496
  const claimedAgentIds = new Set(journal.flatMap((entry) =>
502
497
  entry.data.action === "record" && entry.anchorEntryId !== null ? [entry.data.agentId] : []));
503
498
  const latestOrphanByAgent = new Map<string, ParsedDurableCloudLifecycleEntry>();
504
499
  for (const entry of journal) {
505
500
  if (entry.data.action === "record" && entry.anchorEntryId === null) latestOrphanByAgent.set(entry.data.agentId, entry);
506
501
  }
507
- const branch = ctx.sessionManager.getBranch();
502
+ const branch = session.getBranch?.() ?? [];
508
503
  for (const [agentId, orphan] of latestOrphanByAgent) {
509
504
  if (claimedAgentIds.has(agentId)) continue;
510
505
  let matchingEntry: SessionEntry | undefined;
@@ -518,24 +513,24 @@ function reconcileDurableCloudLifecycleOrphans(pi: CloudLifecycleApi, ctx: Cloud
518
513
  }
519
514
  }
520
515
  if (matchingEntry) {
521
- if (!fsyncCloudLifecycleSessionFile() || !appendDurableCloudLifecycleEntry(orphan.data, matchingEntry.id)) return false;
516
+ if (!fsyncCloudLifecycleSessionFile(session) || !appendDurableCloudLifecycleEntry(orphan.data, matchingEntry.id, session)) return false;
522
517
  continue;
523
518
  }
524
- if (orphan.sessionFile !== sessionFile && !appendCloudLifecycleEntry(pi, orphan.data)) return false;
519
+ if (orphan.sessionFile !== sessionFile && !appendCloudLifecycleEntry(pi, orphan.data, session)) return false;
525
520
  }
526
521
  return true;
527
522
  }
528
523
 
529
- function reconcileDurableCloudLifecycleOrphansForCommand(pi: CloudLifecycleApi, ctx: CloudLifecycleCommandContext): boolean {
530
- if (reconcileDurableCloudLifecycleOrphans(pi, ctx)) return true;
524
+ function reconcileDurableCloudLifecycleOrphansForCommand(pi: CloudLifecycleApi, ctx: CloudLifecycleCommandContext, session: CloudLifecycleSessionState): boolean {
525
+ if (reconcileDurableCloudLifecycleOrphans(pi, session)) return true;
531
526
  ctx.ui.notify("Unable to reconcile the durable Cursor cloud lifecycle journal with this session branch.", "error");
532
527
  return false;
533
528
  }
534
529
 
535
- function readRecordedCloudAgents(ctx: CloudLifecycleCommandContext): CursorCloudLifecycleAgentRecord[] {
536
- const durableEntries = readDurableCloudLifecycleEntries(ctx);
530
+ function readRecordedCloudAgents(session: CloudLifecycleSessionState): CursorCloudLifecycleAgentRecord[] {
531
+ const durableEntries = readDurableCloudLifecycleEntries(session);
537
532
  const durableKeys = new Set(durableEntries.map(cloudLifecycleEntryKey));
538
- const branchOnlyEntries = readCursorCloudLifecycleEntries(ctx.sessionManager.getBranch())
533
+ const branchOnlyEntries = readCursorCloudLifecycleEntries(session.getBranch?.() ?? [])
539
534
  .filter((entry) => !durableKeys.has(cloudLifecycleEntryKey(entry)));
540
535
  return reduceCloudLifecycleEntries([...durableEntries, ...branchOnlyEntries]);
541
536
  }
@@ -618,51 +613,85 @@ async function mutateRecordedCloudAgent(params: {
618
613
  ctx: CloudLifecycleCommandContext;
619
614
  agentId: string;
620
615
  action: "archive" | "delete";
621
- apiKey: string;
616
+ session: CloudLifecycleSessionState;
622
617
  }): Promise<void> {
623
- const records = readRecordedCloudAgents(params.ctx);
624
- const validationError = validateRecordedCloudAgentId(params.agentId, records, params.action);
618
+ const manager = params.ctx.sessionManager;
619
+ const ui = params.ctx.ui;
620
+ const modelRegistry = params.ctx.modelRegistry;
621
+ const binding = cloudBindings.get(params.pi);
622
+ let anchorEntryId = manager.getBranch().at(-1)?.id;
623
+ // Include recoverable fileless records before auth, without writing anything
624
+ // until credentials are available. General provider recording stays live.
625
+ const readAuthority = () => JSON.stringify([readRecordedCloudAgents(params.session), readDurableCloudLifecycleJournal(params.session)]);
626
+ let authority = readAuthority();
627
+ const isOriginCurrent = (): boolean => {
628
+ try {
629
+ return cloudBindings.get(params.pi) === binding &&
630
+ params.ctx.sessionManager === manager &&
631
+ manager.getSessionFile() === params.session.sessionFile &&
632
+ manager.getSessionId() === params.session.sessionId &&
633
+ manager.getBranch().at(-1)?.id === anchorEntryId;
634
+ } catch {
635
+ return false; // Pi invalidates the command context on owning reload.
636
+ }
637
+ };
638
+ const checkAuthority = (): boolean => {
639
+ if (isOriginCurrent() && readAuthority() === authority) return true;
640
+ ui.notify(`Cursor cloud ${params.action} was not started because its originating session branch or authority changed. Retry from the intended branch.`, "error");
641
+ return false;
642
+ };
643
+ const apiKey = await resolveCloudLifecycleMutationApiKey({ modelRegistry, ui, sessionManager: manager });
644
+ if (!apiKey || !checkAuthority()) return;
645
+ if (!reconcileDurableCloudLifecycleOrphansForCommand(params.pi, { modelRegistry, ui, sessionManager: manager }, params.session)) return;
646
+ // Reconciliation may append only the unchanged, captured orphan authority.
647
+ anchorEntryId = manager.getBranch().at(-1)?.id;
648
+ authority = readAuthority();
649
+ const validationError = validateRecordedCloudAgentId(params.agentId, readRecordedCloudAgents(params.session), params.action);
625
650
  if (validationError) {
626
- params.ctx.ui.notify(validationError, "error");
651
+ ui.notify(validationError, "error");
627
652
  return;
628
653
  }
629
- const apiKey = params.apiKey;
630
654
  let operations: CloudLifecycleSdkOperations;
631
655
  try {
632
656
  operations = await getSdkOperations();
633
657
  } catch (error) {
634
- params.ctx.ui.notify(`Failed to prepare Cursor cloud ${params.action}: ${formatCloudLifecycleError(error, apiKey)}`, "error");
658
+ ui.notify(`Failed to prepare Cursor cloud ${params.action}: ${formatCloudLifecycleError(error, apiKey)}`, "error");
635
659
  return;
636
660
  }
661
+ if (!checkAuthority()) return;
637
662
  if (!appendCloudLifecycleMutationEntry(
638
663
  params.pi,
639
- params.ctx,
640
664
  buildBaseEntry(params.agentId, params.action === "archive" ? "archive_intent" : "delete_intent"),
665
+ params.session,
666
+ anchorEntryId,
641
667
  )) {
642
- params.ctx.ui.notify(`Cursor cloud ${params.action} was not started because its durable intent could not be recorded.`, "error");
668
+ ui.notify(`Cursor cloud ${params.action} was not started because its durable intent could not be recorded.`, "error");
643
669
  return;
644
670
  }
671
+ // After SDK start the actual result belongs to this journal and post-intent
672
+ // anchor, even if Pi replaces the context or selects another branch.
673
+ anchorEntryId = manager.getBranch().at(-1)?.id;
645
674
  try {
646
675
  await operations[params.action](params.agentId, { apiKey });
647
676
  } catch (error) {
648
- params.ctx.ui.notify(
677
+ ui.notify(
649
678
  `Cursor cloud ${params.action} for agent ${params.agentId} is unresolved: ${formatCloudLifecycleError(error, apiKey)} Inspect it in the Cursor Cloud dashboard before retrying.`,
650
679
  "error",
651
680
  );
652
681
  return;
653
682
  }
654
- if (!appendCloudLifecycleMutationEntry(params.pi, params.ctx, buildBaseEntry(params.agentId, params.action))) {
655
- params.ctx.ui.notify(
683
+ if (!appendCloudLifecycleMutationEntry(isOriginCurrent() ? params.pi : undefined, buildBaseEntry(params.agentId, params.action), params.session, anchorEntryId)) {
684
+ ui.notify(
656
685
  `Cursor cloud agent ${params.agentId} ${params.action === "archive" ? "archived" : "deleted"}, but its durable result could not be recorded. Inspect it in the Cursor Cloud dashboard before retrying.`,
657
686
  "error",
658
687
  );
659
688
  return;
660
689
  }
661
- params.ctx.ui.notify(`Cursor cloud agent ${params.agentId} ${params.action === "archive" ? "archived" : "deleted"}.`, "info");
690
+ ui.notify(`Cursor cloud agent ${params.agentId} ${params.action === "archive" ? "archived" : "deleted"}.`, "info");
662
691
  }
663
692
 
664
693
  export async function runCursorCloudLifecycleCommand(pi: CloudLifecycleApi, args: string, ctx: CloudLifecycleCommandContext): Promise<void> {
665
- captureCloudLifecycleSession(ctx);
694
+ const session = captureCloudLifecycleSession(ctx);
666
695
  const usage = "Usage: /cursor-cloud list | archive <bc-agentId> | delete <bc-agentId> --yes";
667
696
  const tokens = tokenizeArgs(args);
668
697
  const [subcommand, agentId, ...rest] = tokens;
@@ -671,8 +700,8 @@ export async function runCursorCloudLifecycleCommand(pi: CloudLifecycleApi, args
671
700
  ctx.ui.notify(`Invalid Cursor cloud arguments. ${usage}`, "error");
672
701
  return;
673
702
  }
674
- if (!reconcileDurableCloudLifecycleOrphansForCommand(pi, ctx)) return;
675
- ctx.ui.notify(formatCloudLifecycleRecords(readRecordedCloudAgents(ctx)), "info");
703
+ if (!reconcileDurableCloudLifecycleOrphansForCommand(pi, ctx, session)) return;
704
+ ctx.ui.notify(formatCloudLifecycleRecords(readRecordedCloudAgents(session)), "info");
676
705
  return;
677
706
  }
678
707
  if (subcommand === "archive") {
@@ -685,9 +714,7 @@ export async function runCursorCloudLifecycleCommand(pi: CloudLifecycleApi, args
685
714
  ctx.ui.notify(validationError, "error");
686
715
  return;
687
716
  }
688
- const apiKey = await resolveCloudLifecycleMutationApiKey(ctx);
689
- if (!apiKey || !reconcileDurableCloudLifecycleOrphansForCommand(pi, ctx)) return;
690
- await mutateRecordedCloudAgent({ pi, ctx, agentId, action: "archive", apiKey });
717
+ await mutateRecordedCloudAgent({ pi, ctx, agentId, action: "archive", session });
691
718
  return;
692
719
  }
693
720
  if (subcommand === "delete") {
@@ -700,9 +727,7 @@ export async function runCursorCloudLifecycleCommand(pi: CloudLifecycleApi, args
700
727
  ctx.ui.notify(validationError, "error");
701
728
  return;
702
729
  }
703
- const apiKey = await resolveCloudLifecycleMutationApiKey(ctx);
704
- if (!apiKey || !reconcileDurableCloudLifecycleOrphansForCommand(pi, ctx)) return;
705
- await mutateRecordedCloudAgent({ pi, ctx, agentId, action: "delete", apiKey });
730
+ await mutateRecordedCloudAgent({ pi, ctx, agentId, action: "delete", session });
706
731
  return;
707
732
  }
708
733
  ctx.ui.notify(`Invalid Cursor cloud command. ${usage}`, "error");
@@ -710,6 +735,7 @@ export async function runCursorCloudLifecycleCommand(pi: CloudLifecycleApi, args
710
735
 
711
736
  export const __testUtils = {
712
737
  reset: () => {
738
+ cloudBindings.clear();
713
739
  cloudLifecycleApi = undefined;
714
740
  cloudLifecycleSession = {};
715
741
  durableWriterForTests = undefined;
@@ -1,6 +1,7 @@
1
- import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync } from "node:fs";
1
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
 
3
- function sameFileIdentity(left: { dev: number; ino: number }, right: { dev: number; ino: number }): boolean {
4
+ export function sameFileIdentity(left: { dev: number; ino: number }, right: { dev: number; ino: number }): boolean {
4
5
  return left.dev === right.dev && left.ino === right.ino;
5
6
  }
6
7
 
@@ -28,7 +29,68 @@ export function openExistingRegularFileNoFollow(path: string, flags: number): nu
28
29
  }
29
30
  }
30
31
 
31
- /** Opens a read-write descriptor (required for Windows FlushFileBuffers) so fsync applies to the real file, not a swapped-in symlink target. */
32
+ /** Creates a private regular file without replacing or following an existing path. */
33
+ export function createRegularFileExclusive(path: string, flags: number, mode: number): number {
34
+ const fd = openSync(path, flags | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), mode);
35
+ try {
36
+ const opened = fstatSync(fd);
37
+ const after = lstatSync(path);
38
+ if (!opened.isFile() || !after.isFile() || !sameFileIdentity(opened, after)) throw new Error("created file changed while opening");
39
+ return fd;
40
+ } catch (error) { closeSync(fd); throw error; }
41
+ }
42
+
43
+ /** Bounded, fail-closed framed journals; an incomplete last frame is not recognized. */
44
+ export function readDurableFramePrefix(path: string, maxBytes = 16 * 1024 * 1024): { frames: unknown[]; incompleteTail: boolean } {
45
+ let fd: number;
46
+ try { fd = openExistingRegularFileNoFollow(path, constants.O_RDONLY); }
47
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { frames: [], incompleteTail: false }; throw error; }
48
+ try {
49
+ if (fstatSync(fd).size > maxBytes) throw new Error("journal exceeds size limit");
50
+ const buffer = Buffer.alloc(Math.min(fstatSync(fd).size + 1, maxBytes + 1));
51
+ let length = 0;
52
+ while (length < buffer.length) { const count = readSync(fd, buffer, length, buffer.length - length, length); if (!count) break; length += count; }
53
+ if (length === buffer.length) throw new Error("journal changed or exceeds size limit");
54
+ const text = buffer.subarray(0, length).toString("utf8");
55
+ const end = text.lastIndexOf("\n");
56
+ return {
57
+ frames: end < 0 ? [] : text.slice(0, end).split("\n").map(line => JSON.parse(line) as unknown),
58
+ incompleteTail: !!text && !text.endsWith("\n"),
59
+ };
60
+ } finally { closeSync(fd); }
61
+ }
62
+
63
+ export function readDurableFrames(path: string, maxBytes = 16 * 1024 * 1024): unknown[] {
64
+ const result = readDurableFramePrefix(path, maxBytes);
65
+ if (result.incompleteTail) throw new Error("incomplete journal frame");
66
+ return result.frames;
67
+ }
68
+
69
+ export function appendDurableFrame(path: string, data: unknown, validate?: (frames: unknown[]) => void): void {
70
+ // Validate framing and the owner's complete candidate history once, before recognition.
71
+ const frames = readDurableFrames(path);
72
+ validate?.([...frames, data]);
73
+ let created = false;
74
+ let fd: number;
75
+ try { fd = createRegularFileExclusive(path, constants.O_RDWR | constants.O_APPEND, 0o600); created = true; }
76
+ catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
78
+ fd = openExistingRegularFileNoFollow(path, constants.O_RDWR | constants.O_APPEND);
79
+ }
80
+ try {
81
+ if (process.platform !== "win32") fchmodSync(fd, 0o600);
82
+ const frame = `${JSON.stringify(data)}\n`;
83
+ if (fstatSync(fd).size + Buffer.byteLength(frame) > 16 * 1024 * 1024) throw new Error("journal exceeds size limit");
84
+ writeFileSync(fd, frame);
85
+ fsyncSync(fd);
86
+ if (created && process.platform !== "win32") {
87
+ const directoryFd = openSync(dirname(path), constants.O_RDONLY);
88
+ try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
89
+ }
90
+ } finally { closeSync(fd); }
91
+ }
92
+
93
+ /** Opens a read-write descriptor (required for Windows FlushFileBuffers) without following a swapped-in symlink. */
32
94
  export function fsyncExistingRegularFile(path: string): boolean {
33
95
  let fd: number | undefined;
34
96
  try {