pi-cursor-sdk 0.1.39 → 0.1.41

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 +25 -0
  2. package/README.md +13 -12
  3. package/docs/cursor-dogfood-checklist.md +7 -1
  4. package/docs/cursor-live-smoke-checklist.md +13 -13
  5. package/docs/cursor-model-ux-spec.md +8 -8
  6. package/docs/cursor-native-tool-replay.md +4 -4
  7. package/docs/cursor-native-tool-visual-audit.md +5 -5
  8. package/docs/cursor-testing-lessons.md +5 -5
  9. package/docs/cursor-tool-surfaces.md +4 -0
  10. package/docs/platform-smoke.md +22 -7
  11. package/package.json +8 -5
  12. package/platform-smoke.config.mjs +5 -0
  13. package/scripts/debug-provider-events.mjs +1 -0
  14. package/scripts/isolated-cursor-smoke.sh +7 -7
  15. package/scripts/lib/cursor-visual-manifest.d.mts +3 -0
  16. package/scripts/lib/cursor-visual-manifest.mjs +82 -0
  17. package/scripts/platform-smoke/artifacts.mjs +225 -2
  18. package/scripts/platform-smoke/card-detect.mjs +1 -1
  19. package/scripts/platform-smoke/doctor.mjs +53 -8
  20. package/scripts/platform-smoke/live-suite-runner.mjs +7 -6
  21. package/scripts/platform-smoke/platform-build-windows.ps1 +2 -2
  22. package/scripts/platform-smoke/scenarios.mjs +1 -1
  23. package/scripts/platform-smoke/targets.mjs +2 -2
  24. package/scripts/platform-smoke.mjs +75 -6
  25. package/scripts/steering-rpc-smoke.mjs +1 -1
  26. package/scripts/tmux-live-smoke.sh +1 -1
  27. package/scripts/visual-tui-smoke-self-test.mjs +229 -0
  28. package/scripts/visual-tui-smoke.mjs +46 -179
  29. package/shared/cursor-setting-sources.d.mts +1 -0
  30. package/shared/cursor-setting-sources.mjs +2 -1
  31. package/src/context.ts +25 -10
  32. package/src/cursor-active-tools.ts +7 -0
  33. package/src/cursor-native-tool-display-registration.ts +31 -21
  34. package/src/cursor-native-tool-display-state.ts +13 -4
  35. package/src/cursor-pi-tool-bridge-run.ts +6 -3
  36. package/src/cursor-pi-tool-bridge-types.ts +2 -2
  37. package/src/cursor-provider-errors.ts +2 -1
  38. package/src/cursor-provider-live-run-drain.ts +1 -1
  39. package/src/cursor-provider-turn-prepare.ts +1 -1
  40. package/src/cursor-provider-turn-send.ts +2 -0
  41. package/src/cursor-question-tool.ts +2 -1
  42. package/src/cursor-sdk-event-debug.ts +3 -1
  43. package/src/cursor-setting-sources.ts +2 -0
  44. package/src/cursor-skill-tool.ts +2 -1
  45. package/src/cursor-state.ts +2 -1
  46. package/src/cursor-tool-manifest.ts +2 -1
  47. package/src/cursor-usage-accounting.ts +5 -4
@@ -123,7 +123,7 @@ Runtime budget is part of the contract:
123
123
  - `smoke:platform:doctor` never calls Cursor.
124
124
  - `platform-build` runs once per target and is the only suite that performs the full local CI/build/typecheck/package gate.
125
125
  - Live suites reuse the target checkout and prepared `node_modules` when run after `platform-build`; they do not repeat `npm ci` in a target-session release run.
126
- - Live suites share one target-local packed-install prep directory per target-session release run. The first live suite runs `npm pack` and `npm install --no-save <tarball>` once, then each suite still performs its own `pi install -l <packed package path>`, `pi list`, fresh `--session-dir`, suite `PI_CODING_AGENT_DIR`, workspace fixture, JSONL, visual, bridge, and abort assertions.
126
+ - Live suites share one target-local packed-install prep directory per target-session release run. The first live suite runs `npm pack` and `npm install --no-save <tarball>` once, then each suite still performs its own `pi install --approve -l <packed package path>`, `pi list --approve`, fresh `--session-dir`, suite `PI_CODING_AGENT_DIR`, workspace fixture, JSONL, visual, bridge, and abort assertions.
127
127
  - Visual coverage is batched into one native prompt, one bridge prompt, and one abort/cleanup prompt per target. Do not split these into one prompt per card.
128
128
  - The gate is fail-fast by target to avoid burning Cursor calls after a platform has already failed.
129
129
 
@@ -186,6 +186,11 @@ export default {
186
186
  packageName: "pi-cursor-sdk",
187
187
  cursorModel: "cursor/composer-2-5",
188
188
  artifactRoot: ".artifacts/platform-smoke",
189
+ artifactRetention: {
190
+ maxRunDirs: 18,
191
+ maxAgeDays: 14,
192
+ preserveRecentHours: 24,
193
+ },
189
194
  requiredTargets: ["macos", "ubuntu", "windows-native"],
190
195
  requiredSuites: [
191
196
  "platform-build",
@@ -211,6 +216,8 @@ export default {
211
216
 
212
217
  `windowsParallels` records this repo's default shared Windows template contract. Environment overrides may point at a temporary candidate template during infrastructure work, but release runs should use the shared `pi-extension-windows-template` / `crabbox-ready` baseline unless this document is updated.
213
218
 
219
+ `artifactRetention` bounds local host evidence growth under `artifactRoot`. `smoke:platform:run` prunes only top-level directories named `run-<timestamp>-<suffix>` before starting a new matrix; it leaves non-run/manual directories untouched and preserves directories newer than `preserveRecentHours` to avoid deleting evidence from active or very recent runs. Doctor is read-only and does not prune artifacts.
220
+
214
221
  ## Required local environment
215
222
 
216
223
  The config owns reusable defaults. Environment variables are local-machine knobs and one-off overrides, not a second source of truth. The doctor fails if required auth or target readiness is missing.
@@ -261,12 +268,12 @@ Definitions:
261
268
  - `piProjectRoot`: target-local pi project where platform-build proves packed install.
262
269
  - `livePrepRoot`: target-local shared live-suite prep where the first live suite installs the packed tarball once for reuse by later live suites in the same target session.
263
270
 
264
- Live suites run in a suite-local `testWorkspaceRoot`. The extension loaded by pi is the packed tarball package path from `livePrepRoot`, installed into that suite-local workspace with `pi install -l`; no live suite uses `pi -e .`.
271
+ Live suites run in a suite-local `testWorkspaceRoot`. The extension loaded by pi is the packed tarball package path from `livePrepRoot`, installed into that suite-local workspace with `pi install --approve -l`; no live suite uses `pi -e .`.
265
272
 
266
273
  The runner must prove this by recording:
267
274
 
268
275
  - packed tarball path;
269
- - `pi list` output from the suite-local project after `pi install -l <packed package path>`;
276
+ - `pi list --approve` output from the suite-local project after `pi install --approve -l <packed package path>`;
270
277
  - command line showing no `-e .`;
271
278
  - live suite cwd as `testWorkspaceRoot`.
272
279
 
@@ -354,7 +361,7 @@ Doctor checks:
354
361
  17. `tar` is available on macOS and native Windows.
355
362
  18. `node-pty` self-test passes on every target.
356
363
  19. Target pi tool probe proves the shell tool accepts platform-rendered commands on every target.
357
- 20. Host-side xterm/Playwright render self-test passes.
364
+ 20. Host-side xterm/Playwright render self-test passes by rendering a minimal ANSI fixture through the repo xterm helper and launching Playwright Chromium to write a tiny PNG. If this fails, run `npm install` and `npx playwright install chromium` before live suites.
358
365
  21. `CURSOR_API_KEY` is present.
359
366
  22. Artifact root is writable.
360
367
  23. `git status --short` is recorded.
@@ -386,8 +393,8 @@ Per target, `platform-build` must:
386
393
  6. Run `npm pack`.
387
394
  7. Create `testWorkspaceRoot` with deterministic fixture files copied from the repo.
388
395
  8. Create `piProjectRoot`.
389
- 9. Install the packed tarball into `piProjectRoot` with `pi install -l <tarball>`.
390
- 10. Run `pi list` and assert the installed package points at the packed tarball/install, not `-e .`.
396
+ 9. Install the packed tarball into `piProjectRoot` with `pi install --approve -l <tarball>`.
397
+ 10. Run `pi list --approve` and assert the installed package points at the packed tarball/install, not `-e .`.
391
398
 
392
399
  ## Required suites
393
400
 
@@ -401,7 +408,7 @@ Purpose:
401
408
  - fail before spending Cursor tokens;
402
409
  - produce the packed extension used by later suites.
403
410
 
404
- The host `smoke:platform:all` entrypoint enforces doctor first before running targets. Required artifacts include `node-version.txt`, `npm-version.txt`, stdout/stderr for `npm ci`, `npm run check:platform-smoke`, `npm test`, `npm run typecheck`, `npm pack`, packed npm install, `pi install`, and `pi list`, plus `packed-tarball.txt`, `summary.json`, `artifact-manifest.json`, `assertions.json`, and `failures.md` on failed assertions.
411
+ The host `smoke:platform:all` entrypoint enforces doctor first before running targets. Required artifacts include `node-version.txt`, `npm-version.txt`, stdout/stderr for `npm ci`, `npm run check:platform-smoke`, `npm test`, `npm run typecheck`, `npm pack`, packed npm install, `pi install --approve`, and `pi list --approve`, plus `packed-tarball.txt`, `summary.json`, `artifact-manifest.json`, `assertions.json`, and `failures.md` on failed assertions.
405
412
 
406
413
  ### `cursor-native-visual-matrix`
407
414
 
@@ -636,6 +643,14 @@ Every suite writes under:
636
643
  .artifacts/platform-smoke/<run-id>/<target>/<suite>/
637
644
  ```
638
645
 
646
+ After each `smoke:platform run` invocation, the host writes an atomic latest artifact index for agents and humans:
647
+
648
+ ```text
649
+ .artifacts/platform-smoke/latest.json
650
+ ```
651
+
652
+ `latest.json` records the invocation timestamps, command selection, PID, run id(s), target/suite artifact directories, paths to suite summaries/assertions/failures when present, rendered terminal HTML/PNG paths, visual evidence, session JSONL, JSONL tool-result summaries, and capped Cursor SDK/provider debug artifact paths. The per-suite artifact directories remain the source of truth; `latest.json` is only a discoverability pointer.
653
+
639
654
  Common required artifacts:
640
655
 
641
656
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "pi provider extension backed by @cursor/sdk local agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "scripts/steering-rpc-smoke.mjs",
29
29
  "scripts/tmux-live-smoke.sh",
30
30
  "scripts/visual-tui-smoke.mjs",
31
+ "scripts/visual-tui-smoke-self-test.mjs",
31
32
  "scripts/isolated-cursor-smoke.sh",
32
33
  "scripts/fixtures/plan-strip-shim",
33
34
  "scripts/validate-smoke-jsonl.mjs",
@@ -60,6 +61,8 @@
60
61
  "scripts/lib/cursor-smoke-env.mjs",
61
62
  "scripts/lib/cursor-smoke-env.d.mts",
62
63
  "scripts/lib/cursor-smoke-shell.sh",
64
+ "scripts/lib/cursor-visual-manifest.mjs",
65
+ "scripts/lib/cursor-visual-manifest.d.mts",
63
66
  "scripts/lib/cursor-visual-render.mjs",
64
67
  "scripts/lib/cursor-visual-render.d.mts",
65
68
  "scripts/lib/cursor-sdk-output-filter.mjs",
@@ -106,7 +109,7 @@
106
109
  "smoke:platform:all": "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native"
107
110
  },
108
111
  "dependencies": {
109
- "@cursor/sdk": "1.0.17",
112
+ "@cursor/sdk": "1.0.18",
110
113
  "@modelcontextprotocol/sdk": "^1.29.0"
111
114
  },
112
115
  "peerDependencies": {
@@ -116,9 +119,9 @@
116
119
  "typebox": "*"
117
120
  },
118
121
  "devDependencies": {
119
- "@earendil-works/pi-ai": "0.78.1",
120
- "@earendil-works/pi-coding-agent": "0.78.1",
121
- "@earendil-works/pi-tui": "0.78.1",
122
+ "@earendil-works/pi-ai": "0.79.1",
123
+ "@earendil-works/pi-coding-agent": "0.79.1",
124
+ "@earendil-works/pi-tui": "0.79.1",
122
125
  "@xterm/xterm": "^6.0.0",
123
126
  "node-pty": "^1.1.0",
124
127
  "playwright": "^1.60.0",
@@ -5,6 +5,11 @@ export default {
5
5
  packageName: "pi-cursor-sdk",
6
6
  cursorModel: "cursor/composer-2-5",
7
7
  artifactRoot: ".artifacts/platform-smoke",
8
+ artifactRetention: {
9
+ maxRunDirs: 18,
10
+ maxAgeDays: 14,
11
+ preserveRecentHours: 24,
12
+ },
8
13
  requiredTargets: ["macos", "ubuntu", "windows-native"],
9
14
  requiredSuites: [
10
15
  "platform-build",
@@ -194,6 +194,7 @@ export async function runDebugProviderEvents(args, envInput = process.env) {
194
194
  mkdirSync(sessionDir, { recursive: true });
195
195
 
196
196
  const piArgs = [
197
+ "--approve",
197
198
  "-e",
198
199
  root,
199
200
  "--cursor-no-fast",
@@ -344,12 +344,12 @@ fi
344
344
  log "npm install packed extension deps"
345
345
  run_in_dir_capture_combined "npm install --omit=dev" 120 "$EXTRACT_DIR/package" "$ISOLATED/npm-install.log" "${TOOL_ENV[@]}" "$NPM_BIN" install --omit=dev
346
346
 
347
- log "pi install -l (clean HOME)"
347
+ log "pi install --approve -l (clean HOME)"
348
348
  cp "$REPO/README.md" "$PROJECT_DIR/README.md"
349
- run_in_dir_capture_combined "pi install" 30 "$PROJECT_DIR" "$ISOLATED/pi-install.log" "${PI_DEFAULT_ENV[@]}" "$PI_BIN" install -l "$EXTRACT_DIR/package"
349
+ run_in_dir_capture_combined "pi install" 30 "$PROJECT_DIR" "$ISOLATED/pi-install.log" "${PI_DEFAULT_ENV[@]}" "$PI_BIN" install --approve -l "$EXTRACT_DIR/package"
350
350
 
351
351
  PI_LIST_OUT="$ISOLATED/pi-list.txt"
352
- run_in_dir_capture_combined "pi list" 15 "$PROJECT_DIR" "$PI_LIST_OUT" "${PI_DEFAULT_ENV[@]}" "$PI_BIN" list
352
+ run_in_dir_capture_combined "pi list" 15 "$PROJECT_DIR" "$PI_LIST_OUT" "${PI_DEFAULT_ENV[@]}" "$PI_BIN" list --approve
353
353
  "$RG_BIN" -q "extract/package" "$PI_LIST_OUT" || fail "packed extension not installed"
354
354
 
355
355
  PI_CURSOR_ENV=( "${PI_NONE_ENV[@]}" )
@@ -360,14 +360,14 @@ fi
360
360
  log "check: list-models"
361
361
  LIST_OUT="$ISOLATED/list-models.txt"
362
362
  run_in_dir_capture_combined "list-models" 30 "$PROJECT_DIR" "$LIST_OUT" "${PI_CURSOR_ENV[@]}" \
363
- "$PI_BIN" --cursor-no-fast --list-models cursor
363
+ "$PI_BIN" --approve --cursor-no-fast --list-models cursor
364
364
  "$RG_BIN" -q "composer-2\\.5|composer-2-5" "$LIST_OUT" || fail "composer-2-5 not listed (see $LIST_OUT)"
365
365
 
366
366
  log "check: basic provider prompt"
367
367
  BASIC_DIR="$SESSION_ROOT/basic"
368
368
  mkdir -p "$BASIC_DIR"
369
369
  run_in_dir_capture_split "basic prompt" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/basic.stdout.txt" "$ISOLATED/basic.stderr.txt" "${PI_CURSOR_ENV[@]}" \
370
- "$PI_BIN" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$BASIC_DIR" --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK'
370
+ "$PI_BIN" --approve --cursor-no-fast --model cursor/composer-2-5 --session-dir "$BASIC_DIR" --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK'
371
371
  "$RG_BIN" -q "PI_CURSOR_ISOLATED_OK" "$ISOLATED/basic.stdout.txt" || fail "basic prompt missing PI_CURSOR_ISOLATED_OK"
372
372
  validate_replay_jsonl "$BASIC_DIR"
373
373
 
@@ -375,14 +375,14 @@ log "check: native replay"
375
375
  REPLAY_DIR="$SESSION_ROOT/native-replay"
376
376
  mkdir -p "$REPLAY_DIR"
377
377
  run_in_dir_capture_split "native replay" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/replay.stdout.txt" "$ISOLATED/replay.stderr.txt" "${PI_CURSOR_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
378
- "$PI_BIN" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$REPLAY_DIR" -p 'Read ./README.md briefly, then answer README_SEEN=yes if it mentions pi-cursor-sdk.'
378
+ "$PI_BIN" --approve --cursor-no-fast --model cursor/composer-2-5 --session-dir "$REPLAY_DIR" -p 'Read ./README.md briefly, then answer README_SEEN=yes if it mentions pi-cursor-sdk.'
379
379
  validate_replay_jsonl "$REPLAY_DIR"
380
380
 
381
381
  log "check: plan-strip shim (plan-mode execute reset)"
382
382
  PLAN_DIR="$SESSION_ROOT/plan-strip"
383
383
  mkdir -p "$PLAN_DIR"
384
384
  run_in_dir_capture_split "plan-strip replay" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/plan.stdout.txt" "$ISOLATED/plan.stderr.txt" "${PI_CURSOR_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
385
- "$PI_BIN" -e "$SHIM_DIR" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$PLAN_DIR" -p 'After reset, read README.md and answer PLAN_STRIP_OK=yes.'
385
+ "$PI_BIN" --approve -e "$SHIM_DIR" --cursor-no-fast --model cursor/composer-2-5 --session-dir "$PLAN_DIR" -p 'After reset, read README.md and answer PLAN_STRIP_OK=yes.'
386
386
  validate_replay_jsonl "$PLAN_DIR"
387
387
 
388
388
  log "PASS isolated install smoke: $ISOLATED"
@@ -0,0 +1,3 @@
1
+ export function redactedArgv(argv: string[]): string[];
2
+ export function promptDigest(prompt: string): string;
3
+ export function writeVisualManifest(path: string, options: Record<string, unknown>, artifacts: Record<string, unknown>, failure?: Record<string, unknown>): void;
@@ -0,0 +1,82 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ function writeUtf8(path, text) {
6
+ mkdirSync(dirname(path), { recursive: true });
7
+ writeFileSync(path, text, "utf8");
8
+ }
9
+
10
+ export function redactedArgv(argv) {
11
+ const redacted = [];
12
+ let redactNext = false;
13
+ for (const arg of argv) {
14
+ if (redactNext) {
15
+ redacted.push("[redacted]");
16
+ redactNext = false;
17
+ continue;
18
+ }
19
+ if (arg === "--prompt" || arg === "--prompt-file") {
20
+ redacted.push(arg);
21
+ redactNext = true;
22
+ continue;
23
+ }
24
+ if (arg.startsWith("--prompt=") || arg.startsWith("--prompt-file=")) {
25
+ const [flag] = arg.split("=", 1);
26
+ redacted.push(`${flag}=[redacted]`);
27
+ continue;
28
+ }
29
+ redacted.push(arg);
30
+ }
31
+ return redacted;
32
+ }
33
+
34
+ export function promptDigest(prompt) {
35
+ return createHash("sha256").update(prompt).digest("hex");
36
+ }
37
+
38
+ function manifestExistingPath(path) {
39
+ return path && existsSync(path) ? path : undefined;
40
+ }
41
+
42
+ export function writeVisualManifest(path, options, artifacts, failure) {
43
+ const paths = {
44
+ ansi: manifestExistingPath(artifacts.ansiPath),
45
+ text: manifestExistingPath(artifacts.textPath),
46
+ html: manifestExistingPath(artifacts.htmlPath),
47
+ png: artifacts.pngWritten === true ? manifestExistingPath(artifacts.pngPath) : undefined,
48
+ jsonlPathFile: manifestExistingPath(artifacts.jsonlPathFile),
49
+ jsonl: manifestExistingPath(artifacts.jsonlPath),
50
+ };
51
+ for (const [key, value] of Object.entries(paths)) {
52
+ if (value === undefined) delete paths[key];
53
+ }
54
+ writeUtf8(path, `${JSON.stringify({
55
+ schemaVersion: 1,
56
+ kind: "visual-tui-smoke-manifest",
57
+ label: options.label,
58
+ safeLabel: options.safeLabel,
59
+ promptLength: options.prompt.length,
60
+ promptSha256: promptDigest(options.prompt),
61
+ width: options.width,
62
+ height: options.height,
63
+ model: options.model,
64
+ mode: options.mode,
65
+ cwd: options.cwd,
66
+ ext: options.ext,
67
+ outDir: options.outDir,
68
+ sessionDir: options.sessionDir,
69
+ sessionId: options.sessionId,
70
+ waitMs: options.waitMs,
71
+ startupMs: options.startupMs,
72
+ screenshot: options.screenshot,
73
+ paths,
74
+ command: {
75
+ argv: redactedArgv(process.argv.slice(2)),
76
+ cwd: process.cwd(),
77
+ pid: process.pid,
78
+ },
79
+ ...(failure ? { failure } : {}),
80
+ writtenAt: new Date().toISOString(),
81
+ }, null, 2)}\n`);
82
+ }
@@ -2,8 +2,87 @@
2
2
  * Artifact management — directory layout, manifest, redaction scanning, packaging.
3
3
  */
4
4
 
5
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "node:fs";
6
- import { resolve, relative, basename } from "node:path";
5
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, renameSync } from "node:fs";
6
+ import { resolve, relative, basename, dirname } from "node:path";
7
+
8
+ const PLATFORM_SMOKE_RUN_DIR_PATTERN = /^run-(\d+)-[a-z0-9]+$/i;
9
+ const HOURS_TO_MS = 60 * 60 * 1000;
10
+ const DAYS_TO_MS = 24 * HOURS_TO_MS;
11
+ const LATEST_INDEX_NAME = "latest.json";
12
+
13
+ function finiteNonNegativeNumber(value) {
14
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
15
+ }
16
+
17
+ function finiteNonNegativeInteger(value) {
18
+ return Number.isInteger(value) && value >= 0;
19
+ }
20
+
21
+ /** Prune old top-level platform-smoke run artifact directories. */
22
+ export function prunePlatformSmokeArtifacts(artifactRoot, retention = {}, options = {}) {
23
+ const root = resolve(process.cwd(), artifactRoot);
24
+ const maxRunDirs = finiteNonNegativeInteger(retention.maxRunDirs) ? retention.maxRunDirs : undefined;
25
+ const maxAgeDays = finiteNonNegativeNumber(retention.maxAgeDays) ? retention.maxAgeDays : undefined;
26
+ const preserveRecentHours = finiteNonNegativeNumber(retention.preserveRecentHours) ? retention.preserveRecentHours : 24;
27
+ const enabled = retention.enabled !== false && (maxRunDirs !== undefined || maxAgeDays !== undefined);
28
+ const result = { root, enabled, removed: [], kept: [], ignored: [] };
29
+ if (!enabled || !existsSync(root)) return result;
30
+
31
+ const nowMs = finiteNonNegativeNumber(options.nowMs) ? options.nowMs : Date.now();
32
+ const preserveRecentMs = preserveRecentHours * HOURS_TO_MS;
33
+ const maxAgeMs = maxAgeDays === undefined ? undefined : maxAgeDays * DAYS_TO_MS;
34
+ const runDirs = [];
35
+
36
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
37
+ if (!entry.isDirectory()) {
38
+ result.ignored.push(entry.name);
39
+ continue;
40
+ }
41
+ const match = PLATFORM_SMOKE_RUN_DIR_PATTERN.exec(entry.name);
42
+ if (!match) {
43
+ result.ignored.push(entry.name);
44
+ continue;
45
+ }
46
+ runDirs.push({ name: entry.name, path: resolve(root, entry.name), timestampMs: Number(match[1]) });
47
+ }
48
+
49
+ const recentCutoffMs = nowMs - preserveRecentMs;
50
+ const protectedRecent = new Set(runDirs.filter((dir) => dir.timestampMs > recentCutoffMs).map((dir) => dir.name));
51
+ const removeNames = new Set();
52
+
53
+ if (maxAgeMs !== undefined) {
54
+ const staleCutoffMs = nowMs - maxAgeMs;
55
+ for (const dir of runDirs) {
56
+ if (dir.timestampMs < staleCutoffMs) removeNames.add(dir.name);
57
+ }
58
+ }
59
+
60
+ if (maxRunDirs !== undefined && runDirs.length > maxRunDirs) {
61
+ const sortedNewestFirst = [...runDirs].sort((a, b) => b.timestampMs - a.timestampMs);
62
+ let remainingKeepSlots = maxRunDirs - protectedRecent.size;
63
+ for (const dir of sortedNewestFirst) {
64
+ if (protectedRecent.has(dir.name)) continue;
65
+ if (remainingKeepSlots > 0) {
66
+ remainingKeepSlots--;
67
+ continue;
68
+ }
69
+ removeNames.add(dir.name);
70
+ }
71
+ }
72
+
73
+ for (const dir of runDirs) {
74
+ if (!removeNames.has(dir.name)) {
75
+ result.kept.push(dir.name);
76
+ continue;
77
+ }
78
+ rmSync(dir.path, { recursive: true, force: true });
79
+ result.removed.push(dir.name);
80
+ }
81
+ result.kept.sort();
82
+ result.removed.sort();
83
+ result.ignored.sort();
84
+ return result;
85
+ }
7
86
 
8
87
  /** Create a suite artifact directory. */
9
88
  export function createSuiteDir(artifactRoot, runId, targetName, suiteName) {
@@ -100,6 +179,150 @@ export function writeSummary(dir, data) {
100
179
  }, null, 2));
101
180
  }
102
181
 
182
+ function readJsonFile(path) {
183
+ try {
184
+ return JSON.parse(readFileSync(path, "utf8"));
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ function collectFiles(root) {
191
+ const files = [];
192
+ function walk(dir) {
193
+ let entries;
194
+ try {
195
+ entries = readdirSync(dir, { withFileTypes: true });
196
+ } catch {
197
+ return;
198
+ }
199
+ for (const entry of entries) {
200
+ const path = resolve(dir, entry.name);
201
+ if (entry.isDirectory()) walk(path);
202
+ else if (entry.isFile()) files.push(path);
203
+ }
204
+ }
205
+ if (existsSync(root)) walk(root);
206
+ files.sort();
207
+ return files;
208
+ }
209
+
210
+ function existingPath(path) {
211
+ return existsSync(path) ? path : undefined;
212
+ }
213
+
214
+ function providerDebugPathFields(debugRoot) {
215
+ if (!existsSync(debugRoot)) return {};
216
+ const providerDebugArtifacts = collectFiles(debugRoot);
217
+ const keyArtifacts = providerDebugArtifacts.filter((path) => /(?:^|[\\/])(?:session|summary|timeline|provider-events|bridge-events|wait-result)\.(?:json|jsonl)$/i.test(path));
218
+ const capped = keyArtifacts.slice(0, 40);
219
+ return {
220
+ providerDebugRoot: debugRoot,
221
+ providerDebugArtifactCount: providerDebugArtifacts.length,
222
+ providerDebugArtifacts: capped,
223
+ ...(providerDebugArtifacts.length > capped.length ? { providerDebugArtifactsTruncated: true } : {}),
224
+ };
225
+ }
226
+
227
+ function pathFields(suiteDir) {
228
+ const artifactsDir = resolve(suiteDir, "artifacts");
229
+ const debugRoot = resolve(suiteDir, "cursor-sdk-events");
230
+ const paths = {
231
+ artifactManifest: existingPath(resolve(suiteDir, "artifact-manifest.json")),
232
+ summary: existingPath(resolve(suiteDir, "summary.json")),
233
+ assertions: existingPath(resolve(suiteDir, "assertions.json")),
234
+ failures: existingPath(resolve(suiteDir, "failures.md")),
235
+ terminalHtml: existingPath(resolve(artifactsDir, "terminal.html")),
236
+ terminalFullPng: existingPath(resolve(artifactsDir, "terminal.full.png")),
237
+ terminalFinalViewportPng: existingPath(resolve(artifactsDir, "terminal.final-viewport.png")),
238
+ visualEvidence: existingPath(resolve(artifactsDir, "visual-evidence.json")),
239
+ sessionJsonl: existingPath(resolve(artifactsDir, "session.jsonl")),
240
+ jsonlToolResults: existingPath(resolve(artifactsDir, "jsonl-tool-results.json")),
241
+ ...providerDebugPathFields(debugRoot),
242
+ };
243
+ for (const [key, value] of Object.entries(paths)) {
244
+ if (value === undefined) delete paths[key];
245
+ }
246
+ return paths;
247
+ }
248
+
249
+ function suiteIndexFromResult(result, artifactRoot) {
250
+ if (!result?.suiteDir) return undefined;
251
+ const suiteDir = resolve(result.suiteDir);
252
+ const summary = readJsonFile(resolve(suiteDir, "summary.json"));
253
+ const target = readJsonFile(resolve(suiteDir, "target.json"));
254
+ const suite = readJsonFile(resolve(suiteDir, "suite.json"));
255
+ const rel = relative(resolve(process.cwd(), artifactRoot), suiteDir).split(/[\\/]/);
256
+ return {
257
+ target: summary?.target ?? target?.targetName ?? rel.at(-2),
258
+ suite: summary?.suite ?? suite?.suiteName ?? rel.at(-1),
259
+ runId: target?.runId ?? rel.at(-3),
260
+ ok: result.ok === true,
261
+ artifactDir: suiteDir,
262
+ paths: pathFields(suiteDir),
263
+ };
264
+ }
265
+
266
+ function targetIndexesFromRun(targetName, result, artifactRoot) {
267
+ const suiteResults = Array.isArray(result?.results) ? result.results : [result];
268
+ const suites = suiteResults.map((suiteResult) => suiteIndexFromResult(suiteResult, artifactRoot)).filter(Boolean);
269
+ const runIds = [...new Set(suites.map((suite) => suite.runId).filter(Boolean))];
270
+ return {
271
+ target: targetName,
272
+ ok: result?.ok === true,
273
+ ...(result?.error ? { error: redactSecrets(result.error) } : {}),
274
+ runId: runIds.length === 1 ? runIds[0] : undefined,
275
+ runIds,
276
+ suites,
277
+ };
278
+ }
279
+
280
+ /** Build a stable, agent-readable platform-smoke latest index from target run results. */
281
+ export function buildLatestPlatformSmokeIndex(config, runResults, metadata = {}) {
282
+ const artifactRoot = resolve(process.cwd(), config?.artifactRoot ?? ".artifacts/platform-smoke");
283
+ const targets = runResults.map(({ targetName, result }) => targetIndexesFromRun(targetName, result, artifactRoot));
284
+ const runIds = [...new Set(targets.flatMap((target) => target.runIds).filter(Boolean))].sort();
285
+ const newestRunId = runIds
286
+ .map((runId) => ({ runId, match: PLATFORM_SMOKE_RUN_DIR_PATTERN.exec(runId) }))
287
+ .filter((entry) => entry.match)
288
+ .sort((a, b) => Number(b.match[1]) - Number(a.match[1]))[0]?.runId ?? runIds.at(-1);
289
+ return {
290
+ schemaVersion: 1,
291
+ kind: "platform-smoke-latest",
292
+ runId: runIds.length === 1 ? runIds[0] : newestRunId,
293
+ runIds,
294
+ artifactRoot,
295
+ startedAt: metadata.startedAt,
296
+ finishedAt: metadata.finishedAt,
297
+ command: metadata.command,
298
+ pid: process.pid,
299
+ ok: targets.every((target) => target.ok),
300
+ targets,
301
+ };
302
+ }
303
+
304
+ /** Atomically write .artifacts/platform-smoke/latest.json. */
305
+ export function writeLatestPlatformSmokeIndex(config, runResults, metadata = {}) {
306
+ const index = buildLatestPlatformSmokeIndex(config, runResults, metadata);
307
+ mkdirSync(index.artifactRoot, { recursive: true });
308
+ const outPath = resolve(index.artifactRoot, LATEST_INDEX_NAME);
309
+ const tmpPath = resolve(dirname(outPath), `.${LATEST_INDEX_NAME}.${process.pid}.${Date.now()}.tmp`);
310
+ writeFileSync(tmpPath, `${JSON.stringify(index, null, 2)}\n`);
311
+ renameSync(tmpPath, outPath);
312
+ return { index, path: outPath };
313
+ }
314
+
315
+ /** Return concise existing evidence paths for a failed suite result. */
316
+ export function platformSmokeSuiteEvidence(result, artifactRoot) {
317
+ const suite = suiteIndexFromResult(result, artifactRoot ?? ".artifacts/platform-smoke");
318
+ if (!suite) return undefined;
319
+ return {
320
+ suite: suite.suite,
321
+ artifactDir: suite.artifactDir,
322
+ paths: suite.paths,
323
+ };
324
+ }
325
+
103
326
  /** Write command.txt recording the command that was executed. */
104
327
  export function writeCommand(dir, cmd) {
105
328
  writeFileSync(resolve(dir, "command.txt"), Array.isArray(cmd) ? cmd.join(" ") + "\n" : cmd + "\n");
@@ -18,7 +18,7 @@ const CARD_PATTERNS = [
18
18
  { id: "write", pattern: /^\s*\+.*beta\s*$/i },
19
19
  { id: "edit-diff", pattern: /^\s*\+.*gamma\s*$/i },
20
20
  { id: "shell-failure", pattern: /^\s*(?:native shell failure|Command exited with code 7)\s*$/i },
21
- { id: "bridge-read-success", pattern: /^\s*read \.\/package\.json\s*$/i },
21
+ { id: "bridge-read-success", pattern: /^\s*read (?:\.\/package\.json|.*[\\/]package\.json)\s*$/i },
22
22
  { id: "bridge-read-failure", pattern: /^\s*(?:read \.\/definitely-missing-platform-smoke-file\.txt|ENOENT: no such file)\s*/i },
23
23
  { id: "bridge-shell-success", pattern: /^\s*bridge visual smoke\s*$/i },
24
24
  { id: "footer-status", pattern: /\bcomposer-2-5\b|\bcomposer-2\.5\b/i },
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { execSync, execFileSync } from "node:child_process";
10
- import { accessSync, constants, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
10
+ import { accessSync, constants, existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, statSync } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
+ import { renderAll } from "./render-ansi.mjs";
12
13
 
13
14
  let failures = 0;
14
15
 
@@ -126,6 +127,41 @@ function disposableWindowsSshProbe(cbox, config = {}) {
126
127
 
127
128
  function hasBin(name) { return silent("which", [name]) !== null; }
128
129
 
130
+ async function runRenderProbe(artifactRoot) {
131
+ const probeDir = resolve(artifactRoot, `.doctor-render-${process.pid}-${Date.now()}`);
132
+ try {
133
+ mkdirSync(probeDir, { recursive: true });
134
+ const ansiPath = resolve(probeDir, "terminal.ansi");
135
+ writeFileSync(ansiPath, "\u001b[32mplatform smoke render probe\u001b[0m\n");
136
+ const rendered = await renderAll(ansiPath, probeDir, {
137
+ label: "doctor-render-probe",
138
+ model: "doctor",
139
+ mode: "doctor",
140
+ cwd: process.cwd(),
141
+ sessionId: "doctor-render-probe",
142
+ width: 80,
143
+ height: 10,
144
+ historyLines: 100,
145
+ });
146
+ const pngPath = resolve(probeDir, "terminal.full.png");
147
+ const pngOk = rendered.pngOk && existsSync(pngPath) && statSync(pngPath).size > 100;
148
+ if (!pngOk) {
149
+ return {
150
+ ok: false,
151
+ message: `host-side xterm/Playwright render probe did not produce a PNG at ${pngPath}. Run: npx playwright install chromium`,
152
+ };
153
+ }
154
+ return { ok: true, message: pngPath };
155
+ } catch (error) {
156
+ return {
157
+ ok: false,
158
+ message: `host-side xterm/Playwright render probe failed: ${error instanceof Error ? error.message : String(error)}. Run npm install, then: npx playwright install chromium`,
159
+ };
160
+ } finally {
161
+ rmSync(probeDir, { recursive: true, force: true });
162
+ }
163
+ }
164
+
129
165
  function findGitRoot(startPath) {
130
166
  let dir = startPath;
131
167
  for (let i = 0; i < 8; i++) {
@@ -137,7 +173,7 @@ function findGitRoot(startPath) {
137
173
  return null;
138
174
  }
139
175
 
140
- function runChecks(config) {
176
+ async function runChecks(config) {
141
177
  // ── Phase 1: environment variables ──
142
178
  console.log("\n── Environment variables ──");
143
179
  const requiredVars = [
@@ -396,7 +432,16 @@ function runChecks(config) {
396
432
  fail(`cannot write to ${artRoot}: ${e.message}`);
397
433
  }
398
434
 
399
- // ── Phase 10: Git status ──
435
+ // ── Phase 10: Host-side visual render probe ──
436
+ console.log("\n── Host-side visual render probe ──");
437
+ const renderProbe = await runRenderProbe(artRoot);
438
+ if (renderProbe.ok) {
439
+ ok("xterm/Playwright Chromium render probe wrote a PNG");
440
+ } else {
441
+ fail(renderProbe.message);
442
+ }
443
+
444
+ // ── Phase 11: Git status ──
400
445
  console.log("\n── Git status ──");
401
446
  const branch = shell("git branch --show-current");
402
447
  branch ? ok(`branch: ${branch}`) : warn("could not determine branch");
@@ -408,7 +453,7 @@ function runChecks(config) {
408
453
  ok("clean worktree");
409
454
  }
410
455
 
411
- // ── Phase 11: Forbidden files ──
456
+ // ── Phase 12: Forbidden files ──
412
457
  console.log("\n── Forbidden files ──");
413
458
  let anyForbidden = false;
414
459
  for (const pat of [".env", "*.tgz"]) {
@@ -428,7 +473,7 @@ function runChecks(config) {
428
473
  }
429
474
  ok("no tracked .env.*");
430
475
 
431
- // ── Phase 12: Cursor auth ──
476
+ // ── Phase 13: Cursor auth ──
432
477
  console.log("\n── Cursor auth ──");
433
478
  const key = env("CURSOR_API_KEY");
434
479
  if (key && key.length > 10) {
@@ -439,7 +484,7 @@ function runChecks(config) {
439
484
  fail("CURSOR_API_KEY missing — live Cursor suites will not run");
440
485
  }
441
486
 
442
- // ── Phase 13: node-pty self-test ──
487
+ // ── Phase 14: node-pty self-test ──
443
488
  console.log("\n── node-pty self-test ──");
444
489
  const ptyPath = resolve(process.cwd(), "node_modules", "node-pty");
445
490
  if (existsSync(ptyPath)) {
@@ -458,7 +503,7 @@ function runChecks(config) {
458
503
  warn("node-pty not installed — live PTY suites will not run. Run: npm ci");
459
504
  }
460
505
 
461
- // ── Phase 14: Summary ──
506
+ // ── Phase 15: Summary ──
462
507
  console.log(`\n=== Results: ${failures} failure(s) ===`);
463
508
  if (failures > 0) {
464
509
  console.log("Fix failures above before running live Cursor suites.");
@@ -470,5 +515,5 @@ function runChecks(config) {
470
515
  }
471
516
 
472
517
  export async function runDoctor(config) {
473
- runChecks(config);
518
+ await runChecks(config);
474
519
  }