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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge.js";
|
|
@@ -34,6 +34,11 @@ interface CursorQuestionDetails {
|
|
|
34
34
|
cancelled: boolean;
|
|
35
35
|
}
|
|
36
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
|
+
|
|
37
42
|
type RawQuestionOption = string | { label?: string; value?: string; description?: string };
|
|
38
43
|
|
|
39
44
|
type RawQuestion = {
|
|
@@ -186,7 +191,7 @@ function syncCursorQuestionToolForModel(pi: Pick<ExtensionAPI, "getActiveTools"
|
|
|
186
191
|
pi.setActiveTools([...activeToolNames]);
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
export function registerCursorQuestionTool(pi:
|
|
194
|
+
export function registerCursorQuestionTool(pi: CursorQuestionToolExtensionApi): void {
|
|
190
195
|
pi.registerTool({
|
|
191
196
|
name: CURSOR_ASK_QUESTION_TOOL_NAME,
|
|
192
197
|
label: "Cursor question",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
2
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function getField(value: unknown, field: string): unknown {
|
|
6
|
+
return asRecord(value)?.[field];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasUsableText(value: string | undefined): value is string {
|
|
10
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getFirstStringByKeys(
|
|
14
|
+
record: Record<string, unknown> | undefined,
|
|
15
|
+
keys: readonly string[],
|
|
16
|
+
options?: { nonEmpty?: boolean },
|
|
17
|
+
): string | undefined {
|
|
18
|
+
if (!record) return undefined;
|
|
19
|
+
for (const key of keys) {
|
|
20
|
+
const value = record[key];
|
|
21
|
+
if (typeof value !== "string") continue;
|
|
22
|
+
if (options?.nonEmpty && !value) continue;
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
const cursorSdkOutputSuppression = new AsyncLocalStorage<boolean>();
|
|
4
|
+
|
|
5
|
+
export const CURSOR_SDK_STARTUP_NOISE_PATTERNS = [
|
|
6
|
+
"[hooks]",
|
|
7
|
+
"managed_skills.",
|
|
8
|
+
"CursorPluginsAgentSkillsService load completed",
|
|
9
|
+
"LocalCursorRulesService load completed",
|
|
10
|
+
"AgentSkillsCursorRulesService load completed",
|
|
11
|
+
"Error initializing ignore mapping for",
|
|
12
|
+
"Ripgrep path not configured. Call configureRipgrepPath() at startup.",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export function isCursorSdkOutputSuppressed(): boolean {
|
|
16
|
+
return cursorSdkOutputSuppression.getStore() === true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function suppressCursorSdkOutput<T>(operation: () => Promise<T>): Promise<T> {
|
|
20
|
+
return cursorSdkOutputSuppression.run(true, operation);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isCursorSdkStartupNoise(text: string): boolean {
|
|
24
|
+
return CURSOR_SDK_STARTUP_NOISE_PATTERNS.some((pattern) => text.includes(pattern));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createFilteredProcessWrite<TWrite extends typeof process.stdout.write>(write: TWrite, stream: NodeJS.WriteStream): TWrite {
|
|
28
|
+
return ((
|
|
29
|
+
chunk: string | Uint8Array,
|
|
30
|
+
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
|
31
|
+
callback?: (error?: Error | null) => void,
|
|
32
|
+
): boolean => {
|
|
33
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
34
|
+
if (isCursorSdkOutputSuppressed() || isCursorSdkStartupNoise(text)) {
|
|
35
|
+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
36
|
+
done?.();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return write.call(stream, chunk as string, encodingOrCallback as BufferEncoding, callback);
|
|
40
|
+
}) as TWrite;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createFilteredConsoleMethod<TMethod extends typeof console.log>(method: TMethod): TMethod {
|
|
44
|
+
return ((...args: Parameters<TMethod>): void => {
|
|
45
|
+
const text = args.map((arg) => (typeof arg === "string" ? arg : String(arg))).join(" ");
|
|
46
|
+
if (isCursorSdkOutputSuppressed() || isCursorSdkStartupNoise(text)) return;
|
|
47
|
+
method(...args);
|
|
48
|
+
}) as TMethod;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface CursorSdkOutputFilterOriginals {
|
|
52
|
+
stdoutWrite: typeof process.stdout.write;
|
|
53
|
+
stderrWrite: typeof process.stderr.write;
|
|
54
|
+
consoleLog: typeof console.log;
|
|
55
|
+
consoleInfo: typeof console.info;
|
|
56
|
+
consoleWarn: typeof console.warn;
|
|
57
|
+
consoleError: typeof console.error;
|
|
58
|
+
consoleDebug: typeof console.debug;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let activeOutputFilterInstalls = 0;
|
|
62
|
+
let outputFilterOriginals: CursorSdkOutputFilterOriginals | undefined;
|
|
63
|
+
|
|
64
|
+
export function installCursorSdkOutputFilter(): () => void {
|
|
65
|
+
if (activeOutputFilterInstalls === 0) {
|
|
66
|
+
outputFilterOriginals = {
|
|
67
|
+
stdoutWrite: process.stdout.write,
|
|
68
|
+
stderrWrite: process.stderr.write,
|
|
69
|
+
consoleLog: console.log,
|
|
70
|
+
consoleInfo: console.info,
|
|
71
|
+
consoleWarn: console.warn,
|
|
72
|
+
consoleError: console.error,
|
|
73
|
+
consoleDebug: console.debug,
|
|
74
|
+
};
|
|
75
|
+
process.stdout.write = createFilteredProcessWrite(outputFilterOriginals.stdoutWrite, process.stdout);
|
|
76
|
+
process.stderr.write = createFilteredProcessWrite(outputFilterOriginals.stderrWrite, process.stderr) as typeof process.stderr.write;
|
|
77
|
+
console.log = createFilteredConsoleMethod(outputFilterOriginals.consoleLog);
|
|
78
|
+
console.info = createFilteredConsoleMethod(outputFilterOriginals.consoleInfo);
|
|
79
|
+
console.warn = createFilteredConsoleMethod(outputFilterOriginals.consoleWarn);
|
|
80
|
+
console.error = createFilteredConsoleMethod(outputFilterOriginals.consoleError);
|
|
81
|
+
console.debug = createFilteredConsoleMethod(outputFilterOriginals.consoleDebug);
|
|
82
|
+
}
|
|
83
|
+
activeOutputFilterInstalls += 1;
|
|
84
|
+
|
|
85
|
+
let restored = false;
|
|
86
|
+
return () => {
|
|
87
|
+
if (restored) return;
|
|
88
|
+
restored = true;
|
|
89
|
+
activeOutputFilterInstalls = Math.max(activeOutputFilterInstalls - 1, 0);
|
|
90
|
+
if (activeOutputFilterInstalls > 0 || !outputFilterOriginals) return;
|
|
91
|
+
process.stdout.write = outputFilterOriginals.stdoutWrite;
|
|
92
|
+
process.stderr.write = outputFilterOriginals.stderrWrite;
|
|
93
|
+
console.log = outputFilterOriginals.consoleLog;
|
|
94
|
+
console.info = outputFilterOriginals.consoleInfo;
|
|
95
|
+
console.warn = outputFilterOriginals.consoleWarn;
|
|
96
|
+
console.error = outputFilterOriginals.consoleError;
|
|
97
|
+
console.debug = outputFilterOriginals.consoleDebug;
|
|
98
|
+
outputFilterOriginals = undefined;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CursorPiToolDisplay } from "./cursor-transcript-utils.js";
|
|
2
|
+
|
|
3
|
+
function escapeRegExp(value: string): string {
|
|
4
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function scrubSensitiveText(text: string, apiKey?: string): string {
|
|
8
|
+
let scrubbed = text;
|
|
9
|
+
const trimmedKey = apiKey?.trim();
|
|
10
|
+
if (trimmedKey) {
|
|
11
|
+
scrubbed = scrubbed.replace(new RegExp(escapeRegExp(trimmedKey), "g"), "[redacted]");
|
|
12
|
+
}
|
|
13
|
+
return scrubbed
|
|
14
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
|
|
15
|
+
.replace(/((?:^|[\s,{])cookie["']?\s*[:=]\s*["']?)[^\n]+/gi, "$1[redacted]")
|
|
16
|
+
.replace(
|
|
17
|
+
/((?:authorization|api[_-]?key|apiKey|token|session(?:[_-]?id)?)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/gi,
|
|
18
|
+
"$1[redacted]",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scrubDisplayValue(value: unknown, apiKey?: string): unknown {
|
|
23
|
+
if (typeof value === "string") return scrubSensitiveText(value, apiKey);
|
|
24
|
+
if (Array.isArray(value)) return value.map((entry) => scrubDisplayValue(entry, apiKey));
|
|
25
|
+
if (value && typeof value === "object") {
|
|
26
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrubDisplayValue(entry, apiKey)]));
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scrubPiToolDisplay(display: CursorPiToolDisplay, apiKey?: string): CursorPiToolDisplay {
|
|
32
|
+
return {
|
|
33
|
+
...display,
|
|
34
|
+
args: scrubDisplayValue(display.args, apiKey) as Record<string, unknown>,
|
|
35
|
+
result: scrubDisplayValue(display.result, apiKey) as CursorPiToolDisplay["result"],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import
|
|
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";
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
8
|
+
interface CursorSessionCwdExtensionApi {
|
|
9
|
+
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
|
10
|
+
}
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Pi session cwd when known; falls back to process.cwd() before session_start.
|
|
@@ -10,24 +15,14 @@ const state = {
|
|
|
10
15
|
* changes without a new session_start event are not reflected here.
|
|
11
16
|
*/
|
|
12
17
|
export function getCursorSessionCwd(): string {
|
|
13
|
-
return
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function setCursorSessionCwd(cwd: string): void {
|
|
17
|
-
state.sessionCwd = cwd;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function resetCursorSessionCwd(): void {
|
|
21
|
-
state.sessionCwd = process.cwd();
|
|
18
|
+
return getCursorSessionCwdFromScope();
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
export function registerCursorSessionCwd(pi:
|
|
25
|
-
pi
|
|
26
|
-
setCursorSessionCwd(ctx.cwd);
|
|
27
|
-
});
|
|
21
|
+
export function registerCursorSessionCwd(pi: CursorSessionCwdExtensionApi): void {
|
|
22
|
+
registerCursorSessionScope(pi);
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
export const __testUtils = {
|
|
31
|
-
set:
|
|
32
|
-
reset:
|
|
26
|
+
set: cursorSessionScopeTestUtils.set,
|
|
27
|
+
reset: cursorSessionScopeTestUtils.reset,
|
|
33
28
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
interface CursorSessionScopeExtensionApi {
|
|
4
|
+
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ANONYMOUS_SESSION_SCOPE_KEY = "__anonymous__";
|
|
8
|
+
|
|
9
|
+
type CursorSessionScopeChangeHandler = (previousScopeKey: string) => void;
|
|
10
|
+
|
|
11
|
+
const state = {
|
|
12
|
+
sessionCwd: process.cwd(),
|
|
13
|
+
sessionFile: undefined as string | undefined,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let scopeChangeHandler: CursorSessionScopeChangeHandler | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pi session file when known; used to scope reused Cursor SDK agents to one pi session.
|
|
20
|
+
*/
|
|
21
|
+
export function getCursorSessionFile(): string | undefined {
|
|
22
|
+
return state.sessionFile;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stable scope key for session-agent pooling. Falls back to a process-local anonymous key
|
|
27
|
+
* before the first session_start (tests and early startup).
|
|
28
|
+
*/
|
|
29
|
+
export function getCursorSessionScopeKey(): string {
|
|
30
|
+
return state.sessionFile ?? ANONYMOUS_SESSION_SCOPE_KEY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getCursorSessionCwdFromScope(): string {
|
|
34
|
+
return state.sessionCwd;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setCursorSessionScope(cwd: string, sessionFile: string | undefined): void {
|
|
38
|
+
state.sessionCwd = cwd;
|
|
39
|
+
state.sessionFile = sessionFile;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resetCursorSessionScope(): void {
|
|
43
|
+
state.sessionCwd = process.cwd();
|
|
44
|
+
state.sessionFile = undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function onCursorSessionScopeKeyChange(handler: CursorSessionScopeChangeHandler): void {
|
|
48
|
+
scopeChangeHandler = handler;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function registerCursorSessionScope(pi: CursorSessionScopeExtensionApi): void {
|
|
52
|
+
pi.on("session_start", (_event, ctx) => {
|
|
53
|
+
const previousScopeKey = getCursorSessionScopeKey();
|
|
54
|
+
setCursorSessionScope(ctx.cwd, ctx.sessionManager?.getSessionFile?.() ?? undefined);
|
|
55
|
+
if (previousScopeKey !== getCursorSessionScopeKey()) {
|
|
56
|
+
scopeChangeHandler?.(previousScopeKey);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const __testUtils = {
|
|
62
|
+
ANONYMOUS_SESSION_SCOPE_KEY,
|
|
63
|
+
set: setCursorSessionScope,
|
|
64
|
+
reset: resetCursorSessionScope,
|
|
65
|
+
};
|