@emmaneugene/pi-cursor-sdk 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -0
  3. package/dist/cursor-extension-factory-guard.js +31 -0
  4. package/dist/cursor-pi-tool-bridge-server.js +3 -0
  5. package/dist/cursor-pi-tool-bridge.js +28 -7
  6. package/dist/cursor-provider-lazy.js +2 -2
  7. package/dist/cursor-provider-live-run-drain.js +4 -4
  8. package/dist/cursor-provider-run-finalizer.js +1 -0
  9. package/dist/cursor-provider-runtime-context.js +1 -0
  10. package/dist/cursor-provider-turn-prepare.js +17 -9
  11. package/dist/cursor-provider-turn-runner.js +5 -4
  12. package/dist/cursor-provider.js +3 -2
  13. package/dist/cursor-session-agent.js +9 -7
  14. package/dist/index.js +108 -53
  15. package/docs/cursor-live-smoke-checklist.md +1 -1
  16. package/docs/cursor-model-ux-spec.md +1 -1
  17. package/docs/cursor-native-tool-visual-audit.md +3 -3
  18. package/docs/cursor-testing-lessons.md +24 -0
  19. package/docs/platform-smoke.md +1 -1
  20. package/node_modules/cross-spawn/node_modules/which/CHANGELOG.md +166 -0
  21. package/package.json +1 -1
  22. package/scripts/lib/cursor-visual-render.mjs +12 -2
  23. package/scripts/visual-tui-smoke-self-test.mjs +9 -33
  24. package/scripts/visual-tui-smoke.mjs +54 -11
  25. package/src/cursor-extension-factory-guard.ts +57 -0
  26. package/src/cursor-pi-tool-bridge-server.ts +4 -0
  27. package/src/cursor-pi-tool-bridge.ts +33 -7
  28. package/src/cursor-provider-lazy.ts +3 -1
  29. package/src/cursor-provider-live-run-drain.ts +4 -3
  30. package/src/cursor-provider-run-finalizer.ts +1 -0
  31. package/src/cursor-provider-runtime-context.ts +13 -0
  32. package/src/cursor-provider-turn-prepare.ts +20 -9
  33. package/src/cursor-provider-turn-runner.ts +13 -4
  34. package/src/cursor-provider-turn-types.ts +2 -0
  35. package/src/cursor-provider.ts +4 -1
  36. package/src/cursor-session-agent.ts +11 -7
  37. package/src/index.ts +124 -52
@@ -0,0 +1,166 @@
1
+ # Changes
2
+
3
+
4
+ ## 2.0.2
5
+
6
+ * Rename bin to `node-which`
7
+
8
+ ## 2.0.1
9
+
10
+ * generate changelog and publish on version bump
11
+ * enforce 100% test coverage
12
+ * Promise interface
13
+
14
+ ## 2.0.0
15
+
16
+ * Parallel tests, modern JavaScript, and drop support for node < 8
17
+
18
+ ## 1.3.1
19
+
20
+ * update deps
21
+ * update travis
22
+
23
+ ## v1.3.0
24
+
25
+ * Add nothrow option to which.sync
26
+ * update tap
27
+
28
+ ## v1.2.14
29
+
30
+ * appveyor: drop node 5 and 0.x
31
+ * travis-ci: add node 6, drop 0.x
32
+
33
+ ## v1.2.13
34
+
35
+ * test: Pass missing option to pass on windows
36
+ * update tap
37
+ * update isexe to 2.0.0
38
+ * neveragain.tech pledge request
39
+
40
+ ## v1.2.12
41
+
42
+ * Removed unused require
43
+
44
+ ## v1.2.11
45
+
46
+ * Prevent changelog script from being included in package
47
+
48
+ ## v1.2.10
49
+
50
+ * Use env.PATH only, not env.Path
51
+
52
+ ## v1.2.9
53
+
54
+ * fix for paths starting with ../
55
+ * Remove unused `is-absolute` module
56
+
57
+ ## v1.2.8
58
+
59
+ * bullet items in changelog that contain (but don't start with) #
60
+
61
+ ## v1.2.7
62
+
63
+ * strip 'update changelog' changelog entries out of changelog
64
+
65
+ ## v1.2.6
66
+
67
+ * make the changelog bulleted
68
+
69
+ ## v1.2.5
70
+
71
+ * make a changelog, and keep it up to date
72
+ * don't include tests in package
73
+ * Properly handle relative-path executables
74
+ * appveyor
75
+ * Attach error code to Not Found error
76
+ * Make tests pass on Windows
77
+
78
+ ## v1.2.4
79
+
80
+ * Fix typo
81
+
82
+ ## v1.2.3
83
+
84
+ * update isexe, fix regression in pathExt handling
85
+
86
+ ## v1.2.2
87
+
88
+ * update deps, use isexe module, test windows
89
+
90
+ ## v1.2.1
91
+
92
+ * Sometimes windows PATH entries are quoted
93
+ * Fixed a bug in the check for group and user mode bits. This bug was introduced during refactoring for supporting strict mode.
94
+ * doc cli
95
+
96
+ ## v1.2.0
97
+
98
+ * Add support for opt.all and -as cli flags
99
+ * test the bin
100
+ * update travis
101
+ * Allow checking for multiple programs in bin/which
102
+ * tap 2
103
+
104
+ ## v1.1.2
105
+
106
+ * travis
107
+ * Refactored and fixed undefined error on Windows
108
+ * Support strict mode
109
+
110
+ ## v1.1.1
111
+
112
+ * test +g exes against secondary groups, if available
113
+ * Use windows exe semantics on cygwin & msys
114
+ * cwd should be first in path on win32, not last
115
+ * Handle lower-case 'env.Path' on Windows
116
+ * Update docs
117
+ * use single-quotes
118
+
119
+ ## v1.1.0
120
+
121
+ * Add tests, depend on is-absolute
122
+
123
+ ## v1.0.9
124
+
125
+ * which.js: root is allowed to execute files owned by anyone
126
+
127
+ ## v1.0.8
128
+
129
+ * don't use graceful-fs
130
+
131
+ ## v1.0.7
132
+
133
+ * add license to package.json
134
+
135
+ ## v1.0.6
136
+
137
+ * isc license
138
+
139
+ ## 1.0.5
140
+
141
+ * Awful typo
142
+
143
+ ## 1.0.4
144
+
145
+ * Test for path absoluteness properly
146
+ * win: Allow '' as a pathext if cmd has a . in it
147
+
148
+ ## 1.0.3
149
+
150
+ * Remove references to execPath
151
+ * Make `which.sync()` work on Windows by honoring the PATHEXT variable.
152
+ * Make `isExe()` always return true on Windows.
153
+ * MIT
154
+
155
+ ## 1.0.2
156
+
157
+ * Only files can be exes
158
+
159
+ ## 1.0.1
160
+
161
+ * Respect the PATHEXT env for win32 support
162
+ * should 0755 the bin
163
+ * binary
164
+ * guts
165
+ * package
166
+ * 1st
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emmaneugene/pi-cursor-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "pi provider extension backed by @cursor/sdk local agents",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -112,7 +112,17 @@ export async function writeTerminalScreenshot(htmlPath, pngPath, width, height)
112
112
  let browser;
113
113
  try {
114
114
  const { chromium } = await import("playwright");
115
- browser = await chromium.launch();
115
+ try {
116
+ browser = await chromium.launch();
117
+ } catch (bundledBrowserError) {
118
+ try {
119
+ browser = await chromium.launch({ channel: "chrome" });
120
+ } catch (systemBrowserError) {
121
+ const bundledMessage = bundledBrowserError instanceof Error ? bundledBrowserError.message : String(bundledBrowserError);
122
+ const systemMessage = systemBrowserError instanceof Error ? systemBrowserError.message : String(systemBrowserError);
123
+ throw new Error(`bundled Chromium: ${bundledMessage}\nsystem Chrome: ${systemMessage}`);
124
+ }
125
+ }
116
126
  const page = await browser.newPage({
117
127
  viewport: {
118
128
  width: Math.max(1_200, width * 10),
@@ -125,7 +135,7 @@ export async function writeTerminalScreenshot(htmlPath, pngPath, width, height)
125
135
  await page.locator("#terminal").screenshot({ path: pngPath });
126
136
  } catch (error) {
127
137
  const message = error instanceof Error ? error.message : String(error);
128
- throw new Error(`failed to capture PNG with Playwright: ${message}\nInstall Chromium with: npx playwright install chromium\nOr rerun with --no-screenshot and capture ${htmlPath} with agent_browser.`);
138
+ throw new Error(`failed to capture PNG with Playwright: ${message}\nInstall system Chrome or Chromium with: npx playwright install chromium\nOr rerun with --no-screenshot and capture ${htmlPath} with agent_browser.`);
129
139
  } finally {
130
140
  if (browser) await browser.close();
131
141
  }
@@ -24,7 +24,7 @@ function parseEnvCapture(path) {
24
24
  }
25
25
 
26
26
  export function runVisualSmokeSelfTest(deps) {
27
- const { ROOT, DEFAULT_MODE, DEFAULT_MODEL, DEFAULT_SETTING_SOURCES, DEBUG_ENV_NAMES, shellQuote, parseArgs, snapshotJsonlMtimes, findLatestJsonl, sealedNodePath, resolveCommand, requireNode, requireCommand, buildLaunchPlan, run, runVisualSmoke } = deps;
27
+ const { ROOT, DEFAULT_MODE, DEFAULT_MODEL, DEFAULT_SETTING_SOURCES, DEBUG_ENV_NAMES, shellQuote, parseArgs, snapshotJsonlMtimes, findLatestJsonl, jsonlContainsUserPrompt, waitForCursorTui, sealedNodePath, resolveCommand, requireNode, requireCommand, buildLaunchPlan, run, runVisualSmoke } = deps;
28
28
  const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
29
29
  try {
30
30
  const binDir = join(tempDir, "bin");
@@ -59,6 +59,9 @@ export function runVisualSmokeSelfTest(deps) {
59
59
  utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
60
60
  assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
61
61
  assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
62
+ writeFileSync(freshJsonl, `${JSON.stringify({ type: "message", message: { role: "user", content: "submitted prompt" } })}\n`, "utf8");
63
+ assertSelfTest(jsonlContainsUserPrompt(freshJsonl, "submitted prompt"), "JSONL prompt check should find the submitted user message");
64
+ assertSelfTest(!jsonlContainsUserPrompt(freshJsonl, "different prompt"), "JSONL prompt check should reject a different user message");
62
65
 
63
66
  assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
64
67
  const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
@@ -73,6 +76,7 @@ export function runVisualSmokeSelfTest(deps) {
73
76
  cwd: ROOT,
74
77
  mode: DEFAULT_MODE,
75
78
  model: DEFAULT_MODEL,
79
+ prompt: "self-test prompt",
76
80
  outDir: tempDir,
77
81
  safeLabel: "self-test",
78
82
  sessionDir: join(tempDir, "session"),
@@ -96,6 +100,7 @@ export function runVisualSmokeSelfTest(deps) {
96
100
  assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
97
101
  }
98
102
  assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
103
+ assertSelfTest(!plan.script.includes(baseOptions.prompt), "launch script must keep the prompt out of the process command line");
99
104
  assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
100
105
  const hostileEnv = {
101
106
  ...process.env,
@@ -144,48 +149,18 @@ export function runVisualSmokeSelfTest(deps) {
144
149
  assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
145
150
 
146
151
  const fakeTmux = join(binDir, "tmux");
147
- const deleteBufferMarker = join(tempDir, "delete-buffer-called");
148
- writeFileSync(
149
- fakeTmux,
150
- `#!/bin/sh\ncase "$1" in\n -V) echo 'tmux fake'; exit 0 ;;\n new-session) exit 0 ;;\n load-buffer) cat >/dev/null; exit 0 ;;\n paste-buffer) exit 77 ;;\n delete-buffer) echo deleted > ${shellQuote(deleteBufferMarker)}; exit 0 ;;\n kill-session) exit 0 ;;\n *) echo "unexpected tmux command: $*" >&2; exit 64 ;;\nesac\n`,
151
- "utf8",
152
- );
153
- chmodSync(fakeTmux, 0o755);
154
- const originalPath = process.env.PATH;
155
- try {
156
- process.env.PATH = hostilePath;
157
- let pasteFailed = false;
158
- try {
159
- runVisualSmoke({
160
- ...baseOptions,
161
- prompt: "buffer cleanup prompt",
162
- startupMs: 1,
163
- waitMs: 1,
164
- width: 80,
165
- height: 24,
166
- historyLines: 100,
167
- });
168
- } catch (error) {
169
- pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
170
- }
171
- assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
172
- assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
173
- } finally {
174
- if (originalPath === undefined) delete process.env.PATH;
175
- else process.env.PATH = originalPath;
176
- }
177
-
178
152
  writeFileSync(
179
153
  fakeTmux,
180
154
  `#!/bin/sh
181
155
  case "$1" in
182
156
  -V) echo 'tmux fake'; exit 0 ;;
183
157
  new-session) exit 0 ;;
158
+ set-option) exit 0 ;;
184
159
  load-buffer) cat >/dev/null; exit 0 ;;
185
160
  paste-buffer) exit 0 ;;
186
161
  send-keys) exit 0 ;;
187
162
  delete-buffer) exit 0 ;;
188
- capture-pane) echo 'captured visual smoke output'; exit 0 ;;
163
+ capture-pane) printf 'captured visual smoke output\ncursor · fast:off\n'; exit 0 ;;
189
164
  kill-session) exit 0 ;;
190
165
  *) echo "unexpected tmux command: $*" >&2; exit 64 ;;
191
166
  esac
@@ -194,6 +169,7 @@ esac
194
169
  );
195
170
  chmodSync(fakeTmux, 0o755);
196
171
  const noJsonlManifest = join(tempDir, "self-test-jsonl-missing.manifest.json");
172
+ const originalPath = process.env.PATH;
197
173
  try {
198
174
  process.env.PATH = hostilePath;
199
175
  let missingJsonlFailed = false;
@@ -14,7 +14,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14
14
  const DEFAULT_WIDTH = 150;
15
15
  const DEFAULT_HEIGHT = 45;
16
16
  const DEFAULT_WAIT_MS = 60_000;
17
- const DEFAULT_STARTUP_MS = 5_000;
17
+ const DEFAULT_STARTUP_MS = 15_000;
18
18
  const DEFAULT_HISTORY_LINES = 3_000;
19
19
  const DEFAULT_MODEL = "cursor/grok-4.6";
20
20
  const DEFAULT_MODE = "plan";
@@ -33,15 +33,15 @@ Usage:
33
33
 
34
34
  Required:
35
35
  --label LABEL Artifact filename prefix. Sanitized for paths.
36
- --prompt PROMPT Prompt to paste into the interactive pi TUI.
36
+ --prompt PROMPT Prompt to submit in the interactive pi TUI.
37
37
  Use --prompt-file PATH for multi-line prompts.
38
38
 
39
39
  Common options:
40
40
  --ext PATH Extension repo to load with pi -e. Default: repo root.
41
41
  --cwd PATH Working directory for the pi session. Default: current directory.
42
42
  --out-dir PATH Artifact directory. Default: /tmp/pi-cursor-sdk-visual-smoke-<timestamp>.
43
- --wait-ms N Milliseconds to wait after sending the prompt. Default: ${DEFAULT_WAIT_MS}.
44
- --startup-ms N Milliseconds to wait before pasting the prompt. Default: ${DEFAULT_STARTUP_MS}.
43
+ --wait-ms N Milliseconds to wait for the response before capture. Default: ${DEFAULT_WAIT_MS}.
44
+ --startup-ms N Maximum milliseconds to wait for TUI readiness. Default: ${DEFAULT_STARTUP_MS}.
45
45
  --model MODEL Cursor model. Default: ${DEFAULT_MODEL}.
46
46
  --mode agent|plan Cursor SDK mode. Default: ${DEFAULT_MODE}.
47
47
  --session-dir PATH pi session directory. Default: <out-dir>/<label>.session.
@@ -82,8 +82,8 @@ Artifacts written:
82
82
  Prerequisites:
83
83
  - pi, node, tmux, and npm-installed dev dependencies on PATH / in node_modules.
84
84
  - The runner resolves pi/tmux from the parent PATH, uses process.execPath for node, and seals pi-shim PATH for prereq checks and tmux.
85
- - For automatic PNG capture, install a Playwright browser once when needed:
86
- npx playwright install chromium
85
+ - Automatic PNG capture uses Playwright's Chromium or a system Chrome installation.
86
+ If neither is available, install Chromium once: npx playwright install chromium
87
87
  - In the pi agent harness, --no-screenshot plus agent_browser on the generated HTML is also acceptable.
88
88
 
89
89
  Examples:
@@ -340,6 +340,41 @@ function findLatestJsonl(root, { sinceMs = 0, previousMtimes = new Map() } = {})
340
340
  return matches[0]?.path;
341
341
  }
342
342
 
343
+ function jsonlContainsUserPrompt(path, prompt) {
344
+ for (const line of readFileSync(path, "utf8").split("\n")) {
345
+ if (!line.trim()) continue;
346
+ let entry;
347
+ try {
348
+ entry = JSON.parse(line);
349
+ } catch {
350
+ continue;
351
+ }
352
+ if (entry?.type !== "message" || entry.message?.role !== "user") continue;
353
+ const content = entry.message.content;
354
+ const text = typeof content === "string"
355
+ ? content
356
+ : Array.isArray(content)
357
+ ? content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("")
358
+ : "";
359
+ if (text === prompt) return true;
360
+ }
361
+ return false;
362
+ }
363
+
364
+ function waitForCursorTui(commands, sessionName, timeoutMs) {
365
+ const deadline = Date.now() + timeoutMs;
366
+ do {
367
+ try {
368
+ const pane = capturePane(commands.tmux, sessionName, ["-p", "-S", "-100"]);
369
+ if (pane.includes("cursor ·")) return;
370
+ } catch {
371
+ // The pane can be unavailable briefly while tmux starts the shell.
372
+ }
373
+ sleep(100);
374
+ } while (Date.now() < deadline);
375
+ throw new Error(`Cursor TUI did not become ready within ${timeoutMs}ms`);
376
+ }
377
+
343
378
  function checkLeftovers(patterns) {
344
379
  if (patterns.length === 0) return;
345
380
  const result = run("ps", ["-axo", "pid,etime,command"]);
@@ -465,18 +500,19 @@ function runVisualSmoke(options) {
465
500
  ]);
466
501
  if (start.status !== 0) throw new Error(`tmux new-session failed: ${start.stderr?.toString().trim() || start.status}`);
467
502
  sessionStarted = true;
503
+ const remainOnExit = run(commands.tmux, ["set-option", "-t", sessionName, "remain-on-exit", "on"]);
504
+ if (remainOnExit.status !== 0) throw new Error(`tmux remain-on-exit setup failed: ${remainOnExit.stderr?.toString().trim() || remainOnExit.status}`);
468
505
 
469
- sleep(options.startupMs);
506
+ waitForCursorTui(commands, sessionName, options.startupMs);
470
507
  const load = run(commands.tmux, ["load-buffer", "-b", bufferName, "-"], { input: Buffer.from(options.prompt, "utf8") });
471
508
  if (load.status !== 0) throw new Error(`tmux load-buffer failed: ${load.stderr?.toString().trim() || load.status}`);
472
509
  bufferLoaded = true;
473
510
  try {
474
- const paste = run(commands.tmux, ["paste-buffer", "-b", bufferName, "-t", sessionName]);
511
+ const paste = run(commands.tmux, ["paste-buffer", "-p", "-S", "-b", bufferName, "-t", sessionName]);
475
512
  if (paste.status !== 0) throw new Error(`tmux paste-buffer failed: ${paste.stderr?.toString().trim() || paste.status}`);
476
- // Give bracketed paste handling a moment to finish before submitting.
477
513
  sleep(250);
478
- const enter = run(commands.tmux, ["send-keys", "-t", sessionName, "Enter"]);
479
- if (enter.status !== 0) throw new Error(`tmux send-keys failed: ${enter.stderr?.toString().trim() || enter.status}`);
514
+ const submit = run(commands.tmux, ["send-keys", "-t", sessionName, "-H", "0d"]);
515
+ if (submit.status !== 0) throw new Error(`tmux prompt submit failed: ${submit.stderr?.toString().trim() || submit.status}`);
480
516
  } finally {
481
517
  run(commands.tmux, ["delete-buffer", "-b", bufferName]);
482
518
  bufferLoaded = false;
@@ -507,6 +543,11 @@ function runVisualSmoke(options) {
507
543
  writeVisualManifest(manifestPath, options, partialArtifacts, { message, writtenAt: new Date().toISOString() });
508
544
  throw new Error(message);
509
545
  }
546
+ if (!jsonlContainsUserPrompt(jsonlPath, options.prompt)) {
547
+ const message = "current-run JSONL does not contain the submitted prompt";
548
+ writeVisualManifest(manifestPath, options, { ...partialArtifacts, jsonlPath }, { message, writtenAt: new Date().toISOString() });
549
+ throw new Error(message);
550
+ }
510
551
  writeUtf8(jsonlPathFile, `${jsonlPath}\n`);
511
552
 
512
553
  return { ...partialArtifacts, jsonlPath };
@@ -531,6 +572,8 @@ try {
531
572
  parseArgs,
532
573
  snapshotJsonlMtimes,
533
574
  findLatestJsonl,
575
+ jsonlContainsUserPrompt,
576
+ waitForCursorTui,
534
577
  sealedNodePath,
535
578
  resolveCommand,
536
579
  requireNode,
@@ -0,0 +1,57 @@
1
+ import type { ExtensionHandler, SessionShutdownEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * First factory invocation in this process owns Cursor's process-global
5
+ * registrars (bridge, session scope, pooled SDK agent). Pi child sessions call
6
+ * `createAgentSession` + `bindExtensions` in the same process, which re-invokes
7
+ * this factory against a new ExtensionAPI while the parent Cursor run is still
8
+ * live. A nested factory must not re-register.
9
+ *
10
+ * Ownership ends after the owner's shutdown handlers finish. Pi then reloads
11
+ * the extension for `new`, `resume`, `fork`, and explicit reload operations.
12
+ */
13
+ export type CursorExtensionFactoryClaim =
14
+ | { kind: "owner"; token: symbol }
15
+ | { kind: "nested" };
16
+
17
+ export interface CursorExtensionFactoryGuardApi {
18
+ on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
19
+ }
20
+
21
+ let factoryOwnerToken: symbol | undefined;
22
+
23
+ export function claimCursorExtensionFactory(): CursorExtensionFactoryClaim {
24
+ if (factoryOwnerToken) return { kind: "nested" };
25
+ const token = Symbol("cursor-extension-factory-owner");
26
+ factoryOwnerToken = token;
27
+ return { kind: "owner", token };
28
+ }
29
+
30
+ export function releaseCursorExtensionFactory(token: symbol): void {
31
+ if (factoryOwnerToken === token) {
32
+ factoryOwnerToken = undefined;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Install the owner's release handler after all other factory registration
38
+ * succeeds. The final handler keeps ownership until earlier shutdown cleanup
39
+ * has finished.
40
+ */
41
+ export function registerCursorExtensionFactoryRelease(
42
+ pi: CursorExtensionFactoryGuardApi,
43
+ claim: Extract<CursorExtensionFactoryClaim, { kind: "owner" }>,
44
+ ): void {
45
+ pi.on("session_shutdown", () => {
46
+ releaseCursorExtensionFactory(claim.token);
47
+ });
48
+ }
49
+
50
+ export const __testUtils = {
51
+ isInstalled(): boolean {
52
+ return factoryOwnerToken !== undefined;
53
+ },
54
+ reset(): void {
55
+ factoryOwnerToken = undefined;
56
+ },
57
+ };
@@ -64,6 +64,10 @@ export class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
64
64
  return run;
65
65
  }
66
66
 
67
+ hasLiveRuns(): boolean {
68
+ return this.runs.size > 0;
69
+ }
70
+
67
71
  async disposeAll(reason = "Cursor pi tool bridge disposed"): Promise<void> {
68
72
  await Promise.all([...this.runs].map(async (run) => {
69
73
  run.cancel(reason);
@@ -88,13 +88,13 @@ Get-CimInstance Win32_Process -Filter "Name = 'bash.exe' OR Name = 'sh.exe'" |
88
88
  });
89
89
  }
90
90
 
91
- export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
92
- bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
93
- void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
94
- const bridge = new CursorPiToolBridgeRegistry(pi);
95
- registeredCursorPiToolBridge = bridge;
91
+ function attachCursorPiToolBridgeHandlers(
92
+ pi: CursorPiToolBridgeExtensionApi,
93
+ bridge: CursorPiToolBridgeRegistry,
94
+ options: { abortAllOnShutdown: boolean; isActive: () => boolean },
95
+ ): void {
96
96
  pi.on("tool_call", (event, ctx) => {
97
- if (registeredCursorPiToolBridge !== bridge) return undefined;
97
+ if (!options.isActive()) return undefined;
98
98
  if (!bridge.hasPendingPiToolCallId(event.toolCallId)) {
99
99
  return isCursorPiBridgeToolCallId(event.toolCallId)
100
100
  ? { block: true, reason: "Cursor pi bridge tool call is no longer pending" }
@@ -119,9 +119,35 @@ export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi):
119
119
  });
120
120
  pi.on("session_shutdown", async (event) => {
121
121
  const reason = `Cursor pi tool bridge session shutdown: ${event.reason}`;
122
- bridgeToolExecutionAbortTracker.abortAll(reason);
122
+ if (options.abortAllOnShutdown) bridgeToolExecutionAbortTracker.abortAll(reason);
123
123
  await bridge.disposeAll(reason);
124
124
  });
125
+ }
126
+
127
+ export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
128
+ // Replacing a bridge during a live MCP run cancels its pending pi tool
129
+ // calls. Keep the active registry as a final safety belt.
130
+ if (registeredCursorPiToolBridge?.hasLiveRuns()) {
131
+ return registeredCursorPiToolBridge;
132
+ }
133
+ bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
134
+ void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
135
+ const bridge = new CursorPiToolBridgeRegistry(pi);
136
+ registeredCursorPiToolBridge = bridge;
137
+ attachCursorPiToolBridgeHandlers(pi, bridge, {
138
+ abortAllOnShutdown: true,
139
+ isActive: () => registeredCursorPiToolBridge === bridge,
140
+ });
141
+ return bridge;
142
+ }
143
+
144
+ /** Register a bridge owned only by one nested in-process child session. */
145
+ export function registerNestedCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
146
+ const bridge = new CursorPiToolBridgeRegistry(pi);
147
+ attachCursorPiToolBridgeHandlers(pi, bridge, {
148
+ abortAllOnShutdown: false,
149
+ isActive: () => true,
150
+ });
125
151
  return bridge;
126
152
  }
127
153
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "@earendil-works/pi-ai";
10
10
  import { streamCursor } from "./cursor-provider.js";
11
11
  import { sanitizeCursorProviderError } from "./cursor-provider-errors.js";
12
+ import type { CursorProviderRuntimeContext } from "./cursor-provider-runtime-context.js";
12
13
 
13
14
  function makeProviderRuntimeErrorMessage(model: Model<Api>, error: unknown, apiKey?: string): AssistantMessage {
14
15
  return {
@@ -35,11 +36,12 @@ export function streamCursorLazy(
35
36
  model: Model<Api>,
36
37
  context: Context,
37
38
  options?: SimpleStreamOptions,
39
+ runtimeContext?: CursorProviderRuntimeContext,
38
40
  ): AssistantMessageEventStream {
39
41
  const outer = createAssistantMessageEventStream();
40
42
  queueMicrotask(async () => {
41
43
  try {
42
- for await (const event of streamCursor(model, context, options)) {
44
+ for await (const event of streamCursor(model, context, options, runtimeContext)) {
43
45
  outer.push(event);
44
46
  }
45
47
  } catch (error) {
@@ -67,8 +67,8 @@ export function getPendingCursorLiveRun(context: Context): CursorLiveRun | undef
67
67
  return cursorLiveRuns.getPendingFromContext(context, getCursorNativeReplayIdFromToolCallId);
68
68
  }
69
69
 
70
- export function getActiveCursorLiveRunForCurrentScope(): CursorLiveRun | undefined {
71
- return cursorLiveRuns.getActiveForScope();
70
+ export function getActiveCursorLiveRunForScope(scopeKey?: string): CursorLiveRun | undefined {
71
+ return cursorLiveRuns.getActiveForScope(scopeKey);
72
72
  }
73
73
 
74
74
  function splitTextIntoReplayDeltas(text: string): string[] {
@@ -427,10 +427,11 @@ export async function drainExistingCursorLiveRunBeforeSend(
427
427
  context: Context,
428
428
  signal?: AbortSignal,
429
429
  turnDebugRecorder?: CursorSdkEventDebugRecorder,
430
+ scopeKey?: string,
430
431
  ): Promise<LiveRunPreSendOutcome> {
431
432
  turnDebugRecorder?.recordDrainEvent("pre_send_start", {});
432
433
  while (true) {
433
- const run = getPendingCursorLiveRun(context) ?? getActiveCursorLiveRunForCurrentScope();
434
+ const run = getPendingCursorLiveRun(context) ?? getActiveCursorLiveRunForScope(scopeKey);
434
435
  if (!run || run.disposed) {
435
436
  turnDebugRecorder?.recordDrainEvent("pre_send_end", { outcome: "continue_send", reason: "no_pending_run" });
436
437
  return "continue_send";
@@ -147,6 +147,7 @@ export class CursorRunFinalizer {
147
147
  if (liveCompletion) {
148
148
  void liveCompletion.waitCompletion
149
149
  .finally(async () => {
150
+ await prepared?.lifecycle.dispose().catch(() => {});
150
151
  await this.finalizeSdkEventDebugBestEffort();
151
152
  this.safeCleanup(() => this.params.sdkProcessErrorGuard.dispose());
152
153
  })
@@ -0,0 +1,13 @@
1
+ import type { CursorPiToolBridge } from "./cursor-pi-tool-bridge-types.js";
2
+
3
+ /** Runtime state captured by one nested in-process Cursor provider registration. */
4
+ export interface CursorProviderRuntimeContext {
5
+ readonly scopeKey: string;
6
+ readonly cwd: string;
7
+ readonly sessionFile: string | undefined;
8
+ readonly projectTrusted: boolean;
9
+ readonly bridge: CursorPiToolBridge;
10
+ readonly localResume: false;
11
+ readonly nativeToolReplay: false;
12
+ readonly disposeAgentAfterTurn: true;
13
+ }