diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a20ea2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify: + name: Build and Test + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + cache: npm + + - name: Install system test dependencies + run: sudo apt-get update && sudo apt-get install --yes ffmpeg tmux + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Verify build artifacts + run: | + test -f dist/bin/proofshot.js + test -f dist/src/index.js + node dist/bin/proofshot.js --help + npm pack --dry-run + + - name: Run tests + run: npm test + + - name: Run FFmpeg integration test + run: npm run test:ffmpeg diff --git a/PROOFSHOT.md b/PROOFSHOT.md index 905ca26..a4d6746 100644 --- a/PROOFSHOT.md +++ b/PROOFSHOT.md @@ -11,6 +11,8 @@ After building or modifying UI features, verify with this workflow: ProofShot keeps all `proofshot exec` commands inside the same isolated `agent-browser` session that was created by `proofshot start`, so recording, screenshots, and browser actions stay aligned. +Use `--url` on `start` when verification must begin on a specific target. In an isolated HOME, ProofShot discovers executable-only Chrome/Chromium installs from system/account locations; use `--browser-executable /absolute/path/to/chrome` to select one explicitly. + Key proofshot exec commands: - `proofshot exec snapshot -i` — see interactive elements - `proofshot exec click @e3` — click an element @@ -18,6 +20,7 @@ Key proofshot exec commands: - `proofshot exec screenshot step.png` — capture a moment Artifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary. +Custom `--output` paths do not move active control state, so a separate `proofshot stop` still finds the session. `stop` is idempotent; after `stop --no-close`, run a later plain `stop` to close that exact retained browser without rebundling. You can customize browser launch behavior in `proofshot.config.json`, including HTTPS error ignoring, a custom browser executable path, and a project-specific `agent-browser` config path. Use `proofshot doctor` when the local setup looks wrong. It prints the current config path, browser mode, viewport, installed binaries, and any active ProofShot session. diff --git a/README.md b/README.md index d157a65..fe6b3f8 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,12 @@ Three-step workflow: **start**, **test**, **stop**. proofshot start --run "npm run dev" --port 3000 --description "Login form verification" # 2. Test — the AI agent drives the browser -agent-browser snapshot -i # See interactive elements -agent-browser open http://localhost:3000/login # Navigate -agent-browser fill @e2 "test@example.com" # Fill form -agent-browser click @e5 # Click submit -agent-browser screenshot ./proofshot-artifacts/step-login.png # Capture proof +proofshot exec snapshot -i # See interactive elements +proofshot exec open http://localhost:3000/login # Navigate +proofshot exec fill @e2 "test@example.com" # Fill form +proofshot exec click @e5 # Click submit +proofshot exec assert-visible "#account-home" # Record an expected selector +proofshot exec screenshot step-login.png # Capture proof # 3. Stop — bundle video + screenshots + errors into proof artifacts proofshot stop @@ -97,12 +98,15 @@ Each session produces a timestamped folder in `./proofshot-artifacts/`: | File | Description | |------|-------------| | `session.webm` | Video recording of the entire session | -| `viewer.html` | Standalone interactive viewer with scrub bar, timeline, and Console/Server log tabs | +| `viewer.html` | Standalone interactive viewer with scrub bar, canonical timeline, and grouped Environment/Browser source tabs | | `SUMMARY.md` | Markdown report with errors, screenshots, and video | | `step-*.png` | Screenshots captured at key moments | | `session-log.json` | Action timeline with timestamps and element data | | `server.log` | Dev server stdout/stderr (when using `--run`) | | `console-output.log` | Browser console output | +| `evidence.json` | Canonical browser/environment events, incidents, source integrity, and media timing | +| `verdict.json` | Structured `PASS`, `FAIL`, `INCOMPLETE`, or `BLOCKED` verdict | +| `artifact-manifest.json` | Finalized repository/commit provenance and ordered artifact hashes |

ProofShot artifacts folder @@ -110,7 +114,7 @@ Each session produces a timestamped folder in `./proofshot-artifacts/`: Generated artifacts for a single verification session

-The viewer also includes tabs for browsing console and server logs, with error highlighting and timestamps synced to the video: +The viewer includes grouped Environment and Browser evidence tabs, with incident highlighting and timed live rows synchronized to the video:

ProofShot Viewer — console logs tab @@ -140,6 +144,7 @@ proofshot start # Server already running proofshot start --run "npm run dev" --port 3000 # Start and capture server proofshot start --description "Verify checkout flow" # Add description to report proofshot start --url http://localhost:3000/login # Open specific URL +proofshot start --browser-executable /path/to/chrome # Reuse an exact browser binary proofshot start --headed # Show browser (debugging) proofshot start --force # Override a stale session from a previous crash ``` @@ -158,6 +163,10 @@ You can also configure browser launch behavior in `proofshot.config.json`: Set `browser.configPath` when you need ProofShot to run `agent-browser` against a project-specific config instead of inheriting `~/.agent-browser/config.json`. Relative paths are resolved from the directory that contains `proofshot.config.json`. +ProofShot discovers system and account-level Chrome/Chromium installs even when the command runs with an isolated `HOME`. If no runnable browser is found, `start` prints the exact `agent-browser install` action. An explicit `--browser-executable` takes precedence for one run. + +`--output` changes only where evidence is written. Active control state stays in the configured/default output directory, so later `proofshot exec` and `proofshot stop` processes can find the same session. + ### `proofshot stop` Stop recording, collect errors, generate proof artifacts. @@ -167,6 +176,8 @@ proofshot stop # Stop session and close browser proofshot stop --no-close # Stop but keep browser open ``` +`stop` is idempotent. With `--no-close`, ProofShot retains exact ownership metadata after bundling; run a later plain `proofshot stop` to close that browser without rebuilding the artifacts. + ### `proofshot exec` Pass-through to agent-browser with automatic session logging. Captures timestamps, element data, and resolves screenshot paths. @@ -175,9 +186,12 @@ When a ProofShot session is active, `proofshot exec` reuses the same isolated `a ```bash proofshot exec click @e3 +proofshot exec assert-visible "#checkout-complete" proofshot exec screenshot step-checkout.png ``` +Failed `assert-visible` checks are recorded in `session-log.json` and contribute to the structured verdict. + ### `proofshot diff` Compare current screenshots against a baseline for visual regression. @@ -188,20 +202,34 @@ proofshot diff --baseline ./previous-artifacts ### `proofshot pr` -Upload session artifacts to GitHub and post a verification comment on the PR. Finds all sessions recorded on the current branch, uploads screenshots and video, and posts a formatted comment with embedded screenshots. +Upload one finalized, provenance-compatible session to GitHub and post a verification comment. ProofShot validates the target PR head, source state, artifact paths, and hashes before upload; it never combines historical sessions. ```bash proofshot pr # Auto-detect PR from current branch proofshot pr 42 # Target a specific PR +proofshot pr --session proofshot-2026-08-09_19-00-00 +proofshot pr --session checkout-session --session receipt-session +proofshot pr --session proofshot-2026-08-09_19-00-00 --screenshot checkout.png --screenshot receipt.png proofshot pr --dry-run # Preview the markdown without posting proofshot pr --upload-provider github-web-attachments # Use GitHub's internal attachment flow ``` By default, ProofShot uses the official GitHub repository contents API and uploads artifacts to a dedicated `proofshot-artifacts` branch. This works with normal `gh` authentication and `GH_TOKEN`. +Auto-selection succeeds only when exactly one complete `PASS` or `FAIL` session matches the PR head. Repeat `--session` to publish several explicit compatible sessions, and repeat `--screenshot` to preserve exact artifact selection order. Pre-manifest sessions require both `--session` and `--legacy-session`; that opt-in cannot bypass a present or invalid finalized manifest. A partial upload never posts a PR comment. + The `github-web-attachments` provider is still available for inline GitHub-hosted media, but it relies on GitHub's internal web upload endpoint and may reject browser-based `gh auth login` OAuth sessions. -Converts `.webm` video to `.mp4` if `ffmpeg` is available. +### `proofshot session` + +Inspect and clean durable recovery records after an interrupted or incomplete cleanup: + +```bash +proofshot session list +proofshot session clean --session +``` + +Cleanup validates persisted process identities and never widens to a name-, port-, or default-socket kill. ### `proofshot clean` @@ -211,6 +239,8 @@ Remove the `./proofshot-artifacts/` directory. proofshot clean ``` +`clean` refuses while active or retained session control state exists. Run `proofshot stop` first so ProofShot does not discard exact process ownership metadata. + ### `proofshot doctor` Print the current ProofShot environment, including config path, browser mode, viewport, installed binaries, and any active session. diff --git a/content/docs/concepts/how-it-works.mdx b/content/docs/concepts/how-it-works.mdx index 6bdac70..1494300 100644 --- a/content/docs/concepts/how-it-works.mdx +++ b/content/docs/concepts/how-it-works.mdx @@ -45,11 +45,11 @@ ProofShot uses a three-phase model. `proofshot start` initializes the session: 1. Check if the port is available (fail fast on conflicts) -2. Spawn the dev server if `--run` is provided, pipe output to `server.log` +2. Spawn an isolated dev-server process session if `--run` is provided, pipe timestamped output to `server.log`, and persist its immutable PID/process-group identity 3. Wait for the port to respond (polls every 500ms, 30s timeout) -4. Open headless Chromium +4. Open the requested URL in a short, collision-safe agent-browser session and persist its daemon identity 5. Start video recording -6. Write `.session.json` (active session state) and `metadata.json` (git branch/commit, persists after stop) +6. Write `.session.json` to the configured/default control directory and `metadata.json` beside the evidence (git branch/commit, persists after stop) Recording is mandatory. If it fails after 3 retries, the session aborts. @@ -69,11 +69,12 @@ Each `proofshot exec` call: 1. Collects browser console errors and output (point-in-time snapshot) 2. Stops video recording -3. Closes the browser -4. Trims video dead time using ffmpeg (5s buffer before first action, 3s after last). Adjusts all `session-log.json` timestamps by the trim offset. -5. Scans `server.log` with multi-language regex patterns for errors -6. Generates `SUMMARY.md` and `viewer.html` -7. Clears `.session.json` +3. Closes the exact owned browser session +4. Stops only the dev-server process session created by this run +5. Trims video dead time using ffmpeg (5s buffer before first action, 3s after last). Adjusts all `session-log.json` timestamps by the trim offset. +6. Scans `server.log` with multi-language regex patterns for errors +7. Generates `SUMMARY.md` and `viewer.html` +8. Clears `.session.json` (or retains exact browser ownership after `--no-close`) ## Design principles @@ -81,6 +82,6 @@ Each `proofshot exec` call: **Minimal dependencies.** Three production dependencies: `commander`, `chalk`, `detect-port`. agent-browser is an optional peer dependency. Small install, small supply chain. -**Session isolation.** `.session.json` lives in the output directory, not globally. This supports parallel sessions in different projects. +**Session isolation.** Each project keeps control state in its configured/default output directory. A CLI-only custom evidence path cannot hide the session from a later process, while different projects still run independently. **ESM-only.** All imports use explicit `.js` extensions for correct resolution after TypeScript compilation. diff --git a/content/docs/concepts/interactive-viewer.mdx b/content/docs/concepts/interactive-viewer.mdx index f79b4b0..1336a1c 100644 --- a/content/docs/concepts/interactive-viewer.mdx +++ b/content/docs/concepts/interactive-viewer.mdx @@ -11,7 +11,7 @@ The `viewer.html` file generated by `proofshot stop` is a self-contained HTML vi ``` ┌─────────────────────────────────────────────────────┐ -│ Header: description, console/server error badges │ +│ Header: verdict and canonical evidence badges │ ├──────────────────────────┬──────────────────────────┤ │ │ │ │ Video panel (62%) │ Timeline panel (38%) │ @@ -42,9 +42,13 @@ Overlays are scaled from the original viewport size to the current video display **Keyboard navigation.** Left and right arrow keys jump between action markers on the scrub bar. -**Error badges.** Top-right corner displays console and server error counts. Green means clean, red means errors were found. +**Evidence groups.** Environment events have a merged tab followed by grouped source tabs such as **Frontend · Vite** and **Backend · API**. Browser console/errors stay in a separate Browser group segmented by detected navigation URL. -**Sync.** Playing the video highlights the current action in the timeline and auto-scrolls it into view. The two panels stay in lockstep. +**Integrity badges.** Each source reports presentation-hidden lines, truncation, capture gaps, and grouped incidents. These values and the top-level `PASS`/`FAIL`/`INCOMPLETE`/`BLOCKED` badge come from the same canonical evidence rows. + +**History/live boundaries.** Tmux scrollback appears first as untimed history; live PTY rows then synchronize to the timeline. The boundary identifies deduplicated overlap or a possible capture gap. + +**Sync.** Playing the video highlights current actions and timed evidence rows. Untimed or non-finite rows cannot seek. If the action timeline extends beyond the recording, the viewer warns instead of shortening the timeline and clamps seeks to available media. **Responsive.** On smaller screens, the video and timeline stack vertically. diff --git a/content/docs/faq.mdx b/content/docs/faq.mdx index cd0799d..26bf714 100644 --- a/content/docs/faq.mdx +++ b/content/docs/faq.mdx @@ -25,10 +25,10 @@ No. ffmpeg is optional. Without it, ProofShot skips video trimming (you get the The skill file installed by `proofshot install` teaches your agent the three-step workflow, all browser commands, and when to take screenshots. You just prompt naturally — "verify this feature with proofshot" — and the agent handles the rest. **What are `@eN` references?** -Stable handles to interactive elements on a page. When your agent runs `agent-browser snapshot -i`, it gets a list like `@e1: button "Submit"`, `@e2: input "Email"`. These references persist across commands within a session, so the agent can target elements reliably without CSS selectors. +Stable handles to interactive elements on a page. When your agent runs `proofshot exec snapshot -i`, it gets a list like `@e1: button "Submit"`, `@e2: input "Email"`. These references persist across commands within a session, so the agent can target elements reliably without CSS selectors. **Can I run multiple sessions at the same time?** -Yes. Session state (`.session.json`) lives in the output directory, not globally. Different projects with different output directories can run sessions concurrently. +Yes. Session state (`.session.json`) lives in each project's configured/default output directory, not globally. Different projects can run concurrently. A one-run `--output` override moves evidence without changing where that project finds active control state. **What languages does error detection support?** JavaScript/Node.js, Python, Ruby/Rails, Go, Java/Kotlin, Rust, PHP, C#/.NET, Elixir/Phoenix, plus generic patterns for `FATAL`, `CRITICAL`, and segfaults. See [How to add error patterns](/docs/guides/add-error-pattern) to extend support. @@ -39,7 +39,10 @@ When ffmpeg is available, `proofshot stop` cuts dead time from the video — kee ## Troubleshooting **"No active session" when running exec or stop** -You need to run `proofshot start` first. Each session writes `.session.json` — if it's missing, there's no active session to operate on. +You need to run `proofshot start` before `exec`. `stop` is idempotent, so it succeeds without changing artifacts when the session is already stopped. + +**Chrome is installed, but an isolated HOME cannot find it** +ProofShot checks system paths and executable-only browser caches under the real account home without reusing a browser profile or storage. You can also pass one exact path with `proofshot start --browser-executable /absolute/path/to/chrome`. If nothing is runnable, run the `agent-browser install` command printed by `proofshot start`. **Server errors aren't being detected** Server log capture only works when ProofShot starts the server itself via `--run`. If your server was already running on the port, ProofShot skips spawning and gets no logs. diff --git a/content/docs/guides/post-to-pr.mdx b/content/docs/guides/post-to-pr.mdx index e74e093..45934c6 100644 --- a/content/docs/guides/post-to-pr.mdx +++ b/content/docs/guides/post-to-pr.mdx @@ -10,13 +10,13 @@ import { Callout, Steps } from 'nextra/components' After a verification session, upload screenshots and video to GitHub and post a formatted comment on your pull request with embedded proof artifacts. -**Prerequisites:** [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated, or `GH_TOKEN` / `GITHUB_TOKEN` set. At least one completed ProofShot session on the current branch. +**Prerequisites:** [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated, or `GH_TOKEN` / `GITHUB_TOKEN` set. At least one finalized ProofShot session recorded from the target PR head. ### Run a verification session -Complete a full `start` → test → `stop` cycle on your feature branch. ProofShot records the branch name and commit SHA in each session's `metadata.json`. +Complete a full `start` → test → `stop` cycle on a clean target commit. `stop` writes an `artifact-manifest.json` containing the repository, branch, commit/tree provenance, verdict, and ordered artifact hashes. ### Post to the PR @@ -24,7 +24,7 @@ Complete a full `start` → test → `stop` cycle on your feature branch. ProofS proofshot pr ``` -ProofShot finds all sessions recorded on the current branch, uploads screenshots and video to GitHub, and posts a formatted comment on the PR. +ProofShot auto-selects only when exactly one complete `PASS` or `FAIL` session matches the PR head. Explicit repeated `--session` flags can combine compatible finalized sessions. To target a specific PR number: @@ -32,6 +32,14 @@ To target a specific PR number: proofshot pr 42 ``` +When more than one compatible session exists, choose one or more and optionally narrow their screenshots: + +```sh +proofshot pr 42 --session proofshot-2026-08-09_19-00-00 +proofshot pr 42 --session checkout-session --session receipt-session +proofshot pr 42 --session proofshot-2026-08-09_19-00-00 --screenshot checkout.png --screenshot receipt.png +``` + To use GitHub's internal web attachment flow instead of the default contents-based uploader: ```sh @@ -51,17 +59,25 @@ This outputs the formatted comment to stdout so you can review it. ## How it works -1. ProofShot reads `metadata.json` from each session folder in `proofshot-artifacts/` -2. Sessions matching the current git branch are selected -3. By default, screenshots and video are uploaded to a dedicated `proofshot-artifacts` branch using the official GitHub contents API -4. If ffmpeg is available, `.webm` video is converted to `.mp4` for better browser compatibility -5. A formatted markdown comment is posted on the PR with embedded screenshots and a recording link +1. ProofShot resolves the exact target PR head repository, branch, and SHA +2. One compatible finalized manifest is auto-selected, or the repeated explicit `--session` choices are selected in order +3. Every selected path is checked for traversal/symlinks and re-hashed before upload +4. By default, selected screenshots and video are uploaded to a dedicated `proofshot-artifacts` branch using the official GitHub contents API +5. A formatted comment is posted only after every requested upload succeeds 6. Optional: `--upload-provider github-web-attachments` uses GitHub's internal attachment endpoint for inline-hosted media ## Troubleshooting -**"No sessions found for current branch"** -Make sure you ran `proofshot start` and `proofshot stop` on the same branch you're posting from. The `metadata.json` file persists after `stop` — check that it exists in your session folders. +**"No complete finalized session matches the target PR head"** +Run a new `start` → test → `stop` cycle on the current clean PR head. `INCOMPLETE`, `BLOCKED`, dirty, drifted, mixed-commit, or hash-mismatched sessions cannot publish. + +Pre-manifest sessions are available only through explicit opt-in: + +```sh +proofshot pr 42 --session exact-folder-name --legacy-session +``` + +This flag cannot bypass a present or malformed finalized manifest. **"gh: command not found"** Install the [GitHub CLI](https://cli.github.com/) and authenticate with `gh auth login`. @@ -80,8 +96,8 @@ export GH_TOKEN=YOUR_TOKEN proofshot pr --upload-provider github-web-attachments ``` -**Video not showing in the PR comment** -GitHub doesn't support `.webm` in markdown comments. Install ffmpeg so ProofShot can convert the video to `.mp4` before uploading. With the default `repo-contents` provider, recordings are linked rather than rendered as GitHub attachment embeds. +**Video not showing inline in the PR comment** +With the default `repo-contents` provider, recordings are linked. Use `github-web-attachments` when supported by your GitHub authentication if inline attachment rendering is required. ## What's next? diff --git a/content/docs/guides/verify-feature.mdx b/content/docs/guides/verify-feature.mdx index 0aabe94..fd12632 100644 --- a/content/docs/guides/verify-feature.mdx +++ b/content/docs/guides/verify-feature.mdx @@ -39,7 +39,7 @@ ProofShot opens a headless Chromium browser, starts video recording, and begins ### Take a snapshot to see the page ```sh -agent-browser snapshot -i +proofshot exec snapshot -i ``` This returns a list of interactive elements with stable references: @@ -59,30 +59,30 @@ Use these `@eN` references to target elements in subsequent commands. Fill forms, click buttons, navigate: ```sh -agent-browser fill @e2 "buyer@example.com" -agent-browser fill @e3 "secure-password" -agent-browser click @e4 +proofshot exec fill @e2 "buyer@example.com" +proofshot exec fill @e3 "secure-password" +proofshot exec click @e4 ``` Navigate to a different page: ```sh -agent-browser open http://localhost:3000/dashboard +proofshot exec open http://localhost:3000/dashboard ``` Scroll, type, or press keys: ```sh -agent-browser scroll down -agent-browser press Enter -agent-browser type "Search query" +proofshot exec scroll down +proofshot exec press Enter +proofshot exec type "Search query" ``` ### Capture screenshots at key moments ```sh -agent-browser screenshot step-login.png -agent-browser screenshot step-dashboard.png +proofshot exec screenshot step-login.png +proofshot exec screenshot step-dashboard.png ``` Screenshots are saved in your session's artifact folder automatically. Each one appears in the final report and viewer. diff --git a/content/docs/guides/visual-regression.mdx b/content/docs/guides/visual-regression.mdx index 82906e1..a7f6755 100644 --- a/content/docs/guides/visual-regression.mdx +++ b/content/docs/guides/visual-regression.mdx @@ -20,10 +20,10 @@ Run a verification session on your stable branch (e.g., `main`) and take screens ```sh proofshot start --run "npm run dev" --port 3000 -agent-browser open http://localhost:3000 -agent-browser screenshot homepage.png -agent-browser open http://localhost:3000/dashboard -agent-browser screenshot dashboard.png +proofshot exec open http://localhost:3000 +proofshot exec screenshot homepage.png +proofshot exec open http://localhost:3000/dashboard +proofshot exec screenshot dashboard.png proofshot stop ``` diff --git a/content/docs/quick-start.mdx b/content/docs/quick-start.mdx index c75b9b8..6430d2c 100644 --- a/content/docs/quick-start.mdx +++ b/content/docs/quick-start.mdx @@ -77,12 +77,13 @@ This starts your dev server, opens a headless browser, and begins recording vide Your agent drives the browser using `agent-browser` commands: ```sh -agent-browser snapshot -i # See interactive elements (@e1, @e2, ...) -agent-browser open http://localhost:3000/login -agent-browser fill @e2 "user@example.com" -agent-browser fill @e3 "password123" -agent-browser click @e5 # Submit button -agent-browser screenshot step-login.png +proofshot exec snapshot -i # See interactive elements (@e1, @e2, ...) +proofshot exec open http://localhost:3000/login +proofshot exec fill @e2 "user@example.com" +proofshot exec fill @e3 "password123" +proofshot exec click @e5 # Submit button +proofshot exec assert-visible "#account-home" +proofshot exec screenshot step-login.png ``` Each action is logged with timestamps and element data for the interactive viewer. @@ -93,7 +94,7 @@ Each action is logged with timestamps and element data for the interactive viewe proofshot stop ``` -ProofShot stops recording, collects console and server errors, trims the video, and generates your proof artifacts. +ProofShot stops recording, finalizes canonical browser/environment evidence, trims the video, and generates the viewer, verdict, and provenance manifest. ### Review the artifacts diff --git a/content/docs/reference/artifacts.mdx b/content/docs/reference/artifacts.mdx index be5d03b..46f556a 100644 --- a/content/docs/reference/artifacts.mdx +++ b/content/docs/reference/artifacts.mdx @@ -11,7 +11,7 @@ Each ProofShot session creates a timestamped folder in your output directory (de | File | Created by | Description | |------|------------|-------------| -| `metadata.json` | `start` | Git branch, commit SHA, timestamp, and session description. Persists after `stop` — used by `proofshot pr` to match sessions to branches. | +| `metadata.json` | `start` | Repository, branch, commit/tree provenance, initial dirty state, timestamp, and description. | | `session.webm` | `start` / `stop` | Video recording of the entire session (Playwright screencast). Trimmed by `stop` if ffmpeg is available. | | `session-log.json` | `exec` | Action timeline. Each entry records the command, relative timestamp, and element data (bounding box, label) for `@eN` targets. Appended with each `exec` call. | | `server.log` | `start` | Dev server stdout and stderr. Only created when ProofShot starts the server via `--run`. | @@ -19,51 +19,60 @@ Each ProofShot session creates a timestamped folder in your output directory (de | `step-*.png` | `exec screenshot` | Screenshots captured at key moments. Filenames come from the `exec screenshot` argument. | | `SUMMARY.md` | `stop` | Markdown report containing: session date, description, video link, screenshots, console error count and details, server error count and details, environment info. | | `viewer.html` | `stop` | Self-contained interactive HTML viewer. No external dependencies — open it in any browser. | +| `evidence.json` | `stop` | Canonical action, browser, and environment evidence with source integrity and grouped incidents. | +| `verdict.json` | `stop` | Machine-readable `PASS`, `FAIL`, `INCOMPLETE`, or `BLOCKED` verdict and reasons. | +| `artifact-manifest.json` | `stop` | Finalized session provenance plus stable, ordered artifact IDs, sizes, and SHA-256 hashes. | +| `environment.ndjson` | environment capture | Bounded raw evidence events. Per-source logs live under `logs/`. | ## `metadata.json` format ```json { + "repository": "github.com/example/project", "branch": "feat/login-page", - "commitSha": "a1b2c3d", + "commitSha": "a1b2c3d...", + "treeHash": "e4f5g6...", + "sourceDirty": false, "startedAt": "2025-01-15T10:30:00.000Z", "description": "Login page verification" } ``` -This file persists after `stop` (unlike `.session.json` which is cleared). It allows `proofshot pr` to find sessions for the current branch even after the session has ended. +This start-time record is finalized into `artifact-manifest.json`. Publication requires repository/branch/commit compatibility with the target PR head and rejects dirty or drifted source state. ## `session-log.json` format ```json [ { - "timestamp": 0, - "command": "open", - "args": ["http://localhost:3000/login"], - "type": "navigate" + "timestamp": "2026-08-09T00:00:00.000Z", + "relativeTimeSec": 0, + "action": "open http://localhost:3000/login", + "outcome": "passed" }, { - "timestamp": 3.2, - "command": "fill", - "args": ["@e2", "user@example.com"], - "type": "fill", + "timestamp": "2026-08-09T00:00:03.200Z", + "relativeTimeSec": 3.2, + "action": "fill @e2 user@example.com", + "outcome": "passed", "element": { "bbox": { "x": 120, "y": 340, "width": 280, "height": 40 }, "label": "Email address" } }, { - "timestamp": 5.1, - "command": "screenshot", - "args": ["step-login.png"], - "type": "screenshot" + "timestamp": "2026-08-09T00:00:05.100Z", + "relativeTimeSec": 5.1, + "action": "screenshot step-login.png", + "outcome": "passed" } ] ``` Timestamps are relative to session start (in seconds). After video trimming, timestamps are adjusted by the trim offset to stay in sync with the trimmed video. +Non-finite legacy timestamps remain explicit untimed, non-clickable rows. The canonical action timeline remains authoritative when media is shorter; viewer seeks clamp to the available recording. + Element data (`bbox`, `label`) is captured before execution for `click`, `fill`, and `type` actions targeting `@eN` references. This data powers the viewer's click ripple overlays and action labels. ## `viewer.html` diff --git a/content/docs/reference/cli.mdx b/content/docs/reference/cli.mdx index a2355f0..e0d9c89 100644 --- a/content/docs/reference/cli.mdx +++ b/content/docs/reference/cli.mdx @@ -48,6 +48,8 @@ proofshot start [options] | `--description ` | Description of what you're verifying (appears in reports) | — | | `--headed` | Show the browser window (visible Chromium) | `false` | | `--output

` | Custom output directory for artifacts | `./proofshot-artifacts` | +| `--browser-executable ` | Use an exact Chrome/Chromium executable | auto-discovered | +| `--force` | Clean up and replace an active session | `false` | **Examples:** @@ -55,16 +57,17 @@ proofshot start [options] proofshot start # Server already running on port 3000 proofshot start --run "npm run dev" --port 3000 # Start server, capture logs proofshot start --url http://localhost:3000/login # Open a specific page +proofshot start --browser-executable /path/to/chrome # Use an exact browser binary proofshot start --description "Verify checkout flow" # Add description to report proofshot start --headed # Show the browser window ``` **What happens:** -1. If `--run` is provided: starts the dev server, pipes output to `server.log`, waits for the port +1. Starts either `--run` or the configured owned `environment` (the two are mutually exclusive), attaches every configured log source, and completes readiness checks 2. Opens headless Chromium via agent-browser 3. Navigates to `--url` (or `http://localhost:`) 4. Starts video recording (retries up to 3 times) -5. Writes `.session.json` and `metadata.json` (git branch and commit SHA) +5. Writes control `.session.json` to the configured/default output and durable `metadata.json` beside the evidence. A CLI-only `--output` changes evidence placement, not control discovery. --- @@ -83,11 +86,14 @@ proofshot stop [options] **What happens:** 1. Collects console errors and output from the browser 2. Stops video recording -3. Closes the browser (unless `--no-close`) -4. Trims video dead time (requires ffmpeg): 5s buffer before first action, 3s after last -5. Scans `server.log` for errors across 10+ languages -6. Generates `SUMMARY.md` and `viewer.html` -7. Clears `.session.json` +3. Closes the exact owned browser session (unless `--no-close`) +4. Stops only the tmux session/server, direct processes, capture helpers, and dev-server process identities created by this ProofShot start +5. Trims video dead time (requires ffmpeg): 5s buffer before first action, 3s after last +6. Scans `server.log` for errors across 10+ languages +7. Generates `SUMMARY.md`, canonical `evidence.json`, structured `verdict.json`, `viewer.html`, and a hashed provenance manifest +8. Clears `.session.json`, or retains it after `--no-close` until a later plain `stop` closes that exact browser + +Repeated `stop` calls are successful no-ops. If bundling fails, control state remains retryable; a later `stop` reuses already-collected artifacts instead of widening process cleanup. --- @@ -104,6 +110,7 @@ proofshot exec [args...] ```sh proofshot exec click @e3 proofshot exec fill @e2 "user@example.com" +proofshot exec assert-visible "#checkout-complete" proofshot exec screenshot step-checkout.png proofshot exec scroll down proofshot exec press Enter @@ -112,6 +119,8 @@ proofshot exec press Enter Every `exec` call appends an entry to `session-log.json` with: - Relative timestamp from session start - The command and arguments +- Passed/failed outcome and error text +- Expected selector for `assert-visible` - Element bounding box and label (for `click`, `fill`, `type` targeting `@eN` refs) This data powers the interactive viewer's timeline and overlays. @@ -152,6 +161,9 @@ proofshot pr [number] [options] |-----------------|-------------| | `[number]` | PR number. If omitted, auto-detected from current branch | | `--dry-run` | Print the markdown comment without posting | +| `--session ` | Select a finalized session explicitly. Repeat to publish multiple sessions | +| `--screenshot ` | Publish only selected screenshot IDs, paths, or unique basenames. Use a space-separated list or repeat the flag | +| `--legacy-session` | Explicitly opt into one pre-manifest session; requires `--session` | | `--upload-provider ` | Upload backend: `repo-contents` (default) or `github-web-attachments` | | `--artifacts-branch ` | Git branch used by the `repo-contents` provider. Default: `proofshot-artifacts` | @@ -160,6 +172,9 @@ proofshot pr [number] [options] ```sh proofshot pr # Auto-detect PR from branch proofshot pr 42 # Post to PR #42 +proofshot pr --session proofshot-2026-08-09_19-00-00 +proofshot pr --session proofshot-2026-08-09_19-00-00 --screenshot checkout.png +proofshot pr --session checkout-session --session settings-session proofshot pr --dry-run # Preview without posting proofshot pr --upload-provider github-web-attachments ``` @@ -167,11 +182,11 @@ proofshot pr --upload-provider github-web-attachments **Requires:** [GitHub CLI](https://cli.github.com/) (`gh`) installed and authenticated, or `GH_TOKEN` / `GITHUB_TOKEN`. **What happens:** -1. Reads `metadata.json` from each session folder -2. Selects sessions matching the current git branch -3. Uploads screenshots and video to GitHub using the selected provider -4. Converts `.webm` to `.mp4` if ffmpeg is available -5. Posts a formatted markdown comment on the PR +1. Resolves the target PR repository, branch, and exact head SHA +2. Selects one compatible complete `PASS` or `FAIL` manifest automatically, or the exact finalized sessions passed with repeated `--session` flags +3. Rejects source drift, mixed commits, unsafe paths/symlinks, hash mismatches, incomplete verdicts, and ambiguous screenshot choices +4. Uploads only the selected manifest artifacts +5. Posts only after every requested artifact uploads successfully `repo-contents` uses the official GitHub contents API and stores artifacts on a dedicated branch. @@ -179,6 +194,19 @@ proofshot pr --upload-provider github-web-attachments --- +## `proofshot session` + +Inspect durable recovery records and retry exact cleanup after an interrupted run. + +```sh +proofshot session list +proofshot session clean --session +``` + +`clean` validates persisted process/session identities. Identity mismatches remain in recovery state and never widen cleanup to a port, process name, or default tmux socket. + +--- + ## `proofshot clean` Remove the entire artifacts directory. @@ -187,4 +215,4 @@ Remove the entire artifacts directory. proofshot clean ``` -Deletes `./proofshot-artifacts/` (or the configured output directory). No flags. +Deletes `./proofshot-artifacts/` (or the configured output directory). No flags. If active or retained control state exists, `clean` refuses and asks you to run `proofshot stop` first so exact process ownership metadata is not discarded. diff --git a/content/docs/reference/configuration.mdx b/content/docs/reference/configuration.mdx index b34e516..3f70e7a 100644 --- a/content/docs/reference/configuration.mdx +++ b/content/docs/reference/configuration.mdx @@ -25,7 +25,43 @@ ProofShot searches for `proofshot.config.json` starting from your current direct "width": 1280, "height": 720 }, - "headless": true + "headless": true, + "environment": { + "kind": "tmux", + "launch": { + "kind": "panes", + "panes": [ + { + "id": "vite", + "title": "Vite", + "group": "frontend", + "command": "npm run dev" + }, + { + "id": "api", + "title": "API", + "group": "backend", + "command": "npm run api" + } + ] + }, + "readiness": [ + { "kind": "http", "url": "http://127.0.0.1:3000/health" } + ] + }, + "logs": { + "stripAnsi": true, + "maxBytesPerSource": 5242880, + "sources": [ + { + "id": "vite", + "group": "frontend", + "kind": "tmux-pane", + "match": { "connectionKey": "vite" }, + "exclude": ["GET /health"] + } + ] + } } ``` @@ -71,6 +107,45 @@ The viewport size affects video recording resolution and screenshot dimensions. When `true`, the browser runs without a visible window. Set to `false` (or use `--headed` CLI flag) to see the browser during testing — useful for debugging. +### `environment` + +`environment.kind` is either: + +- `tmux`: ProofShot owns a dedicated tmux socket/session or connects to an external launcher on the launcher-reported socket. +- `processes`: ProofShot starts multiple direct commands and preserves separate `stdout` and `stderr` streams. + +For `tmux`, `launch.kind: "panes"` accepts `{ id, title, group, cwd, command, env }` entries. `launch.kind: "external-command"` runs one launcher and reads either structured JSON or a `tmux -L attach -t ` command from stdout. Set `connection.ownership` to `attach` when the launcher only reports an existing session. Launchers that create resources must provide a stable `connection.socket` hint or `launch.stopCommand`; they also support `launch.timeoutMs`. Structured JSON is preferred: + +```json +{ + "tmux": { + "socket": "/tmp/dev.sock", + "session": "dev", + "panes": [ + { "key": "vite", "paneId": "%12", "title": "Vite", "group": "frontend" } + ] + } +} +``` + +ProofShot snapshots a hinted tmux socket before launch and owns only server/session identities created by that start. Attach-only launchers are never killed. Shared launchers that create a session must provide `stopCommand`. ProofShot never runs `tmux kill-server` against the default or an unowned socket. + +`readiness` accepts HTTP checks (`url`) and TCP checks (`host`, `port`), each with an optional `timeoutMs`. + +### `logs` + +Every source requires a stable `id`; optional `group` values organize viewer tabs. Supported source kinds: + +- `tmux-pane`: match by launcher `connectionKey`, stable `@proofshot-source` `tag`, or exact `session:window.pane` `target`. +- `process`: select a direct environment command by `processId`. +- `file`: capture an existing file, including `--url` attach workflows. + +Pane titles resolve in this order: launcher mapping title, non-empty tmux pane title, then `Pane `. Duplicate display titles gain a pane-number suffix without changing source identity. + +Tmux panes are one PTY byte stream and are always recorded as `stream: "pty"`; stdout and stderr cannot be recovered after tmux multiplexes them. ProofShot installs `pipe-pane` before backfilling retained scrollback. Canonical evidence keeps untimed `history` and timestamped `live` segments plus their overlap/capture-gap boundary. + +`include` and `exclude` filters affect viewer presentation only. Canonical evidence and incident detection retain every bounded event. `maxBytesPerSource` records truncation instead of silently discarding integrity state. + ## CLI flag precedence Command-line flags override config file values. Config file values override defaults. diff --git a/dist/bin/proofshot.js b/dist/bin/proofshot.js new file mode 100755 index 0000000..8625352 --- /dev/null +++ b/dist/bin/proofshot.js @@ -0,0 +1,8257 @@ +#!/usr/bin/env node + +// src/cli.ts +import { Command } from "commander"; + +// src/commands/install.ts +import * as fs2 from "fs"; +import * as path2 from "path"; +import * as os from "os"; +import { execSync } from "child_process"; +import chalk from "chalk"; + +// src/utils/skills.ts +import * as fs from "fs"; +import * as path from "path"; +function getSkillsDir() { + return path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "..", + "..", + "skills" + ); +} +function readBundledSkill(relativePath) { + try { + return fs.readFileSync(path.join(getSkillsDir(), relativePath), "utf-8"); + } catch { + return null; + } +} +function getInlineSkillContent(agent) { + if (agent === "claude" || agent === "codex") { + return `--- +name: proofshot +description: Visual verification of UI features. Use after building or modifying any + UI component, page, or visual feature. Starts a verification session with video + recording and error capture, then you drive the browser to test, then stop to + bundle proof artifacts for the human. +allowed-tools: Bash(proofshot:*), Bash(agent-browser:*) +--- + +# ProofShot \u2014 Visual Verification Workflow + +## When to use + +Use ProofShot after: +- Building a new UI feature or page +- Modifying existing UI components +- Fixing a visual bug +- Any change that affects what the user sees + +## The workflow (always follow these 3 steps) + +### Step 1: Start the session + +\`\`\`bash +proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" +\`\`\` + +This opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output. +If the server is already running, omit --run (no server logs captured). +The description appears in the proof report for the human. + +### Step 2: Drive the browser and test + +Use proofshot exec to navigate, interact, and verify: + +\`\`\`bash +proofshot exec snapshot -i # See interactive elements +proofshot exec open http://localhost:PORT/page # Navigate to a page +proofshot exec click @e3 # Click a button +proofshot exec fill @e2 "test@example.com" # Fill a form field +proofshot exec assert-visible "#expected-result" # Record an expected selector +proofshot exec screenshot step-NAME.png # Capture key moments +\`\`\` + +Take screenshots at important moments \u2014 these become the visual proof. +Verify what you expect to see by reading the snapshot output. + +### Step 3: Stop and bundle the proof + +\`\`\`bash +proofshot stop +\`\`\` + +This stops recording, collects canonical browser + environment evidence, and generates +a SUMMARY.md, viewer, structured verdict, and provenance manifest. + +### Step 4 (optional): Post proof to the PR + +\`\`\`bash +proofshot pr # Auto-detect PR from current branch +proofshot pr 42 # Target a specific PR number +proofshot pr 42 --session SESSION_ID --screenshot step-NAME.png +\`\`\` + +This selects one finalized session compatible with the PR head, validates artifact hashes, uploads the selected screenshots/video, and posts only after every upload succeeds. Requires \`gh\` CLI to be authenticated. +Default upload mode uses the official GitHub contents API on a \`proofshot-artifacts\` branch. For GitHub-hosted attachment URLs, use \`proofshot pr --upload-provider github-web-attachments\`. + +## Tips + +- Always include a meaningful --description so the human knows what was tested +- Take screenshots before AND after key actions (e.g., before form submit, after redirect) +- If you find errors during verification, fix them and re-run the workflow +- Use \`proofshot pr\` after stopping to attach proof directly to the pull request +`; + } + if (agent === "cursor") { + return `--- +description: Visual verification of UI changes using ProofShot +globs: ["**/*.tsx", "**/*.jsx", "**/*.vue", "**/*.svelte", "**/*.html"] +--- + +After modifying UI files, visually verify changes with this workflow: + +1. Start session: \`proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"\` + If the server is already running, omit --run. +2. Drive browser: Use \`proofshot exec\` commands to navigate, click, fill forms, and take screenshots +3. Stop session: \`proofshot stop\` to bundle video + screenshots + error report +4. (Optional) Post to PR: \`proofshot pr\` to upload proof to the GitHub PR + Default provider uses the official contents API. Use \`--upload-provider github-web-attachments\` only if you specifically want GitHub attachment URLs. + +Key proofshot exec commands: +- \`proofshot exec snapshot -i\` \u2014 see interactive elements +- \`proofshot exec click @e3\` \u2014 click an element +- \`proofshot exec fill @e2 "text"\` \u2014 fill a form field +- \`proofshot exec assert-visible "#selector"\` \u2014 record an expected selector +- \`proofshot exec screenshot step.png\` \u2014 capture a moment +`; + } + return `# ProofShot Visual Verification + +After building or modifying UI features, verify with this workflow: + +1. Start: \`proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"\` + If the server is already running, omit --run. +2. Test: Use \`proofshot exec\` to navigate, click, fill forms, take screenshots +3. Stop: \`proofshot stop\` \u2014 bundles video, screenshots, and error report +4. (Optional) Post to PR: \`proofshot pr\` \u2014 uploads proof to the GitHub PR + Default provider uses the official contents API. Use \`--upload-provider github-web-attachments\` only if you specifically want GitHub attachment URLs. + +Key proofshot exec commands: +- \`proofshot exec snapshot -i\` \u2014 see interactive elements +- \`proofshot exec click @e3\` \u2014 click an element +- \`proofshot exec fill @e2 "text"\` \u2014 fill a form field +- \`proofshot exec assert-visible "#selector"\` \u2014 record an expected selector +- \`proofshot exec screenshot step.png\` \u2014 capture a moment + +Artifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary. +`; +} + +// src/commands/install.ts +var MARKER_START = ""; +var MARKER_END = ""; +function getToolDefinitions() { + const home = os.homedir(); + return [ + { + name: "claude", + displayName: "Claude Code", + binaryName: "claude", + configDir: path2.join(home, ".claude"), + skillTarget: { strategy: "file", relativePath: "skills/proofshot/SKILL.md" }, + bundledSkill: "claude/SKILL.md", + inlineAgent: "claude" + }, + { + name: "cursor", + displayName: "Cursor", + binaryName: "cursor", + configDir: path2.join(home, ".cursor"), + skillTarget: { strategy: "file", relativePath: "rules/proofshot.mdc" }, + bundledSkill: "cursor/proofshot.mdc", + inlineAgent: "cursor" + }, + { + name: "codex", + displayName: "Codex (OpenAI)", + binaryName: "codex", + configDir: path2.join(home, ".codex"), + skillTarget: { strategy: "file", relativePath: "skills/proofshot/SKILL.md" }, + bundledSkill: "codex/SKILL.md", + inlineAgent: "codex" + }, + { + name: "gemini", + displayName: "Gemini CLI", + binaryName: "gemini", + configDir: path2.join(home, ".gemini"), + skillTarget: { strategy: "append", relativePath: "GEMINI.md" }, + bundledSkill: "generic/PROOFSHOT.md", + inlineAgent: "generic" + }, + { + name: "windsurf", + displayName: "Windsurf", + binaryName: "windsurf", + configDir: path2.join(home, ".codeium", "windsurf"), + skillTarget: { strategy: "append", relativePath: "memories/global_rules.md" }, + bundledSkill: "generic/PROOFSHOT.md", + inlineAgent: "generic" + }, + { + name: "opencode", + displayName: "OpenCode", + binaryName: "opencode", + configDir: path2.join(home, ".config", "opencode"), + skillTarget: { strategy: "file", relativePath: "skills/proofshot/SKILL.md" }, + bundledSkill: "opencode/SKILL.md", + inlineAgent: "codex" + } + ]; +} +function isBinaryAvailable(binaryName) { + const cmd = process.platform === "win32" ? `where ${binaryName}` : `which ${binaryName}`; + try { + execSync(cmd, { stdio: "pipe" }); + return true; + } catch { + return false; + } +} +function detectInstalledTools() { + return getToolDefinitions().filter( + (tool) => isBinaryAvailable(tool.binaryName) || fs2.existsSync(tool.configDir) + ); +} +function filterTools(detected, only, skip) { + let tools = detected; + if (only) { + const onlySet = new Set(only.split(",").map((s) => s.trim().toLowerCase())); + tools = tools.filter((t) => onlySet.has(t.name)); + } + if (skip) { + const skipSet = new Set(skip.split(",").map((s) => s.trim().toLowerCase())); + tools = tools.filter((t) => !skipSet.has(t.name)); + } + return tools; +} +function getSkillContent(tool) { + return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent); +} +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function installFile(tool, targetPath, content, force) { + const exists = fs2.existsSync(targetPath); + if (exists && !force) { + const existing = fs2.readFileSync(targetPath, "utf-8"); + if (existing === content) { + return { + tool: tool.name, + displayName: tool.displayName, + status: "skipped", + path: targetPath, + message: "Already up to date" + }; + } + } + fs2.writeFileSync(targetPath, content); + return { + tool: tool.name, + displayName: tool.displayName, + status: exists ? "updated" : "installed", + path: targetPath + }; +} +function installAppend(tool, targetPath, content, force) { + const markedContent = `${MARKER_START} +${content} +${MARKER_END}`; + const exists = fs2.existsSync(targetPath); + if (exists) { + const existing = fs2.readFileSync(targetPath, "utf-8"); + if (existing.includes(MARKER_START)) { + const regex = new RegExp( + `${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}` + ); + const updated = existing.replace(regex, markedContent); + if (updated === existing && !force) { + return { + tool: tool.name, + displayName: tool.displayName, + status: "skipped", + path: targetPath, + message: "Already up to date" + }; + } + fs2.writeFileSync(targetPath, updated); + return { + tool: tool.name, + displayName: tool.displayName, + status: "updated", + path: targetPath + }; + } + fs2.appendFileSync(targetPath, "\n\n" + markedContent + "\n"); + return { + tool: tool.name, + displayName: tool.displayName, + status: "installed", + path: targetPath + }; + } + fs2.writeFileSync(targetPath, markedContent + "\n"); + return { + tool: tool.name, + displayName: tool.displayName, + status: "installed", + path: targetPath + }; +} +function installForTool(tool, force) { + const content = getSkillContent(tool); + const targetPath = path2.join(tool.configDir, tool.skillTarget.relativePath); + const targetDir = path2.dirname(targetPath); + try { + fs2.mkdirSync(targetDir, { recursive: true }); + if (tool.skillTarget.strategy === "file") { + return installFile(tool, targetPath, content, force); + } else { + return installAppend(tool, targetPath, content, force); + } + } catch (error) { + return { + tool: tool.name, + displayName: tool.displayName, + status: "failed", + path: targetPath, + message: error.message + }; + } +} +function checkboxSelect(tools) { + return new Promise((resolve13) => { + const selected = new Array(tools.length).fill(true); + let cursor = 0; + function render() { + if (renderCount > 0) { + process.stdout.write(`\x1B[${tools.length + 2}A`); + } + renderCount++; + console.log(chalk.bold("Select tools to install:")); + console.log(""); + for (let i = 0; i < tools.length; i++) { + const check = selected[i] ? chalk.green("[x]") : chalk.dim("[ ]"); + const label = tools[i].displayName; + const pointer = i === cursor ? chalk.green("> ") : " "; + console.log(`${pointer}${check} ${label}`); + } + } + let renderCount = 0; + render(); + console.log(""); + process.stdout.write(chalk.dim(" \u2191/\u2193 navigate \xB7 space toggle \xB7 enter confirm")); + const stdin = process.stdin; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf-8"); + function onData(key) { + if (key === "") { + stdin.setRawMode(false); + stdin.removeListener("data", onData); + stdin.pause(); + process.stdout.write("\r\x1B[K\n"); + resolve13([]); + return; + } + if (key === "\r" || key === "\n") { + stdin.setRawMode(false); + stdin.removeListener("data", onData); + stdin.pause(); + process.stdout.write("\r\x1B[K\n"); + resolve13(tools.filter((_, i) => selected[i])); + return; + } + if (key === " ") { + selected[cursor] = !selected[cursor]; + process.stdout.write("\r\x1B[K"); + process.stdout.write(`\x1B[1A`); + render(); + console.log(""); + process.stdout.write(chalk.dim(" \u2191/\u2193 navigate \xB7 space toggle \xB7 enter confirm")); + return; + } + if (key === "\x1B[A") { + cursor = (cursor - 1 + tools.length) % tools.length; + process.stdout.write("\r\x1B[K"); + process.stdout.write(`\x1B[1A`); + render(); + console.log(""); + process.stdout.write(chalk.dim(" \u2191/\u2193 navigate \xB7 space toggle \xB7 enter confirm")); + return; + } + if (key === "\x1B[B") { + cursor = (cursor + 1) % tools.length; + process.stdout.write("\r\x1B[K"); + process.stdout.write(`\x1B[1A`); + render(); + console.log(""); + process.stdout.write(chalk.dim(" \u2191/\u2193 navigate \xB7 space toggle \xB7 enter confirm")); + return; + } + } + stdin.on("data", onData); + }); +} +async function installCommand(options) { + const allDetected = detectInstalledTools(); + const tools = filterTools(allDetected, options.only, options.skip); + if (tools.length === 0) { + if (options.only || options.skip) { + console.log(chalk.yellow("No matching AI tools found after applying filters.")); + console.log( + chalk.dim( + "Detected tools: " + (allDetected.map((t) => t.name).join(", ") || "none") + ) + ); + } else { + console.log(chalk.yellow("No AI coding tools detected on this machine.")); + console.log(chalk.dim("Looked for: claude, cursor, codex, gemini, windsurf, opencode")); + } + return; + } + let selectedTools = tools; + if (process.stdin.isTTY) { + console.log(""); + const picked = await checkboxSelect(tools); + if (picked.length === 0) { + console.log(chalk.dim("Aborted.")); + return; + } + selectedTools = picked; + } else { + console.log(""); + console.log(chalk.bold("Detected AI coding tools:")); + console.log(""); + for (const tool of tools) { + console.log(` ${chalk.green("\u25CF")} ${tool.displayName}`); + } + console.log(""); + } + const results = []; + for (const tool of selectedTools) { + const result = installForTool(tool, !!options.force); + results.push(result); + const icon = result.status === "failed" ? chalk.red("\u2717") : result.status === "skipped" ? chalk.dim("\u2013") : chalk.green("\u2713"); + const statusText = result.status === "installed" ? "Installed" : result.status === "updated" ? "Updated" : result.status === "skipped" ? "Skipped" : "Failed"; + const suffix = result.message ? chalk.dim(` (${result.message})`) : ""; + console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`); + if (result.status !== "failed") { + console.log(chalk.dim(` \u2192 ${result.path}`)); + } else if (result.message) { + console.log(chalk.red(` ${result.message}`)); + } + } + const installed = results.filter( + (r) => r.status === "installed" || r.status === "updated" + ).length; + const failed = results.filter((r) => r.status === "failed").length; + console.log(""); + if (failed > 0) { + console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`)); + } else if (installed > 0) { + console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`)); + console.log(""); + console.log(`You're all set! In any project, tell your AI agent:`); + console.log(""); + console.log(chalk.white(` "Verify the changes visually with proofshot"`)); + console.log(""); + } else { + console.log(chalk.dim("All tools already up to date.")); + } +} + +// src/commands/start.ts +import * as path13 from "path"; +import chalk2 from "chalk"; + +// src/utils/config.ts +import * as fs3 from "fs"; +import * as path3 from "path"; +var CONFIG_FILENAME = "proofshot.config.json"; +var DEFAULT_CONFIG = { + devServer: { + port: 3e3, + startupTimeout: 3e4 + }, + output: "./proofshot-artifacts", + defaultPages: ["/"], + viewport: { width: 1280, height: 720 }, + headless: true, + browser: { + ignoreHttpsErrors: false + }, + logs: { + stripAnsi: true, + maxBytesPerSource: 5 * 1024 * 1024, + sources: [] + } +}; +function findConfigPath(startDir) { + let dir = startDir || process.cwd(); + while (true) { + const configPath = path3.join(dir, CONFIG_FILENAME); + if (fs3.existsSync(configPath)) return configPath; + const parent = path3.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} +function loadConfig(startDir) { + const configPath = findConfigPath(startDir); + if (!configPath) return { ...DEFAULT_CONFIG }; + try { + const raw = fs3.readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw); + validateConfig(parsed); + const configDir = path3.dirname(configPath); + const resolvedBrowser = { + ...DEFAULT_CONFIG.browser, + ...parsed.browser + }; + if (resolvedBrowser.configPath) { + resolvedBrowser.configPath = path3.resolve(configDir, resolvedBrowser.configPath); + } + const environment = resolveEnvironmentConfig(parsed.environment, configDir); + const logs = resolveLogsConfig(parsed.logs, configDir); + return { + ...DEFAULT_CONFIG, + ...parsed, + output: path3.resolve( + configDir, + typeof parsed.output === "string" ? parsed.output : DEFAULT_CONFIG.output + ), + devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer }, + viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport }, + browser: resolvedBrowser, + environment, + logs + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ProofShot config at ${configPath}: ${message}`); + } +} +function validateConfig(value) { + assertRecord(value, "config"); + assertOptionalString(value.output, "output"); + assertOptionalBoolean(value.headless, "headless"); + assertOptionalStringArray(value.defaultPages, "defaultPages"); + if (value.devServer !== void 0) { + assertRecord(value.devServer, "devServer"); + assertOptionalPositiveInteger(value.devServer.port, "devServer.port", 65535); + assertOptionalPositiveInteger( + value.devServer.startupTimeout, + "devServer.startupTimeout" + ); + } + if (value.viewport !== void 0) { + assertRecord(value.viewport, "viewport"); + assertOptionalPositiveInteger(value.viewport.width, "viewport.width"); + assertOptionalPositiveInteger(value.viewport.height, "viewport.height"); + } + if (value.browser !== void 0) { + assertRecord(value.browser, "browser"); + assertOptionalString(value.browser.configPath, "browser.configPath"); + assertOptionalString(value.browser.executablePath, "browser.executablePath"); + assertOptionalBoolean(value.browser.ignoreHttpsErrors, "browser.ignoreHttpsErrors"); + } + validateEnvironment(value.environment); + validateLogs(value.logs); +} +function validateEnvironment(value) { + if (value === void 0) return; + assertRecord(value, "environment"); + validateReadiness(value.readiness); + if (value.kind === "tmux") { + assertRecord(value.launch, "environment.launch"); + assertOptionalString(value.cwd, "environment.cwd"); + if (value.launch.kind === "panes") { + if (!Array.isArray(value.launch.panes) || value.launch.panes.length === 0) { + throw new Error("environment.launch.panes must be a non-empty array"); + } + validateDefinitions(value.launch.panes, "environment.launch.panes"); + assertOptionalString( + value.launch.sessionName, + "environment.launch.sessionName" + ); + if (value.connection !== void 0) { + throw new Error("environment.connection is only valid for external-command"); + } + return; + } + if (value.launch.kind === "external-command") { + assertNonEmptyString(value.launch.command, "environment.launch.command"); + assertOptionalString( + value.launch.stopCommand, + "environment.launch.stopCommand" + ); + assertOptionalPositiveInteger( + value.launch.timeoutMs, + "environment.launch.timeoutMs" + ); + assertRecord(value.connection, "environment.connection"); + if (value.connection.format !== "json" && value.connection.format !== "tmux-attach-command") { + throw new Error( + 'environment.connection.format must be "json" or "tmux-attach-command"' + ); + } + if (value.connection.source !== void 0 && value.connection.source !== "stdout") { + throw new Error('environment.connection.source must be "stdout"'); + } + assertOptionalString(value.connection.socket, "environment.connection.socket"); + if (value.connection.ownership !== void 0 && value.connection.ownership !== "attach" && value.connection.ownership !== "create") { + throw new Error( + 'environment.connection.ownership must be "attach" or "create"' + ); + } + if (value.connection.ownership !== "attach" && value.connection.socket === void 0 && value.launch.stopCommand === void 0) { + throw new Error( + "external-command requires connection.socket or launch.stopCommand for cleanup" + ); + } + return; + } + throw new Error( + 'environment.launch.kind must be "panes" or "external-command"' + ); + } + if (value.kind === "processes") { + if (!Array.isArray(value.commands)) { + throw new Error("environment.commands must be an array"); + } + validateDefinitions(value.commands, "environment.commands"); + return; + } + throw new Error('environment.kind must be "tmux" or "processes"'); +} +function validateDefinitions(value, field) { + const ids = /* @__PURE__ */ new Set(); + value.forEach((candidate, index) => { + const item = `${field}[${index}]`; + assertRecord(candidate, item); + assertSafeId(candidate.id, `${item}.id`); + if (ids.has(candidate.id)) throw new Error(`Duplicate ${field} id: ${candidate.id}`); + ids.add(candidate.id); + assertNonEmptyString(candidate.command, `${item}.command`); + assertOptionalString(candidate.title, `${item}.title`); + assertOptionalString(candidate.group, `${item}.group`); + assertOptionalString(candidate.cwd, `${item}.cwd`); + if (candidate.env !== void 0) { + assertRecord(candidate.env, `${item}.env`); + for (const [key, envValue] of Object.entries(candidate.env)) { + if (typeof envValue !== "string") { + throw new Error(`${item}.env.${key} must be a string`); + } + } + } + }); +} +function validateReadiness(value) { + if (value === void 0) return; + if (!Array.isArray(value)) throw new Error("environment.readiness must be an array"); + value.forEach((candidate, index) => { + const item = `environment.readiness[${index}]`; + assertRecord(candidate, item); + assertOptionalPositiveInteger(candidate.timeoutMs, `${item}.timeoutMs`); + if (candidate.kind === "http") { + assertNonEmptyString(candidate.url, `${item}.url`); + return; + } + if (candidate.kind === "tcp") { + assertOptionalString(candidate.host, `${item}.host`); + assertOptionalPositiveInteger(candidate.port, `${item}.port`, 65535, true); + return; + } + throw new Error(`${item}.kind must be "http" or "tcp"`); + }); +} +function validateLogs(value) { + if (value === void 0) return; + assertRecord(value, "logs"); + assertOptionalBoolean(value.stripAnsi, "logs.stripAnsi"); + assertOptionalPositiveInteger(value.maxBytesPerSource, "logs.maxBytesPerSource"); + if (value.maxBytesPerSource !== void 0 && value.maxBytesPerSource < 512) { + throw new Error("logs.maxBytesPerSource must be at least 512 bytes"); + } + if (value.sources === void 0) return; + if (!Array.isArray(value.sources)) throw new Error("logs.sources must be an array"); + const ids = /* @__PURE__ */ new Set(); + value.sources.forEach((candidate, index) => { + const item = `logs.sources[${index}]`; + assertRecord(candidate, item); + assertSafeId(candidate.id, `${item}.id`); + if (ids.has(candidate.id)) throw new Error(`Duplicate log source id: ${candidate.id}`); + ids.add(candidate.id); + assertOptionalString(candidate.title, `${item}.title`); + assertOptionalString(candidate.group, `${item}.group`); + assertOptionalStringArray(candidate.include, `${item}.include`); + assertOptionalStringArray(candidate.exclude, `${item}.exclude`); + if (candidate.kind === "tmux-pane") { + assertRecord(candidate.match, `${item}.match`); + const keys = ["connectionKey", "tag", "target"].filter( + (key) => candidate.match[key] !== void 0 + ); + if (keys.length !== 1) { + throw new Error(`${item}.match must set exactly one pane selector`); + } + assertNonEmptyString(candidate.match[keys[0]], `${item}.match.${keys[0]}`); + return; + } + if (candidate.kind === "process") { + assertSafeId(candidate.processId, `${item}.processId`); + return; + } + if (candidate.kind === "file") { + assertNonEmptyString(candidate.path, `${item}.path`); + return; + } + throw new Error(`${item}.kind is unsupported`); + }); +} +function assertRecord(value, field) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } +} +function assertSafeId(value, field) { + assertNonEmptyString(value, field); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) { + throw new Error(`${field} must contain only letters, numbers, "_" or "-"`); + } +} +function assertNonEmptyString(value, field) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${field} must be a non-empty string`); + } +} +function assertOptionalString(value, field) { + if (value !== void 0 && typeof value !== "string") { + throw new Error(`${field} must be a string`); + } +} +function assertOptionalBoolean(value, field) { + if (value !== void 0 && typeof value !== "boolean") { + throw new Error(`${field} must be a boolean`); + } +} +function assertOptionalStringArray(value, field) { + if (value !== void 0 && (!Array.isArray(value) || value.some((entry) => typeof entry !== "string"))) { + throw new Error(`${field} must be an array of strings`); + } +} +function assertOptionalPositiveInteger(value, field, maximum = Number.MAX_SAFE_INTEGER, required = false) { + if (value === void 0 && !required) return; + if (!Number.isInteger(value) || value <= 0 || value > maximum) { + throw new Error(`${field} must be a positive integer no greater than ${maximum}`); + } +} +function resolveEnvironmentConfig(value, configDir) { + if (typeof value !== "object" || value === null) { + return void 0; + } + const environment = value; + if (environment.kind === "tmux") { + const launch = environment.launch.kind === "panes" ? { + ...environment.launch, + panes: environment.launch.panes.map((pane) => ({ + ...pane, + cwd: path3.resolve(configDir, pane.cwd || environment.cwd || ".") + })) + } : environment.launch; + return { + ...environment, + cwd: path3.resolve(configDir, environment.cwd || "."), + connection: environment.connection?.socket ? { + ...environment.connection, + socket: path3.resolve(configDir, environment.connection.socket) + } : environment.connection, + launch + }; + } + if (environment.kind === "processes") { + return { + ...environment, + commands: environment.commands.map((command) => ({ + ...command, + cwd: path3.resolve(configDir, command.cwd || ".") + })) + }; + } + return void 0; +} +function resolveLogsConfig(value, configDir) { + const logs = typeof value === "object" && value !== null ? value : DEFAULT_CONFIG.logs; + const sources = (logs.sources || []).map( + (source) => source.kind === "file" ? { ...source, path: path3.resolve(configDir, source.path) } : source + ); + return { + ...DEFAULT_CONFIG.logs, + ...logs, + sources + }; +} + +// src/utils/exec.ts +import { execSync as execSync3 } from "child_process"; + +// src/utils/process.ts +import * as fs4 from "fs"; +import { + execFileSync, + execSync as execSync2, + spawn +} from "child_process"; +function getShellExecutable(platform = process.platform, env = process.env) { + if (platform === "win32") { + return env.ComSpec || "cmd.exe"; + } + return env.SHELL || "/bin/sh"; +} +function spawnShellCommand(command, options = {}) { + return spawn(command, { + ...options, + shell: getShellExecutable() + }); +} +function parseLinuxProcStat(stat) { + const closeParen = stat.lastIndexOf(")"); + if (closeParen < 0) return null; + const pid = Number(stat.slice(0, stat.indexOf(" "))); + const fields = stat.slice(closeParen + 2).trim().split(/\s+/); + const processGroupId = Number(fields[2]); + const sessionId = Number(fields[3]); + const startTime = fields[19]; + if (!Number.isInteger(pid) || !Number.isInteger(processGroupId) || !Number.isInteger(sessionId) || !startTime) { + return null; + } + return { pid, processGroupId, sessionId, startTime }; +} +function parseUnixProcessIdentity(pid, output) { + const match = output.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + if (!match) return null; + const processGroupId = Number(match[1]); + const sessionId = Number(match[2]); + const startTime = match[3]; + if (!Number.isInteger(processGroupId) || processGroupId <= 0 || !Number.isInteger(sessionId) || sessionId < 0 || !startTime) { + return null; + } + return { pid, processGroupId, sessionId, startTime }; +} +function isDetachedProcessIdentity(identity, platform = process.platform) { + if (platform === "darwin") { + return identity.processGroupId === identity.pid; + } + return identity.sessionId === identity.pid; +} +function captureProcessIdentity(pid) { + if (!Number.isInteger(pid) || pid <= 0) return null; + if (process.platform === "linux") { + try { + const identity = parseLinuxProcStat(fs4.readFileSync(`/proc/${pid}/stat`, "utf-8")); + const bootId = fs4.readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim(); + if (!identity || !bootId) return null; + return { ...identity, bootId }; + } catch { + return null; + } + } + if (process.platform !== "win32") { + try { + const sessionField = process.platform === "darwin" ? "sess=" : "sid="; + const output = execFileSync( + "ps", + ["-o", "pgid=", "-o", sessionField, "-o", "lstart=", "-p", String(pid)], + { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, TZ: "UTC" } + } + ); + const identity = parseUnixProcessIdentity(pid, output); + if (!identity) return null; + if (process.platform !== "darwin") return identity; + const bootId = execFileSync("sysctl", ["-n", "kern.boottime"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"] + }).trim(); + return bootId ? { ...identity, bootId } : null; + } catch { + return null; + } + } + try { + const script = `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`; + const startTime = execFileSync( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] } + ).trim(); + if (!/^\d+$/.test(startTime)) return null; + return { pid, processGroupId: pid, sessionId: pid, startTime }; + } catch { + return null; + } +} +function processIdentityMatches(identity) { + const current = captureProcessIdentity(identity.pid); + return Boolean(current && processIdentitiesMatch(current, identity)); +} +function processIdentitiesMatch(left, right) { + return left.pid === right.pid && left.processGroupId === right.processGroupId && left.sessionId === right.sessionId && left.startTime === right.startTime && left.bootId === right.bootId; +} +function listProcessGroupsInSession(sessionId) { + const groups = /* @__PURE__ */ new Set(); + if (process.platform === "linux") { + let entries = []; + try { + entries = fs4.readdirSync("/proc"); + } catch { + return []; + } + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + try { + const identity = parseLinuxProcStat( + fs4.readFileSync(`/proc/${entry}/stat`, "utf-8") + ); + if (identity?.sessionId === sessionId) { + groups.add(identity.processGroupId); + } + } catch { + } + } + return [...groups]; + } + if (process.platform !== "win32") { + try { + const sessionField = process.platform === "darwin" ? "sess=" : "sid="; + const output = execFileSync("ps", ["-axo", `pgid=,${sessionField}`], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"] + }); + for (const line of output.split(/\r?\n/)) { + const match = line.trim().match(/^(\d+)\s+(\d+)$/); + if (match && Number(match[2]) === sessionId) { + groups.add(Number(match[1])); + } + } + } catch { + return []; + } + } + return [...groups]; +} +function processGroupIsAlive(processGroupId) { + try { + process.kill(-processGroupId, 0); + return true; + } catch (error) { + return error.code === "EPERM"; + } +} +function ownedProcessTreeIsAlive(identity) { + if (process.platform === "win32") return processIdentityMatches(identity); + const current = captureProcessIdentity(identity.pid); + if (current && !processIdentitiesMatch(current, identity)) return false; + if (process.platform === "darwin") { + return processGroupIsAlive(identity.processGroupId); + } + return listProcessGroupsInSession(identity.sessionId).length > 0; +} +function signalOwnedTree(identity, signal) { + if (process.platform === "win32") return false; + const current = captureProcessIdentity(identity.pid); + if (current && !processIdentitiesMatch(current, identity)) return false; + if (!isDetachedProcessIdentity(identity)) return false; + if (process.platform === "darwin") { + if (!processGroupIsAlive(identity.processGroupId)) return false; + try { + process.kill(-identity.processGroupId, signal); + return true; + } catch { + return false; + } + } + const groups = listProcessGroupsInSession(identity.sessionId); + if (groups.length === 0) return false; + let signalled = false; + for (const groupId of groups) { + if (!Number.isInteger(groupId) || groupId <= 0) continue; + try { + process.kill(-groupId, signal); + signalled = true; + } catch { + } + } + return signalled; +} +async function terminateOwnedProcessTree(identity, options = {}) { + if (!identity) return false; + if (process.platform === "win32") { + if (!processIdentityMatches(identity)) return false; + try { + execFileSync("taskkill", ["/F", "/T", "/PID", String(identity.pid)], { + stdio: "pipe" + }); + return true; + } catch { + return false; + } + } + if (!ownedProcessTreeIsAlive(identity)) return false; + const signalled = signalOwnedTree(identity, "SIGTERM"); + if (!signalled) return false; + const graceMs = options.graceMs ?? 1500; + const pollIntervalMs = options.pollIntervalMs ?? 50; + const deadline = Date.now() + graceMs; + while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) { + await new Promise((resolve13) => setTimeout(resolve13, pollIntervalMs)); + } + if (ownedProcessTreeIsAlive(identity)) { + signalOwnedTree(identity, "SIGKILL"); + const killDeadline = Date.now() + 500; + while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) { + await new Promise((resolve13) => setTimeout(resolve13, pollIntervalMs)); + } + } + return true; +} +async function terminateOwnedProcess(identity, options = {}) { + if (!identity || !processIdentityMatches(identity)) { + return false; + } + const graceMs = options.graceMs ?? 1500; + const pollIntervalMs = options.pollIntervalMs ?? 50; + try { + process.kill(identity.pid, "SIGTERM"); + } catch { + return false; + } + const deadline = Date.now() + graceMs; + while (Date.now() < deadline && processIdentityMatches(identity)) { + await new Promise((resolve13) => setTimeout(resolve13, pollIntervalMs)); + } + if (processIdentityMatches(identity)) { + try { + process.kill(identity.pid, "SIGKILL"); + } catch { + return false; + } + } + return true; +} +function terminateProcessTree(pid) { + if (process.platform === "win32") { + execSync2(`taskkill /F /T /PID ${pid}`, { stdio: "pipe" }); + return; + } + process.kill(-pid, "SIGKILL"); +} +function findExecutablePath(command, platform = process.platform, execFn = execSync2) { + try { + const lookupCommand = platform === "win32" ? `where ${command}` : `command -v ${command}`; + const output = execFn(lookupCommand, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + return output.split(/\r?\n/)[0] || null; + } catch { + return null; + } +} +function readCommandVersion(command, args = ["--version"], execFn = execSync2) { + try { + const output = execFn([command, ...args].join(" "), { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + return output.split(/\r?\n/)[0] || null; + } catch { + return null; + } +} + +// src/utils/exec.ts +var ProofShotError = class extends Error { + constructor(message, cause) { + super(message); + this.cause = cause; + this.name = "ProofShotError"; + } +}; +var defaultAgentBrowserOptions = {}; +function setAgentBrowserDefaults(options) { + defaultAgentBrowserOptions = { ...options }; +} +function getAgentBrowserEnvironment(options = {}) { + const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir; + return { + ...process.env, + AGENT_BROWSER_IDLE_TIMEOUT_MS: process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || "1800000", + ...socketDir ? { AGENT_BROWSER_SOCKET_DIR: socketDir } : {} + }; +} +function quoteShellArgument(value) { + const escaped = value.replace(/'/g, "'\\''"); + return `'${escaped}'`; +} +function buildAgentBrowserCommand(command, options = {}) { + const mergedOptions = { + ...defaultAgentBrowserOptions, + ...options + }; + const configFlag = mergedOptions.configPath ? ` --config ${quoteShellArgument(mergedOptions.configPath)}` : ""; + const sessionFlag = mergedOptions.session ? ` --session ${quoteShellArgument(mergedOptions.session)}` : ""; + return `agent-browser${configFlag}${sessionFlag} ${command}`; +} +function ab(command, timeoutOrOptions = 3e4) { + const options = typeof timeoutOrOptions === "number" ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions; + const fullCommand = buildAgentBrowserCommand(command, options); + try { + return execSync3(fullCommand, { + encoding: "utf-8", + timeout: options.timeoutMs ?? 3e4, + stdio: ["pipe", "pipe", "pipe"], + env: getAgentBrowserEnvironment(options) + }).trim(); + } catch (error) { + const stderr = error?.stderr?.toString?.() || ""; + const message = stderr || error?.message || "Unknown error"; + throw new ProofShotError( + `Browser command failed: ${fullCommand} +${message}`, + error + ); + } +} + +// src/server/start.ts +import * as fs5 from "fs"; +import { spawn as spawn2 } from "child_process"; + +// src/utils/port.ts +import * as net from "net"; +async function isPortOpen(port, host = "localhost") { + if (await tryConnect(port, host)) return true; + if (host === "localhost") { + const results = await Promise.all([ + tryConnect(port, "127.0.0.1"), + tryConnect(port, "::1") + ]); + return results.some(Boolean); + } + return false; +} +function tryConnect(port, host) { + return new Promise((resolve13) => { + const socket = new net.Socket(); + socket.setTimeout(1e3); + socket.on("connect", () => { + socket.destroy(); + resolve13(true); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve13(false); + }); + socket.on("error", () => { + socket.destroy(); + resolve13(false); + }); + socket.connect(port, host); + }); +} +async function waitForPort(port, timeoutMs = 3e4, intervalMs = 500) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isPortOpen(port)) return; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`); +} + +// src/server/start.ts +var SERVER_RUNNER_SOURCE = String.raw` +const fs = require('fs'); +const { spawn } = require('child_process'); +const [command, cwd, logPath, shell] = process.argv.slice(1); +const fd = fs.openSync(logPath, 'a'); +let closed = false; +const write = (text) => { + if (!closed) fs.writeSync(fd, Date.now() + '\t' + text + '\n'); +}; +const child = spawn(command, { + cwd, + shell, + stdio: ['ignore', 'pipe', 'pipe'], +}); +const attach = (stream) => { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) write(line); + }); + stream.on('end', () => { + if (buffer) write(buffer); + buffer = ''; + }); +}; +attach(child.stdout); +attach(child.stderr); +child.on('error', (error) => write(error.stack || error.message || String(error))); +child.on('close', (code) => { + closed = true; + fs.closeSync(fd); + process.exit(code == null ? 1 : code); +}); +`; +async function ensureDevServer(command, port, startupTimeout, logPath, onStarted) { + if (await isPortOpen(port)) { + throw new Error( + `Port ${port} is already in use by a process ProofShot did not start. +Choose another port or stop that process explicitly, then retry.` + ); + } + const logFd = fs5.openSync(logPath, "a"); + fs5.closeSync(logFd); + const proc = spawn2(process.execPath, [ + "-e", + SERVER_RUNNER_SOURCE, + command, + process.cwd(), + logPath, + getShellExecutable() + ], { + stdio: "ignore", + detached: true + }); + proc.unref(); + let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null; + for (let attempt = 0; !processIdentity && attempt < 5; attempt++) { + await new Promise((resolve13) => setTimeout(resolve13, 10)); + processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null; + } + if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) { + try { + if (proc.pid) terminateProcessTree(proc.pid); + } catch { + } + throw new Error("ProofShot could not record an exact identity for the dev server process."); + } + const result = { alreadyRunning: false, port, process: processIdentity }; + try { + onStarted?.(result); + } catch (error) { + await terminateOwnedProcessTree(processIdentity); + throw error; + } + try { + await waitForPort(port, startupTimeout); + } catch (error) { + await terminateOwnedProcessTree(processIdentity); + throw new Error( + `Failed to start dev server with "${command}" on port ${port}. +Make sure the command is correct and the port is available. +Original error: ${error instanceof Error ? error.message : error}` + ); + } + await new Promise((resolve13) => setTimeout(resolve13, 1e3)); + return result; +} + +// src/browser/session.ts +function buildOpenBrowserCommand(url, headless = true, browserConfig) { + const flags = []; + if (!headless) flags.push("--headed"); + if (browserConfig?.ignoreHttpsErrors) flags.push("--ignore-https-errors"); + if (browserConfig?.executablePath) flags.push(`--executable-path "${browserConfig.executablePath.replace(/"/g, '\\"')}"`); + const suffix = flags.length > 0 ? ` ${flags.join(" ")}` : ""; + return `open ${quoteShellArgument(url)}${suffix}`; +} +function openBrowser(url, viewport, headless = true, sessionName, browserConfig) { + try { + ab(buildOpenBrowserCommand(url, headless, browserConfig), { + timeoutMs: 6e4, + session: sessionName + }); + } catch (error) { + const currentUrl = getPageUrl(sessionName); + if (!isNavigationTimeout(error) || !urlsMatch(currentUrl, url)) { + throw error; + } + console.warn( + "Browser reached the target URL before its load event timed out; continuing with the active page." + ); + } + ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName }); +} +function isNavigationTimeout(error) { + return error instanceof ProofShotError && error.message.toLowerCase().includes("operation timed out"); +} +function urlsMatch(actual, expected) { + try { + return new URL(actual).href === new URL(expected).href; + } catch { + return actual === expected; + } +} +function closeBrowser(sessionName) { + ab("close", { session: sessionName }); +} +function getConsoleErrors(sessionName) { + return ab("errors", { session: sessionName }); +} +function getConsoleOutput(sessionName) { + return ab("console", { session: sessionName }); +} +function getConsoleOutputJson(sessionName) { + const raw = ab("console --json", { session: sessionName }); + const parsed = JSON.parse(raw); + const messages = parsed?.data?.messages ?? parsed; + if (!Array.isArray(messages)) { + throw new Error("agent-browser returned malformed console JSON."); + } + return messages; +} +function getPageUrl(sessionName) { + try { + return ab("get url", { session: sessionName }); + } catch { + return ""; + } +} + +// src/browser/capture.ts +function startRecording(outputPath, sessionName) { + ab(`record start ${outputPath}`, { timeoutMs: 1e4, session: sessionName }); +} +function stopRecording(sessionName) { + try { + ab("record stop", { timeoutMs: 15e3, session: sessionName }); + } catch { + } +} +function diffScreenshots(baseline, current, outputPath, sessionName) { + try { + const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, { + timeoutMs: 15e3, + session: sessionName + }); + const match = result.match(/([\d.]+)%/); + return match ? parseFloat(match[1]) : null; + } catch { + return null; + } +} + +// src/browser/discovery.ts +import * as fs6 from "fs"; +import * as os2 from "os"; +import * as path4 from "path"; +function isExecutable(filePath) { + try { + const stat = fs6.statSync(filePath); + if (!stat.isFile()) return false; + fs6.accessSync(filePath, fs6.constants.R_OK | fs6.constants.X_OK); + return true; + } catch { + return false; + } +} +function sortedDirectories(root) { + try { + return fs6.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((a, b) => b.localeCompare(a, void 0, { numeric: true })); + } catch { + return []; + } +} +function cachedBrowserCandidates(home) { + const candidates = []; + const agentBrowserRoot = path4.join(home, ".agent-browser", "browsers"); + for (const directory of sortedDirectories(agentBrowserRoot)) { + candidates.push( + path4.join(agentBrowserRoot, directory, "chrome"), + path4.join(agentBrowserRoot, directory, "chrome-linux64", "chrome"), + path4.join(agentBrowserRoot, directory, "chrome-linux", "chrome") + ); + } + const playwrightRoot = path4.join(home, ".cache", "ms-playwright"); + for (const directory of sortedDirectories(playwrightRoot)) { + if (!directory.startsWith("chromium")) continue; + candidates.push( + path4.join(playwrightRoot, directory, "chrome-linux64", "chrome"), + path4.join(playwrightRoot, directory, "chrome-linux", "chrome"), + path4.join(playwrightRoot, directory, "chrome-headless-shell-linux64", "chrome-headless-shell") + ); + } + const puppeteerRoot = path4.join(home, ".cache", "puppeteer", "chrome"); + for (const directory of sortedDirectories(puppeteerRoot)) { + candidates.push( + path4.join(puppeteerRoot, directory, "chrome-linux64", "chrome"), + path4.join(puppeteerRoot, directory, "chrome-linux", "chrome") + ); + } + return candidates; +} +function accountHomeDirectory() { + try { + return os2.userInfo().homedir; + } catch { + return void 0; + } +} +function discoverBrowserExecutable(options = {}) { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const executableLookup = options.findExecutable ?? findExecutablePath; + const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH; + if (explicit) { + const resolved = path4.resolve(explicit); + if (!isExecutable(resolved)) { + throw new Error( + `Browser executable is not runnable: ${resolved} +Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}` + ); + } + return resolved; + } + const homes = /* @__PURE__ */ new Set(); + if (env.HOME) homes.add(path4.resolve(env.HOME)); + const accountHome = options.accountHome ?? accountHomeDirectory(); + if (accountHome) homes.add(path4.resolve(accountHome)); + if (platform === "linux") { + const cached = [...homes].flatMap(cachedBrowserCandidates).find(isExecutable); + if (cached) return cached; + } + const commandNames = platform === "darwin" ? ["google-chrome", "chromium"] : platform === "win32" ? ["chrome", "msedge"] : ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"]; + for (const command of commandNames) { + const executable = executableLookup(command, platform); + if (executable && isExecutable(executable)) return executable; + } + const candidates = []; + if (platform === "darwin") { + candidates.push( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium" + ); + } else if (platform === "win32") { + for (const root of [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA]) { + if (!root) continue; + candidates.push( + path4.join(root, "Google", "Chrome", "Application", "chrome.exe"), + path4.join(root, "Microsoft", "Edge", "Application", "msedge.exe") + ); + } + } else { + candidates.push("/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"); + } + return candidates.find(isExecutable) ?? null; +} +function browserSetupError() { + return new Error( + "No runnable Chrome/Chromium executable was found for this environment.\nRun `agent-browser install` in this environment, then retry `proofshot start`." + ); +} + +// src/browser/runtime.ts +import * as fs7 from "fs"; +import * as os3 from "os"; +import * as path5 from "path"; +var UNIX_SOCKET_PATH_MAX_BYTES = 103; +function assertOwnedDirectory(directory) { + const stat = fs7.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Agent-browser socket path is not a real directory: ${directory}`); + } + const uid = process.getuid?.(); + if (uid !== void 0 && stat.uid !== uid) { + throw new Error( + `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}` + ); + } + fs7.accessSync(directory, fs7.constants.R_OK | fs7.constants.W_OK | fs7.constants.X_OK); + if (uid !== void 0) fs7.chmodSync(directory, 448); +} +function prepareAgentBrowserSocketDir(sessionName, env = process.env, accountHome = os3.userInfo().homedir) { + const uid = process.getuid?.() ?? process.pid; + const explicit = env.AGENT_BROWSER_SOCKET_DIR; + const systemRuntime = `/run/user/${uid}`; + let runtimeRoot = accountHome; + if (!explicit && env.XDG_RUNTIME_DIR && path5.isAbsolute(env.XDG_RUNTIME_DIR)) { + runtimeRoot = env.XDG_RUNTIME_DIR; + } else if (!explicit && fs7.existsSync(systemRuntime)) { + try { + assertOwnedDirectory(systemRuntime); + runtimeRoot = systemRuntime; + } catch { + } + } + const directory = explicit ? path5.resolve(explicit) : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR ? path5.join(runtimeRoot, "proofshot", "agent-browser") : path5.join("/tmp", `proofshot-${uid}`, "agent-browser"); + fs7.mkdirSync(directory, { recursive: true, mode: 448 }); + assertOwnedDirectory(directory); + const socketPath = path5.join(directory, `${sessionName}.sock`); + const byteLength = Buffer.byteLength(socketPath); + if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) { + throw new Error( + `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath} +Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.` + ); + } + return directory; +} +function captureAgentBrowserProcessIdentity(socketDir, sessionName) { + if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null; + try { + assertOwnedDirectory(socketDir); + const pidPath = path5.join(socketDir, `${sessionName}.pid`); + const pid = Number(fs7.readFileSync(pidPath, "utf-8").trim()); + const identity = captureProcessIdentity(pid); + if (!identity || !isDetachedProcessIdentity(identity)) return null; + return identity; + } catch { + return null; + } +} +async function waitForAgentBrowserProcessIdentity(socketDir, sessionName, timeoutMs = 2e3, pollIntervalMs = 25) { + const deadline = Date.now() + timeoutMs; + do { + const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName); + if (identity) { + return identity; + } + await new Promise((resolve13) => setTimeout(resolve13, pollIntervalMs)); + } while (Date.now() < deadline); + return captureAgentBrowserProcessIdentity(socketDir, sessionName); +} +function clearAgentBrowserSessionFiles(socketDir, sessionName) { + if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) { + throw new Error(`Unsafe agent-browser session name: ${sessionName}`); + } + assertOwnedDirectory(socketDir); + const uid = process.getuid?.(); + for (const suffix of [".pid", ".sock"]) { + const filePath = path5.join(socketDir, `${sessionName}${suffix}`); + try { + const stat = fs7.lstatSync(filePath); + if (uid !== void 0 && stat.uid !== uid) { + throw new Error(`Agent-browser sidecar is owned by uid ${stat.uid}: ${filePath}`); + } + fs7.unlinkSync(filePath); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } +} + +// src/artifacts/bundle.ts +import * as fs8 from "fs"; +function ensureOutputDir(outputDir) { + fs8.mkdirSync(outputDir, { recursive: true }); +} +function generateTimestamp() { + return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19); +} +function generateSessionDirName(timestamp, description) { + if (!description) return timestamp; + const slug = description.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40).replace(/-$/, ""); + return slug ? `${timestamp}_${slug}` : timestamp; +} + +// src/session/state.ts +import * as fs9 from "fs"; +import * as path6 from "path"; +import { createHash, randomUUID } from "crypto"; +var SESSION_FILENAME = ".session.json"; +function resolveSessionControlDir(configuredOutput, cwd = process.cwd()) { + return path6.resolve(cwd, configuredOutput); +} +function saveSession(state, controlDir = state.outputDir) { + fs9.mkdirSync(controlDir, { recursive: true }); + const sessionPath = path6.join(controlDir, SESSION_FILENAME); + const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`; + fs9.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + "\n", { + mode: 384 + }); + fs9.renameSync(temporaryPath, sessionPath); +} +function loadSession(controlDir) { + const sessionPath = path6.join(controlDir, SESSION_FILENAME); + if (!fs9.existsSync(sessionPath)) return null; + try { + return JSON.parse(fs9.readFileSync(sessionPath, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `ProofShot session state is corrupt: ${sessionPath} +${message} +Use "proofshot session list" to inspect durable recovery records.` + ); + } +} +function hasActiveSession(controlDir) { + return fs9.existsSync(path6.join(controlDir, SESSION_FILENAME)); +} +function clearSession(controlDir) { + const sessionPath = path6.join(controlDir, SESSION_FILENAME); + if (fs9.existsSync(sessionPath)) { + fs9.unlinkSync(sessionPath); + } +} +function generateAgentBrowserSessionName(seed, nonce = randomUUID()) { + const normalized = seed.toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 8).replace(/-+$/g, ""); + const digest = createHash("sha256").update(`${seed}\0${nonce}`).digest("hex").slice(0, 12); + return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`; +} + +// src/environment/runtime.ts +import * as fs13 from "fs"; +import * as net2 from "net"; +import * as path9 from "path"; + +// src/environment/workers.ts +import * as fs11 from "fs"; +import * as path7 from "path"; +import { spawn as spawn3 } from "child_process"; + +// src/environment/evidence.ts +import * as fs10 from "fs"; +var ANSI_PATTERN = ( + // eslint-disable-next-line no-control-regex + /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g +); +var CONTROL_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/g; +function normalizeLogText(text, stripAnsi = true) { + const normalized = text.replace(/\r\n?/g, "\n").replace(CONTROL_PATTERN, ""); + return stripAnsi ? normalized.replace(ANSI_PATTERN, "") : normalized; +} +function appendEvidenceEvent(filePath, event) { + fs10.appendFileSync(filePath, JSON.stringify(event) + "\n"); +} +function loadEvidenceEvents(filePath) { + if (!fs10.existsSync(filePath)) { + return []; + } + return fs10.readFileSync(filePath, "utf-8").split("\n").filter(Boolean).map((line, index) => { + try { + const parsed = JSON.parse(line); + return isEvidenceEvent(parsed) ? parsed : malformedEvidenceEvent(index + 1); + } catch { + return malformedEvidenceEvent(index + 1); + } + }); +} +function isEvidenceEvent(value) { + if (typeof value !== "object" || value === null) return false; + const event = value; + return event.version === 1 && (event.origin === "environment" || event.origin === "browser") && typeof event.group === "string" && typeof event.sourceId === "string" && typeof event.sourceTitle === "string" && typeof event.text === "string" && (event.relativeTimeSec === null || typeof event.relativeTimeSec === "number" && Number.isFinite(event.relativeTimeSec)); +} +function malformedEvidenceEvent(line) { + return { + version: 1, + origin: "environment", + group: "environment", + sourceId: "capture-health", + sourceTitle: "Capture health", + stream: "stderr", + segment: "live", + timestamp: null, + relativeTimeSec: null, + text: `[malformed canonical evidence row at line ${line}]`, + captureGap: true + }; +} + +// src/environment/workers.ts +var COMMON_WORKER_SOURCE = String.raw` +const fs = require('fs'); +const config = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8')); +const ansiPattern = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g; +const controlPattern = /[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/g; +let bytesWritten = 0; +let truncated = false; +function normalize(text) { + const normalized = text.replace(/\r\n?/g, '\n').replace(controlPattern, ''); + return config.stripAnsi ? normalized.replace(ansiPattern, '') : normalized; +} +function writeEvent(text, stream, segment = 'live', extra = {}) { + const normalized = normalize(text); + if (normalized.length === 0) return; + const now = Date.now(); + const event = { + version: 1, + origin: 'environment', + group: config.source.group, + sourceId: config.source.id, + sourceTitle: config.source.title, + stream, + segment, + timestamp: new Date(now).toISOString(), + relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000), + text: normalized, + ...extra, + }; + const serialized = JSON.stringify(event) + '\n'; + const logLine = normalized + '\n'; + const bytes = Buffer.byteLength(serialized) + Buffer.byteLength(logLine); + const truncationEvent = { + ...event, + text: '[ProofShot capture truncated at configured byte limit]', + truncated: true, + }; + const truncationSerialized = JSON.stringify(truncationEvent) + '\n'; + const truncationLogLine = truncationEvent.text + '\n'; + const truncationBytes = + Buffer.byteLength(truncationSerialized) + + Buffer.byteLength(truncationLogLine); + if (bytesWritten + bytes + truncationBytes > config.maxBytes) { + if (!truncated) { + truncated = true; + if (bytesWritten + truncationBytes <= config.maxBytes) { + bytesWritten += truncationBytes; + fs.appendFileSync(config.evidencePath, truncationSerialized); + fs.appendFileSync(config.logPath, truncationLogLine); + } + } + return; + } + bytesWritten += bytes; + fs.appendFileSync(config.evidencePath, serialized); + fs.appendFileSync(config.logPath, logLine); +} +function attachLines(stream, streamName) { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString().replace(/\r\n?/g, '\n'); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) writeEvent(line, streamName); + }); + stream.on('end', () => { + if (buffer.length > 0) writeEvent(buffer, streamName); + buffer = ''; + }); +} +if (config.pidFile) { + fs.writeFileSync(config.pidFile, String(process.pid), { mode: 0o600 }); +} +function removePidFile() { + if (config.pidFile) { + try { fs.unlinkSync(config.pidFile); } catch {} + } +} +`; +var TMUX_PIPE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE} +attachLines(process.stdin, 'pty'); +process.stdin.on('end', () => { + removePidFile(); + process.exit(0); +}); +process.on('SIGTERM', () => { + removePidFile(); + process.exit(0); +}); +`; +var PROCESS_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE} +const { spawn } = require('child_process'); +let stopping = false; +const child = spawn(config.command, { + cwd: config.cwd, + env: { ...process.env, ...config.env }, + shell: config.shellPath, + stdio: ['ignore', 'pipe', 'pipe'], +}); +attachLines(child.stdout, 'stdout'); +attachLines(child.stderr, 'stderr'); +child.on('error', (error) => writeEvent(error.stack || error.message || String(error), 'stderr')); +child.on('close', (code) => { + writeEvent( + stopping + ? '[process stopped by ProofShot]' + : '[process exited with code ' + (code == null ? 'unknown' : code) + ']', + 'stderr', + ); + removePidFile(); + process.exit(stopping ? 0 : (code == null ? 1 : code)); +}); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + stopping = true; + try { child.kill(signal); } catch {} + }); +} +`; +var FILE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE} +let offset = config.offset || 0; +let fileDevice = config.fileDevice; +let fileInode = config.fileInode; +let buffered = ''; +function readAvailable() { + let fd; + try { + fd = fs.openSync(config.filePath, 'r'); + } catch { + return; + } + const stat = fs.fstatSync(fd); + if ( + (fileDevice !== undefined && stat.dev !== fileDevice) || + (fileInode !== undefined && stat.ino !== fileInode) || + stat.size < offset + ) { + offset = 0; + writeEvent('[file rotated or truncated]', 'file', 'live', { captureGap: true }); + } + fileDevice = stat.dev; + fileInode = stat.ino; + if (stat.size === offset) { + fs.closeSync(fd); + return; + } + const length = Math.min(stat.size - offset, 64 * 1024); + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, offset); + fs.closeSync(fd); + offset += bytesRead; + buffered += buffer.subarray(0, bytesRead).toString().replace(/\\r\\n?/g, '\\n'); + const lines = buffered.split('\\n'); + buffered = lines.pop() || ''; + for (const line of lines) writeEvent(line, 'file'); +} +const timer = setInterval(readAvailable, 100); +function stop() { + clearInterval(timer); + if (buffered.length > 0) writeEvent(buffered, 'file'); + removePidFile(); + process.exit(0); +} +process.on('SIGINT', stop); +process.on('SIGTERM', stop); +`; +function buildTmuxPipeCommand(config) { + const encodedConfig = encodeConfig(config); + return [ + shellQuote(process.execPath), + "-e", + shellQuote(TMUX_PIPE_RUNNER_SOURCE), + shellQuote(encodedConfig) + ].join(" "); +} +async function waitForCaptureProcess(sourceId, pidFile, timeoutMs = 2e3) { + const deadline = Date.now() + timeoutMs; + do { + const identity = readPidIdentity(pidFile); + if (identity) { + return { sourceId, process: identity, pidFile }; + } + await new Promise((resolve13) => setTimeout(resolve13, 25)); + } while (Date.now() < deadline); + throw new Error(`ProofShot could not capture the log helper identity for ${sourceId}.`); +} +async function startProcessCapture(definition, source, evidencePath, startTimeMs, maxBytes, stripAnsi) { + const pidFile = `${source.logPath}.pid`; + const config = { + evidencePath, + logPath: source.logPath, + pidFile, + startTimeMs, + maxBytes, + stripAnsi, + source, + command: definition.command, + cwd: definition.cwd, + env: definition.env, + shellPath: getShellExecutable() + }; + return startDetachedWorker(source.id, pidFile, PROCESS_RUNNER_SOURCE, config); +} +async function startFileCapture(filePath, source, evidencePath, startTimeMs, maxBytes, stripAnsi) { + const pidFile = `${source.logPath}.pid`; + let offset = 0; + let fileDevice; + let fileInode; + let liveMaxBytes = maxBytes; + if (fs11.existsSync(filePath)) { + const fd = fs11.openSync(filePath, "r"); + try { + const stat = fs11.fstatSync(fd); + offset = stat.size; + fileDevice = stat.dev; + fileInode = stat.ino; + const historyBudget = Math.max(1, Math.floor(maxBytes / 2)); + liveMaxBytes = Math.max(1, maxBytes - historyBudget); + const historyLength = Math.min(stat.size, historyBudget); + const history = Buffer.alloc(historyLength); + fs11.readSync(fd, history, 0, historyLength, stat.size - historyLength); + appendHistory( + history.toString("utf-8"), + source, + evidencePath, + historyBudget, + stripAnsi, + "file" + ); + } finally { + fs11.closeSync(fd); + } + } + const config = { + evidencePath, + logPath: source.logPath, + pidFile, + startTimeMs, + maxBytes: liveMaxBytes, + stripAnsi, + source, + offset, + fileDevice, + fileInode, + filePath + }; + return startDetachedWorker(source.id, pidFile, FILE_RUNNER_SOURCE, config); +} +function appendHistory(raw, source, evidencePath, maxBytes, stripAnsi, stream) { + const normalized = normalizeLogText(raw, stripAnsi); + const lines = normalized.split("\n").filter((line) => line.length > 0); + const retained = []; + let retainedBytes = 0; + let truncated = false; + for (let index = lines.length - 1; index >= 0; index -= 1) { + const event = { + version: 1, + origin: "environment", + group: source.group, + sourceId: source.id, + sourceTitle: source.title, + stream, + segment: "history", + timestamp: null, + relativeTimeSec: null, + text: lines[index] + }; + const serialized = JSON.stringify(event) + "\n"; + const logLine = `${lines[index]} +`; + const eventBytes = Buffer.byteLength(serialized) + Buffer.byteLength(logLine); + if (retainedBytes + eventBytes > maxBytes) { + truncated = true; + break; + } + retained.unshift({ event, serialized, logLine }); + retainedBytes += eventBytes; + } + if (truncated && retained.length > 0) { + while (retained.length > 0) { + retained[0].event.truncated = true; + retained[0].serialized = JSON.stringify(retained[0].event) + "\n"; + retainedBytes = retained.reduce( + (total, entry) => total + Buffer.byteLength(entry.serialized) + Buffer.byteLength(entry.logLine), + 0 + ); + if (retainedBytes <= maxBytes) break; + retained.shift(); + } + } + if (truncated && retained.length === 0) { + const event = { + version: 1, + origin: "environment", + group: source.group, + sourceId: source.id, + sourceTitle: source.title, + stream, + segment: "history", + timestamp: null, + relativeTimeSec: null, + text: "[ProofShot capture truncated at configured byte limit]", + truncated: true + }; + const serialized = JSON.stringify(event) + "\n"; + const logLine = `${event.text} +`; + if (Buffer.byteLength(serialized) + Buffer.byteLength(logLine) <= maxBytes) { + retained.push({ event, serialized, logLine }); + } + } + for (const entry of retained) { + fs11.appendFileSync(evidencePath, entry.serialized); + fs11.appendFileSync(source.logPath, entry.logLine); + } +} +function createWorkerConfig(params) { + return params; +} +async function startDetachedWorker(sourceId, pidFile, workerSource, config) { + fs11.mkdirSync(path7.dirname(pidFile), { recursive: true }); + const errorFd = fs11.openSync(`${pidFile}.stderr`, "a", 384); + const worker = spawn3(process.execPath, ["-e", workerSource, encodeConfig(config)], { + detached: true, + stdio: ["ignore", "ignore", errorFd] + }); + fs11.closeSync(errorFd); + worker.unref(); + let identity = worker.pid ? captureProcessIdentity(worker.pid) : null; + for (let attempt = 0; !identity && attempt < 20; attempt += 1) { + await new Promise((resolve13) => setTimeout(resolve13, 10)); + identity = worker.pid ? captureProcessIdentity(worker.pid) : null; + } + if (!identity) { + try { + if (worker.pid) { + process.kill(-worker.pid, "SIGKILL"); + } + } catch { + } + throw new Error(`ProofShot could not capture the runner identity for ${sourceId}.`); + } + return { sourceId, process: identity, pidFile }; +} +function readPidIdentity(pidFile) { + try { + const pid = Number(fs11.readFileSync(pidFile, "utf-8").trim()); + return captureProcessIdentity(pid); + } catch { + return null; + } +} +function encodeConfig(config) { + return Buffer.from(JSON.stringify(config)).toString("base64"); +} +function shellQuote(value) { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +// src/environment/tmux.ts +import * as fs12 from "fs"; +import * as path8 from "path"; +import { execFileSync as execFileSync2 } from "child_process"; +async function startTmuxEnvironment(config, logs, sessionDir, proofShotSessionName, startTimeMs, onState) { + assertTmuxAvailable(); + const evidencePath = path8.join(sessionDir, "environment.ndjson"); + const logsDir = path8.join(sessionDir, "logs"); + const captureDir = path8.join(sessionDir, ".capture"); + fs12.mkdirSync(logsDir, { recursive: true }); + fs12.mkdirSync(captureDir, { recursive: true, mode: 448 }); + fs12.writeFileSync(evidencePath, "", { flag: "a", mode: 384 }); + let state = null; + let pendingLauncher = null; + let connection; + try { + connection = config.launch.kind === "panes" ? startOwnedTmux(config, proofShotSessionName, (startedConnection) => { + const startedState = createTmuxState( + config, + startedConnection, + evidencePath + ); + state = startedState; + onState(startedState); + }) : await startExternalTmux(config, (launcher) => { + pendingLauncher = { + kind: "launcher", + evidencePath, + sources: [], + launcher: { + sourceId: "external-launcher", + process: launcher, + pidFile: "" + } + }; + onState(pendingLauncher); + }); + if (!state) { + const connectedState = createTmuxState( + config, + connection, + evidencePath + ); + state = connectedState; + onState(connectedState); + } + } catch (error) { + if (state) { + await stopTmuxEnvironment(state).catch(() => { + }); + } else if (pendingLauncher) { + await terminateOwnedProcessTree(pendingLauncher.launcher.process).catch(() => { + }); + } + throw error; + } + try { + if (!state) { + throw new Error("tmux environment ownership state was not initialized."); + } + let activeState = state; + const tmuxSources = resolveTmuxSources(config, logs, connection); + const panes = tmuxSources.map( + ({ config: sourceConfig, mapping }) => resolvePane( + connection.socketPath, + connection.sessionName, + sourceConfig, + mapping, + logsDir + ) + ); + const resolvedPaneIds = /* @__PURE__ */ new Set(); + for (const { pane } of panes) { + if (resolvedPaneIds.has(pane.paneId)) { + throw new Error(`Multiple log sources resolved to tmux pane ${pane.paneId}.`); + } + resolvedPaneIds.add(pane.paneId); + } + disambiguateTitles(panes); + activeState = { + ...activeState, + panes: panes.map(({ pane }) => pane), + sources: panes.map(({ source }) => source) + }; + state = activeState; + onState(activeState); + for (const pane of panes) { + const pipeStatus = tmuxExec(connection.socketPath, [ + "display-message", + "-p", + "-t", + pane.pane.paneId, + "#{pane_pipe}" + ]); + if (pipeStatus === "1") { + throw new Error( + `tmux pane ${pane.pane.paneId} already has a pipe-pane consumer.` + ); + } + const pidFile = path8.join(captureDir, `${pane.source.id}.pid`); + const sourceBudget = logs.maxBytesPerSource || 5 * 1024 * 1024; + const historyBudget = Math.max(1, Math.floor(sourceBudget / 2)); + const workerConfig = createWorkerConfig({ + evidencePath, + logPath: pane.source.logPath, + pidFile, + startTimeMs, + maxBytes: Math.max(1, sourceBudget - historyBudget), + stripAnsi: logs.stripAnsi !== false, + source: pane.source + }); + tmuxExec(connection.socketPath, [ + "pipe-pane", + "-t", + pane.pane.paneId, + buildTmuxPipeCommand(workerConfig) + ]); + pane.pane.captureAttached = true; + activeState = { + ...activeState, + panes: activeState.panes.map( + (ownedPane) => ownedPane.paneId === pane.pane.paneId ? { ...ownedPane, captureAttached: true } : ownedPane + ) + }; + state = activeState; + onState(activeState); + const history = tmuxExec(connection.socketPath, [ + "capture-pane", + "-p", + "-S", + "-", + "-t", + pane.pane.paneId + ]); + appendHistory( + history, + pane.source, + evidencePath, + historyBudget, + logs.stripAnsi !== false, + "pty" + ); + appendEvidenceEvent(evidencePath, { + version: 1, + origin: "environment", + group: pane.source.group, + sourceId: pane.source.id, + sourceTitle: pane.source.title, + stream: "pty", + segment: "history", + timestamp: null, + relativeTimeSec: null, + text: "[tmux history/live capture boundary]" + }); + const capture = await waitForCaptureProcess(pane.source.id, pidFile); + activeState = { + ...activeState, + captures: [...activeState.captures, capture] + }; + state = activeState; + onState(activeState); + } + return activeState; + } catch (error) { + if (state) { + await stopTmuxEnvironment(state).catch(() => { + }); + } + throw error; + } +} +async function stopTmuxEnvironment(state) { + const errors = []; + let socketMatches = false; + let socketIdentityError = null; + if (fs12.existsSync(state.socket.path)) { + try { + assertSocketIdentity(state); + socketMatches = true; + } catch (error) { + socketIdentityError = toError(error); + } + } + const currentServer = captureProcessIdentity(state.serverProcess.pid); + const serverIdentityReused = Boolean( + currentServer && !processIdentitiesMatch(currentServer, state.serverProcess) + ); + if (serverIdentityReused) { + errors.push(new Error("tmux server identity changed; refusing widened cleanup.")); + } + const serverMatches = processIdentityMatches(state.serverProcess); + if (socketIdentityError && (serverMatches || state.captures.some((capture) => processIdentityMatches(capture.process)))) { + errors.push(socketIdentityError); + } + if (serverMatches && socketMatches) { + try { + if (state.stopCommand) { + await runCommand(state.stopCommand, state.stopCwd || process.cwd()); + } else if (state.ownsSession && tmuxHasSession(state)) { + tmuxExec(state.socket.path, ["kill-session", "-t", state.sessionName]); + } else { + for (const pane of state.panes.filter( + (candidate) => candidate.captureAttached + )) { + try { + tmuxExec(state.socket.path, ["pipe-pane", "-t", pane.paneId]); + } catch { + } + } + } + } catch (error) { + errors.push(toError(error)); + } + } + for (const capture of state.captures) { + try { + await terminateOwnedProcess(capture.process, { graceMs: 500 }); + if (processIdentityMatches(capture.process)) { + throw new Error(`Log helper for ${capture.sourceId} did not stop.`); + } + } catch (error) { + errors.push(toError(error)); + } + } + if (state.ownsServer && !serverIdentityReused) { + if (processIdentityMatches(state.serverProcess) && socketMatches) { + try { + tmuxExec(state.socket.path, ["kill-server"]); + } catch { + } + } + if (processIdentityMatches(state.serverProcess)) { + try { + await terminateOwnedProcessTree(state.serverProcess, { graceMs: 500 }); + } catch (error) { + errors.push(toError(error)); + } + } + if (processIdentityMatches(state.serverProcess)) { + errors.push(new Error("Owned tmux server did not stop.")); + } + } + if (state.ownsServer && socketMatches && !processIdentityMatches(state.serverProcess) && fs12.existsSync(state.socket.path)) { + try { + const currentSocket = captureSocketIdentity(state.socket.path); + if (currentSocket.inode !== state.socket.inode || currentSocket.uid !== state.socket.uid) { + throw new Error("tmux socket changed before final cleanup."); + } + fs12.unlinkSync(state.socket.path); + } catch (error) { + errors.push(toError(error)); + } + } + if (state.ownsSession && processIdentityMatches(state.serverProcess) && socketMatches && tmuxHasSession(state)) { + errors.push(new Error(`Owned tmux session ${state.sessionName} did not stop.`)); + } + if (errors.length > 0) { + throw new AggregateError(errors, "One or more tmux cleanup steps failed."); + } +} +function toError(error) { + return error instanceof Error ? error : new Error(String(error)); +} +function startOwnedTmux(config, proofShotSessionName, onStarted) { + if (config.launch.kind !== "panes" || config.launch.panes.length === 0) { + throw new Error("tmux pane launch requires at least one pane."); + } + const paneIds = /* @__PURE__ */ new Set(); + for (const pane of config.launch.panes) { + validateId(pane.id); + if (paneIds.has(pane.id)) { + throw new Error(`Duplicate tmux pane id: ${pane.id}`); + } + paneIds.add(pane.id); + buildPaneCommand(pane); + } + const uid = process.getuid?.() ?? process.pid; + const socketDir = path8.join("/tmp", `proofshot-${uid}`, "tmux"); + fs12.mkdirSync(socketDir, { recursive: true, mode: 448 }); + const socketPath = path8.join(socketDir, `${proofShotSessionName}.sock`); + if (fs12.existsSync(socketPath)) { + throw new Error(`Refusing to reuse an existing tmux socket: ${socketPath}`); + } + const sessionName = config.launch.sessionName || proofShotSessionName; + const [firstPane, ...remainingPanes] = config.launch.panes; + const first = parsePaneOutput( + tmuxExec(socketPath, [ + "new-session", + "-d", + "-P", + "-F", + "#{pane_id} #{pane_index} #{pane_pid}", + "-s", + sessionName, + "-n", + "environment", + "-c", + firstPane.cwd || config.cwd || process.cwd(), + buildPaneCommand(firstPane) + ]) + ); + const mappings = [ + { + key: firstPane.id, + paneId: first.paneId, + title: firstPane.title, + group: firstPane.group + } + ]; + onStarted({ + socketPath, + sessionName, + paneMappings: [...mappings], + ownsServer: true, + ownsSession: true + }); + configurePane(socketPath, first.paneId, firstPane.id, firstPane.title); + for (const pane of remainingPanes) { + const created = parsePaneOutput( + tmuxExec(socketPath, [ + "split-window", + "-d", + "-P", + "-F", + "#{pane_id} #{pane_index} #{pane_pid}", + "-t", + `${sessionName}:environment`, + "-c", + pane.cwd || config.cwd || process.cwd(), + buildPaneCommand(pane) + ]) + ); + configurePane(socketPath, created.paneId, pane.id, pane.title); + mappings.push({ + key: pane.id, + paneId: created.paneId, + title: pane.title, + group: pane.group + }); + } + tmuxExec(socketPath, ["select-layout", "-t", `${sessionName}:environment`, "tiled"]); + return { + socketPath, + sessionName, + paneMappings: mappings, + ownsServer: true, + ownsSession: true + }; +} +function createTmuxState(config, connection, evidencePath) { + const serverPid = Number( + tmuxExec(connection.socketPath, ["display-message", "-p", "#{pid}"]) + ); + const serverProcess = captureProcessIdentity(serverPid); + if (!serverProcess) { + throw new Error("ProofShot could not capture the exact tmux server identity."); + } + return { + kind: "tmux", + evidencePath, + sources: [], + socket: captureSocketIdentity(connection.socketPath), + serverProcess, + sessionName: connection.sessionName, + ownsServer: connection.ownsServer, + ownsSession: connection.ownsSession, + panes: [], + captures: [], + stopCommand: config.launch.kind === "external-command" ? config.launch.stopCommand : void 0, + stopCwd: config.cwd + }; +} +async function startExternalTmux(config, onLauncherStarted) { + if (config.launch.kind !== "external-command" || !config.connection) { + throw new Error("External tmux launch requires a connection contract."); + } + const hintedSocket = config.connection.socket; + const socketExistedBefore = hintedSocket ? fs12.existsSync(hintedSocket) : true; + const attachOnly = config.connection.ownership === "attach"; + if (!attachOnly && (!hintedSocket && !config.launch.stopCommand || socketExistedBefore && !config.launch.stopCommand)) { + throw new Error( + "External tmux launch against an existing or undisclosed socket requires stopCommand." + ); + } + const output = await runCommand( + config.launch.command, + config.cwd || process.cwd(), + onLauncherStarted, + config.launch.timeoutMs + ); + const parsed = config.connection.format === "json" ? parseJsonConnection(output) : parseAttachCommand(output, config.cwd || process.cwd()); + const ownsCreatedSocket = hintedSocket !== void 0 && path8.resolve(hintedSocket) === path8.resolve(parsed.socketPath) && !socketExistedBefore; + return { + ...parsed, + ownsServer: ownsCreatedSocket, + ownsSession: ownsCreatedSocket + }; +} +function resolveTmuxSources(config, logs, connection) { + const configured = (logs.sources || []).filter( + (source) => source.kind === "tmux-pane" + ); + if (configured.length > 0) { + return configured.map((source) => { + const connectionKey = "connectionKey" in source.match ? source.match.connectionKey : void 0; + return { + config: source, + mapping: connectionKey ? connection.paneMappings.find( + (mapping) => mapping.key === connectionKey + ) : void 0 + }; + }); + } + if (config.launch.kind !== "panes") { + return []; + } + return connection.paneMappings.map((mapping) => ({ + config: { + id: mapping.key, + title: mapping.title, + group: mapping.group, + kind: "tmux-pane", + match: { connectionKey: mapping.key } + }, + mapping + })); +} +function resolvePane(socketPath, sessionName, sourceConfig, mapping, logsDir) { + let target; + if ("connectionKey" in sourceConfig.match) { + if (!mapping) { + throw new Error( + `No tmux pane mapping matched connection key "${sourceConfig.match.connectionKey}".` + ); + } + target = mapping.paneId; + } else if ("tag" in sourceConfig.match) { + const tag = sourceConfig.match.tag; + const matches = tmuxExec(socketPath, [ + "list-panes", + "-t", + sessionName, + "-F", + "#{pane_id} #{@proofshot-source}" + ]).split("\n").filter((line) => line.split(" ")[1] === tag); + if (matches.length !== 1) { + throw new Error( + `Expected one tmux pane tagged "${tag}", found ${matches.length}.` + ); + } + target = matches[0].split(" ")[0]; + } else { + target = sourceConfig.match.target; + } + const fields = tmuxExec(socketPath, [ + "display-message", + "-p", + "-t", + target, + "#{pane_id} #{pane_index} #{pane_pid} #{pane_title} #{session_name} #{session_name}:#{window_name}.#{pane_index}" + ]).split(" "); + if (fields.length !== 6) { + throw new Error(`Could not resolve tmux pane metadata for ${target}.`); + } + if (fields[4] !== sessionName) { + throw new Error( + `tmux pane ${fields[0]} belongs to session "${fields[4]}", expected "${sessionName}".` + ); + } + const paneIndex = Number(fields[1]); + const tmuxTitle = fields[3].trim(); + const title = mapping?.title || (tmuxTitle.length > 0 ? tmuxTitle : `Pane ${paneIndex}`); + const group = sourceConfig.group || mapping?.group || "environment"; + const source = { + id: sourceConfig.id, + title, + group, + kind: "tmux-pane", + stream: "pty", + logPath: path8.join(logsDir, `${sourceConfig.id}.log`), + include: sourceConfig.include, + exclude: sourceConfig.exclude + }; + return { + source, + pane: { + paneId: fields[0], + paneIndex, + panePid: Number(fields[2]), + sourceId: source.id, + title, + group, + target: fields[5], + captureAttached: false + } + }; +} +function disambiguateTitles(panes) { + const counts = /* @__PURE__ */ new Map(); + for (const pane of panes) { + counts.set(pane.source.title, (counts.get(pane.source.title) || 0) + 1); + } + for (const pane of panes) { + if ((counts.get(pane.source.title) || 0) > 1) { + const title = `${pane.source.title} (Pane ${pane.pane.paneIndex})`; + pane.source.title = title; + pane.pane.title = title; + } + } +} +function configurePane(socketPath, paneId, sourceId, title) { + validateId(sourceId); + tmuxExec(socketPath, [ + "set-option", + "-p", + "-t", + paneId, + "@proofshot-source", + sourceId + ]); + if (title) { + tmuxExec(socketPath, ["select-pane", "-t", paneId, "-T", title]); + } +} +function buildPaneCommand(pane) { + const assignments = Object.entries(pane.env || {}).map(([key, value]) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`Invalid environment variable name: ${key}`); + } + return `${key}=${shellQuote2(value)}`; + }); + return assignments.length > 0 ? `env ${assignments.join(" ")} ${pane.command}` : pane.command; +} +function parsePaneOutput(output) { + const [paneId, paneIndex, panePid] = output.split(" "); + if (!paneId || !Number.isInteger(Number(paneIndex)) || !Number.isInteger(Number(panePid))) { + throw new Error(`Unexpected tmux pane output: ${output}`); + } + return { paneId, paneIndex: Number(paneIndex), panePid: Number(panePid) }; +} +function parseJsonConnection(output) { + const parsed = JSON.parse(output); + if (!parsed.tmux || !path8.isAbsolute(parsed.tmux.socket) || typeof parsed.tmux.session !== "string" || parsed.tmux.session.length === 0 || parsed.tmux.panes !== void 0 && !Array.isArray(parsed.tmux.panes)) { + throw new Error("External launcher returned invalid tmux JSON."); + } + const paneMappings = []; + const keys = /* @__PURE__ */ new Set(); + const paneIds = /* @__PURE__ */ new Set(); + for (const [index, pane] of (parsed.tmux.panes || []).entries()) { + if (typeof pane !== "object" || pane === null || typeof pane.key !== "string" || !/^[A-Za-z0-9_-]+$/.test(pane.key) || typeof pane.paneId !== "string" || !/^%\d+$/.test(pane.paneId) || pane.title !== void 0 && typeof pane.title !== "string" || pane.group !== void 0 && typeof pane.group !== "string") { + throw new Error(`External launcher returned invalid pane mapping at index ${index}.`); + } + if (keys.has(pane.key) || paneIds.has(pane.paneId)) { + throw new Error("External launcher returned duplicate pane mappings."); + } + keys.add(pane.key); + paneIds.add(pane.paneId); + paneMappings.push(pane); + } + return { + socketPath: parsed.tmux.socket, + sessionName: parsed.tmux.session, + paneMappings, + ownsServer: false, + ownsSession: false + }; +} +function parseAttachCommand(output, cwd) { + const tokens = tokenizeShellCommand(output); + const tmuxIndex = tokens.findIndex((token) => path8.basename(token) === "tmux"); + const attachIndex = tokens.findIndex( + (token, index) => index > tmuxIndex && (token === "attach" || token === "attach-session") + ); + const targetIndex = tokens.indexOf("-t", attachIndex + 1); + const socketIndex = tokens.indexOf("-S", tmuxIndex + 1); + const labelIndex = tokens.indexOf("-L", tmuxIndex + 1); + if (tmuxIndex < 0 || attachIndex < 0 || targetIndex < 0 || !tokens[targetIndex + 1] || socketIndex < 0 && labelIndex < 0) { + throw new Error("External launcher did not emit a supported tmux attach command."); + } + const flag = socketIndex >= 0 ? "-S" : "-L"; + const valueIndex = socketIndex >= 0 ? socketIndex + 1 : labelIndex + 1; + const value = tokens[valueIndex]; + const sessionName = tokens[targetIndex + 1]; + if (!value) { + throw new Error("External launcher emitted a tmux socket flag without a value."); + } + const socketPath = flag === "-S" ? path8.resolve(cwd, value) : execFileSync2( + "tmux", + ["-L", value, "display-message", "-p", "#{socket_path}"], + { encoding: "utf-8" } + ).trim(); + return { + socketPath, + sessionName, + paneMappings: [], + ownsServer: false, + ownsSession: false + }; +} +function tokenizeShellCommand(command) { + const tokens = []; + let current = ""; + let quote = null; + let escaping = false; + for (const character of command.trim()) { + if (escaping) { + current += character; + escaping = false; + } else if (character === "\\" && quote !== "'") { + escaping = true; + } else if (quote) { + if (character === quote) quote = null; + else current += character; + } else if (character === "'" || character === '"') { + quote = character; + } else if (/\s/.test(character)) { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += character; + } + } + if (escaping || quote) { + throw new Error("External launcher emitted an unterminated tmux attach command."); + } + if (current) tokens.push(current); + return tokens; +} +function tmuxExec(socketPath, args) { + return execFileSync2("tmux", ["-S", socketPath, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"] + }).trimEnd(); +} +function tmuxHasSession(state) { + if (!processIdentityMatches(state.serverProcess)) { + return false; + } + try { + tmuxExec(state.socket.path, ["has-session", "-t", state.sessionName]); + return true; + } catch { + return false; + } +} +async function runCommand(command, cwd, onStarted, timeoutMs = 3e4) { + const child = spawnShellCommand(command, { + cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"] + }); + const identity = child.pid ? captureProcessIdentity(child.pid) : null; + if (!identity) { + throw new Error("ProofShot could not capture the external launcher identity."); + } + try { + onStarted?.(identity); + } catch (error) { + await terminateOwnedProcessTree(identity); + throw error; + } + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const outcome = await new Promise((resolve13, reject) => { + const timer = setTimeout(() => resolve13({ kind: "timeout" }), timeoutMs); + child.once("error", reject); + child.once("close", (code) => { + clearTimeout(timer); + resolve13({ kind: "exit", code }); + }); + }); + if (outcome.kind === "timeout") { + await terminateOwnedProcessTree(identity); + throw new Error(`External environment command timed out after ${timeoutMs}ms.`); + } + const exitCode = outcome.code; + if (exitCode !== 0) { + await terminateOwnedProcessTree(identity); + throw new Error( + `External environment command failed with code ${String(exitCode)}: ${stderr.trim()}` + ); + } + return stdout.trim(); +} +function captureSocketIdentity(socketPath) { + const stat = fs12.lstatSync(socketPath); + if (!stat.isSocket() || stat.isSymbolicLink()) { + throw new Error(`tmux socket is not an owned Unix socket: ${socketPath}`); + } + const uid = process.getuid?.(); + if (uid !== void 0 && stat.uid !== uid) { + throw new Error(`tmux socket is owned by uid ${stat.uid}, expected ${uid}.`); + } + return { path: socketPath, inode: stat.ino, uid: stat.uid }; +} +function assertSocketIdentity(state) { + if (!fs12.existsSync(state.socket.path)) { + if (!processIdentityMatches(state.serverProcess)) { + return; + } + throw new Error("Owned tmux socket disappeared while its server is still alive."); + } + const current = captureSocketIdentity(state.socket.path); + if (current.inode !== state.socket.inode || current.uid !== state.socket.uid) { + throw new Error("tmux socket identity changed; refusing widened cleanup."); + } +} +function assertTmuxAvailable() { + try { + execFileSync2("tmux", ["-V"], { stdio: "pipe" }); + } catch { + throw new Error('tmux is required for environment.kind "tmux".'); + } +} +function validateId(id) { + if (!/^[A-Za-z0-9_-]+$/.test(id)) { + throw new Error(`Invalid log source id: ${id}`); + } +} +function shellQuote2(value) { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +// src/environment/runtime.ts +async function startOwnedEnvironment(environment, logs, sessionDir, sessionName, startTimeMs, onState) { + const fileSources = (logs.sources || []).filter( + (source) => source.kind === "file" + ); + if (!environment && fileSources.length === 0) { + return null; + } + let state; + if (environment?.kind === "tmux") { + state = await startTmuxEnvironment( + environment, + logs, + sessionDir, + sessionName, + startTimeMs, + onState + ); + } else { + state = await startProcessEnvironment( + environment?.kind === "processes" ? environment.commands : [], + logs, + sessionDir, + startTimeMs, + onState + ); + } + try { + state = await attachFileSources( + state, + fileSources, + logs, + sessionDir, + startTimeMs, + onState + ); + if (environment) { + await waitForReadiness(environment.readiness || []); + } + return state; + } catch (error) { + await stopOwnedEnvironment(state).catch(() => { + }); + throw error; + } +} +async function stopOwnedEnvironment(state) { + if (!state) { + return; + } + switch (state.kind) { + case "tmux": + await stopTmuxEnvironment(state); + return; + case "launcher": + await terminateOwnedProcessTree(state.launcher.process, { graceMs: 1e3 }); + if (ownedProcessTreeIsAlive(state.launcher.process)) { + throw new Error("External environment launcher did not stop."); + } + return; + case "processes": { + const errors = []; + for (const capture of state.processes) { + try { + await terminateOwnedProcessTree(capture.process, { graceMs: 1e3 }); + if (ownedProcessTreeIsAlive(capture.process)) { + throw new Error(`Environment process ${capture.sourceId} did not stop.`); + } + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + "One or more environment processes did not stop." + ); + } + return; + } + default: { + const exhaustiveState = state; + return exhaustiveState; + } + } +} +async function startProcessEnvironment(definitions, logs, sessionDir, startTimeMs, onState) { + const evidencePath = path9.join(sessionDir, "environment.ndjson"); + const logsDir = path9.join(sessionDir, "logs"); + fs13.mkdirSync(logsDir, { recursive: true }); + fs13.writeFileSync(evidencePath, "", { flag: "a", mode: 384 }); + const configuredSources = (logs.sources || []).filter( + (source) => source.kind === "process" + ); + const sourceByProcessId = /* @__PURE__ */ new Map(); + for (const source of configuredSources) { + if (sourceByProcessId.has(source.processId)) { + throw new Error( + `Multiple log sources reference process ${source.processId}; each process can be launched only once.` + ); + } + sourceByProcessId.set(source.processId, source); + } + for (const source of configuredSources) { + if (!definitions.some((definition) => definition.id === source.processId)) { + throw new Error( + `Log source ${source.id} references unknown process ${source.processId}.` + ); + } + } + const sources = definitions.map( + (definition) => sourceByProcessId.get(definition.id) || { + id: definition.id, + title: definition.title, + group: definition.group, + kind: "process", + processId: definition.id, + include: void 0, + exclude: void 0 + } + ); + validateUniqueIds(sources.map((source) => source.id)); + let state = { + kind: "processes", + evidencePath, + sources: [], + processes: [] + }; + onState(state); + try { + for (const sourceConfig of sources) { + const definition = definitions.find( + (candidate) => candidate.id === sourceConfig.processId + ); + if (!definition) throw new Error(`Missing process ${sourceConfig.processId}.`); + const source = { + id: sourceConfig.id, + title: sourceConfig.title || definition.title || definition.id, + group: sourceConfig.group || definition.group || "environment", + kind: "process", + stream: "stdout", + logPath: path9.join(logsDir, `${sourceConfig.id}.log`), + include: sourceConfig.include, + exclude: sourceConfig.exclude + }; + const process2 = await startProcessCapture( + definition, + source, + evidencePath, + startTimeMs, + logs.maxBytesPerSource || 5 * 1024 * 1024, + logs.stripAnsi !== false + ); + state = { + ...state, + sources: [...state.sources, source], + processes: [...state.processes, process2] + }; + onState(state); + } + return state; + } catch (error) { + await stopOwnedEnvironment(state).catch(() => { + }); + throw error; + } +} +async function attachFileSources(state, fileSources, logs, sessionDir, startTimeMs, onState) { + if (fileSources.length === 0) { + return state; + } + if (state.kind === "launcher") { + throw new Error("Cannot attach file sources before the environment launcher exits."); + } + const knownIds = new Set(state.sources.map((source) => source.id)); + const logsDir = path9.join(sessionDir, "logs"); + for (const fileSource of fileSources) { + if (knownIds.has(fileSource.id)) { + throw new Error(`Duplicate log source id: ${fileSource.id}`); + } + knownIds.add(fileSource.id); + const source = { + id: fileSource.id, + title: fileSource.title || path9.basename(fileSource.path), + group: fileSource.group || "environment", + kind: "file", + stream: "file", + logPath: path9.join(logsDir, `${fileSource.id}.log`), + include: fileSource.include, + exclude: fileSource.exclude + }; + const capture = await startFileCapture( + fileSource.path, + source, + state.evidencePath, + startTimeMs, + logs.maxBytesPerSource || 5 * 1024 * 1024, + logs.stripAnsi !== false + ); + state = state.kind === "tmux" ? { + ...state, + sources: [...state.sources, source], + captures: [...state.captures, capture] + } : { + ...state, + sources: [...state.sources, source], + processes: [...state.processes, capture] + }; + onState(state); + } + return state; +} +async function waitForReadiness(checks) { + for (const check of checks) { + const timeoutMs = check.timeoutMs || 30 * 1e3; + const deadline = Date.now() + timeoutMs; + let lastError = "not ready"; + while (Date.now() < deadline) { + try { + if (check.kind === "http") { + const response = await fetch(check.url, { + signal: AbortSignal.timeout(Math.min(2e3, timeoutMs)) + }); + if (response.ok) { + lastError = ""; + break; + } + lastError = `HTTP ${response.status}`; + } else { + await connectTcp(check.host || "127.0.0.1", check.port); + lastError = ""; + break; + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve13) => setTimeout(resolve13, 100)); + } + if (lastError) { + const target = check.kind === "http" ? check.url : `${check.host || "127.0.0.1"}:${check.port}`; + throw new Error(`Environment readiness failed for ${target}: ${lastError}`); + } + } +} +function connectTcp(host, port) { + return new Promise((resolve13, reject) => { + const socket = net2.createConnection({ host, port }); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("TCP readiness timed out")); + }, 2e3); + socket.once("connect", () => { + clearTimeout(timer); + socket.end(); + resolve13(); + }); + socket.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} +function validateUniqueIds(ids) { + const seen = /* @__PURE__ */ new Set(); + for (const id of ids) { + if (!/^[A-Za-z0-9_-]+$/.test(id)) { + throw new Error(`Invalid log source id: ${id}`); + } + if (seen.has(id)) { + throw new Error(`Duplicate log source id: ${id}`); + } + seen.add(id); + } +} + +// src/session/lifecycle.ts +function resolveOwnedBrowserIdentity(session) { + return session.browserProcess || (session.agentBrowserSocketDir ? captureAgentBrowserProcessIdentity( + session.agentBrowserSocketDir, + session.sessionName + ) : null); +} +function canAddressOwnedBrowserSession(session) { + const identity = resolveOwnedBrowserIdentity(session); + return Boolean(identity && processIdentityMatches(identity)); +} +async function stopOwnedBrowser(session) { + const identity = resolveOwnedBrowserIdentity(session); + if (!identity && session.browserLaunchAttempted) { + throw new Error( + `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.` + ); + } + assertIdentityNotReused(identity, "browser"); + let gracefulCloseError; + if (identity && processIdentityMatches(identity)) { + try { + closeBrowser(session.sessionName); + } catch (error) { + gracefulCloseError = error; + } + } + await terminateOwnedProcessTree(identity); + if (identity && ownedProcessTreeIsAlive(identity)) { + throw new AggregateError( + [ + ...gracefulCloseError ? [gracefulCloseError] : [], + new Error(`Owned browser process session ${identity.sessionId} did not stop.`) + ], + "Browser cleanup failed." + ); + } + if (session.agentBrowserSocketDir) { + clearAgentBrowserSessionFiles(session.agentBrowserSocketDir, session.sessionName); + } + if (gracefulCloseError) { + console.warn( + `ProofShot graceful browser close failed; exact owned-process cleanup succeeded: ${gracefulCloseError instanceof Error ? gracefulCloseError.message : String(gracefulCloseError)}` + ); + } +} +async function stopOwnedServer(session) { + assertIdentityNotReused(session.serverProcess, "server"); + await terminateOwnedProcessTree(session.serverProcess); + if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) { + throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`); + } +} +function assertIdentityNotReused(identity, label) { + if (!identity) return; + const current = captureProcessIdentity(identity.pid); + if (current && !processIdentitiesMatch(current, identity)) { + throw new Error( + `Owned ${label} process identity no longer matches PID ${identity.pid}; cleanup state was retained.` + ); + } +} +async function cleanupFailedStart(session) { + let cleanupError; + if (!session.browserProcess && session.browserLaunchAttempted && session.agentBrowserSocketDir) { + session.browserProcess = await waitForAgentBrowserProcessIdentity( + session.agentBrowserSocketDir, + session.sessionName + ); + } + if (session.browserLaunchAttempted && !session.browserProcess) { + cleanupError = new Error( + `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.` + ); + } + if (canAddressOwnedBrowserSession(session)) { + stopRecording(session.sessionName); + } + if (session.browserProcess || !session.browserLaunchAttempted) { + try { + await stopOwnedBrowser(session); + } catch (error) { + cleanupError ||= error; + } + } + try { + await stopOwnedEnvironment(session.environment); + } catch (error) { + cleanupError ||= error; + } + try { + await stopOwnedServer(session); + } catch (error) { + cleanupError ||= error; + } + if (cleanupError) throw cleanupError; +} + +// src/session/registry.ts +import * as fs14 from "fs"; +import * as os4 from "os"; +import * as path10 from "path"; +import { randomUUID as randomUUID2 } from "crypto"; +var SESSION_REGISTRY_DIRECTORY = "sessions"; +function getSessionRegistryDir(env = process.env, homeDir = os4.userInfo().homedir) { + const stateHome = env.XDG_STATE_HOME || path10.join(homeDir, ".local", "state"); + return path10.join(stateHome, "proofshot", SESSION_REGISTRY_DIRECTORY); +} +function registerSession(session, registryDir = getSessionRegistryDir()) { + validateSessionName(session.sessionName); + prepareRegistryDirectory(registryDir); + const registryPath = getRegistryPath(session.sessionName, registryDir); + const temporaryPath = `${registryPath}.${process.pid}.${randomUUID2()}.tmp`; + try { + fs14.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + "\n", { + mode: 384 + }); + fs14.renameSync(temporaryPath, registryPath); + } finally { + if (fs14.existsSync(temporaryPath)) { + fs14.unlinkSync(temporaryPath); + } + } +} +function unregisterSession(sessionName, registryDir = getSessionRegistryDir()) { + validateSessionName(sessionName); + const registryPath = getRegistryPath(sessionName, registryDir); + if (fs14.existsSync(registryPath)) { + fs14.unlinkSync(registryPath); + } +} +function listRegisteredSessions(registryDir = getSessionRegistryDir()) { + if (!fs14.existsSync(registryDir)) { + return []; + } + assertOwnedDirectory2(registryDir); + return fs14.readdirSync(registryDir).filter((fileName) => fileName.endsWith(".json")).map((fileName) => readRegisteredSession(path10.join(registryDir, fileName))).filter((session) => session !== null).sort((left, right) => right.startedAt.localeCompare(left.startedAt)); +} +function getRegisteredSession(sessionName, registryDir = getSessionRegistryDir()) { + validateSessionName(sessionName); + return readRegisteredSession(getRegistryPath(sessionName, registryDir)); +} +function prepareRegistryDirectory(registryDir) { + fs14.mkdirSync(registryDir, { recursive: true, mode: 448 }); + assertOwnedDirectory2(registryDir); +} +function assertOwnedDirectory2(directory) { + const stat = fs14.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`ProofShot session registry is not a real directory: ${directory}`); + } + const uid = process.getuid?.(); + if (uid !== void 0 && stat.uid !== uid) { + throw new Error( + `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}` + ); + } + fs14.accessSync(directory, fs14.constants.R_OK | fs14.constants.W_OK | fs14.constants.X_OK); + if (uid !== void 0) { + fs14.chmodSync(directory, 448); + } +} +function getRegistryPath(sessionName, registryDir) { + return path10.join(registryDir, `${sessionName}.json`); +} +function validateSessionName(sessionName) { + if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) { + throw new Error(`Invalid ProofShot session name: ${sessionName}`); + } +} +function readRegisteredSession(registryPath) { + try { + const stat = fs14.lstatSync(registryPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + return null; + } + const parsed = JSON.parse(fs14.readFileSync(registryPath, "utf-8")); + return isSessionState(parsed) ? parsed : null; + } catch { + return null; + } +} +function isSessionState(value) { + if (typeof value !== "object" || value === null) { + return false; + } + const session = value; + return typeof session.startedAt === "string" && (typeof session.description === "string" || session.description === null) && typeof session.outputDir === "string" && typeof session.sessionDir === "string" && typeof session.sessionName === "string" && typeof session.videoPath === "string" && typeof session.serverErrorLog === "string" && typeof session.port === "number" && (typeof session.serverCommand === "string" || session.serverCommand === null) && typeof session.serverAlreadyRunning === "boolean" && typeof session.recordingActive === "boolean" && isOptionalProcessIdentity(session.serverProcess) && isOptionalProcessIdentity(session.browserProcess); +} +function isOptionalProcessIdentity(value) { + if (value === void 0 || value === null) { + return true; + } + if (typeof value !== "object") { + return false; + } + const identity = value; + return Number.isInteger(identity.pid) && Number.isInteger(identity.processGroupId) && Number.isInteger(identity.sessionId) && typeof identity.startTime === "string"; +} + +// src/session/metadata.ts +import * as fs15 from "fs"; +import * as path11 from "path"; +var METADATA_FILENAME = "metadata.json"; +function writeMetadata(sessionDir, metadata) { + const metadataPath = path11.join(sessionDir, METADATA_FILENAME); + fs15.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + "\n"); +} +function loadMetadata(sessionDir) { + const metadataPath = path11.join(sessionDir, METADATA_FILENAME); + if (!fs15.existsSync(metadataPath)) return null; + try { + return JSON.parse(fs15.readFileSync(metadataPath, "utf-8")); + } catch { + return null; + } +} + +// src/session/manifest.ts +import * as fs16 from "fs"; +import * as path12 from "path"; +import { createHash as createHash2 } from "crypto"; +import { execFileSync as execFileSync3 } from "child_process"; +var MANIFEST_FILENAME = "artifact-manifest.json"; +function captureGitProvenance(cwd = process.cwd(), excludedPaths = []) { + const git = (args) => execFileSync3("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"] + }).trim(); + try { + const repository = normalizeRepository(git(["remote", "get-url", "origin"])); + const branch = git(["branch", "--show-current"]); + const commitSha = git(["rev-parse", "HEAD"]); + const treeHash = git(["rev-parse", "HEAD^{tree}"]); + const exclusions = excludedPaths.map((excludedPath) => path12.relative(cwd, path12.resolve(excludedPath))).filter((relativePath) => relativePath && !relativePath.startsWith("..")).map( + (relativePath) => `:(exclude)${relativePath.split(path12.sep).join(path12.posix.sep)}` + ); + const sourceDirty = git([ + "status", + "--porcelain", + "--untracked-files=all", + "--", + ".", + ...exclusions + ]) !== ""; + return { repository, branch, commitSha, treeHash, sourceDirty }; + } catch { + return { + repository: "", + branch: "", + commitSha: "", + treeHash: "", + sourceDirty: true + }; + } +} +function normalizeRepository(remote) { + const trimmed = remote.trim(); + const scpStyle = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/); + if (scpStyle && !trimmed.includes("://")) { + return `${scpStyle[1]}/${scpStyle[2]}`.replace(/\.git$/, "").replace(/\/$/, ""); + } + try { + const parsed = new URL(trimmed); + return `${parsed.hostname}${parsed.pathname}`.replace(/\.git$/, "").replace(/\/$/, ""); + } catch { + return trimmed.replace(/\.git$/, "").replace(/\/$/, ""); + } +} +function writeArtifactManifest(options) { + const finalized = options.finalizedProvenance || captureGitProvenance(options.metadata.repositoryRoot, [ + path12.dirname(options.sessionDir) + ]); + const sourceDrift = (options.metadata.repository || "") !== finalized.repository || options.metadata.branch !== finalized.branch || options.metadata.commitSha !== finalized.commitSha || (options.metadata.treeHash || "") !== finalized.treeHash || options.metadata.sourceDirty !== false || finalized.sourceDirty; + const artifacts = collectManifestArtifacts( + options.sessionDir, + options.evidence + ); + const manifest = { + version: 1, + sessionId: options.sessionId, + repository: options.metadata.repository || "", + branch: options.metadata.branch, + commitSha: options.metadata.commitSha, + treeHash: options.metadata.treeHash || "", + sourceDirty: options.metadata.sourceDirty !== false, + sourceDrift, + startedAt: options.metadata.startedAt, + finalizedAt: (/* @__PURE__ */ new Date()).toISOString(), + completion: "complete", + verdict: options.verdict.status, + artifacts + }; + writeJsonAtomically( + path12.join(options.sessionDir, MANIFEST_FILENAME), + manifest + ); + return manifest; +} +function loadArtifactManifest(sessionDir) { + const manifestPath = path12.join(sessionDir, MANIFEST_FILENAME); + try { + if (fs16.lstatSync(sessionDir).isSymbolicLink() || fs16.lstatSync(manifestPath).isSymbolicLink()) { + return null; + } + const parsed = JSON.parse(fs16.readFileSync(manifestPath, "utf-8")); + return isArtifactManifest(parsed) ? parsed : null; + } catch { + return null; + } +} +function validateManifestArtifacts(sessionDir, manifest) { + const root = fs16.realpathSync(sessionDir); + const ids = /* @__PURE__ */ new Set(); + const paths = /* @__PURE__ */ new Set(); + for (const [index, artifact] of manifest.artifacts.entries()) { + if (ids.has(artifact.id)) { + throw new Error(`Duplicate artifact ID: ${artifact.id}`); + } + ids.add(artifact.id); + if (paths.has(artifact.path)) { + throw new Error(`Duplicate artifact path: ${artifact.path}`); + } + paths.add(artifact.path); + if (artifact.order !== index) { + throw new Error(`Artifact order is invalid for ${artifact.id}.`); + } + if (!artifact.path || path12.isAbsolute(artifact.path) || artifact.path.split(/[\\/]/).includes("..")) { + throw new Error(`Unsafe artifact path: ${artifact.path}`); + } + if ((artifact.kind === "screenshot" || artifact.kind === "video") && path12.dirname(artifact.path) !== ".") { + throw new Error( + `Publishable media must be stored at the session root: ${artifact.path}` + ); + } + const artifactPath = path12.resolve(sessionDir, artifact.path); + let componentPath = sessionDir; + for (const component of artifact.path.split(/[\\/]/)) { + componentPath = path12.join(componentPath, component); + if (fs16.lstatSync(componentPath).isSymbolicLink()) { + throw new Error(`Artifact path contains a symlink: ${artifact.path}`); + } + } + const stat = fs16.lstatSync(artifactPath); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Artifact is not a regular file: ${artifact.path}`); + } + const realPath = fs16.realpathSync(artifactPath); + if (!realPath.startsWith(`${root}${path12.sep}`)) { + throw new Error(`Artifact escapes its session directory: ${artifact.path}`); + } + const contents = fs16.readFileSync(realPath); + const hash = createHash2("sha256").update(contents).digest("hex"); + if (hash !== artifact.sha256 || contents.length !== artifact.size) { + throw new Error(`Artifact hash mismatch: ${artifact.path}`); + } + } +} +function isArtifactManifest(value) { + if (typeof value !== "object" || value === null) return false; + const manifest = value; + return manifest.version === 1 && typeof manifest.sessionId === "string" && typeof manifest.repository === "string" && typeof manifest.branch === "string" && typeof manifest.commitSha === "string" && typeof manifest.treeHash === "string" && typeof manifest.sourceDirty === "boolean" && typeof manifest.sourceDrift === "boolean" && typeof manifest.startedAt === "string" && typeof manifest.finalizedAt === "string" && manifest.completion === "complete" && (manifest.verdict === "PASS" || manifest.verdict === "FAIL" || manifest.verdict === "INCOMPLETE" || manifest.verdict === "BLOCKED") && Array.isArray(manifest.artifacts) && manifest.artifacts.every( + (artifact, index) => typeof artifact === "object" && artifact !== null && typeof artifact.id === "string" && typeof artifact.path === "string" && typeof artifact.sha256 === "string" && typeof artifact.size === "number" && artifact.size >= 0 && artifact.order === index && [ + "screenshot", + "video", + "viewer", + "summary", + "evidence", + "verdict", + "log" + ].includes(artifact.kind) + ); +} +function collectManifestArtifacts(sessionDir, evidence) { + const screenshotOrder = new Map( + evidence.actions.map((action) => action.action.match(/^screenshot\s+(.+)$/)?.[1]).filter((value) => Boolean(value)).map((value, index) => [path12.basename(value), index]) + ); + const verifiedScreenshots = new Set( + evidence.screenshots.filter( + (screenshot) => screenshot.validPng && !screenshot.visuallyBlank && screenshot.sha256 !== null + ).map((screenshot) => screenshot.file) + ); + const candidates = listArtifactFiles(sessionDir).filter((file) => { + const kind = classifyArtifact(file); + return kind !== null && (kind !== "screenshot" || verifiedScreenshots.has(path12.basename(file))); + }).sort((left, right) => { + const leftOrder = screenshotOrder.get(path12.basename(left)); + const rightOrder = screenshotOrder.get(path12.basename(right)); + if (leftOrder !== void 0 || rightOrder !== void 0) { + return (leftOrder ?? Number.MAX_SAFE_INTEGER) - (rightOrder ?? Number.MAX_SAFE_INTEGER); + } + return left.localeCompare(right); + }); + return candidates.map((file, order) => { + const contents = fs16.readFileSync(path12.join(sessionDir, file)); + const kind = classifyArtifact(file); + return { + id: `${kind}:${file}`, + kind, + path: file, + sha256: createHash2("sha256").update(contents).digest("hex"), + size: contents.length, + order + }; + }); +} +function listArtifactFiles(root, current = root) { + const files = []; + for (const entry of fs16.readdirSync(current, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + continue; + } + const absolutePath = path12.join(current, entry.name); + if (entry.isDirectory()) { + files.push(...listArtifactFiles(root, absolutePath)); + } else if (entry.isFile()) { + files.push(path12.relative(root, absolutePath)); + } + } + return files; +} +function classifyArtifact(file) { + const basename9 = path12.basename(file); + const isSessionRoot = path12.dirname(file) === "."; + if (isSessionRoot && file.endsWith(".png")) return "screenshot"; + if (isSessionRoot && (basename9 === "session.webm" || basename9 === "session.mp4")) { + return "video"; + } + if (isSessionRoot && basename9 === "viewer.html") return "viewer"; + if (isSessionRoot && basename9 === "SUMMARY.md") return "summary"; + if (isSessionRoot && basename9 === "evidence.json") return "evidence"; + if (isSessionRoot && basename9 === "verdict.json") return "verdict"; + if (file.endsWith(".log") || file.endsWith(".ndjson")) return "log"; + return null; +} +function writeJsonAtomically(filePath, value) { + const temporaryPath = `${filePath}.${process.pid}.tmp`; + fs16.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + "\n", { + mode: 384 + }); + fs16.renameSync(temporaryPath, filePath); +} + +// src/commands/start.ts +async function startCommand(options) { + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + if (hasActiveSession(controlDir)) { + if (options.force) { + const existingSession = loadSession(controlDir); + if (existingSession) { + setAgentBrowserDefaults({ + configPath: existingSession.agentBrowserConfigPath || config.browser.configPath, + socketDir: existingSession.agentBrowserSocketDir + }); + await cleanupFailedStart(existingSession); + unregisterSession(existingSession.sessionName); + } + clearSession(controlDir); + console.log(chalk2.yellow("\u26A0") + chalk2.dim(" Cleaned up the previous session")); + } else { + console.log( + chalk2.yellow("\u26A0 A session is already active.") + chalk2.dim(' Run "proofshot stop" first, or use --force to override.') + ); + return; + } + } + if (options.port) config.devServer.port = options.port; + if (options.output) config.output = options.output; + if (options.headed !== void 0) config.headless = !options.headed; + const outputDir = path13.resolve(config.output); + const timestamp = generateTimestamp(); + const sessionDirName = generateSessionDirName(timestamp, options.description || null); + const sessionDir = path13.join(outputDir, sessionDirName); + const sessionName = generateAgentBrowserSessionName(timestamp); + let socketDir; + let browserExecutable; + try { + socketDir = prepareAgentBrowserSocketDir(sessionName); + browserExecutable = discoverBrowserExecutable({ + configuredPath: options.browserExecutable || config.browser.executablePath + }); + if (!browserExecutable && !process.env.AGENT_BROWSER_PROVIDER && !process.env.AGENT_BROWSER_CDP) { + throw browserSetupError(); + } + } catch (error) { + console.error(chalk2.red("\u2717") + ` Browser preflight failed: ${error.message}`); + process.exit(1); + return; + } + if (browserExecutable) config.browser.executablePath = browserExecutable; + setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir }); + ensureOutputDir(outputDir); + ensureOutputDir(sessionDir); + const videoPath = path13.join(sessionDir, "session.webm"); + const serverErrorLog = path13.join(sessionDir, "server.log"); + const provenance = captureGitProvenance(process.cwd(), [outputDir]); + writeMetadata(sessionDir, { + ...provenance, + repositoryRoot: process.cwd(), + startedAt: (/* @__PURE__ */ new Date()).toISOString(), + description: options.description || null + }); + const baseUrl = `http://localhost:${config.devServer.port}`; + const openUrl = options.url || baseUrl; + const session = { + startedAt: (/* @__PURE__ */ new Date()).toISOString(), + startDirectory: process.cwd(), + controlDir, + lifecycleStatus: "starting", + cleanupError: null, + description: options.description || null, + outputDir, + sessionDir, + sessionName, + videoPath, + serverErrorLog, + port: config.devServer.port, + serverCommand: options.run || null, + serverAlreadyRunning: !options.run, + recordingActive: false, + browserLaunchAttempted: false, + bundleComplete: false, + browserRetained: false, + videoTrimComplete: false, + trimOffsetSec: 0, + sessionLogAdjusted: false, + consoleEvidenceAvailable: false, + consoleErrorCount: 0, + targetUrl: openUrl, + headless: config.headless, + agentBrowserSocketDir: socketDir, + agentBrowserConfigPath: config.browser.configPath, + serverProcess: null, + browserProcess: null, + environment: null, + viewport: { width: config.viewport.width, height: config.viewport.height } + }; + persistOwnedSession(session, controlDir); + const signalHandlers = installStartSignalHandlers(session, controlDir); + let failureContext = "start the session"; + try { + if (options.run && config.environment) { + throw new Error("Use either --run or config.environment, not both."); + } + if (config.environment || (config.logs?.sources || []).some((source) => source.kind === "file")) { + failureContext = "start environment"; + session.environment = await startOwnedEnvironment( + config.environment, + config.logs || {}, + sessionDir, + sessionName, + new Date(session.startedAt).getTime(), + (environmentState) => { + session.environment = environmentState; + persistOwnedSession(session, controlDir); + } + ); + console.log(chalk2.green("\u2713") + " Environment and log capture started"); + } + if (options.run) { + failureContext = "start dev server"; + console.log(chalk2.dim(`Starting: ${options.run}`)); + const server = await ensureDevServer( + options.run, + config.devServer.port, + config.devServer.startupTimeout, + serverErrorLog, + (startedServer) => { + session.serverAlreadyRunning = false; + session.serverProcess = startedServer.process; + persistOwnedSession(session, controlDir); + } + ); + session.serverAlreadyRunning = false; + session.serverProcess = server.process; + persistOwnedSession(session, controlDir); + console.log(chalk2.green("\u2713") + ` Dev server started on :${config.devServer.port}`); + console.log(chalk2.dim(` Server logs \u2192 ${serverErrorLog}`)); + } else if (!config.environment) { + console.log(chalk2.dim("No --run provided, assuming server is already running")); + } + failureContext = "open browser"; + console.log(chalk2.dim("Opening browser...")); + session.browserLaunchAttempted = true; + persistOwnedSession(session, controlDir); + openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser); + session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName); + if (!session.browserProcess) { + throw new Error( + `Could not record the exact agent-browser daemon identity for session ${sessionName}.` + ); + } + session.targetUrl = getPageUrl(sessionName) || openUrl; + persistOwnedSession(session, controlDir); + console.log(chalk2.green("\u2713") + " Browser ready"); + failureContext = "initialize recording"; + const RECORDING_RETRIES = 3; + const RETRY_DELAY_MS = 2e3; + let recordingStarted = false; + let lastError; + for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) { + try { + startRecording(videoPath, sessionName); + session.recordingStartedAt = (/* @__PURE__ */ new Date()).toISOString(); + recordingStarted = true; + console.log(chalk2.green("\u2713") + " Recording started"); + break; + } catch (error) { + lastError = error; + if (attempt < RECORDING_RETRIES) { + console.log( + chalk2.yellow("\u26A0") + ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1e3}s...` + ); + await new Promise((resolve13) => setTimeout(resolve13, RETRY_DELAY_MS)); + } + } + } + if (!recordingStarted) { + throw new Error( + `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}` + ); + } + } catch (error) { + if (signalHandlers.isHandling()) { + return; + } + signalHandlers.remove(); + const interruptionSignal = getTerminationSignal(error); + try { + await cleanupFailedStart(session); + clearOwnedSession(session, controlDir); + console.error( + chalk2.red("\u2717") + ` Failed to ${failureContext}: ${error.message} +` + chalk2.dim("All processes started by this ProofShot attempt were cleaned up.") + ); + } catch (cleanupError) { + session.lifecycleStatus = "recovery"; + session.cleanupError = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + persistOwnedSession(session, controlDir); + console.error( + chalk2.red("\u2717") + ` Failed to ${failureContext}: ${error.message} +` + chalk2.yellow(`Cleanup is incomplete: ${session.cleanupError} +`) + chalk2.dim(`Run "proofshot session clean --session ${session.sessionName}" to retry.`) + ); + } + process.exit( + interruptionSignal === "SIGINT" ? 130 : interruptionSignal === "SIGTERM" ? 143 : 1 + ); + return; + } + session.recordingActive = true; + session.lifecycleStatus = "active"; + persistOwnedSession(session, controlDir); + signalHandlers.remove(); + console.log(""); + console.log(chalk2.green.bold("\u2705 ProofShot session started")); + console.log(""); + console.log(`Server: ${options.run ? chalk2.cyan(options.run) : chalk2.dim("external")} on :${config.devServer.port}`); + console.log(`Browser: Chromium (${config.headless ? "headless" : "headed"})`); + console.log(`Session: ${chalk2.dim(sessionName)}`); + console.log(`Target: ${chalk2.dim(openUrl)}`); + console.log(`Recording: ${chalk2.dim(videoPath)}`); + console.log(`Errors log: ${chalk2.dim(serverErrorLog)}`); + if (options.description) { + console.log(`Verifying: ${chalk2.white(options.description)}`); + } + console.log(""); + console.log(chalk2.dim("Use proofshot exec to navigate and test:")); + console.log(chalk2.dim(" proofshot exec snapshot -i # See interactive elements")); + console.log(chalk2.dim(" proofshot exec click @e3 # Click an element")); + console.log(chalk2.dim(' proofshot exec fill @e2 "text" # Fill a form field')); + console.log(chalk2.dim(" proofshot exec screenshot step.png # Capture a moment")); + console.log(""); + console.log(`When done, run: ${chalk2.white("proofshot stop")}`); +} +function persistOwnedSession(session, controlDir) { + saveSession(session, controlDir); + registerSession(session); +} +function clearOwnedSession(session, controlDir) { + clearSession(controlDir); + unregisterSession(session.sessionName); +} +function installStartSignalHandlers(session, controlDir) { + let handlingSignal = false; + const handlers = /* @__PURE__ */ new Map(); + for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + if (handlingSignal) { + return; + } + handlingSignal = true; + void cleanupFailedStart(session).then(() => { + clearOwnedSession(session, controlDir); + process.exit(signal === "SIGINT" ? 130 : 143); + }).catch((error) => { + session.lifecycleStatus = "recovery"; + session.cleanupError = error instanceof Error ? error.message : String(error); + persistOwnedSession(session, controlDir); + process.exit(1); + }); + }; + handlers.set(signal, handler); + process.once(signal, handler); + } + return { + isHandling: () => handlingSignal, + remove: () => { + for (const [signal, handler] of handlers) { + process.removeListener(signal, handler); + } + } + }; +} +function getTerminationSignal(error) { + let current = error; + for (let depth = 0; depth < 4; depth += 1) { + if (typeof current !== "object" || current === null) { + return null; + } + const candidate = current; + if (candidate.signal === "SIGINT" || candidate.signal === "SIGTERM") { + return candidate.signal; + } + current = candidate.cause; + } + return null; +} + +// src/commands/stop.ts +import * as fs21 from "fs"; +import * as path18 from "path"; +import { randomUUID as randomUUID4 } from "crypto"; +import { execFileSync as execFileSync5 } from "child_process"; +import chalk3 from "chalk"; + +// src/artifacts/viewer.ts +import * as fs17 from "fs"; +import * as path14 from "path"; +var MAX_LOG_BYTES = 50 * 1024; +function truncateLog(log, maxBytes) { + if (log.length <= maxBytes) return { text: log, truncated: false }; + const cut = log.slice(0, maxBytes); + const lastNl = cut.lastIndexOf("\n"); + return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true }; +} +function isErrorLine(line) { + const t = line.trim(); + if (!t) return false; + return /\bError:|ERR[_!]|FATAL\b|CRITICAL\b|panic:|Exception:|Traceback/i.test(t); +} +function buildLogLines(text) { + if (!text.trim()) return ""; + return text.split("\n").map((line, i) => { + const num = i + 1; + const cls = isErrorLine(line) ? "log-line log-line-error" : "log-line"; + return `${num}${escapeHtml(line)}`; + }).join("\n"); +} +var MAX_LOG_ENTRIES = 2e3; +function buildTimestampedLogLines(entries) { + if (entries.length === 0) return { html: "", truncated: false }; + const truncated = entries.length > MAX_LOG_ENTRIES; + const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries; + const html = capped.map((entry, i) => { + const num = i + 1; + const cls = isErrorLine(entry.text) ? "log-line log-line-error" : "log-line"; + const timed = Number.isFinite(entry.relativeTimeSec); + const time = formatTime(timed ? Math.max(0, entry.relativeTimeSec) : Number.NaN); + const interaction = timed ? ` data-time="${entry.relativeTimeSec}" onclick="seekTo(${entry.relativeTimeSec})"` : ""; + return `${time}${num}${escapeHtml(entry.text)}`; + }).join("\n"); + return { html, truncated }; +} +function getActionIcon(action) { + const cmd = action.split(" ")[0].toLowerCase(); + switch (cmd) { + case "open": + case "navigate": + return "\u{1F9ED}"; + // compass + case "click": + return "\u{1F5B1}"; + // mouse + case "fill": + case "type": + return "\u2328"; + // keyboard + case "screenshot": + return "\u{1F4F7}"; + // camera + case "snapshot": + return "\u{1F441}"; + // eye + case "scroll": + return "\u2195"; + // scroll arrows + case "press": + return "\u2318"; + // key + default: + return "\u25B6"; + } +} +function formatTime(sec) { + if (!Number.isFinite(sec)) { + return "untimed"; + } + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} +function titleCase(value) { + return value.split(/[-_\s]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join(" "); +} +function buildEvidencePanels(evidence) { + const panels = []; + for (const origin of ["environment", "browser"]) { + const originEvents = evidence.events.filter( + (event) => event.origin === origin && !event.presentationHidden + ); + if (originEvents.length === 0) { + continue; + } + const originLabel = origin === "environment" ? "Environment" : "Browser"; + panels.push({ + key: origin, + label: originLabel, + summary: null, + events: orderEvidenceEvents(originEvents) + }); + const sources = evidence.sources.filter((source) => source.origin === origin).sort( + (left, right) => left.group.localeCompare(right.group) || left.title.localeCompare(right.title) + ); + for (const source of sources) { + panels.push({ + key: `${origin}-${source.id}`, + label: origin === "environment" ? `${titleCase(source.group)} \xB7 ${source.title}` : source.title, + summary: source, + events: orderEvidenceEvents( + originEvents.filter((event) => event.sourceId === source.id) + ) + }); + } + } + return panels; +} +function orderEvidenceEvents(events) { + return [...events].sort((left, right) => { + if (left.segment !== right.segment) { + return left.segment === "history" ? -1 : 1; + } + if (left.relativeTimeSec === null) { + return -1; + } + if (right.relativeTimeSec === null) { + return 1; + } + return left.relativeTimeSec - right.relativeTimeSec; + }); +} +function buildEvidenceLogLines(events) { + if (events.length === 0) { + return '

No visible evidence for this source

'; + } + return `
${events.slice(0, MAX_LOG_ENTRIES).map((event, index) => {
+    const timed = event.relativeTimeSec !== null && Number.isFinite(event.relativeTimeSec);
+    const classes = [
+      "log-line",
+      isErrorLine(event.text) ? "log-line-error" : ""
+    ].filter(Boolean).join(" ");
+    const interaction = timed ? ` data-time="${event.relativeTimeSec}" onclick="seekTo(${event.relativeTimeSec})"` : "";
+    const boundary = event.captureGap ? 'capture gap' : event.segment === "history" ? 'history' : "";
+    return `${formatTime(timed ? event.relativeTimeSec : Number.NaN)}${index + 1}${boundary}${escapeHtml(event.text)}`;
+  }).join("\n")}
${events.length > MAX_LOG_ENTRIES ? '

Viewer display truncated. Canonical evidence.json retains the bounded source evidence.

' : ""}`; +} +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function serializeInlineJson(value) { + return JSON.stringify(value).replace(/ { + const icon = getActionIcon(entry.action); + const time = formatTime(entry.relativeTimeSec); + const action = escapeHtml(entry.action); + const timed = Number.isFinite(entry.relativeTimeSec); + const interaction = timed ? ` data-time="${entry.relativeTimeSec}" onclick="seekTo(${entry.relativeTimeSec})"` : ""; + return `
+ ${i + 1} + ${icon} +
+ ${action} +
+ ${time} +
`; + }).join("\n"); + const descriptionHtml = data.description ? `

${escapeHtml(data.description)}

` : ""; + const consoleEvidenceAvailable = data.consoleEvidenceAvailable !== false; + const consoleBadgeClass = !consoleEvidenceAvailable ? "unavailable" : data.consoleErrorCount === 0 ? "clean" : "has-errors"; + const consoleBadgeText = !consoleEvidenceAvailable ? "Console: unavailable" : data.consoleErrorCount === 0 ? "Console: clean" : `Console: ${data.consoleErrorCount} error(s)`; + const serverBadgeClass = data.serverErrorCount === 0 ? "clean" : "has-errors"; + const serverBadgeText = data.serverErrorCount === 0 ? "Server: clean" : `Server: ${data.serverErrorCount} error(s)`; + const tokenUsageHtml = data.tokenUsage ? `
+
Token Usage (Estimated)
+
+ In: ~${data.tokenUsage.inputTokens.toLocaleString()} + Out: ~${data.tokenUsage.outputTokens.toLocaleString()} + Total: ~${data.tokenUsage.totalTokens.toLocaleString()} + ${data.tokenUsage.estimatedCost > 0 ? `Cost: ~$${data.tokenUsage.estimatedCost.toFixed(4)}` : ""} +
+ ${data.tokenUsage.source === "estimated" ? '
Estimated from session activity
' : ""} +
` : ""; + const hasVideo = !!data.videoFilename; + const markersJson = serializeInlineJson( + data.entries.map((entry, i) => ({ + time: entry.relativeTimeSec, + icon: getActionIcon(entry.action), + action: entry.action, + index: i + })).filter((marker) => Number.isFinite(marker.time)) + ); + const scrubBarHtml = hasVideo ? `
+
+
+
+ ${data.entries.filter((entry) => Number.isFinite(entry.relativeTimeSec)).map((entry, i) => { + const pct = timelineDurationSec > 0 ? entry.relativeTimeSec / timelineDurationSec * 100 : 0; + const icon = getActionIcon(entry.action); + return `
${icon}
`; + }).join("\n ")} +
+
+
` : ""; + const videoPanelHtml = hasVideo ? `
+
+ +
+
+ ${scrubBarHtml} +
` : `

No video recorded

Screenshots are available in the timeline

`; + const entriesJson = serializeInlineJson(data.entries); + let consoleLogBodyHtml; + if (data.consoleEntries && data.consoleEntries.length > 0) { + const built = buildTimestampedLogLines(data.consoleEntries); + consoleLogBodyHtml = `
${built.html}
${built.truncated ? '

Log truncated at 2000 entries. See console-output.log for full output.

' : ""}`; + } else { + const consoleTrunc = truncateLog(data.consoleOutput ?? "", MAX_LOG_BYTES); + const consoleLogLines = buildLogLines(consoleTrunc.text); + consoleLogBodyHtml = consoleLogLines ? `
${consoleLogLines}
${consoleTrunc.truncated ? '

Log truncated at 50 KB. See console-output.log for full output.

' : ""}` : '

No console output captured

'; + } + let serverLogBodyHtml; + if (data.serverEntries && data.serverEntries.length > 0) { + const built = buildTimestampedLogLines(data.serverEntries); + serverLogBodyHtml = `
${built.html}
${built.truncated ? '

Log truncated at 2000 entries. See server.log for full output.

' : ""}`; + } else { + const serverTrunc = truncateLog(data.serverLog ?? "", MAX_LOG_BYTES); + const serverLogLines = buildLogLines(serverTrunc.text); + serverLogBodyHtml = serverLogLines ? `
${serverLogLines}
${serverTrunc.truncated ? '

Log truncated at 50 KB. See server.log for full output.

' : ""}` : '

No server log captured

'; + } + const consoleLineCount = data.consoleEntries && data.consoleEntries.length > 0 ? data.consoleEntries.length : (data.consoleOutput ?? "").split("\n").filter((l) => l.trim()).length; + const serverLineCount = data.serverEntries && data.serverEntries.length > 0 ? data.serverEntries.length : (data.serverLog ?? "").split("\n").filter((l) => l.trim()).length; + const evidencePanels = data.evidence ? buildEvidencePanels(data.evidence) : []; + const evidenceTabsHtml = evidencePanels.map( + (panel, index) => `` + ).join("\n "); + const evidenceContentsHtml = evidencePanels.map((panel, index) => { + const sourceIds = new Set(panel.events.map((event) => event.sourceId)); + const incidents = data.evidence?.incidents.filter( + (incident) => incident.sourceIds.some((sourceId) => sourceIds.has(sourceId)) + ) || []; + const summary = panel.summary; + const status = summary ? `${summary.hiddenLineCount} hidden \xB7 ${summary.truncationCount} truncated \xB7 ${summary.captureGapCount} capture gap(s)` : `${incidents.length} grouped incident(s)`; + const incidentsHtml = incidents.length > 0 ? `
${incidents.map( + (incident) => `
${incident.severity.toUpperCase()} \xD7 ${incident.count} ${escapeHtml(incident.message)}
` + ).join("")}
` : ""; + return ``; + }).join("\n "); + const environmentTabIndex = evidencePanels.findIndex( + (panel) => panel.key === "environment" + ); + const browserTabIndex = evidencePanels.findIndex( + (panel) => panel.key === "browser" + ); + const canonicalTabs = evidencePanels.length > 0; + const verdictStatus = data.verdict?.status || "INCOMPLETE"; + const verdictBadgeClass = verdictStatus === "PASS" ? "clean" : verdictStatus === "FAIL" ? "has-errors" : "unavailable"; + const mediaWarningHtml = data.evidence?.mediaTruncated ? `
Media ends ${Math.max(0, data.evidence.mediaDivergenceSec || 0).toFixed(1)}s before the canonical action timeline. Timeline events remain authoritative; seeks clamp to available media.
` : ""; + return ` + + + + + ProofShot \u2014 Verification Report + + + +
+

ProofShot Verification

+ ${descriptionHtml} +

${escapeHtml(date)} · ${timelineDurationSec}s

+
+ Verdict: ${verdictStatus} + ${canonicalTabs ? `${environmentTabIndex >= 0 ? `` : ""} + ${browserTabIndex >= 0 ? `` : ""}` : ` + `} +
+ ${tokenUsageHtml} +
+
+
+ ${videoPanelHtml} +
+
+
+ + ${canonicalTabs ? evidenceTabsHtml : ` + `} +
+ +
+
+
+${mediaWarningHtml} +${stepsHtml} +
+ ${canonicalTabs ? evidenceContentsHtml : ` + `} +
+
+ + +`; +} +function writeViewer(outputDir, data) { + let entries = data.entries; + if (!entries) { + const logPath = path14.join(outputDir, "session-log.json"); + if (fs17.existsSync(logPath)) { + try { + entries = JSON.parse(fs17.readFileSync(logPath, "utf-8")); + } catch { + entries = []; + } + } else { + entries = []; + } + } + const html = generateViewer({ ...data, entries: entries || [] }); + const viewerPath = path14.join(outputDir, "viewer.html"); + fs17.writeFileSync(viewerPath, html); + return viewerPath; +} + +// src/artifacts/evidence.ts +import * as fs18 from "fs"; +import * as path15 from "path"; +import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto"; +import { execFileSync as execFileSync4 } from "child_process"; +import { PNG } from "pngjs"; +function writeCanonicalEvidence(options) { + const events = collectEvents(options); + applyPresentationFilters(events, options.environment?.sources || []); + const incidents = buildIncidents(events); + const screenshots = inspectScreenshots(options.sessionDir, options.actions); + const mediaDurationSec = probeMediaDuration(options.videoPath); + const actionDuration = options.actions.map((entry) => entry.relativeTimeSec).filter(Number.isFinite).reduce((maximum, current) => Math.max(maximum, current), 0); + const timelineDurationSec = Math.max(options.durationSec, actionDuration); + const mediaDivergenceSec = mediaDurationSec === null ? null : Math.max(0, actionDuration - mediaDurationSec); + const mediaTruncated = mediaDivergenceSec !== null && mediaDivergenceSec > 1; + const sources = buildSourceSummaries( + events, + incidents + ); + const evidence = { + version: 1, + sessionId: options.sessionId, + generatedAt: (/* @__PURE__ */ new Date()).toISOString(), + timelineDurationSec, + mediaDurationSec, + mediaDivergenceSec, + mediaTruncated, + actions: options.actions, + events, + sources, + incidents, + screenshots + }; + const verdict = buildVerdict(options, evidence); + writeJsonAtomically2( + path15.join(options.sessionDir, "evidence.json"), + evidence + ); + writeJsonAtomically2( + path15.join(options.sessionDir, "verdict.json"), + verdict + ); + return { evidence, verdict }; +} +function writeJsonAtomically2(filePath, value) { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID3()}.tmp`; + try { + fs18.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + "\n", { + mode: 384 + }); + fs18.renameSync(temporaryPath, filePath); + } finally { + if (fs18.existsSync(temporaryPath)) fs18.unlinkSync(temporaryPath); + } +} +function collectEvents(options) { + const environmentEvents = options.environment?.evidencePath && fs18.existsSync(options.environment.evidencePath) ? loadEvidenceEvents(options.environment.evidencePath).map( + (event) => adjustEnvironmentEventTime( + event, + options.timelineOffsetSec ?? 0 + ) + ) : []; + if (options.environment && options.environment.kind !== "launcher") { + for (const sourceId of options.environment.healthFailures || []) { + const source = options.environment.sources.find( + (candidate) => candidate.id === sourceId + ); + environmentEvents.push({ + version: 1, + origin: "environment", + group: source?.group || "environment", + sourceId, + sourceTitle: source?.title || sourceId, + stream: source?.stream || "stderr", + segment: "live", + timestamp: null, + relativeTimeSec: null, + text: `[capture worker exited before stop: ${sourceId}]`, + captureGap: true + }); + } + } + environmentEvents.push( + ...options.serverEntries.map( + (entry) => toEvidenceEvent(entry, { + origin: "environment", + group: "backend", + sourceId: "server", + sourceTitle: "Server", + stream: "stderr" + }) + ) + ); + const navigations = buildNavigations(options.actions, options.initialPageUrl); + const browserEvents = options.consoleEntries.map((entry) => { + const navigation = findNavigation(navigations, entry.relativeTimeSec); + return toEvidenceEvent(entry, { + origin: "browser", + group: "browser", + sourceId: navigation.id, + sourceTitle: navigation.url, + navigationId: navigation.id, + pageUrl: navigation.url, + stream: "console" + }); + }); + return [...environmentEvents, ...browserEvents]; +} +function adjustEnvironmentEventTime(event, timelineOffsetSec) { + if (event.relativeTimeSec === null || timelineOffsetSec <= 0) { + return event; + } + const relativeTimeSec = event.relativeTimeSec - timelineOffsetSec; + return { + ...event, + relativeTimeSec: relativeTimeSec >= 0 ? parseFloat(relativeTimeSec.toFixed(3)) : null + }; +} +function toEvidenceEvent(entry, source) { + return { + version: 1, + ...source, + segment: "live", + timestamp: null, + relativeTimeSec: Number.isFinite(entry.relativeTimeSec) ? entry.relativeTimeSec : null, + text: entry.text + }; +} +function buildNavigations(actions, initialPageUrl) { + const navigations = []; + const append = (url, startTimeSec) => { + if (!url || navigations.at(-1)?.url === url) return; + navigations.push({ url, startTimeSec }); + }; + append(initialPageUrl, 0); + for (const entry of actions) { + if (!Number.isFinite(entry.relativeTimeSec)) continue; + const explicit = entry.action.match(/^(?:open|navigate)\s+(\S+)/i)?.[1]; + append(entry.pageUrl || explicit, entry.relativeTimeSec); + } + if (navigations.length === 0) { + navigations.push({ url: "Browser", startTimeSec: 0 }); + } + return navigations.map((navigation, index) => ({ + id: `browser-nav-${index + 1}`, + ...navigation + })); +} +function findNavigation(navigations, relativeTimeSec) { + const timed = Number.isFinite(relativeTimeSec) ? relativeTimeSec : 0; + return [...navigations].reverse().find((navigation) => navigation.startTimeSec <= timed) || navigations[0]; +} +function buildIncidents(events) { + const incidents = /* @__PURE__ */ new Map(); + for (const event of events) { + const severity = classifyIncident(event.text); + if (!severity) { + continue; + } + const message = normalizeIncident(event.text); + const key = `${event.origin}\0${event.group}\0${severity}\0${message}`; + const incident = incidents.get(key) || { + severity, + origin: event.origin, + group: event.group, + message, + count: 0, + sourceIds: /* @__PURE__ */ new Set(), + times: [] + }; + incident.count += 1; + incident.sourceIds.add(event.sourceId); + if (event.relativeTimeSec !== null) { + incident.times.push(event.relativeTimeSec); + } + incidents.set(key, incident); + } + return [...incidents.values()].map((incident, index) => ({ + id: `incident-${index + 1}`, + severity: incident.severity, + origin: incident.origin, + group: incident.group, + message: incident.message, + count: incident.count, + sourceIds: [...incident.sourceIds], + firstTimeSec: incident.times.length > 0 ? Math.min(...incident.times) : null, + lastTimeSec: incident.times.length > 0 ? Math.max(...incident.times) : null + })); +} +function classifyIncident(text) { + if (/\bFATAL\b|\bpanic:|uncaught exception|unhandled rejection|capture worker exited before stop|malformed canonical evidence row|\[process exited with code (?!0\])/i.test( + text + )) { + return "fatal"; + } + if (/\bError:|ERR[_!]|Exception:|Traceback/i.test(text)) { + return "error"; + } + return null; +} +function normalizeIncident(text) { + return text.replace(/\b\d{4}-\d{2}-\d{2}T[\d:.]+Z\b/g, "").replace(/:\d+:\d+\b/g, "::").replace(/\s+/g, " ").trim(); +} +function buildSourceSummaries(events, incidents) { + const sourceKeys = /* @__PURE__ */ new Map(); + for (const event of events) { + const key = `${event.origin}\0${event.sourceId}`; + const existing = sourceKeys.get(key) || { + title: event.sourceTitle, + origin: event.origin, + group: event.group, + events: [] + }; + existing.events.push(event); + sourceKeys.set(key, existing); + } + return [...sourceKeys.values()].map((source) => { + const id = source.events[0].sourceId; + const hiddenLineCount = source.events.filter( + (event) => event.presentationHidden + ).length; + return { + id, + title: source.title, + origin: source.origin, + group: source.group, + lineCount: source.events.length, + hiddenLineCount, + truncationCount: source.events.filter((event) => event.truncated).length, + captureGapCount: source.events.filter((event) => event.captureGap).length, + incidentCount: incidents.filter( + (incident) => incident.origin === source.origin && incident.sourceIds.includes(id) + ).length + }; + }); +} +function applyPresentationFilters(events, configuredSources) { + for (const event of events) { + const config = configuredSources.find( + (candidate) => candidate.id === event.sourceId + ); + if (isHidden(event.text, config)) { + event.presentationHidden = true; + } + } +} +function isHidden(text, config) { + if (!config) { + return false; + } + if (config.include && config.include.length > 0 && !config.include.some((pattern) => text.includes(pattern))) { + return true; + } + return Boolean(config.exclude?.some((pattern) => text.includes(pattern))); +} +function inspectScreenshots(sessionDir, actions) { + const files = [ + ...new Set( + actions.filter((action) => action.outcome === "passed").map((action) => action.action.match(/^screenshot\s+(.+)$/)?.[1]).filter((value) => Boolean(value)).map((value) => path15.basename(value)) + ) + ]; + return files.map((file) => { + const filePath = path15.join(sessionDir, file); + const size = fs18.existsSync(filePath) ? fs18.statSync(filePath).size : 0; + if (size > 50 * 1024 * 1024) { + return { + file, + sha256: null, + validPng: false, + visuallyBlank: false, + size + }; + } + const contents = size > 0 ? fs18.readFileSync(filePath) : Buffer.alloc(0); + const integrity = inspectPng(contents); + return { + file, + sha256: createHash3("sha256").update(contents).digest("hex"), + validPng: integrity.valid, + visuallyBlank: integrity.visuallyBlank, + size + }; + }); +} +function inspectPng(contents) { + if (contents.length < 33 || !contents.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || contents.subarray(12, 16).toString("ascii") !== "IHDR") { + return { valid: false, visuallyBlank: false }; + } + const width = contents.readUInt32BE(16); + const height = contents.readUInt32BE(20); + if (width <= 0 || height <= 0 || width * height > 2e7) { + return { valid: false, visuallyBlank: false }; + } + try { + const decoded = PNG.sync.read(contents, { checkCRC: true }); + const spans = [ + { minimum: 255, maximum: 0 }, + { minimum: 255, maximum: 0 }, + { minimum: 255, maximum: 0 }, + { minimum: 255, maximum: 0 } + ]; + const pixelCount = decoded.width * decoded.height; + const sampleStep = Math.max(1, Math.floor(pixelCount / 1e4)); + for (let pixel = 0; pixel < pixelCount; pixel += sampleStep) { + const offset = pixel * 4; + for (let channel = 0; channel < 4; channel += 1) { + const value = decoded.data[offset + channel]; + spans[channel].minimum = Math.min(spans[channel].minimum, value); + spans[channel].maximum = Math.max(spans[channel].maximum, value); + } + } + return { + valid: true, + visuallyBlank: spans.every( + ({ minimum, maximum }) => maximum - minimum <= 3 + ) + }; + } catch { + return { valid: false, visuallyBlank: false }; + } +} +function buildVerdict(options, evidence) { + const missingArtifacts = []; + if (options.recordingWasActive && !fs18.existsSync(options.videoPath)) { + missingArtifacts.push(path15.basename(options.videoPath)); + } else if (options.recordingWasActive && (evidence.mediaDurationSec === null || evidence.mediaDurationSec <= 0)) { + missingArtifacts.push(path15.basename(options.videoPath)); + } + const screenshotFiles = new Set( + evidence.screenshots.map((screenshot) => screenshot.file) + ); + const successfulScreenshotPaths = options.actions.filter((action) => action.outcome === "passed").map((action) => action.action.match(/^screenshot\s+(.+)$/)?.[1]).filter((value) => Boolean(value)).map((value) => path15.basename(value)); + const reusedScreenshotPaths = successfulScreenshotPaths.length - new Set(successfulScreenshotPaths).size; + for (const action of options.actions) { + const match = action.action.match(/^screenshot\s+(.+)$/); + if (match && !screenshotFiles.has(path15.basename(match[1]))) { + missingArtifacts.push(path15.basename(match[1])); + } + } + for (const screenshot of evidence.screenshots) { + if (!screenshot.validPng || screenshot.visuallyBlank || screenshot.size === 0) { + missingArtifacts.push(screenshot.file); + } + } + const hashes = /* @__PURE__ */ new Map(); + for (const screenshot of evidence.screenshots) { + if (screenshot.sha256 && screenshot.validPng) { + const files = hashes.get(screenshot.sha256) || []; + files.push(screenshot.file); + hashes.set(screenshot.sha256, files); + } + } + const duplicateScreenshotHashes = [...hashes.values()].filter( + (files) => files.length > 1 + ); + const expectedSelectorFailures = options.actions.filter( + (action) => action.expectedSelector && action.outcome === "failed" + ).map((action) => action.expectedSelector); + const pendingExpectedSelectors = options.actions.filter( + (action) => action.expectedSelector && action.outcome === void 0 + ); + const fatalIncidentCount = evidence.incidents.filter( + (incident) => incident.severity === "fatal" + ).length; + const blockingReasons = options.consoleEvidenceAvailable ? [] : ["Browser console evidence was unavailable."]; + const failureReasons = [ + ...fatalIncidentCount > 0 ? [`${fatalIncidentCount} fatal incident(s) detected.`] : [], + ...expectedSelectorFailures.length > 0 ? [`${expectedSelectorFailures.length} expected selector assertion(s) failed.`] : [], + ...duplicateScreenshotHashes.length > 0 ? ["Duplicate key-frame screenshot hashes were detected."] : [] + ]; + const incompleteReasons = [ + ...missingArtifacts.length > 0 ? [`${missingArtifacts.length} required artifact(s) were missing or invalid.`] : [], + ...evidence.mediaTruncated ? ["Recorded media ends before the canonical action timeline."] : [], + ...evidence.sources.some((source) => source.truncationCount > 0) ? ["One or more evidence sources were truncated."] : [], + ...pendingExpectedSelectors.length > 0 ? [ + `${pendingExpectedSelectors.length} expected selector assertion(s) had no recorded outcome.` + ] : [], + ...reusedScreenshotPaths > 0 ? ["One or more screenshot paths were reused by multiple actions."] : [] + ]; + const status = blockingReasons.length > 0 ? "BLOCKED" : incompleteReasons.length > 0 ? "INCOMPLETE" : failureReasons.length > 0 ? "FAIL" : "PASS"; + return { + version: 1, + status, + reasons: [...blockingReasons, ...failureReasons, ...incompleteReasons], + fatalIncidentCount, + missingArtifacts: [...new Set(missingArtifacts)], + duplicateScreenshotHashes, + expectedSelectorFailures, + mediaTruncated: evidence.mediaTruncated + }; +} +function probeMediaDuration(videoPath) { + if (!fs18.existsSync(videoPath)) { + return null; + } + try { + const output = execFileSync4( + "ffprobe", + [ + "-v", + "error", + "-show_entries", + "format=start_time,duration", + "-of", + "json", + videoPath + ], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] } + ).trim(); + const parsed = JSON.parse(output); + const startTime = Number(parsed.format?.start_time || 0); + const duration = Number(parsed.format?.duration); + const playableDuration = duration - startTime; + return Number.isFinite(playableDuration) && playableDuration >= 0 ? playableDuration : null; + } catch { + return null; + } +} + +// src/utils/error-patterns.ts +var PATTERNS = [ + { + name: "JavaScript / Node.js", + patterns: [ + /\bError:/, + // TypeError: x is not a function + /\bERR[_!]/, + // npm ERR!, ERR_MODULE_NOT_FOUND + /\bEACCES\b|\bENOENT\b|\bEADDRINUSE\b/, + // System errors + /\bat\s+.+\(.+:\d+:\d+\)/, + // Stack trace: at fn (file.js:10:5) + /Unhandled.+rejection/i + // Unhandled promise rejection + ] + }, + { + name: "Python", + patterns: [ + /Traceback \(most recent call last\)/, + /^\s*File ".+", line \d+/, + // Stack trace line + /\w+Error:/, + // ValueError:, KeyError:, etc. + /\w+Exception:/ + // Django ImproperlyConfigured, etc. + ] + }, + { + name: "Ruby / Rails", + patterns: [ + /\w+Error \(.+\)/, + // ActionController::RoutingError (...) + /from .+:\d+:in `.+'/, + // Stack trace + /FATAL --/, + // Rails logger FATAL level + /Errno::\w+/ + // Errno::ENOENT + ] + }, + { + name: "Go", + patterns: [ + /^panic:/, + // Go panic + /^goroutine \d+/, + // Goroutine stack dump + /runtime error:/ + ] + }, + { + name: "Java / Kotlin", + patterns: [ + /Exception in thread/, + // Exception in thread "main" + /\w+Exception:/, + // NullPointerException: + /\bat\s+[\w.$]+\(.+:\d+\)/, + // at com.example.Main(Main.java:10) + /Caused by:/ + ] + }, + { + name: "Rust", + patterns: [ + /thread '.+' panicked at/, + // thread 'main' panicked at + /error\[E\d+\]/ + // Compiler error: error[E0308] + ] + }, + { + name: "PHP", + patterns: [ + /PHP\s+(Fatal|Parse|Warning)\s+error:/i, + /Stack trace:/, + /thrown in .+ on line \d+/ + ] + }, + { + name: "C# / .NET", + patterns: [ + /Unhandled exception/, + /\w+Exception:/, + /at .+ in .+:line \d+/ + // Stack trace + ] + }, + { + name: "Elixir / Phoenix", + patterns: [ + /\*\* \(\w+\)/, + // ** (EXIT), ** (RuntimeError) + /\(exit\) an exception was raised/ + ] + }, + { + name: "Generic", + patterns: [ + /\bFATAL\b/, + // Common log level + /\bCRITICAL\b/, + // Common log level + /\bSegmentation fault\b/, + /\bcore dumped\b/, + /\bout of memory\b/i + ] + } +]; +function extractServerErrors(log) { + if (!log.trim()) return []; + const allPatterns = PATTERNS.flatMap((lp) => lp.patterns); + return log.split("\n").filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return false; + return allPatterns.some((p) => p.test(trimmed)); + }); +} + +// src/commands/exec.ts +import * as fs19 from "fs"; +import * as path16 from "path"; +import { execSync as execSync4 } from "child_process"; +var SESSION_LOG_FILENAME = "session-log.json"; +var SESSION_LOG_LOCK_TIMEOUT_MS = 5e3; +var SESSION_LOG_STALE_LOCK_MS = 12e4; +function loadSessionLog(sessionDir) { + const logPath = path16.join(sessionDir, SESSION_LOG_FILENAME); + if (!fs19.existsSync(logPath)) return []; + try { + const parsed = JSON.parse(fs19.readFileSync(logPath, "utf-8")); + if (!Array.isArray(parsed)) { + throw new Error("session log root must be an array"); + } + return parsed; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`ProofShot session action log is corrupt: ${logPath} +${message}`); + } +} +function resolveScreenshotPath(args, sessionDir) { + if (args[0] !== "screenshot" || args.length < 2) return args; + const screenshotPath = args[args.length - 1]; + const resolved = path16.resolve(sessionDir, screenshotPath); + if (path16.dirname(resolved) !== path16.resolve(sessionDir)) { + throw new Error( + "ProofShot screenshots must use a filename directly inside the active session." + ); + } + return [...args.slice(0, -1), resolved]; +} +function buildShellCommand(args, sessionName) { + if (args[0] === "eval" && args.length > 1) { + const jsCode = args.slice(1).join(" "); + const escaped = jsCode.replace(/'/g, "'\\''"); + return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName }); + } + const quotedArgs = args.map((arg) => { + if (/[(){}[\]$`!#&|;<>*? "'\\]/.test(arg)) { + const escaped = arg.replace(/'/g, "'\\''"); + return `'${escaped}'`; + } + return arg; + }); + return buildAgentBrowserCommand(quotedArgs.join(" "), { session: sessionName }); +} +function translateProofShotExecArgs(args) { + if (args[0] === "assert-visible" && args.length > 1) { + return { + agentBrowserArgs: ["is", "visible", ...args.slice(1)], + expectedSelector: args.slice(1).join(" ") + }; + } + return { agentBrowserArgs: args }; +} +function parseElementRef(args) { + for (const arg of args) { + const match = arg.match(/@e\d+/); + if (match) return match[0]; + } + return null; +} +function captureElementData(ref, viewport, sessionName) { + try { + let bbox = null; + let label = ""; + let elemId = ""; + try { + elemId = ab(`get attr ${ref} id`, { session: sessionName }); + } catch { + } + if (elemId) { + try { + const raw = ab(`get box '#${elemId}'`, { session: sessionName }); + bbox = JSON.parse(raw); + } catch { + } + try { + const raw = ab( + `eval "document.getElementById('${elemId}')?.labels?.[0]?.textContent||document.getElementById('${elemId}')?.placeholder||document.getElementById('${elemId}')?.getAttribute('aria-label')||''"`, + { session: sessionName } + ); + label = JSON.parse(raw) || ""; + } catch { + } + } + if (!bbox) { + try { + label = ab(`get text ${ref}`, { session: sessionName }); + } catch { + } + if (!label) { + try { + label = ab(`get attr ${ref} placeholder`, { session: sessionName }); + } catch { + } + } + if (!label) { + try { + label = ab(`get attr ${ref} aria-label`, { session: sessionName }); + } catch { + } + } + if (!label) { + try { + label = ab(`get attr ${ref} name`, { session: sessionName }); + } catch { + } + } + if (label) { + try { + const escaped = label.replace(/'/g, "\\'"); + const raw = ab(`get box 'text=${escaped}'`, { session: sessionName }); + bbox = JSON.parse(raw); + } catch { + } + } + } + if (!bbox) return null; + return { + label: label || "", + bbox: { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }, + viewport + }; + } catch { + return null; + } +} +function isRefTargetedAction(args) { + const cmd = args[0]?.toLowerCase(); + return (cmd === "click" || cmd === "fill" || cmd === "type") && parseElementRef(args) !== null; +} +async function execCommand(args) { + const action = args.join(" "); + const translated = translateProofShotExecArgs(args); + let loggedEntry = null; + let sessionLogPath = null; + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + setAgentBrowserDefaults({ + configPath: session?.agentBrowserConfigPath || config.browser.configPath, + socketDir: session?.agentBrowserSocketDir + }); + if (session && !session.recordingActive) { + console.error( + 'Error: Session has no active recording. Video capture is required.\nRun "proofshot stop" to end this session, then start a new one.' + ); + process.exit(1); + } + if (session && !canAddressOwnedBrowserSession(session)) { + console.error( + "Error: Browser ownership no longer matches this ProofShot session.\nRefusing to address a possibly reused agent-browser session name." + ); + process.exit(1); + return; + } + let resolvedArgs = translated.agentBrowserArgs; + if (session) { + resolvedArgs = resolveScreenshotPath( + translated.agentBrowserArgs, + session.sessionDir + ); + } + let elementData; + if (session && isRefTargetedAction(args)) { + const ref = parseElementRef(args); + const viewport = session.viewport || { width: 1280, height: 720 }; + const captured = captureElementData(ref, viewport, session.sessionName); + if (captured) elementData = captured; + } + if (session) { + const now = /* @__PURE__ */ new Date(); + const startTime = new Date(session.startedAt).getTime(); + const relativeTimeSec = parseFloat(((now.getTime() - startTime) / 1e3).toFixed(1)); + const entry = { + action, + relativeTimeSec, + timestamp: now.toISOString(), + expectedSelector: translated.expectedSelector + }; + if (elementData) { + entry.element = elementData; + } + const logPath = path16.join(session.sessionDir, SESSION_LOG_FILENAME); + updateSessionLog(logPath, (entries) => { + entries.push(entry); + }); + loggedEntry = entry; + sessionLogPath = logPath; + } + const shellCmd = buildShellCommand(resolvedArgs, session?.sessionName); + try { + const result = execSync4(shellCmd, { + encoding: "utf-8", + timeout: 6e4, + stdio: ["pipe", "pipe", "pipe"], + env: getAgentBrowserEnvironment() + }); + if (translated.expectedSelector && result.trim().toLowerCase() !== "true") { + const assertionError = new Error( + `Expected selector to be visible: ${translated.expectedSelector}` + ); + assertionError.status = 1; + throw assertionError; + } + if (result.trim()) { + process.stdout.write(result); + if (!result.endsWith("\n")) { + process.stdout.write("\n"); + } + } + const pageUrl = session ? getPageUrl(session.sessionName) || void 0 : void 0; + persistActionOutcome(loggedEntry, sessionLogPath, "passed", void 0, pageUrl); + } catch (error) { + const stderr = error?.stderr?.toString?.() || ""; + const stdout = error?.stdout?.toString?.() || ""; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + if (!stdout && !stderr && error?.message) { + process.stderr.write(`${error.message} +`); + } + persistActionOutcome( + loggedEntry, + sessionLogPath, + "failed", + stderr.trim() || stdout.trim() || error?.message + ); + process.exit(error?.status || 1); + } + if (session && args[0] === "set" && args[1] === "viewport") { + try { + const vpJson = ab("eval 'JSON.stringify({width: window.innerWidth, height: window.innerHeight})'", { + session: session.sessionName + }); + const vp = JSON.parse(vpJson); + session.viewport = { width: vp.width, height: vp.height }; + saveSession(session, controlDir); + registerSession(session); + } catch { + } + } +} +function persistActionOutcome(entry, logPath, outcome, error, pageUrl) { + if (!entry || !logPath) { + return; + } + entry.outcome = outcome; + if (error) { + entry.error = error; + } + if (pageUrl) { + entry.pageUrl = pageUrl; + } + updateSessionLog(logPath, (entries) => { + const matchingEntry = [...entries].reverse().find( + (candidate) => candidate.timestamp === entry.timestamp && candidate.action === entry.action + ); + if (matchingEntry) { + matchingEntry.outcome = outcome; + if (error) { + matchingEntry.error = error; + } + if (pageUrl) { + matchingEntry.pageUrl = pageUrl; + } + } + }); +} +function updateSessionLog(logPath, update) { + const lockPath = `${logPath}.lock`; + const deadline = Date.now() + SESSION_LOG_LOCK_TIMEOUT_MS; + let lockFd = null; + while (lockFd === null) { + try { + lockFd = fs19.openSync(lockPath, "wx", 384); + } catch (error) { + if (error.code !== "EEXIST") throw error; + try { + if (Date.now() - fs19.statSync(lockPath).mtimeMs > SESSION_LOG_STALE_LOCK_MS) { + fs19.unlinkSync(lockPath); + continue; + } + } catch (statError) { + if (statError.code === "ENOENT") continue; + throw statError; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for session log lock: ${lockPath}`); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } + try { + const entries = loadSessionLog(path16.dirname(logPath)); + update(entries); + const temporaryPath = `${logPath}.${process.pid}.${Date.now()}.tmp`; + fs19.writeFileSync(temporaryPath, JSON.stringify(entries, null, 2) + "\n", { + mode: 384 + }); + fs19.renameSync(temporaryPath, logPath); + } finally { + fs19.closeSync(lockFd); + try { + fs19.unlinkSync(lockPath); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } +} + +// src/utils/token-usage.ts +import * as fs20 from "fs"; +import * as path17 from "path"; +import * as os5 from "os"; +function estimateTokenUsage(sessionDir, startTimeMs, endTimeMs) { + const claudeUsage = tryClaudeCodeLogs(startTimeMs, endTimeMs); + if (claudeUsage) return claudeUsage; + return estimateFromContent(sessionDir); +} +function tryClaudeCodeLogs(startTimeMs, endTimeMs) { + const claudeDir = path17.join(os5.homedir(), ".claude", "sessions"); + if (!fs20.existsSync(claudeDir)) return null; + try { + const files = fs20.readdirSync(claudeDir).filter((f) => f.endsWith(".json")); + for (const file of files) { + const data = JSON.parse(fs20.readFileSync(path17.join(claudeDir, file), "utf-8")); + const sessionStart = new Date(data.startedAt).getTime(); + if (sessionStart >= startTimeMs - 6e4 && sessionStart <= endTimeMs + 6e4) { + if (data.totalInputTokens != null || data.totalOutputTokens != null || data.usage) { + const inputTokens = data.totalInputTokens ?? data.usage?.inputTokens ?? 0; + const outputTokens = data.totalOutputTokens ?? data.usage?.outputTokens ?? 0; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + estimatedCost: 0, + model: data.model || "claude", + source: "claude-logs" + }; + } + } + } + } catch { + } + return null; +} +function estimateFromContent(sessionDir) { + const logPath = path17.join(sessionDir, "session-log.json"); + if (!fs20.existsSync(logPath)) return null; + try { + const entries = JSON.parse(fs20.readFileSync(logPath, "utf-8")); + if (!Array.isArray(entries) || entries.length === 0) return null; + const actionCount = entries.length; + const inputTokens = actionCount * 500; + const outputTokens = actionCount * 300; + const totalTokens = inputTokens + outputTokens; + const estimatedCost = (inputTokens * 3 + outputTokens * 15) / 1e6; + return { + inputTokens, + outputTokens, + totalTokens, + estimatedCost, + model: "estimated", + source: "estimated" + }; + } catch { + return null; + } +} +function formatTokenUsage(usage) { + const fmt = (n) => n.toLocaleString(); + let result = ""; + result += `- Input tokens: ~${fmt(usage.inputTokens)} +`; + result += `- Output tokens: ~${fmt(usage.outputTokens)} +`; + result += `- Total tokens: ~${fmt(usage.totalTokens)} +`; + if (usage.estimatedCost > 0) { + result += `- Estimated cost: ~$${usage.estimatedCost.toFixed(4)} +`; + } + if (usage.source === "estimated") { + result += `- Source: estimated from ${usage.model === "estimated" ? "session activity" : usage.model} +`; + } + return result; +} + +// src/commands/stop.ts +function parseTimestampedServerLog(raw, startTimeMs) { + if (!raw.trim()) return { entries: [], cleanText: "" }; + const lines = raw.split("\n").filter((l) => l.trim()); + const entries = []; + const cleanLines = []; + for (const line of lines) { + const tabIdx = line.indexOf(" "); + if (tabIdx > 0) { + const epochStr = line.slice(0, tabIdx); + const epochMs = parseInt(epochStr, 10); + if (!isNaN(epochMs) && epochMs > 1e12) { + const text = line.slice(tabIdx + 1); + entries.push({ + text, + relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1e3).toFixed(1))) + }); + cleanLines.push(text); + continue; + } + } + entries.push({ text: line, relativeTimeSec: -1 }); + cleanLines.push(line); + } + return { entries, cleanText: cleanLines.join("\n") }; +} +async function stopCommand(options) { + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + if (!session) { + console.log( + chalk3.dim("No active session found; all owned processes are already stopped.") + ); + return; + } + setAgentBrowserDefaults({ + configPath: session.agentBrowserConfigPath || config.browser.configPath, + socketDir: session.agentBrowserSocketDir + }); + if (session.bundleComplete) { + if (session.browserRetained && !options.noClose) { + console.log(chalk3.dim("Closing retained browser...")); + const browserSessionAddressable = canAddressOwnedBrowserSession(session); + await stopOwnedBrowser(session); + session.browserRetained = false; + clearOwnedSession2(session, controlDir); + if (browserSessionAddressable) { + console.log(chalk3.green("\u2713") + " Retained browser closed; proof artifacts were already bundled."); + } else { + console.log( + chalk3.yellow("\u26A0") + " Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup." + ); + } + } else if (session.browserRetained) { + console.log( + chalk3.dim("Proof artifacts are already bundled; the owned browser remains intentionally open.") + ); + } else { + clearOwnedSession2(session, controlDir); + console.log(chalk3.dim("Proof artifacts are already bundled and all owned processes are stopped.")); + } + return; + } + const stopSignals = installStopSignalHandlers(); + try { + session.lifecycleStatus = "stopping"; + session.cleanupError = null; + session.stoppedAt ||= (/* @__PURE__ */ new Date()).toISOString(); + persistOwnedSession2(session, controlDir); + const retryingStoppedSession = !session.recordingActive; + const recordingWasActive = session.recordingActive || Boolean(session.recordingStartedAt); + const startTime = new Date(session.startedAt).getTime(); + const recordingStartTime = session.recordingStartedAt ? new Date(session.recordingStartedAt).getTime() : startTime; + const recordingStartOffsetSec = Math.max( + 0, + (recordingStartTime - startTime) / 1e3 + ); + const durationMs = new Date(session.stoppedAt).getTime() - startTime; + const durationSec = Math.round(durationMs / 1e3); + const browserSessionAvailable = canAddressOwnedBrowserSession(session); + const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true; + if (!browserSessionAvailable && priorConsoleEvidenceAvailable) { + console.log( + chalk3.dim("Browser already stopped; reusing console evidence collected before cleanup.") + ); + } else if (!browserSessionAvailable) { + console.log( + chalk3.yellow("\u26A0") + " Browser ownership could not be verified; skipping console and recording commands.\n" + chalk3.dim(" Browser evidence may be incomplete; exact recorded-process cleanup will still run.") + ); + } + console.log(chalk3.dim("Collecting errors...")); + let consoleErrors = ""; + let consoleOutput = ""; + let consoleEntries = []; + const consoleErrorsPath = path18.join(session.sessionDir, "console-errors.log"); + const consoleOutputPath = path18.join(session.sessionDir, "console-output.log"); + const consoleEntriesPath = path18.join(session.sessionDir, "console-entries.json"); + let consoleCollectionSucceeded = false; + if (browserSessionAvailable) { + try { + consoleErrors = getConsoleErrors(session.sessionName); + consoleOutput = getConsoleOutput(session.sessionName); + const consoleMessages = getConsoleOutputJson(session.sessionName); + consoleEntries = consoleMessages.map((msg) => ({ + text: `[${msg.type}] ${msg.text}`, + relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1e3).toFixed(1))) + })); + consoleCollectionSucceeded = true; + } catch { + consoleCollectionSucceeded = false; + } + } + if (consoleCollectionSucceeded) { + writeTextFileAtomically(consoleErrorsPath, consoleErrors); + writeTextFileAtomically(consoleOutputPath, consoleOutput); + writeTextFileAtomically( + consoleEntriesPath, + JSON.stringify(consoleEntries, null, 2) + "\n" + ); + const capturedErrorLines = consoleErrors.split("\n").filter((line) => line.trim() && line.trim() !== "No errors"); + session.consoleEvidenceAvailable = true; + session.consoleErrorCount = capturedErrorLines.length > 0 && consoleErrors.trim() !== "" ? capturedErrorLines.length : 0; + persistOwnedSession2(session, controlDir); + } else if (priorConsoleEvidenceAvailable) { + if (fs21.existsSync(consoleErrorsPath)) { + consoleErrors = fs21.readFileSync(consoleErrorsPath, "utf-8"); + } + if (fs21.existsSync(consoleOutputPath)) { + consoleOutput = fs21.readFileSync(consoleOutputPath, "utf-8"); + } + if (fs21.existsSync(consoleEntriesPath)) { + try { + const savedEntries = JSON.parse(fs21.readFileSync(consoleEntriesPath, "utf-8")); + if (Array.isArray(savedEntries)) consoleEntries = savedEntries; + } catch { + } + } + } else { + session.consoleEvidenceAvailable = false; + session.consoleErrorCount = 0; + persistOwnedSession2(session, controlDir); + } + console.log(chalk3.dim("Stopping recording...")); + if (browserSessionAvailable) { + stopRecording(session.sessionName); + } + session.recordingActive = false; + persistOwnedSession2(session, controlDir); + const cleanupErrors = []; + if (!options.noClose) { + console.log(chalk3.dim("Closing browser...")); + try { + await stopOwnedBrowser(session); + } catch (error) { + cleanupErrors.push(error); + } + } + if (session.environment && !session.environmentStopped && session.environment.kind !== "launcher") { + const captures = session.environment.kind === "tmux" ? session.environment.captures : session.environment.processes; + session.environment.healthFailures = captures.filter((capture) => !processIdentityMatches(capture.process)).map((capture) => capture.sourceId); + persistOwnedSession2(session, controlDir); + } + const finalizedEnvironment = session.environment; + if (session.environment && !session.environmentStopped) { + console.log(chalk3.dim("Stopping environment...")); + try { + await stopOwnedEnvironment(session.environment); + session.environmentStopped = true; + persistOwnedSession2(session, controlDir); + } catch (error) { + cleanupErrors.push(error); + } + } + if (session.serverProcess) { + console.log(chalk3.dim("Stopping dev server...")); + try { + await stopOwnedServer(session); + } catch (error) { + cleanupErrors.push(error); + } + } + if (cleanupErrors.length > 0) { + const cleanupError = new AggregateError( + cleanupErrors, + `Cleanup failed: ${cleanupErrors.map((error) => error instanceof Error ? error.message : String(error)).join("; ")}` + ); + session.lifecycleStatus = "recovery"; + session.cleanupError = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + persistOwnedSession2(session, controlDir); + throw cleanupError; + } + let serverLog = ""; + let serverEntries = []; + if (fs21.existsSync(session.serverErrorLog)) { + const rawServerLog = fs21.readFileSync(session.serverErrorLog, "utf-8"); + const parsed = parseTimestampedServerLog(rawServerLog, startTime); + serverLog = parsed.cleanText; + serverEntries = parsed.entries; + } + const sessionDir = session.sessionDir; + const screenshots = fs21.existsSync(sessionDir) ? fs21.readdirSync(sessionDir).filter((f) => f.endsWith(".png")) : []; + const sessionLog = loadSessionLog(sessionDir); + let trimOffsetSec = session.trimOffsetSec ?? recordingStartOffsetSec; + if (!session.videoTrimComplete) { + let videoTrimOffsetSec = 0; + if (fs21.existsSync(session.videoPath)) { + videoTrimOffsetSec = trimVideo( + session.videoPath, + screenshots, + sessionDir, + startTime, + sessionLog, + recordingStartOffsetSec + ); + } else if (recordingWasActive) { + console.log( + chalk3.yellow("\u26A0") + " Recording was active but no video file was produced.\n" + chalk3.dim(" The screencast may have been interrupted. Screenshots and logs are still saved.") + ); + } + trimOffsetSec = recordingStartOffsetSec + videoTrimOffsetSec; + session.videoTrimComplete = true; + session.trimOffsetSec = trimOffsetSec; + persistOwnedSession2(session, controlDir); + } + const consoleErrorLines = consoleErrors.split("\n").filter((l) => l.trim() && l.trim() !== "No errors"); + const observedConsoleErrorCount = consoleErrorLines.length > 0 && consoleErrors.trim() !== "" ? consoleErrorLines.length : 0; + const consoleEvidenceAvailable = browserSessionAvailable || priorConsoleEvidenceAvailable; + const consoleErrorCount = browserSessionAvailable ? observedConsoleErrorCount : session.consoleErrorCount ?? 0; + if (browserSessionAvailable) { + session.consoleEvidenceAvailable = true; + session.consoleErrorCount = consoleErrorCount; + persistOwnedSession2(session, controlDir); + } + const serverErrorLines = extractServerErrors(serverLog); + const serverErrorCount = serverErrorLines.length; + const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now()); + const summaryPath = path18.join(sessionDir, "SUMMARY.md"); + const summary = generateProofSummary({ + projectDirectory: session.startDirectory || process.cwd(), + description: session.description, + serverCommand: session.serverCommand, + port: session.port, + headless: session.headless ?? config.headless ?? true, + viewport: session.viewport || config.viewport || { width: 1280, height: 720 }, + videoPath: session.videoPath, + screenshots, + consoleErrors, + consoleErrorCount, + consoleEvidenceAvailable, + serverLog, + serverErrorCount, + tokenUsage, + durationSec, + outputDir: sessionDir + }); + if (!retryingStoppedSession || !fs21.existsSync(summaryPath)) { + writeTextFileAtomically(summaryPath, summary); + } + let viewerEntries = sessionLog; + if (trimOffsetSec > 0 && !session.sessionLogAdjusted) { + viewerEntries = sessionLog.map((e) => ({ + ...e, + relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) + })); + } + if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) { + const logPath = path18.join(sessionDir, "session-log.json"); + writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + "\n"); + } + if (!session.sessionLogAdjusted) { + session.sessionLogAdjusted = true; + persistOwnedSession2(session, controlDir); + } + const adjustTime = (e) => trimOffsetSec > 0 ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) } : e; + const viewerConsoleEntries = consoleEntries.map(adjustTime); + const viewerServerEntries = serverEntries.map(adjustTime); + const canonicalDurationSec = Math.max(0, durationSec - trimOffsetSec); + const { evidence, verdict } = writeCanonicalEvidence({ + sessionId: session.sessionName, + sessionDir, + initialPageUrl: session.targetUrl, + durationSec: canonicalDurationSec, + timelineOffsetSec: trimOffsetSec, + videoPath: session.videoPath, + recordingWasActive, + consoleEvidenceAvailable, + actions: viewerEntries, + consoleEntries: viewerConsoleEntries, + serverEntries: viewerServerEntries, + environment: finalizedEnvironment + }); + const viewerPath = writeViewer(sessionDir, { + description: session.description, + serverCommand: session.serverCommand, + durationSec: canonicalDurationSec, + videoFilename: fs21.existsSync(session.videoPath) ? path18.basename(session.videoPath) : null, + consoleErrorCount, + consoleEvidenceAvailable, + serverErrorCount, + consoleOutput, + serverLog, + consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : void 0, + serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : void 0, + entries: viewerEntries.length > 0 ? viewerEntries : void 0, + tokenUsage, + evidence, + verdict + }); + const metadata = loadMetadata(sessionDir) || { + repository: "", + repositoryRoot: session.startDirectory, + branch: "", + commitSha: "", + treeHash: "", + sourceDirty: true, + startedAt: session.startedAt, + description: session.description + }; + writeArtifactManifest({ + sessionId: session.sessionName, + sessionDir, + metadata, + evidence, + verdict + }); + session.bundleComplete = true; + session.browserRetained = Boolean(options.noClose); + if (session.browserRetained) { + session.lifecycleStatus = "active"; + persistOwnedSession2(session, controlDir); + } else { + clearOwnedSession2(session, controlDir); + } + console.log(""); + console.log(chalk3.green.bold("\u2705 ProofShot verification complete")); + console.log(""); + if (fs21.existsSync(session.videoPath)) { + console.log(`\u{1F4F9} Video: ${chalk3.dim(session.videoPath)} (${durationSec}s)`); + } + console.log(`\u{1F4F8} Screenshots: ${screenshots.length} captured`); + console.log(`\u{1F4DD} Summary: ${chalk3.dim(summaryPath)}`); + console.log(`\u{1F9FE} Verdict: ${verdict.status}`); + if (viewerPath) { + console.log(`\u{1F3AC} Viewer: ${chalk3.dim(viewerPath)}`); + } else { + console.log(chalk3.dim('Tip: Use "proofshot exec" instead of "agent-browser" to get an interactive timeline viewer.')); + } + console.log(""); + console.log( + `Console errors: ${!consoleEvidenceAvailable ? chalk3.yellow("unavailable") : consoleErrorCount === 0 ? chalk3.green("0") : chalk3.red(String(consoleErrorCount))}` + ); + console.log( + `Server errors: ${serverErrorCount === 0 ? chalk3.green("0") : chalk3.red(String(serverErrorCount))}` + ); + console.log(`Duration: ${durationSec} seconds`); + console.log(""); + console.log(`Proof artifacts saved to ${chalk3.dim(sessionDir)}`); + if (session.browserRetained) { + console.log(chalk3.dim('Browser retained. Run "proofshot stop" later to close this exact session.')); + } + if (consoleErrorCount > 0) { + console.log(""); + console.log(chalk3.red.bold("Console Errors:")); + for (const line of consoleErrorLines.slice(0, 10)) { + console.log(chalk3.red(` ${line}`)); + } + if (consoleErrorLines.length > 10) { + console.log(chalk3.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`)); + } + } + if (serverErrorCount > 0) { + console.log(""); + console.log(chalk3.red.bold("Server Errors:")); + for (const line of serverErrorLines.slice(0, 10)) { + console.log(chalk3.red(` ${line}`)); + } + if (serverErrorLines.length > 10) { + console.log(chalk3.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`)); + } + } + } finally { + const interruptedBy = stopSignals.remove(); + if (interruptedBy) { + process.exitCode = interruptedBy === "SIGINT" ? 130 : 143; + } + } +} +function installStopSignalHandlers() { + let interruptedBy = null; + let signalCount = 0; + let forcedExitTimer = null; + const handlers = /* @__PURE__ */ new Map(); + const removeListeners = () => { + for (const [signal, handler] of handlers) { + process.removeListener(signal, handler); + } + }; + for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + signalCount += 1; + interruptedBy ||= signal; + if (signalCount >= 3) { + removeListeners(); + process.kill(process.pid, signal); + return; + } + if (signalCount === 2) { + console.error( + chalk3.yellow( + `Received ${signal} again; forcing exit in 5s if exact teardown does not finish.` + ) + ); + forcedExitTimer = setTimeout(() => { + removeListeners(); + process.kill(process.pid, signal); + }, 5e3); + return; + } + console.error( + chalk3.yellow(`Received ${signal}; finishing exact ProofShot teardown before exit.`) + ); + }; + handlers.set(signal, handler); + process.on(signal, handler); + } + return { + remove: () => { + removeListeners(); + if (forcedExitTimer) clearTimeout(forcedExitTimer); + return interruptedBy; + } + }; +} +function writeTextFileAtomically(filePath, contents) { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID4()}.tmp`; + try { + fs21.writeFileSync(temporaryPath, contents); + fs21.renameSync(temporaryPath, filePath); + } finally { + if (fs21.existsSync(temporaryPath)) fs21.unlinkSync(temporaryPath); + } +} +function persistOwnedSession2(session, controlDir) { + saveSession(session, controlDir); + registerSession(session); +} +function clearOwnedSession2(session, controlDir) { + clearSession(controlDir); + unregisterSession(session.sessionName); +} +function generateProofSummary(data) { + const date = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19); + const projectName = path18.basename(data.projectDirectory); + let md = `# ProofShot Verification Report + +**Date:** ${date} +**Project:** ${projectName} +**Dev Server:** ${data.serverCommand ? data.serverCommand : "external"} on localhost:${data.port} + +`; + if (data.description) { + md += `## What Was Verified + +${data.description} + +`; + } + const relativeVideo = path18.basename(data.videoPath); + md += `## Video Recording + +Full session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s) + +`; + if (data.screenshots.length > 0) { + md += `## Screenshots + +`; + for (const ss of data.screenshots) { + md += `![${ss}](./${ss}) + +`; + } + } + md += `## Console Errors + +`; + if (!data.consoleEvidenceAvailable) { + md += `Browser ownership could not be verified, so console evidence was unavailable. + +`; + } else if (data.consoleErrorCount === 0) { + md += `No console errors detected. + +`; + } else { + md += `${data.consoleErrorCount} error(s) detected: + +\`\`\` +${data.consoleErrors} +\`\`\` + +`; + } + md += `## Server Errors + +`; + if (data.serverErrorCount === 0) { + md += `No server errors detected. + +`; + } else { + md += `${data.serverErrorCount} error(s) detected: + +\`\`\` +${data.serverLog.slice(0, 5e3)} +\`\`\` + +`; + if (data.serverLog.length > 5e3) { + md += `_(truncated \u2014 see server.log for full output)_ + +`; + } + } + if (data.tokenUsage) { + md += `## Token Usage (Estimated) + +`; + md += formatTokenUsage(data.tokenUsage); + md += "\n"; + } + md += `## Environment +- Browser: Chromium (${data.headless ? "headless" : "headed"}) +- Viewport: ${data.viewport.width}x${data.viewport.height} +- Duration: ${data.durationSec} seconds +`; + return md; +} +function trimVideo(videoPath, screenshots, outputDir, sessionStartMs, sessionLog, mediaStartOffsetSec = 0) { + let firstActionSec = null; + let lastActionSec = null; + if (sessionLog.length > 0) { + firstActionSec = sessionLog[0].relativeTimeSec - mediaStartOffsetSec; + lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec - mediaStartOffsetSec; + } else if (screenshots.length > 0) { + const timestamps = screenshots.map((f) => { + try { + return fs21.statSync(path18.join(outputDir, f)).birthtimeMs; + } catch { + return null; + } + }).filter( + (timestamp) => timestamp !== null && timestamp >= sessionStartMs + mediaStartOffsetSec * 1e3 + ); + if (timestamps.length === 0) return 0; + firstActionSec = (Math.min(...timestamps) - sessionStartMs) / 1e3 - mediaStartOffsetSec; + lastActionSec = (Math.max(...timestamps) - sessionStartMs) / 1e3 - mediaStartOffsetSec; + } + if (firstActionSec === null || lastActionSec === null) return 0; + const BUFFER_BEFORE = 5; + const BUFFER_AFTER = 3; + const timelineTrimOffsetSec = Math.max(0, firstActionSec - BUFFER_BEFORE); + const trimEndSec = lastActionSec + BUFFER_AFTER; + const requestedDurationSec = trimEndSec - timelineTrimOffsetSec; + if (requestedDurationSec < 5) return 0; + try { + execFileSync5("ffmpeg", ["-version"], { stdio: "pipe" }); + } catch { + console.log(chalk3.dim("Tip: Install ffmpeg to auto-trim dead time from videos.")); + return 0; + } + const mediaDurationSec = probeMediaDuration(videoPath); + const actionDurationSec = Math.max(0, lastActionSec - firstActionSec); + const maximumPhysicalTrimSec = mediaDurationSec === null ? timelineTrimOffsetSec : Math.max(0, mediaDurationSec - actionDurationSec - BUFFER_BEFORE); + const physicalTrimStartSec = Math.min( + timelineTrimOffsetSec, + maximumPhysicalTrimSec + ); + const trimDurationSec = mediaDurationSec === null ? requestedDurationSec : Math.min( + requestedDurationSec, + mediaDurationSec - physicalTrimStartSec + ); + const dir = path18.dirname(videoPath); + const ext = path18.extname(videoPath); + const base = path18.basename(videoPath, ext); + const rawPath = path18.join(dir, `${base}-raw${ext}`); + try { + fs21.renameSync(videoPath, rawPath); + execFileSync5( + "ffmpeg", + [ + "-y", + "-ss", + physicalTrimStartSec.toFixed(2), + "-i", + rawPath, + "-t", + trimDurationSec.toFixed(2), + "-map", + "0:v:0", + "-c:v", + "libvpx-vp9", + "-deadline", + "realtime", + "-cpu-used", + "8", + "-crf", + "30", + "-b:v", + "0", + "-an", + "-avoid_negative_ts", + "make_zero", + "-abort_on", + "empty_output", + videoPath + ], + { stdio: "pipe", timeout: 6e4 } + ); + validateTrimmedVideo(videoPath); + fs21.unlinkSync(rawPath); + const trimmedDuration = Math.round(trimDurationSec); + console.log(chalk3.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`)); + return timelineTrimOffsetSec; + } catch { + if (fs21.existsSync(videoPath)) { + fs21.unlinkSync(videoPath); + } + if (fs21.existsSync(rawPath)) { + fs21.renameSync(rawPath, videoPath); + } + console.log(chalk3.dim("Video trimming failed, keeping original")); + return 0; + } +} +function validateTrimmedVideo(videoPath) { + if (!fs21.existsSync(videoPath) || fs21.statSync(videoPath).size === 0) { + throw new Error("FFmpeg produced an empty video"); + } + execFileSync5( + "ffmpeg", + ["-v", "error", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"], + { stdio: "pipe", timeout: 6e4 } + ); +} + +// src/commands/diff.ts +import * as fs22 from "fs"; +import * as path19 from "path"; +import chalk4 from "chalk"; +async function diffCommand(options) { + const config = loadConfig(); + const currentDir = path19.resolve(config.output); + const baselineDir = path19.resolve(options.baseline); + if (!fs22.existsSync(baselineDir)) { + console.error(chalk4.red("\u2717") + ` Baseline directory not found: ${baselineDir}`); + process.exit(1); + } + if (!fs22.existsSync(currentDir)) { + console.error( + chalk4.red("\u2717") + ` Current artifacts not found: ${currentDir} +` + chalk4.dim('Run "proofshot verify" first to generate screenshots.') + ); + process.exit(1); + } + const baselineFiles = fs22.readdirSync(baselineDir).filter((f) => f.startsWith("page-") && f.endsWith(".png")); + const currentFiles = fs22.readdirSync(currentDir).filter((f) => f.startsWith("page-") && f.endsWith(".png")); + if (baselineFiles.length === 0) { + console.error(chalk4.red("\u2717") + " No baseline screenshots found (looking for page-*.png)"); + process.exit(1); + } + const diffDir = path19.join(currentDir, "diffs"); + fs22.mkdirSync(diffDir, { recursive: true }); + console.log(chalk4.dim("Comparing screenshots...\n")); + let hasChanges = false; + for (const file of baselineFiles) { + const baselinePath = path19.join(baselineDir, file); + const currentPath = path19.join(currentDir, file); + const diffPath = path19.join(diffDir, `diff-${file}`); + if (!fs22.existsSync(currentPath)) { + console.log(chalk4.yellow("\u26A0") + ` ${file}: no matching current screenshot (page removed?)`); + continue; + } + const mismatch = diffScreenshots(baselinePath, currentPath, diffPath); + if (mismatch === null) { + console.log(chalk4.yellow("\u26A0") + ` ${file}: could not compare`); + } else if (mismatch === 0) { + console.log(chalk4.green("\u2713") + ` ${file}: identical`); + } else { + hasChanges = true; + console.log( + chalk4.red("\u2717") + ` ${file}: ${chalk4.bold(`${mismatch.toFixed(2)}%`)} changed \u2192 ${chalk4.dim(diffPath)}` + ); + } + } + for (const file of currentFiles) { + if (!baselineFiles.includes(file)) { + console.log(chalk4.cyan("+") + ` ${file}: new page (no baseline)`); + hasChanges = true; + } + } + console.log(""); + if (hasChanges) { + console.log(chalk4.yellow("Visual changes detected.") + ` Diff images saved to ${chalk4.dim(diffDir)}`); + } else { + console.log(chalk4.green("No visual changes detected.")); + } +} + +// src/commands/clean.ts +import * as fs23 from "fs"; +import * as path20 from "path"; +import chalk5 from "chalk"; +async function cleanCommand() { + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const outputDir = path20.resolve(config.output); + if (hasActiveSession(controlDir)) { + console.error( + chalk5.red("\u2717") + " Cannot clean while a ProofShot session owns browser or server processes.\n" + chalk5.dim('Run "proofshot stop" first so exact cleanup metadata is preserved.') + ); + process.exit(1); + return; + } + if (!fs23.existsSync(outputDir)) { + console.log(chalk5.dim("Nothing to clean \u2014 no artifacts directory found.")); + return; + } + fs23.rmSync(outputDir, { recursive: true, force: true }); + console.log(chalk5.green("\u2713") + ` Removed ${chalk5.dim(outputDir)}`); +} + +// src/commands/pr.ts +import * as fs26 from "fs"; +import * as path23 from "path"; +import { createHash as createHash4 } from "crypto"; +import chalk6 from "chalk"; + +// src/utils/github.ts +import * as fs24 from "fs"; +import * as path21 from "path"; +import { execFileSync as execFileSync6, execSync as execSync5 } from "child_process"; +var GITHUB_API_VERSION = "2022-11-28"; +var DEFAULT_ARTIFACTS_BRANCH = "proofshot-artifacts"; +var GitHubApiError = class extends ProofShotError { + constructor(status, body) { + super(`GitHub API request failed (${status}): ${body}`); + this.status = status; + this.name = "GitHubApiError"; + } +}; +function getGitHubToken() { + const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (envToken) return envToken.trim(); + try { + return execSync5("gh auth token", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch (error) { + throw new ProofShotError( + "GitHub CLI (gh) is not installed or not authenticated.\nInstall: https://cli.github.com\nThen run: gh auth login", + error + ); + } +} +async function getRepoInfo(token, repository) { + let nwo; + if (repository) { + nwo = repository.replace(/^https?:\/\/github\.com\//, "").replace(/^github\.com\//, "").replace(/\.git$/, ""); + } else { + try { + nwo = execSync5("gh repo view --json nameWithOwner -q .nameWithOwner", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch (error) { + throw new ProofShotError( + "Could not determine GitHub repository. Are you in a git repo with a GitHub remote?", + error + ); + } + } + const [owner, repo] = nwo.split("/"); + if (!owner || !repo || nwo.split("/").length !== 2) { + throw new ProofShotError( + `Could not parse GitHub repository: ${repository || nwo}` + ); + } + const repoResponse = await githubApi(`repos/${owner}/${repo}`, token); + return { + owner, + repo, + id: repoResponse.id, + defaultBranch: repoResponse.default_branch, + isPrivate: repoResponse.private + }; +} +function getPRNumber(explicitPR) { + if (explicitPR) { + if (!/^\d+$/.test(explicitPR)) { + throw new ProofShotError(`Invalid PR number: ${explicitPR}`); + } + const num = parseInt(explicitPR, 10); + try { + execSync5(`gh pr view ${num} --json number -q .number`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }); + } catch { + throw new ProofShotError(`PR #${num} not found or not accessible.`); + } + return num; + } + try { + const numStr = execSync5("gh pr view --json number -q .number", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + return parseInt(numStr, 10); + } catch { + throw new ProofShotError( + "No PR found for the current branch.\nEither specify a PR number: proofshot pr 42\nOr create a PR first: gh pr create" + ); + } +} +function getPRHeadProvenance(prNumber) { + try { + const raw = execFileSync6( + "gh", + [ + "pr", + "view", + String(prNumber), + "--json", + "headRefOid,headRefName,headRepository" + ], + { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"] + } + ); + const parsed = JSON.parse(raw); + return { + repository: `github.com/${parsed.headRepository.nameWithOwner}`, + branch: parsed.headRefName, + headSha: parsed.headRefOid + }; + } catch (error) { + throw new ProofShotError( + `Could not resolve the head provenance for PR #${prNumber}.`, + error + ); + } +} +function getContentType(filePath) { + const ext = path21.extname(filePath).toLowerCase(); + switch (ext) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".webm": + return "video/webm"; + case ".mp4": + return "video/mp4"; + default: + return "application/octet-stream"; + } +} +async function uploadPreparedAsset(assetToUpload, token, repoId) { + const fileName = assetToUpload.name; + const fileSize = assetToUpload.content.length; + const contentType = getContentType(fileName); + const policyResponse = await fetch("https://github.com/upload/policies/assets", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `token ${token}` + }, + body: JSON.stringify({ + name: fileName, + size: fileSize, + content_type: contentType, + repository_id: repoId + }) + }); + if (!policyResponse.ok) { + const body = await policyResponse.text(); + if ([401, 403, 422].includes(policyResponse.status)) { + throw new ProofShotError( + `GitHub web attachment upload failed (${policyResponse.status}). +ProofShot's "github-web-attachments" provider uses GitHub's internal /upload/policies/assets endpoint, which may reject browser-based gh OAuth auth. +Try one of: + - proofshot pr --upload-provider repo-contents + - export GH_TOKEN= and retry + - proofshot pr --dry-run +GitHub response: ${body}` + ); + } + throw new ProofShotError( + `GitHub upload policy request failed (${policyResponse.status}): ${body}` + ); + } + const policy = await policyResponse.json(); + const formData = new FormData(); + for (const [key, value] of Object.entries(policy.form)) { + formData.append(key, value); + } + const blob = new Blob([assetToUpload.content], { type: contentType }); + formData.append("file", blob, fileName); + const uploadResponse = await fetch(policy.upload_url, { + method: "POST", + body: formData + }); + if (!uploadResponse.ok && uploadResponse.status !== 204 && uploadResponse.status !== 201) { + throw new ProofShotError( + `File upload failed (${uploadResponse.status}): ${await uploadResponse.text()}` + ); + } + return { + url: policy.asset.href, + name: fileName + }; +} +async function uploadAssets(options) { + if (options.uploadProvider === "repo-contents") { + return uploadAssetsToRepoContents(options); + } + return uploadAssetsToWebAttachments(options); +} +async function uploadAssetsToWebAttachments(options) { + const results = /* @__PURE__ */ new Map(); + const assets = prepareUploadAssets(options); + const { token, repo, onProgress } = options; + for (let i = 0; i < assets.length; i += 1) { + const prepared = assets[i]; + onProgress?.(i + 1, assets.length, prepared.name); + try { + const asset = await uploadPreparedAsset(prepared, token, repo.id); + results.set(prepared.key, asset); + } catch (error) { + console.error(` Failed to upload ${prepared.name}: ${error.message}`); + } + } + return results; +} +async function uploadAssetsToRepoContents(options) { + const results = /* @__PURE__ */ new Map(); + const artifactsBranch = options.artifactsBranch || DEFAULT_ARTIFACTS_BRANCH; + const assets = prepareUploadAssets(options); + await ensureArtifactsBranch(options.repo, artifactsBranch, options.token); + for (let i = 0; i < assets.length; i += 1) { + const prepared = assets[i]; + const fileName = prepared.name; + options.onProgress?.(i + 1, assets.length, fileName); + try { + const content = prepared.content.toString("base64"); + const uploadPath = path21.posix.join( + options.uploadRoot, + prepared.relativeDirectory, + fileName + ); + let existingSha; + try { + const existing = await githubApi( + `repos/${options.repo.owner}/${options.repo.repo}/contents/${encodePath(uploadPath)}?ref=${encodeURIComponent(artifactsBranch)}`, + options.token + ); + existingSha = existing.sha; + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) { + throw error; + } + } + const result = await githubApi( + `repos/${options.repo.owner}/${options.repo.repo}/contents/${encodePath(uploadPath)}`, + options.token, + { + method: "PUT", + body: JSON.stringify({ + message: `proofshot: add ${uploadPath}`, + content, + branch: artifactsBranch, + ...existingSha ? { sha: existingSha } : {} + }) + } + ); + results.set(prepared.key, { + url: buildBlobUrl(options.repo, result.commit.sha, uploadPath), + name: fileName + }); + } catch (error) { + console.error(` Failed to upload ${fileName}: ${error.message}`); + } + } + return results; +} +function prepareUploadAssets(options) { + if (options.preparedAssets) { + return options.preparedAssets; + } + return (options.filePaths || []).map((filePath) => ({ + key: filePath, + name: path21.basename(filePath), + relativeDirectory: path21.basename(path21.dirname(filePath)), + content: fs24.readFileSync(filePath) + })); +} +async function ensureArtifactsBranch(repo, branch, token) { + try { + await githubApi( + `repos/${repo.owner}/${repo.repo}/git/ref/heads/${encodeURIComponent(branch)}`, + token + ); + return; + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) throw error; + } + const baseRef = await githubApi( + `repos/${repo.owner}/${repo.repo}/git/ref/heads/${encodeURIComponent(repo.defaultBranch)}`, + token + ); + await githubApi(`repos/${repo.owner}/${repo.repo}/git/refs`, token, { + method: "POST", + body: JSON.stringify({ + ref: `refs/heads/${branch}`, + sha: baseRef.object.sha + }) + }); +} +function buildBlobUrl(repo, branch, filePath) { + const encodedBranch = encodeURIComponent(branch); + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + return `https://github.com/${repo.owner}/${repo.repo}/blob/${encodedBranch}/${encodedPath}?raw=1`; +} +function encodePath(filePath) { + return filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); +} +async function githubApi(apiPath, token, init = {}) { + const response = await fetch(`https://api.github.com/${apiPath}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": GITHUB_API_VERSION, + ...init.body ? { "Content-Type": "application/json" } : {}, + ...init.headers || {} + } + }); + if (!response.ok) { + const body = await response.text(); + throw new GitHubApiError(response.status, body); + } + if (response.status === 204) { + return void 0; + } + return await response.json(); +} +function postPRComment(prNumber, body) { + try { + execSync5(`gh pr comment ${prNumber} --body-file -`, { + input: body, + encoding: "utf-8", + timeout: 12e4, + stdio: ["pipe", "pipe", "pipe"] + }); + } catch (error) { + const stderr = error?.stderr?.toString?.() || ""; + throw new ProofShotError(`Failed to post PR comment: ${stderr}`, error); + } +} + +// src/artifacts/pr-format.ts +function formatPRComment(data) { + let md = `## ProofShot Verification + +`; + if (data.description) { + md += `> ${data.description} + +`; + } + const status = (() => { + switch (data.verdict) { + case "PASS": + return "\u2705 Verification passed"; + case "FAIL": + return "\u274C Verification failed"; + case "INCOMPLETE": + return "\u26A0\uFE0F Verification incomplete"; + case "BLOCKED": + return "\u26D4 Verification blocked"; + default: { + const exhaustiveVerdict = data.verdict; + return exhaustiveVerdict; + } + } + })(); + md += `${status}`; + if (data.errorCount > 0) { + md += ` \xB7 ${data.errorCount} incident(s)`; + } + md += "\n\n"; + if (data.verdictReasons.length > 0) { + md += data.verdictReasons.map((reason) => `- ${reason}`).join("\n"); + md += "\n\n"; + } + const recordings = data.recordings || (data.video ? [{ label: null, ...data.video }] : []); + if (recordings.length > 0) { + md += `### Recording${recordings.length === 1 ? "" : "s"} + +`; + for (const recording of recordings) { + if (recordings.length > 1 && recording.label) { + md += `**${recording.label}** + +`; + } + if (recording.renderMode === "embed") { + md += `${recording.url} + +`; + } else { + const label = recording.label ? `Session recording: ${recording.label}` : "Session recording"; + md += `[${label}](${recording.url}) + +`; + } + } + } + if (data.screenshots.size > 0) { + md += `### Screenshots + +`; + if (data.screenshots.size <= 3) { + for (const [filename, url] of data.screenshots) { + const label = filename.replace(/\.png$/, "").replace(/^step-/, ""); + md += `**${label}** + +`; + md += `![${label}](${url}) + +`; + } + } else { + md += `
+View ${data.screenshots.size} screenshots + +`; + for (const [filename, url] of data.screenshots) { + const label = filename.replace(/\.png$/, "").replace(/^step-/, ""); + md += `**${label}** + +![${label}](${url}) + +`; + } + md += `
+ +`; + } + } + md += `--- +`; + md += ``; + md += `Branch: \`${data.branch}\``; + if (data.commitSha) { + md += ` \xB7 Commit: \`${data.commitSha.slice(0, 7)}\``; + } + md += ` \xB7 ${data.sessionCount} session(s)`; + md += ` +`; + md += `Generated by [ProofShot](https://github.com/AmElmo/proofshot) +`; + return md; +} + +// src/session/publication.ts +import * as fs25 from "fs"; +import * as path22 from "path"; +function matchesScreenshotSelector(artifact, selector, sessionId) { + return artifact.id === selector || artifact.path === selector || path22.basename(artifact.path) === selector || `${sessionId}/${artifact.id}` === selector || `${sessionId}/${artifact.path}` === selector || `${sessionId}/${path22.basename(artifact.path)}` === selector; +} +function selectPublications(options) { + const sessionIds = options.sessionIds || []; + if (new Set(sessionIds).size !== sessionIds.length) { + throw new Error("A finalized session was selected more than once."); + } + const selections = sessionIds.length > 0 ? sessionIds.map( + (sessionId) => selectPublication({ + ...options, + sessionId, + screenshotIds: void 0 + }) + ) : [ + selectPublication({ + ...options, + sessionId: void 0, + screenshotIds: void 0 + }) + ]; + if (!options.screenshotIds?.length) { + return selections; + } + const selectedBySession = /* @__PURE__ */ new Map(); + for (const selector of options.screenshotIds) { + const matches = selections.flatMap( + (selection) => selection.screenshots.filter( + (artifact) => matchesScreenshotSelector( + artifact, + selector, + selection.manifest.sessionId + ) + ).map((artifact) => ({ artifact, selection })) + ); + if (matches.length !== 1) { + throw new Error( + matches.length === 0 ? `Screenshot artifact not found: ${selector}` : `Screenshot selector is ambiguous across selected sessions: ${selector}` + ); + } + const match = matches[0]; + if (!match) { + throw new Error(`Screenshot artifact not found: ${selector}`); + } + const selected = selectedBySession.get(match.selection.sessionDir) || []; + if (selected.some((artifact) => artifact.id === match.artifact.id)) { + throw new Error(`Screenshot selected more than once: ${selector}`); + } + selected.push(match.artifact); + selectedBySession.set(match.selection.sessionDir, selected); + } + return selections.map((selection) => ({ + ...selection, + screenshots: selectedBySession.get(selection.sessionDir) || [] + })); +} +function selectPublication(options) { + const sessions = discoverFinalizedSessions(options.outputDir); + let candidates; + if (options.sessionId) { + candidates = sessions.filter( + ({ sessionDir, manifest }) => manifest.sessionId === options.sessionId || path22.basename(sessionDir) === options.sessionId + ); + if (candidates.length === 0) { + throw new Error( + `Finalized ProofShot session not found: ${options.sessionId}` + ); + } + } else { + candidates = sessions.filter( + ({ manifest }) => isCompatibleManifest(manifest, options) + ); + if (candidates.length !== 1) { + const choices = candidates.map( + ({ manifest }) => `${manifest.sessionId} (${manifest.verdict}, ${manifest.commitSha.slice(0, 7)})` + ); + throw new Error( + candidates.length === 0 ? "No complete finalized session matches the target PR head. Use --session to inspect an explicit choice." : `Multiple complete sessions match the target PR head. Choose one with --session: +${choices.join("\n")}` + ); + } + } + if (candidates.length > 1) { + throw new Error( + `Session ID is ambiguous: ${options.sessionId}. Use the exact session folder name.` + ); + } + const selected = candidates[0]; + assertCompatibleManifest(selected.manifest, options); + validateManifestArtifacts(selected.sessionDir, selected.manifest); + assertRequiredManifestArtifacts(selected.manifest); + const allScreenshots = selected.manifest.artifacts.filter( + (artifact) => artifact.kind === "screenshot" + ); + const screenshots = options.screenshotIds?.length ? selectScreenshots(allScreenshots, options.screenshotIds) : allScreenshots; + const videos = selected.manifest.artifacts.filter( + (artifact) => artifact.kind === "video" + ); + if (videos.length > 1) { + throw new Error("Finalized session contains multiple video artifacts."); + } + return { + ...selected, + screenshots, + video: videos[0] || null + }; +} +function discoverFinalizedSessions(outputDir) { + if (!fs25.existsSync(outputDir)) { + return []; + } + return fs25.readdirSync(outputDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()).map((entry) => path22.join(outputDir, entry.name)).map((sessionDir) => ({ + sessionDir, + manifest: loadArtifactManifest(sessionDir) + })).filter( + (entry) => entry.manifest !== null + ).sort( + (left, right) => right.manifest.finalizedAt.localeCompare(left.manifest.finalizedAt) + ); +} +function isCompatibleManifest(manifest, target) { + return manifest.completion === "complete" && manifest.verdict !== "INCOMPLETE" && manifest.verdict !== "BLOCKED" && !manifest.sourceDirty && !manifest.sourceDrift && manifest.repository === target.repository && manifest.branch === target.branch && manifest.commitSha === target.headSha; +} +function assertCompatibleManifest(manifest, target) { + const reasons = []; + if (manifest.completion !== "complete") reasons.push("session is incomplete"); + if (manifest.verdict === "INCOMPLETE" || manifest.verdict === "BLOCKED") { + reasons.push(`verdict is ${manifest.verdict}`); + } + if (manifest.sourceDirty || manifest.sourceDrift) { + reasons.push("source drift was detected"); + } + if (manifest.repository !== target.repository) { + reasons.push("repository does not match"); + } + if (manifest.branch !== target.branch) reasons.push("branch does not match"); + if (manifest.commitSha !== target.headSha) { + reasons.push("commit does not match the target PR head"); + } + if (reasons.length > 0) { + throw new Error( + `Session ${manifest.sessionId} cannot be published: ${reasons.join("; ")}.` + ); + } +} +function assertRequiredManifestArtifacts(manifest) { + for (const kind of ["evidence", "verdict"]) { + if (!manifest.artifacts.some((artifact) => artifact.kind === kind)) { + throw new Error( + `Finalized session is missing its ${kind} artifact record.` + ); + } + } +} +function selectScreenshots(screenshots, requested) { + const selected = []; + for (const selector of requested) { + const matches = screenshots.filter( + (artifact) => artifact.id === selector || artifact.path === selector || path22.basename(artifact.path) === selector + ); + if (matches.length !== 1) { + throw new Error( + matches.length === 0 ? `Screenshot artifact not found: ${selector}` : `Screenshot selector is ambiguous: ${selector}` + ); + } + if (selected.some((artifact) => artifact.id === matches[0].id)) { + throw new Error(`Screenshot selected more than once: ${selector}`); + } + selected.push(matches[0]); + } + return selected; +} + +// src/commands/pr.ts +async function prCommand(options) { + const config = loadConfig(); + const outputDir = path23.resolve(config.output); + const uploadProvider = normalizeUploadProvider(options.uploadProvider); + const artifactsBranch = options.artifactsBranch || "proofshot-artifacts"; + const local = captureGitProvenance(); + if (!local.repository || !local.branch || !local.commitSha) { + throw new Error( + "ProofShot could not determine the current repository, branch, and commit." + ); + } + const prNumber = options.dryRun && !options.prNumber ? null : getPRNumber(options.prNumber); + const target = prNumber ? getPRHeadProvenance(prNumber) : { + repository: local.repository, + branch: local.branch, + headSha: local.commitSha + }; + console.log( + chalk6.dim( + `Target: ${target.repository} ${target.branch}@${target.headSha.slice(0, 7)}` + ) + ); + let selections; + try { + selections = selectPublications({ + outputDir, + sessionIds: options.session, + screenshotIds: options.screenshot, + ...target + }); + } catch (error) { + if (!options.legacySession) { + throw error; + } + if (options.session?.length !== 1) { + throw new Error( + "Legacy publication requires exactly one explicit --session." + ); + } + selections = [ + selectLegacyPublication({ + outputDir, + sessionId: options.session[0], + screenshotIds: options.screenshot, + ...target + }) + ]; + console.log( + chalk6.yellow( + "\u26A0 Publishing an explicitly selected legacy session without a finalized provenance manifest." + ) + ); + } + const descriptions = selections.map((selection) => loadMetadata(selection.sessionDir)?.description).filter( + (description2) => typeof description2 === "string" && description2.length > 0 + ); + const description = descriptions.length > 0 ? [...new Set(descriptions)].join(" \xB7 ") : null; + const screenshotCandidates = selections.flatMap( + (selection) => selection.screenshots.map((artifact) => ({ + artifact, + filePath: path23.join(selection.sessionDir, artifact.path), + label: `${selection.manifest.sessionId}/${path23.basename(artifact.path)}`, + sessionId: selection.manifest.sessionId + })) + ); + const screenshots = options.screenshot?.length ? options.screenshot.map((selector) => { + const match = screenshotCandidates.find( + (candidate) => matchesScreenshotSelector( + candidate.artifact, + selector, + candidate.sessionId + ) + ); + if (!match) { + throw new Error(`Selected screenshot ordering failed: ${selector}`); + } + return match; + }) : screenshotCandidates; + const recordings = selections.flatMap( + (selection) => selection.video ? [ + { + filePath: path23.join(selection.sessionDir, selection.video.path), + label: selection.manifest.sessionId + } + ] : [] + ); + const errorCount = selections.reduce( + (total, selection) => total + readIncidentCount(selection.sessionDir, selection.manifest), + 0 + ); + const verdict = combineVerdicts(selections); + const preparedAssets = prepareSelectedAssets(selections); + if (preparedAssets.length === 0) { + throw new Error("The selected session has no publishable screenshots or video."); + } + if (options.dryRun) { + const screenshotMap2 = /* @__PURE__ */ new Map(); + for (const screenshot of screenshots) { + screenshotMap2.set( + screenshot.label, + `https://github.com/user-attachments/assets/<${screenshot.label}>` + ); + } + const commentData2 = { + description, + sessionCount: selections.length, + screenshots: screenshotMap2, + video: null, + recordings: recordings.map((recording) => ({ + label: recording.label, + url: `https://github.com/user-attachments/assets/<${recording.label}>`, + renderMode: "embed" + })), + errorCount, + verdict: verdict.status, + verdictReasons: verdict.reasons, + branch: selections[0]?.manifest.branch || target.branch, + commitSha: selections[0]?.manifest.commitSha || target.headSha + }; + console.log(""); + console.log(chalk6.yellow("--- Dry run (not posted) ---")); + console.log(formatPRComment(commentData2)); + return; + } + if (prNumber === null) { + throw new Error("A target PR is required for publication."); + } + console.log(chalk6.dim(`Target PR: #${prNumber}`)); + const token = getGitHubToken(); + const repoInfo = await getRepoInfo(token, target.repository); + assertTargetUnchanged(prNumber, target); + const uploadRoot = buildUploadRoot( + prNumber, + selections.map((selection) => selection.manifest) + ); + console.log(chalk6.dim(`Upload provider: ${uploadProvider}`)); + if (uploadProvider === "repo-contents") { + console.log(chalk6.dim(`Artifacts branch: ${artifactsBranch}`)); + } + console.log(chalk6.dim(`Uploading ${preparedAssets.length} artifact(s)...`)); + const uploaded = await uploadAssets({ + preparedAssets, + token, + repo: repoInfo, + uploadProvider, + uploadRoot, + artifactsBranch, + onProgress: (current, total, fileName) => { + console.log(chalk6.dim(` [${current}/${total}] ${fileName}`)); + } + }); + if (uploaded.size !== preparedAssets.length) { + throw new Error( + `Only ${uploaded.size}/${preparedAssets.length} artifacts uploaded. PR comment was not posted.` + ); + } + const screenshotMap = /* @__PURE__ */ new Map(); + for (const screenshot of screenshots) { + const asset = uploaded.get(screenshot.filePath); + if (!asset) { + throw new Error(`Missing uploaded screenshot: ${screenshot.filePath}`); + } + screenshotMap.set(screenshot.label, asset.url); + } + const uploadedRecordings = recordings.map((recording) => { + const videoAsset = uploaded.get(recording.filePath); + if (!videoAsset) { + throw new Error(`Missing uploaded video: ${recording.filePath}`); + } + return { + label: recording.label, + url: videoAsset.url, + renderMode: uploadProvider === "repo-contents" ? "link" : "embed" + }; + }); + const commentData = { + description, + sessionCount: selections.length, + screenshots: screenshotMap, + video: null, + recordings: uploadedRecordings, + errorCount, + verdict: verdict.status, + verdictReasons: verdict.reasons, + branch: selections[0]?.manifest.branch || target.branch, + commitSha: selections[0]?.manifest.commitSha || target.headSha + }; + const commentBody = formatPRComment(commentData); + assertTargetUnchanged(prNumber, target); + console.log(chalk6.dim("Posting PR comment...")); + postPRComment(prNumber, commentBody); + console.log(""); + console.log(chalk6.green.bold(`\u2705 Posted ProofShot verification to PR #${prNumber}`)); + console.log( + chalk6.dim( + ` ${screenshotMap.size} screenshot(s), ${uploadedRecordings.length} video(s)` + ) + ); +} +function assertTargetUnchanged(prNumber, expected) { + const current = getPRHeadProvenance(prNumber); + if (current.repository !== expected.repository || current.branch !== expected.branch || current.headSha !== expected.headSha) { + throw new Error( + "The target PR head changed during publication; no PR comment was posted." + ); + } +} +function prepareSelectedAssets(selections) { + return selections.flatMap((selection) => { + const artifacts = [ + ...selection.screenshots, + ...selection.video ? [selection.video] : [] + ]; + return artifacts.map((artifact) => { + const filePath = path23.join(selection.sessionDir, artifact.path); + const stat = fs26.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Selected artifact is not a regular file: ${artifact.path}`); + } + const content = fs26.readFileSync(filePath); + const hash = createHash4("sha256").update(content).digest("hex"); + if (hash !== artifact.sha256 || content.length !== artifact.size) { + throw new Error(`Selected artifact changed after validation: ${artifact.path}`); + } + return { + key: filePath, + name: path23.basename(artifact.path), + relativeDirectory: path23.basename(selection.sessionDir), + content + }; + }); + }); +} +function buildUploadRoot(prNumber, manifests) { + const sessionId = manifests.length === 1 ? manifests[0]?.sessionId || "session" : `${manifests.length}-sessions`; + const safeSessionId = sessionId.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "session"; + const manifestHash = createHash4("sha256").update(JSON.stringify(manifests)).digest("hex").slice(0, 12); + return path23.posix.join( + "proofshot", + `pr-${prNumber}`, + safeSessionId, + manifestHash + ); +} +function combineVerdicts(selections) { + const summaries = selections.map((selection) => ({ + sessionId: selection.manifest.sessionId, + ...readVerdictSummary(selection.sessionDir, selection.manifest) + })); + const verdictPriority = [ + "BLOCKED", + "INCOMPLETE", + "FAIL", + "PASS" + ]; + const status = verdictPriority.find( + (candidate) => summaries.some((summary) => summary.status === candidate) + ); + if (!status) { + throw new Error("No finalized publication verdicts were selected."); + } + return { + status, + reasons: summaries.flatMap( + (summary) => summary.reasons.map((reason) => `[${summary.sessionId}] ${reason}`) + ) + }; +} +function readIncidentCount(sessionDir, manifest) { + const evidenceArtifact = manifest.artifacts.find( + (artifact) => artifact.kind === "evidence" + ); + if (!evidenceArtifact) return 0; + try { + const contents = fs26.readFileSync(path23.join(sessionDir, evidenceArtifact.path)); + if (contents.length !== evidenceArtifact.size || createHash4("sha256").update(contents).digest("hex") !== evidenceArtifact.sha256) { + throw new Error("Evidence artifact changed after publication selection."); + } + const evidence = JSON.parse(contents.toString("utf-8")); + return (evidence.incidents || []).reduce( + (total, incident) => total + (incident.count || 0), + 0 + ); + } catch (error) { + throw new Error( + `Could not read finalized evidence: ${error instanceof Error ? error.message : String(error)}` + ); + } +} +function readVerdictSummary(sessionDir, manifest) { + const verdictArtifact = manifest.artifacts.find( + (artifact) => artifact.kind === "verdict" + ); + if (!verdictArtifact) { + return { status: manifest.verdict, reasons: [] }; + } + const contents = fs26.readFileSync( + path23.join(sessionDir, verdictArtifact.path) + ); + if (contents.length !== verdictArtifact.size || createHash4("sha256").update(contents).digest("hex") !== verdictArtifact.sha256) { + throw new Error("Verdict artifact changed after publication selection."); + } + const parsed = JSON.parse(contents.toString("utf-8")); + if (parsed.status !== manifest.verdict) { + throw new Error("Verdict artifact does not match the finalized manifest."); + } + const reasons = Array.isArray(parsed.reasons) ? parsed.reasons.filter( + (reason) => typeof reason === "string" + ) : []; + return { status: manifest.verdict, reasons }; +} +function selectLegacyPublication(options) { + if (!options.sessionId || options.sessionId === "." || options.sessionId === ".." || path23.basename(options.sessionId) !== options.sessionId) { + throw new Error( + "Legacy publication requires an exact --session folder name." + ); + } + const sessionDir = path23.join(options.outputDir, options.sessionId); + const outputRoot = fs26.realpathSync(options.outputDir); + const sessionRoot = fs26.realpathSync(sessionDir); + if (path23.dirname(sessionRoot) !== outputRoot) { + throw new Error("Legacy session must be a direct child of the output directory."); + } + const stat = fs26.lstatSync(sessionDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error("Legacy session is not a safe directory."); + } + const manifestPath = path23.join(sessionDir, "artifact-manifest.json"); + try { + fs26.lstatSync(manifestPath); + throw new Error( + "A finalized manifest entry exists; --legacy-session cannot bypass its validation." + ); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const metadata = loadMetadata(sessionDir); + if (!metadata || metadata.branch !== options.branch || metadata.commitSha !== options.headSha) { + throw new Error( + "Legacy session branch and commit must match the target PR head." + ); + } + const artifacts = fs26.readdirSync(sessionDir, { withFileTypes: true }).filter( + (entry) => entry.isFile() && !entry.isSymbolicLink() && (entry.name.endsWith(".png") || entry.name === "session.webm" || entry.name === "session.mp4") + ).map((entry, order) => { + const contents = fs26.readFileSync(path23.join(sessionDir, entry.name)); + return { + id: `${entry.name.endsWith(".png") ? "screenshot" : "video"}:${entry.name}`, + kind: entry.name.endsWith(".png") ? "screenshot" : "video", + path: entry.name, + sha256: createHash4("sha256").update(contents).digest("hex"), + size: contents.length, + order + }; + }); + const screenshots = artifacts.filter( + (artifact) => artifact.kind === "screenshot" + ); + const requestedScreenshots = options.screenshotIds?.length ? options.screenshotIds.map((selector) => { + const matches = screenshots.filter( + (artifact) => artifact.id === selector || artifact.path === selector || path23.basename(artifact.path) === selector + ); + if (matches.length !== 1) { + throw new Error(`Legacy screenshot selection failed: ${selector}`); + } + return matches[0]; + }) : screenshots; + const videos = artifacts.filter((artifact) => artifact.kind === "video"); + if (videos.length > 1) { + throw new Error("Legacy session contains multiple videos."); + } + const manifest = { + version: 1, + sessionId: options.sessionId, + repository: options.repository, + branch: metadata.branch, + commitSha: metadata.commitSha, + treeHash: metadata.treeHash || "", + sourceDirty: true, + sourceDrift: true, + startedAt: metadata.startedAt, + finalizedAt: metadata.startedAt, + completion: "complete", + verdict: "BLOCKED", + artifacts + }; + return { + sessionDir, + manifest, + screenshots: requestedScreenshots, + video: videos[0] || null + }; +} +function normalizeUploadProvider(provider) { + if (!provider || provider === "repo-contents") { + return "repo-contents"; + } + if (provider === "github-web-attachments") { + return "github-web-attachments"; + } + console.error( + chalk6.red("\u2717") + ` Invalid upload provider "${provider}". Use "repo-contents" or "github-web-attachments".` + ); + process.exit(1); +} + +// src/commands/doctor.ts +import chalk7 from "chalk"; + +// src/version.ts +var PROOFSHOT_VERSION = true ? "1.6.0" : readPackageVersion(); + +// src/commands/doctor.ts +function statusLabel(ok, text) { + return ok ? `${chalk7.green("\u2713")} ${text}` : `${chalk7.yellow("\u26A0")} ${text}`; +} +function printLine(label, value) { + console.log(`${label.padEnd(14)} ${value}`); +} +async function doctorCommand() { + const configPath = findConfigPath(); + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + const registeredSessions = listRegisteredSessions(); + const agentBrowserPath = findExecutablePath("agent-browser"); + const ffmpegPath = findExecutablePath("ffmpeg"); + const agentBrowserVersion = readCommandVersion("agent-browser"); + const ffmpegVersion = readCommandVersion("ffmpeg"); + console.log(chalk7.bold("ProofShot Doctor")); + console.log(""); + printLine("ProofShot", PROOFSHOT_VERSION); + printLine("Config", configPath || chalk7.dim("not found")); + printLine("Output", config.output); + printLine("Control state", controlDir); + printLine("Browser mode", config.headless ? "headless" : "headed"); + printLine("Viewport", `${config.viewport.width}x${config.viewport.height}`); + console.log(""); + console.log(statusLabel(Boolean(agentBrowserPath), "agent-browser")); + printLine("Path", agentBrowserPath || chalk7.dim("not found")); + printLine("Version", agentBrowserVersion || chalk7.dim("not available")); + console.log(""); + console.log(statusLabel(Boolean(ffmpegPath), "ffmpeg")); + printLine("Path", ffmpegPath || chalk7.dim("not found")); + printLine("Version", ffmpegVersion || chalk7.dim("not available")); + console.log(""); + console.log(statusLabel(Boolean(session), "active session")); + printLine("Sessions", String(registeredSessions.length)); + if (session) { + printLine("Session dir", session.sessionDir); + printLine("Recording", session.recordingActive ? "active" : "stopped"); + printLine("Port", String(session.port)); + if (session.targetUrl) printLine("Target", session.targetUrl); + } else { + printLine("Session dir", chalk7.dim("none")); + } +} + +// src/commands/session.ts +import chalk8 from "chalk"; +async function sessionListCommand(options) { + const entries = listRegisteredSessions().map(buildSessionListEntry); + if (options.json) { + console.log(JSON.stringify({ sessions: entries }, null, 2)); + return; + } + if (entries.length === 0) { + console.log("No registered ProofShot sessions."); + return; + } + console.log(chalk8.bold("ProofShot Sessions")); + console.log(""); + for (const entry of entries) { + console.log(`${entry.id} ${formatStatus(entry.status)}`); + console.log(chalk8.dim(` Started: ${entry.startedAt}`)); + console.log(chalk8.dim(` From: ${entry.startDirectory || "unknown"}`)); + console.log(chalk8.dim(` Output: ${entry.outputDir}`)); + if (entry.cleanupError) { + console.log(chalk8.yellow(` Cleanup: ${entry.cleanupError}`)); + } + } +} +async function sessionCleanCommand(options) { + const sessions = selectSessionsToClean(options); + if (sessions.length === 0) { + console.log("No recoverable ProofShot sessions."); + return; + } + let failures = 0; + for (const session of sessions) { + setAgentBrowserDefaults({ + configPath: session.agentBrowserConfigPath, + socketDir: session.agentBrowserSocketDir + }); + try { + await cleanupFailedStart(session); + clearMatchingControlState(session); + unregisterSession(session.sessionName); + console.log(`${chalk8.green("\u2713")} Cleaned ${session.sessionName}`); + } catch (error) { + failures += 1; + session.lifecycleStatus = "recovery"; + session.cleanupError = error instanceof Error ? error.message : String(error); + persistMatchingControlState(session); + registerSession(session); + console.error(`${chalk8.red("\u2717")} Kept ${session.sessionName}: ${session.cleanupError}`); + } + } + if (failures > 0) { + process.exitCode = 1; + } +} +function clearMatchingControlState(session) { + const controlDir = session.controlDir ?? session.outputDir; + if (!hasActiveSession(controlDir)) return; + const activeSession = loadControlSessionSafely(controlDir); + if (activeSession?.sessionName === session.sessionName) { + clearSession(controlDir); + return; + } + throw new Error( + `Control state at ${controlDir} is corrupt or belongs to another session; it was not removed.` + ); +} +function persistMatchingControlState(session) { + const controlDir = session.controlDir ?? session.outputDir; + const activeSession = loadControlSessionSafely(controlDir); + if (!hasActiveSession(controlDir) || activeSession?.sessionName === session.sessionName) { + saveSession(session, controlDir); + } +} +function loadControlSessionSafely(controlDir) { + try { + return loadSession(controlDir); + } catch { + return null; + } +} +function selectSessionsToClean(options) { + if (options.session) { + const session = getRegisteredSession(options.session); + if (!session) { + throw new Error(`No registered ProofShot session named "${options.session}".`); + } + return [session]; + } + const sessions = listRegisteredSessions(); + if (options.all) { + return sessions; + } + return sessions.filter((session) => { + const status = getSessionStatus(session); + return status === "recovery" || status === "stale"; + }); +} +function buildSessionListEntry(session) { + return { + id: session.sessionName, + status: getSessionStatus(session), + startedAt: session.startedAt, + startDirectory: session.startDirectory || null, + outputDir: session.outputDir, + cleanupError: session.cleanupError || null + }; +} +function getSessionStatus(session) { + if (session.lifecycleStatus === "recovery") { + return "recovery"; + } + if (session.lifecycleStatus === "starting") { + return "starting"; + } + if (session.browserProcess && ownedProcessTreeIsAlive(session.browserProcess) || session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) { + return "active"; + } + return "stale"; +} +function formatStatus(status) { + switch (status) { + case "active": + return chalk8.green(status); + case "starting": + return chalk8.cyan(status); + case "recovery": + return chalk8.yellow(status); + case "stale": + return chalk8.dim(status); + default: { + const exhaustiveStatus = status; + return exhaustiveStatus; + } + } +} + +// src/cli.ts +function createCLI() { + const program2 = new Command(); + program2.name("proofshot").description("Visual verification for AI coding agents").version(PROOFSHOT_VERSION); + program2.command("install").description("Install ProofShot skills at user level for all detected AI coding tools").option("--only ", "Only install for these tools (comma-separated: claude,codex,cursor,gemini,windsurf,opencode)").option("--skip ", "Skip these tools (comma-separated)").option("--force", "Overwrite existing skill files even if unchanged").action(async (options) => { + await installCommand(options); + }); + program2.command("start").description("Start a verification session: browser, recording, error capture").option("--description ", "What is being verified (included in the proof report)").option("--port ", "Override detected port", parseInt).option("--run ", "Start this command and capture its logs").option("--headed", "Show browser window for debugging").option("--output ", "Custom output directory").option("--url ", "Open this URL instead of the root").option("--browser-executable ", "Use this Chrome/Chromium executable").option("--force", "Override a stale session without running stop first").action(async (options) => { + await startCommand(options); + }); + program2.command("stop").description("Stop session: stop recording, collect errors, bundle proof artifacts").option("--no-close", "Don't close the browser (keep it open for further use)").action(async (options) => { + await stopCommand({ noClose: options.close === false }); + }); + program2.command("diff").description("Compare current screenshots against a baseline").requiredOption("--baseline ", "Directory with baseline screenshots").action(async (options) => { + await diffCommand(options); + }); + program2.command("clean").description("Remove artifact files").action(async () => { + await cleanCommand(); + }); + program2.command("doctor").description("Inspect the local ProofShot environment and active session state").action(async () => { + await doctorCommand(); + }); + program2.command("pr").description("Upload session artifacts and post a ProofShot comment on a GitHub PR").argument("[pr-number]", "PR number (auto-detects from current branch if omitted)").option("--dry-run", "Generate the comment markdown without posting").option( + "--session ", + "Publish a finalized session (repeatable)", + collectOption, + [] + ).option( + "--screenshot ", + "Publish named screenshot artifacts (space-separated or repeatable)", + collectOption, + [] + ).option( + "--legacy-session", + "Allow one explicitly selected pre-manifest session" + ).option( + "--upload-provider ", + "Artifact upload backend: repo-contents or github-web-attachments", + "repo-contents" + ).option( + "--artifacts-branch ", + "Git branch used by the repo-contents upload provider", + "proofshot-artifacts" + ).action(async (prNumber, options) => { + await prCommand({ prNumber, ...options }); + }); + program2.command("exec").description("Run an agent-browser command with logging (use instead of agent-browser directly)").argument("", "agent-browser command and arguments").allowUnknownOption().action(async (args) => { + await execCommand(args); + }); + const session = program2.command("session").description("List and recover registered ProofShot sessions"); + session.command("list").description("List all registered ProofShot sessions").option("--json", "Output machine-readable JSON").action(async (options) => { + await sessionListCommand(options); + }); + session.command("clean").description("Retry exact cleanup for recoverable ProofShot sessions").option("--session ", "Clean one exact registered session").option("--all", "Clean every registered session").action(async (options) => { + await sessionCleanCommand(options); + }); + return program2; +} +function collectOption(value, previous) { + return [...previous, value]; +} + +// bin/proofshot.ts +var program = createCLI(); +program.parse(); +//# sourceMappingURL=proofshot.js.map \ No newline at end of file diff --git a/dist/bin/proofshot.js.map b/dist/bin/proofshot.js.map new file mode 100644 index 0000000..d2318af --- /dev/null +++ b/dist/bin/proofshot.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/cli.ts","../../src/commands/install.ts","../../src/utils/skills.ts","../../src/commands/start.ts","../../src/utils/config.ts","../../src/utils/exec.ts","../../src/utils/process.ts","../../src/server/start.ts","../../src/utils/port.ts","../../src/browser/session.ts","../../src/browser/capture.ts","../../src/browser/discovery.ts","../../src/browser/runtime.ts","../../src/artifacts/bundle.ts","../../src/session/state.ts","../../src/environment/runtime.ts","../../src/environment/workers.ts","../../src/environment/evidence.ts","../../src/environment/tmux.ts","../../src/session/lifecycle.ts","../../src/session/registry.ts","../../src/session/metadata.ts","../../src/session/manifest.ts","../../src/commands/stop.ts","../../src/artifacts/viewer.ts","../../src/artifacts/evidence.ts","../../src/utils/error-patterns.ts","../../src/commands/exec.ts","../../src/utils/token-usage.ts","../../src/commands/diff.ts","../../src/commands/clean.ts","../../src/commands/pr.ts","../../src/utils/github.ts","../../src/artifacts/pr-format.ts","../../src/session/publication.ts","../../src/commands/doctor.ts","../../src/version.ts","../../src/commands/session.ts","../../bin/proofshot.ts"],"sourcesContent":["import { Command } from 'commander';\nimport { installCommand } from './commands/install.js';\nimport { startCommand } from './commands/start.js';\nimport { stopCommand } from './commands/stop.js';\nimport { diffCommand } from './commands/diff.js';\nimport { cleanCommand } from './commands/clean.js';\nimport { prCommand } from './commands/pr.js';\nimport { execCommand } from './commands/exec.js';\nimport { doctorCommand } from './commands/doctor.js';\nimport { sessionCleanCommand, sessionListCommand } from './commands/session.js';\nimport { PROOFSHOT_VERSION } from './version.js';\n\nexport function createCLI(): Command {\n const program = new Command();\n\n program\n .name('proofshot')\n .description('Visual verification for AI coding agents')\n .version(PROOFSHOT_VERSION);\n\n program\n .command('install')\n .description('Install ProofShot skills at user level for all detected AI coding tools')\n .option('--only ', 'Only install for these tools (comma-separated: claude,codex,cursor,gemini,windsurf,opencode)')\n .option('--skip ', 'Skip these tools (comma-separated)')\n .option('--force', 'Overwrite existing skill files even if unchanged')\n .action(async (options) => {\n await installCommand(options);\n });\n\n program\n .command('start')\n .description('Start a verification session: browser, recording, error capture')\n .option('--description ', 'What is being verified (included in the proof report)')\n .option('--port ', 'Override detected port', parseInt)\n .option('--run ', 'Start this command and capture its logs')\n .option('--headed', 'Show browser window for debugging')\n .option('--output ', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--session ',\n 'Publish a finalized session (repeatable)',\n collectOption,\n [],\n )\n .option(\n '--screenshot ',\n 'Publish named screenshot artifacts (space-separated or repeatable)',\n collectOption,\n [],\n )\n .option(\n '--legacy-session',\n 'Allow one explicitly selected pre-manifest session',\n )\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n\nfunction collectOption(value: string, previous: string[]): string[] {\n return [...previous, value];\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec assert-visible \"#expected-result\" # Record an expected selector\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects canonical browser + environment evidence, and generates\na SUMMARY.md, viewer, structured verdict, and provenance manifest.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\nproofshot pr 42 --session SESSION_ID --screenshot step-NAME.png\n\\`\\`\\`\n\nThis selects one finalized session compatible with the PR head, validates artifact hashes, uploads the selected screenshots/video, and posts only after every upload succeeds. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec assert-visible \"#selector\"\\` — record an expected selector\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec assert-visible \"#selector\"\\` — record an expected selector\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { getPageUrl, openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\nimport { captureGitProvenance } from '../session/manifest.js';\nimport { startOwnedEnvironment } from '../environment/runtime.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n const provenance = captureGitProvenance(process.cwd(), [outputDir]);\n\n writeMetadata(sessionDir, {\n ...provenance,\n repositoryRoot: process.cwd(),\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n controlDir,\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n environment: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run && config.environment) {\n throw new Error('Use either --run or config.environment, not both.');\n }\n if (config.environment || (config.logs?.sources || []).some((source) => source.kind === 'file')) {\n failureContext = 'start environment';\n session.environment = await startOwnedEnvironment(\n config.environment,\n config.logs || {},\n sessionDir,\n sessionName,\n new Date(session.startedAt).getTime(),\n (environmentState) => {\n session.environment = environmentState;\n persistOwnedSession(session, controlDir);\n },\n );\n console.log(chalk.green('✓') + ' Environment and log capture started');\n }\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else if (!config.environment) {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n session.targetUrl = getPageUrl(sessionName) || openUrl;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n session.recordingStartedAt = new Date().toISOString();\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type {\n EnvironmentConfig,\n LogsConfig,\n LogSourceConfig,\n} from '../environment/types.js';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n environment?: EnvironmentConfig;\n logs?: LogsConfig;\n}\n\nexport interface ResolvedProofShotConfig extends ProofShotConfig {\n logs: LogsConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ResolvedProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n logs: {\n stripAnsi: true,\n maxBytesPerSource: 5 * 1024 * 1024,\n sources: [],\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ResolvedProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n validateConfig(parsed);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n const environment = resolveEnvironmentConfig(parsed.environment, configDir);\n const logs = resolveLogsConfig(parsed.logs, configDir);\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n environment,\n logs,\n };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`Invalid ProofShot config at ${configPath}: ${message}`);\n }\n}\n\nfunction validateConfig(value: unknown): void {\n assertRecord(value, 'config');\n assertOptionalString(value.output, 'output');\n assertOptionalBoolean(value.headless, 'headless');\n assertOptionalStringArray(value.defaultPages, 'defaultPages');\n\n if (value.devServer !== undefined) {\n assertRecord(value.devServer, 'devServer');\n assertOptionalPositiveInteger(value.devServer.port, 'devServer.port', 65535);\n assertOptionalPositiveInteger(\n value.devServer.startupTimeout,\n 'devServer.startupTimeout',\n );\n }\n if (value.viewport !== undefined) {\n assertRecord(value.viewport, 'viewport');\n assertOptionalPositiveInteger(value.viewport.width, 'viewport.width');\n assertOptionalPositiveInteger(value.viewport.height, 'viewport.height');\n }\n if (value.browser !== undefined) {\n assertRecord(value.browser, 'browser');\n assertOptionalString(value.browser.configPath, 'browser.configPath');\n assertOptionalString(value.browser.executablePath, 'browser.executablePath');\n assertOptionalBoolean(value.browser.ignoreHttpsErrors, 'browser.ignoreHttpsErrors');\n }\n validateEnvironment(value.environment);\n validateLogs(value.logs);\n}\n\nfunction validateEnvironment(value: unknown): void {\n if (value === undefined) return;\n assertRecord(value, 'environment');\n validateReadiness(value.readiness);\n\n if (value.kind === 'tmux') {\n assertRecord(value.launch, 'environment.launch');\n assertOptionalString(value.cwd, 'environment.cwd');\n if (value.launch.kind === 'panes') {\n if (!Array.isArray(value.launch.panes) || value.launch.panes.length === 0) {\n throw new Error('environment.launch.panes must be a non-empty array');\n }\n validateDefinitions(value.launch.panes, 'environment.launch.panes');\n assertOptionalString(\n value.launch.sessionName,\n 'environment.launch.sessionName',\n );\n if (value.connection !== undefined) {\n throw new Error('environment.connection is only valid for external-command');\n }\n return;\n }\n if (value.launch.kind === 'external-command') {\n assertNonEmptyString(value.launch.command, 'environment.launch.command');\n assertOptionalString(\n value.launch.stopCommand,\n 'environment.launch.stopCommand',\n );\n assertOptionalPositiveInteger(\n value.launch.timeoutMs,\n 'environment.launch.timeoutMs',\n );\n assertRecord(value.connection, 'environment.connection');\n if (\n value.connection.format !== 'json' &&\n value.connection.format !== 'tmux-attach-command'\n ) {\n throw new Error(\n 'environment.connection.format must be \"json\" or \"tmux-attach-command\"',\n );\n }\n if (\n value.connection.source !== undefined &&\n value.connection.source !== 'stdout'\n ) {\n throw new Error('environment.connection.source must be \"stdout\"');\n }\n assertOptionalString(value.connection.socket, 'environment.connection.socket');\n if (\n value.connection.ownership !== undefined &&\n value.connection.ownership !== 'attach' &&\n value.connection.ownership !== 'create'\n ) {\n throw new Error(\n 'environment.connection.ownership must be \"attach\" or \"create\"',\n );\n }\n if (\n value.connection.ownership !== 'attach' &&\n value.connection.socket === undefined &&\n value.launch.stopCommand === undefined\n ) {\n throw new Error(\n 'external-command requires connection.socket or launch.stopCommand for cleanup',\n );\n }\n return;\n }\n throw new Error(\n 'environment.launch.kind must be \"panes\" or \"external-command\"',\n );\n }\n\n if (value.kind === 'processes') {\n if (!Array.isArray(value.commands)) {\n throw new Error('environment.commands must be an array');\n }\n validateDefinitions(value.commands, 'environment.commands');\n return;\n }\n throw new Error('environment.kind must be \"tmux\" or \"processes\"');\n}\n\nfunction validateDefinitions(value: unknown[], field: string): void {\n const ids = new Set();\n value.forEach((candidate, index) => {\n const item = `${field}[${index}]`;\n assertRecord(candidate, item);\n assertSafeId(candidate.id, `${item}.id`);\n if (ids.has(candidate.id)) throw new Error(`Duplicate ${field} id: ${candidate.id}`);\n ids.add(candidate.id);\n assertNonEmptyString(candidate.command, `${item}.command`);\n assertOptionalString(candidate.title, `${item}.title`);\n assertOptionalString(candidate.group, `${item}.group`);\n assertOptionalString(candidate.cwd, `${item}.cwd`);\n if (candidate.env !== undefined) {\n assertRecord(candidate.env, `${item}.env`);\n for (const [key, envValue] of Object.entries(candidate.env)) {\n if (typeof envValue !== 'string') {\n throw new Error(`${item}.env.${key} must be a string`);\n }\n }\n }\n });\n}\n\nfunction validateReadiness(value: unknown): void {\n if (value === undefined) return;\n if (!Array.isArray(value)) throw new Error('environment.readiness must be an array');\n value.forEach((candidate, index) => {\n const item = `environment.readiness[${index}]`;\n assertRecord(candidate, item);\n assertOptionalPositiveInteger(candidate.timeoutMs, `${item}.timeoutMs`);\n if (candidate.kind === 'http') {\n assertNonEmptyString(candidate.url, `${item}.url`);\n return;\n }\n if (candidate.kind === 'tcp') {\n assertOptionalString(candidate.host, `${item}.host`);\n assertOptionalPositiveInteger(candidate.port, `${item}.port`, 65535, true);\n return;\n }\n throw new Error(`${item}.kind must be \"http\" or \"tcp\"`);\n });\n}\n\nfunction validateLogs(value: unknown): void {\n if (value === undefined) return;\n assertRecord(value, 'logs');\n assertOptionalBoolean(value.stripAnsi, 'logs.stripAnsi');\n assertOptionalPositiveInteger(value.maxBytesPerSource, 'logs.maxBytesPerSource');\n if (\n value.maxBytesPerSource !== undefined &&\n value.maxBytesPerSource < 512\n ) {\n throw new Error('logs.maxBytesPerSource must be at least 512 bytes');\n }\n if (value.sources === undefined) return;\n if (!Array.isArray(value.sources)) throw new Error('logs.sources must be an array');\n\n const ids = new Set();\n value.sources.forEach((candidate, index) => {\n const item = `logs.sources[${index}]`;\n assertRecord(candidate, item);\n assertSafeId(candidate.id, `${item}.id`);\n if (ids.has(candidate.id)) throw new Error(`Duplicate log source id: ${candidate.id}`);\n ids.add(candidate.id);\n assertOptionalString(candidate.title, `${item}.title`);\n assertOptionalString(candidate.group, `${item}.group`);\n assertOptionalStringArray(candidate.include, `${item}.include`);\n assertOptionalStringArray(candidate.exclude, `${item}.exclude`);\n\n if (candidate.kind === 'tmux-pane') {\n assertRecord(candidate.match, `${item}.match`);\n const keys = ['connectionKey', 'tag', 'target'].filter(\n (key) => candidate.match[key] !== undefined,\n );\n if (keys.length !== 1) {\n throw new Error(`${item}.match must set exactly one pane selector`);\n }\n assertNonEmptyString(candidate.match[keys[0]], `${item}.match.${keys[0]}`);\n return;\n }\n if (candidate.kind === 'process') {\n assertSafeId(candidate.processId, `${item}.processId`);\n return;\n }\n if (candidate.kind === 'file') {\n assertNonEmptyString(candidate.path, `${item}.path`);\n return;\n }\n throw new Error(`${item}.kind is unsupported`);\n });\n}\n\nfunction assertRecord(\n value: unknown,\n field: string,\n): asserts value is Record {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw new Error(`${field} must be an object`);\n }\n}\n\nfunction assertSafeId(value: unknown, field: string): asserts value is string {\n assertNonEmptyString(value, field);\n if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {\n throw new Error(`${field} must contain only letters, numbers, \"_\" or \"-\"`);\n }\n}\n\nfunction assertNonEmptyString(\n value: unknown,\n field: string,\n): asserts value is string {\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${field} must be a non-empty string`);\n }\n}\n\nfunction assertOptionalString(value: unknown, field: string): void {\n if (value !== undefined && typeof value !== 'string') {\n throw new Error(`${field} must be a string`);\n }\n}\n\nfunction assertOptionalBoolean(value: unknown, field: string): void {\n if (value !== undefined && typeof value !== 'boolean') {\n throw new Error(`${field} must be a boolean`);\n }\n}\n\nfunction assertOptionalStringArray(value: unknown, field: string): void {\n if (\n value !== undefined &&\n (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))\n ) {\n throw new Error(`${field} must be an array of strings`);\n }\n}\n\nfunction assertOptionalPositiveInteger(\n value: unknown,\n field: string,\n maximum = Number.MAX_SAFE_INTEGER,\n required = false,\n): void {\n if (value === undefined && !required) return;\n if (\n !Number.isInteger(value) ||\n (value as number) <= 0 ||\n (value as number) > maximum\n ) {\n throw new Error(`${field} must be a positive integer no greater than ${maximum}`);\n }\n}\n\nfunction resolveEnvironmentConfig(\n value: unknown,\n configDir: string,\n): EnvironmentConfig | undefined {\n if (typeof value !== 'object' || value === null) {\n return undefined;\n }\n const environment = value as EnvironmentConfig;\n if (environment.kind === 'tmux') {\n const launch =\n environment.launch.kind === 'panes'\n ? {\n ...environment.launch,\n panes: environment.launch.panes.map((pane) => ({\n ...pane,\n cwd: path.resolve(configDir, pane.cwd || environment.cwd || '.'),\n })),\n }\n : environment.launch;\n return {\n ...environment,\n cwd: path.resolve(configDir, environment.cwd || '.'),\n connection: environment.connection?.socket\n ? {\n ...environment.connection,\n socket: path.resolve(configDir, environment.connection.socket),\n }\n : environment.connection,\n launch,\n };\n }\n if (environment.kind === 'processes') {\n return {\n ...environment,\n commands: environment.commands.map((command) => ({\n ...command,\n cwd: path.resolve(configDir, command.cwd || '.'),\n })),\n };\n }\n return undefined;\n}\n\nfunction resolveLogsConfig(value: unknown, configDir: string): LogsConfig {\n const logs =\n typeof value === 'object' && value !== null\n ? (value as LogsConfig)\n : DEFAULT_CONFIG.logs;\n const sources: LogSourceConfig[] = (logs.sources || []).map((source) =>\n source.kind === 'file'\n ? { ...source, path: path.resolve(configDir, source.path) }\n : source,\n );\n return {\n ...DEFAULT_CONFIG.logs,\n ...logs,\n sources,\n };\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return {\n ...process.env,\n AGENT_BROWSER_IDLE_TIMEOUT_MS:\n process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || '1800000',\n ...(socketDir ? { AGENT_BROWSER_SOCKET_DIR: socketDir } : {}),\n };\n}\n\nexport function quoteShellArgument(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath\n ? ` --config ${quoteShellArgument(mergedOptions.configPath)}`\n : '';\n const sessionFlag = mergedOptions.session\n ? ` --session ${quoteShellArgument(mergedOptions.session)}`\n : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n /** Stable boot token preventing cross-boot PID/start-time collisions. */\n bootId?: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n const identity = parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n const bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf-8').trim();\n if (!identity || !bootId) return null;\n return { ...identity, bootId };\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, TZ: 'UTC' },\n },\n );\n const identity = parseUnixProcessIdentity(pid, output);\n if (!identity) return null;\n if (process.platform !== 'darwin') return identity;\n const bootId = execFileSync('sysctl', ['-n', 'kern.boottime'], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trim();\n return bootId ? { ...identity, bootId } : null;\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && processIdentitiesMatch(current, identity));\n}\n\nexport function processIdentitiesMatch(\n left: ProcessIdentity,\n right: ProcessIdentity,\n): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime &&\n left.bootId === right.bootId\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !processIdentitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !processIdentitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport async function terminateOwnedProcess(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity || !processIdentityMatches(identity)) {\n return false;\n }\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n try {\n process.kill(identity.pid, 'SIGTERM');\n } catch {\n return false;\n }\n\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && processIdentityMatches(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n if (processIdentityMatches(identity)) {\n try {\n process.kill(identity.pid, 'SIGKILL');\n } catch {\n return false;\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import {\n ab,\n ProofShotError,\n quoteShellArgument,\n} from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${quoteShellArgument(url)}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n try {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), {\n timeoutMs: 60000,\n session: sessionName,\n });\n } catch (error) {\n const currentUrl = getPageUrl(sessionName);\n if (!isNavigationTimeout(error) || !urlsMatch(currentUrl, url)) {\n throw error;\n }\n console.warn(\n 'Browser reached the target URL before its load event timed out; continuing with the active page.',\n );\n }\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\nfunction isNavigationTimeout(error: unknown): boolean {\n return (\n error instanceof ProofShotError &&\n error.message.toLowerCase().includes('operation timed out')\n );\n}\n\nfunction urlsMatch(actual: string, expected: string): boolean {\n try {\n return new URL(actual).href === new URL(expected).href;\n } catch {\n return actual === expected;\n }\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n ab('close', { session: sessionName });\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n return ab('errors', { session: sessionName });\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n return ab('console', { session: sessionName });\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n if (!Array.isArray(messages)) {\n throw new Error('agent-browser returned malformed console JSON.');\n }\n return messages;\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n if (platform === 'linux') {\n const cached = [...homes]\n .flatMap(cachedBrowserCandidates)\n .find(isExecutable);\n if (cached) return cached;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n\n/** Remove only the socket and PID sidecars for one verified stopped session. */\nexport function clearAgentBrowserSessionFiles(\n socketDir: string,\n sessionName: string,\n): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Unsafe agent-browser session name: ${sessionName}`);\n }\n assertOwnedDirectory(socketDir);\n const uid = process.getuid?.();\n for (const suffix of ['.pid', '.sock']) {\n const filePath = path.join(socketDir, `${sessionName}${suffix}`);\n try {\n const stat = fs.lstatSync(filePath);\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(`Agent-browser sidecar is owned by uid ${stat.uid}: ${filePath}`);\n }\n fs.unlinkSync(filePath);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n }\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { EnvironmentState } from '../environment/types.js';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n recordingStartedAt?: string;\n stoppedAt?: string;\n startDirectory?: string;\n controlDir?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n environment?: EnvironmentState | null;\n environmentStopped?: boolean;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(\n `ProofShot session state is corrupt: ${sessionPath}\\n${message}\\n` +\n 'Use \"proofshot session list\" to inspect durable recovery records.',\n );\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce: string = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import * as fs from 'fs';\nimport * as net from 'net';\nimport * as path from 'path';\nimport { startFileCapture, startProcessCapture } from './workers.js';\nimport { startTmuxEnvironment, stopTmuxEnvironment } from './tmux.js';\nimport type {\n EnvironmentConfig,\n EnvironmentState,\n LogSourceConfig,\n LogsConfig,\n ProcessDefinition,\n ProcessEnvironmentState,\n ReadinessCheck,\n ResolvedLogSourceState,\n TmuxEnvironmentConfig,\n TmuxEnvironmentState,\n ProcessesEnvironmentConfig,\n} from './types.js';\nimport {\n ownedProcessTreeIsAlive,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\nexport function startOwnedEnvironment(\n environment: TmuxEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport function startOwnedEnvironment(\n environment: ProcessesEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport function startOwnedEnvironment(\n environment: undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport function startOwnedEnvironment(\n environment: EnvironmentConfig | undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport async function startOwnedEnvironment(\n environment: EnvironmentConfig | undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const fileSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'file',\n );\n if (!environment && fileSources.length === 0) {\n return null;\n }\n\n let state: EnvironmentState;\n if (environment?.kind === 'tmux') {\n state = await startTmuxEnvironment(\n environment,\n logs,\n sessionDir,\n sessionName,\n startTimeMs,\n onState,\n );\n } else {\n state = await startProcessEnvironment(\n environment?.kind === 'processes' ? environment.commands : [],\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n }\n\n try {\n state = await attachFileSources(\n state,\n fileSources,\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n if (environment) {\n await waitForReadiness(environment.readiness || []);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nexport async function stopOwnedEnvironment(\n state: EnvironmentState | null | undefined,\n): Promise {\n if (!state) {\n return;\n }\n switch (state.kind) {\n case 'tmux':\n await stopTmuxEnvironment(state);\n return;\n case 'launcher':\n await terminateOwnedProcessTree(state.launcher.process, { graceMs: 1000 });\n if (ownedProcessTreeIsAlive(state.launcher.process)) {\n throw new Error('External environment launcher did not stop.');\n }\n return;\n case 'processes': {\n const errors: Error[] = [];\n for (const capture of state.processes) {\n try {\n await terminateOwnedProcessTree(capture.process, { graceMs: 1000 });\n if (ownedProcessTreeIsAlive(capture.process)) {\n throw new Error(`Environment process ${capture.sourceId} did not stop.`);\n }\n } catch (error) {\n errors.push(error instanceof Error ? error : new Error(String(error)));\n }\n }\n if (errors.length > 0) {\n throw new AggregateError(\n errors,\n 'One or more environment processes did not stop.',\n );\n }\n return;\n }\n default: {\n const exhaustiveState: never = state;\n return exhaustiveState;\n }\n }\n}\n\nasync function startProcessEnvironment(\n definitions: ProcessDefinition[],\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n const configuredSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'process',\n );\n const sourceByProcessId = new Map();\n for (const source of configuredSources) {\n if (sourceByProcessId.has(source.processId)) {\n throw new Error(\n `Multiple log sources reference process ${source.processId}; each process can be launched only once.`,\n );\n }\n sourceByProcessId.set(source.processId, source);\n }\n for (const source of configuredSources) {\n if (!definitions.some((definition) => definition.id === source.processId)) {\n throw new Error(\n `Log source ${source.id} references unknown process ${source.processId}.`,\n );\n }\n }\n const sources = definitions.map(\n (definition) =>\n sourceByProcessId.get(definition.id) || {\n id: definition.id,\n title: definition.title,\n group: definition.group,\n kind: 'process' as const,\n processId: definition.id,\n include: undefined,\n exclude: undefined,\n },\n );\n validateUniqueIds(sources.map((source) => source.id));\n\n let state: ProcessEnvironmentState = {\n kind: 'processes',\n evidencePath,\n sources: [],\n processes: [],\n };\n onState(state);\n try {\n for (const sourceConfig of sources) {\n const definition = definitions.find(\n (candidate) => candidate.id === sourceConfig.processId,\n );\n if (!definition) throw new Error(`Missing process ${sourceConfig.processId}.`);\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title: sourceConfig.title || definition.title || definition.id,\n group: sourceConfig.group || definition.group || 'environment',\n kind: 'process',\n stream: 'stdout',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n const process = await startProcessCapture(\n definition,\n source,\n evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state = {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, process],\n };\n onState(state);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nasync function attachFileSources(\n state: EnvironmentState,\n fileSources: Array>,\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n if (fileSources.length === 0) {\n return state;\n }\n if (state.kind === 'launcher') {\n throw new Error('Cannot attach file sources before the environment launcher exits.');\n }\n const knownIds = new Set(state.sources.map((source) => source.id));\n const logsDir = path.join(sessionDir, 'logs');\n for (const fileSource of fileSources) {\n if (knownIds.has(fileSource.id)) {\n throw new Error(`Duplicate log source id: ${fileSource.id}`);\n }\n knownIds.add(fileSource.id);\n const source: ResolvedLogSourceState = {\n id: fileSource.id,\n title: fileSource.title || path.basename(fileSource.path),\n group: fileSource.group || 'environment',\n kind: 'file',\n stream: 'file',\n logPath: path.join(logsDir, `${fileSource.id}.log`),\n include: fileSource.include,\n exclude: fileSource.exclude,\n };\n const capture = await startFileCapture(\n fileSource.path,\n source,\n state.evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state =\n state.kind === 'tmux'\n ? {\n ...state,\n sources: [...state.sources, source],\n captures: [...state.captures, capture],\n }\n : {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, capture],\n };\n onState(state);\n }\n return state;\n}\n\nasync function waitForReadiness(checks: ReadinessCheck[]): Promise {\n for (const check of checks) {\n const timeoutMs = check.timeoutMs || 30 * 1000;\n const deadline = Date.now() + timeoutMs;\n let lastError = 'not ready';\n while (Date.now() < deadline) {\n try {\n if (check.kind === 'http') {\n const response = await fetch(check.url, {\n signal: AbortSignal.timeout(Math.min(2000, timeoutMs)),\n });\n if (response.ok) {\n lastError = '';\n break;\n }\n lastError = `HTTP ${response.status}`;\n } else {\n await connectTcp(check.host || '127.0.0.1', check.port);\n lastError = '';\n break;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n }\n await new Promise((resolve) => setTimeout(resolve, 100));\n }\n if (lastError) {\n const target =\n check.kind === 'http'\n ? check.url\n : `${check.host || '127.0.0.1'}:${check.port}`;\n throw new Error(`Environment readiness failed for ${target}: ${lastError}`);\n }\n }\n}\n\nfunction connectTcp(host: string, port: number): Promise {\n return new Promise((resolve, reject) => {\n const socket = net.createConnection({ host, port });\n const timer = setTimeout(() => {\n socket.destroy();\n reject(new Error('TCP readiness timed out'));\n }, 2000);\n socket.once('connect', () => {\n clearTimeout(timer);\n socket.end();\n resolve();\n });\n socket.once('error', (error) => {\n clearTimeout(timer);\n reject(error);\n });\n });\n}\n\nfunction validateUniqueIds(ids: string[]): void {\n const seen = new Set();\n for (const id of ids) {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n if (seen.has(id)) {\n throw new Error(`Duplicate log source id: ${id}`);\n }\n seen.add(id);\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { spawn } from 'child_process';\nimport { normalizeLogText } from './evidence.js';\nimport type {\n CaptureProcessState,\n EvidenceEvent,\n ProcessDefinition,\n ResolvedLogSourceState,\n} from './types.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n type ProcessIdentity,\n} from '../utils/process.js';\n\ntype WorkerConfig = {\n evidencePath: string;\n logPath: string;\n pidFile?: string;\n startTimeMs: number;\n maxBytes: number;\n stripAnsi: boolean;\n source: ResolvedLogSourceState;\n command?: string;\n cwd?: string;\n env?: Record;\n shellPath?: string;\n offset?: number;\n fileDevice?: number;\n fileInode?: number;\n};\n\nconst COMMON_WORKER_SOURCE = String.raw`\nconst fs = require('fs');\nconst config = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));\nconst ansiPattern = /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst controlPattern = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\nlet bytesWritten = 0;\nlet truncated = false;\nfunction normalize(text) {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(controlPattern, '');\n return config.stripAnsi ? normalized.replace(ansiPattern, '') : normalized;\n}\nfunction writeEvent(text, stream, segment = 'live', extra = {}) {\n const normalized = normalize(text);\n if (normalized.length === 0) return;\n const now = Date.now();\n const event = {\n version: 1,\n origin: 'environment',\n group: config.source.group,\n sourceId: config.source.id,\n sourceTitle: config.source.title,\n stream,\n segment,\n timestamp: new Date(now).toISOString(),\n relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000),\n text: normalized,\n ...extra,\n };\n const serialized = JSON.stringify(event) + '\\n';\n const logLine = normalized + '\\n';\n const bytes = Buffer.byteLength(serialized) + Buffer.byteLength(logLine);\n const truncationEvent = {\n ...event,\n text: '[ProofShot capture truncated at configured byte limit]',\n truncated: true,\n };\n const truncationSerialized = JSON.stringify(truncationEvent) + '\\n';\n const truncationLogLine = truncationEvent.text + '\\n';\n const truncationBytes =\n Buffer.byteLength(truncationSerialized) +\n Buffer.byteLength(truncationLogLine);\n if (bytesWritten + bytes + truncationBytes > config.maxBytes) {\n if (!truncated) {\n truncated = true;\n if (bytesWritten + truncationBytes <= config.maxBytes) {\n bytesWritten += truncationBytes;\n fs.appendFileSync(config.evidencePath, truncationSerialized);\n fs.appendFileSync(config.logPath, truncationLogLine);\n }\n }\n return;\n }\n bytesWritten += bytes;\n fs.appendFileSync(config.evidencePath, serialized);\n fs.appendFileSync(config.logPath, logLine);\n}\nfunction attachLines(stream, streamName) {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString().replace(/\\r\\n?/g, '\\n');\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) writeEvent(line, streamName);\n });\n stream.on('end', () => {\n if (buffer.length > 0) writeEvent(buffer, streamName);\n buffer = '';\n });\n}\nif (config.pidFile) {\n fs.writeFileSync(config.pidFile, String(process.pid), { mode: 0o600 });\n}\nfunction removePidFile() {\n if (config.pidFile) {\n try { fs.unlinkSync(config.pidFile); } catch {}\n }\n}\n`;\n\nconst TMUX_PIPE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nattachLines(process.stdin, 'pty');\nprocess.stdin.on('end', () => {\n removePidFile();\n process.exit(0);\n});\nprocess.on('SIGTERM', () => {\n removePidFile();\n process.exit(0);\n});\n`;\n\nconst PROCESS_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nconst { spawn } = require('child_process');\nlet stopping = false;\nconst child = spawn(config.command, {\n cwd: config.cwd,\n env: { ...process.env, ...config.env },\n shell: config.shellPath,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nattachLines(child.stdout, 'stdout');\nattachLines(child.stderr, 'stderr');\nchild.on('error', (error) => writeEvent(error.stack || error.message || String(error), 'stderr'));\nchild.on('close', (code) => {\n writeEvent(\n stopping\n ? '[process stopped by ProofShot]'\n : '[process exited with code ' + (code == null ? 'unknown' : code) + ']',\n 'stderr',\n );\n removePidFile();\n process.exit(stopping ? 0 : (code == null ? 1 : code));\n});\nfor (const signal of ['SIGINT', 'SIGTERM']) {\n process.on(signal, () => {\n stopping = true;\n try { child.kill(signal); } catch {}\n });\n}\n`;\n\nconst FILE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nlet offset = config.offset || 0;\nlet fileDevice = config.fileDevice;\nlet fileInode = config.fileInode;\nlet buffered = '';\nfunction readAvailable() {\n let fd;\n try {\n fd = fs.openSync(config.filePath, 'r');\n } catch {\n return;\n }\n const stat = fs.fstatSync(fd);\n if (\n (fileDevice !== undefined && stat.dev !== fileDevice) ||\n (fileInode !== undefined && stat.ino !== fileInode) ||\n stat.size < offset\n ) {\n offset = 0;\n writeEvent('[file rotated or truncated]', 'file', 'live', { captureGap: true });\n }\n fileDevice = stat.dev;\n fileInode = stat.ino;\n if (stat.size === offset) {\n fs.closeSync(fd);\n return;\n }\n const length = Math.min(stat.size - offset, 64 * 1024);\n const buffer = Buffer.alloc(length);\n const bytesRead = fs.readSync(fd, buffer, 0, length, offset);\n fs.closeSync(fd);\n offset += bytesRead;\n buffered += buffer.subarray(0, bytesRead).toString().replace(/\\\\r\\\\n?/g, '\\\\n');\n const lines = buffered.split('\\\\n');\n buffered = lines.pop() || '';\n for (const line of lines) writeEvent(line, 'file');\n}\nconst timer = setInterval(readAvailable, 100);\nfunction stop() {\n clearInterval(timer);\n if (buffered.length > 0) writeEvent(buffered, 'file');\n removePidFile();\n process.exit(0);\n}\nprocess.on('SIGINT', stop);\nprocess.on('SIGTERM', stop);\n`;\n\nexport function buildTmuxPipeCommand(config: WorkerConfig): string {\n const encodedConfig = encodeConfig(config);\n return [\n shellQuote(process.execPath),\n '-e',\n shellQuote(TMUX_PIPE_RUNNER_SOURCE),\n shellQuote(encodedConfig),\n ].join(' ');\n}\n\nexport async function waitForCaptureProcess(\n sourceId: string,\n pidFile: string,\n timeoutMs = 2000,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = readPidIdentity(pidFile);\n if (identity) {\n return { sourceId, process: identity, pidFile };\n }\n await new Promise((resolve) => setTimeout(resolve, 25));\n } while (Date.now() < deadline);\n\n throw new Error(`ProofShot could not capture the log helper identity for ${sourceId}.`);\n}\n\nexport async function startProcessCapture(\n definition: ProcessDefinition,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n const config: WorkerConfig = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes,\n stripAnsi,\n source,\n command: definition.command,\n cwd: definition.cwd,\n env: definition.env,\n shellPath: getShellExecutable(),\n };\n return startDetachedWorker(source.id, pidFile, PROCESS_RUNNER_SOURCE, config);\n}\n\nexport async function startFileCapture(\n filePath: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n let offset = 0;\n let fileDevice: number | undefined;\n let fileInode: number | undefined;\n let liveMaxBytes = maxBytes;\n if (fs.existsSync(filePath)) {\n const fd = fs.openSync(filePath, 'r');\n try {\n const stat = fs.fstatSync(fd);\n offset = stat.size;\n fileDevice = stat.dev;\n fileInode = stat.ino;\n const historyBudget = Math.max(1, Math.floor(maxBytes / 2));\n liveMaxBytes = Math.max(1, maxBytes - historyBudget);\n const historyLength = Math.min(stat.size, historyBudget);\n const history = Buffer.alloc(historyLength);\n fs.readSync(fd, history, 0, historyLength, stat.size - historyLength);\n appendHistory(\n history.toString('utf-8'),\n source,\n evidencePath,\n historyBudget,\n stripAnsi,\n 'file',\n );\n } finally {\n fs.closeSync(fd);\n }\n }\n\n const config = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes: liveMaxBytes,\n stripAnsi,\n source,\n offset,\n fileDevice,\n fileInode,\n filePath,\n };\n return startDetachedWorker(source.id, pidFile, FILE_RUNNER_SOURCE, config);\n}\n\nexport function appendHistory(\n raw: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n maxBytes: number,\n stripAnsi: boolean,\n stream: EvidenceEvent['stream'],\n): void {\n const normalized = normalizeLogText(raw, stripAnsi);\n const lines = normalized.split('\\n').filter((line) => line.length > 0);\n const retained: Array<{ event: EvidenceEvent; serialized: string; logLine: string }> = [];\n let retainedBytes = 0;\n let truncated = false;\n for (let index = lines.length - 1; index >= 0; index -= 1) {\n const event: EvidenceEvent = {\n version: 1,\n origin: 'environment',\n group: source.group,\n sourceId: source.id,\n sourceTitle: source.title,\n stream,\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: lines[index],\n };\n const serialized = JSON.stringify(event) + '\\n';\n const logLine = `${lines[index]}\\n`;\n const eventBytes =\n Buffer.byteLength(serialized) + Buffer.byteLength(logLine);\n if (retainedBytes + eventBytes > maxBytes) {\n truncated = true;\n break;\n }\n retained.unshift({ event, serialized, logLine });\n retainedBytes += eventBytes;\n }\n if (truncated && retained.length > 0) {\n while (retained.length > 0) {\n retained[0].event.truncated = true;\n retained[0].serialized = JSON.stringify(retained[0].event) + '\\n';\n retainedBytes = retained.reduce(\n (total, entry) =>\n total +\n Buffer.byteLength(entry.serialized) +\n Buffer.byteLength(entry.logLine),\n 0,\n );\n if (retainedBytes <= maxBytes) break;\n retained.shift();\n }\n }\n if (truncated && retained.length === 0) {\n const event: EvidenceEvent = {\n version: 1,\n origin: 'environment',\n group: source.group,\n sourceId: source.id,\n sourceTitle: source.title,\n stream,\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: '[ProofShot capture truncated at configured byte limit]',\n truncated: true,\n };\n const serialized = JSON.stringify(event) + '\\n';\n const logLine = `${event.text}\\n`;\n if (\n Buffer.byteLength(serialized) + Buffer.byteLength(logLine) <=\n maxBytes\n ) {\n retained.push({ event, serialized, logLine });\n }\n }\n for (const entry of retained) {\n fs.appendFileSync(evidencePath, entry.serialized);\n fs.appendFileSync(source.logPath, entry.logLine);\n }\n}\n\nexport function createWorkerConfig(params: WorkerConfig): WorkerConfig {\n return params;\n}\n\nasync function startDetachedWorker(\n sourceId: string,\n pidFile: string,\n workerSource: string,\n config: object,\n): Promise {\n fs.mkdirSync(path.dirname(pidFile), { recursive: true });\n const errorFd = fs.openSync(`${pidFile}.stderr`, 'a', 0o600);\n const worker = spawn(process.execPath, ['-e', workerSource, encodeConfig(config)], {\n detached: true,\n stdio: ['ignore', 'ignore', errorFd],\n });\n fs.closeSync(errorFd);\n worker.unref();\n\n let identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n for (let attempt = 0; !identity && attempt < 20; attempt += 1) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n }\n if (!identity) {\n try {\n if (worker.pid) {\n process.kill(-worker.pid, 'SIGKILL');\n }\n } catch {\n // The worker may already have exited.\n }\n throw new Error(`ProofShot could not capture the runner identity for ${sourceId}.`);\n }\n return { sourceId, process: identity, pidFile };\n}\n\nfunction readPidIdentity(pidFile: string): ProcessIdentity | null {\n try {\n const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());\n return captureProcessIdentity(pid);\n } catch {\n return null;\n }\n}\n\nfunction encodeConfig(config: object): string {\n return Buffer.from(JSON.stringify(config)).toString('base64');\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n","import * as fs from 'fs';\nimport type { EvidenceEvent } from './types.js';\n\nconst ANSI_PATTERN =\n // eslint-disable-next-line no-control-regex\n /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst CONTROL_PATTERN = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\n\nexport function normalizeLogText(text: string, stripAnsi = true): string {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(CONTROL_PATTERN, '');\n return stripAnsi ? normalized.replace(ANSI_PATTERN, '') : normalized;\n}\n\nexport function appendEvidenceEvent(filePath: string, event: EvidenceEvent): void {\n fs.appendFileSync(filePath, JSON.stringify(event) + '\\n');\n}\n\nexport function loadEvidenceEvents(filePath: string): EvidenceEvent[] {\n if (!fs.existsSync(filePath)) {\n return [];\n }\n\n return fs\n .readFileSync(filePath, 'utf-8')\n .split('\\n')\n .filter(Boolean)\n .map((line, index) => {\n try {\n const parsed: unknown = JSON.parse(line);\n return isEvidenceEvent(parsed)\n ? parsed\n : malformedEvidenceEvent(index + 1);\n } catch {\n return malformedEvidenceEvent(index + 1);\n }\n });\n}\n\nfunction isEvidenceEvent(value: unknown): value is EvidenceEvent {\n if (typeof value !== 'object' || value === null) return false;\n const event = value as Partial;\n return (\n event.version === 1 &&\n (event.origin === 'environment' || event.origin === 'browser') &&\n typeof event.group === 'string' &&\n typeof event.sourceId === 'string' &&\n typeof event.sourceTitle === 'string' &&\n typeof event.text === 'string' &&\n (event.relativeTimeSec === null ||\n (typeof event.relativeTimeSec === 'number' &&\n Number.isFinite(event.relativeTimeSec)))\n );\n}\n\nfunction malformedEvidenceEvent(line: number): EvidenceEvent {\n return {\n version: 1,\n origin: 'environment',\n group: 'environment',\n sourceId: 'capture-health',\n sourceTitle: 'Capture health',\n stream: 'stderr',\n segment: 'live',\n timestamp: null,\n relativeTimeSec: null,\n text: `[malformed canonical evidence row at line ${line}]`,\n captureGap: true,\n };\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execFileSync } from 'child_process';\nimport {\n appendHistory,\n buildTmuxPipeCommand,\n createWorkerConfig,\n waitForCaptureProcess,\n} from './workers.js';\nimport { appendEvidenceEvent } from './evidence.js';\nimport type {\n ExternalTmuxConnection,\n EnvironmentState,\n LauncherEnvironmentState,\n LogSourceConfig,\n LogsConfig,\n ResolvedLogSourceState,\n TmuxEnvironmentConfig,\n TmuxEnvironmentState,\n TmuxPaneState,\n} from './types.js';\nimport {\n captureProcessIdentity,\n processIdentitiesMatch,\n processIdentityMatches,\n spawnShellCommand,\n terminateOwnedProcess,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\ntype PaneMapping = {\n key: string;\n paneId: string;\n title?: string;\n group?: string;\n};\n\ntype TmuxConnection = {\n socketPath: string;\n sessionName: string;\n paneMappings: PaneMapping[];\n ownsServer: boolean;\n ownsSession: boolean;\n};\n\nexport async function startTmuxEnvironment(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n proofShotSessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n assertTmuxAvailable();\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n const captureDir = path.join(sessionDir, '.capture');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.mkdirSync(captureDir, { recursive: true, mode: 0o700 });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n let state: TmuxEnvironmentState | null = null;\n let pendingLauncher: LauncherEnvironmentState | null = null;\n let connection: TmuxConnection;\n try {\n connection =\n config.launch.kind === 'panes'\n ? startOwnedTmux(config, proofShotSessionName, (startedConnection) => {\n const startedState = createTmuxState(\n config,\n startedConnection,\n evidencePath,\n );\n state = startedState;\n onState(startedState);\n })\n : await startExternalTmux(config, (launcher) => {\n pendingLauncher = {\n kind: 'launcher',\n evidencePath,\n sources: [],\n launcher: {\n sourceId: 'external-launcher',\n process: launcher,\n pidFile: '',\n },\n };\n onState(pendingLauncher);\n });\n if (!state) {\n const connectedState = createTmuxState(\n config,\n connection,\n evidencePath,\n );\n state = connectedState;\n onState(connectedState);\n }\n } catch (error) {\n if (state) {\n await stopTmuxEnvironment(state).catch(() => {});\n } else if (pendingLauncher) {\n await terminateOwnedProcessTree(pendingLauncher.launcher.process).catch(() => {});\n }\n throw error;\n }\n\n try {\n if (!state) {\n throw new Error('tmux environment ownership state was not initialized.');\n }\n let activeState: TmuxEnvironmentState = state;\n const tmuxSources = resolveTmuxSources(config, logs, connection);\n const panes = tmuxSources.map(({ config: sourceConfig, mapping }) =>\n resolvePane(\n connection.socketPath,\n connection.sessionName,\n sourceConfig,\n mapping,\n logsDir,\n ),\n );\n const resolvedPaneIds = new Set();\n for (const { pane } of panes) {\n if (resolvedPaneIds.has(pane.paneId)) {\n throw new Error(`Multiple log sources resolved to tmux pane ${pane.paneId}.`);\n }\n resolvedPaneIds.add(pane.paneId);\n }\n disambiguateTitles(panes);\n activeState = {\n ...activeState,\n panes: panes.map(({ pane }) => pane),\n sources: panes.map(({ source }) => source),\n };\n state = activeState;\n onState(activeState);\n\n for (const pane of panes) {\n const pipeStatus = tmuxExec(connection.socketPath, [\n 'display-message',\n '-p',\n '-t',\n pane.pane.paneId,\n '#{pane_pipe}',\n ]);\n if (pipeStatus === '1') {\n throw new Error(\n `tmux pane ${pane.pane.paneId} already has a pipe-pane consumer.`,\n );\n }\n\n const pidFile = path.join(captureDir, `${pane.source.id}.pid`);\n const sourceBudget = logs.maxBytesPerSource || 5 * 1024 * 1024;\n const historyBudget = Math.max(1, Math.floor(sourceBudget / 2));\n const workerConfig = createWorkerConfig({\n evidencePath,\n logPath: pane.source.logPath,\n pidFile,\n startTimeMs,\n maxBytes: Math.max(1, sourceBudget - historyBudget),\n stripAnsi: logs.stripAnsi !== false,\n source: pane.source,\n });\n tmuxExec(connection.socketPath, [\n 'pipe-pane',\n '-t',\n pane.pane.paneId,\n buildTmuxPipeCommand(workerConfig),\n ]);\n pane.pane.captureAttached = true;\n activeState = {\n ...activeState,\n panes: activeState.panes.map((ownedPane) =>\n ownedPane.paneId === pane.pane.paneId\n ? { ...ownedPane, captureAttached: true }\n : ownedPane,\n ),\n };\n state = activeState;\n onState(activeState);\n const history = tmuxExec(connection.socketPath, [\n 'capture-pane',\n '-p',\n '-S',\n '-',\n '-t',\n pane.pane.paneId,\n ]);\n appendHistory(\n history,\n pane.source,\n evidencePath,\n historyBudget,\n logs.stripAnsi !== false,\n 'pty',\n );\n appendEvidenceEvent(evidencePath, {\n version: 1,\n origin: 'environment',\n group: pane.source.group,\n sourceId: pane.source.id,\n sourceTitle: pane.source.title,\n stream: 'pty',\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: '[tmux history/live capture boundary]',\n });\n const capture = await waitForCaptureProcess(pane.source.id, pidFile);\n activeState = {\n ...activeState,\n captures: [...activeState.captures, capture],\n };\n state = activeState;\n onState(activeState);\n }\n return activeState;\n } catch (error) {\n if (state) {\n await stopTmuxEnvironment(state).catch(() => {});\n }\n throw error;\n }\n}\n\nexport async function stopTmuxEnvironment(\n state: TmuxEnvironmentState,\n): Promise {\n const errors: Error[] = [];\n let socketMatches = false;\n let socketIdentityError: Error | null = null;\n if (fs.existsSync(state.socket.path)) {\n try {\n assertSocketIdentity(state);\n socketMatches = true;\n } catch (error) {\n socketIdentityError = toError(error);\n }\n }\n\n const currentServer = captureProcessIdentity(state.serverProcess.pid);\n const serverIdentityReused = Boolean(\n currentServer && !processIdentitiesMatch(currentServer, state.serverProcess),\n );\n if (serverIdentityReused) {\n errors.push(new Error('tmux server identity changed; refusing widened cleanup.'));\n }\n const serverMatches = processIdentityMatches(state.serverProcess);\n if (\n socketIdentityError &&\n (serverMatches ||\n state.captures.some((capture) => processIdentityMatches(capture.process)))\n ) {\n errors.push(socketIdentityError);\n }\n\n if (serverMatches && socketMatches) {\n try {\n if (state.stopCommand) {\n await runCommand(state.stopCommand, state.stopCwd || process.cwd());\n } else if (state.ownsSession && tmuxHasSession(state)) {\n tmuxExec(state.socket.path, ['kill-session', '-t', state.sessionName]);\n } else {\n for (const pane of state.panes.filter(\n (candidate) => candidate.captureAttached,\n )) {\n try {\n tmuxExec(state.socket.path, ['pipe-pane', '-t', pane.paneId]);\n } catch {\n // A launcher-provided shutdown may already have removed the pane.\n }\n }\n }\n } catch (error) {\n errors.push(toError(error));\n }\n }\n\n for (const capture of state.captures) {\n try {\n await terminateOwnedProcess(capture.process, { graceMs: 500 });\n if (processIdentityMatches(capture.process)) {\n throw new Error(`Log helper for ${capture.sourceId} did not stop.`);\n }\n } catch (error) {\n errors.push(toError(error));\n }\n }\n\n if (state.ownsServer && !serverIdentityReused) {\n if (processIdentityMatches(state.serverProcess) && socketMatches) {\n try {\n tmuxExec(state.socket.path, ['kill-server']);\n } catch {\n // Exact process-session termination below is the verified fallback.\n }\n }\n if (processIdentityMatches(state.serverProcess)) {\n try {\n await terminateOwnedProcessTree(state.serverProcess, { graceMs: 500 });\n } catch (error) {\n errors.push(toError(error));\n }\n }\n if (processIdentityMatches(state.serverProcess)) {\n errors.push(new Error('Owned tmux server did not stop.'));\n }\n }\n\n if (\n state.ownsServer &&\n socketMatches &&\n !processIdentityMatches(state.serverProcess) &&\n fs.existsSync(state.socket.path)\n ) {\n try {\n const currentSocket = captureSocketIdentity(state.socket.path);\n if (\n currentSocket.inode !== state.socket.inode ||\n currentSocket.uid !== state.socket.uid\n ) {\n throw new Error('tmux socket changed before final cleanup.');\n }\n fs.unlinkSync(state.socket.path);\n } catch (error) {\n errors.push(toError(error));\n }\n }\n if (\n state.ownsSession &&\n processIdentityMatches(state.serverProcess) &&\n socketMatches &&\n tmuxHasSession(state)\n ) {\n errors.push(new Error(`Owned tmux session ${state.sessionName} did not stop.`));\n }\n if (errors.length > 0) {\n throw new AggregateError(errors, 'One or more tmux cleanup steps failed.');\n }\n}\n\nfunction toError(error: unknown): Error {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction startOwnedTmux(\n config: TmuxEnvironmentConfig,\n proofShotSessionName: string,\n onStarted: (connection: TmuxConnection) => void,\n): TmuxConnection {\n if (config.launch.kind !== 'panes' || config.launch.panes.length === 0) {\n throw new Error('tmux pane launch requires at least one pane.');\n }\n const paneIds = new Set();\n for (const pane of config.launch.panes) {\n validateId(pane.id);\n if (paneIds.has(pane.id)) {\n throw new Error(`Duplicate tmux pane id: ${pane.id}`);\n }\n paneIds.add(pane.id);\n buildPaneCommand(pane);\n }\n const uid = process.getuid?.() ?? process.pid;\n const socketDir = path.join('/tmp', `proofshot-${uid}`, 'tmux');\n fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });\n const socketPath = path.join(socketDir, `${proofShotSessionName}.sock`);\n if (fs.existsSync(socketPath)) {\n throw new Error(`Refusing to reuse an existing tmux socket: ${socketPath}`);\n }\n\n const sessionName = config.launch.sessionName || proofShotSessionName;\n const [firstPane, ...remainingPanes] = config.launch.panes;\n const first = parsePaneOutput(\n tmuxExec(socketPath, [\n 'new-session',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-s',\n sessionName,\n '-n',\n 'environment',\n '-c',\n firstPane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(firstPane),\n ]),\n );\n const mappings: PaneMapping[] = [\n {\n key: firstPane.id,\n paneId: first.paneId,\n title: firstPane.title,\n group: firstPane.group,\n },\n ];\n onStarted({\n socketPath,\n sessionName,\n paneMappings: [...mappings],\n ownsServer: true,\n ownsSession: true,\n });\n configurePane(socketPath, first.paneId, firstPane.id, firstPane.title);\n\n for (const pane of remainingPanes) {\n const created = parsePaneOutput(\n tmuxExec(socketPath, [\n 'split-window',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-t',\n `${sessionName}:environment`,\n '-c',\n pane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(pane),\n ]),\n );\n configurePane(socketPath, created.paneId, pane.id, pane.title);\n mappings.push({\n key: pane.id,\n paneId: created.paneId,\n title: pane.title,\n group: pane.group,\n });\n }\n tmuxExec(socketPath, ['select-layout', '-t', `${sessionName}:environment`, 'tiled']);\n return {\n socketPath,\n sessionName,\n paneMappings: mappings,\n ownsServer: true,\n ownsSession: true,\n };\n}\n\nfunction createTmuxState(\n config: TmuxEnvironmentConfig,\n connection: TmuxConnection,\n evidencePath: string,\n): TmuxEnvironmentState {\n const serverPid = Number(\n tmuxExec(connection.socketPath, ['display-message', '-p', '#{pid}']),\n );\n const serverProcess = captureProcessIdentity(serverPid);\n if (!serverProcess) {\n throw new Error('ProofShot could not capture the exact tmux server identity.');\n }\n return {\n kind: 'tmux',\n evidencePath,\n sources: [],\n socket: captureSocketIdentity(connection.socketPath),\n serverProcess,\n sessionName: connection.sessionName,\n ownsServer: connection.ownsServer,\n ownsSession: connection.ownsSession,\n panes: [],\n captures: [],\n stopCommand:\n config.launch.kind === 'external-command'\n ? config.launch.stopCommand\n : undefined,\n stopCwd: config.cwd,\n };\n}\n\nasync function startExternalTmux(\n config: TmuxEnvironmentConfig,\n onLauncherStarted: (identity: NonNullable>) => void,\n): Promise {\n if (config.launch.kind !== 'external-command' || !config.connection) {\n throw new Error('External tmux launch requires a connection contract.');\n }\n const hintedSocket = config.connection.socket;\n const socketExistedBefore = hintedSocket ? fs.existsSync(hintedSocket) : true;\n const attachOnly = config.connection.ownership === 'attach';\n if (\n !attachOnly &&\n ((!hintedSocket && !config.launch.stopCommand) ||\n (socketExistedBefore && !config.launch.stopCommand))\n ) {\n throw new Error(\n 'External tmux launch against an existing or undisclosed socket requires stopCommand.',\n );\n }\n const output = await runCommand(\n config.launch.command,\n config.cwd || process.cwd(),\n onLauncherStarted,\n config.launch.timeoutMs,\n );\n const parsed =\n config.connection.format === 'json'\n ? parseJsonConnection(output)\n : parseAttachCommand(output, config.cwd || process.cwd());\n const ownsCreatedSocket =\n hintedSocket !== undefined &&\n path.resolve(hintedSocket) === path.resolve(parsed.socketPath) &&\n !socketExistedBefore;\n return {\n ...parsed,\n ownsServer: ownsCreatedSocket,\n ownsSession: ownsCreatedSocket,\n };\n}\n\nfunction resolveTmuxSources(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n connection: TmuxConnection,\n): Array<{\n config: Extract;\n mapping?: PaneMapping;\n}> {\n const configured = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'tmux-pane',\n );\n if (configured.length > 0) {\n return configured.map((source) => {\n const connectionKey =\n 'connectionKey' in source.match\n ? source.match.connectionKey\n : undefined;\n return {\n config: source,\n mapping: connectionKey\n ? connection.paneMappings.find(\n (mapping) => mapping.key === connectionKey,\n )\n : undefined,\n };\n });\n }\n if (config.launch.kind !== 'panes') {\n return [];\n }\n return connection.paneMappings.map((mapping) => ({\n config: {\n id: mapping.key,\n title: mapping.title,\n group: mapping.group,\n kind: 'tmux-pane',\n match: { connectionKey: mapping.key },\n },\n mapping,\n }));\n}\n\nfunction resolvePane(\n socketPath: string,\n sessionName: string,\n sourceConfig: Extract,\n mapping: PaneMapping | undefined,\n logsDir: string,\n): { pane: TmuxPaneState; source: ResolvedLogSourceState } {\n let target: string;\n if ('connectionKey' in sourceConfig.match) {\n if (!mapping) {\n throw new Error(\n `No tmux pane mapping matched connection key \"${sourceConfig.match.connectionKey}\".`,\n );\n }\n target = mapping.paneId;\n } else if ('tag' in sourceConfig.match) {\n const tag = sourceConfig.match.tag;\n const matches = tmuxExec(socketPath, [\n 'list-panes',\n '-t',\n sessionName,\n '-F',\n '#{pane_id}\\t#{@proofshot-source}',\n ])\n .split('\\n')\n .filter((line) => line.split('\\t')[1] === tag);\n if (matches.length !== 1) {\n throw new Error(\n `Expected one tmux pane tagged \"${tag}\", found ${matches.length}.`,\n );\n }\n target = matches[0].split('\\t')[0];\n } else {\n target = sourceConfig.match.target;\n }\n\n const fields = tmuxExec(socketPath, [\n 'display-message',\n '-p',\n '-t',\n target,\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}\\t#{pane_title}\\t#{session_name}\\t#{session_name}:#{window_name}.#{pane_index}',\n ]).split('\\t');\n if (fields.length !== 6) {\n throw new Error(`Could not resolve tmux pane metadata for ${target}.`);\n }\n if (fields[4] !== sessionName) {\n throw new Error(\n `tmux pane ${fields[0]} belongs to session \"${fields[4]}\", expected \"${sessionName}\".`,\n );\n }\n const paneIndex = Number(fields[1]);\n const tmuxTitle = fields[3].trim();\n const title =\n mapping?.title ||\n (tmuxTitle.length > 0 ? tmuxTitle : `Pane ${paneIndex}`);\n const group = sourceConfig.group || mapping?.group || 'environment';\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title,\n group,\n kind: 'tmux-pane',\n stream: 'pty',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n return {\n source,\n pane: {\n paneId: fields[0],\n paneIndex,\n panePid: Number(fields[2]),\n sourceId: source.id,\n title,\n group,\n target: fields[5],\n captureAttached: false,\n },\n };\n}\n\nfunction disambiguateTitles(\n panes: Array<{ pane: TmuxPaneState; source: ResolvedLogSourceState }>,\n): void {\n const counts = new Map();\n for (const pane of panes) {\n counts.set(pane.source.title, (counts.get(pane.source.title) || 0) + 1);\n }\n for (const pane of panes) {\n if ((counts.get(pane.source.title) || 0) > 1) {\n const title = `${pane.source.title} (Pane ${pane.pane.paneIndex})`;\n pane.source.title = title;\n pane.pane.title = title;\n }\n }\n}\n\nfunction configurePane(\n socketPath: string,\n paneId: string,\n sourceId: string,\n title?: string,\n): void {\n validateId(sourceId);\n tmuxExec(socketPath, [\n 'set-option',\n '-p',\n '-t',\n paneId,\n '@proofshot-source',\n sourceId,\n ]);\n if (title) {\n tmuxExec(socketPath, ['select-pane', '-t', paneId, '-T', title]);\n }\n}\n\nfunction buildPaneCommand(\n pane: { command: string; env?: Record },\n): string {\n const assignments = Object.entries(pane.env || {}).map(([key, value]) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\n throw new Error(`Invalid environment variable name: ${key}`);\n }\n return `${key}=${shellQuote(value)}`;\n });\n return assignments.length > 0\n ? `env ${assignments.join(' ')} ${pane.command}`\n : pane.command;\n}\n\nfunction parsePaneOutput(output: string): {\n paneId: string;\n paneIndex: number;\n panePid: number;\n} {\n const [paneId, paneIndex, panePid] = output.split('\\t');\n if (!paneId || !Number.isInteger(Number(paneIndex)) || !Number.isInteger(Number(panePid))) {\n throw new Error(`Unexpected tmux pane output: ${output}`);\n }\n return { paneId, paneIndex: Number(paneIndex), panePid: Number(panePid) };\n}\n\nfunction parseJsonConnection(output: string): TmuxConnection {\n const parsed = JSON.parse(output) as Partial;\n if (\n !parsed.tmux ||\n !path.isAbsolute(parsed.tmux.socket) ||\n typeof parsed.tmux.session !== 'string' ||\n parsed.tmux.session.length === 0 ||\n (parsed.tmux.panes !== undefined && !Array.isArray(parsed.tmux.panes))\n ) {\n throw new Error('External launcher returned invalid tmux JSON.');\n }\n const paneMappings: PaneMapping[] = [];\n const keys = new Set();\n const paneIds = new Set();\n for (const [index, pane] of (parsed.tmux.panes || []).entries()) {\n if (\n typeof pane !== 'object' ||\n pane === null ||\n typeof pane.key !== 'string' ||\n !/^[A-Za-z0-9_-]+$/.test(pane.key) ||\n typeof pane.paneId !== 'string' ||\n !/^%\\d+$/.test(pane.paneId) ||\n (pane.title !== undefined && typeof pane.title !== 'string') ||\n (pane.group !== undefined && typeof pane.group !== 'string')\n ) {\n throw new Error(`External launcher returned invalid pane mapping at index ${index}.`);\n }\n if (keys.has(pane.key) || paneIds.has(pane.paneId)) {\n throw new Error('External launcher returned duplicate pane mappings.');\n }\n keys.add(pane.key);\n paneIds.add(pane.paneId);\n paneMappings.push(pane);\n }\n return {\n socketPath: parsed.tmux.socket,\n sessionName: parsed.tmux.session,\n paneMappings,\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction parseAttachCommand(output: string, cwd: string): TmuxConnection {\n const tokens = tokenizeShellCommand(output);\n const tmuxIndex = tokens.findIndex((token) => path.basename(token) === 'tmux');\n const attachIndex = tokens.findIndex(\n (token, index) =>\n index > tmuxIndex && (token === 'attach' || token === 'attach-session'),\n );\n const targetIndex = tokens.indexOf('-t', attachIndex + 1);\n const socketIndex = tokens.indexOf('-S', tmuxIndex + 1);\n const labelIndex = tokens.indexOf('-L', tmuxIndex + 1);\n if (\n tmuxIndex < 0 ||\n attachIndex < 0 ||\n targetIndex < 0 ||\n !tokens[targetIndex + 1] ||\n (socketIndex < 0 && labelIndex < 0)\n ) {\n throw new Error('External launcher did not emit a supported tmux attach command.');\n }\n const flag = socketIndex >= 0 ? '-S' : '-L';\n const valueIndex = socketIndex >= 0 ? socketIndex + 1 : labelIndex + 1;\n const value = tokens[valueIndex];\n const sessionName = tokens[targetIndex + 1];\n if (!value) {\n throw new Error('External launcher emitted a tmux socket flag without a value.');\n }\n const socketPath =\n flag === '-S'\n ? path.resolve(cwd, value)\n : execFileSync(\n 'tmux',\n ['-L', value, 'display-message', '-p', '#{socket_path}'],\n { encoding: 'utf-8' },\n ).trim();\n return {\n socketPath,\n sessionName,\n paneMappings: [],\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction tokenizeShellCommand(command: string): string[] {\n const tokens: string[] = [];\n let current = '';\n let quote: \"'\" | '\"' | null = null;\n let escaping = false;\n for (const character of command.trim()) {\n if (escaping) {\n current += character;\n escaping = false;\n } else if (character === '\\\\' && quote !== \"'\") {\n escaping = true;\n } else if (quote) {\n if (character === quote) quote = null;\n else current += character;\n } else if (character === \"'\" || character === '\"') {\n quote = character;\n } else if (/\\s/.test(character)) {\n if (current) {\n tokens.push(current);\n current = '';\n }\n } else {\n current += character;\n }\n }\n if (escaping || quote) {\n throw new Error('External launcher emitted an unterminated tmux attach command.');\n }\n if (current) tokens.push(current);\n return tokens;\n}\n\nfunction tmuxExec(socketPath: string, args: string[]): string {\n return execFileSync('tmux', ['-S', socketPath, ...args], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trimEnd();\n}\n\nfunction tmuxHasSession(state: TmuxEnvironmentState): boolean {\n if (!processIdentityMatches(state.serverProcess)) {\n return false;\n }\n try {\n tmuxExec(state.socket.path, ['has-session', '-t', state.sessionName]);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function runCommand(\n command: string,\n cwd: string,\n onStarted?: (\n identity: NonNullable>,\n ) => void,\n timeoutMs = 30_000,\n): Promise {\n const child = spawnShellCommand(command, {\n cwd,\n detached: true,\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n const identity = child.pid ? captureProcessIdentity(child.pid) : null;\n if (!identity) {\n throw new Error('ProofShot could not capture the external launcher identity.');\n }\n try {\n onStarted?.(identity);\n } catch (error) {\n await terminateOwnedProcessTree(identity);\n throw error;\n }\n let stdout = '';\n let stderr = '';\n child.stdout?.on('data', (chunk) => {\n stdout += chunk.toString();\n });\n child.stderr?.on('data', (chunk) => {\n stderr += chunk.toString();\n });\n const outcome = await new Promise<\n { kind: 'exit'; code: number | null } | { kind: 'timeout' }\n >((resolve, reject) => {\n const timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs);\n child.once('error', reject);\n child.once('close', (code) => {\n clearTimeout(timer);\n resolve({ kind: 'exit', code });\n });\n });\n if (outcome.kind === 'timeout') {\n await terminateOwnedProcessTree(identity);\n throw new Error(`External environment command timed out after ${timeoutMs}ms.`);\n }\n const exitCode = outcome.code;\n if (exitCode !== 0) {\n await terminateOwnedProcessTree(identity);\n throw new Error(\n `External environment command failed with code ${String(exitCode)}: ${stderr.trim()}`,\n );\n }\n return stdout.trim();\n}\n\nfunction captureSocketIdentity(socketPath: string): {\n path: string;\n inode: number;\n uid: number;\n} {\n const stat = fs.lstatSync(socketPath);\n if (!stat.isSocket() || stat.isSymbolicLink()) {\n throw new Error(`tmux socket is not an owned Unix socket: ${socketPath}`);\n }\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(`tmux socket is owned by uid ${stat.uid}, expected ${uid}.`);\n }\n return { path: socketPath, inode: stat.ino, uid: stat.uid };\n}\n\nfunction assertSocketIdentity(state: TmuxEnvironmentState): void {\n if (!fs.existsSync(state.socket.path)) {\n if (!processIdentityMatches(state.serverProcess)) {\n return;\n }\n throw new Error('Owned tmux socket disappeared while its server is still alive.');\n }\n const current = captureSocketIdentity(state.socket.path);\n if (current.inode !== state.socket.inode || current.uid !== state.socket.uid) {\n throw new Error('tmux socket identity changed; refusing widened cleanup.');\n }\n}\n\nfunction assertTmuxAvailable(): void {\n try {\n execFileSync('tmux', ['-V'], { stdio: 'pipe' });\n } catch {\n throw new Error('tmux is required for environment.kind \"tmux\".');\n }\n}\n\nfunction validateId(id: string): void {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport {\n clearAgentBrowserSessionFiles,\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n captureProcessIdentity,\n ownedProcessTreeIsAlive,\n processIdentitiesMatch,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n if (!identity && session.browserLaunchAttempted) {\n throw new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n assertIdentityNotReused(identity, 'browser');\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n let gracefulCloseError: unknown;\n if (identity && processIdentityMatches(identity)) {\n try {\n closeBrowser(session.sessionName);\n } catch (error) {\n gracefulCloseError = error;\n }\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new AggregateError(\n [\n ...(gracefulCloseError ? [gracefulCloseError] : []),\n new Error(`Owned browser process session ${identity.sessionId} did not stop.`),\n ],\n 'Browser cleanup failed.',\n );\n }\n if (session.agentBrowserSocketDir) {\n clearAgentBrowserSessionFiles(session.agentBrowserSocketDir, session.sessionName);\n }\n if (gracefulCloseError) {\n console.warn(\n `ProofShot graceful browser close failed; exact owned-process cleanup succeeded: ${\n gracefulCloseError instanceof Error\n ? gracefulCloseError.message\n : String(gracefulCloseError)\n }`,\n );\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n assertIdentityNotReused(session.serverProcess, 'server');\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nfunction assertIdentityNotReused(\n identity: ProcessIdentity | null | undefined,\n label: string,\n): void {\n if (!identity) return;\n const current = captureProcessIdentity(identity.pid);\n if (current && !processIdentitiesMatch(current, identity)) {\n throw new Error(\n `Owned ${label} process identity no longer matches PID ${identity.pid}; cleanup state was retained.`,\n );\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedEnvironment(session.environment);\n } catch (error) {\n cleanupError ||= error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.userInfo().homedir,\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n repository?: string;\n repositoryRoot?: string;\n treeHash?: string;\n sourceDirty?: boolean;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport type { CanonicalEvidence, Verdict } from '../artifacts/evidence.js';\nimport type { SessionMetadata } from './metadata.js';\n\nconst MANIFEST_FILENAME = 'artifact-manifest.json';\n\nexport type GitProvenance = {\n repository: string;\n branch: string;\n commitSha: string;\n treeHash: string;\n sourceDirty: boolean;\n};\n\nexport type ManifestArtifactKind =\n | 'screenshot'\n | 'video'\n | 'viewer'\n | 'summary'\n | 'evidence'\n | 'verdict'\n | 'log';\n\nexport type ManifestArtifact = {\n id: string;\n kind: ManifestArtifactKind;\n path: string;\n sha256: string;\n size: number;\n order: number;\n};\n\nexport type ArtifactManifest = {\n version: 1;\n sessionId: string;\n repository: string;\n branch: string;\n commitSha: string;\n treeHash: string;\n sourceDirty: boolean;\n sourceDrift: boolean;\n startedAt: string;\n finalizedAt: string;\n completion: 'complete';\n verdict: Verdict['status'];\n artifacts: ManifestArtifact[];\n};\n\nexport function captureGitProvenance(\n cwd: string = process.cwd(),\n excludedPaths: string[] = [],\n): GitProvenance {\n const git = (args: string[]): string =>\n execFileSync('git', args, {\n cwd,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trim();\n try {\n const repository = normalizeRepository(git(['remote', 'get-url', 'origin']));\n const branch = git(['branch', '--show-current']);\n const commitSha = git(['rev-parse', 'HEAD']);\n const treeHash = git(['rev-parse', 'HEAD^{tree}']);\n const exclusions = excludedPaths\n .map((excludedPath) => path.relative(cwd, path.resolve(excludedPath)))\n .filter((relativePath) => relativePath && !relativePath.startsWith('..'))\n .map(\n (relativePath) =>\n `:(exclude)${relativePath.split(path.sep).join(path.posix.sep)}`,\n );\n const sourceDirty =\n git([\n 'status',\n '--porcelain',\n '--untracked-files=all',\n '--',\n '.',\n ...exclusions,\n ]) !== '';\n return { repository, branch, commitSha, treeHash, sourceDirty };\n } catch {\n return {\n repository: '',\n branch: '',\n commitSha: '',\n treeHash: '',\n sourceDirty: true,\n };\n }\n}\n\nexport function normalizeRepository(remote: string): string {\n const trimmed = remote.trim();\n const scpStyle = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);\n if (scpStyle && !trimmed.includes('://')) {\n return `${scpStyle[1]}/${scpStyle[2]}`\n .replace(/\\.git$/, '')\n .replace(/\\/$/, '');\n }\n try {\n const parsed = new URL(trimmed);\n return `${parsed.hostname}${parsed.pathname}`\n .replace(/\\.git$/, '')\n .replace(/\\/$/, '');\n } catch {\n return trimmed\n .replace(/\\.git$/, '')\n .replace(/\\/$/, '');\n }\n}\n\nexport function writeArtifactManifest(options: {\n sessionId: string;\n sessionDir: string;\n metadata: SessionMetadata;\n evidence: CanonicalEvidence;\n verdict: Verdict;\n finalizedProvenance?: GitProvenance;\n}): ArtifactManifest {\n const finalized =\n options.finalizedProvenance ||\n captureGitProvenance(options.metadata.repositoryRoot, [\n path.dirname(options.sessionDir),\n ]);\n const sourceDrift =\n (options.metadata.repository || '') !== finalized.repository ||\n options.metadata.branch !== finalized.branch ||\n options.metadata.commitSha !== finalized.commitSha ||\n (options.metadata.treeHash || '') !== finalized.treeHash ||\n options.metadata.sourceDirty !== false ||\n finalized.sourceDirty;\n const artifacts = collectManifestArtifacts(\n options.sessionDir,\n options.evidence,\n );\n const manifest: ArtifactManifest = {\n version: 1,\n sessionId: options.sessionId,\n repository: options.metadata.repository || '',\n branch: options.metadata.branch,\n commitSha: options.metadata.commitSha,\n treeHash: options.metadata.treeHash || '',\n sourceDirty: options.metadata.sourceDirty !== false,\n sourceDrift,\n startedAt: options.metadata.startedAt,\n finalizedAt: new Date().toISOString(),\n completion: 'complete',\n verdict: options.verdict.status,\n artifacts,\n };\n writeJsonAtomically(\n path.join(options.sessionDir, MANIFEST_FILENAME),\n manifest,\n );\n return manifest;\n}\n\nexport function loadArtifactManifest(\n sessionDir: string,\n): ArtifactManifest | null {\n const manifestPath = path.join(sessionDir, MANIFEST_FILENAME);\n try {\n if (\n fs.lstatSync(sessionDir).isSymbolicLink() ||\n fs.lstatSync(manifestPath).isSymbolicLink()\n ) {\n return null;\n }\n const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));\n return isArtifactManifest(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nexport function validateManifestArtifacts(\n sessionDir: string,\n manifest: ArtifactManifest,\n): void {\n const root = fs.realpathSync(sessionDir);\n const ids = new Set();\n const paths = new Set();\n for (const [index, artifact] of manifest.artifacts.entries()) {\n if (ids.has(artifact.id)) {\n throw new Error(`Duplicate artifact ID: ${artifact.id}`);\n }\n ids.add(artifact.id);\n if (paths.has(artifact.path)) {\n throw new Error(`Duplicate artifact path: ${artifact.path}`);\n }\n paths.add(artifact.path);\n if (artifact.order !== index) {\n throw new Error(`Artifact order is invalid for ${artifact.id}.`);\n }\n if (\n !artifact.path ||\n path.isAbsolute(artifact.path) ||\n artifact.path.split(/[\\\\/]/).includes('..')\n ) {\n throw new Error(`Unsafe artifact path: ${artifact.path}`);\n }\n if (\n (artifact.kind === 'screenshot' || artifact.kind === 'video') &&\n path.dirname(artifact.path) !== '.'\n ) {\n throw new Error(\n `Publishable media must be stored at the session root: ${artifact.path}`,\n );\n }\n const artifactPath = path.resolve(sessionDir, artifact.path);\n let componentPath = sessionDir;\n for (const component of artifact.path.split(/[\\\\/]/)) {\n componentPath = path.join(componentPath, component);\n if (fs.lstatSync(componentPath).isSymbolicLink()) {\n throw new Error(`Artifact path contains a symlink: ${artifact.path}`);\n }\n }\n const stat = fs.lstatSync(artifactPath);\n if (stat.isSymbolicLink() || !stat.isFile()) {\n throw new Error(`Artifact is not a regular file: ${artifact.path}`);\n }\n const realPath = fs.realpathSync(artifactPath);\n if (!realPath.startsWith(`${root}${path.sep}`)) {\n throw new Error(`Artifact escapes its session directory: ${artifact.path}`);\n }\n const contents = fs.readFileSync(realPath);\n const hash = createHash('sha256').update(contents).digest('hex');\n if (hash !== artifact.sha256 || contents.length !== artifact.size) {\n throw new Error(`Artifact hash mismatch: ${artifact.path}`);\n }\n }\n}\n\nfunction isArtifactManifest(value: unknown): value is ArtifactManifest {\n if (typeof value !== 'object' || value === null) return false;\n const manifest = value as Partial;\n return (\n manifest.version === 1 &&\n typeof manifest.sessionId === 'string' &&\n typeof manifest.repository === 'string' &&\n typeof manifest.branch === 'string' &&\n typeof manifest.commitSha === 'string' &&\n typeof manifest.treeHash === 'string' &&\n typeof manifest.sourceDirty === 'boolean' &&\n typeof manifest.sourceDrift === 'boolean' &&\n typeof manifest.startedAt === 'string' &&\n typeof manifest.finalizedAt === 'string' &&\n manifest.completion === 'complete' &&\n (manifest.verdict === 'PASS' ||\n manifest.verdict === 'FAIL' ||\n manifest.verdict === 'INCOMPLETE' ||\n manifest.verdict === 'BLOCKED') &&\n Array.isArray(manifest.artifacts) &&\n manifest.artifacts.every(\n (artifact, index) =>\n typeof artifact === 'object' &&\n artifact !== null &&\n typeof artifact.id === 'string' &&\n typeof artifact.path === 'string' &&\n typeof artifact.sha256 === 'string' &&\n typeof artifact.size === 'number' &&\n artifact.size >= 0 &&\n artifact.order === index &&\n [\n 'screenshot',\n 'video',\n 'viewer',\n 'summary',\n 'evidence',\n 'verdict',\n 'log',\n ].includes(artifact.kind),\n )\n );\n}\n\nfunction collectManifestArtifacts(\n sessionDir: string,\n evidence: CanonicalEvidence,\n): ManifestArtifact[] {\n const screenshotOrder = new Map(\n evidence.actions\n .map((action) => action.action.match(/^screenshot\\s+(.+)$/)?.[1])\n .filter((value): value is string => Boolean(value))\n .map((value, index) => [path.basename(value), index]),\n );\n const verifiedScreenshots = new Set(\n evidence.screenshots\n .filter(\n (screenshot) =>\n screenshot.validPng &&\n !screenshot.visuallyBlank &&\n screenshot.sha256 !== null,\n )\n .map((screenshot) => screenshot.file),\n );\n const candidates = listArtifactFiles(sessionDir)\n .filter((file) => {\n const kind = classifyArtifact(file);\n return (\n kind !== null &&\n (kind !== 'screenshot' || verifiedScreenshots.has(path.basename(file)))\n );\n })\n .sort((left, right) => {\n const leftOrder = screenshotOrder.get(path.basename(left));\n const rightOrder = screenshotOrder.get(path.basename(right));\n if (leftOrder !== undefined || rightOrder !== undefined) {\n return (leftOrder ?? Number.MAX_SAFE_INTEGER) -\n (rightOrder ?? Number.MAX_SAFE_INTEGER);\n }\n return left.localeCompare(right);\n });\n return candidates.map((file, order) => {\n const contents = fs.readFileSync(path.join(sessionDir, file));\n const kind = classifyArtifact(file)!;\n return {\n id: `${kind}:${file}`,\n kind,\n path: file,\n sha256: createHash('sha256').update(contents).digest('hex'),\n size: contents.length,\n order,\n };\n });\n}\n\nfunction listArtifactFiles(\n root: string,\n current: string = root,\n): string[] {\n const files: string[] = [];\n for (const entry of fs.readdirSync(current, { withFileTypes: true })) {\n if (entry.isSymbolicLink()) {\n continue;\n }\n const absolutePath = path.join(current, entry.name);\n if (entry.isDirectory()) {\n files.push(...listArtifactFiles(root, absolutePath));\n } else if (entry.isFile()) {\n files.push(path.relative(root, absolutePath));\n }\n }\n return files;\n}\n\nfunction classifyArtifact(file: string): ManifestArtifactKind | null {\n const basename = path.basename(file);\n const isSessionRoot = path.dirname(file) === '.';\n if (isSessionRoot && file.endsWith('.png')) return 'screenshot';\n if (\n isSessionRoot &&\n (basename === 'session.webm' || basename === 'session.mp4')\n ) {\n return 'video';\n }\n if (isSessionRoot && basename === 'viewer.html') return 'viewer';\n if (isSessionRoot && basename === 'SUMMARY.md') return 'summary';\n if (isSessionRoot && basename === 'evidence.json') return 'evidence';\n if (isSessionRoot && basename === 'verdict.json') return 'verdict';\n if (file.endsWith('.log') || file.endsWith('.ndjson')) return 'log';\n return null;\n}\n\nfunction writeJsonAtomically(filePath: string, value: unknown): void {\n const temporaryPath = `${filePath}.${process.pid}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, filePath);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport {\n probeMediaDuration,\n writeCanonicalEvidence,\n} from '../artifacts/evidence.js';\nimport { loadMetadata } from '../session/metadata.js';\nimport { writeArtifactManifest } from '../session/manifest.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { processIdentityMatches } from '../utils/process.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n const stopSignals = installStopSignalHandlers();\n try {\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n session.stoppedAt ||= new Date().toISOString();\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive =\n session.recordingActive || Boolean(session.recordingStartedAt);\n const startTime = new Date(session.startedAt).getTime();\n const recordingStartTime = session.recordingStartedAt\n ? new Date(session.recordingStartedAt).getTime()\n : startTime;\n const recordingStartOffsetSec = Math.max(\n 0,\n (recordingStartTime - startTime) / 1000,\n );\n const durationMs = new Date(session.stoppedAt).getTime() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n let consoleCollectionSucceeded = false;\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n consoleCollectionSucceeded = true;\n } catch {\n consoleCollectionSucceeded = false;\n }\n }\n if (consoleCollectionSucceeded) {\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n } else {\n session.consoleEvidenceAvailable = false;\n session.consoleErrorCount = 0;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n const cleanupErrors: unknown[] = [];\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupErrors.push(error);\n }\n }\n\n if (\n session.environment &&\n !session.environmentStopped &&\n session.environment.kind !== 'launcher'\n ) {\n const captures =\n session.environment.kind === 'tmux'\n ? session.environment.captures\n : session.environment.processes;\n session.environment.healthFailures = captures\n .filter((capture) => !processIdentityMatches(capture.process))\n .map((capture) => capture.sourceId);\n persistOwnedSession(session, controlDir);\n }\n const finalizedEnvironment = session.environment;\n if (session.environment && !session.environmentStopped) {\n console.log(chalk.dim('Stopping environment...'));\n try {\n await stopOwnedEnvironment(session.environment);\n session.environmentStopped = true;\n persistOwnedSession(session, controlDir);\n } catch (error) {\n cleanupErrors.push(error);\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupErrors.push(error);\n }\n }\n if (cleanupErrors.length > 0) {\n const cleanupError = new AggregateError(\n cleanupErrors,\n `Cleanup failed: ${cleanupErrors\n .map((error) => (error instanceof Error ? error.message : String(error)))\n .join('; ')}`,\n );\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? recordingStartOffsetSec;\n if (!session.videoTrimComplete) {\n let videoTrimOffsetSec = 0;\n if (fs.existsSync(session.videoPath)) {\n videoTrimOffsetSec = trimVideo(\n session.videoPath,\n screenshots,\n sessionDir,\n startTime,\n sessionLog,\n recordingStartOffsetSec,\n );\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n trimOffsetSec = recordingStartOffsetSec + videoTrimOffsetSec;\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n const canonicalDurationSec = Math.max(0, durationSec - trimOffsetSec);\n const { evidence, verdict } = writeCanonicalEvidence({\n sessionId: session.sessionName,\n sessionDir,\n initialPageUrl: session.targetUrl,\n durationSec: canonicalDurationSec,\n timelineOffsetSec: trimOffsetSec,\n videoPath: session.videoPath,\n recordingWasActive,\n consoleEvidenceAvailable,\n actions: viewerEntries,\n consoleEntries: viewerConsoleEntries,\n serverEntries: viewerServerEntries,\n environment: finalizedEnvironment,\n });\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec: canonicalDurationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n evidence,\n verdict,\n });\n const metadata = loadMetadata(sessionDir) || {\n repository: '',\n repositoryRoot: session.startDirectory,\n branch: '',\n commitSha: '',\n treeHash: '',\n sourceDirty: true,\n startedAt: session.startedAt,\n description: session.description,\n };\n writeArtifactManifest({\n sessionId: session.sessionName,\n sessionDir,\n metadata,\n evidence,\n verdict,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n console.log(`🧾 Verdict: ${verdict.status}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n } finally {\n const interruptedBy = stopSignals.remove();\n if (interruptedBy) {\n process.exitCode = interruptedBy === 'SIGINT' ? 130 : 143;\n }\n }\n}\n\nfunction installStopSignalHandlers(): {\n remove: () => NodeJS.Signals | null;\n} {\n let interruptedBy: NodeJS.Signals | null = null;\n let signalCount = 0;\n let forcedExitTimer: NodeJS.Timeout | null = null;\n const handlers = new Map void>();\n const removeListeners = (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n };\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n signalCount += 1;\n interruptedBy ||= signal;\n if (signalCount >= 3) {\n removeListeners();\n process.kill(process.pid, signal);\n return;\n }\n if (signalCount === 2) {\n console.error(\n chalk.yellow(\n `Received ${signal} again; forcing exit in 5s if exact teardown does not finish.`,\n ),\n );\n forcedExitTimer = setTimeout(() => {\n removeListeners();\n process.kill(process.pid, signal);\n }, 5000);\n return;\n }\n console.error(\n chalk.yellow(`Received ${signal}; finishing exact ProofShot teardown before exit.`),\n );\n };\n handlers.set(signal, handler);\n process.on(signal, handler);\n }\n return {\n remove: (): NodeJS.Signals | null => {\n removeListeners();\n if (forcedExitTimer) clearTimeout(forcedExitTimer);\n return interruptedBy;\n },\n };\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n sessionStartMs: number,\n sessionLog: SessionLogEntry[],\n mediaStartOffsetSec = 0,\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec =\n sessionLog[0].relativeTimeSec - mediaStartOffsetSec;\n lastActionSec =\n sessionLog[sessionLog.length - 1].relativeTimeSec - mediaStartOffsetSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter(\n (timestamp): timestamp is number =>\n timestamp !== null &&\n timestamp >= sessionStartMs + mediaStartOffsetSec * 1000,\n );\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec =\n (Math.min(...timestamps) - sessionStartMs) / 1000 -\n mediaStartOffsetSec;\n lastActionSec =\n (Math.max(...timestamps) - sessionStartMs) / 1000 -\n mediaStartOffsetSec;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const timelineTrimOffsetSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n const requestedDurationSec = trimEndSec - timelineTrimOffsetSec;\n\n // Don't trim very short videos\n if (requestedDurationSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n const mediaDurationSec = probeMediaDuration(videoPath);\n const actionDurationSec = Math.max(0, lastActionSec - firstActionSec);\n const maximumPhysicalTrimSec =\n mediaDurationSec === null\n ? timelineTrimOffsetSec\n : Math.max(0, mediaDurationSec - actionDurationSec - BUFFER_BEFORE);\n const physicalTrimStartSec = Math.min(\n timelineTrimOffsetSec,\n maximumPhysicalTrimSec,\n );\n const trimDurationSec =\n mediaDurationSec === null\n ? requestedDurationSec\n : Math.min(\n requestedDurationSec,\n mediaDurationSec - physicalTrimStartSec,\n );\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-ss',\n physicalTrimStartSec.toFixed(2),\n '-i',\n rawPath,\n '-t',\n trimDurationSec.toFixed(2),\n '-map',\n '0:v:0',\n '-c:v',\n 'libvpx-vp9',\n '-deadline',\n 'realtime',\n '-cpu-used',\n '8',\n '-crf',\n '30',\n '-b:v',\n '0',\n '-an',\n '-avoid_negative_ts',\n 'make_zero',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimDurationSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return timelineTrimOffsetSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\nimport type {\n CanonicalEvidence,\n EvidenceSourceSummary,\n Verdict,\n} from './evidence.js';\nimport type { EvidenceEvent } from '../environment/types.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n evidence?: CanonicalEvidence;\n verdict?: Verdict;\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const timed = Number.isFinite(entry.relativeTimeSec);\n const time = formatTime(timed ? Math.max(0, entry.relativeTimeSec) : Number.NaN);\n const interaction = timed\n ? ` data-time=\"${entry.relativeTimeSec}\" onclick=\"seekTo(${entry.relativeTimeSec})\"`\n : '';\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n if (!Number.isFinite(sec)) {\n return 'untimed';\n }\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\nfunction titleCase(value: string): string {\n return value\n .split(/[-_\\s]+/)\n .filter(Boolean)\n .map((part) => part[0].toUpperCase() + part.slice(1))\n .join(' ');\n}\n\ntype EvidencePanel = {\n key: string;\n label: string;\n summary: EvidenceSourceSummary | null;\n events: EvidenceEvent[];\n};\n\nfunction buildEvidencePanels(evidence: CanonicalEvidence): EvidencePanel[] {\n const panels: EvidencePanel[] = [];\n for (const origin of ['environment', 'browser'] as const) {\n const originEvents = evidence.events.filter(\n (event) => event.origin === origin && !event.presentationHidden,\n );\n if (originEvents.length === 0) {\n continue;\n }\n const originLabel = origin === 'environment' ? 'Environment' : 'Browser';\n panels.push({\n key: origin,\n label: originLabel,\n summary: null,\n events: orderEvidenceEvents(originEvents),\n });\n const sources = evidence.sources\n .filter((source) => source.origin === origin)\n .sort(\n (left, right) =>\n left.group.localeCompare(right.group) ||\n left.title.localeCompare(right.title),\n );\n for (const source of sources) {\n panels.push({\n key: `${origin}-${source.id}`,\n label:\n origin === 'environment'\n ? `${titleCase(source.group)} · ${source.title}`\n : source.title,\n summary: source,\n events: orderEvidenceEvents(\n originEvents.filter((event) => event.sourceId === source.id),\n ),\n });\n }\n }\n return panels;\n}\n\nfunction orderEvidenceEvents(events: EvidenceEvent[]): EvidenceEvent[] {\n return [...events].sort((left, right) => {\n if (left.segment !== right.segment) {\n return left.segment === 'history' ? -1 : 1;\n }\n if (left.relativeTimeSec === null) {\n return -1;\n }\n if (right.relativeTimeSec === null) {\n return 1;\n }\n return left.relativeTimeSec - right.relativeTimeSec;\n });\n}\n\nfunction buildEvidenceLogLines(events: EvidenceEvent[]): string {\n if (events.length === 0) {\n return '

No visible evidence for this source

';\n }\n return `
${events\n    .slice(0, MAX_LOG_ENTRIES)\n    .map((event, index) => {\n      const timed =\n        event.relativeTimeSec !== null &&\n        Number.isFinite(event.relativeTimeSec);\n      const classes = [\n        'log-line',\n        isErrorLine(event.text) ? 'log-line-error' : '',\n      ]\n        .filter(Boolean)\n        .join(' ');\n      const interaction = timed\n        ? ` data-time=\"${event.relativeTimeSec}\" onclick=\"seekTo(${event.relativeTimeSec})\"`\n        : '';\n      const boundary = event.captureGap\n        ? 'capture gap'\n        : event.segment === 'history'\n          ? 'history'\n          : '';\n      return `${formatTime(timed ? event.relativeTimeSec! : Number.NaN)}${index + 1}${boundary}${escapeHtml(event.text)}`;\n    })\n    .join('\\n')}
${\n events.length > MAX_LOG_ENTRIES\n ? '

Viewer display truncated. Canonical evidence.json retains the bounded source evidence.

'\n : ''\n }`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (fs.existsSync(logPath)) {\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n entries = [];\n }\n } else {\n entries = [];\n }\n }\n\n const html = generateViewer({ ...data, entries: entries || [] });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport { PNG } from 'pngjs';\nimport type { SessionLogEntry } from '../commands/exec.js';\nimport { loadEvidenceEvents } from '../environment/evidence.js';\nimport type {\n EnvironmentState,\n EvidenceEvent,\n ResolvedLogSourceState,\n} from '../environment/types.js';\nimport type { TimestampedLogEntry } from './viewer.js';\n\nexport type VerdictStatus = 'PASS' | 'FAIL' | 'INCOMPLETE' | 'BLOCKED';\n\nexport type EvidenceSourceSummary = {\n id: string;\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n lineCount: number;\n hiddenLineCount: number;\n truncationCount: number;\n captureGapCount: number;\n incidentCount: number;\n};\n\nexport type EvidenceIncident = {\n id: string;\n severity: 'fatal' | 'error';\n origin: EvidenceEvent['origin'];\n group: string;\n message: string;\n count: number;\n sourceIds: string[];\n firstTimeSec: number | null;\n lastTimeSec: number | null;\n};\n\nexport type ScreenshotIntegrity = {\n file: string;\n sha256: string | null;\n validPng: boolean;\n visuallyBlank: boolean;\n size: number;\n};\n\nexport type CanonicalEvidence = {\n version: 1;\n sessionId: string;\n generatedAt: string;\n timelineDurationSec: number;\n mediaDurationSec: number | null;\n mediaDivergenceSec: number | null;\n mediaTruncated: boolean;\n actions: SessionLogEntry[];\n events: EvidenceEvent[];\n sources: EvidenceSourceSummary[];\n incidents: EvidenceIncident[];\n screenshots: ScreenshotIntegrity[];\n};\n\nexport type Verdict = {\n version: 1;\n status: VerdictStatus;\n reasons: string[];\n fatalIncidentCount: number;\n missingArtifacts: string[];\n duplicateScreenshotHashes: string[][];\n expectedSelectorFailures: string[];\n mediaTruncated: boolean;\n};\n\nexport type EvidenceBuildOptions = {\n sessionId: string;\n sessionDir: string;\n initialPageUrl?: string;\n durationSec: number;\n timelineOffsetSec?: number;\n videoPath: string;\n recordingWasActive: boolean;\n consoleEvidenceAvailable: boolean;\n actions: SessionLogEntry[];\n consoleEntries: TimestampedLogEntry[];\n serverEntries: TimestampedLogEntry[];\n environment?: EnvironmentState | null;\n};\n\nexport function writeCanonicalEvidence(\n options: EvidenceBuildOptions,\n): { evidence: CanonicalEvidence; verdict: Verdict } {\n const events = collectEvents(options);\n applyPresentationFilters(events, options.environment?.sources || []);\n const incidents = buildIncidents(events);\n const screenshots = inspectScreenshots(options.sessionDir, options.actions);\n const mediaDurationSec = probeMediaDuration(options.videoPath);\n const actionDuration = options.actions\n .map((entry) => entry.relativeTimeSec)\n .filter(Number.isFinite)\n .reduce((maximum, current) => Math.max(maximum, current), 0);\n const timelineDurationSec = Math.max(options.durationSec, actionDuration);\n const mediaDivergenceSec =\n mediaDurationSec === null\n ? null\n : Math.max(0, actionDuration - mediaDurationSec);\n const mediaTruncated =\n mediaDivergenceSec !== null && mediaDivergenceSec > 1;\n const sources = buildSourceSummaries(\n events,\n incidents,\n );\n const evidence: CanonicalEvidence = {\n version: 1,\n sessionId: options.sessionId,\n generatedAt: new Date().toISOString(),\n timelineDurationSec,\n mediaDurationSec,\n mediaDivergenceSec,\n mediaTruncated,\n actions: options.actions,\n events,\n sources,\n incidents,\n screenshots,\n };\n const verdict = buildVerdict(options, evidence);\n writeJsonAtomically(\n path.join(options.sessionDir, 'evidence.json'),\n evidence,\n );\n writeJsonAtomically(\n path.join(options.sessionDir, 'verdict.json'),\n verdict,\n );\n return { evidence, verdict };\n}\n\nfunction writeJsonAtomically(filePath: string, value: unknown): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction collectEvents(options: EvidenceBuildOptions): EvidenceEvent[] {\n const environmentEvents: EvidenceEvent[] =\n options.environment?.evidencePath &&\n fs.existsSync(options.environment.evidencePath)\n ? loadEvidenceEvents(options.environment.evidencePath).map((event) =>\n adjustEnvironmentEventTime(\n event,\n options.timelineOffsetSec ?? 0,\n ),\n )\n : [];\n if (options.environment && options.environment.kind !== 'launcher') {\n for (const sourceId of options.environment.healthFailures || []) {\n const source = options.environment.sources.find(\n (candidate) => candidate.id === sourceId,\n );\n environmentEvents.push({\n version: 1,\n origin: 'environment',\n group: source?.group || 'environment',\n sourceId,\n sourceTitle: source?.title || sourceId,\n stream: source?.stream || 'stderr',\n segment: 'live',\n timestamp: null,\n relativeTimeSec: null,\n text: `[capture worker exited before stop: ${sourceId}]`,\n captureGap: true,\n });\n }\n }\n environmentEvents.push(\n ...options.serverEntries.map((entry) =>\n toEvidenceEvent(entry, {\n origin: 'environment',\n group: 'backend',\n sourceId: 'server',\n sourceTitle: 'Server',\n stream: 'stderr',\n }),\n ),\n );\n\n const navigations = buildNavigations(options.actions, options.initialPageUrl);\n const browserEvents = options.consoleEntries.map((entry) => {\n const navigation = findNavigation(navigations, entry.relativeTimeSec);\n return toEvidenceEvent(entry, {\n origin: 'browser',\n group: 'browser',\n sourceId: navigation.id,\n sourceTitle: navigation.url,\n navigationId: navigation.id,\n pageUrl: navigation.url,\n stream: 'console',\n });\n });\n return [...environmentEvents, ...browserEvents];\n}\n\nfunction adjustEnvironmentEventTime(\n event: EvidenceEvent,\n timelineOffsetSec: number,\n): EvidenceEvent {\n if (event.relativeTimeSec === null || timelineOffsetSec <= 0) {\n return event;\n }\n const relativeTimeSec = event.relativeTimeSec - timelineOffsetSec;\n return {\n ...event,\n relativeTimeSec:\n relativeTimeSec >= 0\n ? parseFloat(relativeTimeSec.toFixed(3))\n : null,\n };\n}\n\nfunction toEvidenceEvent(\n entry: TimestampedLogEntry,\n source: Pick<\n EvidenceEvent,\n | 'origin'\n | 'group'\n | 'sourceId'\n | 'sourceTitle'\n | 'stream'\n | 'navigationId'\n | 'pageUrl'\n >,\n): EvidenceEvent {\n return {\n version: 1,\n ...source,\n segment: 'live',\n timestamp: null,\n relativeTimeSec: Number.isFinite(entry.relativeTimeSec)\n ? entry.relativeTimeSec\n : null,\n text: entry.text,\n };\n}\n\nfunction buildNavigations(\n actions: SessionLogEntry[],\n initialPageUrl?: string,\n): Array<{ id: string; url: string; startTimeSec: number }> {\n const navigations: Array<{ url: string; startTimeSec: number }> = [];\n const append = (url: string | undefined, startTimeSec: number): void => {\n if (!url || navigations.at(-1)?.url === url) return;\n navigations.push({ url, startTimeSec });\n };\n append(initialPageUrl, 0);\n for (const entry of actions) {\n if (!Number.isFinite(entry.relativeTimeSec)) continue;\n const explicit = entry.action.match(/^(?:open|navigate)\\s+(\\S+)/i)?.[1];\n append(entry.pageUrl || explicit, entry.relativeTimeSec);\n }\n if (navigations.length === 0) {\n navigations.push({ url: 'Browser', startTimeSec: 0 });\n }\n return navigations.map((navigation, index) => ({\n id: `browser-nav-${index + 1}`,\n ...navigation,\n }));\n}\n\nfunction findNavigation(\n navigations: Array<{ id: string; url: string; startTimeSec: number }>,\n relativeTimeSec: number,\n): { id: string; url: string } {\n const timed = Number.isFinite(relativeTimeSec) ? relativeTimeSec : 0;\n return (\n [...navigations]\n .reverse()\n .find((navigation) => navigation.startTimeSec <= timed) ||\n navigations[0]\n );\n}\n\nfunction buildIncidents(events: EvidenceEvent[]): EvidenceIncident[] {\n const incidents = new Map<\n string,\n {\n severity: 'fatal' | 'error';\n origin: EvidenceEvent['origin'];\n group: string;\n message: string;\n count: number;\n sourceIds: Set;\n times: number[];\n }\n >();\n for (const event of events) {\n const severity = classifyIncident(event.text);\n if (!severity) {\n continue;\n }\n const message = normalizeIncident(event.text);\n const key = `${event.origin}\\0${event.group}\\0${severity}\\0${message}`;\n const incident = incidents.get(key) || {\n severity,\n origin: event.origin,\n group: event.group,\n message,\n count: 0,\n sourceIds: new Set(),\n times: [],\n };\n incident.count += 1;\n incident.sourceIds.add(event.sourceId);\n if (event.relativeTimeSec !== null) {\n incident.times.push(event.relativeTimeSec);\n }\n incidents.set(key, incident);\n }\n\n return [...incidents.values()].map((incident, index) => ({\n id: `incident-${index + 1}`,\n severity: incident.severity,\n origin: incident.origin,\n group: incident.group,\n message: incident.message,\n count: incident.count,\n sourceIds: [...incident.sourceIds],\n firstTimeSec:\n incident.times.length > 0 ? Math.min(...incident.times) : null,\n lastTimeSec:\n incident.times.length > 0 ? Math.max(...incident.times) : null,\n }));\n}\n\nfunction classifyIncident(text: string): 'fatal' | 'error' | null {\n if (\n /\\bFATAL\\b|\\bpanic:|uncaught exception|unhandled rejection|capture worker exited before stop|malformed canonical evidence row|\\[process exited with code (?!0\\])/i.test(\n text,\n )\n ) {\n return 'fatal';\n }\n if (/\\bError:|ERR[_!]|Exception:|Traceback/i.test(text)) {\n return 'error';\n }\n return null;\n}\n\nfunction normalizeIncident(text: string): string {\n return text\n .replace(/\\b\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\b/g, '')\n .replace(/:\\d+:\\d+\\b/g, '::')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction buildSourceSummaries(\n events: EvidenceEvent[],\n incidents: EvidenceIncident[],\n): EvidenceSourceSummary[] {\n const sourceKeys = new Map<\n string,\n {\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n events: EvidenceEvent[];\n }\n >();\n for (const event of events) {\n const key = `${event.origin}\\0${event.sourceId}`;\n const existing = sourceKeys.get(key) || {\n title: event.sourceTitle,\n origin: event.origin,\n group: event.group,\n events: [],\n };\n existing.events.push(event);\n sourceKeys.set(key, existing);\n }\n\n return [...sourceKeys.values()].map((source) => {\n const id = source.events[0].sourceId;\n const hiddenLineCount = source.events.filter(\n (event) => event.presentationHidden,\n ).length;\n return {\n id,\n title: source.title,\n origin: source.origin,\n group: source.group,\n lineCount: source.events.length,\n hiddenLineCount,\n truncationCount: source.events.filter((event) => event.truncated).length,\n captureGapCount: source.events.filter((event) => event.captureGap).length,\n incidentCount: incidents.filter(\n (incident) =>\n incident.origin === source.origin && incident.sourceIds.includes(id),\n ).length,\n };\n });\n}\n\nfunction applyPresentationFilters(\n events: EvidenceEvent[],\n configuredSources: ResolvedLogSourceState[],\n): void {\n for (const event of events) {\n const config = configuredSources.find(\n (candidate) => candidate.id === event.sourceId,\n );\n if (isHidden(event.text, config)) {\n event.presentationHidden = true;\n }\n }\n}\n\nfunction isHidden(\n text: string,\n config: ResolvedLogSourceState | undefined,\n): boolean {\n if (!config) {\n return false;\n }\n if (\n config.include &&\n config.include.length > 0 &&\n !config.include.some((pattern) => text.includes(pattern))\n ) {\n return true;\n }\n return Boolean(config.exclude?.some((pattern) => text.includes(pattern)));\n}\n\nfunction inspectScreenshots(\n sessionDir: string,\n actions: SessionLogEntry[],\n): ScreenshotIntegrity[] {\n const files = [\n ...new Set(\n actions\n .filter((action) => action.outcome === 'passed')\n .map((action) => action.action.match(/^screenshot\\s+(.+)$/)?.[1])\n .filter((value): value is string => Boolean(value))\n .map((value) => path.basename(value)),\n ),\n ];\n return files\n .map((file) => {\n const filePath = path.join(sessionDir, file);\n const size = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;\n if (size > 50 * 1024 * 1024) {\n return {\n file,\n sha256: null,\n validPng: false,\n visuallyBlank: false,\n size,\n };\n }\n const contents = size > 0 ? fs.readFileSync(filePath) : Buffer.alloc(0);\n const integrity = inspectPng(contents);\n return {\n file,\n sha256: createHash('sha256').update(contents).digest('hex'),\n validPng: integrity.valid,\n visuallyBlank: integrity.visuallyBlank,\n size,\n };\n });\n}\n\nfunction inspectPng(contents: Buffer): {\n valid: boolean;\n visuallyBlank: boolean;\n} {\n if (\n contents.length < 33 ||\n !contents\n .subarray(0, 8)\n .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||\n contents.subarray(12, 16).toString('ascii') !== 'IHDR'\n ) {\n return { valid: false, visuallyBlank: false };\n }\n const width = contents.readUInt32BE(16);\n const height = contents.readUInt32BE(20);\n if (width <= 0 || height <= 0 || width * height > 20_000_000) {\n return { valid: false, visuallyBlank: false };\n }\n try {\n const decoded = PNG.sync.read(contents, { checkCRC: true });\n const spans = [\n { minimum: 255, maximum: 0 },\n { minimum: 255, maximum: 0 },\n { minimum: 255, maximum: 0 },\n { minimum: 255, maximum: 0 },\n ];\n const pixelCount = decoded.width * decoded.height;\n const sampleStep = Math.max(1, Math.floor(pixelCount / 10_000));\n for (let pixel = 0; pixel < pixelCount; pixel += sampleStep) {\n const offset = pixel * 4;\n for (let channel = 0; channel < 4; channel += 1) {\n const value = decoded.data[offset + channel];\n spans[channel].minimum = Math.min(spans[channel].minimum, value);\n spans[channel].maximum = Math.max(spans[channel].maximum, value);\n }\n }\n return {\n valid: true,\n visuallyBlank: spans.every(\n ({ minimum, maximum }) => maximum - minimum <= 3,\n ),\n };\n } catch {\n return { valid: false, visuallyBlank: false };\n }\n}\n\nfunction buildVerdict(\n options: EvidenceBuildOptions,\n evidence: CanonicalEvidence,\n): Verdict {\n const missingArtifacts: string[] = [];\n if (options.recordingWasActive && !fs.existsSync(options.videoPath)) {\n missingArtifacts.push(path.basename(options.videoPath));\n } else if (\n options.recordingWasActive &&\n (evidence.mediaDurationSec === null || evidence.mediaDurationSec <= 0)\n ) {\n missingArtifacts.push(path.basename(options.videoPath));\n }\n const screenshotFiles = new Set(\n evidence.screenshots.map((screenshot) => screenshot.file),\n );\n const successfulScreenshotPaths = options.actions\n .filter((action) => action.outcome === 'passed')\n .map((action) => action.action.match(/^screenshot\\s+(.+)$/)?.[1])\n .filter((value): value is string => Boolean(value))\n .map((value) => path.basename(value));\n const reusedScreenshotPaths =\n successfulScreenshotPaths.length - new Set(successfulScreenshotPaths).size;\n for (const action of options.actions) {\n const match = action.action.match(/^screenshot\\s+(.+)$/);\n if (match && !screenshotFiles.has(path.basename(match[1]))) {\n missingArtifacts.push(path.basename(match[1]));\n }\n }\n for (const screenshot of evidence.screenshots) {\n if (\n !screenshot.validPng ||\n screenshot.visuallyBlank ||\n screenshot.size === 0\n ) {\n missingArtifacts.push(screenshot.file);\n }\n }\n\n const hashes = new Map();\n for (const screenshot of evidence.screenshots) {\n if (screenshot.sha256 && screenshot.validPng) {\n const files = hashes.get(screenshot.sha256) || [];\n files.push(screenshot.file);\n hashes.set(screenshot.sha256, files);\n }\n }\n const duplicateScreenshotHashes = [...hashes.values()].filter(\n (files) => files.length > 1,\n );\n const expectedSelectorFailures = options.actions\n .filter(\n (action) =>\n action.expectedSelector && action.outcome === 'failed',\n )\n .map((action) => action.expectedSelector!);\n const pendingExpectedSelectors = options.actions.filter(\n (action) => action.expectedSelector && action.outcome === undefined,\n );\n const fatalIncidentCount = evidence.incidents.filter(\n (incident) => incident.severity === 'fatal',\n ).length;\n const blockingReasons = options.consoleEvidenceAvailable\n ? []\n : ['Browser console evidence was unavailable.'];\n const failureReasons = [\n ...(fatalIncidentCount > 0\n ? [`${fatalIncidentCount} fatal incident(s) detected.`]\n : []),\n ...(expectedSelectorFailures.length > 0\n ? [`${expectedSelectorFailures.length} expected selector assertion(s) failed.`]\n : []),\n ...(duplicateScreenshotHashes.length > 0\n ? ['Duplicate key-frame screenshot hashes were detected.']\n : []),\n ];\n const incompleteReasons = [\n ...(missingArtifacts.length > 0\n ? [`${missingArtifacts.length} required artifact(s) were missing or invalid.`]\n : []),\n ...(evidence.mediaTruncated\n ? ['Recorded media ends before the canonical action timeline.']\n : []),\n ...(evidence.sources.some((source) => source.truncationCount > 0)\n ? ['One or more evidence sources were truncated.']\n : []),\n ...(pendingExpectedSelectors.length > 0\n ? [\n `${pendingExpectedSelectors.length} expected selector assertion(s) had no recorded outcome.`,\n ]\n : []),\n ...(reusedScreenshotPaths > 0\n ? ['One or more screenshot paths were reused by multiple actions.']\n : []),\n ];\n const status: VerdictStatus =\n blockingReasons.length > 0\n ? 'BLOCKED'\n : incompleteReasons.length > 0\n ? 'INCOMPLETE'\n : failureReasons.length > 0\n ? 'FAIL'\n : 'PASS';\n return {\n version: 1,\n status,\n reasons: [...blockingReasons, ...failureReasons, ...incompleteReasons],\n fatalIncidentCount,\n missingArtifacts: [...new Set(missingArtifacts)],\n duplicateScreenshotHashes,\n expectedSelectorFailures,\n mediaTruncated: evidence.mediaTruncated,\n };\n}\n\nexport function probeMediaDuration(videoPath: string): number | null {\n if (!fs.existsSync(videoPath)) {\n return null;\n }\n try {\n const output = execFileSync(\n 'ffprobe',\n [\n '-v',\n 'error',\n '-show_entries',\n 'format=start_time,duration',\n '-of',\n 'json',\n videoPath,\n ],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n const parsed = JSON.parse(output) as {\n format?: {\n start_time?: string;\n duration?: string;\n };\n };\n const startTime = Number(parsed.format?.start_time || 0);\n const duration = Number(parsed.format?.duration);\n const playableDuration = duration - startTime;\n return Number.isFinite(playableDuration) && playableDuration >= 0\n ? playableDuration\n : null;\n } catch {\n return null;\n }\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { getPageUrl } from '../browser/session.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\nconst SESSION_LOG_LOCK_TIMEOUT_MS = 5000;\nconst SESSION_LOG_STALE_LOCK_MS = 120000;\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n outcome?: 'passed' | 'failed';\n expectedSelector?: string;\n error?: string;\n pageUrl?: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n if (!Array.isArray(parsed)) {\n throw new Error('session log root must be an array');\n }\n return parsed as SessionLogEntry[];\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`ProofShot session action log is corrupt: ${logPath}\\n${message}`);\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n const resolved = path.resolve(sessionDir, screenshotPath);\n if (path.dirname(resolved) !== path.resolve(sessionDir)) {\n throw new Error(\n 'ProofShot screenshots must use a filename directly inside the active session.',\n );\n }\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\nexport function translateProofShotExecArgs(args: string[]): {\n agentBrowserArgs: string[];\n expectedSelector?: string;\n} {\n if (args[0] === 'assert-visible' && args.length > 1) {\n return {\n agentBrowserArgs: ['is', 'visible', ...args.slice(1)],\n expectedSelector: args.slice(1).join(' '),\n };\n }\n return { agentBrowserArgs: args };\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=
", "Custom output directory").option("--url ", "Open this URL instead of the root").option("--browser-executable ", "Use this Chrome/Chromium executable").option("--force", "Override a stale session without running stop first").action(async (options) => { + await startCommand(options); + }); + program.command("stop").description("Stop session: stop recording, collect errors, bundle proof artifacts").option("--no-close", "Don't close the browser (keep it open for further use)").action(async (options) => { + await stopCommand({ noClose: options.close === false }); + }); + program.command("diff").description("Compare current screenshots against a baseline").requiredOption("--baseline ", "Directory with baseline screenshots").action(async (options) => { + await diffCommand(options); + }); + program.command("clean").description("Remove artifact files").action(async () => { + await cleanCommand(); + }); + program.command("doctor").description("Inspect the local ProofShot environment and active session state").action(async () => { + await doctorCommand(); + }); + program.command("pr").description("Upload session artifacts and post a ProofShot comment on a GitHub PR").argument("[pr-number]", "PR number (auto-detects from current branch if omitted)").option("--dry-run", "Generate the comment markdown without posting").option( + "--session ", + "Publish a finalized session (repeatable)", + collectOption, + [] + ).option( + "--screenshot ", + "Publish named screenshot artifacts (space-separated or repeatable)", + collectOption, + [] + ).option( + "--legacy-session", + "Allow one explicitly selected pre-manifest session" + ).option( + "--upload-provider ", + "Artifact upload backend: repo-contents or github-web-attachments", + "repo-contents" + ).option( + "--artifacts-branch ", + "Git branch used by the repo-contents upload provider", + "proofshot-artifacts" + ).action(async (prNumber, options) => { + await prCommand({ prNumber, ...options }); + }); + program.command("exec").description("Run an agent-browser command with logging (use instead of agent-browser directly)").argument("", "agent-browser command and arguments").allowUnknownOption().action(async (args) => { + await execCommand(args); + }); + const session = program.command("session").description("List and recover registered ProofShot sessions"); + session.command("list").description("List all registered ProofShot sessions").option("--json", "Output machine-readable JSON").action(async (options) => { + await sessionListCommand(options); + }); + session.command("clean").description("Retry exact cleanup for recoverable ProofShot sessions").option("--session ", "Clean one exact registered session").option("--all", "Clean every registered session").action(async (options) => { + await sessionCleanCommand(options); + }); + return program; +} +function collectOption(value, previous) { + return [...previous, value]; +} +export { + ProofShotError, + ab, + captureGitProvenance, + createCLI, + ensureDevServer, + findSessionsForBranch, + formatPRComment, + generateViewer, + installCommand, + isPortOpen, + loadArtifactManifest, + loadConfig, + loadMetadata, + loadSession, + saveSession, + startOwnedEnvironment, + stopOwnedEnvironment, + trimVideo, + validateManifestArtifacts, + waitForPort, + writeArtifactManifest, + writeCanonicalEvidence, + writeConfig, + writeMetadata, + writeViewer +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/src/index.js.map b/dist/src/index.js.map new file mode 100644 index 0000000..ad392b3 --- /dev/null +++ b/dist/src/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/cli.ts","../../src/commands/install.ts","../../src/utils/skills.ts","../../src/commands/start.ts","../../src/utils/config.ts","../../src/utils/exec.ts","../../src/utils/process.ts","../../src/server/start.ts","../../src/utils/port.ts","../../src/browser/session.ts","../../src/browser/capture.ts","../../src/browser/discovery.ts","../../src/browser/runtime.ts","../../src/artifacts/bundle.ts","../../src/session/state.ts","../../src/environment/runtime.ts","../../src/environment/workers.ts","../../src/environment/evidence.ts","../../src/environment/tmux.ts","../../src/session/lifecycle.ts","../../src/session/registry.ts","../../src/session/metadata.ts","../../src/session/manifest.ts","../../src/commands/stop.ts","../../src/artifacts/viewer.ts","../../src/artifacts/evidence.ts","../../src/utils/error-patterns.ts","../../src/commands/exec.ts","../../src/utils/token-usage.ts","../../src/commands/diff.ts","../../src/commands/clean.ts","../../src/commands/pr.ts","../../src/utils/github.ts","../../src/artifacts/pr-format.ts","../../src/session/publication.ts","../../src/commands/doctor.ts","../../src/version.ts","../../src/commands/session.ts"],"sourcesContent":["import { Command } from 'commander';\nimport { installCommand } from './commands/install.js';\nimport { startCommand } from './commands/start.js';\nimport { stopCommand } from './commands/stop.js';\nimport { diffCommand } from './commands/diff.js';\nimport { cleanCommand } from './commands/clean.js';\nimport { prCommand } from './commands/pr.js';\nimport { execCommand } from './commands/exec.js';\nimport { doctorCommand } from './commands/doctor.js';\nimport { sessionCleanCommand, sessionListCommand } from './commands/session.js';\nimport { PROOFSHOT_VERSION } from './version.js';\n\nexport function createCLI(): Command {\n const program = new Command();\n\n program\n .name('proofshot')\n .description('Visual verification for AI coding agents')\n .version(PROOFSHOT_VERSION);\n\n program\n .command('install')\n .description('Install ProofShot skills at user level for all detected AI coding tools')\n .option('--only ', 'Only install for these tools (comma-separated: claude,codex,cursor,gemini,windsurf,opencode)')\n .option('--skip ', 'Skip these tools (comma-separated)')\n .option('--force', 'Overwrite existing skill files even if unchanged')\n .action(async (options) => {\n await installCommand(options);\n });\n\n program\n .command('start')\n .description('Start a verification session: browser, recording, error capture')\n .option('--description ', 'What is being verified (included in the proof report)')\n .option('--port ', 'Override detected port', parseInt)\n .option('--run ', 'Start this command and capture its logs')\n .option('--headed', 'Show browser window for debugging')\n .option('--output ', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--session ',\n 'Publish a finalized session (repeatable)',\n collectOption,\n [],\n )\n .option(\n '--screenshot ',\n 'Publish named screenshot artifacts (space-separated or repeatable)',\n collectOption,\n [],\n )\n .option(\n '--legacy-session',\n 'Allow one explicitly selected pre-manifest session',\n )\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n\nfunction collectOption(value: string, previous: string[]): string[] {\n return [...previous, value];\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec assert-visible \"#expected-result\" # Record an expected selector\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects canonical browser + environment evidence, and generates\na SUMMARY.md, viewer, structured verdict, and provenance manifest.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\nproofshot pr 42 --session SESSION_ID --screenshot step-NAME.png\n\\`\\`\\`\n\nThis selects one finalized session compatible with the PR head, validates artifact hashes, uploads the selected screenshots/video, and posts only after every upload succeeds. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec assert-visible \"#selector\"\\` — record an expected selector\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec assert-visible \"#selector\"\\` — record an expected selector\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { getPageUrl, openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\nimport { captureGitProvenance } from '../session/manifest.js';\nimport { startOwnedEnvironment } from '../environment/runtime.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n const provenance = captureGitProvenance(process.cwd(), [outputDir]);\n\n writeMetadata(sessionDir, {\n ...provenance,\n repositoryRoot: process.cwd(),\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n controlDir,\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n environment: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run && config.environment) {\n throw new Error('Use either --run or config.environment, not both.');\n }\n if (config.environment || (config.logs?.sources || []).some((source) => source.kind === 'file')) {\n failureContext = 'start environment';\n session.environment = await startOwnedEnvironment(\n config.environment,\n config.logs || {},\n sessionDir,\n sessionName,\n new Date(session.startedAt).getTime(),\n (environmentState) => {\n session.environment = environmentState;\n persistOwnedSession(session, controlDir);\n },\n );\n console.log(chalk.green('✓') + ' Environment and log capture started');\n }\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else if (!config.environment) {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n session.targetUrl = getPageUrl(sessionName) || openUrl;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n session.recordingStartedAt = new Date().toISOString();\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type {\n EnvironmentConfig,\n LogsConfig,\n LogSourceConfig,\n} from '../environment/types.js';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n environment?: EnvironmentConfig;\n logs?: LogsConfig;\n}\n\nexport interface ResolvedProofShotConfig extends ProofShotConfig {\n logs: LogsConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ResolvedProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n logs: {\n stripAnsi: true,\n maxBytesPerSource: 5 * 1024 * 1024,\n sources: [],\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ResolvedProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n validateConfig(parsed);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n const environment = resolveEnvironmentConfig(parsed.environment, configDir);\n const logs = resolveLogsConfig(parsed.logs, configDir);\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n environment,\n logs,\n };\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`Invalid ProofShot config at ${configPath}: ${message}`);\n }\n}\n\nfunction validateConfig(value: unknown): void {\n assertRecord(value, 'config');\n assertOptionalString(value.output, 'output');\n assertOptionalBoolean(value.headless, 'headless');\n assertOptionalStringArray(value.defaultPages, 'defaultPages');\n\n if (value.devServer !== undefined) {\n assertRecord(value.devServer, 'devServer');\n assertOptionalPositiveInteger(value.devServer.port, 'devServer.port', 65535);\n assertOptionalPositiveInteger(\n value.devServer.startupTimeout,\n 'devServer.startupTimeout',\n );\n }\n if (value.viewport !== undefined) {\n assertRecord(value.viewport, 'viewport');\n assertOptionalPositiveInteger(value.viewport.width, 'viewport.width');\n assertOptionalPositiveInteger(value.viewport.height, 'viewport.height');\n }\n if (value.browser !== undefined) {\n assertRecord(value.browser, 'browser');\n assertOptionalString(value.browser.configPath, 'browser.configPath');\n assertOptionalString(value.browser.executablePath, 'browser.executablePath');\n assertOptionalBoolean(value.browser.ignoreHttpsErrors, 'browser.ignoreHttpsErrors');\n }\n validateEnvironment(value.environment);\n validateLogs(value.logs);\n}\n\nfunction validateEnvironment(value: unknown): void {\n if (value === undefined) return;\n assertRecord(value, 'environment');\n validateReadiness(value.readiness);\n\n if (value.kind === 'tmux') {\n assertRecord(value.launch, 'environment.launch');\n assertOptionalString(value.cwd, 'environment.cwd');\n if (value.launch.kind === 'panes') {\n if (!Array.isArray(value.launch.panes) || value.launch.panes.length === 0) {\n throw new Error('environment.launch.panes must be a non-empty array');\n }\n validateDefinitions(value.launch.panes, 'environment.launch.panes');\n assertOptionalString(\n value.launch.sessionName,\n 'environment.launch.sessionName',\n );\n if (value.connection !== undefined) {\n throw new Error('environment.connection is only valid for external-command');\n }\n return;\n }\n if (value.launch.kind === 'external-command') {\n assertNonEmptyString(value.launch.command, 'environment.launch.command');\n assertOptionalString(\n value.launch.stopCommand,\n 'environment.launch.stopCommand',\n );\n assertOptionalPositiveInteger(\n value.launch.timeoutMs,\n 'environment.launch.timeoutMs',\n );\n assertRecord(value.connection, 'environment.connection');\n if (\n value.connection.format !== 'json' &&\n value.connection.format !== 'tmux-attach-command'\n ) {\n throw new Error(\n 'environment.connection.format must be \"json\" or \"tmux-attach-command\"',\n );\n }\n if (\n value.connection.source !== undefined &&\n value.connection.source !== 'stdout'\n ) {\n throw new Error('environment.connection.source must be \"stdout\"');\n }\n assertOptionalString(value.connection.socket, 'environment.connection.socket');\n if (\n value.connection.ownership !== undefined &&\n value.connection.ownership !== 'attach' &&\n value.connection.ownership !== 'create'\n ) {\n throw new Error(\n 'environment.connection.ownership must be \"attach\" or \"create\"',\n );\n }\n if (\n value.connection.ownership !== 'attach' &&\n value.connection.socket === undefined &&\n value.launch.stopCommand === undefined\n ) {\n throw new Error(\n 'external-command requires connection.socket or launch.stopCommand for cleanup',\n );\n }\n return;\n }\n throw new Error(\n 'environment.launch.kind must be \"panes\" or \"external-command\"',\n );\n }\n\n if (value.kind === 'processes') {\n if (!Array.isArray(value.commands)) {\n throw new Error('environment.commands must be an array');\n }\n validateDefinitions(value.commands, 'environment.commands');\n return;\n }\n throw new Error('environment.kind must be \"tmux\" or \"processes\"');\n}\n\nfunction validateDefinitions(value: unknown[], field: string): void {\n const ids = new Set();\n value.forEach((candidate, index) => {\n const item = `${field}[${index}]`;\n assertRecord(candidate, item);\n assertSafeId(candidate.id, `${item}.id`);\n if (ids.has(candidate.id)) throw new Error(`Duplicate ${field} id: ${candidate.id}`);\n ids.add(candidate.id);\n assertNonEmptyString(candidate.command, `${item}.command`);\n assertOptionalString(candidate.title, `${item}.title`);\n assertOptionalString(candidate.group, `${item}.group`);\n assertOptionalString(candidate.cwd, `${item}.cwd`);\n if (candidate.env !== undefined) {\n assertRecord(candidate.env, `${item}.env`);\n for (const [key, envValue] of Object.entries(candidate.env)) {\n if (typeof envValue !== 'string') {\n throw new Error(`${item}.env.${key} must be a string`);\n }\n }\n }\n });\n}\n\nfunction validateReadiness(value: unknown): void {\n if (value === undefined) return;\n if (!Array.isArray(value)) throw new Error('environment.readiness must be an array');\n value.forEach((candidate, index) => {\n const item = `environment.readiness[${index}]`;\n assertRecord(candidate, item);\n assertOptionalPositiveInteger(candidate.timeoutMs, `${item}.timeoutMs`);\n if (candidate.kind === 'http') {\n assertNonEmptyString(candidate.url, `${item}.url`);\n return;\n }\n if (candidate.kind === 'tcp') {\n assertOptionalString(candidate.host, `${item}.host`);\n assertOptionalPositiveInteger(candidate.port, `${item}.port`, 65535, true);\n return;\n }\n throw new Error(`${item}.kind must be \"http\" or \"tcp\"`);\n });\n}\n\nfunction validateLogs(value: unknown): void {\n if (value === undefined) return;\n assertRecord(value, 'logs');\n assertOptionalBoolean(value.stripAnsi, 'logs.stripAnsi');\n assertOptionalPositiveInteger(value.maxBytesPerSource, 'logs.maxBytesPerSource');\n if (\n value.maxBytesPerSource !== undefined &&\n value.maxBytesPerSource < 512\n ) {\n throw new Error('logs.maxBytesPerSource must be at least 512 bytes');\n }\n if (value.sources === undefined) return;\n if (!Array.isArray(value.sources)) throw new Error('logs.sources must be an array');\n\n const ids = new Set();\n value.sources.forEach((candidate, index) => {\n const item = `logs.sources[${index}]`;\n assertRecord(candidate, item);\n assertSafeId(candidate.id, `${item}.id`);\n if (ids.has(candidate.id)) throw new Error(`Duplicate log source id: ${candidate.id}`);\n ids.add(candidate.id);\n assertOptionalString(candidate.title, `${item}.title`);\n assertOptionalString(candidate.group, `${item}.group`);\n assertOptionalStringArray(candidate.include, `${item}.include`);\n assertOptionalStringArray(candidate.exclude, `${item}.exclude`);\n\n if (candidate.kind === 'tmux-pane') {\n assertRecord(candidate.match, `${item}.match`);\n const keys = ['connectionKey', 'tag', 'target'].filter(\n (key) => candidate.match[key] !== undefined,\n );\n if (keys.length !== 1) {\n throw new Error(`${item}.match must set exactly one pane selector`);\n }\n assertNonEmptyString(candidate.match[keys[0]], `${item}.match.${keys[0]}`);\n return;\n }\n if (candidate.kind === 'process') {\n assertSafeId(candidate.processId, `${item}.processId`);\n return;\n }\n if (candidate.kind === 'file') {\n assertNonEmptyString(candidate.path, `${item}.path`);\n return;\n }\n throw new Error(`${item}.kind is unsupported`);\n });\n}\n\nfunction assertRecord(\n value: unknown,\n field: string,\n): asserts value is Record {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw new Error(`${field} must be an object`);\n }\n}\n\nfunction assertSafeId(value: unknown, field: string): asserts value is string {\n assertNonEmptyString(value, field);\n if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {\n throw new Error(`${field} must contain only letters, numbers, \"_\" or \"-\"`);\n }\n}\n\nfunction assertNonEmptyString(\n value: unknown,\n field: string,\n): asserts value is string {\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${field} must be a non-empty string`);\n }\n}\n\nfunction assertOptionalString(value: unknown, field: string): void {\n if (value !== undefined && typeof value !== 'string') {\n throw new Error(`${field} must be a string`);\n }\n}\n\nfunction assertOptionalBoolean(value: unknown, field: string): void {\n if (value !== undefined && typeof value !== 'boolean') {\n throw new Error(`${field} must be a boolean`);\n }\n}\n\nfunction assertOptionalStringArray(value: unknown, field: string): void {\n if (\n value !== undefined &&\n (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))\n ) {\n throw new Error(`${field} must be an array of strings`);\n }\n}\n\nfunction assertOptionalPositiveInteger(\n value: unknown,\n field: string,\n maximum = Number.MAX_SAFE_INTEGER,\n required = false,\n): void {\n if (value === undefined && !required) return;\n if (\n !Number.isInteger(value) ||\n (value as number) <= 0 ||\n (value as number) > maximum\n ) {\n throw new Error(`${field} must be a positive integer no greater than ${maximum}`);\n }\n}\n\nfunction resolveEnvironmentConfig(\n value: unknown,\n configDir: string,\n): EnvironmentConfig | undefined {\n if (typeof value !== 'object' || value === null) {\n return undefined;\n }\n const environment = value as EnvironmentConfig;\n if (environment.kind === 'tmux') {\n const launch =\n environment.launch.kind === 'panes'\n ? {\n ...environment.launch,\n panes: environment.launch.panes.map((pane) => ({\n ...pane,\n cwd: path.resolve(configDir, pane.cwd || environment.cwd || '.'),\n })),\n }\n : environment.launch;\n return {\n ...environment,\n cwd: path.resolve(configDir, environment.cwd || '.'),\n connection: environment.connection?.socket\n ? {\n ...environment.connection,\n socket: path.resolve(configDir, environment.connection.socket),\n }\n : environment.connection,\n launch,\n };\n }\n if (environment.kind === 'processes') {\n return {\n ...environment,\n commands: environment.commands.map((command) => ({\n ...command,\n cwd: path.resolve(configDir, command.cwd || '.'),\n })),\n };\n }\n return undefined;\n}\n\nfunction resolveLogsConfig(value: unknown, configDir: string): LogsConfig {\n const logs =\n typeof value === 'object' && value !== null\n ? (value as LogsConfig)\n : DEFAULT_CONFIG.logs;\n const sources: LogSourceConfig[] = (logs.sources || []).map((source) =>\n source.kind === 'file'\n ? { ...source, path: path.resolve(configDir, source.path) }\n : source,\n );\n return {\n ...DEFAULT_CONFIG.logs,\n ...logs,\n sources,\n };\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return {\n ...process.env,\n AGENT_BROWSER_IDLE_TIMEOUT_MS:\n process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || '1800000',\n ...(socketDir ? { AGENT_BROWSER_SOCKET_DIR: socketDir } : {}),\n };\n}\n\nexport function quoteShellArgument(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath\n ? ` --config ${quoteShellArgument(mergedOptions.configPath)}`\n : '';\n const sessionFlag = mergedOptions.session\n ? ` --session ${quoteShellArgument(mergedOptions.session)}`\n : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n /** Stable boot token preventing cross-boot PID/start-time collisions. */\n bootId?: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n const identity = parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n const bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf-8').trim();\n if (!identity || !bootId) return null;\n return { ...identity, bootId };\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, TZ: 'UTC' },\n },\n );\n const identity = parseUnixProcessIdentity(pid, output);\n if (!identity) return null;\n if (process.platform !== 'darwin') return identity;\n const bootId = execFileSync('sysctl', ['-n', 'kern.boottime'], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trim();\n return bootId ? { ...identity, bootId } : null;\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && processIdentitiesMatch(current, identity));\n}\n\nexport function processIdentitiesMatch(\n left: ProcessIdentity,\n right: ProcessIdentity,\n): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime &&\n left.bootId === right.bootId\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !processIdentitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !processIdentitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport async function terminateOwnedProcess(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity || !processIdentityMatches(identity)) {\n return false;\n }\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n try {\n process.kill(identity.pid, 'SIGTERM');\n } catch {\n return false;\n }\n\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && processIdentityMatches(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n if (processIdentityMatches(identity)) {\n try {\n process.kill(identity.pid, 'SIGKILL');\n } catch {\n return false;\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import {\n ab,\n ProofShotError,\n quoteShellArgument,\n} from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${quoteShellArgument(url)}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n try {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), {\n timeoutMs: 60000,\n session: sessionName,\n });\n } catch (error) {\n const currentUrl = getPageUrl(sessionName);\n if (!isNavigationTimeout(error) || !urlsMatch(currentUrl, url)) {\n throw error;\n }\n console.warn(\n 'Browser reached the target URL before its load event timed out; continuing with the active page.',\n );\n }\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\nfunction isNavigationTimeout(error: unknown): boolean {\n return (\n error instanceof ProofShotError &&\n error.message.toLowerCase().includes('operation timed out')\n );\n}\n\nfunction urlsMatch(actual: string, expected: string): boolean {\n try {\n return new URL(actual).href === new URL(expected).href;\n } catch {\n return actual === expected;\n }\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n ab('close', { session: sessionName });\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n return ab('errors', { session: sessionName });\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n return ab('console', { session: sessionName });\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n if (!Array.isArray(messages)) {\n throw new Error('agent-browser returned malformed console JSON.');\n }\n return messages;\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n if (platform === 'linux') {\n const cached = [...homes]\n .flatMap(cachedBrowserCandidates)\n .find(isExecutable);\n if (cached) return cached;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n\n/** Remove only the socket and PID sidecars for one verified stopped session. */\nexport function clearAgentBrowserSessionFiles(\n socketDir: string,\n sessionName: string,\n): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Unsafe agent-browser session name: ${sessionName}`);\n }\n assertOwnedDirectory(socketDir);\n const uid = process.getuid?.();\n for (const suffix of ['.pid', '.sock']) {\n const filePath = path.join(socketDir, `${sessionName}${suffix}`);\n try {\n const stat = fs.lstatSync(filePath);\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(`Agent-browser sidecar is owned by uid ${stat.uid}: ${filePath}`);\n }\n fs.unlinkSync(filePath);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n }\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { EnvironmentState } from '../environment/types.js';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n recordingStartedAt?: string;\n stoppedAt?: string;\n startDirectory?: string;\n controlDir?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n environment?: EnvironmentState | null;\n environmentStopped?: boolean;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(\n `ProofShot session state is corrupt: ${sessionPath}\\n${message}\\n` +\n 'Use \"proofshot session list\" to inspect durable recovery records.',\n );\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce: string = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import * as fs from 'fs';\nimport * as net from 'net';\nimport * as path from 'path';\nimport { startFileCapture, startProcessCapture } from './workers.js';\nimport { startTmuxEnvironment, stopTmuxEnvironment } from './tmux.js';\nimport type {\n EnvironmentConfig,\n EnvironmentState,\n LogSourceConfig,\n LogsConfig,\n ProcessDefinition,\n ProcessEnvironmentState,\n ReadinessCheck,\n ResolvedLogSourceState,\n TmuxEnvironmentConfig,\n TmuxEnvironmentState,\n ProcessesEnvironmentConfig,\n} from './types.js';\nimport {\n ownedProcessTreeIsAlive,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\nexport function startOwnedEnvironment(\n environment: TmuxEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport function startOwnedEnvironment(\n environment: ProcessesEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport function startOwnedEnvironment(\n environment: undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport function startOwnedEnvironment(\n environment: EnvironmentConfig | undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise;\nexport async function startOwnedEnvironment(\n environment: EnvironmentConfig | undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const fileSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'file',\n );\n if (!environment && fileSources.length === 0) {\n return null;\n }\n\n let state: EnvironmentState;\n if (environment?.kind === 'tmux') {\n state = await startTmuxEnvironment(\n environment,\n logs,\n sessionDir,\n sessionName,\n startTimeMs,\n onState,\n );\n } else {\n state = await startProcessEnvironment(\n environment?.kind === 'processes' ? environment.commands : [],\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n }\n\n try {\n state = await attachFileSources(\n state,\n fileSources,\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n if (environment) {\n await waitForReadiness(environment.readiness || []);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nexport async function stopOwnedEnvironment(\n state: EnvironmentState | null | undefined,\n): Promise {\n if (!state) {\n return;\n }\n switch (state.kind) {\n case 'tmux':\n await stopTmuxEnvironment(state);\n return;\n case 'launcher':\n await terminateOwnedProcessTree(state.launcher.process, { graceMs: 1000 });\n if (ownedProcessTreeIsAlive(state.launcher.process)) {\n throw new Error('External environment launcher did not stop.');\n }\n return;\n case 'processes': {\n const errors: Error[] = [];\n for (const capture of state.processes) {\n try {\n await terminateOwnedProcessTree(capture.process, { graceMs: 1000 });\n if (ownedProcessTreeIsAlive(capture.process)) {\n throw new Error(`Environment process ${capture.sourceId} did not stop.`);\n }\n } catch (error) {\n errors.push(error instanceof Error ? error : new Error(String(error)));\n }\n }\n if (errors.length > 0) {\n throw new AggregateError(\n errors,\n 'One or more environment processes did not stop.',\n );\n }\n return;\n }\n default: {\n const exhaustiveState: never = state;\n return exhaustiveState;\n }\n }\n}\n\nasync function startProcessEnvironment(\n definitions: ProcessDefinition[],\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n const configuredSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'process',\n );\n const sourceByProcessId = new Map();\n for (const source of configuredSources) {\n if (sourceByProcessId.has(source.processId)) {\n throw new Error(\n `Multiple log sources reference process ${source.processId}; each process can be launched only once.`,\n );\n }\n sourceByProcessId.set(source.processId, source);\n }\n for (const source of configuredSources) {\n if (!definitions.some((definition) => definition.id === source.processId)) {\n throw new Error(\n `Log source ${source.id} references unknown process ${source.processId}.`,\n );\n }\n }\n const sources = definitions.map(\n (definition) =>\n sourceByProcessId.get(definition.id) || {\n id: definition.id,\n title: definition.title,\n group: definition.group,\n kind: 'process' as const,\n processId: definition.id,\n include: undefined,\n exclude: undefined,\n },\n );\n validateUniqueIds(sources.map((source) => source.id));\n\n let state: ProcessEnvironmentState = {\n kind: 'processes',\n evidencePath,\n sources: [],\n processes: [],\n };\n onState(state);\n try {\n for (const sourceConfig of sources) {\n const definition = definitions.find(\n (candidate) => candidate.id === sourceConfig.processId,\n );\n if (!definition) throw new Error(`Missing process ${sourceConfig.processId}.`);\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title: sourceConfig.title || definition.title || definition.id,\n group: sourceConfig.group || definition.group || 'environment',\n kind: 'process',\n stream: 'stdout',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n const process = await startProcessCapture(\n definition,\n source,\n evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state = {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, process],\n };\n onState(state);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nasync function attachFileSources(\n state: EnvironmentState,\n fileSources: Array>,\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n if (fileSources.length === 0) {\n return state;\n }\n if (state.kind === 'launcher') {\n throw new Error('Cannot attach file sources before the environment launcher exits.');\n }\n const knownIds = new Set(state.sources.map((source) => source.id));\n const logsDir = path.join(sessionDir, 'logs');\n for (const fileSource of fileSources) {\n if (knownIds.has(fileSource.id)) {\n throw new Error(`Duplicate log source id: ${fileSource.id}`);\n }\n knownIds.add(fileSource.id);\n const source: ResolvedLogSourceState = {\n id: fileSource.id,\n title: fileSource.title || path.basename(fileSource.path),\n group: fileSource.group || 'environment',\n kind: 'file',\n stream: 'file',\n logPath: path.join(logsDir, `${fileSource.id}.log`),\n include: fileSource.include,\n exclude: fileSource.exclude,\n };\n const capture = await startFileCapture(\n fileSource.path,\n source,\n state.evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state =\n state.kind === 'tmux'\n ? {\n ...state,\n sources: [...state.sources, source],\n captures: [...state.captures, capture],\n }\n : {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, capture],\n };\n onState(state);\n }\n return state;\n}\n\nasync function waitForReadiness(checks: ReadinessCheck[]): Promise {\n for (const check of checks) {\n const timeoutMs = check.timeoutMs || 30 * 1000;\n const deadline = Date.now() + timeoutMs;\n let lastError = 'not ready';\n while (Date.now() < deadline) {\n try {\n if (check.kind === 'http') {\n const response = await fetch(check.url, {\n signal: AbortSignal.timeout(Math.min(2000, timeoutMs)),\n });\n if (response.ok) {\n lastError = '';\n break;\n }\n lastError = `HTTP ${response.status}`;\n } else {\n await connectTcp(check.host || '127.0.0.1', check.port);\n lastError = '';\n break;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n }\n await new Promise((resolve) => setTimeout(resolve, 100));\n }\n if (lastError) {\n const target =\n check.kind === 'http'\n ? check.url\n : `${check.host || '127.0.0.1'}:${check.port}`;\n throw new Error(`Environment readiness failed for ${target}: ${lastError}`);\n }\n }\n}\n\nfunction connectTcp(host: string, port: number): Promise {\n return new Promise((resolve, reject) => {\n const socket = net.createConnection({ host, port });\n const timer = setTimeout(() => {\n socket.destroy();\n reject(new Error('TCP readiness timed out'));\n }, 2000);\n socket.once('connect', () => {\n clearTimeout(timer);\n socket.end();\n resolve();\n });\n socket.once('error', (error) => {\n clearTimeout(timer);\n reject(error);\n });\n });\n}\n\nfunction validateUniqueIds(ids: string[]): void {\n const seen = new Set();\n for (const id of ids) {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n if (seen.has(id)) {\n throw new Error(`Duplicate log source id: ${id}`);\n }\n seen.add(id);\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { spawn } from 'child_process';\nimport { normalizeLogText } from './evidence.js';\nimport type {\n CaptureProcessState,\n EvidenceEvent,\n ProcessDefinition,\n ResolvedLogSourceState,\n} from './types.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n type ProcessIdentity,\n} from '../utils/process.js';\n\ntype WorkerConfig = {\n evidencePath: string;\n logPath: string;\n pidFile?: string;\n startTimeMs: number;\n maxBytes: number;\n stripAnsi: boolean;\n source: ResolvedLogSourceState;\n command?: string;\n cwd?: string;\n env?: Record;\n shellPath?: string;\n offset?: number;\n fileDevice?: number;\n fileInode?: number;\n};\n\nconst COMMON_WORKER_SOURCE = String.raw`\nconst fs = require('fs');\nconst config = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));\nconst ansiPattern = /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst controlPattern = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\nlet bytesWritten = 0;\nlet truncated = false;\nfunction normalize(text) {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(controlPattern, '');\n return config.stripAnsi ? normalized.replace(ansiPattern, '') : normalized;\n}\nfunction writeEvent(text, stream, segment = 'live', extra = {}) {\n const normalized = normalize(text);\n if (normalized.length === 0) return;\n const now = Date.now();\n const event = {\n version: 1,\n origin: 'environment',\n group: config.source.group,\n sourceId: config.source.id,\n sourceTitle: config.source.title,\n stream,\n segment,\n timestamp: new Date(now).toISOString(),\n relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000),\n text: normalized,\n ...extra,\n };\n const serialized = JSON.stringify(event) + '\\n';\n const logLine = normalized + '\\n';\n const bytes = Buffer.byteLength(serialized) + Buffer.byteLength(logLine);\n const truncationEvent = {\n ...event,\n text: '[ProofShot capture truncated at configured byte limit]',\n truncated: true,\n };\n const truncationSerialized = JSON.stringify(truncationEvent) + '\\n';\n const truncationLogLine = truncationEvent.text + '\\n';\n const truncationBytes =\n Buffer.byteLength(truncationSerialized) +\n Buffer.byteLength(truncationLogLine);\n if (bytesWritten + bytes + truncationBytes > config.maxBytes) {\n if (!truncated) {\n truncated = true;\n if (bytesWritten + truncationBytes <= config.maxBytes) {\n bytesWritten += truncationBytes;\n fs.appendFileSync(config.evidencePath, truncationSerialized);\n fs.appendFileSync(config.logPath, truncationLogLine);\n }\n }\n return;\n }\n bytesWritten += bytes;\n fs.appendFileSync(config.evidencePath, serialized);\n fs.appendFileSync(config.logPath, logLine);\n}\nfunction attachLines(stream, streamName) {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString().replace(/\\r\\n?/g, '\\n');\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) writeEvent(line, streamName);\n });\n stream.on('end', () => {\n if (buffer.length > 0) writeEvent(buffer, streamName);\n buffer = '';\n });\n}\nif (config.pidFile) {\n fs.writeFileSync(config.pidFile, String(process.pid), { mode: 0o600 });\n}\nfunction removePidFile() {\n if (config.pidFile) {\n try { fs.unlinkSync(config.pidFile); } catch {}\n }\n}\n`;\n\nconst TMUX_PIPE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nattachLines(process.stdin, 'pty');\nprocess.stdin.on('end', () => {\n removePidFile();\n process.exit(0);\n});\nprocess.on('SIGTERM', () => {\n removePidFile();\n process.exit(0);\n});\n`;\n\nconst PROCESS_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nconst { spawn } = require('child_process');\nlet stopping = false;\nconst child = spawn(config.command, {\n cwd: config.cwd,\n env: { ...process.env, ...config.env },\n shell: config.shellPath,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nattachLines(child.stdout, 'stdout');\nattachLines(child.stderr, 'stderr');\nchild.on('error', (error) => writeEvent(error.stack || error.message || String(error), 'stderr'));\nchild.on('close', (code) => {\n writeEvent(\n stopping\n ? '[process stopped by ProofShot]'\n : '[process exited with code ' + (code == null ? 'unknown' : code) + ']',\n 'stderr',\n );\n removePidFile();\n process.exit(stopping ? 0 : (code == null ? 1 : code));\n});\nfor (const signal of ['SIGINT', 'SIGTERM']) {\n process.on(signal, () => {\n stopping = true;\n try { child.kill(signal); } catch {}\n });\n}\n`;\n\nconst FILE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nlet offset = config.offset || 0;\nlet fileDevice = config.fileDevice;\nlet fileInode = config.fileInode;\nlet buffered = '';\nfunction readAvailable() {\n let fd;\n try {\n fd = fs.openSync(config.filePath, 'r');\n } catch {\n return;\n }\n const stat = fs.fstatSync(fd);\n if (\n (fileDevice !== undefined && stat.dev !== fileDevice) ||\n (fileInode !== undefined && stat.ino !== fileInode) ||\n stat.size < offset\n ) {\n offset = 0;\n writeEvent('[file rotated or truncated]', 'file', 'live', { captureGap: true });\n }\n fileDevice = stat.dev;\n fileInode = stat.ino;\n if (stat.size === offset) {\n fs.closeSync(fd);\n return;\n }\n const length = Math.min(stat.size - offset, 64 * 1024);\n const buffer = Buffer.alloc(length);\n const bytesRead = fs.readSync(fd, buffer, 0, length, offset);\n fs.closeSync(fd);\n offset += bytesRead;\n buffered += buffer.subarray(0, bytesRead).toString().replace(/\\\\r\\\\n?/g, '\\\\n');\n const lines = buffered.split('\\\\n');\n buffered = lines.pop() || '';\n for (const line of lines) writeEvent(line, 'file');\n}\nconst timer = setInterval(readAvailable, 100);\nfunction stop() {\n clearInterval(timer);\n if (buffered.length > 0) writeEvent(buffered, 'file');\n removePidFile();\n process.exit(0);\n}\nprocess.on('SIGINT', stop);\nprocess.on('SIGTERM', stop);\n`;\n\nexport function buildTmuxPipeCommand(config: WorkerConfig): string {\n const encodedConfig = encodeConfig(config);\n return [\n shellQuote(process.execPath),\n '-e',\n shellQuote(TMUX_PIPE_RUNNER_SOURCE),\n shellQuote(encodedConfig),\n ].join(' ');\n}\n\nexport async function waitForCaptureProcess(\n sourceId: string,\n pidFile: string,\n timeoutMs = 2000,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = readPidIdentity(pidFile);\n if (identity) {\n return { sourceId, process: identity, pidFile };\n }\n await new Promise((resolve) => setTimeout(resolve, 25));\n } while (Date.now() < deadline);\n\n throw new Error(`ProofShot could not capture the log helper identity for ${sourceId}.`);\n}\n\nexport async function startProcessCapture(\n definition: ProcessDefinition,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n const config: WorkerConfig = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes,\n stripAnsi,\n source,\n command: definition.command,\n cwd: definition.cwd,\n env: definition.env,\n shellPath: getShellExecutable(),\n };\n return startDetachedWorker(source.id, pidFile, PROCESS_RUNNER_SOURCE, config);\n}\n\nexport async function startFileCapture(\n filePath: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n let offset = 0;\n let fileDevice: number | undefined;\n let fileInode: number | undefined;\n let liveMaxBytes = maxBytes;\n if (fs.existsSync(filePath)) {\n const fd = fs.openSync(filePath, 'r');\n try {\n const stat = fs.fstatSync(fd);\n offset = stat.size;\n fileDevice = stat.dev;\n fileInode = stat.ino;\n const historyBudget = Math.max(1, Math.floor(maxBytes / 2));\n liveMaxBytes = Math.max(1, maxBytes - historyBudget);\n const historyLength = Math.min(stat.size, historyBudget);\n const history = Buffer.alloc(historyLength);\n fs.readSync(fd, history, 0, historyLength, stat.size - historyLength);\n appendHistory(\n history.toString('utf-8'),\n source,\n evidencePath,\n historyBudget,\n stripAnsi,\n 'file',\n );\n } finally {\n fs.closeSync(fd);\n }\n }\n\n const config = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes: liveMaxBytes,\n stripAnsi,\n source,\n offset,\n fileDevice,\n fileInode,\n filePath,\n };\n return startDetachedWorker(source.id, pidFile, FILE_RUNNER_SOURCE, config);\n}\n\nexport function appendHistory(\n raw: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n maxBytes: number,\n stripAnsi: boolean,\n stream: EvidenceEvent['stream'],\n): void {\n const normalized = normalizeLogText(raw, stripAnsi);\n const lines = normalized.split('\\n').filter((line) => line.length > 0);\n const retained: Array<{ event: EvidenceEvent; serialized: string; logLine: string }> = [];\n let retainedBytes = 0;\n let truncated = false;\n for (let index = lines.length - 1; index >= 0; index -= 1) {\n const event: EvidenceEvent = {\n version: 1,\n origin: 'environment',\n group: source.group,\n sourceId: source.id,\n sourceTitle: source.title,\n stream,\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: lines[index],\n };\n const serialized = JSON.stringify(event) + '\\n';\n const logLine = `${lines[index]}\\n`;\n const eventBytes =\n Buffer.byteLength(serialized) + Buffer.byteLength(logLine);\n if (retainedBytes + eventBytes > maxBytes) {\n truncated = true;\n break;\n }\n retained.unshift({ event, serialized, logLine });\n retainedBytes += eventBytes;\n }\n if (truncated && retained.length > 0) {\n while (retained.length > 0) {\n retained[0].event.truncated = true;\n retained[0].serialized = JSON.stringify(retained[0].event) + '\\n';\n retainedBytes = retained.reduce(\n (total, entry) =>\n total +\n Buffer.byteLength(entry.serialized) +\n Buffer.byteLength(entry.logLine),\n 0,\n );\n if (retainedBytes <= maxBytes) break;\n retained.shift();\n }\n }\n if (truncated && retained.length === 0) {\n const event: EvidenceEvent = {\n version: 1,\n origin: 'environment',\n group: source.group,\n sourceId: source.id,\n sourceTitle: source.title,\n stream,\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: '[ProofShot capture truncated at configured byte limit]',\n truncated: true,\n };\n const serialized = JSON.stringify(event) + '\\n';\n const logLine = `${event.text}\\n`;\n if (\n Buffer.byteLength(serialized) + Buffer.byteLength(logLine) <=\n maxBytes\n ) {\n retained.push({ event, serialized, logLine });\n }\n }\n for (const entry of retained) {\n fs.appendFileSync(evidencePath, entry.serialized);\n fs.appendFileSync(source.logPath, entry.logLine);\n }\n}\n\nexport function createWorkerConfig(params: WorkerConfig): WorkerConfig {\n return params;\n}\n\nasync function startDetachedWorker(\n sourceId: string,\n pidFile: string,\n workerSource: string,\n config: object,\n): Promise {\n fs.mkdirSync(path.dirname(pidFile), { recursive: true });\n const errorFd = fs.openSync(`${pidFile}.stderr`, 'a', 0o600);\n const worker = spawn(process.execPath, ['-e', workerSource, encodeConfig(config)], {\n detached: true,\n stdio: ['ignore', 'ignore', errorFd],\n });\n fs.closeSync(errorFd);\n worker.unref();\n\n let identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n for (let attempt = 0; !identity && attempt < 20; attempt += 1) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n }\n if (!identity) {\n try {\n if (worker.pid) {\n process.kill(-worker.pid, 'SIGKILL');\n }\n } catch {\n // The worker may already have exited.\n }\n throw new Error(`ProofShot could not capture the runner identity for ${sourceId}.`);\n }\n return { sourceId, process: identity, pidFile };\n}\n\nfunction readPidIdentity(pidFile: string): ProcessIdentity | null {\n try {\n const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());\n return captureProcessIdentity(pid);\n } catch {\n return null;\n }\n}\n\nfunction encodeConfig(config: object): string {\n return Buffer.from(JSON.stringify(config)).toString('base64');\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n","import * as fs from 'fs';\nimport type { EvidenceEvent } from './types.js';\n\nconst ANSI_PATTERN =\n // eslint-disable-next-line no-control-regex\n /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst CONTROL_PATTERN = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\n\nexport function normalizeLogText(text: string, stripAnsi = true): string {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(CONTROL_PATTERN, '');\n return stripAnsi ? normalized.replace(ANSI_PATTERN, '') : normalized;\n}\n\nexport function appendEvidenceEvent(filePath: string, event: EvidenceEvent): void {\n fs.appendFileSync(filePath, JSON.stringify(event) + '\\n');\n}\n\nexport function loadEvidenceEvents(filePath: string): EvidenceEvent[] {\n if (!fs.existsSync(filePath)) {\n return [];\n }\n\n return fs\n .readFileSync(filePath, 'utf-8')\n .split('\\n')\n .filter(Boolean)\n .map((line, index) => {\n try {\n const parsed: unknown = JSON.parse(line);\n return isEvidenceEvent(parsed)\n ? parsed\n : malformedEvidenceEvent(index + 1);\n } catch {\n return malformedEvidenceEvent(index + 1);\n }\n });\n}\n\nfunction isEvidenceEvent(value: unknown): value is EvidenceEvent {\n if (typeof value !== 'object' || value === null) return false;\n const event = value as Partial;\n return (\n event.version === 1 &&\n (event.origin === 'environment' || event.origin === 'browser') &&\n typeof event.group === 'string' &&\n typeof event.sourceId === 'string' &&\n typeof event.sourceTitle === 'string' &&\n typeof event.text === 'string' &&\n (event.relativeTimeSec === null ||\n (typeof event.relativeTimeSec === 'number' &&\n Number.isFinite(event.relativeTimeSec)))\n );\n}\n\nfunction malformedEvidenceEvent(line: number): EvidenceEvent {\n return {\n version: 1,\n origin: 'environment',\n group: 'environment',\n sourceId: 'capture-health',\n sourceTitle: 'Capture health',\n stream: 'stderr',\n segment: 'live',\n timestamp: null,\n relativeTimeSec: null,\n text: `[malformed canonical evidence row at line ${line}]`,\n captureGap: true,\n };\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execFileSync } from 'child_process';\nimport {\n appendHistory,\n buildTmuxPipeCommand,\n createWorkerConfig,\n waitForCaptureProcess,\n} from './workers.js';\nimport { appendEvidenceEvent } from './evidence.js';\nimport type {\n ExternalTmuxConnection,\n EnvironmentState,\n LauncherEnvironmentState,\n LogSourceConfig,\n LogsConfig,\n ResolvedLogSourceState,\n TmuxEnvironmentConfig,\n TmuxEnvironmentState,\n TmuxPaneState,\n} from './types.js';\nimport {\n captureProcessIdentity,\n processIdentitiesMatch,\n processIdentityMatches,\n spawnShellCommand,\n terminateOwnedProcess,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\ntype PaneMapping = {\n key: string;\n paneId: string;\n title?: string;\n group?: string;\n};\n\ntype TmuxConnection = {\n socketPath: string;\n sessionName: string;\n paneMappings: PaneMapping[];\n ownsServer: boolean;\n ownsSession: boolean;\n};\n\nexport async function startTmuxEnvironment(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n proofShotSessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n assertTmuxAvailable();\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n const captureDir = path.join(sessionDir, '.capture');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.mkdirSync(captureDir, { recursive: true, mode: 0o700 });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n let state: TmuxEnvironmentState | null = null;\n let pendingLauncher: LauncherEnvironmentState | null = null;\n let connection: TmuxConnection;\n try {\n connection =\n config.launch.kind === 'panes'\n ? startOwnedTmux(config, proofShotSessionName, (startedConnection) => {\n const startedState = createTmuxState(\n config,\n startedConnection,\n evidencePath,\n );\n state = startedState;\n onState(startedState);\n })\n : await startExternalTmux(config, (launcher) => {\n pendingLauncher = {\n kind: 'launcher',\n evidencePath,\n sources: [],\n launcher: {\n sourceId: 'external-launcher',\n process: launcher,\n pidFile: '',\n },\n };\n onState(pendingLauncher);\n });\n if (!state) {\n const connectedState = createTmuxState(\n config,\n connection,\n evidencePath,\n );\n state = connectedState;\n onState(connectedState);\n }\n } catch (error) {\n if (state) {\n await stopTmuxEnvironment(state).catch(() => {});\n } else if (pendingLauncher) {\n await terminateOwnedProcessTree(pendingLauncher.launcher.process).catch(() => {});\n }\n throw error;\n }\n\n try {\n if (!state) {\n throw new Error('tmux environment ownership state was not initialized.');\n }\n let activeState: TmuxEnvironmentState = state;\n const tmuxSources = resolveTmuxSources(config, logs, connection);\n const panes = tmuxSources.map(({ config: sourceConfig, mapping }) =>\n resolvePane(\n connection.socketPath,\n connection.sessionName,\n sourceConfig,\n mapping,\n logsDir,\n ),\n );\n const resolvedPaneIds = new Set();\n for (const { pane } of panes) {\n if (resolvedPaneIds.has(pane.paneId)) {\n throw new Error(`Multiple log sources resolved to tmux pane ${pane.paneId}.`);\n }\n resolvedPaneIds.add(pane.paneId);\n }\n disambiguateTitles(panes);\n activeState = {\n ...activeState,\n panes: panes.map(({ pane }) => pane),\n sources: panes.map(({ source }) => source),\n };\n state = activeState;\n onState(activeState);\n\n for (const pane of panes) {\n const pipeStatus = tmuxExec(connection.socketPath, [\n 'display-message',\n '-p',\n '-t',\n pane.pane.paneId,\n '#{pane_pipe}',\n ]);\n if (pipeStatus === '1') {\n throw new Error(\n `tmux pane ${pane.pane.paneId} already has a pipe-pane consumer.`,\n );\n }\n\n const pidFile = path.join(captureDir, `${pane.source.id}.pid`);\n const sourceBudget = logs.maxBytesPerSource || 5 * 1024 * 1024;\n const historyBudget = Math.max(1, Math.floor(sourceBudget / 2));\n const workerConfig = createWorkerConfig({\n evidencePath,\n logPath: pane.source.logPath,\n pidFile,\n startTimeMs,\n maxBytes: Math.max(1, sourceBudget - historyBudget),\n stripAnsi: logs.stripAnsi !== false,\n source: pane.source,\n });\n tmuxExec(connection.socketPath, [\n 'pipe-pane',\n '-t',\n pane.pane.paneId,\n buildTmuxPipeCommand(workerConfig),\n ]);\n pane.pane.captureAttached = true;\n activeState = {\n ...activeState,\n panes: activeState.panes.map((ownedPane) =>\n ownedPane.paneId === pane.pane.paneId\n ? { ...ownedPane, captureAttached: true }\n : ownedPane,\n ),\n };\n state = activeState;\n onState(activeState);\n const history = tmuxExec(connection.socketPath, [\n 'capture-pane',\n '-p',\n '-S',\n '-',\n '-t',\n pane.pane.paneId,\n ]);\n appendHistory(\n history,\n pane.source,\n evidencePath,\n historyBudget,\n logs.stripAnsi !== false,\n 'pty',\n );\n appendEvidenceEvent(evidencePath, {\n version: 1,\n origin: 'environment',\n group: pane.source.group,\n sourceId: pane.source.id,\n sourceTitle: pane.source.title,\n stream: 'pty',\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: '[tmux history/live capture boundary]',\n });\n const capture = await waitForCaptureProcess(pane.source.id, pidFile);\n activeState = {\n ...activeState,\n captures: [...activeState.captures, capture],\n };\n state = activeState;\n onState(activeState);\n }\n return activeState;\n } catch (error) {\n if (state) {\n await stopTmuxEnvironment(state).catch(() => {});\n }\n throw error;\n }\n}\n\nexport async function stopTmuxEnvironment(\n state: TmuxEnvironmentState,\n): Promise {\n const errors: Error[] = [];\n let socketMatches = false;\n let socketIdentityError: Error | null = null;\n if (fs.existsSync(state.socket.path)) {\n try {\n assertSocketIdentity(state);\n socketMatches = true;\n } catch (error) {\n socketIdentityError = toError(error);\n }\n }\n\n const currentServer = captureProcessIdentity(state.serverProcess.pid);\n const serverIdentityReused = Boolean(\n currentServer && !processIdentitiesMatch(currentServer, state.serverProcess),\n );\n if (serverIdentityReused) {\n errors.push(new Error('tmux server identity changed; refusing widened cleanup.'));\n }\n const serverMatches = processIdentityMatches(state.serverProcess);\n if (\n socketIdentityError &&\n (serverMatches ||\n state.captures.some((capture) => processIdentityMatches(capture.process)))\n ) {\n errors.push(socketIdentityError);\n }\n\n if (serverMatches && socketMatches) {\n try {\n if (state.stopCommand) {\n await runCommand(state.stopCommand, state.stopCwd || process.cwd());\n } else if (state.ownsSession && tmuxHasSession(state)) {\n tmuxExec(state.socket.path, ['kill-session', '-t', state.sessionName]);\n } else {\n for (const pane of state.panes.filter(\n (candidate) => candidate.captureAttached,\n )) {\n try {\n tmuxExec(state.socket.path, ['pipe-pane', '-t', pane.paneId]);\n } catch {\n // A launcher-provided shutdown may already have removed the pane.\n }\n }\n }\n } catch (error) {\n errors.push(toError(error));\n }\n }\n\n for (const capture of state.captures) {\n try {\n await terminateOwnedProcess(capture.process, { graceMs: 500 });\n if (processIdentityMatches(capture.process)) {\n throw new Error(`Log helper for ${capture.sourceId} did not stop.`);\n }\n } catch (error) {\n errors.push(toError(error));\n }\n }\n\n if (state.ownsServer && !serverIdentityReused) {\n if (processIdentityMatches(state.serverProcess) && socketMatches) {\n try {\n tmuxExec(state.socket.path, ['kill-server']);\n } catch {\n // Exact process-session termination below is the verified fallback.\n }\n }\n if (processIdentityMatches(state.serverProcess)) {\n try {\n await terminateOwnedProcessTree(state.serverProcess, { graceMs: 500 });\n } catch (error) {\n errors.push(toError(error));\n }\n }\n if (processIdentityMatches(state.serverProcess)) {\n errors.push(new Error('Owned tmux server did not stop.'));\n }\n }\n\n if (\n state.ownsServer &&\n socketMatches &&\n !processIdentityMatches(state.serverProcess) &&\n fs.existsSync(state.socket.path)\n ) {\n try {\n const currentSocket = captureSocketIdentity(state.socket.path);\n if (\n currentSocket.inode !== state.socket.inode ||\n currentSocket.uid !== state.socket.uid\n ) {\n throw new Error('tmux socket changed before final cleanup.');\n }\n fs.unlinkSync(state.socket.path);\n } catch (error) {\n errors.push(toError(error));\n }\n }\n if (\n state.ownsSession &&\n processIdentityMatches(state.serverProcess) &&\n socketMatches &&\n tmuxHasSession(state)\n ) {\n errors.push(new Error(`Owned tmux session ${state.sessionName} did not stop.`));\n }\n if (errors.length > 0) {\n throw new AggregateError(errors, 'One or more tmux cleanup steps failed.');\n }\n}\n\nfunction toError(error: unknown): Error {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction startOwnedTmux(\n config: TmuxEnvironmentConfig,\n proofShotSessionName: string,\n onStarted: (connection: TmuxConnection) => void,\n): TmuxConnection {\n if (config.launch.kind !== 'panes' || config.launch.panes.length === 0) {\n throw new Error('tmux pane launch requires at least one pane.');\n }\n const paneIds = new Set();\n for (const pane of config.launch.panes) {\n validateId(pane.id);\n if (paneIds.has(pane.id)) {\n throw new Error(`Duplicate tmux pane id: ${pane.id}`);\n }\n paneIds.add(pane.id);\n buildPaneCommand(pane);\n }\n const uid = process.getuid?.() ?? process.pid;\n const socketDir = path.join('/tmp', `proofshot-${uid}`, 'tmux');\n fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });\n const socketPath = path.join(socketDir, `${proofShotSessionName}.sock`);\n if (fs.existsSync(socketPath)) {\n throw new Error(`Refusing to reuse an existing tmux socket: ${socketPath}`);\n }\n\n const sessionName = config.launch.sessionName || proofShotSessionName;\n const [firstPane, ...remainingPanes] = config.launch.panes;\n const first = parsePaneOutput(\n tmuxExec(socketPath, [\n 'new-session',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-s',\n sessionName,\n '-n',\n 'environment',\n '-c',\n firstPane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(firstPane),\n ]),\n );\n const mappings: PaneMapping[] = [\n {\n key: firstPane.id,\n paneId: first.paneId,\n title: firstPane.title,\n group: firstPane.group,\n },\n ];\n onStarted({\n socketPath,\n sessionName,\n paneMappings: [...mappings],\n ownsServer: true,\n ownsSession: true,\n });\n configurePane(socketPath, first.paneId, firstPane.id, firstPane.title);\n\n for (const pane of remainingPanes) {\n const created = parsePaneOutput(\n tmuxExec(socketPath, [\n 'split-window',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-t',\n `${sessionName}:environment`,\n '-c',\n pane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(pane),\n ]),\n );\n configurePane(socketPath, created.paneId, pane.id, pane.title);\n mappings.push({\n key: pane.id,\n paneId: created.paneId,\n title: pane.title,\n group: pane.group,\n });\n }\n tmuxExec(socketPath, ['select-layout', '-t', `${sessionName}:environment`, 'tiled']);\n return {\n socketPath,\n sessionName,\n paneMappings: mappings,\n ownsServer: true,\n ownsSession: true,\n };\n}\n\nfunction createTmuxState(\n config: TmuxEnvironmentConfig,\n connection: TmuxConnection,\n evidencePath: string,\n): TmuxEnvironmentState {\n const serverPid = Number(\n tmuxExec(connection.socketPath, ['display-message', '-p', '#{pid}']),\n );\n const serverProcess = captureProcessIdentity(serverPid);\n if (!serverProcess) {\n throw new Error('ProofShot could not capture the exact tmux server identity.');\n }\n return {\n kind: 'tmux',\n evidencePath,\n sources: [],\n socket: captureSocketIdentity(connection.socketPath),\n serverProcess,\n sessionName: connection.sessionName,\n ownsServer: connection.ownsServer,\n ownsSession: connection.ownsSession,\n panes: [],\n captures: [],\n stopCommand:\n config.launch.kind === 'external-command'\n ? config.launch.stopCommand\n : undefined,\n stopCwd: config.cwd,\n };\n}\n\nasync function startExternalTmux(\n config: TmuxEnvironmentConfig,\n onLauncherStarted: (identity: NonNullable>) => void,\n): Promise {\n if (config.launch.kind !== 'external-command' || !config.connection) {\n throw new Error('External tmux launch requires a connection contract.');\n }\n const hintedSocket = config.connection.socket;\n const socketExistedBefore = hintedSocket ? fs.existsSync(hintedSocket) : true;\n const attachOnly = config.connection.ownership === 'attach';\n if (\n !attachOnly &&\n ((!hintedSocket && !config.launch.stopCommand) ||\n (socketExistedBefore && !config.launch.stopCommand))\n ) {\n throw new Error(\n 'External tmux launch against an existing or undisclosed socket requires stopCommand.',\n );\n }\n const output = await runCommand(\n config.launch.command,\n config.cwd || process.cwd(),\n onLauncherStarted,\n config.launch.timeoutMs,\n );\n const parsed =\n config.connection.format === 'json'\n ? parseJsonConnection(output)\n : parseAttachCommand(output, config.cwd || process.cwd());\n const ownsCreatedSocket =\n hintedSocket !== undefined &&\n path.resolve(hintedSocket) === path.resolve(parsed.socketPath) &&\n !socketExistedBefore;\n return {\n ...parsed,\n ownsServer: ownsCreatedSocket,\n ownsSession: ownsCreatedSocket,\n };\n}\n\nfunction resolveTmuxSources(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n connection: TmuxConnection,\n): Array<{\n config: Extract;\n mapping?: PaneMapping;\n}> {\n const configured = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'tmux-pane',\n );\n if (configured.length > 0) {\n return configured.map((source) => {\n const connectionKey =\n 'connectionKey' in source.match\n ? source.match.connectionKey\n : undefined;\n return {\n config: source,\n mapping: connectionKey\n ? connection.paneMappings.find(\n (mapping) => mapping.key === connectionKey,\n )\n : undefined,\n };\n });\n }\n if (config.launch.kind !== 'panes') {\n return [];\n }\n return connection.paneMappings.map((mapping) => ({\n config: {\n id: mapping.key,\n title: mapping.title,\n group: mapping.group,\n kind: 'tmux-pane',\n match: { connectionKey: mapping.key },\n },\n mapping,\n }));\n}\n\nfunction resolvePane(\n socketPath: string,\n sessionName: string,\n sourceConfig: Extract,\n mapping: PaneMapping | undefined,\n logsDir: string,\n): { pane: TmuxPaneState; source: ResolvedLogSourceState } {\n let target: string;\n if ('connectionKey' in sourceConfig.match) {\n if (!mapping) {\n throw new Error(\n `No tmux pane mapping matched connection key \"${sourceConfig.match.connectionKey}\".`,\n );\n }\n target = mapping.paneId;\n } else if ('tag' in sourceConfig.match) {\n const tag = sourceConfig.match.tag;\n const matches = tmuxExec(socketPath, [\n 'list-panes',\n '-t',\n sessionName,\n '-F',\n '#{pane_id}\\t#{@proofshot-source}',\n ])\n .split('\\n')\n .filter((line) => line.split('\\t')[1] === tag);\n if (matches.length !== 1) {\n throw new Error(\n `Expected one tmux pane tagged \"${tag}\", found ${matches.length}.`,\n );\n }\n target = matches[0].split('\\t')[0];\n } else {\n target = sourceConfig.match.target;\n }\n\n const fields = tmuxExec(socketPath, [\n 'display-message',\n '-p',\n '-t',\n target,\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}\\t#{pane_title}\\t#{session_name}\\t#{session_name}:#{window_name}.#{pane_index}',\n ]).split('\\t');\n if (fields.length !== 6) {\n throw new Error(`Could not resolve tmux pane metadata for ${target}.`);\n }\n if (fields[4] !== sessionName) {\n throw new Error(\n `tmux pane ${fields[0]} belongs to session \"${fields[4]}\", expected \"${sessionName}\".`,\n );\n }\n const paneIndex = Number(fields[1]);\n const tmuxTitle = fields[3].trim();\n const title =\n mapping?.title ||\n (tmuxTitle.length > 0 ? tmuxTitle : `Pane ${paneIndex}`);\n const group = sourceConfig.group || mapping?.group || 'environment';\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title,\n group,\n kind: 'tmux-pane',\n stream: 'pty',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n return {\n source,\n pane: {\n paneId: fields[0],\n paneIndex,\n panePid: Number(fields[2]),\n sourceId: source.id,\n title,\n group,\n target: fields[5],\n captureAttached: false,\n },\n };\n}\n\nfunction disambiguateTitles(\n panes: Array<{ pane: TmuxPaneState; source: ResolvedLogSourceState }>,\n): void {\n const counts = new Map();\n for (const pane of panes) {\n counts.set(pane.source.title, (counts.get(pane.source.title) || 0) + 1);\n }\n for (const pane of panes) {\n if ((counts.get(pane.source.title) || 0) > 1) {\n const title = `${pane.source.title} (Pane ${pane.pane.paneIndex})`;\n pane.source.title = title;\n pane.pane.title = title;\n }\n }\n}\n\nfunction configurePane(\n socketPath: string,\n paneId: string,\n sourceId: string,\n title?: string,\n): void {\n validateId(sourceId);\n tmuxExec(socketPath, [\n 'set-option',\n '-p',\n '-t',\n paneId,\n '@proofshot-source',\n sourceId,\n ]);\n if (title) {\n tmuxExec(socketPath, ['select-pane', '-t', paneId, '-T', title]);\n }\n}\n\nfunction buildPaneCommand(\n pane: { command: string; env?: Record },\n): string {\n const assignments = Object.entries(pane.env || {}).map(([key, value]) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\n throw new Error(`Invalid environment variable name: ${key}`);\n }\n return `${key}=${shellQuote(value)}`;\n });\n return assignments.length > 0\n ? `env ${assignments.join(' ')} ${pane.command}`\n : pane.command;\n}\n\nfunction parsePaneOutput(output: string): {\n paneId: string;\n paneIndex: number;\n panePid: number;\n} {\n const [paneId, paneIndex, panePid] = output.split('\\t');\n if (!paneId || !Number.isInteger(Number(paneIndex)) || !Number.isInteger(Number(panePid))) {\n throw new Error(`Unexpected tmux pane output: ${output}`);\n }\n return { paneId, paneIndex: Number(paneIndex), panePid: Number(panePid) };\n}\n\nfunction parseJsonConnection(output: string): TmuxConnection {\n const parsed = JSON.parse(output) as Partial;\n if (\n !parsed.tmux ||\n !path.isAbsolute(parsed.tmux.socket) ||\n typeof parsed.tmux.session !== 'string' ||\n parsed.tmux.session.length === 0 ||\n (parsed.tmux.panes !== undefined && !Array.isArray(parsed.tmux.panes))\n ) {\n throw new Error('External launcher returned invalid tmux JSON.');\n }\n const paneMappings: PaneMapping[] = [];\n const keys = new Set();\n const paneIds = new Set();\n for (const [index, pane] of (parsed.tmux.panes || []).entries()) {\n if (\n typeof pane !== 'object' ||\n pane === null ||\n typeof pane.key !== 'string' ||\n !/^[A-Za-z0-9_-]+$/.test(pane.key) ||\n typeof pane.paneId !== 'string' ||\n !/^%\\d+$/.test(pane.paneId) ||\n (pane.title !== undefined && typeof pane.title !== 'string') ||\n (pane.group !== undefined && typeof pane.group !== 'string')\n ) {\n throw new Error(`External launcher returned invalid pane mapping at index ${index}.`);\n }\n if (keys.has(pane.key) || paneIds.has(pane.paneId)) {\n throw new Error('External launcher returned duplicate pane mappings.');\n }\n keys.add(pane.key);\n paneIds.add(pane.paneId);\n paneMappings.push(pane);\n }\n return {\n socketPath: parsed.tmux.socket,\n sessionName: parsed.tmux.session,\n paneMappings,\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction parseAttachCommand(output: string, cwd: string): TmuxConnection {\n const tokens = tokenizeShellCommand(output);\n const tmuxIndex = tokens.findIndex((token) => path.basename(token) === 'tmux');\n const attachIndex = tokens.findIndex(\n (token, index) =>\n index > tmuxIndex && (token === 'attach' || token === 'attach-session'),\n );\n const targetIndex = tokens.indexOf('-t', attachIndex + 1);\n const socketIndex = tokens.indexOf('-S', tmuxIndex + 1);\n const labelIndex = tokens.indexOf('-L', tmuxIndex + 1);\n if (\n tmuxIndex < 0 ||\n attachIndex < 0 ||\n targetIndex < 0 ||\n !tokens[targetIndex + 1] ||\n (socketIndex < 0 && labelIndex < 0)\n ) {\n throw new Error('External launcher did not emit a supported tmux attach command.');\n }\n const flag = socketIndex >= 0 ? '-S' : '-L';\n const valueIndex = socketIndex >= 0 ? socketIndex + 1 : labelIndex + 1;\n const value = tokens[valueIndex];\n const sessionName = tokens[targetIndex + 1];\n if (!value) {\n throw new Error('External launcher emitted a tmux socket flag without a value.');\n }\n const socketPath =\n flag === '-S'\n ? path.resolve(cwd, value)\n : execFileSync(\n 'tmux',\n ['-L', value, 'display-message', '-p', '#{socket_path}'],\n { encoding: 'utf-8' },\n ).trim();\n return {\n socketPath,\n sessionName,\n paneMappings: [],\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction tokenizeShellCommand(command: string): string[] {\n const tokens: string[] = [];\n let current = '';\n let quote: \"'\" | '\"' | null = null;\n let escaping = false;\n for (const character of command.trim()) {\n if (escaping) {\n current += character;\n escaping = false;\n } else if (character === '\\\\' && quote !== \"'\") {\n escaping = true;\n } else if (quote) {\n if (character === quote) quote = null;\n else current += character;\n } else if (character === \"'\" || character === '\"') {\n quote = character;\n } else if (/\\s/.test(character)) {\n if (current) {\n tokens.push(current);\n current = '';\n }\n } else {\n current += character;\n }\n }\n if (escaping || quote) {\n throw new Error('External launcher emitted an unterminated tmux attach command.');\n }\n if (current) tokens.push(current);\n return tokens;\n}\n\nfunction tmuxExec(socketPath: string, args: string[]): string {\n return execFileSync('tmux', ['-S', socketPath, ...args], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trimEnd();\n}\n\nfunction tmuxHasSession(state: TmuxEnvironmentState): boolean {\n if (!processIdentityMatches(state.serverProcess)) {\n return false;\n }\n try {\n tmuxExec(state.socket.path, ['has-session', '-t', state.sessionName]);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function runCommand(\n command: string,\n cwd: string,\n onStarted?: (\n identity: NonNullable>,\n ) => void,\n timeoutMs = 30_000,\n): Promise {\n const child = spawnShellCommand(command, {\n cwd,\n detached: true,\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n const identity = child.pid ? captureProcessIdentity(child.pid) : null;\n if (!identity) {\n throw new Error('ProofShot could not capture the external launcher identity.');\n }\n try {\n onStarted?.(identity);\n } catch (error) {\n await terminateOwnedProcessTree(identity);\n throw error;\n }\n let stdout = '';\n let stderr = '';\n child.stdout?.on('data', (chunk) => {\n stdout += chunk.toString();\n });\n child.stderr?.on('data', (chunk) => {\n stderr += chunk.toString();\n });\n const outcome = await new Promise<\n { kind: 'exit'; code: number | null } | { kind: 'timeout' }\n >((resolve, reject) => {\n const timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs);\n child.once('error', reject);\n child.once('close', (code) => {\n clearTimeout(timer);\n resolve({ kind: 'exit', code });\n });\n });\n if (outcome.kind === 'timeout') {\n await terminateOwnedProcessTree(identity);\n throw new Error(`External environment command timed out after ${timeoutMs}ms.`);\n }\n const exitCode = outcome.code;\n if (exitCode !== 0) {\n await terminateOwnedProcessTree(identity);\n throw new Error(\n `External environment command failed with code ${String(exitCode)}: ${stderr.trim()}`,\n );\n }\n return stdout.trim();\n}\n\nfunction captureSocketIdentity(socketPath: string): {\n path: string;\n inode: number;\n uid: number;\n} {\n const stat = fs.lstatSync(socketPath);\n if (!stat.isSocket() || stat.isSymbolicLink()) {\n throw new Error(`tmux socket is not an owned Unix socket: ${socketPath}`);\n }\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(`tmux socket is owned by uid ${stat.uid}, expected ${uid}.`);\n }\n return { path: socketPath, inode: stat.ino, uid: stat.uid };\n}\n\nfunction assertSocketIdentity(state: TmuxEnvironmentState): void {\n if (!fs.existsSync(state.socket.path)) {\n if (!processIdentityMatches(state.serverProcess)) {\n return;\n }\n throw new Error('Owned tmux socket disappeared while its server is still alive.');\n }\n const current = captureSocketIdentity(state.socket.path);\n if (current.inode !== state.socket.inode || current.uid !== state.socket.uid) {\n throw new Error('tmux socket identity changed; refusing widened cleanup.');\n }\n}\n\nfunction assertTmuxAvailable(): void {\n try {\n execFileSync('tmux', ['-V'], { stdio: 'pipe' });\n } catch {\n throw new Error('tmux is required for environment.kind \"tmux\".');\n }\n}\n\nfunction validateId(id: string): void {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport {\n clearAgentBrowserSessionFiles,\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n captureProcessIdentity,\n ownedProcessTreeIsAlive,\n processIdentitiesMatch,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n if (!identity && session.browserLaunchAttempted) {\n throw new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n assertIdentityNotReused(identity, 'browser');\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n let gracefulCloseError: unknown;\n if (identity && processIdentityMatches(identity)) {\n try {\n closeBrowser(session.sessionName);\n } catch (error) {\n gracefulCloseError = error;\n }\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new AggregateError(\n [\n ...(gracefulCloseError ? [gracefulCloseError] : []),\n new Error(`Owned browser process session ${identity.sessionId} did not stop.`),\n ],\n 'Browser cleanup failed.',\n );\n }\n if (session.agentBrowserSocketDir) {\n clearAgentBrowserSessionFiles(session.agentBrowserSocketDir, session.sessionName);\n }\n if (gracefulCloseError) {\n console.warn(\n `ProofShot graceful browser close failed; exact owned-process cleanup succeeded: ${\n gracefulCloseError instanceof Error\n ? gracefulCloseError.message\n : String(gracefulCloseError)\n }`,\n );\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n assertIdentityNotReused(session.serverProcess, 'server');\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nfunction assertIdentityNotReused(\n identity: ProcessIdentity | null | undefined,\n label: string,\n): void {\n if (!identity) return;\n const current = captureProcessIdentity(identity.pid);\n if (current && !processIdentitiesMatch(current, identity)) {\n throw new Error(\n `Owned ${label} process identity no longer matches PID ${identity.pid}; cleanup state was retained.`,\n );\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedEnvironment(session.environment);\n } catch (error) {\n cleanupError ||= error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.userInfo().homedir,\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n repository?: string;\n repositoryRoot?: string;\n treeHash?: string;\n sourceDirty?: boolean;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport type { CanonicalEvidence, Verdict } from '../artifacts/evidence.js';\nimport type { SessionMetadata } from './metadata.js';\n\nconst MANIFEST_FILENAME = 'artifact-manifest.json';\n\nexport type GitProvenance = {\n repository: string;\n branch: string;\n commitSha: string;\n treeHash: string;\n sourceDirty: boolean;\n};\n\nexport type ManifestArtifactKind =\n | 'screenshot'\n | 'video'\n | 'viewer'\n | 'summary'\n | 'evidence'\n | 'verdict'\n | 'log';\n\nexport type ManifestArtifact = {\n id: string;\n kind: ManifestArtifactKind;\n path: string;\n sha256: string;\n size: number;\n order: number;\n};\n\nexport type ArtifactManifest = {\n version: 1;\n sessionId: string;\n repository: string;\n branch: string;\n commitSha: string;\n treeHash: string;\n sourceDirty: boolean;\n sourceDrift: boolean;\n startedAt: string;\n finalizedAt: string;\n completion: 'complete';\n verdict: Verdict['status'];\n artifacts: ManifestArtifact[];\n};\n\nexport function captureGitProvenance(\n cwd: string = process.cwd(),\n excludedPaths: string[] = [],\n): GitProvenance {\n const git = (args: string[]): string =>\n execFileSync('git', args, {\n cwd,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trim();\n try {\n const repository = normalizeRepository(git(['remote', 'get-url', 'origin']));\n const branch = git(['branch', '--show-current']);\n const commitSha = git(['rev-parse', 'HEAD']);\n const treeHash = git(['rev-parse', 'HEAD^{tree}']);\n const exclusions = excludedPaths\n .map((excludedPath) => path.relative(cwd, path.resolve(excludedPath)))\n .filter((relativePath) => relativePath && !relativePath.startsWith('..'))\n .map(\n (relativePath) =>\n `:(exclude)${relativePath.split(path.sep).join(path.posix.sep)}`,\n );\n const sourceDirty =\n git([\n 'status',\n '--porcelain',\n '--untracked-files=all',\n '--',\n '.',\n ...exclusions,\n ]) !== '';\n return { repository, branch, commitSha, treeHash, sourceDirty };\n } catch {\n return {\n repository: '',\n branch: '',\n commitSha: '',\n treeHash: '',\n sourceDirty: true,\n };\n }\n}\n\nexport function normalizeRepository(remote: string): string {\n const trimmed = remote.trim();\n const scpStyle = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);\n if (scpStyle && !trimmed.includes('://')) {\n return `${scpStyle[1]}/${scpStyle[2]}`\n .replace(/\\.git$/, '')\n .replace(/\\/$/, '');\n }\n try {\n const parsed = new URL(trimmed);\n return `${parsed.hostname}${parsed.pathname}`\n .replace(/\\.git$/, '')\n .replace(/\\/$/, '');\n } catch {\n return trimmed\n .replace(/\\.git$/, '')\n .replace(/\\/$/, '');\n }\n}\n\nexport function writeArtifactManifest(options: {\n sessionId: string;\n sessionDir: string;\n metadata: SessionMetadata;\n evidence: CanonicalEvidence;\n verdict: Verdict;\n finalizedProvenance?: GitProvenance;\n}): ArtifactManifest {\n const finalized =\n options.finalizedProvenance ||\n captureGitProvenance(options.metadata.repositoryRoot, [\n path.dirname(options.sessionDir),\n ]);\n const sourceDrift =\n (options.metadata.repository || '') !== finalized.repository ||\n options.metadata.branch !== finalized.branch ||\n options.metadata.commitSha !== finalized.commitSha ||\n (options.metadata.treeHash || '') !== finalized.treeHash ||\n options.metadata.sourceDirty !== false ||\n finalized.sourceDirty;\n const artifacts = collectManifestArtifacts(\n options.sessionDir,\n options.evidence,\n );\n const manifest: ArtifactManifest = {\n version: 1,\n sessionId: options.sessionId,\n repository: options.metadata.repository || '',\n branch: options.metadata.branch,\n commitSha: options.metadata.commitSha,\n treeHash: options.metadata.treeHash || '',\n sourceDirty: options.metadata.sourceDirty !== false,\n sourceDrift,\n startedAt: options.metadata.startedAt,\n finalizedAt: new Date().toISOString(),\n completion: 'complete',\n verdict: options.verdict.status,\n artifacts,\n };\n writeJsonAtomically(\n path.join(options.sessionDir, MANIFEST_FILENAME),\n manifest,\n );\n return manifest;\n}\n\nexport function loadArtifactManifest(\n sessionDir: string,\n): ArtifactManifest | null {\n const manifestPath = path.join(sessionDir, MANIFEST_FILENAME);\n try {\n if (\n fs.lstatSync(sessionDir).isSymbolicLink() ||\n fs.lstatSync(manifestPath).isSymbolicLink()\n ) {\n return null;\n }\n const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));\n return isArtifactManifest(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nexport function validateManifestArtifacts(\n sessionDir: string,\n manifest: ArtifactManifest,\n): void {\n const root = fs.realpathSync(sessionDir);\n const ids = new Set();\n const paths = new Set();\n for (const [index, artifact] of manifest.artifacts.entries()) {\n if (ids.has(artifact.id)) {\n throw new Error(`Duplicate artifact ID: ${artifact.id}`);\n }\n ids.add(artifact.id);\n if (paths.has(artifact.path)) {\n throw new Error(`Duplicate artifact path: ${artifact.path}`);\n }\n paths.add(artifact.path);\n if (artifact.order !== index) {\n throw new Error(`Artifact order is invalid for ${artifact.id}.`);\n }\n if (\n !artifact.path ||\n path.isAbsolute(artifact.path) ||\n artifact.path.split(/[\\\\/]/).includes('..')\n ) {\n throw new Error(`Unsafe artifact path: ${artifact.path}`);\n }\n if (\n (artifact.kind === 'screenshot' || artifact.kind === 'video') &&\n path.dirname(artifact.path) !== '.'\n ) {\n throw new Error(\n `Publishable media must be stored at the session root: ${artifact.path}`,\n );\n }\n const artifactPath = path.resolve(sessionDir, artifact.path);\n let componentPath = sessionDir;\n for (const component of artifact.path.split(/[\\\\/]/)) {\n componentPath = path.join(componentPath, component);\n if (fs.lstatSync(componentPath).isSymbolicLink()) {\n throw new Error(`Artifact path contains a symlink: ${artifact.path}`);\n }\n }\n const stat = fs.lstatSync(artifactPath);\n if (stat.isSymbolicLink() || !stat.isFile()) {\n throw new Error(`Artifact is not a regular file: ${artifact.path}`);\n }\n const realPath = fs.realpathSync(artifactPath);\n if (!realPath.startsWith(`${root}${path.sep}`)) {\n throw new Error(`Artifact escapes its session directory: ${artifact.path}`);\n }\n const contents = fs.readFileSync(realPath);\n const hash = createHash('sha256').update(contents).digest('hex');\n if (hash !== artifact.sha256 || contents.length !== artifact.size) {\n throw new Error(`Artifact hash mismatch: ${artifact.path}`);\n }\n }\n}\n\nfunction isArtifactManifest(value: unknown): value is ArtifactManifest {\n if (typeof value !== 'object' || value === null) return false;\n const manifest = value as Partial;\n return (\n manifest.version === 1 &&\n typeof manifest.sessionId === 'string' &&\n typeof manifest.repository === 'string' &&\n typeof manifest.branch === 'string' &&\n typeof manifest.commitSha === 'string' &&\n typeof manifest.treeHash === 'string' &&\n typeof manifest.sourceDirty === 'boolean' &&\n typeof manifest.sourceDrift === 'boolean' &&\n typeof manifest.startedAt === 'string' &&\n typeof manifest.finalizedAt === 'string' &&\n manifest.completion === 'complete' &&\n (manifest.verdict === 'PASS' ||\n manifest.verdict === 'FAIL' ||\n manifest.verdict === 'INCOMPLETE' ||\n manifest.verdict === 'BLOCKED') &&\n Array.isArray(manifest.artifacts) &&\n manifest.artifacts.every(\n (artifact, index) =>\n typeof artifact === 'object' &&\n artifact !== null &&\n typeof artifact.id === 'string' &&\n typeof artifact.path === 'string' &&\n typeof artifact.sha256 === 'string' &&\n typeof artifact.size === 'number' &&\n artifact.size >= 0 &&\n artifact.order === index &&\n [\n 'screenshot',\n 'video',\n 'viewer',\n 'summary',\n 'evidence',\n 'verdict',\n 'log',\n ].includes(artifact.kind),\n )\n );\n}\n\nfunction collectManifestArtifacts(\n sessionDir: string,\n evidence: CanonicalEvidence,\n): ManifestArtifact[] {\n const screenshotOrder = new Map(\n evidence.actions\n .map((action) => action.action.match(/^screenshot\\s+(.+)$/)?.[1])\n .filter((value): value is string => Boolean(value))\n .map((value, index) => [path.basename(value), index]),\n );\n const verifiedScreenshots = new Set(\n evidence.screenshots\n .filter(\n (screenshot) =>\n screenshot.validPng &&\n !screenshot.visuallyBlank &&\n screenshot.sha256 !== null,\n )\n .map((screenshot) => screenshot.file),\n );\n const candidates = listArtifactFiles(sessionDir)\n .filter((file) => {\n const kind = classifyArtifact(file);\n return (\n kind !== null &&\n (kind !== 'screenshot' || verifiedScreenshots.has(path.basename(file)))\n );\n })\n .sort((left, right) => {\n const leftOrder = screenshotOrder.get(path.basename(left));\n const rightOrder = screenshotOrder.get(path.basename(right));\n if (leftOrder !== undefined || rightOrder !== undefined) {\n return (leftOrder ?? Number.MAX_SAFE_INTEGER) -\n (rightOrder ?? Number.MAX_SAFE_INTEGER);\n }\n return left.localeCompare(right);\n });\n return candidates.map((file, order) => {\n const contents = fs.readFileSync(path.join(sessionDir, file));\n const kind = classifyArtifact(file)!;\n return {\n id: `${kind}:${file}`,\n kind,\n path: file,\n sha256: createHash('sha256').update(contents).digest('hex'),\n size: contents.length,\n order,\n };\n });\n}\n\nfunction listArtifactFiles(\n root: string,\n current: string = root,\n): string[] {\n const files: string[] = [];\n for (const entry of fs.readdirSync(current, { withFileTypes: true })) {\n if (entry.isSymbolicLink()) {\n continue;\n }\n const absolutePath = path.join(current, entry.name);\n if (entry.isDirectory()) {\n files.push(...listArtifactFiles(root, absolutePath));\n } else if (entry.isFile()) {\n files.push(path.relative(root, absolutePath));\n }\n }\n return files;\n}\n\nfunction classifyArtifact(file: string): ManifestArtifactKind | null {\n const basename = path.basename(file);\n const isSessionRoot = path.dirname(file) === '.';\n if (isSessionRoot && file.endsWith('.png')) return 'screenshot';\n if (\n isSessionRoot &&\n (basename === 'session.webm' || basename === 'session.mp4')\n ) {\n return 'video';\n }\n if (isSessionRoot && basename === 'viewer.html') return 'viewer';\n if (isSessionRoot && basename === 'SUMMARY.md') return 'summary';\n if (isSessionRoot && basename === 'evidence.json') return 'evidence';\n if (isSessionRoot && basename === 'verdict.json') return 'verdict';\n if (file.endsWith('.log') || file.endsWith('.ndjson')) return 'log';\n return null;\n}\n\nfunction writeJsonAtomically(filePath: string, value: unknown): void {\n const temporaryPath = `${filePath}.${process.pid}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, filePath);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport {\n probeMediaDuration,\n writeCanonicalEvidence,\n} from '../artifacts/evidence.js';\nimport { loadMetadata } from '../session/metadata.js';\nimport { writeArtifactManifest } from '../session/manifest.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { processIdentityMatches } from '../utils/process.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n const stopSignals = installStopSignalHandlers();\n try {\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n session.stoppedAt ||= new Date().toISOString();\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive =\n session.recordingActive || Boolean(session.recordingStartedAt);\n const startTime = new Date(session.startedAt).getTime();\n const recordingStartTime = session.recordingStartedAt\n ? new Date(session.recordingStartedAt).getTime()\n : startTime;\n const recordingStartOffsetSec = Math.max(\n 0,\n (recordingStartTime - startTime) / 1000,\n );\n const durationMs = new Date(session.stoppedAt).getTime() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n let consoleCollectionSucceeded = false;\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n consoleCollectionSucceeded = true;\n } catch {\n consoleCollectionSucceeded = false;\n }\n }\n if (consoleCollectionSucceeded) {\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n } else {\n session.consoleEvidenceAvailable = false;\n session.consoleErrorCount = 0;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n const cleanupErrors: unknown[] = [];\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupErrors.push(error);\n }\n }\n\n if (\n session.environment &&\n !session.environmentStopped &&\n session.environment.kind !== 'launcher'\n ) {\n const captures =\n session.environment.kind === 'tmux'\n ? session.environment.captures\n : session.environment.processes;\n session.environment.healthFailures = captures\n .filter((capture) => !processIdentityMatches(capture.process))\n .map((capture) => capture.sourceId);\n persistOwnedSession(session, controlDir);\n }\n const finalizedEnvironment = session.environment;\n if (session.environment && !session.environmentStopped) {\n console.log(chalk.dim('Stopping environment...'));\n try {\n await stopOwnedEnvironment(session.environment);\n session.environmentStopped = true;\n persistOwnedSession(session, controlDir);\n } catch (error) {\n cleanupErrors.push(error);\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupErrors.push(error);\n }\n }\n if (cleanupErrors.length > 0) {\n const cleanupError = new AggregateError(\n cleanupErrors,\n `Cleanup failed: ${cleanupErrors\n .map((error) => (error instanceof Error ? error.message : String(error)))\n .join('; ')}`,\n );\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? recordingStartOffsetSec;\n if (!session.videoTrimComplete) {\n let videoTrimOffsetSec = 0;\n if (fs.existsSync(session.videoPath)) {\n videoTrimOffsetSec = trimVideo(\n session.videoPath,\n screenshots,\n sessionDir,\n startTime,\n sessionLog,\n recordingStartOffsetSec,\n );\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n trimOffsetSec = recordingStartOffsetSec + videoTrimOffsetSec;\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n const canonicalDurationSec = Math.max(0, durationSec - trimOffsetSec);\n const { evidence, verdict } = writeCanonicalEvidence({\n sessionId: session.sessionName,\n sessionDir,\n initialPageUrl: session.targetUrl,\n durationSec: canonicalDurationSec,\n timelineOffsetSec: trimOffsetSec,\n videoPath: session.videoPath,\n recordingWasActive,\n consoleEvidenceAvailable,\n actions: viewerEntries,\n consoleEntries: viewerConsoleEntries,\n serverEntries: viewerServerEntries,\n environment: finalizedEnvironment,\n });\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec: canonicalDurationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n evidence,\n verdict,\n });\n const metadata = loadMetadata(sessionDir) || {\n repository: '',\n repositoryRoot: session.startDirectory,\n branch: '',\n commitSha: '',\n treeHash: '',\n sourceDirty: true,\n startedAt: session.startedAt,\n description: session.description,\n };\n writeArtifactManifest({\n sessionId: session.sessionName,\n sessionDir,\n metadata,\n evidence,\n verdict,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n console.log(`🧾 Verdict: ${verdict.status}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n } finally {\n const interruptedBy = stopSignals.remove();\n if (interruptedBy) {\n process.exitCode = interruptedBy === 'SIGINT' ? 130 : 143;\n }\n }\n}\n\nfunction installStopSignalHandlers(): {\n remove: () => NodeJS.Signals | null;\n} {\n let interruptedBy: NodeJS.Signals | null = null;\n let signalCount = 0;\n let forcedExitTimer: NodeJS.Timeout | null = null;\n const handlers = new Map void>();\n const removeListeners = (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n };\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n signalCount += 1;\n interruptedBy ||= signal;\n if (signalCount >= 3) {\n removeListeners();\n process.kill(process.pid, signal);\n return;\n }\n if (signalCount === 2) {\n console.error(\n chalk.yellow(\n `Received ${signal} again; forcing exit in 5s if exact teardown does not finish.`,\n ),\n );\n forcedExitTimer = setTimeout(() => {\n removeListeners();\n process.kill(process.pid, signal);\n }, 5000);\n return;\n }\n console.error(\n chalk.yellow(`Received ${signal}; finishing exact ProofShot teardown before exit.`),\n );\n };\n handlers.set(signal, handler);\n process.on(signal, handler);\n }\n return {\n remove: (): NodeJS.Signals | null => {\n removeListeners();\n if (forcedExitTimer) clearTimeout(forcedExitTimer);\n return interruptedBy;\n },\n };\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n sessionStartMs: number,\n sessionLog: SessionLogEntry[],\n mediaStartOffsetSec = 0,\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec =\n sessionLog[0].relativeTimeSec - mediaStartOffsetSec;\n lastActionSec =\n sessionLog[sessionLog.length - 1].relativeTimeSec - mediaStartOffsetSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter(\n (timestamp): timestamp is number =>\n timestamp !== null &&\n timestamp >= sessionStartMs + mediaStartOffsetSec * 1000,\n );\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec =\n (Math.min(...timestamps) - sessionStartMs) / 1000 -\n mediaStartOffsetSec;\n lastActionSec =\n (Math.max(...timestamps) - sessionStartMs) / 1000 -\n mediaStartOffsetSec;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const timelineTrimOffsetSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n const requestedDurationSec = trimEndSec - timelineTrimOffsetSec;\n\n // Don't trim very short videos\n if (requestedDurationSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n const mediaDurationSec = probeMediaDuration(videoPath);\n const actionDurationSec = Math.max(0, lastActionSec - firstActionSec);\n const maximumPhysicalTrimSec =\n mediaDurationSec === null\n ? timelineTrimOffsetSec\n : Math.max(0, mediaDurationSec - actionDurationSec - BUFFER_BEFORE);\n const physicalTrimStartSec = Math.min(\n timelineTrimOffsetSec,\n maximumPhysicalTrimSec,\n );\n const trimDurationSec =\n mediaDurationSec === null\n ? requestedDurationSec\n : Math.min(\n requestedDurationSec,\n mediaDurationSec - physicalTrimStartSec,\n );\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-ss',\n physicalTrimStartSec.toFixed(2),\n '-i',\n rawPath,\n '-t',\n trimDurationSec.toFixed(2),\n '-map',\n '0:v:0',\n '-c:v',\n 'libvpx-vp9',\n '-deadline',\n 'realtime',\n '-cpu-used',\n '8',\n '-crf',\n '30',\n '-b:v',\n '0',\n '-an',\n '-avoid_negative_ts',\n 'make_zero',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimDurationSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return timelineTrimOffsetSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\nimport type {\n CanonicalEvidence,\n EvidenceSourceSummary,\n Verdict,\n} from './evidence.js';\nimport type { EvidenceEvent } from '../environment/types.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n evidence?: CanonicalEvidence;\n verdict?: Verdict;\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const timed = Number.isFinite(entry.relativeTimeSec);\n const time = formatTime(timed ? Math.max(0, entry.relativeTimeSec) : Number.NaN);\n const interaction = timed\n ? ` data-time=\"${entry.relativeTimeSec}\" onclick=\"seekTo(${entry.relativeTimeSec})\"`\n : '';\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n if (!Number.isFinite(sec)) {\n return 'untimed';\n }\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\nfunction titleCase(value: string): string {\n return value\n .split(/[-_\\s]+/)\n .filter(Boolean)\n .map((part) => part[0].toUpperCase() + part.slice(1))\n .join(' ');\n}\n\ntype EvidencePanel = {\n key: string;\n label: string;\n summary: EvidenceSourceSummary | null;\n events: EvidenceEvent[];\n};\n\nfunction buildEvidencePanels(evidence: CanonicalEvidence): EvidencePanel[] {\n const panels: EvidencePanel[] = [];\n for (const origin of ['environment', 'browser'] as const) {\n const originEvents = evidence.events.filter(\n (event) => event.origin === origin && !event.presentationHidden,\n );\n if (originEvents.length === 0) {\n continue;\n }\n const originLabel = origin === 'environment' ? 'Environment' : 'Browser';\n panels.push({\n key: origin,\n label: originLabel,\n summary: null,\n events: orderEvidenceEvents(originEvents),\n });\n const sources = evidence.sources\n .filter((source) => source.origin === origin)\n .sort(\n (left, right) =>\n left.group.localeCompare(right.group) ||\n left.title.localeCompare(right.title),\n );\n for (const source of sources) {\n panels.push({\n key: `${origin}-${source.id}`,\n label:\n origin === 'environment'\n ? `${titleCase(source.group)} · ${source.title}`\n : source.title,\n summary: source,\n events: orderEvidenceEvents(\n originEvents.filter((event) => event.sourceId === source.id),\n ),\n });\n }\n }\n return panels;\n}\n\nfunction orderEvidenceEvents(events: EvidenceEvent[]): EvidenceEvent[] {\n return [...events].sort((left, right) => {\n if (left.segment !== right.segment) {\n return left.segment === 'history' ? -1 : 1;\n }\n if (left.relativeTimeSec === null) {\n return -1;\n }\n if (right.relativeTimeSec === null) {\n return 1;\n }\n return left.relativeTimeSec - right.relativeTimeSec;\n });\n}\n\nfunction buildEvidenceLogLines(events: EvidenceEvent[]): string {\n if (events.length === 0) {\n return '

No visible evidence for this source

';\n }\n return `
${events\n    .slice(0, MAX_LOG_ENTRIES)\n    .map((event, index) => {\n      const timed =\n        event.relativeTimeSec !== null &&\n        Number.isFinite(event.relativeTimeSec);\n      const classes = [\n        'log-line',\n        isErrorLine(event.text) ? 'log-line-error' : '',\n      ]\n        .filter(Boolean)\n        .join(' ');\n      const interaction = timed\n        ? ` data-time=\"${event.relativeTimeSec}\" onclick=\"seekTo(${event.relativeTimeSec})\"`\n        : '';\n      const boundary = event.captureGap\n        ? 'capture gap'\n        : event.segment === 'history'\n          ? 'history'\n          : '';\n      return `${formatTime(timed ? event.relativeTimeSec! : Number.NaN)}${index + 1}${boundary}${escapeHtml(event.text)}`;\n    })\n    .join('\\n')}
${\n events.length > MAX_LOG_ENTRIES\n ? '

Viewer display truncated. Canonical evidence.json retains the bounded source evidence.

'\n : ''\n }`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (fs.existsSync(logPath)) {\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n entries = [];\n }\n } else {\n entries = [];\n }\n }\n\n const html = generateViewer({ ...data, entries: entries || [] });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport { PNG } from 'pngjs';\nimport type { SessionLogEntry } from '../commands/exec.js';\nimport { loadEvidenceEvents } from '../environment/evidence.js';\nimport type {\n EnvironmentState,\n EvidenceEvent,\n ResolvedLogSourceState,\n} from '../environment/types.js';\nimport type { TimestampedLogEntry } from './viewer.js';\n\nexport type VerdictStatus = 'PASS' | 'FAIL' | 'INCOMPLETE' | 'BLOCKED';\n\nexport type EvidenceSourceSummary = {\n id: string;\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n lineCount: number;\n hiddenLineCount: number;\n truncationCount: number;\n captureGapCount: number;\n incidentCount: number;\n};\n\nexport type EvidenceIncident = {\n id: string;\n severity: 'fatal' | 'error';\n origin: EvidenceEvent['origin'];\n group: string;\n message: string;\n count: number;\n sourceIds: string[];\n firstTimeSec: number | null;\n lastTimeSec: number | null;\n};\n\nexport type ScreenshotIntegrity = {\n file: string;\n sha256: string | null;\n validPng: boolean;\n visuallyBlank: boolean;\n size: number;\n};\n\nexport type CanonicalEvidence = {\n version: 1;\n sessionId: string;\n generatedAt: string;\n timelineDurationSec: number;\n mediaDurationSec: number | null;\n mediaDivergenceSec: number | null;\n mediaTruncated: boolean;\n actions: SessionLogEntry[];\n events: EvidenceEvent[];\n sources: EvidenceSourceSummary[];\n incidents: EvidenceIncident[];\n screenshots: ScreenshotIntegrity[];\n};\n\nexport type Verdict = {\n version: 1;\n status: VerdictStatus;\n reasons: string[];\n fatalIncidentCount: number;\n missingArtifacts: string[];\n duplicateScreenshotHashes: string[][];\n expectedSelectorFailures: string[];\n mediaTruncated: boolean;\n};\n\nexport type EvidenceBuildOptions = {\n sessionId: string;\n sessionDir: string;\n initialPageUrl?: string;\n durationSec: number;\n timelineOffsetSec?: number;\n videoPath: string;\n recordingWasActive: boolean;\n consoleEvidenceAvailable: boolean;\n actions: SessionLogEntry[];\n consoleEntries: TimestampedLogEntry[];\n serverEntries: TimestampedLogEntry[];\n environment?: EnvironmentState | null;\n};\n\nexport function writeCanonicalEvidence(\n options: EvidenceBuildOptions,\n): { evidence: CanonicalEvidence; verdict: Verdict } {\n const events = collectEvents(options);\n applyPresentationFilters(events, options.environment?.sources || []);\n const incidents = buildIncidents(events);\n const screenshots = inspectScreenshots(options.sessionDir, options.actions);\n const mediaDurationSec = probeMediaDuration(options.videoPath);\n const actionDuration = options.actions\n .map((entry) => entry.relativeTimeSec)\n .filter(Number.isFinite)\n .reduce((maximum, current) => Math.max(maximum, current), 0);\n const timelineDurationSec = Math.max(options.durationSec, actionDuration);\n const mediaDivergenceSec =\n mediaDurationSec === null\n ? null\n : Math.max(0, actionDuration - mediaDurationSec);\n const mediaTruncated =\n mediaDivergenceSec !== null && mediaDivergenceSec > 1;\n const sources = buildSourceSummaries(\n events,\n incidents,\n );\n const evidence: CanonicalEvidence = {\n version: 1,\n sessionId: options.sessionId,\n generatedAt: new Date().toISOString(),\n timelineDurationSec,\n mediaDurationSec,\n mediaDivergenceSec,\n mediaTruncated,\n actions: options.actions,\n events,\n sources,\n incidents,\n screenshots,\n };\n const verdict = buildVerdict(options, evidence);\n writeJsonAtomically(\n path.join(options.sessionDir, 'evidence.json'),\n evidence,\n );\n writeJsonAtomically(\n path.join(options.sessionDir, 'verdict.json'),\n verdict,\n );\n return { evidence, verdict };\n}\n\nfunction writeJsonAtomically(filePath: string, value: unknown): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction collectEvents(options: EvidenceBuildOptions): EvidenceEvent[] {\n const environmentEvents: EvidenceEvent[] =\n options.environment?.evidencePath &&\n fs.existsSync(options.environment.evidencePath)\n ? loadEvidenceEvents(options.environment.evidencePath).map((event) =>\n adjustEnvironmentEventTime(\n event,\n options.timelineOffsetSec ?? 0,\n ),\n )\n : [];\n if (options.environment && options.environment.kind !== 'launcher') {\n for (const sourceId of options.environment.healthFailures || []) {\n const source = options.environment.sources.find(\n (candidate) => candidate.id === sourceId,\n );\n environmentEvents.push({\n version: 1,\n origin: 'environment',\n group: source?.group || 'environment',\n sourceId,\n sourceTitle: source?.title || sourceId,\n stream: source?.stream || 'stderr',\n segment: 'live',\n timestamp: null,\n relativeTimeSec: null,\n text: `[capture worker exited before stop: ${sourceId}]`,\n captureGap: true,\n });\n }\n }\n environmentEvents.push(\n ...options.serverEntries.map((entry) =>\n toEvidenceEvent(entry, {\n origin: 'environment',\n group: 'backend',\n sourceId: 'server',\n sourceTitle: 'Server',\n stream: 'stderr',\n }),\n ),\n );\n\n const navigations = buildNavigations(options.actions, options.initialPageUrl);\n const browserEvents = options.consoleEntries.map((entry) => {\n const navigation = findNavigation(navigations, entry.relativeTimeSec);\n return toEvidenceEvent(entry, {\n origin: 'browser',\n group: 'browser',\n sourceId: navigation.id,\n sourceTitle: navigation.url,\n navigationId: navigation.id,\n pageUrl: navigation.url,\n stream: 'console',\n });\n });\n return [...environmentEvents, ...browserEvents];\n}\n\nfunction adjustEnvironmentEventTime(\n event: EvidenceEvent,\n timelineOffsetSec: number,\n): EvidenceEvent {\n if (event.relativeTimeSec === null || timelineOffsetSec <= 0) {\n return event;\n }\n const relativeTimeSec = event.relativeTimeSec - timelineOffsetSec;\n return {\n ...event,\n relativeTimeSec:\n relativeTimeSec >= 0\n ? parseFloat(relativeTimeSec.toFixed(3))\n : null,\n };\n}\n\nfunction toEvidenceEvent(\n entry: TimestampedLogEntry,\n source: Pick<\n EvidenceEvent,\n | 'origin'\n | 'group'\n | 'sourceId'\n | 'sourceTitle'\n | 'stream'\n | 'navigationId'\n | 'pageUrl'\n >,\n): EvidenceEvent {\n return {\n version: 1,\n ...source,\n segment: 'live',\n timestamp: null,\n relativeTimeSec: Number.isFinite(entry.relativeTimeSec)\n ? entry.relativeTimeSec\n : null,\n text: entry.text,\n };\n}\n\nfunction buildNavigations(\n actions: SessionLogEntry[],\n initialPageUrl?: string,\n): Array<{ id: string; url: string; startTimeSec: number }> {\n const navigations: Array<{ url: string; startTimeSec: number }> = [];\n const append = (url: string | undefined, startTimeSec: number): void => {\n if (!url || navigations.at(-1)?.url === url) return;\n navigations.push({ url, startTimeSec });\n };\n append(initialPageUrl, 0);\n for (const entry of actions) {\n if (!Number.isFinite(entry.relativeTimeSec)) continue;\n const explicit = entry.action.match(/^(?:open|navigate)\\s+(\\S+)/i)?.[1];\n append(entry.pageUrl || explicit, entry.relativeTimeSec);\n }\n if (navigations.length === 0) {\n navigations.push({ url: 'Browser', startTimeSec: 0 });\n }\n return navigations.map((navigation, index) => ({\n id: `browser-nav-${index + 1}`,\n ...navigation,\n }));\n}\n\nfunction findNavigation(\n navigations: Array<{ id: string; url: string; startTimeSec: number }>,\n relativeTimeSec: number,\n): { id: string; url: string } {\n const timed = Number.isFinite(relativeTimeSec) ? relativeTimeSec : 0;\n return (\n [...navigations]\n .reverse()\n .find((navigation) => navigation.startTimeSec <= timed) ||\n navigations[0]\n );\n}\n\nfunction buildIncidents(events: EvidenceEvent[]): EvidenceIncident[] {\n const incidents = new Map<\n string,\n {\n severity: 'fatal' | 'error';\n origin: EvidenceEvent['origin'];\n group: string;\n message: string;\n count: number;\n sourceIds: Set;\n times: number[];\n }\n >();\n for (const event of events) {\n const severity = classifyIncident(event.text);\n if (!severity) {\n continue;\n }\n const message = normalizeIncident(event.text);\n const key = `${event.origin}\\0${event.group}\\0${severity}\\0${message}`;\n const incident = incidents.get(key) || {\n severity,\n origin: event.origin,\n group: event.group,\n message,\n count: 0,\n sourceIds: new Set(),\n times: [],\n };\n incident.count += 1;\n incident.sourceIds.add(event.sourceId);\n if (event.relativeTimeSec !== null) {\n incident.times.push(event.relativeTimeSec);\n }\n incidents.set(key, incident);\n }\n\n return [...incidents.values()].map((incident, index) => ({\n id: `incident-${index + 1}`,\n severity: incident.severity,\n origin: incident.origin,\n group: incident.group,\n message: incident.message,\n count: incident.count,\n sourceIds: [...incident.sourceIds],\n firstTimeSec:\n incident.times.length > 0 ? Math.min(...incident.times) : null,\n lastTimeSec:\n incident.times.length > 0 ? Math.max(...incident.times) : null,\n }));\n}\n\nfunction classifyIncident(text: string): 'fatal' | 'error' | null {\n if (\n /\\bFATAL\\b|\\bpanic:|uncaught exception|unhandled rejection|capture worker exited before stop|malformed canonical evidence row|\\[process exited with code (?!0\\])/i.test(\n text,\n )\n ) {\n return 'fatal';\n }\n if (/\\bError:|ERR[_!]|Exception:|Traceback/i.test(text)) {\n return 'error';\n }\n return null;\n}\n\nfunction normalizeIncident(text: string): string {\n return text\n .replace(/\\b\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\b/g, '')\n .replace(/:\\d+:\\d+\\b/g, '::')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction buildSourceSummaries(\n events: EvidenceEvent[],\n incidents: EvidenceIncident[],\n): EvidenceSourceSummary[] {\n const sourceKeys = new Map<\n string,\n {\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n events: EvidenceEvent[];\n }\n >();\n for (const event of events) {\n const key = `${event.origin}\\0${event.sourceId}`;\n const existing = sourceKeys.get(key) || {\n title: event.sourceTitle,\n origin: event.origin,\n group: event.group,\n events: [],\n };\n existing.events.push(event);\n sourceKeys.set(key, existing);\n }\n\n return [...sourceKeys.values()].map((source) => {\n const id = source.events[0].sourceId;\n const hiddenLineCount = source.events.filter(\n (event) => event.presentationHidden,\n ).length;\n return {\n id,\n title: source.title,\n origin: source.origin,\n group: source.group,\n lineCount: source.events.length,\n hiddenLineCount,\n truncationCount: source.events.filter((event) => event.truncated).length,\n captureGapCount: source.events.filter((event) => event.captureGap).length,\n incidentCount: incidents.filter(\n (incident) =>\n incident.origin === source.origin && incident.sourceIds.includes(id),\n ).length,\n };\n });\n}\n\nfunction applyPresentationFilters(\n events: EvidenceEvent[],\n configuredSources: ResolvedLogSourceState[],\n): void {\n for (const event of events) {\n const config = configuredSources.find(\n (candidate) => candidate.id === event.sourceId,\n );\n if (isHidden(event.text, config)) {\n event.presentationHidden = true;\n }\n }\n}\n\nfunction isHidden(\n text: string,\n config: ResolvedLogSourceState | undefined,\n): boolean {\n if (!config) {\n return false;\n }\n if (\n config.include &&\n config.include.length > 0 &&\n !config.include.some((pattern) => text.includes(pattern))\n ) {\n return true;\n }\n return Boolean(config.exclude?.some((pattern) => text.includes(pattern)));\n}\n\nfunction inspectScreenshots(\n sessionDir: string,\n actions: SessionLogEntry[],\n): ScreenshotIntegrity[] {\n const files = [\n ...new Set(\n actions\n .filter((action) => action.outcome === 'passed')\n .map((action) => action.action.match(/^screenshot\\s+(.+)$/)?.[1])\n .filter((value): value is string => Boolean(value))\n .map((value) => path.basename(value)),\n ),\n ];\n return files\n .map((file) => {\n const filePath = path.join(sessionDir, file);\n const size = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;\n if (size > 50 * 1024 * 1024) {\n return {\n file,\n sha256: null,\n validPng: false,\n visuallyBlank: false,\n size,\n };\n }\n const contents = size > 0 ? fs.readFileSync(filePath) : Buffer.alloc(0);\n const integrity = inspectPng(contents);\n return {\n file,\n sha256: createHash('sha256').update(contents).digest('hex'),\n validPng: integrity.valid,\n visuallyBlank: integrity.visuallyBlank,\n size,\n };\n });\n}\n\nfunction inspectPng(contents: Buffer): {\n valid: boolean;\n visuallyBlank: boolean;\n} {\n if (\n contents.length < 33 ||\n !contents\n .subarray(0, 8)\n .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||\n contents.subarray(12, 16).toString('ascii') !== 'IHDR'\n ) {\n return { valid: false, visuallyBlank: false };\n }\n const width = contents.readUInt32BE(16);\n const height = contents.readUInt32BE(20);\n if (width <= 0 || height <= 0 || width * height > 20_000_000) {\n return { valid: false, visuallyBlank: false };\n }\n try {\n const decoded = PNG.sync.read(contents, { checkCRC: true });\n const spans = [\n { minimum: 255, maximum: 0 },\n { minimum: 255, maximum: 0 },\n { minimum: 255, maximum: 0 },\n { minimum: 255, maximum: 0 },\n ];\n const pixelCount = decoded.width * decoded.height;\n const sampleStep = Math.max(1, Math.floor(pixelCount / 10_000));\n for (let pixel = 0; pixel < pixelCount; pixel += sampleStep) {\n const offset = pixel * 4;\n for (let channel = 0; channel < 4; channel += 1) {\n const value = decoded.data[offset + channel];\n spans[channel].minimum = Math.min(spans[channel].minimum, value);\n spans[channel].maximum = Math.max(spans[channel].maximum, value);\n }\n }\n return {\n valid: true,\n visuallyBlank: spans.every(\n ({ minimum, maximum }) => maximum - minimum <= 3,\n ),\n };\n } catch {\n return { valid: false, visuallyBlank: false };\n }\n}\n\nfunction buildVerdict(\n options: EvidenceBuildOptions,\n evidence: CanonicalEvidence,\n): Verdict {\n const missingArtifacts: string[] = [];\n if (options.recordingWasActive && !fs.existsSync(options.videoPath)) {\n missingArtifacts.push(path.basename(options.videoPath));\n } else if (\n options.recordingWasActive &&\n (evidence.mediaDurationSec === null || evidence.mediaDurationSec <= 0)\n ) {\n missingArtifacts.push(path.basename(options.videoPath));\n }\n const screenshotFiles = new Set(\n evidence.screenshots.map((screenshot) => screenshot.file),\n );\n const successfulScreenshotPaths = options.actions\n .filter((action) => action.outcome === 'passed')\n .map((action) => action.action.match(/^screenshot\\s+(.+)$/)?.[1])\n .filter((value): value is string => Boolean(value))\n .map((value) => path.basename(value));\n const reusedScreenshotPaths =\n successfulScreenshotPaths.length - new Set(successfulScreenshotPaths).size;\n for (const action of options.actions) {\n const match = action.action.match(/^screenshot\\s+(.+)$/);\n if (match && !screenshotFiles.has(path.basename(match[1]))) {\n missingArtifacts.push(path.basename(match[1]));\n }\n }\n for (const screenshot of evidence.screenshots) {\n if (\n !screenshot.validPng ||\n screenshot.visuallyBlank ||\n screenshot.size === 0\n ) {\n missingArtifacts.push(screenshot.file);\n }\n }\n\n const hashes = new Map();\n for (const screenshot of evidence.screenshots) {\n if (screenshot.sha256 && screenshot.validPng) {\n const files = hashes.get(screenshot.sha256) || [];\n files.push(screenshot.file);\n hashes.set(screenshot.sha256, files);\n }\n }\n const duplicateScreenshotHashes = [...hashes.values()].filter(\n (files) => files.length > 1,\n );\n const expectedSelectorFailures = options.actions\n .filter(\n (action) =>\n action.expectedSelector && action.outcome === 'failed',\n )\n .map((action) => action.expectedSelector!);\n const pendingExpectedSelectors = options.actions.filter(\n (action) => action.expectedSelector && action.outcome === undefined,\n );\n const fatalIncidentCount = evidence.incidents.filter(\n (incident) => incident.severity === 'fatal',\n ).length;\n const blockingReasons = options.consoleEvidenceAvailable\n ? []\n : ['Browser console evidence was unavailable.'];\n const failureReasons = [\n ...(fatalIncidentCount > 0\n ? [`${fatalIncidentCount} fatal incident(s) detected.`]\n : []),\n ...(expectedSelectorFailures.length > 0\n ? [`${expectedSelectorFailures.length} expected selector assertion(s) failed.`]\n : []),\n ...(duplicateScreenshotHashes.length > 0\n ? ['Duplicate key-frame screenshot hashes were detected.']\n : []),\n ];\n const incompleteReasons = [\n ...(missingArtifacts.length > 0\n ? [`${missingArtifacts.length} required artifact(s) were missing or invalid.`]\n : []),\n ...(evidence.mediaTruncated\n ? ['Recorded media ends before the canonical action timeline.']\n : []),\n ...(evidence.sources.some((source) => source.truncationCount > 0)\n ? ['One or more evidence sources were truncated.']\n : []),\n ...(pendingExpectedSelectors.length > 0\n ? [\n `${pendingExpectedSelectors.length} expected selector assertion(s) had no recorded outcome.`,\n ]\n : []),\n ...(reusedScreenshotPaths > 0\n ? ['One or more screenshot paths were reused by multiple actions.']\n : []),\n ];\n const status: VerdictStatus =\n blockingReasons.length > 0\n ? 'BLOCKED'\n : incompleteReasons.length > 0\n ? 'INCOMPLETE'\n : failureReasons.length > 0\n ? 'FAIL'\n : 'PASS';\n return {\n version: 1,\n status,\n reasons: [...blockingReasons, ...failureReasons, ...incompleteReasons],\n fatalIncidentCount,\n missingArtifacts: [...new Set(missingArtifacts)],\n duplicateScreenshotHashes,\n expectedSelectorFailures,\n mediaTruncated: evidence.mediaTruncated,\n };\n}\n\nexport function probeMediaDuration(videoPath: string): number | null {\n if (!fs.existsSync(videoPath)) {\n return null;\n }\n try {\n const output = execFileSync(\n 'ffprobe',\n [\n '-v',\n 'error',\n '-show_entries',\n 'format=start_time,duration',\n '-of',\n 'json',\n videoPath,\n ],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n const parsed = JSON.parse(output) as {\n format?: {\n start_time?: string;\n duration?: string;\n };\n };\n const startTime = Number(parsed.format?.start_time || 0);\n const duration = Number(parsed.format?.duration);\n const playableDuration = duration - startTime;\n return Number.isFinite(playableDuration) && playableDuration >= 0\n ? playableDuration\n : null;\n } catch {\n return null;\n }\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { getPageUrl } from '../browser/session.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\nconst SESSION_LOG_LOCK_TIMEOUT_MS = 5000;\nconst SESSION_LOG_STALE_LOCK_MS = 120000;\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n outcome?: 'passed' | 'failed';\n expectedSelector?: string;\n error?: string;\n pageUrl?: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n const parsed: unknown = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n if (!Array.isArray(parsed)) {\n throw new Error('session log root must be an array');\n }\n return parsed as SessionLogEntry[];\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`ProofShot session action log is corrupt: ${logPath}\\n${message}`);\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n const resolved = path.resolve(sessionDir, screenshotPath);\n if (path.dirname(resolved) !== path.resolve(sessionDir)) {\n throw new Error(\n 'ProofShot screenshots must use a filename directly inside the active session.',\n );\n }\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\nexport function translateProofShotExecArgs(args: string[]): {\n agentBrowserArgs: string[];\n expectedSelector?: string;\n} {\n if (args[0] === 'assert-visible' && args.length > 1) {\n return {\n agentBrowserArgs: ['is', 'visible', ...args.slice(1)],\n expectedSelector: args.slice(1).join(' '),\n };\n }\n return { agentBrowserArgs: args };\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=