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.
- package/CHANGELOG.md +60 -2
- package/README.md +100 -28
- package/docs/cursor-live-smoke-checklist.md +7 -7
- package/docs/cursor-model-ux-spec.md +48 -41
- package/docs/cursor-native-tool-replay.md +4 -4
- 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 +39 -7
- 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-errors.ts +31 -5
- package/src/cursor-provider-live-run-drain.ts +5 -0
- package/src/cursor-provider-run-finalizer.ts +37 -28
- package/src/cursor-provider-run-outcome.ts +7 -2
- package/src/cursor-provider-turn-finalize.ts +77 -7
- package/src/cursor-provider-turn-prepare.ts +228 -12
- package/src/cursor-provider-turn-runner.ts +42 -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-tool-manifest.ts +1 -1
- package/src/cursor-usage-accounting.ts +14 -4
- package/src/index.ts +11 -2
- 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
|
-
|
|
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
|
|
369
|
-
let
|
|
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
|
|
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,
|