pi-cursor-sdk 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.9 - 2026-09-19
4
+
5
+ ### Fixed
6
+
7
+ - Serialize replay tool-call arguments once at the Pi transcript boundary to satisfy Pi 0.86.1's JSON-only contract without sharing mutable SDK payloads.
8
+ - Preserve secret-scanned JSON and JSONL smoke artifacts byte-for-byte, and fail qualification when retained structured evidence cannot be parsed.
9
+
10
+ ### Changed
11
+
12
+ - Restore the compiled `dist/index.js` Pi entrypoint and the pre-0.3.8 tool-argument handling. Released Pi 0.85.1 did not require the source-loader or newer transcript-type changes.
13
+ - Remove the additional source-host compatibility matrix, probes, and compaction qualification added with 0.3.8. Keep the terminal capture detector fix, which corrects QA evidence rather than runtime rendering.
14
+ - Extension filters changed for 0.3.8 must target `dist/index.js` again. Keep exclusions for both entrypoints when disabling Cursor across versions; see README's “Existing extension filters”.
15
+
16
+ ## 0.3.8 - 2026-09-19
17
+
18
+ ### Fixed
19
+
20
+ - Narrow SDK replay and MCP bridge arguments to JSON objects before emitting Pi tool calls, preserving valid payloads without serialization or casts. This supports Pi's tightened transcript types and reports non-JSON arguments before dispatch.
21
+ - Compile source and test fixtures against published Pi 0.85.1, pinned official source, and the fork. Native packed-host checks require common provider, replay, and runtime behavior on every host. The fork additionally qualifies legacy `SystemMessage.replace` checkpoints; current official Pi removed that host feature, which is not required by this extension.
22
+ - Load the already-shipped TypeScript source graph through Pi's native loader so provider streams and replay helpers remain host-owned even with local Pi peer packages installed. Keep compiled output for scripts and programmatic consumers.
23
+ - Preserve hyperlink text and split terminal control sequences when checking platform-smoke read cards, using Node's terminal-control stripping rather than a custom regular expression.
24
+
25
+ ### Changed
26
+
27
+ - Native source loading fixes host peer identity at the cost of more initial startup/load work. On macOS/Node 24.21.0/Pi fork `90e6`, three fresh-process, warm-cache loader runs measured 0.85–1.24 s for source versus 0.08–0.13 s for dist; these are not TUI startup timings or guarantees for other environments or repeated `/reload`.
28
+ - **Filter migration required:** update old dist-targeting extension filters to preserve include/exclude intent (`+dist/index.js` → `+src/index.ts`, `-dist/index.js` → `-src/index.ts`, `!dist/**` → `!src/**`). The old exclusion alone no longer disables Cursor; retain `-dist/index.js` alongside `-src/index.ts` for rollback. See README's “Existing extension filters”; settings are not rewritten automatically.
29
+
3
30
  ## 0.3.7 - 2026-09-16
4
31
 
5
32
  ### Fixed
package/README.md CHANGED
@@ -70,6 +70,12 @@ Alternative GitHub install:
70
70
  pi install https://github.com/fitchmultz/pi-cursor-sdk
71
71
  ```
72
72
 
73
+ ### Existing extension filters
74
+
75
+ The Pi entrypoint is `dist/index.js`. If you changed extension filters for 0.3.8's `src/index.ts` entrypoint, restore their compiled-entry equivalents: `+src/index.ts` → `+dist/index.js`, `-src/index.ts` → `-dist/index.js`, and `!src/**` → `!dist/**`.
76
+
77
+ To keep Cursor disabled across both entrypoints, retain both `-src/index.ts` and `-dist/index.js`. The package does not rewrite user or project settings.
78
+
73
79
  ### Project-local install
74
80
 
75
81
  Use `-l` if you want the package recorded in the current project's `.pi/settings.json` instead of your global pi settings:
@@ -673,6 +679,10 @@ Cursor native replay is a display enhancement for TUI sessions and structured JS
673
679
 
674
680
  ## Development
675
681
 
682
+ The source qualification baseline is official Pi **0.86.1**, with optional wildcard host peers and Cursor SDK **1.0.27** unchanged. Run `npm ci --ignore-scripts` then `npm run check:compat` in an empty HOME/agent profile for credential-free qualification. This builds the manifest entry, runs existing type/unit/package checks, and tests the compiled provider's registration plus production prompt shaping against native Pi transcript/tool transitions. Replay arguments must match their JSON stream deltas. Tests use no live Cursor service.
683
+
684
+ This is not Cursor authentication, desktop/cloud execution, visual, or all-platform release proof. The existing `smoke:platform:all` and applicable `smoke:cloud` gates remain required; a passing compatibility check does not make a runtime change release-ready. Older advertised Pi/Node floors require their own matrix evidence.
685
+
676
686
  Run checks:
677
687
 
678
688
  ```bash
@@ -129,14 +129,16 @@ function emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolR
129
129
  const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
130
130
  for (const tool of tools) {
131
131
  const contentIndex = partial.content.length;
132
+ // Pi persists JSON arguments: keep the completed call identical to its wire delta.
133
+ const serializedArgs = JSON.stringify(tool.args);
132
134
  partial.content.push({
133
135
  type: "toolCall",
134
136
  id: tool.id,
135
137
  name: tool.toolName,
136
- arguments: tool.args,
138
+ arguments: JSON.parse(serializedArgs),
137
139
  });
138
140
  stream.push({ type: "toolcall_start", contentIndex, partial });
139
- stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(tool.args), partial });
141
+ stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
140
142
  const block = partial.content[contentIndex];
141
143
  if (block.type === "toolCall")
142
144
  stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
@@ -173,14 +175,15 @@ function emitInactiveCursorReplayTrace(turn, tools, debugRecorder) {
173
175
  function emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests) {
174
176
  for (const request of requests) {
175
177
  const contentIndex = partial.content.length;
178
+ const serializedArgs = JSON.stringify(request.args);
176
179
  partial.content.push({
177
180
  type: "toolCall",
178
181
  id: request.piToolCallId,
179
182
  name: request.piToolName,
180
- arguments: request.args,
183
+ arguments: JSON.parse(serializedArgs),
181
184
  });
182
185
  stream.push({ type: "toolcall_start", contentIndex, partial });
183
- stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(request.args), partial });
186
+ stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
184
187
  const block = partial.content[contentIndex];
185
188
  if (block.type === "toolCall")
186
189
  stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
@@ -18,6 +18,8 @@ Required behavior:
18
18
  - fail when a card/evidence item has the wrong success/error state;
19
19
  - fail when footer/status is missing or unreadable.
20
20
 
21
+ The plain-text card inventory uses Node's `stripVTControlCharacters` on the complete captured ANSI stream. OSC-8 hyperlinks may use either BEL or ST (ESC followed by a backslash) terminators, and a PTY chunk may split either sequence. Do not strip chunks independently or use a greedy OSC regex: that can consume the visible `package.json` label, successful read content, or subsequent cards even when xterm renders them correctly. Raw ANSI remains unchanged for independent xterm/PNG rendering and secret scanning; exact card patterns and JSONL success/error assertions still apply. `test/fixtures/platform-read-hyperlinks.json` retains minimal native/bridge headers from real captures with only the local hyperlink target anonymized.
22
+
21
23
  Meaningful gap closed: earlier card assertions could pass when the prompt mentioned `pi__read` or a missing-file path even if the actual tool card/result never rendered. The gate now requires JSONL result evidence and per-evidence rendered screenshots for native read, native shell success/failure, native edit diffs, bridge read success/failure, and bridge shell success.
22
24
 
23
25
  ## Registry visual classification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "pi provider extension backed by @cursor/sdk local and cloud agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -120,6 +120,7 @@
120
120
  "node": ">=22.19.0"
121
121
  },
122
122
  "scripts": {
123
+ "check:compat": "npm run build && npm run verify && node --test test/native-provider.test.mjs",
123
124
  "build": "node ./scripts/build.mjs",
124
125
  "prepare": "node ./scripts/prepare.mjs",
125
126
  "typecheck": "npm run typecheck:src && npm run typecheck:tests && npm run typecheck:replay-compile",
@@ -175,9 +176,9 @@
175
176
  "typebox": "*"
176
177
  },
177
178
  "devDependencies": {
178
- "@earendil-works/pi-ai": "0.84.0",
179
- "@earendil-works/pi-coding-agent": "0.84.0",
180
- "@earendil-works/pi-tui": "0.84.0",
179
+ "@earendil-works/pi-ai": "0.86.1",
180
+ "@earendil-works/pi-coding-agent": "0.86.1",
181
+ "@earendil-works/pi-tui": "0.86.1",
181
182
  "@xterm/xterm": "^6.0.0",
182
183
  "node-pty": "^1.1.0",
183
184
  "playwright": "^1.60.0",
@@ -71,6 +71,60 @@ export function redactSecrets(text) {
71
71
  return redacted;
72
72
  }
73
73
 
74
+ /** JSON syntax is not secret material. Scan decoded scalar values with the same
75
+ * canonical rules, retaining key/value context for auth fields. Never regex-scrub
76
+ * a serialized document: escaped quotes, numeric tokens and scoped names break. */
77
+ export function scanArtifactSecrets(path, content) {
78
+ if (!/\.jsonl?$/i.test(path) || isBinaryArtifactContent(content)) return scanForSecrets(content);
79
+ const text = String(content);
80
+ const documents = /\.jsonl$/i.test(path) ? text.split(/\r?\n/).filter((line) => line.trim()) : [text];
81
+ try {
82
+ for (const document of documents) JSON.parse(document);
83
+ } catch {
84
+ // Malformed evidence is never repaired. Scan its bytes and let the
85
+ // structural gate report it independently (without echoing input).
86
+ return scanForSecrets(content);
87
+ }
88
+ const violations = new Set();
89
+ const scan = (value) => scanForSecrets(value).forEach((violation) => violations.add(violation));
90
+ for (const document of documents) {
91
+ // Native JSON validation above owns grammar. Scan the original tokens, not
92
+ // the parsed object: duplicate members (including entire subtrees) survive
93
+ // in transported bytes even though JSON.parse overwrites them.
94
+ let key;
95
+ for (const match of document.matchAll(/("(?:[^"\\]|\\.)*")(\s*:)?|[{}\[\],]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g)) {
96
+ const token = match[1] ?? match[0];
97
+ if ("{}[],".includes(token)) {
98
+ key = undefined;
99
+ continue;
100
+ }
101
+ // Decode string escapes, but retain number spelling (no rounding/overflow).
102
+ const value = match[1] ? JSON.parse(token) : token;
103
+ scan(value);
104
+ if (match[2]) key = value;
105
+ else {
106
+ // A space after ':' keeps syntax from looking like SCP credentials.
107
+ if (key !== undefined) scan(`${JSON.stringify(key)}: ${match[1] ? JSON.stringify(value) : token}`);
108
+ key = undefined;
109
+ }
110
+ }
111
+ }
112
+ return [...violations];
113
+ }
114
+
115
+ export function structuredArtifactViolations(path, content) {
116
+ if (!/\.jsonl?$/i.test(path)) return [];
117
+ const lines = /\.jsonl$/i.test(path) ? String(content).split(/\r?\n/) : [String(content)];
118
+ const violations = [];
119
+ for (const [index, line] of lines.entries()) {
120
+ if (/\.jsonl$/i.test(path) && !line.trim()) continue;
121
+ try { JSON.parse(line); } catch {
122
+ violations.push(/\.jsonl$/i.test(path) ? `invalid JSONL at line ${index + 1}` : "invalid JSON");
123
+ }
124
+ }
125
+ return violations;
126
+ }
127
+
74
128
  export function isBinaryArtifactContent(value) {
75
129
  return Buffer.isBuffer(value) && (!isUtf8(value) || value.includes(0));
76
130
  }
@@ -18,7 +18,7 @@ import {
18
18
  boundedFileSnapshot, openRegularFileNoFollow, walkArtifactTree,
19
19
  writeBundleSpillFile, writeExtractedFiles,
20
20
  } from "./artifact-fs-safety.mjs";
21
- import { isBinaryArtifactContent, redactSecrets, scanForSecrets } from "./artifact-secrets.mjs";
21
+ import { isBinaryArtifactContent, redactSecrets, scanArtifactSecrets, scanForSecrets, structuredArtifactViolations } from "./artifact-secrets.mjs";
22
22
 
23
23
  export {
24
24
  MAX_BUNDLE_AGGREGATE_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_BUNDLE_FILE_COUNT,
@@ -182,7 +182,7 @@ export function scanArtifacts(dir) {
182
182
  failures.push({ file, violation: `artifact scan ${snapshot.reason}` });
183
183
  return;
184
184
  }
185
- for (const violation of scanForSecrets(snapshot.content)) findings.push({ file, violation });
185
+ for (const violation of [...scanArtifactSecrets(path, snapshot.content), ...structuredArtifactViolations(path, snapshot.content)]) findings.push({ file, violation });
186
186
  },
187
187
  });
188
188
  return failures.length > 0 ? failures : findings;
@@ -265,7 +265,7 @@ export function buildPlatformArtifactBundle(root, pathPrefix = "") {
265
265
  return;
266
266
  }
267
267
  const { content } = snapshot;
268
- for (const violation of scanForSecrets(content)) findings.push({ file, violation });
268
+ for (const violation of scanArtifactSecrets(path, content)) findings.push({ file, violation });
269
269
  if (pathViolations.length > 0 || !shouldTransportBundleFile(canonicalRoot, path, pathPrefix)) return;
270
270
  if (isBinaryArtifactContent(content)) {
271
271
  limitReasons.push("binary-content");
@@ -308,7 +308,7 @@ function validatePlatformArtifactBundle(bundle) {
308
308
  aggregateBytes += file.size;
309
309
  if (aggregateBytes > MAX_BUNDLE_AGGREGATE_BYTES) return undefined;
310
310
  const content = decodeCanonicalBase64(file.contentBase64, file.size);
311
- if (!content || content.length !== file.size || isBinaryArtifactContent(content) || scanForSecrets(content).length > 0) return undefined;
311
+ if (!content || content.length !== file.size || isBinaryArtifactContent(content) || scanArtifactSecrets(file.path, content).length > 0) return undefined;
312
312
  files.push({ path: file.path, content });
313
313
  }
314
314
  for (const path of paths) {
@@ -406,9 +406,12 @@ export function extractPlatformArtifactBundle(outputDir, stdout) {
406
406
 
407
407
  const files = [];
408
408
  const violations = [];
409
+ let structuredValid = true;
409
410
  for (const file of decodedFiles) {
410
411
  const text = file.content.toString("utf8");
411
- violations.push(...scanForSecrets(file.content).map((violation) => ({ file: file.path, violation })));
412
+ const malformed = structuredArtifactViolations(file.path, file.content);
413
+ structuredValid &&= malformed.length === 0;
414
+ violations.push(...malformed.map((violation) => ({ file: file.path, violation })));
412
415
  if (file.path.endsWith("redaction-violations.json")) {
413
416
  try {
414
417
  const parsed = JSON.parse(text);
@@ -417,11 +420,13 @@ export function extractPlatformArtifactBundle(outputDir, stdout) {
417
420
  })));
418
421
  } catch {}
419
422
  }
420
- files.push({ path: file.path, content: Buffer.from(redactSecrets(text)) });
423
+ // validatePlatformArtifactBundle already secret-scanned these bytes.
424
+ // Keep even malformed evidence verbatim for diagnosis; never repair it.
425
+ files.push({ path: file.path, content: /\.jsonl?$/i.test(file.path) ? file.content : Buffer.from(redactSecrets(text)) });
421
426
  }
422
427
 
423
428
  const succeeded = writeExtractedFiles(outputDir, files);
424
- return succeeded ? { ok: true, violations } : failed;
429
+ return succeeded ? { ok: structuredValid, violations } : failed;
425
430
  }
426
431
 
427
432
  function collectFiles(root) {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { writeFileSync, mkdirSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
+ import { stripVTControlCharacters } from "node:util";
11
12
  import { matchesWrappedLineAt } from "./wrapped-line-match.mjs";
12
13
 
13
14
  const CARD_PATTERNS = [
@@ -35,10 +36,7 @@ const CARD_PATTERNS = [
35
36
  ];
36
37
 
37
38
  function cleanLine(line) {
38
- return line
39
- .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
40
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
41
- .replace(/\r/g, "");
39
+ return stripVTControlCharacters(line).replace(/\r/g, "");
42
40
  }
43
41
 
44
42
  function matchesCardAt(lines, index, card) {
@@ -13,6 +13,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSy
13
13
  import { tmpdir } from "node:os";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { setTimeout as delay } from "node:timers/promises";
16
+ import { stripVTControlCharacters } from "node:util";
16
17
  import { redactSecrets, writePlatformArtifactBundle } from "./artifacts.mjs";
17
18
  import { extractContentText, jsonlHasAssistantFinalTextMarker } from "./jsonl-text.mjs";
18
19
  import { getScenario, renderPrompt } from "./scenarios.mjs";
@@ -237,10 +238,6 @@ function prepareSharedPackedInstall(prepDir, logDir, artifactDir, packageName) {
237
238
  return prepared;
238
239
  }
239
240
 
240
- function stripANSI(text) {
241
- return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
242
- }
243
-
244
241
  function ptySpawnCommand(args) {
245
242
  const cliEntry = resolve(process.cwd(), "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
246
243
  if (!existsSync(cliEntry)) throw new Error(`Pi CLI entry not found: ${cliEntry}`);
@@ -278,7 +275,6 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
278
275
  }
279
276
 
280
277
  let ansi = "";
281
- let plain = "";
282
278
  const events = [];
283
279
  const startedAt = Date.now();
284
280
  const { file, args } = ptyCommand;
@@ -297,7 +293,6 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
297
293
  let exitEvent;
298
294
  child.onData((data) => {
299
295
  ansi += data;
300
- plain += stripANSI(data);
301
296
  events.push({ type: "output", elapsedMs: Date.now() - startedAt, bytes: data.length });
302
297
  });
303
298
  child.onExit((event) => {
@@ -313,7 +308,9 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
313
308
  let abortObserved = false;
314
309
  const abortStartedPath = join(cwd, ".debug", "platform-smoke", "abort-started.txt");
315
310
  while (Date.now() - startedAt < waitMs) {
316
- const currentPlain = plain;
311
+ // PTY chunks may split an OSC-8 link. Strip the complete captured stream,
312
+ // not individual chunks, and preserve the label between open/close links.
313
+ const currentPlain = stripVTControlCharacters(ansi);
317
314
  const responsePlain = currentPlain.slice(responseStartOffset);
318
315
  if (finalMarker && responsePlain.includes(finalMarker)) finalMarkerSeen = true;
319
316
  if (finalMarkerSeen && sessionJsonlMeetsRequirements(sessionDir, scenario) && sessionJsonlHasAssistantMarker(sessionDir, finalMarker)) {
@@ -364,6 +361,7 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
364
361
  await waitForSessionJsonl(sessionDir, null, startedAt, events);
365
362
  await delay(1_000);
366
363
 
364
+ const plain = stripVTControlCharacters(ansi);
367
365
  writeRedactedTextFile(join(artifactDir, "terminal.ansi"), ansi);
368
366
  writeRedactedTextFile(join(artifactDir, "terminal.txt"), plain);
369
367
  writeFileSync(join(artifactDir, "pty.events.jsonl"), events.map((event) => JSON.stringify(event)).join("\n") + "\n");
@@ -441,7 +439,9 @@ function writeJsonlArtifacts(artifactDir, sessionDir) {
441
439
  const jsonlFiles = findJsonlFiles(sessionDir);
442
440
  writeRedactedTextFile(join(artifactDir, "session-jsonl-files.txt"), jsonlFiles.join("\n") + (jsonlFiles.length ? "\n" : ""));
443
441
  if (jsonlFiles[0]) {
444
- writeRedactedTextFile(join(artifactDir, "session.jsonl"), readFileSync(jsonlFiles[0], "utf8"));
442
+ // The bundle boundary secret-scans structured evidence before transport.
443
+ // Text redaction here corrupts JSON escaping and numeric fields.
444
+ copyFileSync(jsonlFiles[0], join(artifactDir, "session.jsonl"));
445
445
  }
446
446
  return jsonlFiles;
447
447
  }
@@ -8,6 +8,7 @@
8
8
  import { spawn } from "node:child_process";
9
9
  import { writeFileSync, appendFileSync, mkdirSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
+ import { stripVTControlCharacters } from "node:util";
11
12
 
12
13
  const COLS = 150;
13
14
  const ROWS = 45;
@@ -27,7 +28,6 @@ export async function capturePTY(dir, command, opts = {}) {
27
28
  const txtPath = resolve(dir, "terminal.txt");
28
29
 
29
30
  let ansiBuffer = "";
30
- let txtBuffer = "";
31
31
 
32
32
  function appendEvent(event) {
33
33
  appendFileSync(eventsPath, JSON.stringify(event) + "\n");
@@ -35,8 +35,6 @@ export async function capturePTY(dir, command, opts = {}) {
35
35
  function appendOutput(data) {
36
36
  const text = typeof data === "string" ? data : data.toString();
37
37
  ansiBuffer += text;
38
- // Strip ANSI for plain text
39
- txtBuffer += stripANSI(text);
40
38
  }
41
39
 
42
40
  // Try node-pty first
@@ -79,7 +77,7 @@ export async function capturePTY(dir, command, opts = {}) {
79
77
  });
80
78
 
81
79
  writeFileSync(ansiPath, ansiBuffer);
82
- writeFileSync(txtPath, txtBuffer);
80
+ writeFileSync(txtPath, stripVTControlCharacters(ansiBuffer));
83
81
 
84
82
  return { code: code ?? (signal ? 1 : 0), signal, ansiPath, txtPath, eventsPath };
85
83
  } else {
@@ -111,17 +109,12 @@ export async function capturePTY(dir, command, opts = {}) {
111
109
  });
112
110
 
113
111
  writeFileSync(ansiPath, ansiBuffer);
114
- writeFileSync(txtPath, txtBuffer);
112
+ writeFileSync(txtPath, stripVTControlCharacters(ansiBuffer));
115
113
 
116
114
  return { code: code ?? (signal ? 1 : 0), signal, ansiPath, txtPath, eventsPath };
117
115
  }
118
116
  }
119
117
 
120
- /** Strip ANSI escape sequences from a string. */
121
- function stripANSI(str) {
122
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
123
- }
124
-
125
118
  /** Self-test: can we create a simple PTY? */
126
119
  export async function ptySelfTest() {
127
120
  const dir = resolve(process.cwd(), ".artifacts", "pty-self-test");
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
+ import { stripVTControlCharacters } from "node:util";
10
11
 
11
12
  import { buildTerminalHtml, writeTerminalScreenshot } from "../lib/cursor-visual-render.mjs";
12
13
 
@@ -14,14 +15,10 @@ const COLS = 150;
14
15
  const ROWS = 45;
15
16
  const HISTORY_LINES = 3_000;
16
17
 
17
- function stripANSI(text) {
18
- return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
19
- }
20
-
21
18
  /** Render terminal.ansi to terminal.html using the shared xterm.js renderer. */
22
19
  export async function renderHTML(ansiPath, htmlPath, options = {}) {
23
20
  const ansi = readFileSync(ansiPath, "utf8");
24
- const plain = options.plain ?? stripANSI(ansi);
21
+ const plain = options.plain ?? stripVTControlCharacters(ansi);
25
22
  const html = buildTerminalHtml({
26
23
  ansi,
27
24
  plain,
@@ -11,6 +11,7 @@ import {
11
11
  } from "./artifact-bundle-contract.mjs";
12
12
  import { redactSecrets } from "./artifact-secrets.mjs";
13
13
  import {
14
+ scanArtifacts,
14
15
  writeCommand,
15
16
  writeExitCode,
16
17
  writeManifest,
@@ -29,6 +30,12 @@ export function finalizeSuiteArtifacts(
29
30
  summaryData,
30
31
  expectedFiles,
31
32
  ) {
33
+ const malformed = scanArtifacts(suiteDir).filter(({ violation }) => violation.startsWith("invalid JSON"));
34
+ checks = [...checks, {
35
+ id: "structured-artifacts-parseable",
36
+ fn: () => malformed.length === 0,
37
+ error: malformed.map(({ file, violation }) => `${file}: ${violation}`).join("; "),
38
+ }];
32
39
  const assertions = runAssertions(suiteDir, checks);
33
40
  writeSummary(suiteDir, { ...summaryData, ok: assertions.ok });
34
41
  const expected = assertions.ok
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
9
+ import { scanArtifactSecrets } from "./artifact-secrets.mjs";
9
10
  import { resolve } from "node:path";
10
11
 
11
12
  import {
@@ -604,7 +605,8 @@ async function executeLiveSuite(config, targetName, suiteName, suiteDir, slug, l
604
605
  },
605
606
  }));
606
607
  const violations = [
607
- ...scanForSecrets(result.stdout + result.stderr + terminalText + jsonlRaw).map((violation) => ({ file: "process-output", violation })),
608
+ ...scanForSecrets(result.stdout + result.stderr + terminalText).map((violation) => ({ file: "process-output", violation })),
609
+ ...scanArtifactSecrets(jsonlPath, jsonlRaw).map((violation) => ({ file: "artifacts/session.jsonl", violation })),
608
610
  ...bundle.violations,
609
611
  ...scanArtifacts(suiteDir),
610
612
  ];
@@ -181,14 +181,16 @@ function emitCursorNativeToolUseTurn(
181
181
  const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
182
182
  for (const tool of tools) {
183
183
  const contentIndex = partial.content.length;
184
+ // Pi persists JSON arguments: keep the completed call identical to its wire delta.
185
+ const serializedArgs = JSON.stringify(tool.args);
184
186
  partial.content.push({
185
187
  type: "toolCall",
186
188
  id: tool.id,
187
189
  name: tool.toolName,
188
- arguments: tool.args,
190
+ arguments: JSON.parse(serializedArgs),
189
191
  });
190
192
  stream.push({ type: "toolcall_start", contentIndex, partial });
191
- stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(tool.args), partial });
193
+ stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
192
194
  const block = partial.content[contentIndex];
193
195
  if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
194
196
  if (recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate })) {
@@ -237,14 +239,15 @@ function emitCursorBridgeToolUseTurn(
237
239
  ): void {
238
240
  for (const request of requests) {
239
241
  const contentIndex = partial.content.length;
242
+ const serializedArgs = JSON.stringify(request.args);
240
243
  partial.content.push({
241
244
  type: "toolCall",
242
245
  id: request.piToolCallId,
243
246
  name: request.piToolName,
244
- arguments: request.args,
247
+ arguments: JSON.parse(serializedArgs),
245
248
  });
246
249
  stream.push({ type: "toolcall_start", contentIndex, partial });
247
- stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(request.args), partial });
250
+ stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
248
251
  const block = partial.content[contentIndex];
249
252
  if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
250
253
  }