pi-cursor-sdk 0.1.14 → 0.1.16

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.
@@ -0,0 +1,252 @@
1
+ import type { ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge.js";
5
+
6
+ export const CURSOR_ASK_QUESTION_TOOL_NAME = "cursor_ask_question";
7
+
8
+ interface CursorQuestionOption {
9
+ label: string;
10
+ value: string;
11
+ description?: string;
12
+ }
13
+
14
+ interface CursorQuestion {
15
+ id: string;
16
+ question: string;
17
+ options: CursorQuestionOption[];
18
+ allowCustom: boolean;
19
+ }
20
+
21
+ interface CursorQuestionAnswer {
22
+ id: string;
23
+ question: string;
24
+ answer: string | null;
25
+ value?: string;
26
+ wasCustom: boolean;
27
+ cancelled: boolean;
28
+ }
29
+
30
+ interface CursorQuestionDetails {
31
+ questions: CursorQuestion[];
32
+ answers: CursorQuestionAnswer[];
33
+ uiAvailable: boolean;
34
+ cancelled: boolean;
35
+ }
36
+
37
+ interface CursorQuestionToolExtensionApi extends Pick<ExtensionAPI, "getActiveTools" | "registerTool" | "setActiveTools"> {
38
+ on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
39
+ on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
40
+ }
41
+
42
+ type RawQuestionOption = string | { label?: string; value?: string; description?: string };
43
+
44
+ type RawQuestion = {
45
+ id?: string;
46
+ question?: string;
47
+ prompt?: string;
48
+ options?: RawQuestionOption[];
49
+ choices?: RawQuestionOption[];
50
+ allowCustom?: boolean;
51
+ };
52
+
53
+ type CursorAskQuestionParams = RawQuestion & {
54
+ questions?: RawQuestion[];
55
+ };
56
+
57
+ const QuestionOptionSchema = Type.Union([
58
+ Type.String(),
59
+ Type.Object({
60
+ label: Type.String({ description: "User-facing option label" }),
61
+ value: Type.Optional(Type.String({ description: "Optional value returned to Cursor; defaults to label" })),
62
+ description: Type.Optional(Type.String({ description: "Optional helper text shown by compatible pi UIs" })),
63
+ }),
64
+ ]);
65
+
66
+ const QuestionSchema = Type.Object({
67
+ id: Type.Optional(Type.String({ description: "Stable question identifier" })),
68
+ question: Type.Optional(Type.String({ description: "Question to ask the user" })),
69
+ prompt: Type.Optional(Type.String({ description: "Alias for question" })),
70
+ options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Choices the user can select" })),
71
+ choices: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Alias for options" })),
72
+ allowCustom: Type.Optional(Type.Boolean({ description: "Allow a typed answer in addition to listed options; defaults to true" })),
73
+ });
74
+
75
+ const CursorAskQuestionParamsSchema = Type.Object({
76
+ question: Type.Optional(Type.String({ description: "Question to ask the user" })),
77
+ prompt: Type.Optional(Type.String({ description: "Alias for question" })),
78
+ options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Choices the user can select" })),
79
+ choices: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Alias for options" })),
80
+ allowCustom: Type.Optional(Type.Boolean({ description: "Allow a typed answer in addition to listed options; defaults to true" })),
81
+ questions: Type.Optional(Type.Array(QuestionSchema, { description: "Ask multiple questions sequentially" })),
82
+ });
83
+
84
+ function isCursorModel(model: ExtensionContext["model"]): boolean {
85
+ return model?.provider === "cursor" || model?.api === "cursor-sdk";
86
+ }
87
+
88
+ function normalizeOption(option: RawQuestionOption, index: number): CursorQuestionOption | undefined {
89
+ if (typeof option === "string") {
90
+ const trimmed = option.trim();
91
+ return trimmed ? { label: trimmed, value: trimmed } : undefined;
92
+ }
93
+ const label = option.label?.trim() || option.value?.trim() || `Option ${index + 1}`;
94
+ return {
95
+ label,
96
+ value: option.value?.trim() || label,
97
+ ...(option.description?.trim() ? { description: option.description.trim() } : {}),
98
+ };
99
+ }
100
+
101
+ function normalizeOptions(options: RawQuestionOption[] | undefined): CursorQuestionOption[] {
102
+ return (options ?? []).map(normalizeOption).filter((option): option is CursorQuestionOption => option !== undefined);
103
+ }
104
+
105
+ function normalizeQuestion(raw: RawQuestion, index: number): CursorQuestion | undefined {
106
+ const question = raw.question?.trim() || raw.prompt?.trim();
107
+ if (!question) return undefined;
108
+ return {
109
+ id: raw.id?.trim() || `question_${index + 1}`,
110
+ question,
111
+ options: normalizeOptions(raw.options ?? raw.choices),
112
+ allowCustom: raw.allowCustom !== false,
113
+ };
114
+ }
115
+
116
+ function normalizeQuestions(params: CursorAskQuestionParams): CursorQuestion[] {
117
+ const rawQuestions = Array.isArray(params.questions) && params.questions.length > 0 ? params.questions : [params];
118
+ return rawQuestions.map(normalizeQuestion).filter((question): question is CursorQuestion => question !== undefined);
119
+ }
120
+
121
+ function summarizeAnswers(answers: CursorQuestionAnswer[]): string {
122
+ if (answers.length === 0) return "No answer was collected.";
123
+ if (answers.length === 1) {
124
+ const [answer] = answers;
125
+ return answer.cancelled || answer.answer === null ? "User cancelled the question." : `User answered: ${answer.answer}`;
126
+ }
127
+ return [
128
+ "User answered:",
129
+ ...answers.map((answer) => {
130
+ const value = answer.cancelled || answer.answer === null ? "cancelled" : answer.answer;
131
+ return `- ${answer.id}: ${value}`;
132
+ }),
133
+ ].join("\n");
134
+ }
135
+
136
+ function buildDetails(questions: CursorQuestion[], answers: CursorQuestionAnswer[], uiAvailable: boolean): CursorQuestionDetails {
137
+ return {
138
+ questions,
139
+ answers,
140
+ uiAvailable,
141
+ cancelled: answers.some((answer) => answer.cancelled),
142
+ };
143
+ }
144
+
145
+ async function askOneQuestion(question: CursorQuestion, ctx: { ui: ExtensionContext["ui"] }): Promise<CursorQuestionAnswer> {
146
+ if (question.options.length > 0) {
147
+ const labels = question.options.map((option) => option.description ? `${option.label} — ${option.description}` : option.label);
148
+ const customLabel = "Type a custom answer";
149
+ const choices = question.allowCustom ? [...labels, customLabel] : labels;
150
+ const selected = await ctx.ui.select(question.question, choices);
151
+ if (!selected) {
152
+ return { id: question.id, question: question.question, answer: null, wasCustom: false, cancelled: true };
153
+ }
154
+ if (selected === customLabel) {
155
+ const customAnswer = await ctx.ui.input(question.question, "Type your answer");
156
+ const trimmed = customAnswer?.trim();
157
+ return trimmed
158
+ ? { id: question.id, question: question.question, answer: trimmed, value: trimmed, wasCustom: true, cancelled: false }
159
+ : { id: question.id, question: question.question, answer: null, wasCustom: true, cancelled: true };
160
+ }
161
+ const selectedIndex = labels.indexOf(selected);
162
+ const selectedOption = selectedIndex >= 0 ? question.options[selectedIndex] : undefined;
163
+ const answer = selectedOption?.label ?? selected;
164
+ return {
165
+ id: question.id,
166
+ question: question.question,
167
+ answer,
168
+ value: selectedOption?.value ?? answer,
169
+ wasCustom: false,
170
+ cancelled: false,
171
+ };
172
+ }
173
+
174
+ const answer = await ctx.ui.input(question.question, "Type your answer");
175
+ const trimmed = answer?.trim();
176
+ return trimmed
177
+ ? { id: question.id, question: question.question, answer: trimmed, value: trimmed, wasCustom: true, cancelled: false }
178
+ : { id: question.id, question: question.question, answer: null, wasCustom: true, cancelled: true };
179
+ }
180
+
181
+ function syncCursorQuestionToolForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
182
+ const activeToolNames = new Set(pi.getActiveTools());
183
+ const shouldBeActive = isCursorModel(model) && resolveCursorPiToolBridgeEnabled();
184
+ const alreadyActive = activeToolNames.has(CURSOR_ASK_QUESTION_TOOL_NAME);
185
+ if (shouldBeActive === alreadyActive) return;
186
+ if (shouldBeActive) {
187
+ activeToolNames.add(CURSOR_ASK_QUESTION_TOOL_NAME);
188
+ } else {
189
+ activeToolNames.delete(CURSOR_ASK_QUESTION_TOOL_NAME);
190
+ }
191
+ pi.setActiveTools([...activeToolNames]);
192
+ }
193
+
194
+ export function registerCursorQuestionTool(pi: CursorQuestionToolExtensionApi): void {
195
+ pi.registerTool({
196
+ name: CURSOR_ASK_QUESTION_TOOL_NAME,
197
+ label: "Cursor question",
198
+ description:
199
+ "Ask the user a clarifying question from Cursor. Use when user preferences materially affect the next step; provide options when possible.",
200
+ parameters: CursorAskQuestionParamsSchema,
201
+ promptGuidelines: [
202
+ "Use cursor_ask_question only when running a Cursor model and user input would materially change the plan, scope, platform, or implementation path.",
203
+ "Prefer cursor_ask_question with 2-4 concrete options instead of guessing when Cursor plan mode needs user choices.",
204
+ ],
205
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
206
+ const questions = normalizeQuestions(params as CursorAskQuestionParams);
207
+ if (questions.length === 0) {
208
+ return {
209
+ content: [{ type: "text" as const, text: "No valid question was provided." }],
210
+ details: buildDetails([], [], ctx.hasUI),
211
+ isError: true,
212
+ };
213
+ }
214
+ if (!ctx.hasUI) {
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text" as const,
219
+ text: "Cannot ask the user because pi UI is unavailable. Make a reasonable default choice and state the assumption before proceeding.",
220
+ },
221
+ ],
222
+ details: buildDetails(questions, [], false),
223
+ isError: true,
224
+ };
225
+ }
226
+
227
+ const answers: CursorQuestionAnswer[] = [];
228
+ for (const question of questions) {
229
+ const answer = await askOneQuestion(question, ctx);
230
+ answers.push(answer);
231
+ if (answer.cancelled) break;
232
+ }
233
+
234
+ return {
235
+ content: [{ type: "text" as const, text: summarizeAnswers(answers) }],
236
+ details: buildDetails(questions, answers, true),
237
+ };
238
+ },
239
+ renderCall(args, theme) {
240
+ const questions = normalizeQuestions(args as CursorAskQuestionParams);
241
+ const label = questions[0]?.question ?? "Ask the user";
242
+ return new Text(theme.fg("toolTitle", theme.bold("cursor question ")) + theme.fg("muted", label), 0, 0);
243
+ },
244
+ });
245
+
246
+ pi.on("session_start", (_event, ctx) => {
247
+ syncCursorQuestionToolForModel(pi, ctx.model);
248
+ });
249
+ pi.on("model_select", (event) => {
250
+ syncCursorQuestionToolForModel(pi, event.model);
251
+ });
252
+ }
@@ -0,0 +1,372 @@
1
+ import type {
2
+ ExtensionHandler,
3
+ SessionBeforeTreeEvent,
4
+ SessionCompactEvent,
5
+ SessionShutdownEvent,
6
+ SessionTreeEvent,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { createHash } from "node:crypto";
9
+ import { Agent } from "@cursor/sdk";
10
+ import type { ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk";
11
+ import type { Context } from "@earendil-works/pi-ai";
12
+ import {
13
+ getRegisteredCursorPiToolBridge,
14
+ type CursorPiBridgeToolRequest,
15
+ type CursorPiToolBridgeRun,
16
+ } from "./cursor-pi-tool-bridge.js";
17
+ import { computeCursorContextFingerprint } from "./context.js";
18
+ import { getCursorSessionScopeKey, onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
19
+
20
+ export interface SessionCursorAgentSendState {
21
+ bootstrapped: boolean;
22
+ contextFingerprint: string;
23
+ }
24
+
25
+ export interface SessionCursorAgentLease {
26
+ scopeKey: string;
27
+ agent: SDKAgent;
28
+ bridgeRun?: CursorPiToolBridgeRun;
29
+ sendState: SessionCursorAgentSendState;
30
+ created: boolean;
31
+ }
32
+
33
+ interface SessionCursorAgentPoolEntry {
34
+ poolKey: string;
35
+ scopeKey: string;
36
+ agent?: SDKAgent;
37
+ bridgeRun?: CursorPiToolBridgeRun;
38
+ sendState: SessionCursorAgentSendState;
39
+ creating?: Promise<SessionCursorAgentPoolEntry>;
40
+ creationGeneration?: number;
41
+ }
42
+
43
+ class SessionCursorAgentCreationSupersededError extends Error {
44
+ constructor() {
45
+ super("Cursor session agent creation was superseded");
46
+ this.name = "SessionCursorAgentCreationSupersededError";
47
+ }
48
+ }
49
+
50
+ export class SessionCursorAgentScopeClosedError extends Error {
51
+ constructor() {
52
+ super("Cursor session agent scope is closed");
53
+ this.name = "SessionCursorAgentScopeClosedError";
54
+ }
55
+ }
56
+
57
+ function assertScopeAcceptsAcquire(scopeKey: string): void {
58
+ if (terminalDisposedScopeKeys.has(scopeKey)) {
59
+ throw new SessionCursorAgentScopeClosedError();
60
+ }
61
+ }
62
+
63
+ function rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey: string, poolKey: string, error: unknown): void {
64
+ if (!(error instanceof SessionCursorAgentCreationSupersededError)) return;
65
+ const replacement = sessionAgentsByScope.get(scopeKey);
66
+ if (replacement && replacement.poolKey !== poolKey) {
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ interface SessionCursorAgentCreateParams {
72
+ apiKey: string;
73
+ cwd: string;
74
+ modelSelection: ModelSelection;
75
+ settingSources?: SettingSource[];
76
+ onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void;
77
+ createAgent?: typeof Agent.create;
78
+ }
79
+
80
+ interface CursorSessionAgentExtensionApi {
81
+ on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
82
+ on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
83
+ on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent>): void;
84
+ on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
85
+ on(event: "model_select", handler: ExtensionHandler<{ model: unknown }>): void;
86
+ }
87
+
88
+ const sessionAgentsByScope = new Map<string, SessionCursorAgentPoolEntry>();
89
+ const invalidatedScopeKeys = new Set<string>();
90
+ const terminalDisposedScopeKeys = new Set<string>();
91
+ const scopeCreationGenerations = new Map<string, number>();
92
+
93
+ function getScopeCreationGeneration(scopeKey: string): number {
94
+ return scopeCreationGenerations.get(scopeKey) ?? 0;
95
+ }
96
+
97
+ function invalidateScopeCreations(scopeKey: string): void {
98
+ scopeCreationGenerations.set(scopeKey, getScopeCreationGeneration(scopeKey) + 1);
99
+ }
100
+
101
+ function buildModelPoolKey(modelSelection: ModelSelection): string {
102
+ return JSON.stringify(modelSelection);
103
+ }
104
+
105
+ function buildSettingSourcesPoolKey(settingSources?: SettingSource[]): string {
106
+ return settingSources?.join(",") ?? "";
107
+ }
108
+
109
+ function buildApiKeyPoolKeyFingerprint(apiKey: string): string {
110
+ return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
111
+ }
112
+
113
+ function buildBridgePoolKeySuffix(): string {
114
+ const registeredBridge = getRegisteredCursorPiToolBridge();
115
+ if (!registeredBridge) return "bridge:absent";
116
+ return registeredBridge.getToolSurfaceSignature();
117
+ }
118
+
119
+ function buildSessionAgentPoolKey(scopeKey: string, params: SessionCursorAgentCreateParams): string {
120
+ return [
121
+ scopeKey,
122
+ params.cwd,
123
+ buildModelPoolKey(params.modelSelection),
124
+ buildSettingSourcesPoolKey(params.settingSources),
125
+ buildApiKeyPoolKeyFingerprint(params.apiKey),
126
+ buildBridgePoolKeySuffix(),
127
+ ].join("\0");
128
+ }
129
+
130
+ async function disposePoolEntry(entry: SessionCursorAgentPoolEntry): Promise<void> {
131
+ entry.bridgeRun?.cancel("Cursor session agent disposed");
132
+ try {
133
+ await entry.bridgeRun?.dispose();
134
+ } catch {
135
+ // disposal failure should not block session replacement
136
+ }
137
+ if (!entry.agent) return;
138
+ try {
139
+ await entry.agent[Symbol.asyncDispose]();
140
+ } catch {
141
+ // disposal failure should not block session replacement
142
+ }
143
+ }
144
+
145
+ async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?: boolean }): Promise<void> {
146
+ invalidateScopeCreations(scopeKey);
147
+ if (options?.terminal) {
148
+ terminalDisposedScopeKeys.add(scopeKey);
149
+ }
150
+ const entry = sessionAgentsByScope.get(scopeKey);
151
+ invalidatedScopeKeys.delete(scopeKey);
152
+ if (!entry) return;
153
+ sessionAgentsByScope.delete(scopeKey);
154
+ if (entry.creating || !entry.agent) return;
155
+ await disposePoolEntry(entry);
156
+ }
157
+
158
+ function createInitialSendState(): SessionCursorAgentSendState {
159
+ return { bootstrapped: false, contextFingerprint: "" };
160
+ }
161
+
162
+ function bindBridgeToolRequest(
163
+ entry: SessionCursorAgentPoolEntry,
164
+ onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void,
165
+ ): void {
166
+ entry.bridgeRun?.setOnToolRequest(onBridgeToolRequest);
167
+ }
168
+
169
+ function leaseFromEntry(
170
+ entry: SessionCursorAgentPoolEntry,
171
+ scopeKey: string,
172
+ params: SessionCursorAgentCreateParams,
173
+ created: boolean,
174
+ ): SessionCursorAgentLease {
175
+ bindBridgeToolRequest(entry, params.onBridgeToolRequest);
176
+ return {
177
+ scopeKey,
178
+ agent: entry.agent!,
179
+ bridgeRun: entry.bridgeRun,
180
+ sendState: entry.sendState,
181
+ created,
182
+ };
183
+ }
184
+
185
+ async function createSessionAgentEntry(
186
+ scopeKey: string,
187
+ poolKey: string,
188
+ params: SessionCursorAgentCreateParams,
189
+ ): Promise<SessionCursorAgentPoolEntry> {
190
+ const registeredBridge = getRegisteredCursorPiToolBridge();
191
+ let bridgeRun: CursorPiToolBridgeRun | undefined;
192
+ if (registeredBridge) {
193
+ bridgeRun = await registeredBridge.createRun({
194
+ onToolRequest: params.onBridgeToolRequest,
195
+ });
196
+ if (!bridgeRun.enabled || !bridgeRun.mcpServers) {
197
+ await bridgeRun.dispose();
198
+ bridgeRun = undefined;
199
+ }
200
+ }
201
+
202
+ const resolvedPoolKey = buildSessionAgentPoolKey(scopeKey, params);
203
+ const createAgent = params.createAgent ?? Agent.create;
204
+ let agent: SDKAgent;
205
+ try {
206
+ agent = await createAgent({
207
+ apiKey: params.apiKey,
208
+ model: params.modelSelection,
209
+ local: params.settingSources ? { cwd: params.cwd, settingSources: params.settingSources } : { cwd: params.cwd },
210
+ ...(bridgeRun?.mcpServers ? { mcpServers: bridgeRun.mcpServers } : {}),
211
+ });
212
+ } catch (error) {
213
+ if (bridgeRun) {
214
+ bridgeRun.cancel("Cursor session agent create failed");
215
+ try {
216
+ await bridgeRun.dispose();
217
+ } catch {
218
+ // bridge disposal failure should not mask agent create failure
219
+ }
220
+ }
221
+ throw error;
222
+ }
223
+
224
+ return {
225
+ poolKey: resolvedPoolKey,
226
+ scopeKey,
227
+ agent,
228
+ bridgeRun,
229
+ sendState: createInitialSendState(),
230
+ };
231
+ }
232
+
233
+ export { shouldBootstrapCursorSend } from "./context.js";
234
+
235
+ export function commitSessionAgentSend(scopeKey: string, context: Context, bootstrapped: boolean): void {
236
+ const entry = sessionAgentsByScope.get(scopeKey);
237
+ if (!entry) return;
238
+ entry.sendState.bootstrapped = bootstrapped || entry.sendState.bootstrapped;
239
+ entry.sendState.contextFingerprint = computeCursorContextFingerprint(context);
240
+ }
241
+
242
+ export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeKey()): void {
243
+ invalidatedScopeKeys.add(scopeKey);
244
+ }
245
+
246
+ export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise<SessionCursorAgentLease> {
247
+ const scopeKey = getCursorSessionScopeKey();
248
+ assertScopeAcceptsAcquire(scopeKey);
249
+ if (invalidatedScopeKeys.has(scopeKey)) {
250
+ await disposePoolEntryForScope(scopeKey);
251
+ }
252
+
253
+ const poolKey = buildSessionAgentPoolKey(scopeKey, params);
254
+ const existing = sessionAgentsByScope.get(scopeKey);
255
+ if (existing?.poolKey === poolKey && !existing.creating) {
256
+ return leaseFromEntry(existing, scopeKey, params, false);
257
+ }
258
+
259
+ if (existing && existing.poolKey !== poolKey) {
260
+ await disposePoolEntryForScope(scopeKey);
261
+ }
262
+
263
+ let entry = sessionAgentsByScope.get(scopeKey);
264
+ if (entry?.creating) {
265
+ try {
266
+ await entry.creating;
267
+ } catch (error) {
268
+ if (error instanceof SessionCursorAgentCreationSupersededError) {
269
+ assertScopeAcceptsAcquire(scopeKey);
270
+ rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey, poolKey, error);
271
+ } else {
272
+ throw error;
273
+ }
274
+ }
275
+ entry = sessionAgentsByScope.get(scopeKey);
276
+ if (entry && entry.poolKey === poolKey && entry.agent && !entry.creating) {
277
+ return leaseFromEntry(entry, scopeKey, params, false);
278
+ }
279
+ }
280
+
281
+ assertScopeAcceptsAcquire(scopeKey);
282
+ const creationGeneration = getScopeCreationGeneration(scopeKey);
283
+ const placeholder: SessionCursorAgentPoolEntry = {
284
+ poolKey,
285
+ scopeKey,
286
+ sendState: createInitialSendState(),
287
+ creationGeneration,
288
+ };
289
+ const creating = createSessionAgentEntry(scopeKey, poolKey, params).then(async (createdEntry) => {
290
+ const stillCurrent =
291
+ sessionAgentsByScope.get(scopeKey) === placeholder &&
292
+ getScopeCreationGeneration(scopeKey) === placeholder.creationGeneration;
293
+ if (!stillCurrent) {
294
+ await disposePoolEntry(createdEntry);
295
+ if (sessionAgentsByScope.get(scopeKey) === placeholder) {
296
+ sessionAgentsByScope.delete(scopeKey);
297
+ }
298
+ throw new SessionCursorAgentCreationSupersededError();
299
+ }
300
+ sessionAgentsByScope.set(scopeKey, createdEntry);
301
+ return createdEntry;
302
+ });
303
+ placeholder.creating = creating;
304
+ sessionAgentsByScope.set(scopeKey, placeholder);
305
+
306
+ try {
307
+ const createdEntry = await creating;
308
+ return leaseFromEntry(createdEntry, scopeKey, params, true);
309
+ } catch (error) {
310
+ if (sessionAgentsByScope.get(scopeKey) === placeholder) {
311
+ sessionAgentsByScope.delete(scopeKey);
312
+ }
313
+ if (error instanceof SessionCursorAgentCreationSupersededError) {
314
+ assertScopeAcceptsAcquire(scopeKey);
315
+ rethrowSupersededWhenReplacedByDifferentPoolKey(scopeKey, poolKey, error);
316
+ return acquireSessionCursorAgent(params);
317
+ }
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ export async function resetSessionCursorAgent(scopeKey: string = getCursorSessionScopeKey()): Promise<void> {
323
+ await disposePoolEntryForScope(scopeKey);
324
+ }
325
+
326
+ export async function disposeSessionCursorAgent(scopeKey: string = getCursorSessionScopeKey()): Promise<void> {
327
+ await disposePoolEntryForScope(scopeKey, { terminal: true });
328
+ }
329
+
330
+ export async function disposeAllSessionCursorAgents(): Promise<void> {
331
+ const scopeKeys = [...new Set([...sessionAgentsByScope.keys(), ...terminalDisposedScopeKeys])];
332
+ await Promise.all(scopeKeys.map((scopeKey) => disposePoolEntryForScope(scopeKey, { terminal: true })));
333
+ invalidatedScopeKeys.clear();
334
+ terminalDisposedScopeKeys.clear();
335
+ }
336
+
337
+ export function registerCursorSessionAgent(_pi: CursorSessionAgentExtensionApi): void {
338
+ onCursorSessionScopeKeyChange((previousScopeKey) => {
339
+ void disposePoolEntryForScope(previousScopeKey, { terminal: true });
340
+ });
341
+ _pi.on("session_shutdown", async (event) => {
342
+ if (event.reason === "reload") {
343
+ await resetSessionCursorAgent();
344
+ return;
345
+ }
346
+ await disposeSessionCursorAgent();
347
+ });
348
+ _pi.on("session_compact", () => {
349
+ invalidateSessionAgent();
350
+ });
351
+ _pi.on("session_before_tree", () => {
352
+ invalidateSessionAgent();
353
+ });
354
+ _pi.on("session_tree", async () => {
355
+ await resetSessionCursorAgent();
356
+ });
357
+ _pi.on("model_select", () => {
358
+ invalidateSessionAgent();
359
+ });
360
+ }
361
+
362
+ export const __testUtils = {
363
+ sessionAgentsByScope,
364
+ invalidateSessionAgent,
365
+ disposeSessionCursorAgent,
366
+ resetSessionCursorAgent,
367
+ disposeAllSessionCursorAgents,
368
+ buildApiKeyPoolKeyFingerprint,
369
+ buildSessionAgentPoolKey,
370
+ SessionCursorAgentCreationSupersededError,
371
+ SessionCursorAgentScopeClosedError,
372
+ };
@@ -0,0 +1,28 @@
1
+ import {
2
+ getCursorSessionCwdFromScope,
3
+ registerCursorSessionScope,
4
+ __testUtils as cursorSessionScopeTestUtils,
5
+ } from "./cursor-session-scope.js";
6
+ import type { ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
7
+
8
+ interface CursorSessionCwdExtensionApi {
9
+ on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
10
+ }
11
+
12
+ /**
13
+ * Pi session cwd when known; falls back to process.cwd() before session_start.
14
+ * Updated on session_start only until pi threads cwd into streamSimple—mid-session cwd
15
+ * changes without a new session_start event are not reflected here.
16
+ */
17
+ export function getCursorSessionCwd(): string {
18
+ return getCursorSessionCwdFromScope();
19
+ }
20
+
21
+ export function registerCursorSessionCwd(pi: CursorSessionCwdExtensionApi): void {
22
+ registerCursorSessionScope(pi);
23
+ }
24
+
25
+ export const __testUtils = {
26
+ set: cursorSessionScopeTestUtils.set,
27
+ reset: cursorSessionScopeTestUtils.reset,
28
+ };