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.
- package/CHANGELOG.md +45 -1
- package/README.md +100 -28
- package/docs/cursor-live-smoke-checklist.md +7 -7
- package/docs/cursor-model-ux-spec.md +45 -38
- package/docs/cursor-native-tool-replay.md +2 -2
- package/docs/cursor-native-tool-visual-audit.md +1 -1
- package/docs/cursor-testing-lessons.md +20 -7
- package/docs/cursor-tool-surfaces.md +13 -2
- package/docs/platform-smoke-implementation.md +220 -0
- package/docs/platform-smoke.md +183 -247
- package/package.json +38 -6
- package/platform-smoke.config.mjs +5 -1
- package/scripts/cloud-runtime-smoke.d.mts +3 -0
- package/scripts/cloud-runtime-smoke.mjs +502 -0
- package/scripts/debug-provider-events.mjs +7 -2
- package/scripts/isolated-cursor-smoke.sh +4 -6
- package/scripts/lib/cursor-child-process.d.mts +1 -0
- package/scripts/lib/cursor-child-process.mjs +137 -7
- package/scripts/lib/local-resume-smoke-harness.mjs +543 -0
- package/scripts/local-resume-cleanup-smoke.mjs +108 -0
- package/scripts/local-resume-smoke.d.mts +1 -0
- package/scripts/local-resume-smoke.mjs +642 -0
- package/scripts/platform-smoke/artifact-anchored-extract.d.mts +10 -0
- package/scripts/platform-smoke/artifact-anchored-extract.mjs +111 -0
- package/scripts/platform-smoke/artifact-bundle-chunk.mjs +57 -0
- package/scripts/platform-smoke/artifact-bundle-contract.mjs +34 -0
- package/scripts/platform-smoke/artifact-fs-safety.mjs +311 -0
- package/scripts/platform-smoke/artifact-openat-extract.c +335 -0
- package/scripts/platform-smoke/artifact-secrets.mjs +155 -0
- package/scripts/platform-smoke/artifacts.mjs +293 -65
- package/scripts/platform-smoke/card-detect.mjs +16 -4
- package/scripts/platform-smoke/crabbox-runner.mjs +45 -3
- package/scripts/platform-smoke/doctor.mjs +20 -10
- package/scripts/platform-smoke/live-suite-runner.mjs +18 -57
- package/scripts/platform-smoke/local-resume-runner.mjs +252 -0
- package/scripts/platform-smoke/local-resume-suites.d.mts +15 -0
- package/scripts/platform-smoke/local-resume-suites.mjs +104 -0
- package/scripts/platform-smoke/scenarios.mjs +16 -2
- package/scripts/platform-smoke/target-runtime.mjs +206 -0
- package/scripts/platform-smoke/targets.mjs +33 -141
- package/scripts/platform-smoke/visual-evidence.mjs +6 -7
- package/scripts/platform-smoke/wrapped-line-match.mjs +9 -0
- package/scripts/platform-smoke.mjs +40 -27
- package/scripts/refresh-cursor-model-snapshots.mjs +18 -6
- package/scripts/steering-rpc-smoke.mjs +12 -2
- package/scripts/tmux-live-smoke.sh +3 -5
- package/shared/cursor-cloud-lifecycle-constants.d.mts +3 -0
- package/shared/cursor-cloud-lifecycle-constants.mjs +7 -0
- package/shared/cursor-sensitive-text.mjs +7 -1
- package/src/context.ts +5 -2
- package/src/cursor-agents-context-registration.ts +7 -0
- package/src/cursor-agents-context.ts +3 -1
- package/src/cursor-api-key.ts +15 -1
- package/src/cursor-bridge-contract.ts +3 -0
- package/src/cursor-cloud-lifecycle.ts +733 -0
- package/src/cursor-cloud-options.ts +206 -0
- package/src/cursor-cloud-reporting.ts +246 -0
- package/src/cursor-config.ts +659 -0
- package/src/cursor-display-only-trace.ts +14 -0
- package/src/cursor-display-text.ts +8 -2
- package/src/cursor-durable-fs.ts +49 -0
- package/src/cursor-fallback-models.generated.ts +2045 -485
- package/src/cursor-live-run-accounting.ts +7 -1
- package/src/cursor-live-run-coordinator.ts +1 -0
- package/src/cursor-pi-tool-bridge-run.ts +14 -4
- package/src/cursor-provider-live-run-drain.ts +5 -0
- package/src/cursor-provider-run-finalizer.ts +36 -28
- package/src/cursor-provider-turn-finalize.ts +72 -6
- package/src/cursor-provider-turn-prepare.ts +228 -12
- package/src/cursor-provider-turn-runner.ts +41 -13
- package/src/cursor-provider-turn-send.ts +59 -16
- package/src/cursor-provider-turn-types.ts +44 -10
- package/src/cursor-runtime-state.ts +478 -0
- package/src/cursor-sdk-event-debug.ts +46 -6
- package/src/cursor-sdk-process-error-guard.ts +101 -30
- package/src/cursor-session-agent-cleanup.ts +328 -0
- package/src/cursor-session-agent-resume.ts +439 -0
- package/src/cursor-session-agent.ts +109 -13
- package/src/cursor-session-scope.ts +35 -2
- package/src/cursor-session-send-policy.ts +1 -1
- package/src/cursor-skill-tool.ts +30 -11
- package/src/cursor-state.ts +112 -69
- package/src/cursor-usage-accounting.ts +14 -4
- package/src/index.ts +11 -2
- 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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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 (
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
50
|
-
}
|
|
78
|
+
return forwardEmit.call(this, event, ...args);
|
|
79
|
+
};
|
|
80
|
+
process.emit = cursorProcessEmit as typeof process.emit;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
if (!
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
72
|
-
if (
|
|
73
|
-
|
|
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
|
-
|
|
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
|
+
};
|