pi-cursor-sdk 0.1.55 → 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 (88) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +100 -28
  3. package/docs/cursor-live-smoke-checklist.md +7 -7
  4. package/docs/cursor-model-ux-spec.md +48 -41
  5. package/docs/cursor-native-tool-replay.md +4 -4
  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 +39 -7
  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-errors.ts +31 -5
  67. package/src/cursor-provider-live-run-drain.ts +5 -0
  68. package/src/cursor-provider-run-finalizer.ts +37 -28
  69. package/src/cursor-provider-run-outcome.ts +7 -2
  70. package/src/cursor-provider-turn-finalize.ts +77 -7
  71. package/src/cursor-provider-turn-prepare.ts +228 -12
  72. package/src/cursor-provider-turn-runner.ts +42 -13
  73. package/src/cursor-provider-turn-send.ts +59 -16
  74. package/src/cursor-provider-turn-types.ts +44 -10
  75. package/src/cursor-runtime-state.ts +478 -0
  76. package/src/cursor-sdk-event-debug.ts +46 -6
  77. package/src/cursor-sdk-process-error-guard.ts +101 -30
  78. package/src/cursor-session-agent-cleanup.ts +328 -0
  79. package/src/cursor-session-agent-resume.ts +439 -0
  80. package/src/cursor-session-agent.ts +109 -13
  81. package/src/cursor-session-scope.ts +35 -2
  82. package/src/cursor-session-send-policy.ts +1 -1
  83. package/src/cursor-skill-tool.ts +30 -11
  84. package/src/cursor-state.ts +112 -69
  85. package/src/cursor-tool-manifest.ts +1 -1
  86. package/src/cursor-usage-accounting.ts +14 -4
  87. package/src/index.ts +11 -2
  88. package/src/model-discovery.ts +10 -56
@@ -0,0 +1,439 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionCursorAgentSendState } from "./cursor-session-agent.js";
5
+ import { asRecord } from "./cursor-record-utils.js";
6
+ import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
7
+
8
+ export const CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE = "cursor-sdk-agent-resume";
9
+
10
+ const RESUME_ENTRY_VERSION = 1;
11
+ const MAX_LOCAL_AGENT_ID_LENGTH = 256;
12
+ const EMPTY_BRANCH_HASH = hashParts(["cursor-sdk-agent-resume-branch", "v1"]);
13
+
14
+ // @cursor/sdk AgentOptions.agentId is a public custom string, so local resume narrows it without assuming UUIDs.
15
+ export function isCursorLocalAgentId(value: unknown): value is string {
16
+ return typeof value === "string" && value.length <= MAX_LOCAL_AGENT_ID_LENGTH && /^agent-[A-Za-z0-9_-]+$/.test(value);
17
+ }
18
+
19
+ export interface CursorSessionAgentResumeScope {
20
+ scopeKey: string;
21
+ sessionFile?: string;
22
+ sessionId?: string;
23
+ cwd: string;
24
+ repoRoot?: string;
25
+ }
26
+
27
+ export interface CursorSessionAgentResumeEntryData {
28
+ version: 1;
29
+ runtime: "local";
30
+ agentId: string;
31
+ scopeKey: string;
32
+ sessionFile?: string;
33
+ sessionId?: string;
34
+ cwd: string;
35
+ repoRoot?: string;
36
+ poolKey: string;
37
+ branchPathHash: string;
38
+ compactionGeneration: number;
39
+ sendState: SessionCursorAgentSendState;
40
+ createdAt: string;
41
+ cleanupCandidateAgentIds?: string[];
42
+ }
43
+
44
+ interface PendingCursorSessionAgentResumeHandle {
45
+ runtime: "local";
46
+ agentId: string;
47
+ poolKey: string;
48
+ sendState: SessionCursorAgentSendState;
49
+ }
50
+
51
+ interface CursorSessionResumeState {
52
+ appendEntry?: ExtensionAPI["appendEntry"];
53
+ scopeKey: string;
54
+ sessionFile?: string;
55
+ sessionId?: string;
56
+ cwd: string;
57
+ repoRoot?: string;
58
+ branchPathHash: string;
59
+ compactionGeneration: number;
60
+ activeHandle?: CursorSessionAgentResumeEntryData;
61
+ lastBranchHandle?: CursorSessionAgentResumeEntryData;
62
+ pendingHandle?: PendingCursorSessionAgentResumeHandle;
63
+ unownedUserEntryIds: Set<string>;
64
+ }
65
+
66
+ const state: CursorSessionResumeState = {
67
+ scopeKey: getCursorSessionScopeKey(),
68
+ cwd: process.cwd(),
69
+ branchPathHash: EMPTY_BRANCH_HASH,
70
+ compactionGeneration: 0,
71
+ unownedUserEntryIds: new Set(),
72
+ };
73
+
74
+ function hashParts(parts: readonly string[]): string {
75
+ const hash = createHash("sha256");
76
+ for (const part of parts) {
77
+ hash.update(part);
78
+ hash.update("\0");
79
+ }
80
+ return hash.digest("hex").slice(0, 32);
81
+ }
82
+
83
+ function hashBranchStep(previous: string, entry: SessionEntry): string {
84
+ return hashParts([
85
+ previous,
86
+ entry.type,
87
+ entry.id,
88
+ entry.parentId ?? "",
89
+ entry.type === "custom" ? entry.customType : "",
90
+ ]);
91
+ }
92
+
93
+ export function resolveCursorSessionRepoRoot(cwd: string): string | undefined {
94
+ try {
95
+ return execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
96
+ encoding: "utf8",
97
+ stdio: ["ignore", "pipe", "ignore"],
98
+ timeout: 2_000,
99
+ }).trim() || undefined;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ function isSendState(value: unknown): value is SessionCursorAgentSendState {
106
+ const record = asRecord(value);
107
+ return typeof record?.bootstrapped === "boolean" &&
108
+ typeof record.contextFingerprint === "string" &&
109
+ typeof record.incrementalSendCount === "number";
110
+ }
111
+
112
+ export function parseCursorSessionAgentResumeEntryData(value: unknown): CursorSessionAgentResumeEntryData | undefined {
113
+ const record = asRecord(value);
114
+ if (!record) return undefined;
115
+ if (record.version !== RESUME_ENTRY_VERSION || record.runtime !== "local") return undefined;
116
+ if (
117
+ !isCursorLocalAgentId(record.agentId) ||
118
+ typeof record.scopeKey !== "string" ||
119
+ typeof record.cwd !== "string" ||
120
+ typeof record.poolKey !== "string" ||
121
+ typeof record.branchPathHash !== "string" ||
122
+ typeof record.compactionGeneration !== "number" ||
123
+ typeof record.createdAt !== "string" ||
124
+ !isSendState(record.sendState)
125
+ ) return undefined;
126
+ if (record.sessionFile !== undefined && typeof record.sessionFile !== "string") return undefined;
127
+ if (record.sessionId !== undefined && typeof record.sessionId !== "string") return undefined;
128
+ if (record.repoRoot !== undefined && typeof record.repoRoot !== "string") return undefined;
129
+ const cleanupCandidateAgentIds = Array.isArray(record.cleanupCandidateAgentIds)
130
+ ? record.cleanupCandidateAgentIds.filter(isCursorLocalAgentId)
131
+ : undefined;
132
+ return {
133
+ version: RESUME_ENTRY_VERSION,
134
+ runtime: "local",
135
+ agentId: record.agentId,
136
+ scopeKey: record.scopeKey,
137
+ ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
138
+ ...(record.sessionId ? { sessionId: record.sessionId } : {}),
139
+ cwd: record.cwd,
140
+ ...(record.repoRoot ? { repoRoot: record.repoRoot } : {}),
141
+ poolKey: record.poolKey,
142
+ branchPathHash: record.branchPathHash,
143
+ compactionGeneration: record.compactionGeneration,
144
+ sendState: {
145
+ bootstrapped: record.sendState.bootstrapped,
146
+ contextFingerprint: record.sendState.contextFingerprint,
147
+ incrementalSendCount: record.sendState.incrementalSendCount,
148
+ },
149
+ createdAt: record.createdAt,
150
+ ...(cleanupCandidateAgentIds?.length ? { cleanupCandidateAgentIds: [...new Set(cleanupCandidateAgentIds)] } : {}),
151
+ };
152
+ }
153
+
154
+ function matchesResumeScope(data: CursorSessionAgentResumeEntryData, scope: CursorSessionAgentResumeScope): boolean {
155
+ return data.scopeKey === scope.scopeKey &&
156
+ data.sessionFile === scope.sessionFile &&
157
+ data.sessionId === scope.sessionId &&
158
+ data.cwd === scope.cwd &&
159
+ data.repoRoot === scope.repoRoot;
160
+ }
161
+
162
+ function matchesCurrentSession(
163
+ data: CursorSessionAgentResumeEntryData,
164
+ branchPathHash: string,
165
+ compactionGeneration = state.compactionGeneration,
166
+ ): boolean {
167
+ return matchesResumeScope(data, state) &&
168
+ data.compactionGeneration === compactionGeneration &&
169
+ data.branchPathHash === branchPathHash;
170
+ }
171
+
172
+ function canResumeHandleSpanEntry(entry: SessionEntry): boolean {
173
+ if (entry.type === "custom" || entry.type === "label" || entry.type === "session_info") return true;
174
+ return entry.type === "message" && entry.message.role === "user";
175
+ }
176
+
177
+ function resumeAgentLineageKey(data: CursorSessionAgentResumeEntryData): string {
178
+ return JSON.stringify([
179
+ data.agentId,
180
+ data.scopeKey,
181
+ data.sessionFile,
182
+ data.sessionId,
183
+ data.cwd,
184
+ data.repoRoot,
185
+ data.poolKey,
186
+ ]);
187
+ }
188
+
189
+ function indexLatestResumeEntries(entries: readonly SessionEntry[]): {
190
+ entryIds: Set<string>;
191
+ latestEntryIdByLineage: Map<string, string>;
192
+ } {
193
+ const entryIds = new Set<string>();
194
+ const latestEntryIdByLineage = new Map<string, string>();
195
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
196
+ const entry = entries[index];
197
+ entryIds.add(entry.id);
198
+ if (entry.type !== "custom" || entry.customType !== CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE) continue;
199
+ const data = parseCursorSessionAgentResumeEntryData(entry.data);
200
+ if (!data) continue;
201
+ const lineage = resumeAgentLineageKey(data);
202
+ if (!latestEntryIdByLineage.has(lineage)) latestEntryIdByLineage.set(lineage, entry.id);
203
+ }
204
+ return { entryIds, latestEntryIdByLineage };
205
+ }
206
+
207
+ interface ResumeBranchFoldState {
208
+ branchPathHash: string;
209
+ compactionGeneration: number;
210
+ activeHandle?: CursorSessionAgentResumeEntryData;
211
+ }
212
+
213
+ interface ResumeBranchFoldParams {
214
+ matchesEntry: (data: CursorSessionAgentResumeEntryData, branchPathHash: string, compactionGeneration: number) => boolean;
215
+ canSpanEntry: (entry: SessionEntry) => boolean;
216
+ }
217
+
218
+ /** One fold step shared by the tree-wide and single-branch resume-handle walks: advances
219
+ * branchPathHash/compactionGeneration and adopts a matching, non-superseded resume handle. */
220
+ function advanceResumeBranchState(
221
+ entry: SessionEntry,
222
+ previous: ResumeBranchFoldState,
223
+ resumeIndex: { entryIds: Set<string>; latestEntryIdByLineage: Map<string, string> },
224
+ params: ResumeBranchFoldParams,
225
+ ): ResumeBranchFoldState {
226
+ if (entry.type === "custom" && entry.customType === CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE) {
227
+ const data = parseCursorSessionAgentResumeEntryData(entry.data);
228
+ const latestEntryId = data ? resumeIndex.latestEntryIdByLineage.get(resumeAgentLineageKey(data)) : undefined;
229
+ const superseded = resumeIndex.entryIds.has(entry.id) && latestEntryId !== entry.id;
230
+ if (data && params.matchesEntry(data, previous.branchPathHash, previous.compactionGeneration) && !superseded) {
231
+ return { ...previous, activeHandle: data };
232
+ }
233
+ return previous;
234
+ }
235
+ return {
236
+ branchPathHash: hashBranchStep(previous.branchPathHash, entry),
237
+ compactionGeneration: entry.type === "compaction" ? previous.compactionGeneration + 1 : previous.compactionGeneration,
238
+ activeHandle: previous.activeHandle && !params.canSpanEntry(entry) ? undefined : previous.activeHandle,
239
+ };
240
+ }
241
+
242
+ export function readResumableCursorSessionAgentIds(
243
+ entries: readonly SessionEntry[],
244
+ scope: CursorSessionAgentResumeScope,
245
+ ): string[] {
246
+ const resumeIndex = indexLatestResumeEntries(entries);
247
+ const states = new Map<string, ResumeBranchFoldState>();
248
+ const parentIds = new Set<string>();
249
+ let rootCount = 0;
250
+ let completeTree = true;
251
+ for (const entry of entries) {
252
+ if (states.has(entry.id)) completeTree = false;
253
+ if (entry.parentId === null) rootCount += 1;
254
+ const parent = entry.parentId ? states.get(entry.parentId) : undefined;
255
+ if (entry.parentId) {
256
+ parentIds.add(entry.parentId);
257
+ if (!parent) completeTree = false;
258
+ }
259
+ const previous: ResumeBranchFoldState = {
260
+ branchPathHash: parent?.branchPathHash ?? EMPTY_BRANCH_HASH,
261
+ compactionGeneration: parent?.compactionGeneration ?? 0,
262
+ activeHandle: parent?.activeHandle,
263
+ };
264
+ states.set(entry.id, advanceResumeBranchState(entry, previous, resumeIndex, {
265
+ matchesEntry: (data, branchPathHash, compactionGeneration) =>
266
+ matchesResumeScope(data, scope) &&
267
+ data.compactionGeneration === compactionGeneration &&
268
+ data.branchPathHash === branchPathHash,
269
+ canSpanEntry: canResumeHandleSpanEntry,
270
+ }));
271
+ }
272
+ if (entries.length > 0 && rootCount !== 1) completeTree = false;
273
+ if (!completeTree) {
274
+ return [...new Set(entries.flatMap((entry) => {
275
+ if (entry.type !== "custom" || entry.customType !== CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE) return [];
276
+ const data = parseCursorSessionAgentResumeEntryData(entry.data);
277
+ return data && matchesResumeScope(data, scope) ? [data.agentId] : [];
278
+ }))].sort((a, b) => a.localeCompare(b));
279
+ }
280
+ const agentIds = new Set<string>();
281
+ for (const [entryId, branchState] of states) {
282
+ if (!parentIds.has(entryId) && branchState.activeHandle) agentIds.add(branchState.activeHandle.agentId);
283
+ }
284
+ return [...agentIds].sort((a, b) => a.localeCompare(b));
285
+ }
286
+
287
+ function canRestoreHandleSpanEntry(entry: SessionEntry): boolean {
288
+ if (entry.type === "message" && entry.message.role === "user") return !state.unownedUserEntryIds.has(entry.id);
289
+ return canResumeHandleSpanEntry(entry);
290
+ }
291
+
292
+ function restoreFromBranch(branch: readonly SessionEntry[], allEntries: readonly SessionEntry[] = branch): void {
293
+ const resumeIndex = indexLatestResumeEntries(allEntries);
294
+ let fold: ResumeBranchFoldState = { branchPathHash: EMPTY_BRANCH_HASH, compactionGeneration: 0 };
295
+ let lastBranchHandle: CursorSessionAgentResumeEntryData | undefined;
296
+ for (const entry of branch) {
297
+ const next = advanceResumeBranchState(entry, fold, resumeIndex, {
298
+ matchesEntry: matchesCurrentSession,
299
+ canSpanEntry: canRestoreHandleSpanEntry,
300
+ });
301
+ if (next.activeHandle && next.activeHandle !== fold.activeHandle) lastBranchHandle = next.activeHandle;
302
+ fold = next;
303
+ }
304
+ state.branchPathHash = fold.branchPathHash;
305
+ state.compactionGeneration = fold.compactionGeneration;
306
+ state.activeHandle = fold.activeHandle;
307
+ state.lastBranchHandle = lastBranchHandle;
308
+ }
309
+
310
+ export function getMatchingCursorSessionAgentResumeHandle(poolKey: string): CursorSessionAgentResumeEntryData | undefined {
311
+ const handle = state.activeHandle;
312
+ if (!handle || !isCursorLocalAgentId(handle.agentId)) return undefined;
313
+ if (handle.poolKey !== poolKey) return undefined;
314
+ if (handle.scopeKey !== state.scopeKey) return undefined;
315
+ if (handle.sessionFile !== state.sessionFile) return undefined;
316
+ if (handle.sessionId !== state.sessionId) return undefined;
317
+ if (handle.cwd !== state.cwd) return undefined;
318
+ if (handle.repoRoot !== state.repoRoot) return undefined;
319
+ if (handle.compactionGeneration !== state.compactionGeneration) return undefined;
320
+ return {
321
+ ...handle,
322
+ sendState: { ...handle.sendState },
323
+ };
324
+ }
325
+
326
+ export function persistCursorSessionAgentResumeHandle(input: PendingCursorSessionAgentResumeHandle): void {
327
+ if (!isCursorLocalAgentId(input.agentId)) return;
328
+ state.pendingHandle = {
329
+ runtime: input.runtime,
330
+ agentId: input.agentId,
331
+ poolKey: input.poolKey,
332
+ sendState: { ...input.sendState },
333
+ };
334
+ }
335
+
336
+ function flushPendingCursorSessionAgentResumeHandle(branch: readonly SessionEntry[]): void {
337
+ restoreFromBranch(branch);
338
+ const pending = state.pendingHandle;
339
+ state.pendingHandle = undefined;
340
+ if (!pending || !state.appendEntry) return;
341
+ const previousAgentId = state.activeHandle?.agentId ?? state.lastBranchHandle?.agentId;
342
+ const cleanupCandidateAgentIds = previousAgentId && previousAgentId !== pending.agentId ? [previousAgentId] : undefined;
343
+ const data: CursorSessionAgentResumeEntryData = {
344
+ version: RESUME_ENTRY_VERSION,
345
+ runtime: pending.runtime,
346
+ agentId: pending.agentId,
347
+ scopeKey: state.scopeKey,
348
+ ...(state.sessionFile ? { sessionFile: state.sessionFile } : {}),
349
+ ...(state.sessionId ? { sessionId: state.sessionId } : {}),
350
+ cwd: state.cwd,
351
+ ...(state.repoRoot ? { repoRoot: state.repoRoot } : {}),
352
+ poolKey: pending.poolKey,
353
+ branchPathHash: state.branchPathHash,
354
+ compactionGeneration: state.compactionGeneration,
355
+ sendState: { ...pending.sendState },
356
+ createdAt: new Date().toISOString(),
357
+ ...(cleanupCandidateAgentIds ? { cleanupCandidateAgentIds } : {}),
358
+ };
359
+ try {
360
+ state.appendEntry<CursorSessionAgentResumeEntryData>(CURSOR_SESSION_AGENT_RESUME_ENTRY_TYPE, data);
361
+ state.activeHandle = data;
362
+ } catch {
363
+ // Resume persistence is an optimization; a failed custom-entry append must not fail the completed turn.
364
+ }
365
+ }
366
+
367
+ interface CursorSessionAgentResumeExtensionApi {
368
+ appendEntry: ExtensionAPI["appendEntry"];
369
+ on: ExtensionAPI["on"];
370
+ }
371
+
372
+ export function registerCursorSessionAgentResume(pi: CursorSessionAgentResumeExtensionApi): void {
373
+ state.appendEntry = pi.appendEntry;
374
+ const restoreFromSessionManager = (sessionManager: { getBranch(): SessionEntry[]; getEntries(): SessionEntry[] }): void => {
375
+ const branch = sessionManager.getBranch();
376
+ const entries = sessionManager.getEntries();
377
+ restoreFromBranch(branch, entries.length > 0 ? entries : branch);
378
+ };
379
+ pi.on("session_start", (_event, ctx) => {
380
+ state.scopeKey = getCursorSessionScopeKey();
381
+ state.sessionFile = ctx.sessionManager.getSessionFile?.() ?? undefined;
382
+ state.sessionId = ctx.sessionManager.getSessionId?.() ?? undefined;
383
+ state.cwd = ctx.cwd;
384
+ state.repoRoot = resolveCursorSessionRepoRoot(ctx.cwd);
385
+ state.unownedUserEntryIds = new Set(ctx.sessionManager.getBranch().flatMap((entry) =>
386
+ entry.type === "message" && entry.message.role === "user" ? [entry.id] : []));
387
+ restoreFromSessionManager(ctx.sessionManager);
388
+ });
389
+ pi.on("before_agent_start", (_event, ctx) => {
390
+ restoreFromSessionManager(ctx.sessionManager);
391
+ });
392
+ pi.on("turn_end", (_event, ctx) => {
393
+ flushPendingCursorSessionAgentResumeHandle(ctx.sessionManager.getBranch());
394
+ });
395
+ pi.on("session_tree", (_event, ctx) => {
396
+ for (const entry of ctx.sessionManager.getBranch()) {
397
+ if (entry.type === "message" && entry.message.role === "user") state.unownedUserEntryIds.add(entry.id);
398
+ }
399
+ restoreFromSessionManager(ctx.sessionManager);
400
+ });
401
+ pi.on("session_compact", (event, ctx) => {
402
+ const branch = ctx.sessionManager.getBranch();
403
+ if (branch.length > 0) {
404
+ restoreFromSessionManager(ctx.sessionManager);
405
+ return;
406
+ }
407
+ state.activeHandle = undefined;
408
+ state.lastBranchHandle = undefined;
409
+ state.compactionGeneration += 1;
410
+ state.branchPathHash = hashBranchStep(state.branchPathHash, event.compactionEntry);
411
+ });
412
+ }
413
+
414
+ function setStateForTests(next: Partial<CursorSessionResumeState>): void {
415
+ Object.assign(state, next);
416
+ }
417
+
418
+ function resetStateForTests(): void {
419
+ state.appendEntry = undefined;
420
+ state.scopeKey = getCursorSessionScopeKey();
421
+ state.sessionFile = undefined;
422
+ state.sessionId = undefined;
423
+ state.cwd = process.cwd();
424
+ state.repoRoot = undefined;
425
+ state.branchPathHash = EMPTY_BRANCH_HASH;
426
+ state.compactionGeneration = 0;
427
+ state.activeHandle = undefined;
428
+ state.lastBranchHandle = undefined;
429
+ state.pendingHandle = undefined;
430
+ state.unownedUserEntryIds = new Set();
431
+ }
432
+
433
+ export const __testUtils = {
434
+ EMPTY_BRANCH_HASH,
435
+ hashBranchStep,
436
+ reset: resetStateForTests,
437
+ set: setStateForTests,
438
+ state,
439
+ };
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { AgentModeOption, ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk";
2
+ import type { AgentModeOption, LocalAgentOptions, ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk";
3
3
  import type { Context } from "@earendil-works/pi-ai/compat";
4
4
  import {
5
5
  getRegisteredCursorPiToolBridge,
@@ -8,6 +8,10 @@ import {
8
8
  } from "./cursor-pi-tool-bridge.js";
9
9
  import { computeCursorContextFingerprint } from "./context.js";
10
10
  import { getCursorSessionScopeGeneration, getCursorSessionScopeKey } from "./cursor-session-scope.js";
11
+ import {
12
+ getMatchingCursorSessionAgentResumeHandle,
13
+ persistCursorSessionAgentResumeHandle,
14
+ } from "./cursor-session-agent-resume.js";
11
15
  import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
12
16
  import { loadCursorSdk, type CursorSdkModule } from "./cursor-sdk-runtime.js";
13
17
 
@@ -25,6 +29,8 @@ export interface SessionCursorAgentLease {
25
29
  bridgeRun?: CursorPiToolBridgeRun;
26
30
  sendState: SessionCursorAgentSendState;
27
31
  created: boolean;
32
+ resumed?: boolean;
33
+ resumeNotice?: string;
28
34
  commitSend(context: Context, bootstrapped: boolean): void;
29
35
  trackRunCompletion(completion: Promise<unknown>): void;
30
36
  }
@@ -46,12 +52,18 @@ interface SessionCursorAgentReadyEntry extends SessionCursorAgentPoolEntryBase {
46
52
  status: "ready";
47
53
  agent: SDKAgent;
48
54
  bridgeRun?: CursorPiToolBridgeRun;
55
+ resumeEnabled: boolean;
56
+ resumed: boolean;
57
+ resumeNotice?: string;
49
58
  }
50
59
 
51
60
  interface SessionCursorAgentBusyEntry extends SessionCursorAgentPoolEntryBase {
52
61
  status: "busy";
53
62
  agent: SDKAgent;
54
63
  bridgeRun?: CursorPiToolBridgeRun;
64
+ resumeEnabled: boolean;
65
+ resumed: boolean;
66
+ resumeNotice?: string;
55
67
  completionSettled: Promise<void>;
56
68
  pendingCompletion: Promise<void>;
57
69
  releaseBusyWait: () => void;
@@ -103,9 +115,13 @@ interface SessionCursorAgentCreateParams {
103
115
  cwd: string;
104
116
  modelSelection: ModelSelection;
105
117
  settingSources?: SettingSource[];
118
+ localSafety?: CursorLocalSafetyOptions;
106
119
  onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void;
107
120
  debugRecorder?: CursorSdkEventDebugRecorder;
121
+ localResume?: boolean;
122
+ forceCreate?: boolean;
108
123
  createAgent?: CursorSdkModule["Agent"]["create"];
124
+ resumeAgent?: CursorSdkModule["Agent"]["resume"];
109
125
  }
110
126
 
111
127
  const sessionAgentsByScope = new Map<string, SessionCursorAgentPoolEntry>();
@@ -115,6 +131,24 @@ const scopeCreationGenerations = new Map<string, number>();
115
131
  const EMPTY_POOL_STATE: SessionCursorAgentPoolState = { status: "empty" };
116
132
  let nextSessionAgentInstanceId = 1;
117
133
 
134
+ export interface CursorLocalSafetyOptions {
135
+ autoReview?: boolean;
136
+ sandboxEnabled?: boolean;
137
+ }
138
+
139
+ export function buildCursorLocalAgentOptions(options: {
140
+ cwd: string;
141
+ settingSources?: SettingSource[];
142
+ localSafety?: CursorLocalSafetyOptions;
143
+ }): LocalAgentOptions {
144
+ return {
145
+ cwd: options.cwd,
146
+ ...(options.settingSources ? { settingSources: options.settingSources } : {}),
147
+ ...(options.localSafety?.autoReview === true ? { autoReview: true } : {}),
148
+ ...(options.localSafety?.sandboxEnabled === true ? { sandboxOptions: { enabled: true } } : {}),
149
+ };
150
+ }
151
+
118
152
  function allocateSessionAgentInstanceId(): number {
119
153
  return nextSessionAgentInstanceId++;
120
154
  }
@@ -143,6 +177,13 @@ function buildSettingSourcesPoolKey(settingSources?: SettingSource[]): string {
143
177
  return settingSources?.join(",") ?? "";
144
178
  }
145
179
 
180
+ function buildLocalSafetyPoolKey(localSafety?: CursorLocalSafetyOptions): string {
181
+ return JSON.stringify({
182
+ autoReview: localSafety?.autoReview === true,
183
+ sandboxEnabled: localSafety?.sandboxEnabled === true,
184
+ });
185
+ }
186
+
146
187
  function buildApiKeyPoolKeyFingerprint(apiKey: string): string {
147
188
  return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
148
189
  }
@@ -159,6 +200,7 @@ function buildSessionAgentPoolKey(scopeKey: string, params: SessionCursorAgentCr
159
200
  params.cwd,
160
201
  buildModelPoolKey(params.modelSelection),
161
202
  buildSettingSourcesPoolKey(params.settingSources),
203
+ buildLocalSafetyPoolKey(params.localSafety),
162
204
  buildApiKeyPoolKeyFingerprint(params.apiKey),
163
205
  buildBridgePoolKeySuffix(),
164
206
  ].join("\0");
@@ -225,9 +267,17 @@ function commitSessionAgentSendForLease(
225
267
  entry.sendState.contextFingerprint = computeCursorContextFingerprint(context);
226
268
  if (bootstrapped) {
227
269
  entry.sendState.incrementalSendCount = 0;
228
- return;
270
+ } else {
271
+ entry.sendState.incrementalSendCount += 1;
272
+ }
273
+ if (entry.resumeEnabled) {
274
+ persistCursorSessionAgentResumeHandle({
275
+ runtime: "local",
276
+ agentId: entry.agent.agentId,
277
+ poolKey: entry.poolKey,
278
+ sendState: entry.sendState,
279
+ });
229
280
  }
230
- entry.sendState.incrementalSendCount += 1;
231
281
  }
232
282
 
233
283
  function normalizeRunCompletion(completion: Promise<unknown>): Promise<void> {
@@ -299,8 +349,11 @@ function leaseFromEntry(
299
349
  params: SessionCursorAgentCreateParams,
300
350
  created: boolean,
301
351
  ): SessionCursorAgentLease {
352
+ entry.resumeEnabled = params.localResume === true;
302
353
  bindBridgeToolRequest(entry, params.onBridgeToolRequest);
303
354
  entry.bridgeRun?.setDebugRecorder(params.debugRecorder);
355
+ const resumeNotice = entry.resumeNotice;
356
+ entry.resumeNotice = undefined;
304
357
  return {
305
358
  scopeKey,
306
359
  poolKey: entry.poolKey,
@@ -309,6 +362,8 @@ function leaseFromEntry(
309
362
  bridgeRun: entry.bridgeRun,
310
363
  sendState: entry.sendState,
311
364
  created,
365
+ resumed: entry.resumed,
366
+ ...(resumeNotice ? { resumeNotice } : {}),
312
367
  commitSend: (context, bootstrapped) => {
313
368
  commitSessionAgentSendForLease(scopeKey, entry.poolKey, entry.instanceId, context, bootstrapped);
314
369
  },
@@ -365,16 +420,41 @@ async function createSessionAgentEntry(
365
420
  }
366
421
 
367
422
  const resolvedPoolKey = buildSessionAgentPoolKey(scopeKey, params);
368
- const createAgent = params.createAgent ?? (await loadCursorSdk()).Agent.create;
369
- let agent: SDKAgent;
423
+ const resumeEligible = params.localResume === true && !params.forceCreate;
424
+ let createAgent = params.createAgent;
425
+ let resumeAgent = params.resumeAgent;
426
+ if (!createAgent || (resumeEligible && !resumeAgent)) {
427
+ const sdk = await loadCursorSdk();
428
+ createAgent ??= sdk.Agent.create;
429
+ resumeAgent ??= sdk.Agent.resume;
430
+ }
431
+ const agentOptions = {
432
+ apiKey: params.apiKey,
433
+ model: params.modelSelection,
434
+ mode: params.agentMode,
435
+ local: buildCursorLocalAgentOptions({
436
+ cwd: params.cwd,
437
+ settingSources: params.settingSources,
438
+ localSafety: params.localSafety,
439
+ }),
440
+ ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}),
441
+ };
442
+ let agent: SDKAgent | undefined;
443
+ let effectiveSendState = sendState;
444
+ let resumed = false;
445
+ let resumeNotice: string | undefined;
446
+ const resumeHandle = resumeEligible ? getMatchingCursorSessionAgentResumeHandle(resolvedPoolKey) : undefined;
447
+ if (resumeHandle && resumeAgent) {
448
+ try {
449
+ agent = await resumeAgent(resumeHandle.agentId, agentOptions);
450
+ effectiveSendState = { ...resumeHandle.sendState };
451
+ resumed = true;
452
+ } catch {
453
+ resumeNotice = "Could not resume prior Cursor agent; continuing from current pi transcript in a new Cursor agent.";
454
+ }
455
+ }
370
456
  try {
371
- agent = await createAgent({
372
- apiKey: params.apiKey,
373
- model: params.modelSelection,
374
- mode: params.agentMode,
375
- local: params.settingSources ? { cwd: params.cwd, settingSources: params.settingSources } : { cwd: params.cwd },
376
- ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}),
377
- });
457
+ agent ??= await createAgent(agentOptions);
378
458
  } catch (error) {
379
459
  if (bridgeRun) {
380
460
  bridgeRun.cancel("Cursor session agent create failed");
@@ -386,6 +466,7 @@ async function createSessionAgentEntry(
386
466
  }
387
467
  throw error;
388
468
  }
469
+ if (!agent) throw new Error("Cursor SDK agent creation returned no agent");
389
470
 
390
471
  return {
391
472
  status: "ready",
@@ -394,7 +475,10 @@ async function createSessionAgentEntry(
394
475
  scopeKey,
395
476
  agent,
396
477
  bridgeRun,
397
- sendState,
478
+ sendState: effectiveSendState,
479
+ resumeEnabled: params.localResume === true,
480
+ resumed,
481
+ ...(resumeNotice ? { resumeNotice } : {}),
398
482
  };
399
483
  }
400
484
 
@@ -504,6 +588,17 @@ export async function acquireSessionCursorAgent(params: SessionCursorAgentCreate
504
588
  }
505
589
  }
506
590
 
591
+ export type RefreshSessionCursorAgentConfigResult = "reloaded" | "no-agent" | "busy" | "unsupported";
592
+
593
+ export async function refreshSessionCursorAgentConfig(scopeKey: string = getCursorSessionScopeKey()): Promise<RefreshSessionCursorAgentConfigResult> {
594
+ const entry = sessionAgentsByScope.get(scopeKey);
595
+ if (!entry || entry.status === "creating") return "no-agent";
596
+ if (entry.status === "busy") return "busy";
597
+ if (typeof entry.agent.reload !== "function") return "unsupported";
598
+ await entry.agent.reload();
599
+ return "reloaded";
600
+ }
601
+
507
602
  export async function resetSessionCursorAgent(scopeKey: string = getCursorSessionScopeKey()): Promise<void> {
508
603
  await disposePoolEntryForScope(scopeKey);
509
604
  }
@@ -525,6 +620,7 @@ export const __testUtils = {
525
620
  invalidateSessionAgent,
526
621
  disposeSessionCursorAgent,
527
622
  resetSessionCursorAgent,
623
+ refreshSessionCursorAgentConfig,
528
624
  disposeAllSessionCursorAgents,
529
625
  buildApiKeyPoolKeyFingerprint,
530
626
  buildSessionAgentPoolKey,