pi-cursor-sdk 0.1.56 → 0.1.57

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 (85) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +100 -28
  3. package/docs/cursor-live-smoke-checklist.md +7 -7
  4. package/docs/cursor-model-ux-spec.md +45 -38
  5. package/docs/cursor-native-tool-replay.md +2 -2
  6. package/docs/cursor-native-tool-visual-audit.md +1 -1
  7. package/docs/cursor-testing-lessons.md +20 -7
  8. package/docs/cursor-tool-surfaces.md +13 -2
  9. package/docs/platform-smoke-implementation.md +220 -0
  10. package/docs/platform-smoke.md +183 -247
  11. package/package.json +38 -6
  12. package/platform-smoke.config.mjs +5 -1
  13. package/scripts/cloud-runtime-smoke.d.mts +3 -0
  14. package/scripts/cloud-runtime-smoke.mjs +502 -0
  15. package/scripts/debug-provider-events.mjs +7 -2
  16. package/scripts/isolated-cursor-smoke.sh +4 -6
  17. package/scripts/lib/cursor-child-process.d.mts +1 -0
  18. package/scripts/lib/cursor-child-process.mjs +137 -7
  19. package/scripts/lib/local-resume-smoke-harness.mjs +543 -0
  20. package/scripts/local-resume-cleanup-smoke.mjs +108 -0
  21. package/scripts/local-resume-smoke.d.mts +1 -0
  22. package/scripts/local-resume-smoke.mjs +642 -0
  23. package/scripts/platform-smoke/artifact-anchored-extract.d.mts +10 -0
  24. package/scripts/platform-smoke/artifact-anchored-extract.mjs +111 -0
  25. package/scripts/platform-smoke/artifact-bundle-chunk.mjs +57 -0
  26. package/scripts/platform-smoke/artifact-bundle-contract.mjs +34 -0
  27. package/scripts/platform-smoke/artifact-fs-safety.mjs +311 -0
  28. package/scripts/platform-smoke/artifact-openat-extract.c +335 -0
  29. package/scripts/platform-smoke/artifact-secrets.mjs +155 -0
  30. package/scripts/platform-smoke/artifacts.mjs +293 -65
  31. package/scripts/platform-smoke/card-detect.mjs +16 -4
  32. package/scripts/platform-smoke/crabbox-runner.mjs +45 -3
  33. package/scripts/platform-smoke/doctor.mjs +20 -10
  34. package/scripts/platform-smoke/live-suite-runner.mjs +18 -57
  35. package/scripts/platform-smoke/local-resume-runner.mjs +252 -0
  36. package/scripts/platform-smoke/local-resume-suites.d.mts +15 -0
  37. package/scripts/platform-smoke/local-resume-suites.mjs +104 -0
  38. package/scripts/platform-smoke/scenarios.mjs +16 -2
  39. package/scripts/platform-smoke/target-runtime.mjs +206 -0
  40. package/scripts/platform-smoke/targets.mjs +33 -141
  41. package/scripts/platform-smoke/visual-evidence.mjs +6 -7
  42. package/scripts/platform-smoke/wrapped-line-match.mjs +9 -0
  43. package/scripts/platform-smoke.mjs +40 -27
  44. package/scripts/refresh-cursor-model-snapshots.mjs +18 -6
  45. package/scripts/steering-rpc-smoke.mjs +12 -2
  46. package/scripts/tmux-live-smoke.sh +3 -5
  47. package/shared/cursor-cloud-lifecycle-constants.d.mts +3 -0
  48. package/shared/cursor-cloud-lifecycle-constants.mjs +7 -0
  49. package/shared/cursor-sensitive-text.mjs +7 -1
  50. package/src/context.ts +5 -2
  51. package/src/cursor-agents-context-registration.ts +7 -0
  52. package/src/cursor-agents-context.ts +3 -1
  53. package/src/cursor-api-key.ts +15 -1
  54. package/src/cursor-bridge-contract.ts +3 -0
  55. package/src/cursor-cloud-lifecycle.ts +733 -0
  56. package/src/cursor-cloud-options.ts +206 -0
  57. package/src/cursor-cloud-reporting.ts +246 -0
  58. package/src/cursor-config.ts +659 -0
  59. package/src/cursor-display-only-trace.ts +14 -0
  60. package/src/cursor-display-text.ts +8 -2
  61. package/src/cursor-durable-fs.ts +49 -0
  62. package/src/cursor-fallback-models.generated.ts +2045 -485
  63. package/src/cursor-live-run-accounting.ts +7 -1
  64. package/src/cursor-live-run-coordinator.ts +1 -0
  65. package/src/cursor-pi-tool-bridge-run.ts +14 -4
  66. package/src/cursor-provider-live-run-drain.ts +5 -0
  67. package/src/cursor-provider-run-finalizer.ts +36 -28
  68. package/src/cursor-provider-turn-finalize.ts +72 -6
  69. package/src/cursor-provider-turn-prepare.ts +228 -12
  70. package/src/cursor-provider-turn-runner.ts +41 -13
  71. package/src/cursor-provider-turn-send.ts +59 -16
  72. package/src/cursor-provider-turn-types.ts +44 -10
  73. package/src/cursor-runtime-state.ts +478 -0
  74. package/src/cursor-sdk-event-debug.ts +46 -6
  75. package/src/cursor-sdk-process-error-guard.ts +101 -30
  76. package/src/cursor-session-agent-cleanup.ts +328 -0
  77. package/src/cursor-session-agent-resume.ts +439 -0
  78. package/src/cursor-session-agent.ts +109 -13
  79. package/src/cursor-session-scope.ts +35 -2
  80. package/src/cursor-session-send-policy.ts +1 -1
  81. package/src/cursor-skill-tool.ts +30 -11
  82. package/src/cursor-state.ts +112 -69
  83. package/src/cursor-usage-accounting.ts +14 -4
  84. package/src/index.ts +11 -2
  85. package/src/model-discovery.ts +10 -56
@@ -1,22 +1,36 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
2
  import { classifyCursorConnectError, isCursorSdkAbortConnectError } from "./cursor-provider-errors.js";
2
3
 
3
4
  interface CursorSdkProcessErrorGuardToken {
4
5
  suppressAbortErrors: boolean;
5
6
  }
6
7
 
8
+ interface CursorSdkSessionProcessErrorGuardToken {}
9
+
7
10
  export interface CursorSdkProcessErrorGuard {
8
11
  suppressAbortErrors(): void;
9
12
  dispose(): void;
10
13
  }
11
14
 
15
+ export interface CursorSdkSessionProcessErrorGuard {
16
+ dispose(): void;
17
+ }
18
+
12
19
  type GenericProcessEmit = (event: string | symbol, ...args: unknown[]) => boolean;
13
20
 
14
- // The local Cursor SDK can surface some ConnectRPC failures as process-level
15
- // uncaught exceptions/unhandled rejections even when run.wait()/run.cancel() is awaited.
16
- // Keep suppression scoped to active Cursor provider turns and tightly matched ConnectRPC shapes.
21
+ // Cursor SDK controlled-exec tasks can reject after their originating provider turn
22
+ // has ended. The exact closed-writable failure is therefore session-scoped; existing
23
+ // ConnectRPC suppression remains scoped to active provider turns.
17
24
  const activeProviderTurns = new Set<CursorSdkProcessErrorGuardToken>();
25
+ const activeSessions = new Set<CursorSdkSessionProcessErrorGuardToken>();
26
+ let activeLifecycleSessionGuard: CursorSdkSessionProcessErrorGuard | undefined;
18
27
  let originalProcessEmit: GenericProcessEmit | undefined;
19
- let captureCallbackInstalled = false;
28
+ let cursorProcessEmit: GenericProcessEmit | undefined;
29
+ let bunUnhandledRejectionListenerInstalled = false;
30
+
31
+ function hasActiveGuard(): boolean {
32
+ return activeProviderTurns.size > 0 || activeSessions.size > 0;
33
+ }
20
34
 
21
35
  function hasActiveAbortSuppression(): boolean {
22
36
  for (const turn of activeProviderTurns) {
@@ -29,63 +43,94 @@ function isCursorProvenance(source: string): boolean {
29
43
  return source === "cursor-sdk-stack" || source === "cursor-extension-connect-stack" || source === "cursor-backend-details";
30
44
  }
31
45
 
46
+ function isCursorSdkWriteIterableClosedError(error: unknown): boolean {
47
+ return (
48
+ error instanceof Error &&
49
+ error.name === "WriteIterableClosedError" &&
50
+ error.message === "WritableIterable is closed" &&
51
+ /(?:^|[\\/])node_modules[\\/]@cursor[\\/]sdk[\\/]dist[\\/]/.test(error.stack ?? "")
52
+ );
53
+ }
54
+
32
55
  function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
33
56
  if (event !== "uncaughtException" && event !== "unhandledRejection") return false;
34
57
  const error = args[0];
58
+ if (isCursorSdkWriteIterableClosedError(error)) return activeSessions.size > 0;
35
59
  const classification = classifyCursorConnectError(error);
36
60
  if (!classification) return false;
37
61
  if (classification.kind === "abort") return hasActiveAbortSuppression();
38
62
  if (activeProviderTurns.size === 0) return false;
39
- // pi's supported Cursor SDK runtime is Node, where the SDK uses connect-node.
40
63
  if (classification.kind === "network") return isCursorProvenance(classification.source) || classification.source === "connect-node-stack";
41
64
  return isCursorProvenance(classification.source);
42
65
  }
43
66
 
44
67
  function installProcessEmitPatch(): void {
45
- if (originalProcessEmit) return;
46
- originalProcessEmit = process.emit.bind(process) as GenericProcessEmit;
47
- process.emit = function patchedCursorSdkProcessErrorEmit(this: NodeJS.Process, event: string | symbol, ...args: unknown[]): boolean {
68
+ if (cursorProcessEmit) {
69
+ if (process.emit === cursorProcessEmit) return;
70
+ if (process.emit !== originalProcessEmit) return;
71
+ cursorProcessEmit = undefined;
72
+ originalProcessEmit = undefined;
73
+ }
74
+ const forwardEmit = process.emit as GenericProcessEmit;
75
+ originalProcessEmit = forwardEmit;
76
+ cursorProcessEmit = function patchedCursorSdkProcessErrorEmit(this: NodeJS.Process, event: string | symbol, ...args: unknown[]): boolean {
48
77
  if (shouldSuppressProcessError(event, args)) return true;
49
- return originalProcessEmit!(event, ...args);
50
- } as typeof process.emit;
78
+ return forwardEmit.call(this, event, ...args);
79
+ };
80
+ process.emit = cursorProcessEmit as typeof process.emit;
51
81
  }
52
82
 
53
- function installCaptureCallbackIfAvailable(): void {
54
- if (captureCallbackInstalled || process.hasUncaughtExceptionCaptureCallback()) return;
55
- process.setUncaughtExceptionCaptureCallback((error: Error) => {
56
- if (shouldSuppressProcessError("uncaughtException", [error])) return;
57
- uninstallCaptureCallbackIfIdle(true);
58
- if (originalProcessEmit?.("uncaughtException", error)) return;
59
- throw error;
60
- });
61
- captureCallbackInstalled = true;
83
+ function isBunRuntime(): boolean {
84
+ return typeof (process.versions as { bun?: string }).bun === "string";
85
+ }
86
+
87
+ function handleBunUnhandledRejection(error: unknown): void {
88
+ if (shouldSuppressProcessError("unhandledRejection", [error])) return;
89
+ // Without another rejection listener, retain Bun's default fatal behavior.
90
+ if (process.listenerCount("unhandledRejection") === 1) throw error;
91
+ }
92
+
93
+ function installBunUnhandledRejectionListener(): void {
94
+ if (!isBunRuntime() || bunUnhandledRejectionListenerInstalled) return;
95
+ process.prependListener("unhandledRejection", handleBunUnhandledRejection);
96
+ bunUnhandledRejectionListenerInstalled = true;
62
97
  }
63
98
 
64
- function uninstallCaptureCallbackIfIdle(force = false): void {
65
- if (!captureCallbackInstalled) return;
66
- if (!force && activeProviderTurns.size > 0) return;
67
- process.setUncaughtExceptionCaptureCallback(null);
68
- captureCallbackInstalled = false;
99
+ function uninstallBunUnhandledRejectionListenerIfIdle(): void {
100
+ if (hasActiveGuard() || !bunUnhandledRejectionListenerInstalled) return;
101
+ process.off("unhandledRejection", handleBunUnhandledRejection);
102
+ bunUnhandledRejectionListenerInstalled = false;
69
103
  }
70
104
 
71
- function uninstallProcessEmitPatchIfIdle(): void {
72
- if (activeProviderTurns.size > 0 || !originalProcessEmit) return;
73
- uninstallCaptureCallbackIfIdle();
105
+ function uninstallProcessHooksIfIdle(): void {
106
+ if (hasActiveGuard()) return;
107
+ uninstallBunUnhandledRejectionListenerIfIdle();
108
+ if (!originalProcessEmit || !cursorProcessEmit || process.emit !== cursorProcessEmit) return;
74
109
  process.emit = originalProcessEmit as typeof process.emit;
75
110
  originalProcessEmit = undefined;
111
+ cursorProcessEmit = undefined;
112
+ }
113
+
114
+ function installProcessHooks(): void {
115
+ installProcessEmitPatch();
116
+ installBunUnhandledRejectionListener();
76
117
  }
77
118
 
78
119
  export const __testUtils = {
79
120
  activeProviderTurnCount: (): number => activeProviderTurns.size,
121
+ activeSessionCount: (): number => activeSessions.size,
122
+ resetLifecycleSessionGuard(): void {
123
+ activeLifecycleSessionGuard?.dispose();
124
+ activeLifecycleSessionGuard = undefined;
125
+ },
80
126
  };
81
127
 
82
128
  export { isCursorSdkAbortConnectError };
83
129
 
84
130
  export function installCursorSdkProcessErrorGuard(): CursorSdkProcessErrorGuard {
85
- installProcessEmitPatch();
86
- installCaptureCallbackIfAvailable();
87
131
  const token: CursorSdkProcessErrorGuardToken = { suppressAbortErrors: false };
88
132
  activeProviderTurns.add(token);
133
+ installProcessHooks();
89
134
  let disposed = false;
90
135
  return {
91
136
  suppressAbortErrors(): void {
@@ -96,7 +141,33 @@ export function installCursorSdkProcessErrorGuard(): CursorSdkProcessErrorGuard
96
141
  if (disposed) return;
97
142
  disposed = true;
98
143
  activeProviderTurns.delete(token);
99
- uninstallProcessEmitPatchIfIdle();
144
+ uninstallProcessHooksIfIdle();
145
+ },
146
+ };
147
+ }
148
+
149
+ export function installCursorSdkSessionProcessErrorGuard(): CursorSdkSessionProcessErrorGuard {
150
+ const token: CursorSdkSessionProcessErrorGuardToken = {};
151
+ activeSessions.add(token);
152
+ installProcessHooks();
153
+ let disposed = false;
154
+ return {
155
+ dispose(): void {
156
+ if (disposed) return;
157
+ disposed = true;
158
+ activeSessions.delete(token);
159
+ uninstallProcessHooksIfIdle();
100
160
  },
101
161
  };
102
162
  }
163
+
164
+ export function registerCursorSdkSessionProcessErrorGuard(pi: Pick<ExtensionAPI, "on">): void {
165
+ pi.on("session_start", () => {
166
+ activeLifecycleSessionGuard?.dispose();
167
+ activeLifecycleSessionGuard = installCursorSdkSessionProcessErrorGuard();
168
+ });
169
+ pi.on("session_shutdown", () => {
170
+ activeLifecycleSessionGuard?.dispose();
171
+ activeLifecycleSessionGuard = undefined;
172
+ });
173
+ }
@@ -0,0 +1,328 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { asRecord, getString } from "./cursor-record-utils.js";
3
+ import { fsyncExistingRegularFile } from "./cursor-durable-fs.js";
4
+ import { scrubSensitiveText } from "./cursor-sensitive-text.js";
5
+ import { loadCursorSdk } from "./cursor-sdk-runtime.js";
6
+ import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
7
+ import {
8
+ CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE,
9
+ isCursorLocalAgentId,
10
+ parseCursorSessionAgentResumeEntryData,
11
+ readResumableCursorSessionAgentIds,
12
+ resolveCursorSessionRepoRoot,
13
+ type CursorSessionAgentResumeEntryData,
14
+ type CursorSessionAgentResumeScope,
15
+ } from "./cursor-session-agent-resume.js";
16
+
17
+ export const CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE = "cursor-sdk-agent-cleanup";
18
+
19
+ type CleanupAction = "dry-run" | "delete";
20
+ type CleanupPhase = "intent" | "result";
21
+
22
+ export interface CursorSessionAgentCleanupFailure {
23
+ agentId: string;
24
+ error: string;
25
+ }
26
+
27
+ export interface CursorSessionAgentCleanupEntryData {
28
+ action: CleanupAction;
29
+ phase?: CleanupPhase;
30
+ runtime: "local";
31
+ timestamp: string;
32
+ candidateAgentIds: string[];
33
+ protectedAgentIds?: string[];
34
+ deletedAgentIds?: string[];
35
+ failedAgentIds?: CursorSessionAgentCleanupFailure[];
36
+ }
37
+
38
+ export interface CursorSessionAgentCleanupPlan {
39
+ candidateAgentIds: string[];
40
+ protectedAgentIds: string[];
41
+ }
42
+
43
+ export type CursorSessionAgentCleanupScope = CursorSessionAgentResumeScope;
44
+
45
+ type LocalResumeCleanupApi = Pick<ExtensionAPI, "appendEntry">;
46
+ type LocalResumeCleanupCommandContext = Pick<ExtensionCommandContext, "cwd"> & {
47
+ sessionManager: Pick<ExtensionCommandContext["sessionManager"], "getEntries" | "getBranch" | "getSessionFile" | "getSessionId">;
48
+ ui: Pick<ExtensionCommandContext["ui"], "notify">;
49
+ };
50
+ type LocalResumeCleanupSdkOperations = {
51
+ delete(agentId: string, options?: { cwd?: string }): Promise<void>;
52
+ };
53
+
54
+ // ponytail: grows for the process lifetime, but its ceiling is the exact agent IDs this process
55
+ // attempted to delete (never global) — it only fills the gap until this process exits; the durable
56
+ // intent entry, not this Set, is the authority a restarted process relies on to block retries.
57
+ // Tests must call __testUtils.reset() between cases.
58
+ const nondurableCleanupResultAgentIds = new Set<string>();
59
+ let appendDurabilityForTests: ((data: CursorSessionAgentCleanupEntryData) => boolean) | undefined;
60
+ let sdkOperationsForTests: LocalResumeCleanupSdkOperations | undefined;
61
+
62
+ function uniqueSorted(values: Iterable<string>): string[] {
63
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
64
+ }
65
+
66
+ function readResumeEntries(entries: readonly SessionEntry[]): CursorSessionAgentResumeEntryData[] {
67
+ const records: CursorSessionAgentResumeEntryData[] = [];
68
+ for (const entry of entries) {
69
+ if (entry.type !== "custom" || entry.customType !== CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE) continue;
70
+ const data = parseCursorSessionAgentResumeEntryData(entry.data);
71
+ if (data) records.push(data);
72
+ }
73
+ return records;
74
+ }
75
+
76
+ function resumeEntryMatchesCleanupScope(data: CursorSessionAgentResumeEntryData, scope: CursorSessionAgentCleanupScope): boolean {
77
+ return data.scopeKey === scope.scopeKey &&
78
+ data.sessionFile === scope.sessionFile &&
79
+ data.sessionId === scope.sessionId &&
80
+ data.cwd === scope.cwd &&
81
+ data.repoRoot === scope.repoRoot;
82
+ }
83
+
84
+ function getCurrentCleanupScope(ctx: LocalResumeCleanupCommandContext): CursorSessionAgentCleanupScope {
85
+ const sessionFile = ctx.sessionManager.getSessionFile();
86
+ const sessionId = ctx.sessionManager.getSessionId();
87
+ const repoRoot = resolveCursorSessionRepoRoot(ctx.cwd);
88
+ return {
89
+ scopeKey: getCursorSessionScopeKey(),
90
+ ...(sessionFile ? { sessionFile } : {}),
91
+ ...(sessionId ? { sessionId } : {}),
92
+ cwd: ctx.cwd,
93
+ ...(repoRoot ? { repoRoot } : {}),
94
+ };
95
+ }
96
+
97
+ function parseCleanupEntryData(value: unknown): CursorSessionAgentCleanupEntryData | undefined {
98
+ const record = asRecord(value);
99
+ if (!record || (record.action !== "dry-run" && record.action !== "delete") || record.runtime !== "local") return undefined;
100
+ if (typeof record.timestamp !== "string") return undefined;
101
+ if (record.phase !== undefined && record.phase !== "intent" && record.phase !== "result") return undefined;
102
+ if (record.action === "dry-run" && record.phase !== undefined) return undefined;
103
+ const candidateAgentIds = Array.isArray(record.candidateAgentIds) ? record.candidateAgentIds.filter((id): id is string => typeof id === "string") : [];
104
+ const protectedAgentIds = Array.isArray(record.protectedAgentIds) ? record.protectedAgentIds.filter((id): id is string => typeof id === "string") : undefined;
105
+ const deletedAgentIds = Array.isArray(record.deletedAgentIds) ? record.deletedAgentIds.filter((id): id is string => typeof id === "string") : undefined;
106
+ const failedAgentIds = Array.isArray(record.failedAgentIds)
107
+ ? record.failedAgentIds.flatMap((item): CursorSessionAgentCleanupFailure[] => {
108
+ const failure = asRecord(item);
109
+ return isCursorLocalAgentId(failure?.agentId) && typeof failure.error === "string" ? [{ agentId: failure.agentId, error: failure.error }] : [];
110
+ })
111
+ : undefined;
112
+ return {
113
+ action: record.action,
114
+ ...(record.phase ? { phase: record.phase } : {}),
115
+ runtime: "local",
116
+ timestamp: record.timestamp,
117
+ candidateAgentIds: uniqueSorted(candidateAgentIds.filter(isCursorLocalAgentId)),
118
+ ...(protectedAgentIds?.length ? { protectedAgentIds: uniqueSorted(protectedAgentIds.filter(isCursorLocalAgentId)) } : {}),
119
+ ...(deletedAgentIds?.length ? { deletedAgentIds: uniqueSorted(deletedAgentIds.filter(isCursorLocalAgentId)) } : {}),
120
+ ...(failedAgentIds?.length ? { failedAgentIds } : {}),
121
+ };
122
+ }
123
+
124
+ function readUnavailableAgentIds(entries: readonly SessionEntry[]): Set<string> {
125
+ const deleted = new Set<string>();
126
+ const pending = new Set<string>();
127
+ for (const entry of entries) {
128
+ if (entry.type !== "custom" || entry.customType !== CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE) continue;
129
+ const data = parseCleanupEntryData(entry.data);
130
+ if (data?.action !== "delete") continue;
131
+ if (data.phase === "intent") {
132
+ for (const agentId of data.candidateAgentIds) {
133
+ if (!deleted.has(agentId)) pending.add(agentId);
134
+ }
135
+ continue;
136
+ }
137
+ for (const agentId of data.deletedAgentIds ?? []) {
138
+ deleted.add(agentId);
139
+ pending.delete(agentId);
140
+ }
141
+ if (data.phase === "result") {
142
+ for (const { agentId } of data.failedAgentIds ?? []) pending.delete(agentId);
143
+ }
144
+ }
145
+ return new Set([...deleted, ...pending, ...nondurableCleanupResultAgentIds]);
146
+ }
147
+
148
+ function readLatestBranchAgentId(branch: readonly SessionEntry[], scope: CursorSessionAgentCleanupScope): string | undefined {
149
+ return readResumeEntries(branch).filter((entry) => resumeEntryMatchesCleanupScope(entry, scope)).at(-1)?.agentId;
150
+ }
151
+
152
+ export function readCursorSessionAgentCleanupPlan(
153
+ entries: readonly SessionEntry[],
154
+ branch: readonly SessionEntry[],
155
+ scope: CursorSessionAgentCleanupScope,
156
+ ): CursorSessionAgentCleanupPlan {
157
+ const unavailable = readUnavailableAgentIds(entries);
158
+ const latestBranchAgentId = readLatestBranchAgentId(branch, scope);
159
+ const protectedAgentIds = new Set(readResumableCursorSessionAgentIds(entries, scope));
160
+ if (latestBranchAgentId && isCursorLocalAgentId(latestBranchAgentId)) protectedAgentIds.add(latestBranchAgentId);
161
+ const candidates = new Set<string>();
162
+ for (const resume of readResumeEntries(entries)) {
163
+ if (!resumeEntryMatchesCleanupScope(resume, scope)) continue;
164
+ for (const agentId of resume.cleanupCandidateAgentIds ?? []) {
165
+ if (!isCursorLocalAgentId(agentId) || protectedAgentIds.has(agentId) || unavailable.has(agentId)) continue;
166
+ candidates.add(agentId);
167
+ }
168
+ }
169
+ return {
170
+ candidateAgentIds: uniqueSorted(candidates),
171
+ protectedAgentIds: uniqueSorted(protectedAgentIds),
172
+ };
173
+ }
174
+
175
+ function formatCleanupPlan(plan: CursorSessionAgentCleanupPlan): string {
176
+ if (plan.candidateAgentIds.length === 0) return "No recorded superseded local Cursor SDK agents are cleanup-eligible.";
177
+ return [
178
+ "Recorded superseded local Cursor SDK agents eligible for cleanup:",
179
+ ...plan.candidateAgentIds.map((agentId) => `- ${agentId}`),
180
+ "Run /cursor-local-resume-cleanup --yes to delete exactly these recorded agent IDs.",
181
+ ].join("\n");
182
+ }
183
+
184
+ async function getSdkOperations(): Promise<LocalResumeCleanupSdkOperations> {
185
+ if (sdkOperationsForTests) return sdkOperationsForTests;
186
+ const { Agent } = await loadCursorSdk();
187
+ return {
188
+ delete: (agentId, options) => Agent.delete(agentId, options),
189
+ };
190
+ }
191
+
192
+ function cleanupEntryVerificationKey(data: CursorSessionAgentCleanupEntryData): string {
193
+ return JSON.stringify({
194
+ action: data.action,
195
+ phase: data.phase,
196
+ runtime: data.runtime,
197
+ timestamp: data.timestamp,
198
+ candidateAgentIds: data.candidateAgentIds,
199
+ protectedAgentIds: data.protectedAgentIds ?? [],
200
+ deletedAgentIds: data.deletedAgentIds ?? [],
201
+ failedAgentIds: data.failedAgentIds ?? [],
202
+ });
203
+ }
204
+
205
+ function cleanupEntriesMatch(left: CursorSessionAgentCleanupEntryData, right: CursorSessionAgentCleanupEntryData): boolean {
206
+ return cleanupEntryVerificationKey(left) === cleanupEntryVerificationKey(right);
207
+ }
208
+
209
+ function appendCleanupEntry(pi: LocalResumeCleanupApi, data: CursorSessionAgentCleanupEntryData): void {
210
+ pi.appendEntry<CursorSessionAgentCleanupEntryData>(CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE, data);
211
+ }
212
+
213
+ function appendDurableCleanupEntry(
214
+ pi: LocalResumeCleanupApi,
215
+ ctx: LocalResumeCleanupCommandContext,
216
+ data: CursorSessionAgentCleanupEntryData,
217
+ ): boolean {
218
+ const sessionFile = ctx.sessionManager.getSessionFile();
219
+ const previousEntryId = ctx.sessionManager.getBranch().at(-1)?.id;
220
+ try {
221
+ appendCleanupEntry(pi, data);
222
+ } catch {
223
+ return false;
224
+ }
225
+ if (appendDurabilityForTests) return appendDurabilityForTests(data);
226
+ const anchor = ctx.sessionManager.getBranch().at(-1);
227
+ const persisted = anchor?.type === "custom" &&
228
+ anchor.customType === CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE &&
229
+ anchor.id !== previousEntryId
230
+ ? parseCleanupEntryData(anchor.data)
231
+ : undefined;
232
+ if (!sessionFile || !persisted || !cleanupEntriesMatch(persisted, data)) return false;
233
+ return fsyncExistingRegularFile(sessionFile);
234
+ }
235
+
236
+ export async function runCursorSessionAgentCleanupCommand(pi: LocalResumeCleanupApi, args: string, ctx: LocalResumeCleanupCommandContext): Promise<void> {
237
+ const usage = "Usage: /cursor-local-resume-cleanup [--dry-run|--yes]";
238
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
239
+ const dryRun = tokens.length === 0 || (tokens.length === 1 && tokens[0] === "--dry-run");
240
+ const deleteNow = tokens.length === 1 && tokens[0] === "--yes";
241
+ if (!dryRun && !deleteNow) {
242
+ ctx.ui.notify(`Invalid Cursor local resume cleanup arguments. ${usage}`, "error");
243
+ return;
244
+ }
245
+
246
+ const entries = ctx.sessionManager.getEntries();
247
+ const branch = ctx.sessionManager.getBranch();
248
+ const plan = readCursorSessionAgentCleanupPlan(entries, branch, getCurrentCleanupScope(ctx));
249
+ const baseEntry = {
250
+ runtime: "local" as const,
251
+ timestamp: new Date().toISOString(),
252
+ candidateAgentIds: plan.candidateAgentIds,
253
+ ...(plan.protectedAgentIds.length ? { protectedAgentIds: plan.protectedAgentIds } : {}),
254
+ };
255
+ if (dryRun) {
256
+ appendCleanupEntry(pi, { action: "dry-run", ...baseEntry });
257
+ ctx.ui.notify(formatCleanupPlan(plan), "info");
258
+ return;
259
+ }
260
+ if (plan.candidateAgentIds.length === 0) {
261
+ try {
262
+ appendCleanupEntry(pi, { action: "delete", phase: "result", ...baseEntry, deletedAgentIds: [] });
263
+ ctx.ui.notify("No recorded superseded local Cursor SDK agents to delete.", "info");
264
+ } catch {
265
+ ctx.ui.notify("No agents were deleted, but the no-op cleanup result could not be recorded.", "error");
266
+ }
267
+ return;
268
+ }
269
+
270
+ if (!appendDurableCleanupEntry(pi, ctx, { action: "delete", phase: "intent", ...baseEntry })) {
271
+ ctx.ui.notify("Cleanup intent could not be durably recorded. No agents were deleted.", "error");
272
+ return;
273
+ }
274
+
275
+ const deletedAgentIds: string[] = [];
276
+ const failedAgentIds: CursorSessionAgentCleanupFailure[] = [];
277
+ try {
278
+ const operations = await getSdkOperations();
279
+ for (const agentId of plan.candidateAgentIds) {
280
+ try {
281
+ await operations.delete(agentId, { cwd: ctx.cwd });
282
+ deletedAgentIds.push(agentId);
283
+ } catch (error) {
284
+ failedAgentIds.push({ agentId, error: scrubSensitiveText(getString(asRecord(error), "message") ?? String(error)) });
285
+ }
286
+ }
287
+ } catch (error) {
288
+ const message = scrubSensitiveText(getString(asRecord(error), "message") ?? String(error));
289
+ failedAgentIds.push(...plan.candidateAgentIds.map((agentId) => ({ agentId, error: message })));
290
+ }
291
+ if (!appendDurableCleanupEntry(pi, ctx, {
292
+ action: "delete",
293
+ phase: "result",
294
+ ...baseEntry,
295
+ deletedAgentIds,
296
+ ...(failedAgentIds.length ? { failedAgentIds } : {}),
297
+ })) {
298
+ for (const agentId of plan.candidateAgentIds) nondurableCleanupResultAgentIds.add(agentId);
299
+ ctx.ui.notify(
300
+ `Deleted ${deletedAgentIds.length} recorded local Cursor SDK agent(s). The cleanup ledger is partial because its result could not be durably recorded; the durable intent blocks automatic retries.`,
301
+ "error",
302
+ );
303
+ return;
304
+ }
305
+ if (failedAgentIds.length > 0) {
306
+ ctx.ui.notify(
307
+ `Deleted ${deletedAgentIds.length} recorded local Cursor SDK agent(s); ${failedAgentIds.length} failed.`,
308
+ "error",
309
+ );
310
+ return;
311
+ }
312
+ ctx.ui.notify(`Deleted ${deletedAgentIds.length} recorded local Cursor SDK agent(s).`, "info");
313
+ }
314
+
315
+ export const __testUtils = {
316
+ reset: () => {
317
+ nondurableCleanupResultAgentIds.clear();
318
+ appendDurabilityForTests = undefined;
319
+ sdkOperationsForTests = undefined;
320
+ },
321
+ setAppendDurability: (appendDurability: ((data: CursorSessionAgentCleanupEntryData) => boolean) | undefined) => {
322
+ appendDurabilityForTests = appendDurability;
323
+ },
324
+ setSdkOperations: (operations: LocalResumeCleanupSdkOperations | undefined) => {
325
+ sdkOperationsForTests = operations;
326
+ },
327
+ parseCleanupEntryData,
328
+ };