pi-cursor-sdk 0.1.27 → 0.1.29

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 (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +40 -37
  3. package/docs/crabbox-platform-testing-lessons.md +508 -0
  4. package/docs/cursor-dogfood-checklist.md +4 -3
  5. package/docs/cursor-live-smoke-checklist.md +24 -22
  6. package/docs/cursor-model-ux-spec.md +12 -12
  7. package/docs/cursor-native-tool-replay.md +10 -10
  8. package/docs/cursor-native-tool-visual-audit.md +9 -7
  9. package/docs/cursor-testing-lessons.md +22 -17
  10. package/docs/cursor-tool-surfaces.md +3 -3
  11. package/docs/platform-smoke.md +994 -0
  12. package/package.json +35 -6
  13. package/platform-smoke.config.mjs +21 -0
  14. package/scripts/debug-provider-events.mjs +10 -3
  15. package/scripts/debug-sdk-events.mjs +10 -2
  16. package/scripts/isolated-cursor-smoke.sh +4 -4
  17. package/scripts/lib/cursor-visual-render.mjs +1 -0
  18. package/scripts/platform-smoke/artifacts.mjs +124 -0
  19. package/scripts/platform-smoke/assertions.mjs +101 -0
  20. package/scripts/platform-smoke/card-detect.mjs +96 -0
  21. package/scripts/platform-smoke/crabbox-runner.mjs +215 -0
  22. package/scripts/platform-smoke/doctor.mjs +446 -0
  23. package/scripts/platform-smoke/jsonl-text.mjs +31 -0
  24. package/scripts/platform-smoke/live-suite-runner.mjs +677 -0
  25. package/scripts/platform-smoke/platform-build-windows.ps1 +187 -0
  26. package/scripts/platform-smoke/pty-capture.mjs +131 -0
  27. package/scripts/platform-smoke/render-ansi.mjs +65 -0
  28. package/scripts/platform-smoke/scenarios.mjs +186 -0
  29. package/scripts/platform-smoke/targets.mjs +900 -0
  30. package/scripts/platform-smoke/visual-evidence.mjs +139 -0
  31. package/scripts/platform-smoke.mjs +193 -0
  32. package/scripts/probe-mcp-coldstart.mjs +8 -1
  33. package/scripts/steering-rpc-smoke.mjs +1 -1
  34. package/scripts/tmux-live-smoke.sh +3 -3
  35. package/scripts/visual-tui-smoke.mjs +1 -1
  36. package/src/cursor-pi-tool-bridge-abort.ts +1 -0
  37. package/src/cursor-pi-tool-bridge-diagnostics.ts +12 -1
  38. package/src/cursor-pi-tool-bridge.ts +46 -1
  39. package/src/cursor-provider-errors.ts +18 -2
  40. package/src/cursor-provider-turn-lifecycle-emitter.ts +65 -8
  41. package/src/cursor-provider-turn-tool-ledger.ts +2 -3
  42. package/src/cursor-run-final-text.ts +11 -1
  43. package/src/cursor-sdk-process-error-guard.ts +1 -1
  44. package/src/cursor-state.ts +38 -19
  45. package/src/cursor-tool-lifecycle.ts +1 -1
  46. package/src/cursor-tool-manifest.ts +1 -1
  47. package/src/cursor-transcript-utils.ts +7 -3
@@ -0,0 +1,139 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ function pngSize(path) {
6
+ try {
7
+ const buffer = readFileSync(path);
8
+ if (buffer.length < 24 || buffer.toString("ascii", 1, 4) !== "PNG") return undefined;
9
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), bytes: buffer.length };
10
+ } catch {
11
+ return undefined;
12
+ }
13
+ }
14
+
15
+ function safeFileName(id) {
16
+ return String(id).replace(/[^A-Za-z0-9_.-]+/g, "-");
17
+ }
18
+
19
+ function makeRegex(spec) {
20
+ if (!spec?.pattern) return undefined;
21
+ try {
22
+ return new RegExp(spec.pattern, spec.flags ?? "i");
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ export function findVisualEvidenceItems(lines, specs = []) {
29
+ return specs.map((spec) => {
30
+ const regex = makeRegex(spec);
31
+ if (!regex) return { id: spec.id, ok: false, error: `invalid regex: ${spec.pattern}` };
32
+ const lineIndex = lines.findIndex((line) => {
33
+ regex.lastIndex = 0;
34
+ return regex.test(line);
35
+ });
36
+ if (lineIndex === -1) return { id: spec.id, ok: false, pattern: spec.pattern };
37
+ return { id: spec.id, ok: true, pattern: spec.pattern, lineIndex, line: lines[lineIndex] };
38
+ });
39
+ }
40
+
41
+ export async function collectVisualEvidence({ htmlPath, pngPath, outDir, specs = [] }) {
42
+ mkdirSync(outDir, { recursive: true });
43
+ const evidence = {
44
+ ok: false,
45
+ htmlPath,
46
+ pngPath,
47
+ png: pngSize(pngPath),
48
+ style: null,
49
+ items: [],
50
+ checks: [],
51
+ writtenAt: new Date().toISOString(),
52
+ };
53
+
54
+ if (!existsSync(htmlPath)) {
55
+ evidence.checks.push({ id: "visual-html-present", ok: false, error: "terminal.html missing" });
56
+ writeFileSync(resolve(outDir, "visual-evidence.json"), JSON.stringify(evidence, null, 2));
57
+ return evidence;
58
+ }
59
+
60
+ let browser;
61
+ try {
62
+ const { chromium } = await import("playwright");
63
+ browser = await chromium.launch();
64
+ const page = await browser.newPage({ viewport: { width: 1_400, height: 1_000 }, deviceScaleFactor: 1 });
65
+ await page.goto(pathToFileURL(htmlPath).href);
66
+ await page.waitForSelector('body[data-render-ready="true"]', { timeout: 30_000 });
67
+ evidence.style = await page.evaluate(() => {
68
+ const terminal = document.querySelector("#terminal");
69
+ const screen = document.querySelector(".xterm-screen");
70
+ const rows = [...document.querySelectorAll(".xterm-rows > div")];
71
+ const spans = [...document.querySelectorAll(".xterm-rows span")];
72
+ const terminalStyle = terminal ? getComputedStyle(terminal) : undefined;
73
+ const screenStyle = screen ? getComputedStyle(screen) : undefined;
74
+ const colors = new Set(spans.map((span) => getComputedStyle(span).color));
75
+ const backgrounds = new Set(spans.map((span) => getComputedStyle(span).backgroundColor));
76
+ const term = window.__piVisualSmokeTerminal;
77
+ return {
78
+ terminalPresent: Boolean(terminal),
79
+ terminalRect: terminal ? terminal.getBoundingClientRect().toJSON() : null,
80
+ screenRect: screen ? screen.getBoundingClientRect().toJSON() : null,
81
+ rowCount: rows.length,
82
+ spanCount: spans.length,
83
+ colorCount: colors.size,
84
+ backgroundCount: backgrounds.size,
85
+ terminalBackground: terminalStyle?.backgroundColor,
86
+ terminalBorderColor: terminalStyle?.borderColor,
87
+ terminalBorderRadius: terminalStyle?.borderRadius,
88
+ screenBackground: screenStyle?.backgroundColor,
89
+ bufferLength: term?.buffer?.active?.length ?? 0,
90
+ };
91
+ });
92
+
93
+ const lines = await page.evaluate(() => {
94
+ const term = window.__piVisualSmokeTerminal;
95
+ const buffer = term?.buffer?.active;
96
+ if (!buffer) return [];
97
+ const out = [];
98
+ for (let index = 0; index < buffer.length; index++) {
99
+ out.push(buffer.getLine(index)?.translateToString(true) ?? "");
100
+ }
101
+ return out;
102
+ });
103
+
104
+ for (const item of findVisualEvidenceItems(lines, specs)) {
105
+ if (item.ok) {
106
+ const screenshot = `cards/${safeFileName(item.id)}.png`;
107
+ await page.evaluate((targetLine) => {
108
+ window.__piVisualSmokeTerminal?.scrollToLine(Math.max(0, targetLine - 4));
109
+ }, item.lineIndex);
110
+ await page.waitForTimeout(100);
111
+ await page.locator("#terminal").screenshot({ path: resolve(outDir, screenshot) });
112
+ evidence.items.push({ ...item, screenshot });
113
+ } else {
114
+ evidence.items.push(item);
115
+ }
116
+ }
117
+ } catch (error) {
118
+ evidence.checks.push({ id: "visual-playwright", ok: false, error: error instanceof Error ? error.message : String(error) });
119
+ } finally {
120
+ if (browser) await browser.close();
121
+ }
122
+
123
+ const style = evidence.style;
124
+ const png = evidence.png;
125
+ evidence.checks.push(
126
+ { id: "visual-png-size", ok: Boolean(png && png.width >= 800 && png.height >= 500 && png.bytes > 10_000), value: png },
127
+ { id: "visual-terminal-present", ok: style?.terminalPresent === true },
128
+ { id: "visual-xterm-buffer", ok: Number(style?.bufferLength ?? 0) >= 10, value: style?.bufferLength ?? 0 },
129
+ { id: "visual-xterm-rows", ok: Number(style?.rowCount ?? 0) >= 20, value: style?.rowCount ?? 0 },
130
+ { id: "visual-xterm-styled-spans", ok: Number(style?.spanCount ?? 0) >= 10, value: style?.spanCount ?? 0 },
131
+ { id: "visual-terminal-theme", ok: style?.terminalBackground === "rgb(11, 15, 20)" && style?.terminalBorderColor !== "rgba(0, 0, 0, 0)" && style?.terminalBorderRadius !== "0px", value: style },
132
+ );
133
+ for (const item of evidence.items) {
134
+ evidence.checks.push({ id: `visual-evidence-${item.id}`, ok: item.ok === true, line: item.line, screenshot: item.screenshot, pattern: item.pattern, error: item.error });
135
+ }
136
+ evidence.ok = evidence.checks.every((check) => check.ok);
137
+ writeFileSync(resolve(outDir, "visual-evidence.json"), JSON.stringify(evidence, null, 2));
138
+ return evidence;
139
+ }
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from "node:module";
4
+ import { resolve, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { accessSync, constants, readFileSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ // ── helpers ────────────────────────────────────────────────────────────────
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const repoRoot = resolve(__dirname, "..");
13
+
14
+ const require = createRequire(import.meta.url);
15
+ let config;
16
+ try {
17
+ config = require(resolve(repoRoot, "platform-smoke.config.mjs"));
18
+ if (config.default) config = config.default;
19
+ } catch (err) {
20
+ config = null;
21
+ }
22
+
23
+ function printHelp() {
24
+ console.log(`Usage: node scripts/platform-smoke.mjs <command> [options]
25
+
26
+ Commands:
27
+ doctor Run all preflight checks (no Cursor tokens)
28
+ run --target <names> Run one or more comma-separated targets concurrently
29
+ run --suite <name> Run one suite on all or specified targets
30
+ run --target <n> --suite <n>
31
+
32
+ Options:
33
+ --target Comma-separated target names: macos,ubuntu,windows-native
34
+ --suite Suite name: platform-build,cursor-native-visual-matrix,cursor-bridge-visual-matrix,cursor-abort-cleanup
35
+ --help, -h Show this help
36
+
37
+ Examples:
38
+ node scripts/platform-smoke.mjs doctor
39
+ node scripts/platform-smoke.mjs run --target macos
40
+ node scripts/platform-smoke.mjs run --target macos,ubuntu
41
+ node scripts/platform-smoke.mjs run --suite platform-build
42
+ node scripts/platform-smoke.mjs run --target macos --suite cursor-native-visual-matrix
43
+
44
+ Environment:
45
+ PLATFORM_SMOKE_CRABBOX Path to Crabbox binary
46
+ CURSOR_API_KEY Cursor auth key (required for live suites)
47
+ PLATFORM_SMOKE_MAC_HOST macOS SSH host (default: localhost)
48
+ PLATFORM_SMOKE_MAC_USER macOS SSH user (default: \$USER)
49
+ PLATFORM_SMOKE_MAC_WORK_ROOT macOS work root
50
+ PLATFORM_SMOKE_WINDOWS_VM Parallels source VM name
51
+ PLATFORM_SMOKE_WINDOWS_SNAPSHOT Snapshot name
52
+ PLATFORM_SMOKE_WINDOWS_USER Windows SSH user
53
+ PLATFORM_SMOKE_UBUNTU_IMAGE Ubuntu container image
54
+ PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT Windows native work root
55
+ `);
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const args = { _: [], target: null, suite: null, command: null };
60
+ let i = 2;
61
+ while (i < argv.length) {
62
+ const a = argv[i];
63
+ if (a === "--help" || a === "-h") {
64
+ args.command = "help";
65
+ return args;
66
+ }
67
+ if (a === "doctor") {
68
+ args.command = "doctor";
69
+ i++;
70
+ continue;
71
+ }
72
+ if (a === "run") {
73
+ args.command = "run";
74
+ i++;
75
+ continue;
76
+ }
77
+ if (a === "--target" && i + 1 < argv.length) {
78
+ args.target = argv[i + 1];
79
+ i += 2;
80
+ continue;
81
+ }
82
+ if (a === "--suite" && i + 1 < argv.length) {
83
+ args.suite = argv[i + 1];
84
+ i += 2;
85
+ continue;
86
+ }
87
+ args._.push(a);
88
+ i++;
89
+ }
90
+ return args;
91
+ }
92
+
93
+ function assertHostReleaseVersionGuard() {
94
+ const packageJson = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8"));
95
+ const result = spawnSync("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"], {
96
+ cwd: repoRoot,
97
+ encoding: "utf8",
98
+ });
99
+ if (result.status !== 0) throw new Error(`failed to inspect release tags: ${result.stderr || result.error?.message || "unknown git error"}`);
100
+ const latestTag = result.stdout.split(/\r?\n/).find((tag) => tag.length > 0);
101
+ if (!latestTag) throw new Error("no local release tags found; cannot enforce package version reuse guard");
102
+ const latestVersion = latestTag.replace(/^v/, "");
103
+ if (packageJson.version === latestVersion) throw new Error(`package version ${packageJson.version} reuses latest release tag ${latestTag}`);
104
+ }
105
+
106
+ // ── commands ───────────────────────────────────────────────────────────────
107
+ async function runDoctor() {
108
+ try {
109
+ const { runDoctor } = await import("./platform-smoke/doctor.mjs");
110
+ await runDoctor(config);
111
+ } catch (err) {
112
+ if (err.code === "ERR_MODULE_NOT_FOUND") {
113
+ console.error("doctor module not found. Is scripts/platform-smoke/doctor.mjs present?");
114
+ } else {
115
+ console.error("doctor failed:", err.message);
116
+ }
117
+ process.exit(1);
118
+ }
119
+ }
120
+
121
+ async function runSuite(targetName, suiteName) {
122
+ try {
123
+ const { runTargetSuite } = await import("./platform-smoke/targets.mjs");
124
+ const result = await runTargetSuite(config, targetName, suiteName);
125
+ return result;
126
+ } catch (err) {
127
+ console.error(`suite ${suiteName} on ${targetName} exception:`, err.message);
128
+ return { ok: false, error: err.message };
129
+ }
130
+ }
131
+
132
+ async function runTarget(targetName, suites) {
133
+ try {
134
+ const { runTargetSuites } = await import("./platform-smoke/targets.mjs");
135
+ return await runTargetSuites(config, targetName, suites);
136
+ } catch (err) {
137
+ console.error(`target ${targetName} exception:`, err.message);
138
+ return { ok: false, error: err.message };
139
+ }
140
+ }
141
+
142
+ async function main() {
143
+ const args = parseArgs(process.argv);
144
+
145
+ if (!args.command || args.command === "help") {
146
+ printHelp();
147
+ process.exit(args.command === "help" ? 0 : 1);
148
+ }
149
+
150
+ if (!config) {
151
+ console.error("platform-smoke.config.mjs not found or failed to load");
152
+ process.exit(1);
153
+ }
154
+
155
+ if (args.command === "doctor") {
156
+ await runDoctor();
157
+ return;
158
+ }
159
+
160
+ if (args.command === "run") {
161
+ assertHostReleaseVersionGuard();
162
+ const targets = args.target
163
+ ? args.target.split(",").map((s) => s.trim()).filter(Boolean)
164
+ : config.requiredTargets;
165
+
166
+ const suites = args.suite
167
+ ? [args.suite]
168
+ : config.requiredSuites;
169
+
170
+ const targetRuns = targets.map(async (targetName) => {
171
+ console.log(`\n=== Target: ${targetName} ===`);
172
+ const result = args.suite
173
+ ? await runSuite(targetName, suites[0])
174
+ : await runTarget(targetName, suites);
175
+ return { targetName, result };
176
+ });
177
+ const results = await Promise.all(targetRuns);
178
+ const anyFailed = results.some(({ result }) => !result.ok);
179
+ if (anyFailed) {
180
+ console.log("\nOne or more suites failed. Check .artifacts/platform-smoke/ for details.");
181
+ process.exit(1);
182
+ }
183
+ return;
184
+ }
185
+
186
+ console.error(`Unknown command: ${args.command}`);
187
+ process.exit(1);
188
+ }
189
+
190
+ main().catch((err) => {
191
+ console.error(err);
192
+ process.exit(1);
193
+ });
@@ -6,6 +6,7 @@
6
6
  import { spawn } from "node:child_process";
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { resolve } from "node:path";
9
10
  import {
10
11
  installCursorMcpToolTimeoutOverride,
11
12
  restoreCursorMcpToolTimeoutOverride,
@@ -16,6 +17,12 @@ import { createScriptFail } from "./lib/cursor-script-fail.mjs";
16
17
  import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
17
18
 
18
19
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
20
+
21
+ function isMainModule() {
22
+ if (!process.argv[1]) return false;
23
+ const invoked = resolve(process.argv[1]);
24
+ return process.platform === "win32" ? SCRIPT_PATH.toLowerCase() === invoked.toLowerCase() : SCRIPT_PATH === invoked;
25
+ }
19
26
  const SCENARIOS = [
20
27
  { label: "with-all-settings", settingSources: ["all"] },
21
28
  { label: "with-all-settings+connect-override", settingSources: ["all"], installConnectOverride: true },
@@ -218,7 +225,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
218
225
  }
219
226
  }
220
227
 
221
- if (import.meta.url === new URL(process.argv[1], "file:").href) {
228
+ if (isMainModule()) {
222
229
  main().catch((error) => {
223
230
  const message = error instanceof Error ? error.message : String(error);
224
231
  fail(message, apiKeySecretsFromProcess());
@@ -165,7 +165,7 @@ function buildPiRpcEnv(baseEnv = process.env, nodePath = process.execPath) {
165
165
  }
166
166
 
167
167
  async function runPiRpcSmoke(sessionDir, piBin) {
168
- const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2.5", "--mode", "rpc", "--session-dir", sessionDir];
168
+ const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2-5", "--mode", "rpc", "--session-dir", sessionDir];
169
169
  const env = buildPiRpcEnv();
170
170
 
171
171
  const child = spawn(piBin, args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
@@ -245,7 +245,7 @@ exec %s
245
245
  "$TMUX_BIN" capture-pane -pt "$session" >"$capture" 2>/dev/null || true
246
246
  missing=""
247
247
  "$RG_BIN" -q "SUM=42" "$capture" || missing="${missing} SUM=42"
248
- "$RG_BIN" -q "\\(cursor\\) composer-2\\.5" "$capture" || missing="${missing} footer (cursor) composer-2.5"
248
+ "$RG_BIN" -q "\\(cursor\\) composer-2[-.]5" "$capture" || missing="${missing} footer (cursor) composer-2-5"
249
249
  if [[ -z "$missing" ]]; then
250
250
  "$TMUX_BIN" kill-session -t "$session" 2>/dev/null || true
251
251
  log "$name PASS"
@@ -394,7 +394,7 @@ fi
394
394
  PI_BASE=(
395
395
  "$PI_BIN" -e "$ROOT"
396
396
  --cursor-no-fast
397
- --model cursor/composer-2.5
397
+ --model cursor/composer-2-5
398
398
  )
399
399
 
400
400
  if [[ -z "${CURSOR_API_KEY:-}" ]]; then
@@ -416,7 +416,7 @@ log "partial live smoke: prereq, basic, default-settings, noninteractive-math, t
416
416
 
417
417
  if ! "${NONE_ENV[@]}" "${PI_BASE[@]}" --list-models cursor 2>"$SMOKE_DIR/prereq.stderr.txt" | tee "$SMOKE_DIR/prereq.models.txt" | "$RG_BIN" -q "composer-2\\.5"; then
418
418
  if ! model_listed "$SMOKE_DIR/prereq.stderr.txt"; then
419
- fail "cursor/composer-2.5 not listed"
419
+ fail "cursor/composer-2-5 not listed"
420
420
  fi
421
421
  fi
422
422
  log "prereq PASS"
@@ -14,7 +14,7 @@ const DEFAULT_HEIGHT = 45;
14
14
  const DEFAULT_WAIT_MS = 60_000;
15
15
  const DEFAULT_STARTUP_MS = 5_000;
16
16
  const DEFAULT_HISTORY_LINES = 3_000;
17
- const DEFAULT_MODEL = "cursor/composer-2.5";
17
+ const DEFAULT_MODEL = "cursor/composer-2-5";
18
18
  const DEFAULT_MODE = "plan";
19
19
  const DEFAULT_SETTING_SOURCES = "none";
20
20
  const DEBUG_ENV_NAMES = CURSOR_SDK_EVENT_DEBUG_ENV_NAMES;
@@ -33,6 +33,7 @@ class CursorPiToolBridgeToolExecutionAbortTracker {
33
33
 
34
34
  execution.onAbort = () => {
35
35
  this.cancelExecution(execution, "Cursor pi bridge tool execution was aborted");
36
+ this.abortExecution(execution);
36
37
  this.finish(toolCallId);
37
38
  };
38
39
  execution.signal?.addEventListener("abort", execution.onAbort, { once: true });
@@ -1,8 +1,10 @@
1
+ import { appendFileSync } from "node:fs";
1
2
  import { stableNameHash } from "./cursor-pi-tool-bridge-mcp.js";
2
3
  import { parseEnvBoolean } from "./cursor-env-boolean.js";
3
4
  import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
4
5
 
5
6
  export const CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV = "PI_CURSOR_PI_TOOL_BRIDGE_DEBUG";
7
+ export const CURSOR_PI_TOOL_BRIDGE_DEBUG_FILE_ENV = "PI_CURSOR_PI_TOOL_BRIDGE_DEBUG_FILE";
6
8
  export const CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX = "[pi-cursor-sdk:bridge]";
7
9
 
8
10
  export function resolveCursorPiToolBridgeDebugEnabled(env: Record<string, string | undefined> = process.env): boolean {
@@ -180,9 +182,18 @@ export function writeCursorPiToolBridgeDiagnostic(
180
182
  } catch {
181
183
  // Diagnostics must never affect bridge execution.
182
184
  }
185
+ const serialized = serializeCursorPiToolBridgeDiagnostic(event);
186
+ const debugFile = env[CURSOR_PI_TOOL_BRIDGE_DEBUG_FILE_ENV];
187
+ if (debugFile) {
188
+ try {
189
+ appendFileSync(debugFile, `${JSON.stringify(serialized)}\n`);
190
+ } catch {
191
+ // Diagnostics must never affect bridge execution.
192
+ }
193
+ }
183
194
  if (!resolveCursorPiToolBridgeDebugEnabled(env)) return;
184
195
  try {
185
- process.stderr.write(`${CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX} ${JSON.stringify(serializeCursorPiToolBridgeDiagnostic(event))}\n`);
196
+ process.stderr.write(`${CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX} ${JSON.stringify(serialized)}\n`);
186
197
  } catch {
187
198
  // Diagnostics must never affect bridge execution.
188
199
  }
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import {
2
3
  CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV,
3
4
  CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX,
@@ -44,6 +45,46 @@ export {
44
45
 
45
46
  let registeredCursorPiToolBridge: CursorPiToolBridgeRegistry | undefined;
46
47
 
48
+ const WINDOWS_BRIDGE_ABORT_ENV = "PI_CURSOR_BRIDGE_TOOL_CALL_ID";
49
+
50
+ function buildWindowsBridgeBashAbortCommand(command: string, marker: string): string {
51
+ return `export ${WINDOWS_BRIDGE_ABORT_ENV}=${marker}; ${command}`;
52
+ }
53
+
54
+ function installWindowsBridgeBashAbortMarker(event: { toolCallId: string; toolName: string; input: unknown }): string | undefined {
55
+ if (process.platform !== "win32" || event.toolName !== "bash") return undefined;
56
+ if (typeof event.input !== "object" || event.input === null || !("command" in event.input)) return undefined;
57
+ const input = event.input as { command?: unknown };
58
+ if (typeof input.command !== "string" || input.command.length === 0) return undefined;
59
+ const marker = event.toolCallId.replace(/[^A-Za-z0-9_.:-]/g, "_");
60
+ input.command = buildWindowsBridgeBashAbortCommand(input.command, marker);
61
+ return marker;
62
+ }
63
+
64
+ function killWindowsBridgeBashMarkerTree(marker: string | undefined): void {
65
+ if (process.platform !== "win32" || !marker) return;
66
+ const encodedMarker = Buffer.from(marker, "utf8").toString("base64");
67
+ const script = `
68
+ $marker = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedMarker}'))
69
+ $needle = '${WINDOWS_BRIDGE_ABORT_ENV}=' + $marker
70
+ $seen = @{}
71
+ function Stop-Tree([int]$ProcessId) {
72
+ if ($seen.ContainsKey($ProcessId)) { return }
73
+ $seen[$ProcessId] = $true
74
+ Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq $ProcessId } | ForEach-Object { Stop-Tree $_.ProcessId }
75
+ Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
76
+ }
77
+ Get-CimInstance Win32_Process -Filter "Name = 'bash.exe' OR Name = 'sh.exe'" |
78
+ Where-Object { $_.CommandLine -and $_.CommandLine.Contains($needle) } |
79
+ ForEach-Object { Stop-Tree $_.ProcessId }
80
+ `;
81
+ spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
82
+ stdio: "ignore",
83
+ timeout: 3_000,
84
+ windowsHide: true,
85
+ });
86
+ }
87
+
47
88
  export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
48
89
  bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
49
90
  void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
@@ -51,10 +92,12 @@ export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi):
51
92
  registeredCursorPiToolBridge = bridge;
52
93
  pi.on("tool_call", (event, ctx) => {
53
94
  if (!bridge.hasPendingPiToolCallId(event.toolCallId)) return undefined;
95
+ const windowsAbortMarker = installWindowsBridgeBashAbortMarker(event);
54
96
  const trackingStarted = bridgeToolExecutionAbortTracker.track(event.toolCallId, {
55
97
  signal: ctx.signal,
56
98
  abort: () => {
57
- void ctx.abort();
99
+ ctx.abort();
100
+ killWindowsBridgeBashMarkerTree(windowsAbortMarker);
58
101
  },
59
102
  cancelPending: (reason) => {
60
103
  bridge.cancelPendingPiToolCallId(event.toolCallId, reason);
@@ -100,6 +143,8 @@ export const __testUtils = {
100
143
  getActiveBridgeToolExecutionAbortCount() {
101
144
  return bridgeToolExecutionAbortTracker.getActiveCount();
102
145
  },
146
+ buildWindowsBridgeBashAbortCommandForTests: buildWindowsBridgeBashAbortCommand,
147
+ installWindowsBridgeBashAbortMarkerForTests: installWindowsBridgeBashAbortMarker,
103
148
  emitBridgeToolExecutionProcessAbortSignalForTests(signal: NodeJS.Signals) {
104
149
  bridgeToolExecutionAbortTracker.emitProcessAbortSignalForTests(signal);
105
150
  },
@@ -47,9 +47,14 @@ function isUnauthenticatedConnectCode(code: unknown): boolean {
47
47
  return code === 16 || (typeof code === "string" && /^(?:16|unauthenticated)$/i.test(code));
48
48
  }
49
49
 
50
+ function isCursorExtensionConnectStack(stack: string): boolean {
51
+ return stack.includes("@connectrpc/connect-node") && /(?:^|[\\/])pi-cursor-sdk(?:[\\/]|$)/.test(stack);
52
+ }
53
+
50
54
  function getCursorConnectSource(error: unknown, record: Record<string, unknown> | undefined): CursorConnectErrorSource {
51
55
  const stack = getErrorStack(error, record);
52
56
  if (stack.includes("@cursor/sdk")) return "cursor-sdk-stack";
57
+ if (isCursorExtensionConnectStack(stack)) return "cursor-extension-connect-stack";
53
58
  const details = Array.isArray(record?.details) ? record.details : [];
54
59
  const hasCursorBackendDetails = details.some((detail) => {
55
60
  const type = getErrorStringField(asRecord(detail), "type");
@@ -58,11 +63,16 @@ function getCursorConnectSource(error: unknown, record: Record<string, unknown>
58
63
  return hasCursorBackendDetails ? "cursor-backend-details" : "generic-connect";
59
64
  }
60
65
 
61
- export type CursorConnectErrorSource = "cursor-sdk-stack" | "cursor-backend-details" | "generic-connect";
66
+ export type CursorConnectErrorSource =
67
+ | "cursor-sdk-stack"
68
+ | "cursor-extension-connect-stack"
69
+ | "cursor-backend-details"
70
+ | "generic-connect";
62
71
 
63
72
  export type CursorConnectErrorClassification =
64
73
  | { kind: "abort"; source: "cursor-sdk-stack" }
65
- | { kind: "unauthenticated"; source: CursorConnectErrorSource };
74
+ | { kind: "unauthenticated"; source: CursorConnectErrorSource }
75
+ | { kind: "network"; source: CursorConnectErrorSource };
66
76
 
67
77
  export function classifyCursorConnectError(error: unknown): CursorConnectErrorClassification | undefined {
68
78
  const record = asRecord(error);
@@ -89,6 +99,12 @@ export function classifyCursorConnectError(error: unknown): CursorConnectErrorCl
89
99
  return { kind: "unauthenticated", source: getCursorConnectSource(error, record) };
90
100
  }
91
101
 
102
+ const causeCode = getErrorStringField(cause, "code");
103
+ const causeSyscall = getErrorStringField(cause, "syscall");
104
+ if (isLikelyNetworkTimeout(`${message}\n${rawMessage}\n${causeCode ?? ""}\n${causeSyscall ?? ""}`)) {
105
+ return { kind: "network", source: getCursorConnectSource(error, record) };
106
+ }
107
+
92
108
  return undefined;
93
109
  }
94
110