pi-cursor-sdk 0.1.15 → 0.1.17
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 +56 -1
- package/README.md +20 -8
- package/docs/cursor-live-smoke-checklist.md +267 -0
- package/docs/cursor-model-ux-spec.md +15 -5
- package/docs/cursor-native-tool-replay.md +16 -5
- package/package.json +12 -5
- package/scripts/steering-rpc-smoke.mjs +238 -0
- package/scripts/tmux-live-smoke.sh +418 -0
- package/scripts/validate-smoke-jsonl.mjs +152 -0
- package/src/context.ts +180 -5
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-edit-diff.ts +11 -0
- package/src/cursor-env-boolean.ts +22 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-live-run-coordinator.ts +483 -0
- package/src/cursor-native-tool-display-registration.ts +93 -0
- package/src/cursor-native-tool-display-replay.ts +465 -0
- package/src/cursor-native-tool-display-state.ts +78 -0
- package/src/cursor-native-tool-display-tools.ts +102 -0
- package/src/cursor-native-tool-display.ts +10 -639
- package/src/cursor-partial-content-emitter.ts +121 -0
- package/src/cursor-pi-tool-bridge-abort.ts +133 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
- package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
- package/src/cursor-pi-tool-bridge-run.ts +384 -0
- package/src/cursor-pi-tool-bridge-server.ts +182 -0
- package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
- package/src/cursor-pi-tool-bridge-types.ts +80 -0
- package/src/cursor-pi-tool-bridge.ts +77 -602
- package/src/cursor-provider-live-run-drain.ts +379 -0
- package/src/cursor-provider-turn-coordinator.ts +456 -0
- package/src/cursor-provider.ts +133 -1092
- package/src/cursor-question-tool.ts +7 -2
- package/src/cursor-record-utils.ts +26 -0
- package/src/cursor-sdk-output-filter.ts +100 -0
- package/src/cursor-sensitive-text.ts +37 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +14 -19
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-transcript.ts +28 -1229
- package/src/cursor-transcript-tool-formatters.ts +641 -0
- package/src/cursor-transcript-tool-specs.ts +441 -0
- package/src/cursor-transcript-utils.ts +276 -0
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +20 -3
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import type { Context, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SDKAgent } from "@cursor/sdk";
|
|
3
|
+
import {
|
|
4
|
+
consumeCursorLiveToolResults,
|
|
5
|
+
createCursorLiveRunAccountingState,
|
|
6
|
+
takeCursorLiveTurnInputTokens,
|
|
7
|
+
type CursorLiveRunAccountingState,
|
|
8
|
+
type CursorLiveToolResultConsumption,
|
|
9
|
+
} from "./cursor-live-run-accounting.js";
|
|
10
|
+
import type { CursorNativeToolDisplayItem } from "./cursor-native-tool-display.js";
|
|
11
|
+
import type { CursorPiBridgeToolRequest, CursorPiToolBridgeRun } from "./cursor-pi-tool-bridge.js";
|
|
12
|
+
import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
|
|
13
|
+
|
|
14
|
+
export class CursorLiveRunAbortError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super("aborted");
|
|
17
|
+
this.name = "CursorLiveRunAbortError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CursorLiveQueuedEvent =
|
|
22
|
+
| { type: "thinking-delta"; text: string }
|
|
23
|
+
| { type: "thinking-completed" }
|
|
24
|
+
| { type: "text-delta"; text: string }
|
|
25
|
+
| { type: "tool"; tool: CursorNativeToolDisplayItem }
|
|
26
|
+
| { type: "bridge-tool"; request: CursorPiBridgeToolRequest };
|
|
27
|
+
|
|
28
|
+
export interface CursorLiveSdkRun {
|
|
29
|
+
cancel(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CursorLiveRun {
|
|
33
|
+
id: string;
|
|
34
|
+
agent: SDKAgent;
|
|
35
|
+
bridgeRun?: CursorPiToolBridgeRun;
|
|
36
|
+
sessionBridgeRun?: CursorPiToolBridgeRun;
|
|
37
|
+
sessionAgentScopeKey: string;
|
|
38
|
+
sdkRun?: CursorLiveSdkRun;
|
|
39
|
+
accounting: CursorLiveRunAccountingState;
|
|
40
|
+
pendingEvents: CursorLiveQueuedEvent[];
|
|
41
|
+
textDeltas: string[];
|
|
42
|
+
emittedText: string;
|
|
43
|
+
recordedToolDisplayIds: string[];
|
|
44
|
+
finalText?: string;
|
|
45
|
+
done: boolean;
|
|
46
|
+
cancelled: boolean;
|
|
47
|
+
disposed: boolean;
|
|
48
|
+
errorMessage?: string;
|
|
49
|
+
chainUserInputAfterCompletion: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CursorLiveRunCreateParams {
|
|
53
|
+
id: string;
|
|
54
|
+
agent: SDKAgent;
|
|
55
|
+
bridgeRun?: CursorPiToolBridgeRun;
|
|
56
|
+
sessionBridgeRun?: CursorPiToolBridgeRun;
|
|
57
|
+
sessionAgentScopeKey?: string;
|
|
58
|
+
promptInputTokens: number;
|
|
59
|
+
textDeltas?: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CursorLiveRunCoordinatorDeps {
|
|
63
|
+
getScopeKey?: () => string;
|
|
64
|
+
getIdleDisposeMs: () => number;
|
|
65
|
+
deleteNativeToolDisplay: (id: string) => void;
|
|
66
|
+
abandonSessionAgent: (scopeKey: string | undefined) => Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CursorLiveRunCoordinator {
|
|
70
|
+
start(params: CursorLiveRunCreateParams): CursorLiveRun;
|
|
71
|
+
attachSdkRun(run: CursorLiveRun, sdkRun: CursorLiveSdkRun): void;
|
|
72
|
+
markFinished(run: CursorLiveRun, finalText: string): void;
|
|
73
|
+
markCancelled(run: CursorLiveRun): void;
|
|
74
|
+
markError(run: CursorLiveRun, errorMessage: string): void;
|
|
75
|
+
queueEvent(run: CursorLiveRun, event: CursorLiveQueuedEvent): void;
|
|
76
|
+
peekEvent(run: CursorLiveRun): CursorLiveQueuedEvent | undefined;
|
|
77
|
+
shiftEvent(run: CursorLiveRun): CursorLiveQueuedEvent | undefined;
|
|
78
|
+
collectNativeToolBatch(run: CursorLiveRun): CursorNativeToolDisplayItem[];
|
|
79
|
+
collectBridgeToolBatch(run: CursorLiveRun): CursorPiBridgeToolRequest[];
|
|
80
|
+
consumeToolResults(run: CursorLiveRun, context: Context, getReplayId: CursorReplayIdResolver): CursorLiveToolResultConsumption;
|
|
81
|
+
takeTurnInputTokens(run: CursorLiveRun, toolResultInputTokens: number): number;
|
|
82
|
+
getPendingFromContext(context: Context, getReplayId: CursorReplayIdResolver): CursorLiveRun | undefined;
|
|
83
|
+
getActiveForScope(scopeKey?: string): CursorLiveRun | undefined;
|
|
84
|
+
isReady(run: CursorLiveRun): boolean;
|
|
85
|
+
waitForProgress(run: CursorLiveRun, signal?: AbortSignal): Promise<void>;
|
|
86
|
+
withRunLease<T>(run: CursorLiveRun, signal: AbortSignal | undefined, body: () => Promise<T>): Promise<T>;
|
|
87
|
+
requestIdleDispose(run: CursorLiveRun): void;
|
|
88
|
+
release(run: CursorLiveRun): Promise<void>;
|
|
89
|
+
count(): number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type CursorLiveBridgeMatcher = Pick<CursorPiToolBridgeRun, "hasPendingPiToolCallId">;
|
|
93
|
+
|
|
94
|
+
export interface CursorLiveRunRecord {
|
|
95
|
+
id: string;
|
|
96
|
+
disposed: boolean;
|
|
97
|
+
bridgeRun?: CursorLiveBridgeMatcher;
|
|
98
|
+
sessionAgentScopeKey?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type CursorReplayIdResolver = (toolCallId: string) => string | undefined;
|
|
102
|
+
|
|
103
|
+
interface ProgressWaiter {
|
|
104
|
+
resolve: () => void;
|
|
105
|
+
reject: (error: unknown) => void;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
onAbort?: () => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface LeaseWaiter {
|
|
111
|
+
resolve: () => void;
|
|
112
|
+
reject: (error: unknown) => void;
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
onAbort?: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface CursorLiveRunPrivateState {
|
|
118
|
+
waiters: Set<ProgressWaiter>;
|
|
119
|
+
idleDisposeTimer?: ReturnType<typeof setTimeout>;
|
|
120
|
+
idleDisposeRequested: boolean;
|
|
121
|
+
leased: boolean;
|
|
122
|
+
leaseQueue: LeaseWaiter[];
|
|
123
|
+
releasing?: Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function hasTrailingUserMessagesAfterToolResults(context: Context): boolean {
|
|
127
|
+
let index = context.messages.length - 1;
|
|
128
|
+
let sawTrailingUser = false;
|
|
129
|
+
while (index >= 0 && context.messages[index]?.role === "user") {
|
|
130
|
+
sawTrailingUser = true;
|
|
131
|
+
index -= 1;
|
|
132
|
+
}
|
|
133
|
+
if (!sawTrailingUser) return false;
|
|
134
|
+
|
|
135
|
+
let sawToolResult = false;
|
|
136
|
+
while (index >= 0 && context.messages[index]?.role === "toolResult") {
|
|
137
|
+
sawToolResult = true;
|
|
138
|
+
index -= 1;
|
|
139
|
+
}
|
|
140
|
+
return sawToolResult;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function matchesCursorLiveRunToolResult(
|
|
144
|
+
run: CursorLiveRunRecord,
|
|
145
|
+
message: ToolResultMessage,
|
|
146
|
+
getReplayId: CursorReplayIdResolver,
|
|
147
|
+
): boolean {
|
|
148
|
+
const replayId = getReplayId(message.toolCallId);
|
|
149
|
+
if (replayId) return replayId === run.id;
|
|
150
|
+
return run.bridgeRun?.hasPendingPiToolCallId(message.toolCallId) ?? false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isSuccessfulCursorLiveRun(run: CursorLiveRun): boolean {
|
|
154
|
+
return run.done && !run.cancelled && !run.errorMessage;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDeps): CursorLiveRunCoordinator {
|
|
158
|
+
const pendingRuns = new Map<string, CursorLiveRun>();
|
|
159
|
+
const pendingRunIdsByScopeKey = new Map<string, string>();
|
|
160
|
+
const privateStates = new WeakMap<CursorLiveRun, CursorLiveRunPrivateState>();
|
|
161
|
+
const getScopeKey = deps.getScopeKey ?? getCursorSessionScopeKey;
|
|
162
|
+
|
|
163
|
+
function getPrivateState(run: CursorLiveRun): CursorLiveRunPrivateState {
|
|
164
|
+
let state = privateStates.get(run);
|
|
165
|
+
if (!state) {
|
|
166
|
+
state = {
|
|
167
|
+
waiters: new Set(),
|
|
168
|
+
idleDisposeRequested: false,
|
|
169
|
+
leased: false,
|
|
170
|
+
leaseQueue: [],
|
|
171
|
+
};
|
|
172
|
+
privateStates.set(run, state);
|
|
173
|
+
}
|
|
174
|
+
return state;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getUndisposed(runId: string | undefined): CursorLiveRun | undefined {
|
|
178
|
+
if (!runId) return undefined;
|
|
179
|
+
const run = pendingRuns.get(runId);
|
|
180
|
+
if (!run || run.disposed) return undefined;
|
|
181
|
+
return run;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function clearIdleDisposeTimer(run: CursorLiveRun): void {
|
|
185
|
+
const state = getPrivateState(run);
|
|
186
|
+
if (!state.idleDisposeTimer) return;
|
|
187
|
+
clearTimeout(state.idleDisposeTimer);
|
|
188
|
+
state.idleDisposeTimer = undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function notifyProgress(run: CursorLiveRun): void {
|
|
192
|
+
const state = getPrivateState(run);
|
|
193
|
+
const waiters = [...state.waiters];
|
|
194
|
+
state.waiters.clear();
|
|
195
|
+
for (const waiter of waiters) {
|
|
196
|
+
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
197
|
+
waiter.resolve();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function removeLeaseWaiter(state: CursorLiveRunPrivateState, waiter: LeaseWaiter): void {
|
|
202
|
+
const index = state.leaseQueue.indexOf(waiter);
|
|
203
|
+
if (index >= 0) state.leaseQueue.splice(index, 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function grantNextLeaseOrUnlock(run: CursorLiveRun): void {
|
|
207
|
+
const state = getPrivateState(run);
|
|
208
|
+
while (state.leaseQueue.length > 0) {
|
|
209
|
+
const next = state.leaseQueue.shift();
|
|
210
|
+
if (!next) continue;
|
|
211
|
+
if (next.onAbort) next.signal?.removeEventListener("abort", next.onAbort);
|
|
212
|
+
next.resolve();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
state.leased = false;
|
|
216
|
+
if (state.idleDisposeRequested && !run.disposed) {
|
|
217
|
+
coordinator.requestIdleDispose(run);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function acquireLease(run: CursorLiveRun, signal?: AbortSignal): Promise<void> {
|
|
222
|
+
if (signal?.aborted) throw new CursorLiveRunAbortError();
|
|
223
|
+
const state = getPrivateState(run);
|
|
224
|
+
if (!state.leased) {
|
|
225
|
+
state.leased = true;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await new Promise<void>((resolve, reject) => {
|
|
230
|
+
const waiter: LeaseWaiter = { resolve, reject, signal };
|
|
231
|
+
const onAbort = (): void => {
|
|
232
|
+
removeLeaseWaiter(state, waiter);
|
|
233
|
+
reject(new CursorLiveRunAbortError());
|
|
234
|
+
};
|
|
235
|
+
waiter.onAbort = onAbort;
|
|
236
|
+
state.leaseQueue.push(waiter);
|
|
237
|
+
if (signal?.aborted) {
|
|
238
|
+
onAbort();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function unregister(run: CursorLiveRun): void {
|
|
246
|
+
pendingRuns.delete(run.id);
|
|
247
|
+
const scopeKey = run.sessionAgentScopeKey;
|
|
248
|
+
if (pendingRunIdsByScopeKey.get(scopeKey) === run.id) {
|
|
249
|
+
pendingRunIdsByScopeKey.delete(scopeKey);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const coordinator: CursorLiveRunCoordinator = {
|
|
254
|
+
start(params): CursorLiveRun {
|
|
255
|
+
const sessionAgentScopeKey = params.sessionAgentScopeKey ?? getScopeKey();
|
|
256
|
+
const run: CursorLiveRun = {
|
|
257
|
+
id: params.id,
|
|
258
|
+
agent: params.agent,
|
|
259
|
+
bridgeRun: params.bridgeRun,
|
|
260
|
+
sessionBridgeRun: params.sessionBridgeRun,
|
|
261
|
+
sessionAgentScopeKey,
|
|
262
|
+
accounting: createCursorLiveRunAccountingState(params.promptInputTokens),
|
|
263
|
+
pendingEvents: [],
|
|
264
|
+
textDeltas: params.textDeltas ?? [],
|
|
265
|
+
emittedText: "",
|
|
266
|
+
recordedToolDisplayIds: [],
|
|
267
|
+
done: false,
|
|
268
|
+
cancelled: false,
|
|
269
|
+
disposed: false,
|
|
270
|
+
chainUserInputAfterCompletion: false,
|
|
271
|
+
};
|
|
272
|
+
privateStates.set(run, {
|
|
273
|
+
waiters: new Set(),
|
|
274
|
+
idleDisposeRequested: false,
|
|
275
|
+
leased: false,
|
|
276
|
+
leaseQueue: [],
|
|
277
|
+
});
|
|
278
|
+
pendingRuns.set(run.id, run);
|
|
279
|
+
pendingRunIdsByScopeKey.set(sessionAgentScopeKey, run.id);
|
|
280
|
+
return run;
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
attachSdkRun(run, sdkRun): void {
|
|
284
|
+
if (run.disposed) return;
|
|
285
|
+
run.sdkRun = sdkRun;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
markFinished(run, finalText): void {
|
|
289
|
+
if (run.disposed) return;
|
|
290
|
+
run.finalText = finalText;
|
|
291
|
+
run.cancelled = false;
|
|
292
|
+
run.done = true;
|
|
293
|
+
notifyProgress(run);
|
|
294
|
+
coordinator.requestIdleDispose(run);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
markCancelled(run): void {
|
|
298
|
+
if (run.disposed) return;
|
|
299
|
+
run.cancelled = true;
|
|
300
|
+
run.done = true;
|
|
301
|
+
notifyProgress(run);
|
|
302
|
+
coordinator.requestIdleDispose(run);
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
markError(run, errorMessage): void {
|
|
306
|
+
if (run.disposed) return;
|
|
307
|
+
run.errorMessage = errorMessage;
|
|
308
|
+
run.done = true;
|
|
309
|
+
notifyProgress(run);
|
|
310
|
+
coordinator.requestIdleDispose(run);
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
queueEvent(run, event): void {
|
|
314
|
+
if (run.disposed) return;
|
|
315
|
+
run.pendingEvents.push(event);
|
|
316
|
+
notifyProgress(run);
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
peekEvent(run): CursorLiveQueuedEvent | undefined {
|
|
320
|
+
return run.pendingEvents[0];
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
shiftEvent(run): CursorLiveQueuedEvent | undefined {
|
|
324
|
+
return run.pendingEvents.shift();
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
collectNativeToolBatch(run): CursorNativeToolDisplayItem[] {
|
|
328
|
+
const tools: CursorNativeToolDisplayItem[] = [];
|
|
329
|
+
while (run.pendingEvents[0]?.type === "tool") {
|
|
330
|
+
const event = run.pendingEvents.shift();
|
|
331
|
+
if (event?.type === "tool") tools.push(event.tool);
|
|
332
|
+
}
|
|
333
|
+
return tools;
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
collectBridgeToolBatch(run): CursorPiBridgeToolRequest[] {
|
|
337
|
+
const requests: CursorPiBridgeToolRequest[] = [];
|
|
338
|
+
while (run.pendingEvents[0]?.type === "bridge-tool") {
|
|
339
|
+
const event = run.pendingEvents.shift();
|
|
340
|
+
if (event?.type === "bridge-tool") requests.push(event.request);
|
|
341
|
+
}
|
|
342
|
+
return requests;
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
consumeToolResults(run, context, getReplayId): CursorLiveToolResultConsumption {
|
|
346
|
+
const consumed = consumeCursorLiveToolResults(run.accounting, context, (toolResult) =>
|
|
347
|
+
matchesCursorLiveRunToolResult(run, toolResult, getReplayId),
|
|
348
|
+
);
|
|
349
|
+
run.accounting = consumed.state;
|
|
350
|
+
return consumed;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
takeTurnInputTokens(run, toolResultInputTokens): number {
|
|
354
|
+
const taken = takeCursorLiveTurnInputTokens(run.accounting, toolResultInputTokens);
|
|
355
|
+
run.accounting = taken.state;
|
|
356
|
+
return taken.sessionInputTokens;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
getPendingFromContext(context, getReplayId): CursorLiveRun | undefined {
|
|
360
|
+
let index = context.messages.length - 1;
|
|
361
|
+
while (index >= 0 && context.messages[index]?.role === "user") {
|
|
362
|
+
index -= 1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (; index >= 0; index -= 1) {
|
|
366
|
+
const message = context.messages[index];
|
|
367
|
+
if (message.role !== "toolResult") break;
|
|
368
|
+
const replayId = getReplayId(message.toolCallId);
|
|
369
|
+
if (replayId) {
|
|
370
|
+
const replayRun = getUndisposed(replayId);
|
|
371
|
+
if (replayRun) return replayRun;
|
|
372
|
+
}
|
|
373
|
+
for (const run of pendingRuns.values()) {
|
|
374
|
+
if (run.disposed) continue;
|
|
375
|
+
if (run.bridgeRun?.hasPendingPiToolCallId(message.toolCallId)) return run;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
getActiveForScope(scopeKey = getScopeKey()): CursorLiveRun | undefined {
|
|
382
|
+
return getUndisposed(pendingRunIdsByScopeKey.get(scopeKey));
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
isReady(run): boolean {
|
|
386
|
+
return run.disposed || run.pendingEvents.length > 0 || run.done || run.cancelled || run.errorMessage !== undefined;
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
async waitForProgress(run, signal): Promise<void> {
|
|
390
|
+
if (signal?.aborted) throw new CursorLiveRunAbortError();
|
|
391
|
+
if (coordinator.isReady(run)) return;
|
|
392
|
+
await new Promise<void>((resolve, reject) => {
|
|
393
|
+
const state = getPrivateState(run);
|
|
394
|
+
const waiter: ProgressWaiter = { resolve, reject, signal };
|
|
395
|
+
const cleanup = (): void => {
|
|
396
|
+
state.waiters.delete(waiter);
|
|
397
|
+
if (waiter.onAbort) signal?.removeEventListener("abort", waiter.onAbort);
|
|
398
|
+
};
|
|
399
|
+
const onAbort = (): void => {
|
|
400
|
+
cleanup();
|
|
401
|
+
reject(new CursorLiveRunAbortError());
|
|
402
|
+
};
|
|
403
|
+
waiter.onAbort = onAbort;
|
|
404
|
+
waiter.resolve = () => {
|
|
405
|
+
cleanup();
|
|
406
|
+
resolve();
|
|
407
|
+
};
|
|
408
|
+
state.waiters.add(waiter);
|
|
409
|
+
if (signal?.aborted) {
|
|
410
|
+
onAbort();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
414
|
+
});
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
async withRunLease(run, signal, body): Promise<Awaited<ReturnType<typeof body>>> {
|
|
418
|
+
await acquireLease(run, signal);
|
|
419
|
+
clearIdleDisposeTimer(run);
|
|
420
|
+
try {
|
|
421
|
+
if (signal?.aborted) throw new CursorLiveRunAbortError();
|
|
422
|
+
return await body();
|
|
423
|
+
} finally {
|
|
424
|
+
grantNextLeaseOrUnlock(run);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
requestIdleDispose(run): void {
|
|
429
|
+
if (run.disposed) return;
|
|
430
|
+
const state = getPrivateState(run);
|
|
431
|
+
clearIdleDisposeTimer(run);
|
|
432
|
+
state.idleDisposeRequested = true;
|
|
433
|
+
if (state.leased || state.leaseQueue.length > 0) return;
|
|
434
|
+
state.idleDisposeRequested = false;
|
|
435
|
+
state.idleDisposeTimer = setTimeout(() => {
|
|
436
|
+
void coordinator.release(run);
|
|
437
|
+
}, deps.getIdleDisposeMs());
|
|
438
|
+
state.idleDisposeTimer.unref?.();
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async release(run): Promise<void> {
|
|
442
|
+
const state = getPrivateState(run);
|
|
443
|
+
if (state.releasing) return state.releasing;
|
|
444
|
+
state.releasing = (async () => {
|
|
445
|
+
if (run.disposed) return;
|
|
446
|
+
const abandoned = !isSuccessfulCursorLiveRun(run);
|
|
447
|
+
run.disposed = true;
|
|
448
|
+
unregister(run);
|
|
449
|
+
clearIdleDisposeTimer(run);
|
|
450
|
+
state.idleDisposeRequested = false;
|
|
451
|
+
notifyProgress(run);
|
|
452
|
+
run.bridgeRun?.cancel("Cursor live run released");
|
|
453
|
+
for (const toolDisplayId of run.recordedToolDisplayIds) deps.deleteNativeToolDisplay(toolDisplayId);
|
|
454
|
+
run.recordedToolDisplayIds = [];
|
|
455
|
+
if (run.sessionBridgeRun) {
|
|
456
|
+
run.sessionBridgeRun.setOnToolRequest(undefined);
|
|
457
|
+
}
|
|
458
|
+
if (run.bridgeRun && run.bridgeRun !== run.sessionBridgeRun) {
|
|
459
|
+
try {
|
|
460
|
+
await run.bridgeRun.dispose();
|
|
461
|
+
} catch {
|
|
462
|
+
// bridge disposal failure should not mask the provider result
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (abandoned) {
|
|
466
|
+
try {
|
|
467
|
+
await run.sdkRun?.cancel();
|
|
468
|
+
} catch {
|
|
469
|
+
// cancellation failure should not block session-agent abandonment
|
|
470
|
+
}
|
|
471
|
+
await deps.abandonSessionAgent(run.sessionAgentScopeKey);
|
|
472
|
+
}
|
|
473
|
+
})();
|
|
474
|
+
return state.releasing;
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
count(): number {
|
|
478
|
+
return pendingRuns.size;
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return coordinator;
|
|
483
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES,
|
|
4
|
+
CURSOR_REPLAY_TOOL_NAMES,
|
|
5
|
+
isNativeCursorToolName,
|
|
6
|
+
NATIVE_CURSOR_TOOL_NAMES,
|
|
7
|
+
registerNativeCursorTool,
|
|
8
|
+
type NativeCursorToolName,
|
|
9
|
+
} from "./cursor-native-tool-display-tools.js";
|
|
10
|
+
import {
|
|
11
|
+
isCursorNativeToolDisplayRequested,
|
|
12
|
+
isCursorNativeToolRegistrationRequested,
|
|
13
|
+
NATIVE_CURSOR_TOOL_DISPLAY_ENV,
|
|
14
|
+
readBooleanEnv,
|
|
15
|
+
registeredNativeToolNames,
|
|
16
|
+
} from "./cursor-native-tool-display-state.js";
|
|
17
|
+
import { isCursorReplayToolName } from "./cursor-tool-names.js";
|
|
18
|
+
|
|
19
|
+
type CursorNativeToolRegistryApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "registerTool" | "setActiveTools">;
|
|
20
|
+
|
|
21
|
+
export interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi {
|
|
22
|
+
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
|
23
|
+
on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasNonBuiltinTool(pi: Pick<ExtensionAPI, "getAllTools">, toolName: NativeCursorToolName): boolean {
|
|
27
|
+
const existingTool = pi.getAllTools().find((tool) => tool.name === toolName);
|
|
28
|
+
return existingTool !== undefined && existingTool.sourceInfo.source !== "builtin";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type NativeRegistrationContext = { hasUI: boolean; ui: Pick<ExtensionContext["ui"], "notify">; model?: ExtensionContext["model"] };
|
|
32
|
+
|
|
33
|
+
function isCursorModel(model: ExtensionContext["model"]): boolean {
|
|
34
|
+
return model?.provider === "cursor" || model?.api === "cursor-sdk";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
|
|
38
|
+
if (registeredNativeToolNames.size === 0) return;
|
|
39
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
40
|
+
let changed = false;
|
|
41
|
+
if (isCursorModel(model)) {
|
|
42
|
+
for (const toolName of registeredNativeToolNames) {
|
|
43
|
+
if (isCursorReplayToolName(toolName) && !CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES.some((activeReplayToolName) => activeReplayToolName === toolName)) continue;
|
|
44
|
+
if (activeToolNames.has(toolName)) continue;
|
|
45
|
+
activeToolNames.add(toolName);
|
|
46
|
+
changed = true;
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
for (const toolName of CURSOR_REPLAY_TOOL_NAMES) {
|
|
50
|
+
if (!activeToolNames.delete(toolName)) continue;
|
|
51
|
+
changed = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (changed) pi.setActiveTools([...activeToolNames]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function registerAvailableNativeCursorTools(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
|
|
58
|
+
if (!isCursorNativeToolRegistrationRequested()) {
|
|
59
|
+
registeredNativeToolNames.clear();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const skippedToolNames: string[] = [];
|
|
64
|
+
for (const toolName of NATIVE_CURSOR_TOOL_NAMES) {
|
|
65
|
+
if (registeredNativeToolNames.has(toolName)) continue;
|
|
66
|
+
if (hasNonBuiltinTool(pi, toolName)) {
|
|
67
|
+
skippedToolNames.push(toolName);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
registerNativeCursorTool(pi, toolName);
|
|
71
|
+
registeredNativeToolNames.add(toolName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
|
|
75
|
+
|
|
76
|
+
if (skippedToolNames.length > 0 && readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) === true && ctx.hasUI) {
|
|
77
|
+
ctx.ui.notify(
|
|
78
|
+
`Cursor native tool replay skipped for ${skippedToolNames.join(", ")} because another extension already provides ${skippedToolNames.length === 1 ? "that tool" : "those tools"}. Cursor will use scrubbed activity transcripts for skipped tools.`,
|
|
79
|
+
"warning",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExtensionApi): void {
|
|
85
|
+
pi.on("session_start", (_event, ctx) => {
|
|
86
|
+
registerAvailableNativeCursorTools(pi, ctx);
|
|
87
|
+
});
|
|
88
|
+
pi.on("model_select", (event) => {
|
|
89
|
+
syncRegisteredNativeCursorToolsForModel(pi, event.model);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { isNativeCursorToolName, isCursorNativeToolDisplayRequested };
|