pi-cursor-sdk 0.1.20 → 0.1.21

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 (88) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +49 -9
  3. package/docs/cursor-dogfood-checklist.md +57 -0
  4. package/docs/cursor-live-smoke-checklist.md +115 -9
  5. package/docs/cursor-model-ux-spec.md +57 -17
  6. package/docs/cursor-native-tool-replay.md +15 -7
  7. package/docs/cursor-native-tool-visual-audit.md +104 -59
  8. package/docs/cursor-testing-lessons.md +8 -3
  9. package/docs/cursor-tool-surfaces.md +69 -0
  10. package/package.json +34 -10
  11. package/scripts/debug-provider-events.d.mts +59 -0
  12. package/scripts/debug-provider-events.mjs +70 -175
  13. package/scripts/debug-sdk-events.d.mts +90 -0
  14. package/scripts/debug-sdk-events.mjs +36 -98
  15. package/scripts/fixtures/plan-strip-shim/index.ts +12 -0
  16. package/scripts/isolated-cursor-smoke.sh +264 -102
  17. package/scripts/lib/cursor-child-process.d.mts +10 -0
  18. package/scripts/lib/cursor-child-process.mjs +50 -0
  19. package/scripts/lib/cursor-cli-args.d.mts +63 -0
  20. package/scripts/lib/cursor-cli-args.mjs +129 -0
  21. package/scripts/lib/cursor-script-fail.d.mts +1 -0
  22. package/scripts/lib/cursor-script-fail.mjs +13 -0
  23. package/scripts/lib/cursor-sdk-output-filter.d.mts +5 -0
  24. package/scripts/lib/cursor-smoke-env.d.mts +38 -0
  25. package/scripts/lib/cursor-smoke-env.mjs +81 -0
  26. package/scripts/lib/cursor-smoke-shell.sh +174 -0
  27. package/scripts/lib/cursor-visual-render.d.mts +15 -0
  28. package/scripts/lib/cursor-visual-render.mjs +131 -0
  29. package/scripts/probe-mcp-coldstart.mjs +20 -38
  30. package/scripts/refresh-cursor-model-snapshots.mjs +29 -65
  31. package/scripts/steering-rpc-smoke.mjs +170 -65
  32. package/scripts/tmux-live-smoke.sh +152 -98
  33. package/scripts/visual-tui-smoke.mjs +659 -0
  34. package/shared/cursor-sdk-event-debug-env.d.mts +12 -0
  35. package/shared/cursor-sdk-event-debug-env.mjs +13 -0
  36. package/shared/cursor-sensitive-text.d.mts +1 -0
  37. package/{scripts/lib/cursor-probe-utils.mjs → shared/cursor-sensitive-text.mjs} +1 -13
  38. package/shared/cursor-setting-sources.d.mts +5 -0
  39. package/shared/cursor-setting-sources.mjs +22 -0
  40. package/src/context.ts +21 -12
  41. package/src/cursor-bridge-contract.ts +1 -3
  42. package/src/cursor-incomplete-tool-visibility.ts +22 -5
  43. package/src/cursor-native-tool-display-registration.ts +63 -27
  44. package/src/cursor-native-tool-display-replay.ts +246 -144
  45. package/src/cursor-native-tool-display-state.ts +2 -0
  46. package/src/cursor-native-tool-display-tools.ts +149 -41
  47. package/src/cursor-provider-live-run-drain.ts +1 -52
  48. package/src/cursor-provider-run-finalizer.ts +235 -0
  49. package/src/cursor-provider-run-outcome.ts +149 -0
  50. package/src/cursor-provider-turn-api-key.ts +8 -0
  51. package/src/cursor-provider-turn-coordinator.ts +98 -446
  52. package/src/cursor-provider-turn-display-router.ts +216 -0
  53. package/src/cursor-provider-turn-emit.ts +59 -0
  54. package/src/cursor-provider-turn-finalize.ts +119 -0
  55. package/src/cursor-provider-turn-lifecycle-emitter.ts +97 -0
  56. package/src/cursor-provider-turn-message-offset.ts +15 -0
  57. package/src/cursor-provider-turn-prepare.ts +216 -0
  58. package/src/cursor-provider-turn-runner.ts +138 -0
  59. package/src/cursor-provider-turn-sdk-normalizer.ts +88 -0
  60. package/src/cursor-provider-turn-send.ts +103 -0
  61. package/src/cursor-provider-turn-shell-output.ts +107 -0
  62. package/src/cursor-provider-turn-tool-ledger.ts +126 -0
  63. package/src/cursor-provider-turn-types.ts +87 -0
  64. package/src/cursor-provider.ts +16 -504
  65. package/src/cursor-replay-activity-builders.ts +276 -0
  66. package/src/cursor-replay-source-names.ts +33 -0
  67. package/src/cursor-replay-summary-args.ts +191 -0
  68. package/src/cursor-replay-tool-details.ts +464 -0
  69. package/src/cursor-run-final-text.ts +56 -0
  70. package/src/cursor-sdk-abort-error-guard.ts +4 -0
  71. package/src/cursor-sdk-event-debug-constants.ts +14 -5
  72. package/src/cursor-sdk-event-debug.ts +2 -1
  73. package/src/cursor-sensitive-text.ts +3 -36
  74. package/src/cursor-session-agent.ts +3 -1
  75. package/src/cursor-setting-sources.ts +7 -10
  76. package/src/cursor-state.ts +232 -28
  77. package/src/cursor-tool-lifecycle.ts +9 -8
  78. package/src/cursor-tool-manifest.ts +41 -0
  79. package/src/cursor-tool-names.ts +18 -106
  80. package/src/cursor-tool-presentation-registry.ts +556 -0
  81. package/src/cursor-tool-transcript.ts +1 -1
  82. package/src/cursor-tool-visibility.ts +3 -27
  83. package/src/cursor-transcript-tool-formatters.ts +0 -59
  84. package/src/cursor-transcript-tool-specs.ts +158 -233
  85. package/src/cursor-transcript-utils.ts +0 -44
  86. package/src/cursor-web-tool-activity.ts +10 -60
  87. package/src/cursor-web-tool-args.ts +39 -0
  88. package/src/index.ts +4 -10
@@ -0,0 +1,659 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { delimiter, dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { commonBooleanFlag, commonRepeatStringFlag, parseArgv } from "./lib/cursor-cli-args.mjs";
8
+ import { buildCursorSmokeEnvPlan, CURSOR_SDK_EVENT_DEBUG_ENV_NAMES, sealedNodePath } from "./lib/cursor-smoke-env.mjs";
9
+ import { buildTerminalHtml, writeTerminalScreenshot } from "./lib/cursor-visual-render.mjs";
10
+
11
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
+ const DEFAULT_WIDTH = 150;
13
+ const DEFAULT_HEIGHT = 45;
14
+ const DEFAULT_WAIT_MS = 60_000;
15
+ const DEFAULT_STARTUP_MS = 5_000;
16
+ const DEFAULT_HISTORY_LINES = 3_000;
17
+ const DEFAULT_MODEL = "cursor/composer-2.5";
18
+ const DEFAULT_MODE = "plan";
19
+ const DEFAULT_SETTING_SOURCES = "none";
20
+ const DEBUG_ENV_NAMES = CURSOR_SDK_EVENT_DEBUG_ENV_NAMES;
21
+
22
+ const EXIT_FAILURE = 1;
23
+ const EXIT_USAGE = 2;
24
+
25
+ function printHelp() {
26
+ console.log(`Canonical offscreen TUI visual smoke runner for pi-cursor-sdk.
27
+
28
+ Usage:
29
+ node scripts/visual-tui-smoke.mjs --label LABEL --prompt PROMPT [options]
30
+ npm run smoke:visual -- --label LABEL --prompt PROMPT [options]
31
+
32
+ Required:
33
+ --label LABEL Artifact filename prefix. Sanitized for paths.
34
+ --prompt PROMPT Prompt to paste into the interactive pi TUI.
35
+ Use --prompt-file PATH for multi-line prompts.
36
+
37
+ Common options:
38
+ --ext PATH Extension repo to load with pi -e. Default: repo root.
39
+ --cwd PATH Working directory for the pi session. Default: current directory.
40
+ --out-dir PATH Artifact directory. Default: /tmp/pi-cursor-sdk-visual-smoke-<timestamp>.
41
+ --wait-ms N Milliseconds to wait after sending the prompt. Default: ${DEFAULT_WAIT_MS}.
42
+ --startup-ms N Milliseconds to wait before pasting the prompt. Default: ${DEFAULT_STARTUP_MS}.
43
+ --model MODEL Cursor model. Default: ${DEFAULT_MODEL}.
44
+ --mode agent|plan Cursor SDK mode. Default: ${DEFAULT_MODE}.
45
+ --session-dir PATH pi session directory. Default: <out-dir>/<label>.session.
46
+ --session-id ID pi session id. Default: visual-<label>-<timestamp>.
47
+ --width N PTY columns. Default: ${DEFAULT_WIDTH}.
48
+ --height N PTY rows. Default: ${DEFAULT_HEIGHT}.
49
+ --history-lines N tmux capture history lines. Default: ${DEFAULT_HISTORY_LINES}.
50
+ --setting-sources VALUE Cursor setting sources. Default: ${DEFAULT_SETTING_SOURCES}.
51
+ --bridge Opt in to the pi tool bridge for bridge-specific visual audits.
52
+ --expose-builtin-tools Opt in to exposing overlapping built-in pi tools to Cursor. Requires --bridge.
53
+ --event-debug Set PI_CURSOR_SDK_EVENT_DEBUG=1 and write debug artifacts under <out-dir>.
54
+ --leftover-pattern REGEX After capture, fail if a process command still matches REGEX. Repeatable.
55
+ --no-screenshot Write .ansi/.txt/.html/.jsonl.path only; use agent_browser manually.
56
+ --self-test Run the fake-PATH/env isolation probe without launching pi.
57
+ -h, --help Show this help.
58
+
59
+ Native replay isolation defaults:
60
+ PI_CURSOR_NATIVE_TOOL_DISPLAY=1
61
+ PI_CURSOR_REGISTER_NATIVE_TOOLS=1
62
+ PI_CURSOR_SETTING_SOURCES=none
63
+ PI_CURSOR_PI_TOOL_BRIDGE=0
64
+ PI_CURSOR_EXPOSE_BUILTIN_TOOLS=0
65
+ TERM=xterm-256color
66
+ Debug artifact env is cleared before each run; --event-debug sets a deterministic debug dir.
67
+
68
+ Artifacts written:
69
+ <label>.ansi Raw tmux ANSI capture.
70
+ <label>.txt Plain tmux text capture.
71
+ <label>.html Self-contained browser/xterm render.
72
+ <label>.png Browser-rendered screenshot, unless --no-screenshot.
73
+ <label>.jsonl.path Latest persisted pi session JSONL path.
74
+
75
+ Prerequisites:
76
+ - pi, node, tmux, and npm-installed dev dependencies on PATH / in node_modules.
77
+ - The runner resolves pi/tmux from the parent PATH, uses process.execPath for node, and seals pi-shim PATH for prereq checks and tmux.
78
+ - For automatic PNG capture, install a Playwright browser once when needed:
79
+ npx playwright install chromium
80
+ - In the pi agent harness, --no-screenshot plus agent_browser on the generated HTML is also acceptable.
81
+
82
+ Examples:
83
+ npm run smoke:visual -- \\
84
+ --label read-package \\
85
+ --prompt 'Read ./package.json using the read/file tool, then answer with the package name.' \\
86
+ --out-dir /tmp/pi-cursor-sdk-visual-review
87
+
88
+ npm run smoke:visual -- \\
89
+ --label after-shell-success \\
90
+ --ext /path/to/pi-cursor-sdk \\
91
+ --cwd /path/to/test-workspace \\
92
+ --prompt 'Run a safe shell command that prints "cursor visual smoke" and report the output.' \\
93
+ --wait-ms 60000 \\
94
+ --out-dir /tmp/pi-cursor-sdk-visual-review
95
+
96
+ Exit codes:
97
+ 0 capture and required artifacts were written
98
+ 1 TUI run, JSONL discovery, HTML render, or screenshot failed
99
+ 2 invalid usage or missing prerequisite command
100
+ `);
101
+ }
102
+
103
+ function fail(message, code = EXIT_FAILURE) {
104
+ console.error(`[visual-smoke] ${message}`);
105
+ process.exit(code);
106
+ }
107
+
108
+ function timestamp() {
109
+ return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
110
+ }
111
+
112
+ function parseInteger(value, name) {
113
+ if (!/^\d+$/.test(value)) fail(`${name} must be a positive integer: ${value}`, EXIT_USAGE);
114
+ const parsed = Number(value);
115
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) fail(`${name} must be a positive integer: ${value}`, EXIT_USAGE);
116
+ return parsed;
117
+ }
118
+
119
+ function readPromptFile(path) {
120
+ try {
121
+ return readFileSync(path, "utf8");
122
+ } catch (error) {
123
+ fail(`failed to read --prompt-file ${path}: ${error instanceof Error ? error.message : String(error)}`, EXIT_USAGE);
124
+ }
125
+ }
126
+
127
+ function parseMode(value) {
128
+ if (value !== "agent" && value !== "plan") fail(`--mode must be agent or plan: ${value}`, EXIT_USAGE);
129
+ return value;
130
+ }
131
+
132
+ function parseSettingSources(value) {
133
+ if (!value.trim()) fail("--setting-sources requires a non-empty value", EXIT_USAGE);
134
+ return value;
135
+ }
136
+
137
+ function parseArgs(argv) {
138
+ const options = parseArgv(argv, {
139
+ defaults: {
140
+ ext: ROOT,
141
+ cwd: process.cwd(),
142
+ waitMs: DEFAULT_WAIT_MS,
143
+ startupMs: DEFAULT_STARTUP_MS,
144
+ model: DEFAULT_MODEL,
145
+ mode: DEFAULT_MODE,
146
+ settingSources: DEFAULT_SETTING_SOURCES,
147
+ bridge: false,
148
+ exposeBuiltinTools: false,
149
+ leftoverPatterns: [],
150
+ width: DEFAULT_WIDTH,
151
+ height: DEFAULT_HEIGHT,
152
+ historyLines: DEFAULT_HISTORY_LINES,
153
+ eventDebug: false,
154
+ screenshot: true,
155
+ selfTest: false,
156
+ },
157
+ flags: {
158
+ label: { names: ["--label"] },
159
+ prompt: { names: ["--prompt", "--prompt-file"], allowDashValue: true, assign: (value, flagName) => (flagName === "--prompt-file" ? readPromptFile(value) : value) },
160
+ ext: { names: ["--ext"], assign: (value) => resolve(value) },
161
+ cwd: { names: ["--cwd"], assign: (value) => resolve(value) },
162
+ outDir: { names: ["--out-dir"], assign: (value) => resolve(value) },
163
+ waitMs: { names: ["--wait-ms"], assign: (value) => parseInteger(value, "--wait-ms") },
164
+ startupMs: { names: ["--startup-ms"], assign: (value) => parseInteger(value, "--startup-ms") },
165
+ model: { names: ["--model"] },
166
+ mode: { names: ["--mode"], assign: parseMode },
167
+ sessionDir: { names: ["--session-dir"], assign: (value) => resolve(value) },
168
+ sessionId: { names: ["--session-id"] },
169
+ width: { names: ["--width"], assign: (value) => parseInteger(value, "--width") },
170
+ height: { names: ["--height"], assign: (value) => parseInteger(value, "--height") },
171
+ historyLines: { names: ["--history-lines"], assign: (value) => parseInteger(value, "--history-lines") },
172
+ settingSources: { names: ["--setting-sources"], assign: parseSettingSources },
173
+ bridge: commonBooleanFlag("--bridge"),
174
+ exposeBuiltinTools: commonBooleanFlag("--expose-builtin-tools"),
175
+ eventDebug: commonBooleanFlag("--event-debug"),
176
+ leftoverPatterns: { ...commonRepeatStringFlag("--leftover-pattern"), allowDashValue: true },
177
+ screenshot: { ...commonBooleanFlag("--no-screenshot"), assign: () => false },
178
+ selfTest: commonBooleanFlag("--self-test"),
179
+ },
180
+ fail: (message) => fail(message, EXIT_USAGE),
181
+ });
182
+
183
+ if (options.help) {
184
+ printHelp();
185
+ process.exit(0);
186
+ }
187
+ if (options.selfTest) return options;
188
+ if (!options.label?.trim()) fail("--label is required", EXIT_USAGE);
189
+ if (!options.prompt?.trim()) fail("--prompt or --prompt-file is required", EXIT_USAGE);
190
+ if (options.exposeBuiltinTools && !options.bridge) fail("--expose-builtin-tools requires --bridge", EXIT_USAGE);
191
+
192
+ options.safeLabel = sanitizeLabel(options.label);
193
+ options.outDir ??= resolve(`/tmp/pi-cursor-sdk-visual-smoke-${timestamp()}`);
194
+ options.sessionDir ??= resolve(options.outDir, `${options.safeLabel}.session`);
195
+ options.sessionId ??= `visual-${options.safeLabel}-${Date.now()}`;
196
+ return options;
197
+ }
198
+
199
+ function sanitizeLabel(label) {
200
+ const safe = label.trim().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
201
+ return safe || "visual-smoke";
202
+ }
203
+
204
+ function shellQuote(value) {
205
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
206
+ }
207
+
208
+ function run(command, args, options = {}) {
209
+ const result = spawnSync(command, args, {
210
+ encoding: options.input === undefined ? "utf8" : undefined,
211
+ env: options.env,
212
+ input: options.input,
213
+ stdio: options.stdio ?? (options.input === undefined ? "pipe" : ["pipe", "pipe", "pipe"]),
214
+ });
215
+ if (result.error) {
216
+ throw result.error;
217
+ }
218
+ return result;
219
+ }
220
+
221
+ function isExecutable(path) {
222
+ try {
223
+ accessSync(path, constants.X_OK);
224
+ return true;
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ function resolveCommand(command, envPath = process.env.PATH ?? "") {
231
+ if (!command.trim()) fail("empty command name", EXIT_USAGE);
232
+ if (command.includes("/")) {
233
+ const path = resolve(command);
234
+ if (!isExecutable(path)) fail(`${command} is not executable`, EXIT_USAGE);
235
+ return path;
236
+ }
237
+ for (const entry of envPath.split(delimiter)) {
238
+ if (!entry) continue;
239
+ const candidate = resolve(entry, command);
240
+ if (isExecutable(candidate)) return candidate;
241
+ }
242
+ fail(`${command} is required on PATH`, EXIT_USAGE);
243
+ }
244
+
245
+ function requireCommand(command, options = {}) {
246
+ const path = resolveCommand(command, options.envPath ?? process.env.PATH ?? "");
247
+ const args = command === "tmux" ? ["-V"] : ["--version"];
248
+ const result = run(path, args, { env: options.env });
249
+ if (result.status !== 0) fail(`${command} failed prerequisite check at ${path}`, EXIT_USAGE);
250
+ return path;
251
+ }
252
+
253
+ function requireNode() {
254
+ const path = process.execPath;
255
+ if (!path || !isExecutable(path)) fail(`current Node executable is not executable: ${path || "<empty>"}`, EXIT_USAGE);
256
+ return path;
257
+ }
258
+
259
+ function resolveShell(shell) {
260
+ if (shell.startsWith("/")) {
261
+ if (!isExecutable(shell)) fail(`shell is not executable: ${shell}`, EXIT_USAGE);
262
+ return shell;
263
+ }
264
+ return resolveCommand(shell);
265
+ }
266
+
267
+ function sleep(ms) {
268
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
269
+ }
270
+
271
+ function writeUtf8(path, text) {
272
+ mkdirSync(dirname(path), { recursive: true });
273
+ writeFileSync(path, text, "utf8");
274
+ }
275
+
276
+ function capturePane(tmuxBin, sessionName, args) {
277
+ const result = run(tmuxBin, ["capture-pane", ...args, "-t", sessionName]);
278
+ if (result.status !== 0) {
279
+ throw new Error(result.stderr?.toString().trim() || `tmux capture-pane exited ${result.status}`);
280
+ }
281
+ return result.stdout.toString();
282
+ }
283
+
284
+ function collectJsonlMtimes(root) {
285
+ const files = [];
286
+ function visit(dir) {
287
+ let entries;
288
+ try {
289
+ entries = readdirSync(dir, { withFileTypes: true });
290
+ } catch {
291
+ return;
292
+ }
293
+ for (const entry of entries) {
294
+ const path = resolve(dir, entry.name);
295
+ if (entry.isDirectory()) {
296
+ visit(path);
297
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
298
+ files.push({ path, mtimeMs: statSync(path).mtimeMs });
299
+ }
300
+ }
301
+ }
302
+ visit(root);
303
+ return files;
304
+ }
305
+
306
+ function snapshotJsonlMtimes(root) {
307
+ return new Map(collectJsonlMtimes(root).map(({ path, mtimeMs }) => [path, mtimeMs]));
308
+ }
309
+
310
+ function findLatestJsonl(root, { sinceMs = 0, previousMtimes = new Map() } = {}) {
311
+ const matches = [];
312
+ for (const file of collectJsonlMtimes(root)) {
313
+ const previousMtimeMs = previousMtimes.get(file.path);
314
+ if (previousMtimeMs === undefined ? file.mtimeMs >= sinceMs : file.mtimeMs > previousMtimeMs) matches.push(file);
315
+ }
316
+ matches.sort((a, b) => b.mtimeMs - a.mtimeMs);
317
+ return matches[0]?.path;
318
+ }
319
+
320
+ function checkLeftovers(patterns) {
321
+ if (patterns.length === 0) return;
322
+ const result = run("ps", ["-axo", "pid,etime,command"]);
323
+ if (result.status !== 0) {
324
+ throw new Error(`failed to inspect leftover processes: ${result.stderr?.toString().trim() || result.status}`);
325
+ }
326
+ const lines = result.stdout
327
+ .toString()
328
+ .split("\n")
329
+ .filter((line) => line.trim() && !line.includes("scripts/visual-tui-smoke.mjs") && !line.includes("--leftover-pattern"));
330
+ const matches = [];
331
+ for (const pattern of patterns) {
332
+ let regex;
333
+ try {
334
+ regex = new RegExp(pattern);
335
+ } catch (error) {
336
+ throw new Error(`invalid --leftover-pattern ${pattern}: ${error instanceof Error ? error.message : String(error)}`);
337
+ }
338
+ for (const line of lines) {
339
+ if (regex.test(line)) matches.push(line.trim());
340
+ }
341
+ }
342
+ if (matches.length > 0) {
343
+ throw new Error(`leftover process pattern matched after visual smoke:\n${matches.join("\n")}`);
344
+ }
345
+ }
346
+
347
+ function buildLaunchPlan(options, commands, shell) {
348
+ const smokeEnvPlan = buildCursorSmokeEnvPlan({
349
+ baseEnv: process.env,
350
+ nodePath: commands.node,
351
+ settingSources: options.settingSources,
352
+ nativeToolDisplay: true,
353
+ registerNativeTools: true,
354
+ bridge: options.bridge,
355
+ exposeBuiltinTools: options.exposeBuiltinTools,
356
+ term: "xterm-256color",
357
+ eventDebugDir: options.eventDebug ? resolve(options.outDir, `${options.safeLabel ?? "visual-smoke"}.cursor-sdk-events`) : undefined,
358
+ });
359
+ const sealedPath = commands.sealedPath ?? smokeEnvPlan.sealedPath;
360
+ const envAssignments = smokeEnvPlan.envEntries;
361
+ const clearEnvNames = smokeEnvPlan.clearEnvNames;
362
+ const command = [
363
+ ...envAssignments.map(([name, value]) => `${name}=${shellQuote(value)}`),
364
+ "exec",
365
+ shellQuote(commands.pi),
366
+ "-e", shellQuote(options.ext),
367
+ "--cursor-no-fast",
368
+ "--cursor-mode", shellQuote(options.mode),
369
+ "--session-dir", shellQuote(options.sessionDir),
370
+ "--session-id", shellQuote(options.sessionId),
371
+ "--model", shellQuote(options.model),
372
+ ].join(" ");
373
+ const clearLines = clearEnvNames.map((name) => `unset ${name}`).join("\n");
374
+ const script = [
375
+ `export PATH=${shellQuote(sealedPath)}`,
376
+ clearLines,
377
+ `cd ${shellQuote(options.cwd)} || exit 97`,
378
+ command,
379
+ ]
380
+ .filter(Boolean)
381
+ .join("\n");
382
+ return { command, clearEnvNames, envAssignments, script, shell };
383
+ }
384
+
385
+ function runVisualSmoke(options) {
386
+ const node = requireNode();
387
+ const sealedPath = sealedNodePath(node);
388
+ const commands = {
389
+ pi: requireCommand("pi", { env: { ...process.env, PATH: sealedPath } }),
390
+ node,
391
+ sealedPath,
392
+ tmux: requireCommand("tmux"),
393
+ };
394
+
395
+ mkdirSync(options.outDir, { recursive: true });
396
+ mkdirSync(options.sessionDir, { recursive: true });
397
+
398
+ const sessionName = `pi-visual-${options.safeLabel}-${process.pid}`;
399
+ const bufferName = `pi-visual-prompt-${process.pid}`;
400
+ const shell = resolveShell(process.env.SHELL || "/bin/bash");
401
+ const { script } = buildLaunchPlan(options, commands, shell);
402
+
403
+ console.log(`[visual-smoke] out-dir=${options.outDir}`);
404
+ console.log(`[visual-smoke] session-dir=${options.sessionDir}`);
405
+ console.log(`[visual-smoke] tmux-session=${sessionName}`);
406
+ console.log(`[visual-smoke] pi=${commands.pi}`);
407
+ console.log(`[visual-smoke] node=${commands.node}`);
408
+ console.log(`[visual-smoke] tmux=${commands.tmux}`);
409
+ console.log(
410
+ `[visual-smoke] native-replay-only=${!options.bridge && !options.exposeBuiltinTools && options.settingSources === DEFAULT_SETTING_SOURCES ? "true" : "false"}`,
411
+ );
412
+
413
+ let sessionStarted = false;
414
+ let bufferLoaded = false;
415
+ const jsonlMtimesBeforeRun = snapshotJsonlMtimes(options.sessionDir);
416
+ const runStartedAtMs = Date.now();
417
+ try {
418
+ const start = run(commands.tmux, ["new-session", "-d", "-s", sessionName, "-x", String(options.width), "-y", String(options.height), "--", shell, "-lc", script]);
419
+ if (start.status !== 0) throw new Error(`tmux new-session failed: ${start.stderr?.toString().trim() || start.status}`);
420
+ sessionStarted = true;
421
+
422
+ sleep(options.startupMs);
423
+ const load = run(commands.tmux, ["load-buffer", "-b", bufferName, "-"], { input: Buffer.from(options.prompt, "utf8") });
424
+ if (load.status !== 0) throw new Error(`tmux load-buffer failed: ${load.stderr?.toString().trim() || load.status}`);
425
+ bufferLoaded = true;
426
+ try {
427
+ const paste = run(commands.tmux, ["paste-buffer", "-b", bufferName, "-t", sessionName]);
428
+ if (paste.status !== 0) throw new Error(`tmux paste-buffer failed: ${paste.stderr?.toString().trim() || paste.status}`);
429
+ // Give bracketed paste handling a moment to finish before submitting.
430
+ sleep(250);
431
+ const enter = run(commands.tmux, ["send-keys", "-t", sessionName, "Enter"]);
432
+ if (enter.status !== 0) throw new Error(`tmux send-keys failed: ${enter.stderr?.toString().trim() || enter.status}`);
433
+ } finally {
434
+ run(commands.tmux, ["delete-buffer", "-b", bufferName]);
435
+ bufferLoaded = false;
436
+ }
437
+
438
+ sleep(options.waitMs);
439
+
440
+ const historyStart = `-${options.historyLines}`;
441
+ const ansi = capturePane(commands.tmux, sessionName, ["-e", "-p", "-S", historyStart]);
442
+ const plain = capturePane(commands.tmux, sessionName, ["-p", "-S", historyStart]);
443
+
444
+ const base = resolve(options.outDir, options.safeLabel);
445
+ const ansiPath = `${base}.ansi`;
446
+ const textPath = `${base}.txt`;
447
+ const htmlPath = `${base}.html`;
448
+ const pngPath = `${base}.png`;
449
+ const jsonlPathFile = `${base}.jsonl.path`;
450
+
451
+ writeUtf8(ansiPath, ansi);
452
+ writeUtf8(textPath, plain);
453
+ writeUtf8(htmlPath, buildTerminalHtml({ ansi, plain, options }));
454
+
455
+ const jsonlPath = findLatestJsonl(options.sessionDir, { sinceMs: runStartedAtMs, previousMtimes: jsonlMtimesBeforeRun });
456
+ if (!jsonlPath) throw new Error(`no current-run persisted .jsonl found under ${options.sessionDir}`);
457
+ writeUtf8(jsonlPathFile, `${jsonlPath}\n`);
458
+
459
+ return { ansiPath, textPath, htmlPath, pngPath, jsonlPathFile, jsonlPath };
460
+ } finally {
461
+ if (bufferLoaded) run(commands.tmux, ["delete-buffer", "-b", bufferName]);
462
+ if (sessionStarted) run(commands.tmux, ["kill-session", "-t", sessionName]);
463
+ }
464
+ }
465
+
466
+ function assertSelfTest(condition, message) {
467
+ if (!condition) throw new Error(`self-test failed: ${message}`);
468
+ }
469
+
470
+ function envMap(assignments) {
471
+ return new Map(assignments.map(([name, value]) => [name, value]));
472
+ }
473
+
474
+ function parseEnvCapture(path) {
475
+ return new Map(
476
+ readFileSync(path, "utf8")
477
+ .split("\n")
478
+ .filter(Boolean)
479
+ .map((line) => {
480
+ const index = line.indexOf("=");
481
+ return index === -1 ? [line, ""] : [line.slice(0, index), line.slice(index + 1)];
482
+ }),
483
+ );
484
+ }
485
+
486
+ function runSelfTest() {
487
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
488
+ try {
489
+ const binDir = join(tempDir, "bin");
490
+ mkdirSync(binDir, { recursive: true });
491
+ const fakePi = join(binDir, "pi");
492
+ const fakeNode = join(binDir, "node");
493
+ const fakeNodeMarker = join(tempDir, "fake-node-used");
494
+ const envCapture = join(tempDir, "fake-pi.env");
495
+ writeFileSync(
496
+ fakePi,
497
+ `#!/usr/bin/env node\nconst { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(envCapture)}, Object.entries(process.env).map(([key, value]) => key + "=" + (value ?? "")).join("\\n") + "\\n", "utf8");\n`,
498
+ "utf8",
499
+ );
500
+ writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${shellQuote(fakeNodeMarker)}\nexit 99\n`, "utf8");
501
+ chmodSync(fakePi, 0o755);
502
+ chmodSync(fakeNode, 0o755);
503
+
504
+ const promptFile = join(tempDir, "prompt.txt");
505
+ writeFileSync(promptFile, "file prompt", "utf8");
506
+ assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt-file", promptFile, "--prompt", "inline prompt"]).prompt === "inline prompt", "--prompt should override an earlier --prompt-file");
507
+ assertSelfTest(parseArgs(["--label", "prompt-dash", "--prompt", "--starts-with-dash"]).prompt === "--starts-with-dash", "--prompt should accept dash-prefixed free-form text");
508
+ assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt", "inline prompt", "--prompt-file", promptFile]).prompt === "file prompt", "--prompt-file should override an earlier --prompt");
509
+
510
+ const jsonlDir = join(tempDir, "jsonl-filter");
511
+ mkdirSync(jsonlDir, { recursive: true });
512
+ const staleJsonl = join(jsonlDir, "stale.jsonl");
513
+ const freshJsonl = join(jsonlDir, "fresh.jsonl");
514
+ writeFileSync(staleJsonl, "{}\n", "utf8");
515
+ utimesSync(staleJsonl, new Date(1_000), new Date(1_000));
516
+ const previousJsonlMtimes = snapshotJsonlMtimes(jsonlDir);
517
+ writeFileSync(freshJsonl, "{}\n", "utf8");
518
+ utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
519
+ assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
520
+ assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
521
+
522
+ assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
523
+ const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
524
+ const sealedHostilePath = sealedNodePath(process.execPath, hostilePath);
525
+ assertSelfTest(resolveCommand("pi", hostilePath) === fakePi, "direct PATH resolver did not prefer fake PATH head");
526
+ assertSelfTest(requireNode() === process.execPath, "node resolver must use process.execPath");
527
+ assertSelfTest(requireCommand("pi", { envPath: hostilePath, env: { ...process.env, PATH: sealedHostilePath } }) === fakePi, "pi prereq should use sealed PATH when executing the shim");
528
+ assertSelfTest(!existsSync(fakeNodeMarker), "pi prereq should not use hostile fake node");
529
+
530
+ const baseOptions = {
531
+ ext: ROOT,
532
+ cwd: ROOT,
533
+ mode: DEFAULT_MODE,
534
+ model: DEFAULT_MODEL,
535
+ outDir: tempDir,
536
+ safeLabel: "self-test",
537
+ sessionDir: join(tempDir, "session"),
538
+ sessionId: "self-test",
539
+ settingSources: DEFAULT_SETTING_SOURCES,
540
+ bridge: false,
541
+ exposeBuiltinTools: false,
542
+ eventDebug: false,
543
+ };
544
+ const plan = buildLaunchPlan(baseOptions, { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath }, "/bin/sh");
545
+ const defaults = envMap(plan.envAssignments);
546
+ assertSelfTest(defaults.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "native display must be forced on");
547
+ assertSelfTest(defaults.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "native tool registration must be forced on");
548
+ assertSelfTest(defaults.get("PI_CURSOR_SETTING_SOURCES") === "none", "setting sources must default to none");
549
+ assertSelfTest(defaults.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "bridge must default off");
550
+ assertSelfTest(defaults.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "built-in exposure must default off");
551
+ for (const name of DEBUG_ENV_NAMES) {
552
+ assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
553
+ }
554
+ assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
555
+ assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
556
+ const hostileEnv = {
557
+ ...process.env,
558
+ ...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
559
+ PATH: hostilePath,
560
+ PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
561
+ PI_CURSOR_SETTING_SOURCES: "all",
562
+ PI_CURSOR_PI_TOOL_BRIDGE: "1",
563
+ PI_CURSOR_EXPOSE_BUILTIN_TOOLS: "1",
564
+ };
565
+ const probe = run("/bin/sh", ["-c", plan.script], { env: hostileEnv });
566
+ assertSelfTest(probe.status === 0, `fake-pi env capture exited ${probe.status}: ${probe.stderr?.toString() ?? ""}`);
567
+ const capturedEnv = parseEnvCapture(envCapture);
568
+ assertSelfTest(!existsSync(fakeNodeMarker), "launch PATH should force the resolved node before hostile fake node");
569
+ assertSelfTest((capturedEnv.get("PATH") ?? "").split(delimiter)[0] === dirname(process.execPath), "captured PATH should start with resolved node directory");
570
+ assertSelfTest(capturedEnv.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "captured env should force native display on");
571
+ assertSelfTest(capturedEnv.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "captured env should force native registration on");
572
+ assertSelfTest(capturedEnv.get("PI_CURSOR_SETTING_SOURCES") === "none", "captured env should force settings off");
573
+ assertSelfTest(capturedEnv.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "captured env should force bridge off");
574
+ assertSelfTest(capturedEnv.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "captured env should force built-in exposure off");
575
+ for (const name of DEBUG_ENV_NAMES) {
576
+ assertSelfTest(!capturedEnv.has(name), `${name} should be absent from captured env by default`);
577
+ }
578
+
579
+ const optInPlan = buildLaunchPlan(
580
+ { ...baseOptions, settingSources: "all", bridge: true, exposeBuiltinTools: true, eventDebug: true },
581
+ { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath },
582
+ "/bin/sh",
583
+ );
584
+ const optIns = envMap(optInPlan.envAssignments);
585
+ assertSelfTest(optIns.get("PI_CURSOR_SETTING_SOURCES") === "all", "setting source opt-in must be reflected");
586
+ assertSelfTest(optIns.get("PI_CURSOR_PI_TOOL_BRIDGE") === "1", "bridge opt-in must be reflected");
587
+ assertSelfTest(optIns.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "1", "built-in exposure opt-in must be reflected");
588
+ assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug opt-in must be reflected");
589
+ assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir must be deterministic under out-dir");
590
+ for (const name of DEBUG_ENV_NAMES) {
591
+ assertSelfTest(optInPlan.clearEnvNames.includes(name), `${name} must be cleared even when event debug is explicit`);
592
+ }
593
+ const eventDebugProbe = run("/bin/sh", ["-c", optInPlan.script], { env: hostileEnv });
594
+ assertSelfTest(eventDebugProbe.status === 0, `fake-pi event-debug env capture exited ${eventDebugProbe.status}: ${eventDebugProbe.stderr?.toString() ?? ""}`);
595
+ const capturedEventDebugEnv = parseEnvCapture(envCapture);
596
+ assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug should be explicitly enabled");
597
+ assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir should be deterministic under out-dir");
598
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR"), "stale event debug run dir should be cleared");
599
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR"), "stale event debug session dir should be cleared");
600
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
601
+
602
+ const fakeTmux = join(binDir, "tmux");
603
+ const deleteBufferMarker = join(tempDir, "delete-buffer-called");
604
+ writeFileSync(
605
+ fakeTmux,
606
+ `#!/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`,
607
+ "utf8",
608
+ );
609
+ chmodSync(fakeTmux, 0o755);
610
+ const originalPath = process.env.PATH;
611
+ try {
612
+ process.env.PATH = hostilePath;
613
+ let pasteFailed = false;
614
+ try {
615
+ runVisualSmoke({
616
+ ...baseOptions,
617
+ prompt: "buffer cleanup prompt",
618
+ startupMs: 1,
619
+ waitMs: 1,
620
+ width: 80,
621
+ height: 24,
622
+ historyLines: 100,
623
+ });
624
+ } catch (error) {
625
+ pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
626
+ }
627
+ assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
628
+ assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
629
+ } finally {
630
+ if (originalPath === undefined) delete process.env.PATH;
631
+ else process.env.PATH = originalPath;
632
+ }
633
+ console.log("[visual-smoke] self-test PASS");
634
+ } finally {
635
+ rmSync(tempDir, { recursive: true, force: true });
636
+ }
637
+ }
638
+
639
+ const options = parseArgs(process.argv.slice(2));
640
+ try {
641
+ if (options.selfTest) {
642
+ runSelfTest();
643
+ process.exit(0);
644
+ }
645
+ const artifacts = runVisualSmoke(options);
646
+ checkLeftovers(options.leftoverPatterns);
647
+ if (options.screenshot) {
648
+ await writeTerminalScreenshot(artifacts.htmlPath, artifacts.pngPath, options.width, options.height);
649
+ }
650
+ console.log("[visual-smoke] artifacts:");
651
+ console.log(` ansi: ${artifacts.ansiPath}`);
652
+ console.log(` text: ${artifacts.textPath}`);
653
+ console.log(` html: ${artifacts.htmlPath}`);
654
+ if (options.screenshot) console.log(` png: ${artifacts.pngPath}`);
655
+ console.log(` jsonl.path: ${artifacts.jsonlPathFile}`);
656
+ console.log(` jsonl: ${artifacts.jsonlPath}`);
657
+ } catch (error) {
658
+ fail(error instanceof Error ? error.message : String(error));
659
+ }
@@ -0,0 +1,12 @@
1
+ export declare const CURSOR_SDK_EVENT_DEBUG_ENV: "PI_CURSOR_SDK_EVENT_DEBUG";
2
+ export declare const CURSOR_SDK_EVENT_DEBUG_DIR_ENV: "PI_CURSOR_SDK_EVENT_DEBUG_DIR";
3
+ export declare const CURSOR_SDK_EVENT_DEBUG_RUN_DIR_ENV: "PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR";
4
+ export declare const CURSOR_SDK_EVENT_DEBUG_SESSION_DIR_ENV: "PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR";
5
+ export declare const CURSOR_SDK_EVENT_DEBUG_STDERR_ENV: "PI_CURSOR_SDK_EVENT_DEBUG_STDERR";
6
+ export declare const CURSOR_SDK_EVENT_DEBUG_ENV_NAMES: readonly [
7
+ typeof CURSOR_SDK_EVENT_DEBUG_ENV,
8
+ typeof CURSOR_SDK_EVENT_DEBUG_DIR_ENV,
9
+ typeof CURSOR_SDK_EVENT_DEBUG_RUN_DIR_ENV,
10
+ typeof CURSOR_SDK_EVENT_DEBUG_SESSION_DIR_ENV,
11
+ typeof CURSOR_SDK_EVENT_DEBUG_STDERR_ENV,
12
+ ];
@@ -0,0 +1,13 @@
1
+ export const CURSOR_SDK_EVENT_DEBUG_ENV = "PI_CURSOR_SDK_EVENT_DEBUG";
2
+ export const CURSOR_SDK_EVENT_DEBUG_DIR_ENV = "PI_CURSOR_SDK_EVENT_DEBUG_DIR";
3
+ export const CURSOR_SDK_EVENT_DEBUG_RUN_DIR_ENV = "PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR";
4
+ export const CURSOR_SDK_EVENT_DEBUG_SESSION_DIR_ENV = "PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR";
5
+ export const CURSOR_SDK_EVENT_DEBUG_STDERR_ENV = "PI_CURSOR_SDK_EVENT_DEBUG_STDERR";
6
+
7
+ export const CURSOR_SDK_EVENT_DEBUG_ENV_NAMES = Object.freeze([
8
+ CURSOR_SDK_EVENT_DEBUG_ENV,
9
+ CURSOR_SDK_EVENT_DEBUG_DIR_ENV,
10
+ CURSOR_SDK_EVENT_DEBUG_RUN_DIR_ENV,
11
+ CURSOR_SDK_EVENT_DEBUG_SESSION_DIR_ENV,
12
+ CURSOR_SDK_EVENT_DEBUG_STDERR_ENV,
13
+ ]);