pi-agent-browser-native 0.6.8 → 0.6.10

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 (34) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +16 -14
  3. package/dist/extensions/agent-browser/index.js +8 -21
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +6 -7
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +6 -0
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +4 -8
  7. package/dist/extensions/agent-browser/lib/command-taxonomy.js +15 -2
  8. package/dist/extensions/agent-browser/lib/electron/cleanup.js +5 -5
  9. package/dist/extensions/agent-browser/lib/electron/launch.js +77 -23
  10. package/dist/extensions/agent-browser/lib/managed-session-restore.js +2 -2
  11. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +3 -5
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +6 -14
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +11 -25
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +4 -4
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +26 -3
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +16 -18
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +5 -11
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +4 -3
  19. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +4 -1
  20. package/dist/extensions/agent-browser/lib/playbook.js +4 -4
  21. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +17 -29
  22. package/dist/extensions/agent-browser/lib/results/presentation/common.js +5 -5
  23. package/dist/extensions/agent-browser/lib/session-page-state.js +1 -1
  24. package/dist/extensions/agent-browser/lib/temp.js +14 -0
  25. package/dist/scripts/agent-browser-target.mjs +1 -1
  26. package/docs/ARCHITECTURE.md +5 -4
  27. package/docs/COMMAND_REFERENCE.md +38 -20
  28. package/docs/ELECTRON.md +8 -4
  29. package/docs/RELEASE.md +8 -4
  30. package/docs/SUPPORT_MATRIX.md +19 -15
  31. package/docs/TOOL_CONTRACT.md +15 -13
  32. package/package.json +1 -1
  33. package/scripts/agent-browser-capability-baseline.mjs +10 -3
  34. package/scripts/agent-browser-target.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.10 - 2026-09-08
4
+
5
+ ### Fixed
6
+
7
+ - Follow native artifact operands and raw-batch precedence consistently in preflight, recording reservations, result metadata and timeout recovery. Preserve literal batch operands and replay retries as a single native row (#168).
8
+ - Handle recording `--fps` options without losing the requested path or intended pinned page. Keep conservative start/ref protection for older supported natives without claiming a page replacement; FPS-only restarts keep refs (#169).
9
+ - Show positive native WebMCP availability in navigation summaries and distinguish current recording dependency checks from older deferred failures (#169).
10
+
11
+ ### Changed
12
+
13
+ - Recommend `agent-browser` 0.37.0 while keeping the stable 0.35.0 minimum, no upper cap and native-owned recording/tab setup.
14
+
15
+ ### Known limitations
16
+
17
+ - Native 0.37 short/cold recordings on Ubuntu can still fail or produce a shorter clip. This release does not change the upstream recording engine.
18
+
19
+ ## 0.6.9 - 2026-09-08
20
+
21
+ ### Fixed
22
+
23
+ - Include redacted stdout/stderr tails in failed Electron startup diagnostics and visible errors (#128). Capture uses private regular files inside the isolated profile, with the last 4096 bytes read per stream; this is not a lifetime disk limit. Preserve the profile and logs when failed-startup process cleanup cannot finish, without changing normal quit cleanup.
24
+
25
+ - Restore ordinary browser access to a tracked Electron app after Pi reload or resume by checking its live debug endpoint and the named upstream connection. Keep the app, profile, and session intact; unrelated or replaced connections still fail verification.
26
+
27
+ ### Validation
28
+
29
+ - Verified startup output, reload continuity and quit cleanup with genuine Electron through the official Pi SDK on native macOS and Ubuntu. Native Windows was waived and not run; permanent release gates are unchanged.
30
+
3
31
  ## 0.6.8 - 2026-09-07
4
32
 
5
33
  ### Fixed
package/README.md CHANGED
@@ -82,13 +82,13 @@ The result is optimized for agent work:
82
82
  | Clicks can report success without the page receiving the event | Top-level non-Electron direct `click` calls on `xpath=` targets or role-gated current `@e…` refs (`button`, `checkbox`, `menuitem`, `radio`, `switch`, `tab`) install a bounded target-specific DOM-event probe; eligible `@e…` refs require a unique role/name in both the latest snapshot and the live candidates. Duplicate-name refs pass through without a probe: their old ordinal cannot prove which element received the click. If upstream reports success but no trusted event reaches the resolved target, the wrapper fails the tool, exposes `details.clickDispatch`, and suggests explicit retry/inspect next actions (no in-page replay), including a nested-scroll `scrollintoview` action when the probe sees the target outside a scroll container or viewport. Unresolved locator clicks such as raw `find … click` are left upstream-owned to avoid false failures for frame-scoped targets. Other click results still expose `details.pageChangeSummary`; `observed: false` explicitly marks dispatch-only mutation summaries and adds a visible `Action dispatched; application change unverified` warning. Unchanged-URL clicks can surface evidence-backed `details.overlayBlockers` candidates. | [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md#details), `extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts`, `extensions/agent-browser/lib/results/presentation/navigation.ts`, `test/agent-browser.presentation.test.ts`, `test/agent-browser.extension-click-dispatch.test.ts` |
83
83
  | Dashboard scroll commands can look successful while nothing moves | Handles standard `scroll <dir> [px]` against the document first (including pages whose smooth-scroll CSS defeats upstream wheel timing), falls back upstream when the document cannot move, and samples viewport/containers around the fallback; unchanged positions fail as `upstream-error` with `details.scrollNoop`, visible recovery guidance, and exact snapshot/screenshot checks. Unsupported `scrollintoview text=...` fails before dispatch, including inside effective batch rows, and shows exact native `find text ... hover` and snapshot/ref recovery payloads; help remains native pass-through. | [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md#details), [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#core-page-and-element-commands), `test/agent-browser.extension-validation.test.ts` |
84
84
  | Dropdown/combobox clicks can focus or hit native option box-model errors | Adds first-class `select <selector> <value...>` paths through raw `args`, `job`, and `semanticAction`; semantic role/name or label select resolves exactly one current visible combobox/listbox ref before action. Custom combobox clicks still detect focused controls with explicit `aria-expanded` state but no visible options and return `details.comboboxFocus` plus exact recovery `nextActions` | [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md#details), [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#core-page-and-element-commands), `extensions/agent-browser/lib/input-modes/semantic-action.ts`, `test/agent-browser.extension-input-modes.test.ts`, `test/agent-browser.extension-validation.test.ts` |
85
- | Recording workflows fail late when `ffmpeg` is missing or report stale lifecycle state | After successful `record start` / `record restart`, reports `successCategory: "artifact-pending"`, returns an exact `stop-pending-recording` action, warns when `ffmpeg` is unavailable, and tells agents that `record start` switches to a fresh active page whose in-page state does not carry over while invalidating prior page-scoped `@e…` refs on every executed start attempt (even a failed already-active one) and on URL-bearing `record restart` (stale-ref until a fresh snapshot); an unbounded transcript-backed namespace/session index reserves active destinations across aliases, serializes artifact lifecycle and explicit wait/output writes, persists cross-branch close tombstones, retires every successful close path (including every matching namespace owner for `close --all`), rejects missing/stale restart output, coalesces terminal batch state, keeps only the newest pending path per identity, rejects recording starts after a nested close, folds Unicode path aliases, and retains exact cleanup actions with visible guidance on any later same-session failure | [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md#details), [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#diff-debug-and-streaming), `test/agent-browser.extension-validation.test.ts`, `test/agent-browser.presentation-artifacts-batch.test.ts` |
86
- | Upstream CLI drift can silently invalidate wrapper behavior | Publishes a repo-readable command reference, verifies it against the recommended 0.36.0 target, and probes browser-backed calls once per cwd/PATH so stable versions below the 0.35.0 floor fail before browser launch with installed/expected version evidence | `npm run verify` |
87
- | Pages can expose structured workflows through experimental WebMCP | Passes through `webmcp list`, `invoke`, detached `result` / `cancel`, params/frame/timeout options, and the bundled `webmcp-gen` skill; treats `--no-webmcp` as launch-scoped, keeps pending or unsuccessfully settled targets unverified with an actionable `get url` follow-up, invalidates stale refs after page tools run, and budgets effective raw or stdin batch timeouts | [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#webmcp-page-tools), `test/agent-browser.extension-ref-guards.test.ts`, `test/agent-browser.wait-timeouts.test.ts`, `test/agent-browser.real-upstream-contract.test.ts` |
85
+ | Recording workflows fail late when `ffmpeg` is missing or report stale lifecycle state | After successful `record start` / `record restart`, reports `successCategory: "artifact-pending"`, returns an exact `stop-pending-recording` action, warns when older natives report pending output without `ffmpeg`, and conservatively invalidates prior page-scoped `@e…` refs on every executed start attempt and URL-bearing restart to protect older supported natives. This is not evidence of a page change: 0.37 records the active page unless a URL is supplied; FPS-only calls keep the intended tab; an unbounded transcript-backed namespace/session index reserves active destinations across aliases, serializes artifact lifecycle and explicit wait/output writes, persists cross-branch close tombstones, retires every successful close path (including every matching namespace owner for `close --all`), rejects missing/stale restart output, coalesces terminal batch state, keeps only the newest pending path per identity, rejects recording starts after a nested close, folds Unicode path aliases, and retains exact cleanup actions with visible guidance on any later same-session failure | [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md#details), [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#diff-debug-and-streaming), `test/agent-browser.extension-validation.test.ts`, `test/agent-browser.presentation-artifacts-batch.test.ts` |
86
+ | Upstream CLI drift can silently invalidate wrapper behavior | Publishes a repo-readable command reference, verifies it against the recommended 0.37.0 target, and probes browser-backed calls once per cwd/PATH so stable versions below the 0.35.0 floor fail before browser launch with installed/expected version evidence | `npm run verify` |
87
+ | Pages can expose structured workflows through experimental WebMCP | Shows native positive WebMCP availability in navigation summaries and passes through `webmcp list`, `invoke`, detached `result` / `cancel`, params/frame/timeout options, and the bundled `webmcp-gen` skill; treats `--no-webmcp` as launch-scoped, keeps pending or unsuccessfully settled targets unverified with an actionable `get url` follow-up, invalidates stale refs after page tools run, and budgets effective raw or stdin batch timeouts | [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#webmcp-page-tools), `test/agent-browser.extension-ref-guards.test.ts`, `test/agent-browser.wait-timeouts.test.ts`, `test/agent-browser.real-upstream-contract.test.ts` |
88
88
  | Desktop Electron apps need discovery, CDP attach, and safe teardown | Top-level `electron` runs host `list` / isolated `launch` (temp profile, OS-chosen debug port) / `status` / `probe` / `cleanup`, merges `launchId` plus managed `sessionName`, supports `handoff` `snapshot` / `tabs` / `connect`, and surfaces mismatch and post-command health guidance; wrapper cleanup applies only to launches it created | `extensions/agent-browser/lib/electron/discovery.ts`, `launch.ts`, `cleanup.ts`, [`docs/TOOL_CONTRACT.md`](docs/TOOL_CONTRACT.md#electron), [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#electron-desktop-apps) |
89
89
  | Agents need bundled `skills` text and local setup/status commands without touching the live session | Treats `skills list/get/path`, local auth/profile/setup commands, `session list`, and local state lifecycle commands as sessionless reads/actions when upstream does not need a live page. Session/state rows and targets remain visible, and supported upstream state/config/path operations pass through unchanged. Browser-backed workflows still receive an implicit session only when the caller did not choose one. | [`docs/COMMAND_REFERENCE.md`](docs/COMMAND_REFERENCE.md#built-in-skills), `extensions/agent-browser/lib/command-policy.ts`, `extensions/agent-browser/lib/runtime.ts` |
90
90
 
91
- Artifact results show known requested paths separately from reported/resolved locations and infer image MIME types from file headers, not suffixes. Parent-directory failures return path-specific `validation-error` guidance before browser dispatch. Use absolute artifact paths in raw batch strings because the daemon's working directory may differ from Pi's. Dispatched `record start` and URL-bearing `record restart` attempts also return one fresh-snapshot warning on success or failure, including in JSON output; unreached rows do not claim a page change.
91
+ Artifact results show known requested paths separately from reported/resolved locations and infer image MIME types from file headers, not suffixes. Parent-directory failures return path-specific `validation-error` guidance before browser dispatch. Use absolute artifact paths in raw batch strings because the daemon's working directory may differ from Pi's. Dispatched `record start` and URL-bearing `record restart` attempts also return one fresh-snapshot warning on success or failure, including in JSON output. It describes conservative ref invalidation, not an observed page change; unreached rows do not emit it.
92
92
 
93
93
  ## Fastest way to try it
94
94
 
@@ -104,13 +104,13 @@ Optional external tools unlock the full command surface:
104
104
  | Dependency | Required for | macOS install example |
105
105
  | --- | --- | --- |
106
106
  | `agent-browser` | All browser automation through this extension | See upstream install docs |
107
- | `ffmpeg` | `record stop` WebM encoding after `record start` / `record restart` | `brew install ffmpeg` or `brew install ffmpeg-full` |
107
+ | `ffmpeg` | Recording WebM (libvpx) or MP4 (libx264); install before `record start` / `record restart` | `brew install ffmpeg` or `brew install ffmpeg-full` |
108
108
 
109
- Keep both binaries on `PATH`. This package recommends `agent-browser 0.36.0` and accepts stable versions at or above the 0.35.0 floor; browser-backed calls fail fast below that floor while local inspection/setup commands remain available for diagnosis. `record start` can begin without a file on disk, but `record stop` needs `ffmpeg` to encode the WebM.
109
+ Keep both binaries on `PATH`. This package recommends `agent-browser 0.37.0` and accepts stable versions at or above the 0.35.0 floor; browser-backed calls fail fast below that floor while local inspection/setup commands remain available for diagnosis. Native 0.37 validates `ffmpeg`, the output extension and FPS before recording; older supported natives may defer failures until stop. Use `record start <path.webm|path.mp4> [url] [--fps <n>]` (1–60 fps, default 30), then verify the completed file after `record stop`. Native `doctor` checks the recording dependency and encoders.
110
110
 
111
111
  ### Android / Termux
112
112
 
113
- Android support currently uses Termux's system Chromium rather than Chrome for Testing. Upstream issue [vercel-labs/agent-browser#1587](https://github.com/vercel-labs/agent-browser/issues/1587) tracks native Android packaging; until upstream ships an Android launcher, install the packaged Linux-musl arm64 binary without lifecycle scripts and point the global command at it:
113
+ Android support currently uses Termux's system Chromium rather than Chrome for Testing. Upstream issue [vercel-labs/agent-browser#1587](https://github.com/vercel-labs/agent-browser/issues/1587) tracks native Android packaging; until upstream ships an Android launcher, install the packaged Linux-musl arm64 binary without lifecycle scripts and point the global command at it. The last locally validated Android setup used 0.36.0:
114
114
 
115
115
  ```bash
116
116
  pkg install tur-repo x11-repo
@@ -135,6 +135,8 @@ Then install this Pi package:
135
135
  pi install npm:pi-agent-browser-native
136
136
  ```
137
137
 
138
+ After updating `pi-agent-browser-native`, fully quit and restart Pi before using the updated tools. `/reload` can retain previously loaded compiled JavaScript even after `dist/` is rebuilt, so it is not a reliable way to pick up package updates.
139
+
138
140
  Start Pi and ask for a browser action:
139
141
 
140
142
  ```text
@@ -184,7 +186,7 @@ npm run doctor
184
186
  The doctor checks:
185
187
 
186
188
  - upstream `agent-browser` exists on `PATH`
187
- - the installed upstream is a stable version at or above the supported 0.35.0 floor; 0.36.0 remains the recommended baseline
189
+ - the installed upstream is a stable version at or above the supported 0.35.0 floor; 0.37.0 remains the recommended baseline
188
190
  - `pi --version` meets the minimum Pi runtime floor for this release; older Pi versions are setup failures
189
191
  - Pi settings do not point at multiple active `pi-agent-browser-native` sources
190
192
 
@@ -385,7 +387,7 @@ Evaluate page JavaScript through stdin. Put the script in the top-level `stdin`
385
387
 
386
388
  Use `outputPath` when `eval`, `get`, `snapshot`, or another extraction should be saved as a durable workspace file. Keep it distinct from screenshot, download, recording, and other browser artifact destinations; preflight rejects known same-call aliases before browser activity, and the result writer preserves the browser artifact if an alias becomes apparent only afterward. The wrapper writes `details.data` when present, otherwise the model-facing text content. When presentation compacted a large direct result, a result row, or the whole `batch`, it instead reads the full command-redacted pre-compaction payload only from the corresponding live wrapper-managed spill recorded in `details.artifactManifest`; if any required spill is unavailable or untrusted, the call fails without writing compact metadata to the requested path. `details.outputFile` reports the saved path and byte count. Explicit upstream `--json` content stays parseable; in that case the save notice lives only in `details.outputFile`.
387
389
 
388
- Extract several known refs or selectors in one `batch` call instead of many serial getter calls:
390
+ Extract several known refs or selectors in one `batch` call instead of many serial getter calls. When a prior snapshot and session are available and the same-page freshness checks apply, ref-consuming calls add one extra `snapshot -i` preflight per top-level call or batch. Batching shares that probe across rows; it does not remove it:
389
391
 
390
392
  ```json
391
393
  { "args": ["batch"], "stdin": "[[\"get\",\"text\",\"@e64\"],[\"get\",\"text\",\"@e65\"]]" }
@@ -455,7 +457,7 @@ Typical pitfalls:
455
457
 
456
458
  ### Constrained browser jobs
457
459
 
458
- For short repeatable workflows, pass a top-level `job` instead of hand-writing `batch` stdin. Keep dynamic app jobs short around navigation, click, and rerender boundaries; avoid packing a whole checkout into one job. The wrapper only supports constrained steps (`open`, `click`, `fill`, `type`, `select`, `wait`, `assertText`, `assertUrl`, `waitForDownload`, `snapshot`, and `screenshot`), compiles them to existing upstream `batch` commands, and echoes the compiled commands as `details.compiledJob` for auditability. `open` steps can include `loadState` (`domcontentloaded`, `load`, or `networkidle`) to insert a readiness wait before the next step. `click` and `fill` steps can use either CSS `selector` or semantic locator fields (`locator`, `role`/`value`, optional `name`) so a job can express flows like role/name search without brittle selectors. `type` can use `selector`, `text`, optional `delayMs` for per-character pacing, and optional `press` for a final key such as `Enter`; paced type compiles to existing `focus`, `keyboard type`, `wait`, and `press` batch rows, is capped at 200 characters per delayed step, and compacts model-visible batch text while full rows remain in `details.batchSteps`. The same compile path backs top-level `qa`, so long `qa` runs surface the same timeout evidence shape. If a long `job`, `qa`, or `batch` hits the wrapper watchdog, `details.timeoutPartialProgress` may recover per-step status (`completed`, `failed`, `pending`, or `unknown`), current page URL plus a title after the URL is verified, declared artifact paths that already exist on disk, and either a `retry-timeout-step` next action for the first incomplete read-only or idempotent step, `inspect-current-page-after-timeout` when the target is already verified, or a fail-fast `verify-page-target-after-timeout` batch that runs `get url` before `snapshot -i` when the target is unknown (see [`docs/TOOL_CONTRACT.md#details`](docs/TOOL_CONTRACT.md#details)). There is no separate catalog of reusable named browser recipes above one-shot ad hoc `script`, `job`, `qa`, and raw `batch`; `script` has no names, registry, or persistent workflow state; see [`docs/ARCHITECTURE.md#no-reusable-recipe-layer-yet`](docs/ARCHITECTURE.md#no-reusable-recipe-layer-yet) for the closed `RQ-0068` decision and when to revisit it.
460
+ For short repeatable workflows, pass a top-level `job` instead of hand-writing `batch` stdin. Keep dynamic app jobs short around navigation, click, and rerender boundaries; avoid packing a whole checkout into one job. The wrapper only supports constrained steps (`open`, `click`, `fill`, `type`, `select`, `wait`, `assertText`, `assertUrl`, `waitForDownload`, `snapshot`, and `screenshot`), compiles them to existing upstream `batch` commands, and echoes the compiled commands as `details.compiledJob` for auditability. `open` steps can include `loadState` (`domcontentloaded`, `load`, or `networkidle`) to insert a readiness wait before the next step. `click` and `fill` steps can use either CSS `selector` or semantic locator fields (`locator`, `role`/`value`, optional `name`) so a job can express flows like role/name search without brittle selectors. `type` can use `selector`, `text`, optional `delayMs` for per-character pacing, and optional `press` for a final key such as `Enter`; paced type compiles to existing `focus`, `keyboard type`, `wait`, and `press` batch rows, is capped at 200 characters per delayed step, and compacts model-visible batch text while full rows remain in `details.batchSteps`. The same compile path backs top-level `qa`, so long `qa` runs surface the same timeout evidence shape. If a long `job`, `qa`, or `batch` hits the wrapper watchdog, `details.timeoutPartialProgress` may recover per-step status (`completed`, `failed`, `pending`, or `unknown`), current page URL plus a title after the URL is verified, declared artifact paths that already exist on disk, and either a `retry-timeout-step` next action for the first incomplete read-only or idempotent step, `inspect-current-page-after-timeout` when the target is already verified, or a fail-fast `verify-page-target-after-timeout` batch that runs `get url` before `snapshot -i` when the target is unknown (see [`docs/TOOL_CONTRACT.md#details`](docs/TOOL_CONTRACT.md#details)). Timeout recovery follows the effective batch source and native artifact operand positions, including literal dash-leading paths. Executable retries preserve the original row in a one-row batch. There is no separate catalog of reusable named browser recipes above one-shot ad hoc `script`, `job`, `qa`, and raw `batch`; `script` has no names, registry, or persistent workflow state; see [`docs/ARCHITECTURE.md#no-reusable-recipe-layer-yet`](docs/ARCHITECTURE.md#no-reusable-recipe-layer-yet) for the closed `RQ-0068` decision and when to revisit it.
459
461
 
460
462
  `assertText` accepts only `text`, not selector or locator fields. Clicks can stale later `@refs`; split the job and take a fresh snapshot before using them.
461
463
 
@@ -505,7 +507,7 @@ For desktop Electron apps, use top-level `electron` to avoid hand-building the d
505
507
  { "electron": { "action": "cleanup", "launchId": "electron-…" } }
506
508
  ```
507
509
 
508
- `electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` only tightens managed-session title/url reads used for mismatch checks. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Launch timeouts expose `details.electron.failure.diagnostics` for PID, profile, DevToolsActivePort, and timing evidence.
510
+ `electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` applies to managed-session title/URL reads and any `get cdp-url` read needed to verify a restored connection. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches. First reuse after reload/resume checks the live app's saved debug endpoint and the exact named upstream connection without reconnecting or resetting page refs; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Failed startups expose redacted stdout/stderr tails in visible errors and `details.electron.failure.diagnostics`, alongside PID, profile, DevToolsActivePort, and timing evidence. Each tail reads at most 4096 source bytes; private capture files follow the profile lifecycle, not a lifetime disk cap. See [`docs/ELECTRON.md`](docs/ELECTRON.md#failure-categories-and-recovery).
509
511
 
510
512
  Explicit-ID `electron.status` labels historical cleaned launch records; default and `all: true` selection exclude them. Current PID/port liveness stays independent of cleanup history. `details.electron.statuses[].userDataDirState` freshly measures only the tracked profile path: `present` (including dangling symlinks), `absent` (ENOENT), or `unknown` (other `lstat` errors), not whether all app residue is gone.
511
513
 
@@ -711,7 +713,7 @@ The opt-in real-upstream suite is separate because it drives a real browser inst
711
713
  npm run verify -- real-upstream
712
714
  ```
713
715
 
714
- That mode sets `PI_AGENT_BROWSER_REAL_UPSTREAM=1` and runs `test/agent-browser.real-upstream-contract.test.ts` against the real `agent-browser` on `PATH` (the stable version must meet the 0.35.0 floor; current command-reference validation targets the recommended 0.36.0 capability baseline). It covers inspection, skills, experimental WebMCP list/invoke/result/cancel plus `--no-webmcp`, and a broad core interaction and navigation matrix on localhost fixtures (including off-viewport click, frame-scoped selector/wait/click behavior, form command fixes, `batch` stdin, and `pushstate`), plus `vitals`, network route/requests/HAR, diff snapshot/screenshot/url, trace/profiler, console/errors/highlight, stream enable/status/disable, `cookies set --curl`, a `react tree` missing-renderer path, and `wait --download` with the on-disk caveat documented in release notes. The harness uses a throwaway temp `HOME` and dedicated socket/screenshot directories so the run does not touch your normal browser profile paths. Browser-opening or credential-dependent families such as `inspect`, `dashboard`, `chat`, provider clouds, and OS clipboard flows stay in fake-upstream or manual validation unless a safe deterministic fixture is added. For prerequisites, isolation details, and troubleshooting, see [`docs/RELEASE.md`](docs/RELEASE.md#real-upstream-contract-validation).
716
+ That mode sets `PI_AGENT_BROWSER_REAL_UPSTREAM=1` and runs `test/agent-browser.real-upstream-contract.test.ts` against the real `agent-browser` on `PATH` (the stable version must meet the 0.35.0 floor; current command-reference validation targets the recommended 0.37.0 capability baseline). It covers inspection, skills, experimental WebMCP list/invoke/result/cancel plus `--no-webmcp`, and a broad core interaction and navigation matrix on localhost fixtures (including off-viewport click, frame-scoped selector/wait/click behavior, form command fixes, `batch` stdin, and `pushstate`), plus `vitals`, network route/requests/HAR, diff snapshot/screenshot/url, trace/profiler, console/errors/highlight, stream enable/status/disable, `cookies set --curl`, a `react tree` missing-renderer path, and `wait --download` with the on-disk caveat documented in release notes. The harness uses a throwaway temp `HOME` and dedicated socket/screenshot directories so the run does not touch your normal browser profile paths. Browser-opening or credential-dependent families such as `inspect`, `dashboard`, `chat`, provider clouds, and OS clipboard flows stay in fake-upstream or manual validation unless a safe deterministic fixture is added. For prerequisites, isolation details, and troubleshooting, see [`docs/RELEASE.md`](docs/RELEASE.md#real-upstream-contract-validation).
715
717
 
716
718
  A deterministic host-only live-browser wrapper smoke is available without an LLM choosing tool calls:
717
719
 
@@ -744,12 +746,12 @@ npm run verify -- release
744
746
  5. Screenshots, downloads, recordings, traces, profiles, and spill files are normalized as Pi-visible artifacts where possible.
745
747
  6. Generated playbook text in docs and tool metadata stays aligned with `extensions/agent-browser/lib/playbook.ts`.
746
748
 
747
- The upstream browser engine remains [`agent-browser`](https://agent-browser.dev/). This package does not bundle it. The recommended baseline is 0.36.0 and the stable runtime floor is 0.35.0; newer stable versions are accepted without version-specific compatibility shims.
749
+ The upstream browser engine remains [`agent-browser`](https://agent-browser.dev/). This package does not bundle it. The recommended baseline is 0.37.0 and the stable runtime floor is 0.35.0; newer stable versions are accepted without version-specific compatibility shims.
748
750
 
749
751
  ## Current limits
750
752
 
751
753
  - Published pre-1.0 package.
752
- - Recommends upstream `agent-browser` 0.36.0 and accepts stable runtimes at or above 0.35.0.
754
+ - Recommends upstream `agent-browser` 0.37.0 and accepts stable runtimes at or above 0.35.0.
753
755
  - Does not bundle `agent-browser`; users install it separately.
754
756
  - Does not provide a human browser UI inside Pi; the primary UX is agent-invoked tool calls. `--headed` asks upstream to show a browser window, but the wrapper cannot yet prove that the window is visible on the user's desktop.
755
757
  - Localhost means the browser host's loopback, not necessarily the shell/Pi host. If `http://localhost:<port>` or `http://127.0.0.1:<port>` fails with errors such as `ERR_EMPTY_RESPONSE`, use an environment-specific host-reachable HTTP(S) address. A `file://` fixture is supported when upstream browser launch settings allow it; use HTTP(S) only when the browser environment cannot reach the local file.
@@ -6,7 +6,7 @@ import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "./
6
6
  import { PROJECT_RULE_PROMPT, buildBrowserDefaultProfileGuideline, buildBrowserExecutablePathGuideline, buildToolPromptGuidelines, } from "./lib/playbook.js";
7
7
  import { SessionPageState } from "./lib/session-page-state.js";
8
8
  import { buildExecutionPlan, canUseHeadlessCompatibilityUserAgent, createEphemeralSessionSeed, createFreshSessionName, createImplicitSessionName, extractUpstreamCommandTokens, getImplicitSessionCloseTimeoutMs, getImplicitSessionIdleTimeoutMs, isRestorableManagedSessionName, restoreManagedSessionStateFromBranch, validateToolArgs, redactSensitiveText, isPlainTextInspectionArgs, } from "./lib/runtime.js";
9
- import { extractExplicitNamespace, extractExplicitSessionName, getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace, isUpstreamEnvFlagEnabled, resolveAgentBrowserNamespace } from "./lib/argv-grammar.js";
9
+ import { deleteIdentityKeysInNamespace, extractExplicitNamespace, extractExplicitSessionName, getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace, isUpstreamEnvFlagEnabled, resolveAgentBrowserNamespace } from "./lib/argv-grammar.js";
10
10
  import { parseArgvDescriptor } from "./lib/argv-descriptor.js";
11
11
  import { needsManagedSession } from "./lib/command-policy.js";
12
12
  import { ManagedSessionRestoreState } from "./lib/managed-session-restore.js";
@@ -67,7 +67,7 @@ function getArtifactPreflightValidationError(options) {
67
67
  return error;
68
68
  const activeRecordingDestinations = new Set();
69
69
  const cleanupOnly = steps.length > 0 && steps.every((step) => {
70
- const [command, subcommand] = extractUpstreamCommandTokens(step);
70
+ const [command, subcommand] = step;
71
71
  return isCloseCommand(command) || (command === "record" && subcommand === "stop");
72
72
  });
73
73
  for (const reservation of options.activeRecordingReservations ?? []) {
@@ -93,10 +93,9 @@ function getArtifactPreflightValidationError(options) {
93
93
  }
94
94
  const artifactDestinations = new Map();
95
95
  let sawBatchClose = false;
96
- for (const [index, step] of steps.entries()) {
97
- const commandStep = extractUpstreamCommandTokens(step);
96
+ for (const [index, commandStep] of steps.entries()) {
98
97
  if (batch) {
99
- const stepValidationError = validateToolArgs(step, { batchStep: true });
98
+ const stepValidationError = validateToolArgs(commandStep, { batchStep: true });
100
99
  if (stepValidationError)
101
100
  return `Unsupported batch step ${index + 1}: ${stepValidationError}`;
102
101
  if (sawBatchClose && commandStep[0] === "record" && (commandStep[1] === "start" || commandStep[1] === "restart")) {
@@ -127,7 +126,7 @@ function getArtifactPreflightValidationError(options) {
127
126
  }
128
127
  artifactDestinations.set(canonicalDestination, index);
129
128
  }
130
- if (batch && commandStep[0] === "screenshot" && step.includes("--annotate")) {
129
+ if (batch && commandStep[0] === "screenshot" && commandStep.includes("--annotate")) {
131
130
  return [
132
131
  `Unsupported batch screenshot annotation in step ${index + 1}: put --annotate in top-level args, not inside the batch step.`,
133
132
  `Use: { "args": ["--annotate", "batch"], "stdin": "[[\\"screenshot\\",\\"/path/to/image.png\\"]]" }`,
@@ -138,7 +137,7 @@ function getArtifactPreflightValidationError(options) {
138
137
  }
139
138
  function commandClosesAllSessions(args, stdin) {
140
139
  const parsed = getArtifactCommandSteps(args, stdin);
141
- return !parsed.error && parsed.steps.some((step) => isCloseAllCommand(extractUpstreamCommandTokens(step)));
140
+ return !parsed.error && parsed.steps.some(isCloseAllCommand);
142
141
  }
143
142
  function commandTouchesArtifactLifecycle(args, stdin, outputPath) {
144
143
  if (outputPath)
@@ -146,10 +145,7 @@ function commandTouchesArtifactLifecycle(args, stdin, outputPath) {
146
145
  const parsed = getArtifactCommandSteps(args, stdin);
147
146
  if (parsed.error)
148
147
  return true;
149
- return parsed.steps.some((step) => {
150
- const commandStep = extractUpstreamCommandTokens(step);
151
- return getExplicitArtifactDestination(commandStep) !== undefined || commandStep[0] === "record" || commandStep[0] === "screenshot" || isCloseCommand(commandStep[0]);
152
- });
148
+ return parsed.steps.some((step) => getExplicitArtifactDestination(step) !== undefined || step[0] === "record" || step[0] === "screenshot" || isCloseCommand(step[0]));
153
149
  }
154
150
  function isResultFileArtifact(artifact) {
155
151
  return isRecord(artifact)
@@ -299,12 +295,6 @@ function detailsReportCloseAllApplied(details, succeeded) {
299
295
  || (succeeded && isCloseAllCommand(extractUpstreamCommandTokens(args)))
300
296
  || batchHasSuccessfulCloseAll(details.batchSteps);
301
297
  }
302
- function deleteIdentityKeysInNamespace(entries, namespace) {
303
- for (const key of entries.keys()) {
304
- if (isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
305
- entries.delete(key);
306
- }
307
- }
308
298
  function isAttachedBrowserInvocation(args, env = getAgentBrowserProcessEnvironment()) {
309
299
  const autoConnectEnv = env.AGENT_BROWSER_AUTO_CONNECT;
310
300
  return extractUpstreamCommandTokens(args)[0] === "connect"
@@ -663,9 +653,6 @@ async function closeOwnedManagedSessionsExcept(sessions, restoreState, keepSessi
663
653
  }
664
654
  }
665
655
  }
666
- async function closeOwnedManagedSessions(sessions, restoreState, timeoutMs, attachedSessionKeys, onClosed) {
667
- await closeOwnedManagedSessionsExcept(sessions, restoreState, undefined, timeoutMs, attachedSessionKeys, undefined, onClosed);
668
- }
669
656
  function getOffBranchOwnedElectronLaunchRecords(ownedRecords, branchRecords) {
670
657
  const activeBranchLaunchIds = new Set(getActiveElectronRecords(branchRecords).map((record) => record.launchId));
671
658
  const offBranchRecords = new Map();
@@ -1308,7 +1295,7 @@ export default function agentBrowserExtension(pi) {
1308
1295
  for (const identity of getCleanupResultsClosedManagedSessionIdentities(electronCleanupResults))
1309
1296
  retireRecordingSession(identity.sessionName, identity.namespace);
1310
1297
  if (quitting) {
1311
- await closeOwnedManagedSessions(ownedManagedSessions, managedSessionRestoreState, implicitSessionCloseTimeoutMs, attachedSessionKeys, (owner) => retireRecordingSession(owner.sessionName, owner.namespace));
1298
+ await closeOwnedManagedSessionsExcept(ownedManagedSessions, managedSessionRestoreState, undefined, implicitSessionCloseTimeoutMs, attachedSessionKeys, undefined, (owner) => retireRecordingSession(owner.sessionName, owner.namespace));
1312
1299
  }
1313
1300
  else {
1314
1301
  await closeOwnedManagedSessionsExcept(ownedManagedSessions, managedSessionRestoreState, managedSessionActive ? managedSessionName : undefined, implicitSessionCloseTimeoutMs, attachedSessionKeys, managedSessionActive ? managedSessionNamespace : undefined, (owner) => retireRecordingSession(owner.sessionName, owner.namespace));
@@ -48,14 +48,13 @@ export function parseWaitCommandTokens(commandTokens) {
48
48
  if (match)
49
49
  return { subcommand: match.token };
50
50
  }
51
- const download = considered.find((entry) => entry.token === "--download" || entry.token === "-d");
52
- if (download) {
53
- const downloadPathIndex = download.index + 1;
54
- const candidate = commandTokens[downloadPathIndex];
51
+ const downloadIndex = considered.findIndex((entry) => entry.token === "--download" || entry.token === "-d");
52
+ if (downloadIndex >= 0) {
53
+ const candidate = considered[downloadIndex + 1];
55
54
  return {
56
- downloadPath: candidate && !candidate.startsWith("--") ? candidate : undefined,
57
- downloadPathIndex: candidate && !candidate.startsWith("--") ? downloadPathIndex : undefined,
58
- subcommand: download.token,
55
+ downloadPath: candidate && !candidate.token.startsWith("--") ? candidate.token : undefined,
56
+ downloadPathIndex: candidate && !candidate.token.startsWith("--") ? candidate.index : undefined,
57
+ subcommand: considered[downloadIndex].token,
59
58
  };
60
59
  }
61
60
  return { subcommand: considered[0]?.token };
@@ -175,6 +175,12 @@ export function isAgentBrowserSessionIdentityKeyInNamespace(identityKey, namespa
175
175
  const prefix = getAgentBrowserSessionIdentityKey("", namespace);
176
176
  return prefix ? identityKey.startsWith(prefix) : !identityKey.includes("\0");
177
177
  }
178
+ export function deleteIdentityKeysInNamespace(entries, namespace) {
179
+ for (const key of entries.keys()) {
180
+ if (isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
181
+ entries.delete(key);
182
+ }
183
+ }
178
184
  /** Mirror upstream global parsing: full argv, no `--` sentinel, and only global value payloads are skipped. */
179
185
  export function scanUpstreamGlobalFlagOccurrences(args, targetFlag) {
180
186
  const occurrences = [];
@@ -1,12 +1,6 @@
1
1
  import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
2
2
  import { isCloseAllCommand, isCloseCommand } from "./command-taxonomy.js";
3
3
  import { isRecord } from "./parsing.js";
4
- function getRowBrowserLaunched(row) {
5
- const result = isRecord(row.result) ? row.result : isRecord(row.data) ? row.data : undefined;
6
- const lifecycle = isRecord(row.lifecycle) ? row.lifecycle : isRecord(result?.lifecycle) ? result.lifecycle : undefined;
7
- const effectiveLaunch = isRecord(lifecycle?.effectiveLaunch) ? lifecycle.effectiveLaunch : undefined;
8
- return typeof effectiveLaunch?.browserLaunched === "boolean" ? effectiveLaunch.browserLaunched : undefined;
9
- }
10
4
  export function batchHasSuccessfulCloseAll(data, fallbackCommands = []) {
11
5
  if (!Array.isArray(data))
12
6
  return false;
@@ -34,7 +28,10 @@ export function getSuccessfulBatchCloseLifecycle(rows, fallbackCommands = []) {
34
28
  const rowCommand = Array.isArray(row.command) && row.command.every((token) => typeof token === "string")
35
29
  ? row.command
36
30
  : fallbackCommands[index];
37
- const browserLaunched = getRowBrowserLaunched(row);
31
+ const result = isRecord(row.result) ? row.result : isRecord(row.data) ? row.data : undefined;
32
+ const lifecycle = isRecord(row.lifecycle) ? row.lifecycle : isRecord(result?.lifecycle) ? result.lifecycle : undefined;
33
+ const effectiveLaunch = isRecord(lifecycle?.effectiveLaunch) ? lifecycle.effectiveLaunch : undefined;
34
+ const browserLaunched = typeof effectiveLaunch?.browserLaunched === "boolean" ? effectiveLaunch.browserLaunched : undefined;
38
35
  if (!rowCommand) {
39
36
  if (sawClose && browserLaunched !== false) {
40
37
  endsClosed = false;
@@ -49,7 +46,6 @@ export function getSuccessfulBatchCloseLifecycle(rows, fallbackCommands = []) {
49
46
  endsClosed = true;
50
47
  browserActiveAfterClose = false;
51
48
  recordingClosedAfterBatch = true;
52
- const result = isRecord(row.result) ? row.result : isRecord(row.data) ? row.data : undefined;
53
49
  statePath = typeof result?.statePath === "string" ? result.statePath : undefined;
54
50
  }
55
51
  else if (sawClose && command === "record") {
@@ -316,13 +316,26 @@ export function isSessionTabPinningExcludedCommand(command) {
316
316
  export function isSessionTabPostCommandCorrectionExcludedCommand(command) {
317
317
  return hasCommandCapability(command, "excludedFromPostCommandCorrection");
318
318
  }
319
- /** Upstream 0.33.2 record start swaps to a fresh active page before its already-active check, so even a failed start can replace the page; record restart navigates the current page only when a URL operand (any fourth token, mirroring upstream's positional slot) is present. */
319
+ export function getRecordCommandOperands(tokens) {
320
+ if (tokens[0] !== "record" || !["start", "restart"].includes(tokens[1] ?? ""))
321
+ return {};
322
+ const operands = [];
323
+ for (let index = 2; index < tokens.length && operands.length < 2; index += 1) {
324
+ // Native validates the range; bare/non-numeric --fps keeps its old literal meaning.
325
+ if (tokens[index] === "--fps" && /^\+?\d+$/.test(tokens[index + 1] ?? ""))
326
+ index += 1;
327
+ else
328
+ operands.push(tokens[index]);
329
+ }
330
+ return operands.length > 0 ? { path: operands[0], url: operands[1] } : { path: tokens[2], url: tokens[3] };
331
+ }
332
+ /** Starts conservatively invalidate refs because older supported natives replace the page, even on failure. Restarts invalidate only when they have a URL. */
320
333
  export function isRecordPageTransitionCommand(tokens) {
321
334
  if (tokens[0] !== "record")
322
335
  return false;
323
336
  if (tokens[1] === "start")
324
337
  return true;
325
- return tokens[1] === "restart" && tokens.length >= 4;
338
+ return tokens[1] === "restart" && getRecordCommandOperands(tokens).url !== undefined;
326
339
  }
327
340
  export function isWebMcpPageMutationCommand(tokens) {
328
341
  return isWebMcpPageMutation(tokens[0], tokens[1]);
@@ -24,15 +24,15 @@ function isPidAlive(pid) {
24
24
  return code === "EPERM" ? true : false;
25
25
  }
26
26
  }
27
- async function isPortAlive(port) {
28
- const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`));
27
+ async function isPortAlive(port, signal) {
28
+ const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`, signal));
29
29
  if (!version)
30
30
  return { targets: [] };
31
- const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`));
31
+ const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`, signal));
32
32
  return { targets, version };
33
33
  }
34
- export async function inspectElectronLaunchStatus(record) {
35
- const cdp = await isPortAlive(record.port);
34
+ export async function inspectElectronLaunchStatus(record, signal) {
35
+ const cdp = await isPortAlive(record.port, signal);
36
36
  let userDataDirState;
37
37
  try {
38
38
  await lstat(record.userDataDir);
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile, rm } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { open, readFile, rm } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
5
  import { fetchCdpJson, parseCdpTargets, parseCdpVersion, } from "./cdp.js";
6
6
  import { discoverElectronApps, inspectElectronAppPath, inspectElectronExecutablePath, } from "./discovery.js";
7
- import { createSecureTempDirectory } from "../temp.js";
7
+ import { createSecureTempDirectory, preserveSecureTempDirectory } from "../temp.js";
8
8
  export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
9
9
  export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
10
10
  export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
@@ -12,6 +12,8 @@ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
12
12
  export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
13
13
  const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"];
14
14
  const ELECTRON_DEVTOOLS_POLL_INTERVAL_MS = 100;
15
+ // ponytail: bound failure reads, not lifetime log growth; noisy long-lived apps need rotation if that becomes a problem.
16
+ const ELECTRON_OUTPUT_TAIL_BYTES = 4096;
15
17
  function normalizeTimeoutMs(timeoutMs) {
16
18
  if (!Number.isSafeInteger(timeoutMs) || (timeoutMs ?? 0) <= 0)
17
19
  return ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS;
@@ -284,40 +286,90 @@ export async function launchElectronApp(options) {
284
286
  let exitCode = null;
285
287
  let exitSignal = null;
286
288
  const args = buildLaunchArgs(userDataDir, appArgs);
287
- const child = spawn(target.executablePath, args, {
288
- cwd: dirname(target.executablePath),
289
- detached: process.platform !== "win32",
290
- stdio: "ignore",
291
- });
292
- child.once("error", (error) => {
293
- spawnError = error;
294
- });
295
- child.once("exit", (code, signal) => {
296
- exitCode = code;
297
- exitSignal = signal;
298
- });
299
- child.unref();
289
+ let child;
290
+ let outputCaptured = false;
291
+ const outputFiles = [];
292
+ try {
293
+ for (const stream of ["stdout", "stderr"])
294
+ outputFiles.push(await open(join(userDataDir, `${stream}.log`), "wx", 0o600));
295
+ options.signal?.throwIfAborted();
296
+ child = spawn(target.executablePath, args, {
297
+ cwd: dirname(target.executablePath),
298
+ detached: process.platform !== "win32",
299
+ stdio: ["ignore", outputFiles[0].fd, outputFiles[1].fd],
300
+ });
301
+ outputCaptured = true;
302
+ child.once("error", (error) => {
303
+ spawnError = error;
304
+ });
305
+ child.once("exit", (code, signal) => {
306
+ exitCode = code;
307
+ exitSignal = signal;
308
+ });
309
+ child.unref();
310
+ }
311
+ catch (error) {
312
+ spawnError = error instanceof Error ? error : new Error(String(error));
313
+ }
314
+ finally {
315
+ for (const file of outputFiles) {
316
+ await file.close().catch((error) => {
317
+ cleanupError = [cleanupError, `Output handle close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
318
+ });
319
+ }
320
+ }
300
321
  const buildFailureDiagnostics = (options = {}) => ({
301
322
  cdpVersionReached: options.cdpVersionReached,
302
323
  devToolsActivePort: options.devToolsActivePort,
303
324
  elapsedMs: Math.max(0, Date.now() - startedAtMs),
304
325
  exitCode,
305
326
  exitSignal,
306
- outputCaptured: false,
307
- pid: child.pid,
308
- pidAlive: isLaunchChildPidAlive(child),
327
+ outputCaptured,
328
+ pid: child?.pid,
329
+ pidAlive: child ? isLaunchChildPidAlive(child) : undefined,
309
330
  port: options.port ?? options.devToolsActivePort?.port,
310
331
  timeoutMs,
311
332
  userDataDir,
312
333
  });
313
334
  const fail = async (reason, detail, diagnosticOptions) => {
314
335
  const diagnostics = buildFailureDiagnostics(diagnosticOptions);
315
- const processCleanupError = await terminateLaunchChild(child);
336
+ const processCleanupError = child ? await terminateLaunchChild(child) : undefined;
337
+ const outputLines = [];
338
+ if (outputCaptured) {
339
+ for (const stream of ["stdout", "stderr"]) {
340
+ let file;
341
+ try {
342
+ file = await open(join(userDataDir, `${stream}.log`), "r");
343
+ const { size } = await file.stat();
344
+ const buffer = Buffer.alloc(Math.min(size, ELECTRON_OUTPUT_TAIL_BYTES));
345
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
346
+ diagnostics[`${stream}Tail`] = buffer.subarray(0, bytesRead).toString("utf8");
347
+ diagnostics[`${stream}Truncated`] = size > buffer.length;
348
+ }
349
+ catch (error) {
350
+ diagnostics[`${stream}Error`] = error instanceof Error ? error.message : String(error);
351
+ }
352
+ finally {
353
+ await file?.close().catch((error) => {
354
+ diagnostics[`${stream}Error`] = [diagnostics[`${stream}Error`], `Output reader close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
355
+ });
356
+ }
357
+ const tail = diagnostics[`${stream}Tail`];
358
+ if (tail !== undefined)
359
+ outputLines.push(`App ${stream}${diagnostics[`${stream}Truncated`] ? ` (last ${ELECTRON_OUTPUT_TAIL_BYTES} bytes)` : ""}: ${tail || "(empty)"}`);
360
+ if (diagnostics[`${stream}Error`])
361
+ outputLines.push(`App ${stream} capture error: ${diagnostics[`${stream}Error`]}`);
362
+ }
363
+ }
316
364
  try {
317
- await rm(userDataDir, { force: true, recursive: true });
365
+ if (processCleanupError)
366
+ await preserveSecureTempDirectory(userDataDir);
367
+ else
368
+ await rm(userDataDir, { force: true, recursive: true });
318
369
  }
319
370
  catch (error) {
320
- cleanupError = error instanceof Error ? error.message : String(error);
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ cleanupError = [cleanupError, processCleanupError ? `Profile preservation failed: ${message}` : message].filter(Boolean).join("; ");
321
373
  }
322
374
  cleanupError = [processCleanupError, cleanupError].filter((value) => value !== undefined).join("; ") || undefined;
323
375
  return {
@@ -326,13 +378,15 @@ export async function launchElectronApp(options) {
326
378
  appArgs,
327
379
  cleanupError,
328
380
  diagnostics,
329
- error: launchFailureMessage(reason, target, detail),
381
+ error: [launchFailureMessage(reason, target, detail), ...outputLines].join("\n"),
330
382
  reason,
331
383
  target,
332
384
  userDataDir,
333
385
  },
334
386
  };
335
387
  };
388
+ if (!child)
389
+ return fail(options.signal?.aborted ? "aborted" : "spawn-error", spawnError?.message);
336
390
  const portResult = await pollDevToolsActivePort({
337
391
  deadlineMs,
338
392
  getChildExit: () => ({ code: exitCode, signal: exitSignal }),
@@ -104,7 +104,7 @@ export function agentBrowserExplicitConfigIsPresent(parentEnv = getAgentBrowserP
104
104
  return hasExplicitConfigArg(args) || hasUpstreamEnvValue(parentEnv, AGENT_BROWSER_CONFIG_ENV);
105
105
  }
106
106
  /** Caller-selected upstream config disables the wrapper's automatic restore injection without blocking that config. */
107
- export function agentBrowserConfigBlocksManagedRestore(_cwd, parentEnv = getAgentBrowserProcessEnvironment(), args = [], platform = process.platform) {
107
+ export function agentBrowserConfigBlocksManagedRestore(parentEnv = getAgentBrowserProcessEnvironment(), args = [], platform = process.platform) {
108
108
  return !resolveManagedSessionRestoreHome(parentEnv, platform) || agentBrowserExplicitConfigIsPresent(parentEnv, args);
109
109
  }
110
110
  function omitWrapperInjectedUserAgent(args, enabled) {
@@ -151,7 +151,7 @@ function isManagedSessionRestoreIncompatible(options, namespace = extractExplici
151
151
  const args = omitWrapperInjectedUserAgent(options.args, options.wrapperInjectedUserAgent);
152
152
  if (options.cwd && !hasManagedSessionRestoreProjectIdentity(options.cwd))
153
153
  return true;
154
- if (options.cwd && agentBrowserConfigBlocksManagedRestore(options.cwd, effectiveEnv, args))
154
+ if (options.cwd && agentBrowserConfigBlocksManagedRestore(effectiveEnv, args))
155
155
  return true;
156
156
  return !ensureManagedSessionRestoreStorageIsSecure(effectiveEnv, process.platform, namespace);
157
157
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join } from "node:path";
4
- import { createManagedSessionRestoreKey, directoryContainsSymlink, ensureManagedSessionRestoreStorageIsSecure, hasManagedSessionRestoreProjectIdentity, ensureOwnerOnlyDirectory, getManagedRestoreSessionsDirectory, isManagedSessionRestoreKey, resolveManagedSessionRestoreCheckoutRoot, resolveManagedSessionRestoreHome, } from "./managed-session-storage.js";
4
+ import { directoryContainsSymlink, ensureManagedSessionRestoreStorageIsSecure, ensureOwnerOnlyDirectory, getManagedRestoreSessionsDirectory, isManagedSessionRestoreKey, resolveManagedSessionRestoreCheckoutRoot, resolveManagedSessionRestoreHome, } from "./managed-session-storage.js";
5
5
  const OWNED_RESTORE_SNAPSHOT_FAMILIES_TO_KEEP = 2;
6
6
  const OWNED_RESTORE_SNAPSHOT_MAX_RECORDS = 256;
7
7
  const OWNED_RESTORE_SNAPSHOT_RECORD_MAX_BYTES = 16 * 1_024;
@@ -286,10 +286,8 @@ function scanOwnedSnapshots(options) {
286
286
  export function pruneOwnedManagedSessionRestoreSnapshots(options) {
287
287
  const parentEnv = options.parentEnv ?? process.env;
288
288
  const platform = options.platform ?? process.platform;
289
- const restoreKey = options.restoreKey === undefined
290
- ? hasManagedSessionRestoreProjectIdentity(options.cwd) ? createManagedSessionRestoreKey(options.cwd) : undefined
291
- : isManagedSessionRestoreKey(options.restoreKey) ? options.restoreKey : undefined;
292
- if (!restoreKey)
289
+ const restoreKey = options.restoreKey;
290
+ if (!isManagedSessionRestoreKey(restoreKey))
293
291
  return 0;
294
292
  const home = resolveManagedSessionRestoreHome(parentEnv, platform);
295
293
  if (!home)