pi-cursor-sdk 0.3.7 → 0.3.8

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.8 - 2026-09-19
4
+
5
+ ### Fixed
6
+
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Changed
13
+
14
+ - 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`.
15
+ - **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.
16
+
3
17
  ## 0.3.7 - 2026-09-16
4
18
 
5
19
  ### Fixed
package/README.md CHANGED
@@ -70,6 +70,15 @@ 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 now `src/index.ts`. Update any filters in this package's `extensions` setting that target the old `dist/` entry, preserving their include/exclude intent:
76
+
77
+ - Exact filters: `+dist/index.js` → `+src/index.ts`; `-dist/index.js` → `-src/index.ts`.
78
+ - Glob filters: for example, `dist/**` → `src/**` and `!dist/**` → `!src/**`. Adjust other old-dist patterns to match the new entrypoint with the same intent.
79
+
80
+ To keep Cursor disabled across upgrades and rollback, retain `-dist/index.js` and add `-src/index.ts`; the old exclusion alone no longer disables Cursor. The package does not rewrite user or project settings.
81
+
73
82
  ### Project-local install
74
83
 
75
84
  Use `-l` if you want the package recorded in the current project's `.pi/settings.json` instead of your global pi settings:
@@ -91,11 +100,13 @@ Without `--approve`, the project-local extension still runs after Pi trusts the
91
100
  For development from this repository:
92
101
 
93
102
  ```bash
94
- npm install # runs prepare, which compiles src/ into dist/ (the manifest entry pi loads)
103
+ npm install # runs prepare to build dist/ for scripts and programmatic consumers
95
104
  pi --approve -e . --model cursor/grok-4.6
96
105
  ```
97
106
 
98
- After editing `src/`, run `npm run build` before the next `pi -e .` run, or pi loads the previous build.
107
+ Pi loads the packaged `src/index.ts` graph through its native TypeScript loader. This keeps Pi imports bound to the running host even when another Pi version is installed alongside the extension. `dist/` remains shipped for scripts and programmatic consumers. After editing `src/`, run `npm run build` for validation and compiled consumers, then restart Pi.
108
+
109
+ The host-identity fix adds initial startup/load work: three fresh-process loader measurements on macOS with Node 24.21.0, Pi fork `90e6`, a warm Jiti disk cache, and network denied took 0.85–1.24 s for source versus 0.08–0.13 s for dist. These are environment-specific loader timings, not full TUI startup measurements or timing guarantees. That host caches extension factories for the same cwd; the initial-load cost does not describe every in-process `/reload`.
99
110
 
100
111
  ## Configure your Cursor SDK API key
101
112
 
@@ -708,7 +719,7 @@ npm install
708
719
  CURSOR_API_KEY="your-key" pi --approve -e . --model cursor/grok-4.6
709
720
  ```
710
721
 
711
- After editing `src/`, run `npm run build` before the next `pi -e .` run, or pi loads the previous build.
722
+ After editing `src/`, run `npm run build` for validation and compiled consumers, then restart Pi to load the changed source graph.
712
723
 
713
724
  Maintainer design notes live in [`docs/cursor-model-ux-spec.md`](docs/cursor-model-ux-spec.md).
714
725
 
@@ -9,6 +9,7 @@ import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text
9
9
  import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
10
10
  import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
11
11
  import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
12
+ import { isJsonObject } from "./cursor-record-utils.js";
12
13
  export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
13
14
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
14
15
  let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
@@ -128,6 +129,8 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
128
129
  function emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, tools, debugRecorder) {
129
130
  const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
130
131
  for (const tool of tools) {
132
+ if (!isJsonObject(tool.args))
133
+ throw new Error("Cursor replay tool arguments must be a JSON object");
131
134
  const contentIndex = partial.content.length;
132
135
  partial.content.push({
133
136
  type: "toolCall",
@@ -172,6 +175,8 @@ function emitInactiveCursorReplayTrace(turn, tools, debugRecorder) {
172
175
  }
173
176
  function emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests) {
174
177
  for (const request of requests) {
178
+ if (!isJsonObject(request.args))
179
+ throw new Error("Cursor bridge tool arguments must be a JSON object");
175
180
  const contentIndex = partial.content.length;
176
181
  partial.content.push({
177
182
  type: "toolCall",
@@ -1,3 +1,22 @@
1
+ /** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
2
+ export function isJsonObject(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value)
4
+ && isJsonValue(value, new Set());
5
+ }
6
+ function isJsonValue(value, ancestors) {
7
+ if (value === null || typeof value === "string" || typeof value === "boolean")
8
+ return true;
9
+ if (typeof value === "number")
10
+ return Number.isFinite(value);
11
+ if (typeof value !== "object" || ancestors.has(value))
12
+ return false;
13
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
14
+ return false;
15
+ ancestors.add(value);
16
+ const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
17
+ ancestors.delete(value);
18
+ return valid;
19
+ }
1
20
  export function asRecord(value) {
2
21
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
3
22
  }
@@ -4,7 +4,7 @@ Short maintainer checklist for **minimal-surface** validation after prompt, brid
4
4
 
5
5
  ## Minimal environment
6
6
 
7
- - Build first after any `src/` edit: `npm run build` (the pi manifest loads compiled `dist/`)
7
+ - Build first after any `src/` edit: `npm run build` (validation and compiled-script consumers); restart Pi to load the changed `src/index.ts` manifest graph
8
8
  - Extension only: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`
9
9
  - Fresh session dir: `--session-dir /tmp/pi-cursor-dogfood-<id>`
10
10
  - Baseline surface (no ambient Cursor MCP/rules):
@@ -8,7 +8,7 @@ Use this manual checklist during development and debugging of Cursor provider/ru
8
8
 
9
9
  ## Inner-loop rule
10
10
 
11
- - Build first: `npm run build` after any `src/` edit — the pi manifest loads compiled `dist/`, so unbuilt runs validate stale code. (the cloud/steering/local-resume/provider-debug launchers rebuild automatically even when run directly with `node scripts/...`; `smoke:live`/`smoke:visual`/`smoke:isolated` build via their npm scripts; direct `pi -e .` invocations do not build.)
11
+ - Build first: `npm run build` after any `src/` edit validates emit and updates `dist/` for scripts/programmatic consumers. Pi loads the packaged `src/index.ts` graph; restart Pi after source changes. The cloud/steering/local-resume/provider-debug launchers rebuild automatically even when run directly with `node scripts/...`; `smoke:live`/`smoke:visual`/`smoke:isolated` build via their npm scripts. Packed-install gates must exercise the tarball's actual manifest, not a checkout path.
12
12
  - Run from a clean working tree except for the intended branch diff.
13
13
  - Use the local extension under test: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`.
14
14
  - Use a temporary `--session-dir` for every run.
@@ -54,6 +54,22 @@ If resync runs but `context.tools` is still stale (e.g. only `read` listed), the
54
54
 
55
55
  The test covers bootstrap/incremental prompts, empty-vs-absent request tools, native replay/drain, cloud fresh/bootstrap selection, and actual host prompt serialization for context files and skills. It is offline contract evidence, not a replacement for the required live platform/cloud release gates.
56
56
 
57
+ ### Native package identity regression
58
+
59
+ Pi's manifest loads the shipped `src/index.ts` graph, while scripts and programmatic consumers retain `dist/`. Native ESM imports of compiled JS can bypass Pi's host-peer aliases when another Pi version is installed beside the extension. A TypeScript wrapper around compiled JS does not fix transitive ownership. Do not remove local peers or use an import hook to turn this regression green.
60
+
61
+ `test/native-package-host.test.ts` packs and normally installs the package with both 0.84.0 and 0.85.1 peers, then exercises the actual manifest via the installed development host's SDK and CLI (including the bundled CLI when shipped by that host; Pi 0.84 ships only the modular CLI). It also checks a minimal native managed install. For each additional pinned host, run the same offline probe against the installed tarball (not this checkout):
62
+
63
+ ```bash
64
+ node scripts/check-pi-host-runtime.mjs /path/to/pi-coding-agent /path/to/installed/pi-cursor-sdk --out=/new/evidence/sdk
65
+ node scripts/check-pi-host-runtime.mjs /path/to/pi-coding-agent /path/to/installed/pi-cursor-sdk --cli=modular --out=/new/evidence/modular
66
+ node scripts/check-pi-host-runtime.mjs /path/to/pi-coding-agent /path/to/installed/pi-cursor-sdk --cli=bundled --out=/new/evidence/bundled
67
+ ```
68
+
69
+ The probe asserts host stream identity, ordinary prompt/tool replay, recorded native tool emission and replay execution without reading the fixture path, final drain, lazy bridge/SDK/SQLite imports and bundled ripgrep lookup. The SDK lane also invokes the actual manifest-registered provider callback with no key, requiring the expected auth preflight error before SDK execution. It uses no import redirection and makes no Cursor requests. The recorded tool fixture is offline replay evidence, not a claim of a live SDK tool call.
70
+
71
+ Every host must pass the common provider, context and replay checks. Add `--verify-fork-checkpoints` only to qualify the fork's native `SystemMessage.replace` support; explicit qualification fails on hosts lacking those semantics or transcript helpers. This additional host check is not a Cursor requirement. Published Pi 0.85.1 uses its supported shorthand context instead of synthetic transcript messages. None of these probes replaces the live platform gate.
72
+
57
73
  ## Auth: use `auth.json`, not only env
58
74
 
59
75
  pi resolves Cursor auth in this order:
@@ -177,6 +193,8 @@ Session summaries can hide per-message usage bugs. When investigating token or c
177
193
 
178
194
  The compaction poison fixture mirrors the observed failure shape: one assistant message with `RunResult`-sized input/cache-read counts near 1M immediately before compaction. Regression coverage should prove that such usage falls back to bounded pi estimates before it reaches `AssistantMessage.usage`.
179
195
 
196
+ Compaction continuity needs content evidence, not just a new agent ID or a successful recall reply. The compaction smoke checks the returned and persisted summaries for the exact marker before recall, verifies the original user was cut and the native kept context has no other marker source, and checks the actual bootstrap inputs too. Both post-compaction and restart recall reject tool calls: retained session/debug files must not become a filesystem fallback. Keep the raw summary and journals on failure as well as success; an unchanged later model success does not erase an earlier summary-content failure or prove a deterministic provider fix.
197
+
180
198
  ### False-positive edge case (2026-05-23)
181
199
 
182
200
  Plan-strip live smoke can make Cursor `read` testing docs that *document* replay failure strings. A naive whole-record JSON scan reported four failures from one successful `read` toolResult (`isError: false`).
@@ -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
@@ -206,7 +206,7 @@ The copy/switch lane copies a session file containing resume custom entries, swi
206
206
 
207
207
  The fallback lane rewrites a persisted handle to a missing local SDK `agent-*`, verifies create+bootstrap fallback, and asserts the continuity notice is emitted in `pi-stream-events.jsonl`.
208
208
 
209
- The compaction lane uses an isolated temp pi settings file with `compaction.keepRecentTokens: 1` to force manual compaction without huge dummy prompts. It verifies the pre-compaction SDK agent is not reused, the new handle records `compactionGeneration: 1`, and restart resumes the post-compaction agent.
209
+ The compaction lane uses an isolated temp pi settings file with `compaction.keepRecentTokens: 1` to force manual compaction without huge dummy prompts. Before recall, it requires the exact marker in both the returned and native persisted summary, agreement on the kept boundary, and removal of the original marker-bearing user from the kept context. Native `get_messages` and the subsequent captured provider inputs must supply the marker only through that summary. The post-compaction agent must bootstrap without reusing the baseline or captured summarizer agents, persist `compactionGeneration: 1`, and resume as the same agent after restart. Both recall turns must return the marker without tool calls, so searching retained QA files cannot satisfy continuity. The lane retains its journals/debug artifacts and `compaction-boundary.json` on success and failure.
210
210
 
211
211
  The default/opt-out lane verifies the built-in local resume default resumes, then verifies `PI_CURSOR_LOCAL_RESUME=0` opts out and creates a new agent while bootstrapping the transcript.
212
212
 
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.8",
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",
@@ -187,7 +187,7 @@
187
187
  },
188
188
  "pi": {
189
189
  "extensions": [
190
- "./dist/index.js"
190
+ "./src/index.ts"
191
191
  ]
192
192
  },
193
193
  "allowScripts": {
package/scripts/build.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Purpose: Produce the compiled runtime files that the Pi extension manifest loads.
3
+ * Purpose: Produce compiled runtime files for scripts and programmatic consumers.
4
+ * Pi's manifest loads the shipped source graph through its native TypeScript loader.
4
5
  * Responsibilities: Run TypeScript emit into a staging directory, then atomically swap it
5
6
  * into dist/ so a failed TypeScript emit never destroys a previously working dist.
6
7
  * Usage: `npm run build`; also invoked by scripts/prepare.mjs during install lifecycles.
@@ -307,7 +307,7 @@ function metadataFiles(artifactDir) {
307
307
  return files.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
308
308
  }
309
309
 
310
- function readMetadataSince(artifactDir, seenPaths) {
310
+ export function readMetadataSince(artifactDir, seenPaths) {
311
311
  return metadataFiles(artifactDir)
312
312
  .filter((metadataPath) => !seenPaths.has(metadataPath))
313
313
  .map((metadataPath) => ({
@@ -383,11 +383,6 @@ export function resumeEntryCount(entries) {
383
383
  return resumeEntries(entries).length;
384
384
  }
385
385
 
386
- export function compactionEntryCount(entries) {
387
- return (entries.entries ?? []).filter((entry) => entry?.type === "compaction")
388
- .length;
389
- }
390
-
391
386
  function entryText(entry) {
392
387
  const content = entry?.message?.content;
393
388
  if (typeof content === "string") return content;
@@ -8,7 +8,6 @@ import {
8
8
  assistantEntryContaining,
9
9
  buildLocalResumeSmokeEnv,
10
10
  cleanupArtifactRoot,
11
- compactionEntryCount,
12
11
  createRunContext,
13
12
  fail,
14
13
  getEntries,
@@ -17,6 +16,7 @@ import {
17
16
  parseTimeout,
18
17
  promptAbortAndRead,
19
18
  promptAndRead,
19
+ readMetadataSince,
20
20
  reportFailure,
21
21
  resumeEntries,
22
22
  scrubSmokeText,
@@ -514,10 +514,33 @@ async function runCompactionSmoke() {
514
514
  });
515
515
  assertTurnMetadata("compaction baseline", baseline, { resumedAgent: false });
516
516
  preCompactionAgentId = baseline.metadata.run.agentId;
517
+ const originalUser = userEntryContaining(await getEntries(rpc), marker);
518
+ if (!originalUser) fail("compaction baseline did not persist the marker-bearing user message");
517
519
  const result = await rpcData(rpc, "compact", { customInstructions: `Preserve the exact marker ${marker}.` }, timeoutMs);
518
- if (!result.summary || typeof result.tokensBefore !== "number") fail("manual compaction did not return a summary result", JSON.stringify(result, null, 2));
519
520
  const compactedEntries = await getEntries(rpc);
520
- if (compactionEntryCount(compactedEntries) < 1) fail("manual compaction did not append a compaction entry");
521
+ const compaction = compactedEntries.entries.findLast((entry) => entry.type === "compaction");
522
+ const active = await rpcData(rpc, "get_messages");
523
+ const summarizerTurns = readMetadataSince(artifactRoot, seenMetadata);
524
+ // Save the native boundary before assertions or any recall turn can inspect files.
525
+ const boundaryPath = join(artifactRoot, "compaction-boundary.json");
526
+ writeFileSync(boundaryPath, JSON.stringify({ result, compaction, originalUserEntryId: originalUser.id, activeMessages: active.messages, summarizerTurns }, null, 2));
527
+ if (typeof result.summary !== "string" || typeof result.tokensBefore !== "number") fail("manual compaction did not return a summary result", boundaryPath);
528
+ if (!result.summary.includes(marker)) fail("returned compaction summary lost the exact marker", boundaryPath);
529
+ if (!compaction) fail("manual compaction did not append a compaction entry", boundaryPath);
530
+ if (typeof compaction.summary !== "string" || !compaction.summary.includes(marker)) fail("persisted compaction summary lost the exact marker", boundaryPath);
531
+ if (compaction.summary !== result.summary || compaction.firstKeptEntryId !== result.firstKeptEntryId) fail("returned and persisted compaction boundaries disagree", boundaryPath);
532
+ const originalIndex = compactedEntries.entries.findIndex((entry) => entry.id === originalUser.id);
533
+ const keptIndex = compactedEntries.entries.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
534
+ const compactionIndex = compactedEntries.entries.indexOf(compaction);
535
+ if (originalIndex < 0 || keptIndex <= originalIndex || keptIndex >= compactionIndex) fail("compaction did not move the kept boundary past the original marker-bearing user", boundaryPath);
536
+ // Ask the host for its active messages; do not reproduce its cut/replay algorithm.
537
+ const summaryMessage = active.messages.find((message) => message.role === "compactionSummary" && message.summary === compaction.summary);
538
+ if (!summaryMessage) fail("native active context does not contain the persisted compaction summary", boundaryPath);
539
+ if (active.messages.some((message) => message !== summaryMessage && JSON.stringify(message).includes(marker))) fail("kept context independently supplies the compaction marker", boundaryPath);
540
+ for (const turn of summarizerTurns) {
541
+ assertNotResumedFrom("compaction summarizer", turn, preCompactionAgentId);
542
+ seenMetadata.add(turn.metadataPath);
543
+ }
521
544
 
522
545
  const postCompaction = await promptAndRead({
523
546
  rpc,
@@ -527,6 +550,19 @@ async function runCompactionSmoke() {
527
550
  seenMetadata,
528
551
  });
529
552
  assertNotResumedFrom("post-compaction turn", postCompaction, preCompactionAgentId);
553
+ for (const turn of summarizerTurns) assertNotResumedFrom("post-compaction turn versus summarizer", postCompaction, turn.metadata.run.agentId);
554
+ if (postCompaction.metadata.providerMeta?.sendPlan?.mode !== "bootstrap") fail("post-compaction turn did not bootstrap the compacted context", postCompaction.metadataPath);
555
+ // Inspect the actual provider inputs too: the native summary must be the only marker source.
556
+ const postDir = dirname(postCompaction.metadataPath);
557
+ const escapedSummary = JSON.stringify(compaction.summary).slice(1, -1);
558
+ for (const file of ["context-snapshot.json", "send-payload.json"]) {
559
+ const path = join(postDir, file);
560
+ const input = JSON.stringify(JSON.parse(readFileSync(path, "utf8")));
561
+ if (!input.includes(escapedSummary)) fail("post-compaction input omitted the persisted summary", path);
562
+ if (input.replace(escapedSummary, "").includes(marker)) fail("post-compaction input independently supplies the marker outside the summary", path);
563
+ }
564
+ const postSteps = readFileSync(join(postDir, "on-step.jsonl"), "utf8").split("\n").filter(Boolean).map(JSON.parse);
565
+ if (postSteps.some((row) => row.step?.type === "toolCall")) fail("post-compaction recall used tools instead of summary-only recall", postCompaction.metadataPath);
530
566
  if (!postCompaction.text.includes(`MARKER=${marker}`)) fail("post-compaction turn did not recall marker", JSON.stringify({ expected: `MARKER=${marker}`, actual: postCompaction.text }, null, 2));
531
567
  postCompactionAgentId = postCompaction.metadata.run.agentId;
532
568
  const postHandle = latestResumeEntry(await getEntries(rpc));
@@ -544,11 +580,14 @@ async function runCompactionSmoke() {
544
580
  });
545
581
  assertTurnMetadata("post-compaction restart", restart, { resumedAgent: true });
546
582
  if (restart.metadata.run.agentId !== postCompactionAgentId) fail("post-compaction restart did not resume the post-compaction agent", JSON.stringify({ expected: postCompactionAgentId, actual: restart.metadata.run.agentId }, null, 2));
583
+ const restartSteps = readFileSync(join(dirname(restart.metadataPath), "on-step.jsonl"), "utf8").split("\n").filter(Boolean).map(JSON.parse);
584
+ if (restartSteps.some((row) => row.step?.type === "toolCall")) fail("post-compaction restart used tools instead of recalling the marker", restart.metadataPath);
585
+ if (!restart.text.includes(`MARKER=${marker}`)) fail("post-compaction restart did not recall marker", JSON.stringify({ expected: `MARKER=${marker}`, actual: restart.text }, null, 2));
547
586
  });
548
587
  console.log("local-resume-compaction-smoke-ok");
549
588
  console.error(scrubSmokeText(`[local-resume-smoke] pre-compaction ${preCompactionAgentId} replaced by and resumed post-compaction ${postCompactionAgentId}`));
550
589
  } finally {
551
- cleanupArtifactRoot(artifactRoot);
590
+ console.error(scrubSmokeText(`[local-resume-smoke] retained compaction evidence: ${artifactRoot}`));
552
591
  }
553
592
  }
554
593
 
@@ -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");
@@ -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,
@@ -28,6 +28,7 @@ import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./curso
28
28
  import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
29
29
  import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
30
30
  import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
31
+ import { isJsonObject } from "./cursor-record-utils.js";
31
32
 
32
33
  export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
33
34
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
@@ -180,6 +181,7 @@ function emitCursorNativeToolUseTurn(
180
181
  ): void {
181
182
  const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
182
183
  for (const tool of tools) {
184
+ if (!isJsonObject(tool.args)) throw new Error("Cursor replay tool arguments must be a JSON object");
183
185
  const contentIndex = partial.content.length;
184
186
  partial.content.push({
185
187
  type: "toolCall",
@@ -236,6 +238,7 @@ function emitCursorBridgeToolUseTurn(
236
238
  requests: CursorPiBridgeToolRequest[],
237
239
  ): void {
238
240
  for (const request of requests) {
241
+ if (!isJsonObject(request.args)) throw new Error("Cursor bridge tool arguments must be a JSON object");
239
242
  const contentIndex = partial.content.length;
240
243
  partial.content.push({
241
244
  type: "toolCall",
@@ -1,3 +1,22 @@
1
+ import type { JsonValue } from "@earendil-works/pi-ai";
2
+
3
+ /** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
4
+ export function isJsonObject(value: unknown): value is Record<string, JsonValue> {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value)
6
+ && isJsonValue(value, new Set());
7
+ }
8
+
9
+ function isJsonValue(value: unknown, ancestors: Set<object>): boolean {
10
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
11
+ if (typeof value === "number") return Number.isFinite(value);
12
+ if (typeof value !== "object" || ancestors.has(value)) return false;
13
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
14
+ ancestors.add(value);
15
+ const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
16
+ ancestors.delete(value);
17
+ return valid;
18
+ }
19
+
1
20
  export function asRecord(value: unknown): Record<string, unknown> | undefined {
2
21
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
3
22
  }