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 |
@@ -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:
@@ -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 '
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.