From 19573c984d56c927669d1179471febb6f512a4ee Mon Sep 17 00:00:00 2001 From: justinTM <9123665+justinTM@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:25:45 -0700 Subject: [PATCH 01/21] fix: isolate ProofShot session lifecycle --- PROOFSHOT.md | 3 + README.md | 9 + content/docs/concepts/how-it-works.mdx | 19 +- content/docs/faq.mdx | 7 +- content/docs/reference/cli.mdx | 22 +- proofshot-spec.md | 37 +- skills/claude/SKILL.md | 2 +- skills/codex/SKILL.md | 2 +- skills/cursor/proofshot.mdc | 2 +- skills/generic/PROOFSHOT.md | 2 +- skills/opencode/SKILL.md | 2 +- src/artifacts/viewer.ts | 23 +- src/browser/discovery.test.ts | 57 +++ src/browser/discovery.ts | 142 ++++++ src/browser/runtime.test.ts | 57 +++ src/browser/runtime.ts | 89 ++++ src/cli.ts | 3 +- src/commands/clean.test.ts | 45 ++ src/commands/clean.ts | 12 + src/commands/doctor.test.ts | 5 +- src/commands/doctor.ts | 10 +- src/commands/exec.ts | 36 +- src/commands/lifecycle.integration.test.ts | 493 +++++++++++++++++++++ src/commands/start.test.ts | 75 +++- src/commands/start.ts | 217 +++++---- src/commands/stop.test.ts | 221 +++++++++ src/commands/stop.ts | 229 ++++++++-- src/server/start.test.ts | 96 ++++ src/server/start.ts | 148 ++++--- src/session/lifecycle.test.ts | 85 ++++ src/session/lifecycle.ts | 78 ++++ src/session/state.test.ts | 25 +- src/session/state.ts | 65 ++- src/utils/exec.ts | 15 +- src/utils/process.test.ts | 71 +++ src/utils/process.ts | 238 +++++++++- 36 files changed, 2360 insertions(+), 282 deletions(-) create mode 100644 src/browser/discovery.test.ts create mode 100644 src/browser/discovery.ts create mode 100644 src/browser/runtime.test.ts create mode 100644 src/browser/runtime.ts create mode 100644 src/commands/clean.test.ts create mode 100644 src/commands/lifecycle.integration.test.ts create mode 100644 src/commands/stop.test.ts create mode 100644 src/server/start.test.ts create mode 100644 src/session/lifecycle.test.ts create mode 100644 src/session/lifecycle.ts 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..fa4be6c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,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 +159,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 +172,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. @@ -211,6 +218,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/faq.mdx b/content/docs/faq.mdx index cd0799d..9dd71cd 100644 --- a/content/docs/faq.mdx +++ b/content/docs/faq.mdx @@ -28,7 +28,7 @@ The skill file installed by `proofshot install` teaches your agent the three-ste 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. **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/reference/cli.mdx b/content/docs/reference/cli.mdx index a2355f0..7f6a8ae 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. If `--run` is provided: fails without killing anything when the port is occupied; otherwise starts an owned dev-server process session, pipes timestamped output to `server.log`, and waits for the port 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 dev-server process session 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` and `viewer.html` +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. --- @@ -187,4 +193,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/proofshot-spec.md b/proofshot-spec.md index 93a55f3..a50619e 100644 --- a/proofshot-spec.md +++ b/proofshot-spec.md @@ -303,6 +303,8 @@ proofshot clean # Removes ./proofshot-artifacts/ ``` +If `.session.json` exists, `clean` refuses and directs the user to `proofshot stop`; it never discards exact process ownership metadata or performs implicit broad cleanup. + ### `proofshot pr` Format artifacts for inclusion in a PR description. @@ -362,22 +364,26 @@ async function ensureDevServer(config, errorLogPath: string) { ## 6. Session State -ProofShot uses a `.session.json` file in the output directory to track the active session: +ProofShot uses a `.session.json` file in the configured/default output directory to track the active session. A CLI-only `--output` override moves evidence but not this discoverable control file: ```json { "startedAt": "2026-02-25T14:32:00.000Z", "description": "Login form: fill credentials, submit, verify redirect", - "outputDir": "./proofshot-artifacts", - "videoPath": "./proofshot-artifacts/session-2026-02-25.webm", - "serverErrorLog": "./proofshot-artifacts/server-errors.log", + "outputDir": "/audit/custom-evidence", + "sessionDir": "/audit/custom-evidence/2026-02-25_login-form", + "sessionName": "ps-2026-02-a1b2c3d4e5f6", + "targetUrl": "http://localhost:5173/login", + "agentBrowserSocketDir": "/run/user/1000/proofshot/agent-browser", + "videoPath": "/audit/custom-evidence/2026-02-25_login-form/session.webm", + "serverErrorLog": "/audit/custom-evidence/2026-02-25_login-form/server.log", "port": 5173, - "framework": "Vite", - "pid": 12345 + "serverProcess": { "pid": 12345, "processGroupId": 12345, "sessionId": 12345, "startTime": "987654" }, + "browserProcess": { "pid": 12367, "processGroupId": 12367, "sessionId": 12367, "startTime": "987699" } } ``` -`proofshot stop` reads this file to know where to find artifacts and what metadata to include in the summary. +`proofshot exec` and `proofshot stop` read this file from separate CLI processes. Cleanup verifies the immutable identities and signals only process groups inside the recorded process sessions; it never kills by command name or occupied port. --- @@ -568,8 +574,8 @@ function ab(command: string): string { proofshot start: 1. Load config 2. Ensure output dir exists - 3. Start dev server (if needed), piping stderr to server-errors.log - 4. Open browser via agent-browser + 3. Fail actionably if the requested port is occupied; otherwise start an owned dev-server process session and timestamp output in server.log + 4. Open the requested URL in a short, collision-safe agent-browser session and persist its daemon identity 5. Start recording via agent-browser 6. Write .session.json with metadata 7. Print instructions for the agent @@ -581,12 +587,13 @@ proofshot stop: 2. Collect console errors via agent-browser errors 3. Collect console output via agent-browser console 4. Stop recording via agent-browser record stop - 5. Close browser via agent-browser close - 6. Read server-errors.log - 7. List all screenshots in output dir - 8. Generate SUMMARY.md - 9. Delete .session.json - 10. Print summary to stdout + 5. Close the exact browser session via agent-browser close + 6. Stop only the owned dev-server process session + 7. Read server.log + 8. List all screenshots in the evidence session dir + 9. Generate SUMMARY.md and viewer.html + 10. Delete .session.json (or retain exact browser ownership after --no-close) + 11. Print summary to stdout ``` ### Server Error Capture diff --git a/skills/claude/SKILL.md b/skills/claude/SKILL.md index 395178f..14c59fc 100644 --- a/skills/claude/SKILL.md +++ b/skills/claude/SKILL.md @@ -27,7 +27,7 @@ Use ProofShot after: proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" ``` -This opens a browser and begins recording. If the port is already in use, proofshot will kill the existing process automatically. +This opens a browser and begins recording. If the port is already in use, ProofShot leaves that unowned listener alone and asks you to choose another port or stop it explicitly. **Always use `--run`** to let proofshot start and capture your dev server output (server logs appear in the proof report). Only omit `--run` if the server was explicitly started by the user or another process — without it, no server logs are captured. diff --git a/skills/codex/SKILL.md b/skills/codex/SKILL.md index 2feda3d..07048f2 100644 --- a/skills/codex/SKILL.md +++ b/skills/codex/SKILL.md @@ -26,7 +26,7 @@ Use ProofShot after: proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" ``` -This opens a browser and begins recording. If the port is already in use, proofshot will kill the existing process automatically. +This opens a browser and begins recording. If the port is already in use, ProofShot leaves that unowned listener alone and asks you to choose another port or stop it explicitly. **Always use `--run`** to let proofshot start and capture your dev server output (server logs appear in the proof report). Only omit `--run` if the server was explicitly started by the user or another process — without it, no server logs are captured. diff --git a/skills/cursor/proofshot.mdc b/skills/cursor/proofshot.mdc index 49ca396..5cb308e 100644 --- a/skills/cursor/proofshot.mdc +++ b/skills/cursor/proofshot.mdc @@ -9,7 +9,7 @@ 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"` Always use --run so proofshot captures server logs. Only omit --run if the server was started by the user. - If the port is in use, proofshot kills the existing process. Add --force if a stale session blocks startup. + If the port is in use, ProofShot leaves that unowned listener alone; choose another port or stop it explicitly. Add --force only if a stale ProofShot session blocks startup. 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 diff --git a/skills/generic/PROOFSHOT.md b/skills/generic/PROOFSHOT.md index c8da9e8..43798b3 100644 --- a/skills/generic/PROOFSHOT.md +++ b/skills/generic/PROOFSHOT.md @@ -6,7 +6,7 @@ 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"` Always use --run so proofshot captures server logs. Only omit --run if the server was started by the user. - If the port is in use, proofshot kills the existing process. Add --force if a stale session blocks startup. + If the port is in use, ProofShot leaves that unowned listener alone; choose another port or stop it explicitly. Add --force only if a stale ProofShot session blocks startup. 2. Test: Use `proofshot exec` to navigate, click, fill forms, take screenshots 3. Stop: `proofshot stop` — bundles video, screenshots, and error report diff --git a/skills/opencode/SKILL.md b/skills/opencode/SKILL.md index 6d98489..b5b9eff 100644 --- a/skills/opencode/SKILL.md +++ b/skills/opencode/SKILL.md @@ -27,7 +27,7 @@ Use ProofShot after: proofshot start --run "your-dev-command" --port PORT --description "what you are about to verify" ``` -This opens a browser and begins recording. If the port is already in use, proofshot will kill the existing process automatically. +This opens a browser and begins recording. If the port is already in use, ProofShot leaves that unowned listener alone and asks you to choose another port or stop it explicitly. **Always use `--run`** to let proofshot start and capture your dev server output (server logs appear in the proof report). Only omit `--run` if the server was explicitly started by the user or another process - without it, no server logs are captured. diff --git a/src/artifacts/viewer.ts b/src/artifacts/viewer.ts index 13e7c53..7df6b72 100644 --- a/src/artifacts/viewer.ts +++ b/src/artifacts/viewer.ts @@ -14,6 +14,7 @@ interface ViewerData { videoFilename: string | null; entries: SessionLogEntry[]; consoleErrorCount: number; + consoleEvidenceAvailable?: boolean; serverErrorCount: number; consoleOutput?: string; serverLog?: string; @@ -157,9 +158,15 @@ export function generateViewer(data: ViewerData): string { ? `

${escapeHtml(data.description)}

` : ''; - const consoleBadgeClass = data.consoleErrorCount === 0 ? 'clean' : 'has-errors'; - const consoleBadgeText = - data.consoleErrorCount === 0 + const consoleEvidenceAvailable = data.consoleEvidenceAvailable !== false; + const consoleBadgeClass = !consoleEvidenceAvailable + ? 'unavailable' + : data.consoleErrorCount === 0 + ? 'clean' + : 'has-errors'; + const consoleBadgeText = !consoleEvidenceAvailable + ? 'Console: unavailable' + : data.consoleErrorCount === 0 ? 'Console: clean' : `Console: ${data.consoleErrorCount} error(s)`; @@ -458,6 +465,12 @@ export function generateViewer(data: ViewerData): string { border: 1px solid rgba(248, 81, 73, 0.25); } + .error-badge.unavailable { + background: rgba(210, 153, 34, 0.12); + color: #d29922; + border: 1px solid rgba(210, 153, 34, 0.25); + } + .error-badge .badge-dot { width: 6px; height: 6px; @@ -472,6 +485,10 @@ export function generateViewer(data: ViewerData): string { background: #f85149; } + .error-badge.unavailable .badge-dot { + background: #d29922; + } + .viewer { display: flex; height: calc(100vh - 180px); diff --git a/src/browser/discovery.test.ts b/src/browser/discovery.test.ts new file mode 100644 index 0000000..4ba5434 --- /dev/null +++ b/src/browser/discovery.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { discoverBrowserExecutable } from './discovery.js'; + +const createdRoots: string[] = []; + +function createRoot(): string { + const cache = path.join(os.userInfo().homedir, '.cache'); + fs.mkdirSync(cache, { recursive: true }); + const root = fs.mkdtempSync(path.join(cache, 'proofshot-browser-test-')); + createdRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of createdRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('discoverBrowserExecutable', () => { + it('finds an executable in the real account home when HOME is isolated', () => { + const accountHome = createRoot(); + const chrome = path.join( + accountHome, + '.agent-browser', + 'browsers', + 'chrome-151.0.0', + 'chrome', + ); + fs.mkdirSync(path.dirname(chrome), { recursive: true }); + fs.writeFileSync(chrome, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(chrome, 0o700); + + expect( + discoverBrowserExecutable({ + env: { HOME: path.join(accountHome, 'isolated-home') }, + accountHome, + platform: 'linux', + findExecutable: () => null, + }), + ).toBe(chrome); + }); + + it('returns one exact retry flag when an explicit browser path is invalid', () => { + const missing = path.join(createRoot(), 'missing-chrome'); + + expect(() => + discoverBrowserExecutable({ + configuredPath: missing, + findExecutable: () => null, + }), + ).toThrow(`proofshot start --browser-executable ${JSON.stringify(missing)}`); + }); +}); diff --git a/src/browser/discovery.ts b/src/browser/discovery.ts new file mode 100644 index 0000000..1722df2 --- /dev/null +++ b/src/browser/discovery.ts @@ -0,0 +1,142 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { findExecutablePath } from '../utils/process.js'; + +export interface BrowserDiscoveryOptions { + configuredPath?: string; + env?: NodeJS.ProcessEnv; + accountHome?: string; + platform?: NodeJS.Platform; + findExecutable?: typeof findExecutablePath; +} + +function isExecutable(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function sortedDirectories(root: string): string[] { + try { + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + } catch { + return []; + } +} + +function cachedBrowserCandidates(home: string): string[] { + const candidates: string[] = []; + const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers'); + for (const directory of sortedDirectories(agentBrowserRoot)) { + candidates.push( + path.join(agentBrowserRoot, directory, 'chrome'), + path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'), + path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'), + ); + } + + const playwrightRoot = path.join(home, '.cache', 'ms-playwright'); + for (const directory of sortedDirectories(playwrightRoot)) { + if (!directory.startsWith('chromium')) continue; + candidates.push( + path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'), + path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'), + path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), + ); + } + + const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome'); + for (const directory of sortedDirectories(puppeteerRoot)) { + candidates.push( + path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'), + path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'), + ); + } + return candidates; +} + +function accountHomeDirectory(): string | undefined { + try { + return os.userInfo().homedir; + } catch { + return undefined; + } +} + +/** + * Find a Chrome/Chromium executable without assuming that `$HOME` is the + * account's real home directory. No profile, cookies, or storage are reused. + */ +export function discoverBrowserExecutable( + options: BrowserDiscoveryOptions = {}, +): string | null { + 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 = path.resolve(explicit); + if (!isExecutable(resolved)) { + throw new Error( + `Browser executable is not runnable: ${resolved}\n` + + `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`, + ); + } + return resolved; + } + + 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 homes = new Set(); + if (env.HOME) homes.add(path.resolve(env.HOME)); + const accountHome = options.accountHome ?? accountHomeDirectory(); + if (accountHome) homes.add(path.resolve(accountHome)); + + const candidates: string[] = []; + 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( + path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + ); + } + } else { + candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'); + for (const home of homes) candidates.push(...cachedBrowserCandidates(home)); + } + + return candidates.find(isExecutable) ?? null; +} + +export function browserSetupError(): Error { + return new Error( + 'No runnable Chrome/Chromium executable was found for this environment.\n' + + 'Run `agent-browser install` in this environment, then retry `proofshot start`.', + ); +} diff --git a/src/browser/runtime.test.ts b/src/browser/runtime.test.ts new file mode 100644 index 0000000..663bf72 --- /dev/null +++ b/src/browser/runtime.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + prepareAgentBrowserSocketDir, + UNIX_SOCKET_PATH_MAX_BYTES, +} from './runtime.js'; + +const createdRoots: string[] = []; + +function createAccountHome(): string { + const cache = path.join(os.userInfo().homedir, '.cache'); + fs.mkdirSync(cache, { recursive: true }); + const root = fs.mkdtempSync(path.join(cache, 'proofshot-runtime-test-')); + createdRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of createdRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('prepareAgentBrowserSocketDir', () => { + it('stays short and independent of a long isolated HOME', () => { + const accountHome = createAccountHome(); + const isolatedHome = path.join(accountHome, 'isolated', 'x'.repeat(180)); + const sessionName = 'ps-audit-123456789abc'; + + const socketDir = prepareAgentBrowserSocketDir( + sessionName, + { HOME: isolatedHome }, + accountHome, + ); + + expect(socketDir).not.toContain(isolatedHome); + expect(Buffer.byteLength(path.join(socketDir, `${sessionName}.sock`))).toBeLessThanOrEqual( + UNIX_SOCKET_PATH_MAX_BYTES, + ); + expect(fs.statSync(socketDir).mode & 0o777).toBe(0o700); + }); + + it('rejects an explicitly configured socket path before agent-browser starts', () => { + const accountHome = createAccountHome(); + const longSocketDir = path.join(accountHome, 'x'.repeat(90)); + + expect(() => + prepareAgentBrowserSocketDir( + 'ps-audit-123456789abc', + { AGENT_BROWSER_SOCKET_DIR: longSocketDir }, + accountHome, + ), + ).toThrow(/max 103/); + }); +}); diff --git a/src/browser/runtime.ts b/src/browser/runtime.ts new file mode 100644 index 0000000..b106d3f --- /dev/null +++ b/src/browser/runtime.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + captureProcessIdentity, + type ProcessIdentity, +} from '../utils/process.js'; + +export const UNIX_SOCKET_PATH_MAX_BYTES = 103; + +function assertOwnedDirectory(directory: string): void { + const stat = fs.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 !== undefined && stat.uid !== uid) { + throw new Error( + `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`, + ); + } + + fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); + if (uid !== undefined) fs.chmodSync(directory, 0o700); +} + +/** + * Prepare a short, user-owned socket directory that is stable across the + * separate `start`, `exec`, and `stop` CLI processes in one environment. + */ +export function prepareAgentBrowserSocketDir( + sessionName: string, + env: NodeJS.ProcessEnv = process.env, + accountHome = os.userInfo().homedir, +): string { + 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 && path.isAbsolute(env.XDG_RUNTIME_DIR)) { + runtimeRoot = env.XDG_RUNTIME_DIR; + } else if (!explicit && fs.existsSync(systemRuntime)) { + try { + assertOwnedDirectory(systemRuntime); + runtimeRoot = systemRuntime; + } catch { + // Fall back to the real account home, independently of isolated $HOME. + } + } + const directory = explicit + ? path.resolve(explicit) + : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR + ? path.join(runtimeRoot, 'proofshot', 'agent-browser') + : path.join(runtimeRoot, '.cache', 'proofshot', 'run', 'agent-browser'); + + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertOwnedDirectory(directory); + + const socketPath = path.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}\n` + + 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.', + ); + } + + return directory; +} + +/** Read the exact daemon PID written for this isolated agent-browser session. */ +export function captureAgentBrowserProcessIdentity( + socketDir: string, + sessionName: string, +): ProcessIdentity | null { + if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null; + + try { + assertOwnedDirectory(socketDir); + const pidPath = path.join(socketDir, `${sessionName}.pid`); + const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim()); + const identity = captureProcessIdentity(pid); + if (!identity || identity.sessionId !== identity.pid) return null; + return identity; + } catch { + return null; + } +} diff --git a/src/cli.ts b/src/cli.ts index 5be1c3f..e3bccea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ export function createCLI(): Command { .option('--headed', 'Show browser window for debugging') .option('--output ', 'Custom output directory') .option('--url ', 'Open this URL instead of the root') + .option('--browser-executable ', 'Use this Chrome/Chromium executable') .option('--force', 'Override a stale session without running stop first') .action(async (options) => { await startCommand(options); @@ -46,7 +47,7 @@ export function createCLI(): Command { .description('Stop session: stop recording, collect errors, bundle proof artifacts') .option('--no-close', 'Don\'t close the browser (keep it open for further use)') .action(async (options) => { - await stopCommand(options); + await stopCommand({ noClose: options.close === false }); }); program diff --git a/src/commands/clean.test.ts b/src/commands/clean.test.ts new file mode 100644 index 0000000..9355030 --- /dev/null +++ b/src/commands/clean.test.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ loadConfig: vi.fn() })); +vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); + +import { cleanCommand } from './clean.js'; + +let root: string; + +beforeEach(() => { + const cache = path.join(os.userInfo().homedir, '.cache'); + fs.mkdirSync(cache, { recursive: true }); + root = fs.mkdtempSync(path.join(cache, 'proofshot-clean-test-')); + mocks.loadConfig.mockReturnValue({ output: root }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); + mocks.loadConfig.mockReset(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('cleanCommand', () => { + it('refuses to discard active exact-process ownership metadata', async () => { + const controlPath = path.join(root, '.session.json'); + const evidencePath = path.join(root, 'evidence.txt'); + fs.writeFileSync(controlPath, JSON.stringify({ browserRetained: true })); + fs.writeFileSync(evidencePath, 'keep'); + + await expect(cleanCommand()).rejects.toThrow('process.exit:1'); + + expect(fs.readFileSync(controlPath, 'utf-8')).toContain('browserRetained'); + expect(fs.readFileSync(evidencePath, 'utf-8')).toBe('keep'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Run "proofshot stop" first'), + ); + }); +}); diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 8eeb088..72f1676 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -2,11 +2,23 @@ import * as fs from 'fs'; import * as path from 'path'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; +import { hasActiveSession, resolveSessionControlDir } from '../session/state.js'; export async function cleanCommand(): Promise { const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); const outputDir = path.resolve(config.output); + if (hasActiveSession(controlDir)) { + console.error( + chalk.red('✗') + + ' Cannot clean while a ProofShot session owns browser or server processes.\n' + + chalk.dim('Run "proofshot stop" first so exact cleanup metadata is preserved.'), + ); + process.exit(1); + return; + } + if (!fs.existsSync(outputDir)) { console.log(chalk.dim('Nothing to clean — no artifacts directory found.')); return; diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index e7d2d15..76943f1 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { findConfigPathMock, loadConfigMock, loadSessionMock, findExecutablePathMock, readCommandVersionMock } = +const { findConfigPathMock, loadConfigMock, loadSessionMock, resolveSessionControlDirMock, findExecutablePathMock, readCommandVersionMock } = vi.hoisted(() => ({ findConfigPathMock: vi.fn(), loadConfigMock: vi.fn(), loadSessionMock: vi.fn(), + resolveSessionControlDirMock: vi.fn(), findExecutablePathMock: vi.fn(), readCommandVersionMock: vi.fn(), })); @@ -16,6 +17,7 @@ vi.mock('../utils/config.js', () => ({ vi.mock('../session/state.js', () => ({ loadSession: loadSessionMock, + resolveSessionControlDir: resolveSessionControlDirMock, })); vi.mock('../utils/process.js', () => ({ @@ -37,6 +39,7 @@ describe('doctorCommand', () => { defaultPages: ['/'], }); loadSessionMock.mockReturnValue(null); + resolveSessionControlDirMock.mockReturnValue('/workspace/proofshot-artifacts'); findExecutablePathMock.mockImplementation((name: string) => name === 'agent-browser' ? '/usr/local/bin/agent-browser' : '/opt/homebrew/bin/ffmpeg', ); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 6904317..6340dc2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { PROOFSHOT_VERSION } from '../version.js'; import { findConfigPath, loadConfig } from '../utils/config.js'; import { findExecutablePath, readCommandVersion } from '../utils/process.js'; -import { loadSession } from '../session/state.js'; +import { loadSession, resolveSessionControlDir } from '../session/state.js'; function statusLabel(ok: boolean, text: string): string { return ok ? `${chalk.green('✓')} ${text}` : `${chalk.yellow('⚠')} ${text}`; @@ -15,8 +15,8 @@ function printLine(label: string, value: string): void { export async function doctorCommand(): Promise { const configPath = findConfigPath(); const config = loadConfig(); - const outputDir = config.output; - const session = loadSession(outputDir); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); const agentBrowserPath = findExecutablePath('agent-browser'); const ffmpegPath = findExecutablePath('ffmpeg'); @@ -28,7 +28,8 @@ export async function doctorCommand(): Promise { printLine('ProofShot', PROOFSHOT_VERSION); printLine('Config', configPath || chalk.dim('not found')); - printLine('Output', outputDir); + printLine('Output', config.output); + printLine('Control state', controlDir); printLine('Browser mode', config.headless ? 'headless' : 'headed'); printLine('Viewport', `${config.viewport.width}x${config.viewport.height}`); console.log(''); @@ -48,6 +49,7 @@ export async function doctorCommand(): Promise { printLine('Session dir', session.sessionDir); printLine('Recording', session.recordingActive ? 'active' : 'stopped'); printLine('Port', String(session.port)); + if (session.targetUrl) printLine('Target', session.targetUrl); } else { printLine('Session dir', chalk.dim('none')); } diff --git a/src/commands/exec.ts b/src/commands/exec.ts index 921d8e9..6933f97 100644 --- a/src/commands/exec.ts +++ b/src/commands/exec.ts @@ -2,8 +2,19 @@ import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; import { loadConfig } from '../utils/config.js'; -import { ab, buildAgentBrowserCommand, setAgentBrowserDefaults } from '../utils/exec.js'; -import { loadSession, saveSession, type SessionState } from '../session/state.js'; +import { + ab, + buildAgentBrowserCommand, + getAgentBrowserEnvironment, + setAgentBrowserDefaults, +} from '../utils/exec.js'; +import { + loadSession, + resolveSessionControlDir, + saveSession, + type SessionState, +} from '../session/state.js'; +import { canAddressOwnedBrowserSession } from '../session/lifecycle.js'; const SESSION_LOG_FILENAME = 'session-log.json'; @@ -180,9 +191,12 @@ export async function execCommand(args: string[]): Promise { // Load session state const config = loadConfig(); - setAgentBrowserDefaults({ configPath: config.browser.configPath }); - const outputDir = path.resolve(config.output); - const session = loadSession(outputDir); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + setAgentBrowserDefaults({ + configPath: session?.agentBrowserConfigPath || config.browser.configPath, + socketDir: session?.agentBrowserSocketDir, + }); if (session && !session.recordingActive) { console.error( @@ -192,6 +206,15 @@ export async function execCommand(args: string[]): Promise { process.exit(1); } + if (session && !canAddressOwnedBrowserSession(session)) { + console.error( + 'Error: Browser ownership no longer matches this ProofShot session.\n' + + 'Refusing to address a possibly reused agent-browser session name.', + ); + process.exit(1); + return; + } + // Resolve args (screenshot path rewriting) let resolvedArgs = args; if (session) { @@ -237,6 +260,7 @@ export async function execCommand(args: string[]): Promise { encoding: 'utf-8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'], + env: getAgentBrowserEnvironment(), }); if (result.trim()) { process.stdout.write(result); @@ -262,7 +286,7 @@ export async function execCommand(args: string[]): Promise { }); const vp = JSON.parse(vpJson); session.viewport = { width: vp.width, height: vp.height }; - saveSession(session); + saveSession(session, controlDir); } catch { // Non-critical — viewport cache stays stale } diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts new file mode 100644 index 0000000..2ee54f8 --- /dev/null +++ b/src/commands/lifecycle.integration.test.ts @@ -0,0 +1,493 @@ +import * as fs from 'fs'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync, spawn, spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { isPortOpen } from '../utils/port.js'; +import { + captureProcessIdentity, + terminateOwnedProcessTree, + type ProcessIdentity, +} from '../utils/process.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const cliPath = path.join(repoRoot, 'dist', 'bin', 'proofshot.js'); +const createdRoots: string[] = []; +const cleanupProcesses: ProcessIdentity[] = []; + +function cacheRoot(): string { + const cache = path.join(os.userInfo().homedir, '.cache'); + fs.mkdirSync(cache, { recursive: true }); + return cache; +} + +function createAuditRoot(): { base: string; audit: string } { + const base = fs.mkdtempSync(path.join(cacheRoot(), 'proofshot-lifecycle-test-')); + const audit = path.join( + base, + `generated-audit-${'x'.repeat(64)}`, + `consumer-evidence-${'y'.repeat(48)}`, + ); + fs.mkdirSync(audit, { recursive: true }); + createdRoots.push(base); + return { base, audit }; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function freePort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing free port'); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} + +function processIsAlive(pid: number): boolean { + return captureProcessIdentity(pid) !== null; +} + +async function waitForProcessExit(pid: number, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!processIsAlive(pid)) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`process ${pid} did not exit`); +} + +function writeFixtureTools(base: string): { + binDir: string; + browserPath: string; + browserLog: string; + serverScript: string; +} { + const binDir = path.join(base, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const browserLog = path.join(base, 'agent-browser.jsonl'); + const fakeAgentBrowser = path.join(binDir, 'agent-browser'); + fs.writeFileSync( + fakeAgentBrowser, + `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +let args = process.argv.slice(2); +let session = 'default'; +const sessionIndex = args.indexOf('--session'); +if (sessionIndex >= 0) { + session = args[sessionIndex + 1]; + args.splice(sessionIndex, 2); +} +const configIndex = args.indexOf('--config'); +if (configIndex >= 0) args.splice(configIndex, 2); +const socketDir = process.env.AGENT_BROWSER_SOCKET_DIR; +if (!socketDir) { + process.stderr.write('missing AGENT_BROWSER_SOCKET_DIR\\n'); + process.exit(2); +} +fs.mkdirSync(socketDir, { recursive: true }); +const pidPath = path.join(socketDir, session + '.pid'); +const statePath = path.join(socketDir, session + '.fake.json'); +const command = args[0] || ''; +const detail = args.slice(1); +fs.appendFileSync(process.env.FAKE_AGENT_BROWSER_LOG, JSON.stringify({ + pid: process.pid, + session, + socketDir, + home: process.env.HOME, + command, + detail, +}) + '\\n'); +if (Buffer.byteLength(path.join(socketDir, session + '.sock')) > 103) { + process.stderr.write('socket path too long\\n'); + process.exit(3); +} +if (command === 'open') { + const daemon = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: 'ignore', + }); + daemon.unref(); + fs.writeFileSync(pidPath, String(daemon.pid)); + fs.writeFileSync(statePath, JSON.stringify({ url: detail[0] })); + fs.appendFileSync(process.env.FAKE_AGENT_BROWSER_LOG, JSON.stringify({ + session, + socketDir, + command: 'daemon', + daemonPid: daemon.pid, + }) + '\\n'); + if (process.env.FAKE_AGENT_BROWSER_FAIL_OPEN === '1') { + process.stderr.write('simulated browser open failure\\n'); + process.exit(9); + } + process.exit(0); +} +if (command === 'get' && detail[0] === 'url') { + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + process.stdout.write(state.url + '\\n'); + process.exit(0); +} +if (command === 'console' && detail.includes('--json')) { + process.stdout.write(JSON.stringify({ success: true, data: { messages: [] } }) + '\\n'); + process.exit(0); +} +if (command === 'console') { + process.stdout.write('No console output\\n'); + process.exit(0); +} +if (command === 'errors') { + process.stdout.write('No errors\\n'); + process.exit(0); +} +if (command === 'close') { + try { + const pid = Number(fs.readFileSync(pidPath, 'utf8')); + process.kill(-pid, 'SIGTERM'); + } catch {} + try { fs.unlinkSync(pidPath); } catch {} + try { fs.unlinkSync(statePath); } catch {} + process.exit(0); +} +process.exit(0); +`, + ); + fs.chmodSync(fakeAgentBrowser, 0o700); + + const browserPath = path.join(binDir, 'fake-chrome'); + fs.writeFileSync(browserPath, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(browserPath, 0o700); + + const serverScript = path.join(base, 'server.mjs'); + fs.writeFileSync( + serverScript, + [ + "import fs from 'node:fs';", + "import http from 'node:http';", + 'const port = Number(process.argv[2]);', + 'const pidFile = process.argv[3];', + "fs.writeFileSync(pidFile, String(process.pid));", + "const server = http.createServer((request, response) => response.end(request.url || '/'));", + "server.listen(port, '127.0.0.1', () => console.log('server-ready'));", + ].join('\n'), + ); + return { binDir, browserPath, browserLog, serverScript }; +} + +function isolatedEnvironment( + audit: string, + tools: ReturnType, + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: path.join(audit, 'isolated-home'), + XDG_CACHE_HOME: path.join(audit, 'isolated-cache'), + PATH: `${tools.binDir}${path.delimiter}${process.env.PATH || ''}`, + FAKE_AGENT_BROWSER_LOG: tools.browserLog, + ...overrides, + }; + delete env.AGENT_BROWSER_SOCKET_DIR; + delete env.XDG_RUNTIME_DIR; + return env; +} + +function runCli( + cwd: string, + env: NodeJS.ProcessEnv, + args: string[], +): ReturnType { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd, + env, + encoding: 'utf-8', + timeout: 15000, + }); +} + +beforeAll(() => { + execFileSync('npm', ['run', 'build'], { cwd: repoRoot, stdio: 'ignore' }); +}, 30000); + +afterEach(async () => { + for (const identity of cleanupProcesses.splice(0)) { + await terminateOwnedProcessTree(identity, { graceMs: 300 }); + } + for (const root of createdRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('isolated CLI lifecycle', () => { + it('shares custom-output control across processes and stops idempotently', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const unrelated = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: 'ignore', + }); + unrelated.unref(); + const unrelatedIdentity = captureProcessIdentity(unrelated.pid!); + if (!unrelatedIdentity) throw new Error('failed to capture unrelated process'); + cleanupProcesses.push(unrelatedIdentity); + + const port = await freePort(); + const serverPidFile = path.join(base, 'owned-server.pid'); + const customOutput = path.join(audit, 'custom-evidence'); + const intendedUrl = `http://127.0.0.1:${port}/intended-target`; + const serverCommand = [ + shellQuote(process.execPath), + shellQuote(tools.serverScript), + String(port), + shellQuote(serverPidFile), + ].join(' '); + + const start = runCli(audit, env, [ + 'start', + '--run', + serverCommand, + '--port', + String(port), + '--output', + customOutput, + '--url', + intendedUrl, + '--browser-executable', + tools.browserPath, + '--description', + 'isolated lifecycle integration', + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + expect(start.stdout).toContain(`Target: ${intendedUrl}`); + + const controlPath = path.join(audit, 'proofshot-artifacts', '.session.json'); + expect(fs.existsSync(controlPath)).toBe(true); + expect(fs.existsSync(path.join(customOutput, '.session.json'))).toBe(false); + const state = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + expect(state).toMatchObject({ + outputDir: customOutput, + targetUrl: intendedUrl, + recordingActive: true, + }); + expect(Buffer.byteLength(path.join(state.agentBrowserSocketDir, `${state.sessionName}.sock`))).toBeLessThanOrEqual(103); + expect(state.agentBrowserSocketDir).not.toContain(env.HOME); + expect(state.serverProcess).toMatchObject({ pid: expect.any(Number), startTime: expect.any(String) }); + expect(state.browserProcess).toMatchObject({ pid: expect.any(Number), startTime: expect.any(String) }); + cleanupProcesses.push(state.serverProcess, state.browserProcess); + + const ownedServerPid = Number(fs.readFileSync(serverPidFile, 'utf-8')); + expect(processIsAlive(ownedServerPid)).toBe(true); + expect(processIsAlive(unrelated.pid!)).toBe(true); + + const execResult = runCli(audit, env, ['exec', 'get', 'url']); + expect(execResult.status, `${execResult.stdout}\n${execResult.stderr}`).toBe(0); + expect(execResult.stdout.trim()).toBe(intendedUrl); + expect(execResult.stdout).not.toContain('about:blank'); + const browserCalls = fs + .readFileSync(tools.browserLog, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(browserCalls.at(-1)).toMatchObject({ + session: state.sessionName, + socketDir: state.agentBrowserSocketDir, + command: 'get', + detail: ['url'], + }); + + const browserLogBeforeMismatchedExec = fs.readFileSync(tools.browserLog, 'utf-8'); + const mismatchedState = { + ...state, + browserProcess: { + ...state.browserProcess, + startTime: `${state.browserProcess.startTime}-recycled`, + }, + }; + fs.writeFileSync(controlPath, JSON.stringify(mismatchedState, null, 2) + '\n'); + const mismatchedExec = runCli(audit, env, ['exec', 'get', 'url']); + expect(mismatchedExec.status).toBe(1); + expect(mismatchedExec.stderr).toContain( + 'Browser ownership no longer matches this ProofShot session', + ); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe( + browserLogBeforeMismatchedExec, + ); + fs.writeFileSync(controlPath, JSON.stringify(state, null, 2) + '\n'); + + const stop = runCli(audit, env, ['stop']); + expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); + expect(fs.existsSync(controlPath)).toBe(false); + await waitForProcessExit(ownedServerPid); + await waitForProcessExit(state.serverProcess.pid); + await waitForProcessExit(state.browserProcess.pid); + expect(processIsAlive(unrelated.pid!)).toBe(true); + cleanupProcesses.splice(cleanupProcesses.indexOf(state.serverProcess), 1); + cleanupProcesses.splice(cleanupProcesses.indexOf(state.browserProcess), 1); + + const summaryPath = path.join(state.sessionDir, 'SUMMARY.md'); + const summaryBefore = fs.readFileSync(summaryPath, 'utf-8'); + const summaryMtimeBefore = fs.statSync(summaryPath).mtimeMs; + const browserLogBefore = fs.readFileSync(tools.browserLog, 'utf-8'); + + const secondStop = runCli(audit, env, ['stop']); + expect(secondStop.status, `${secondStop.stdout}\n${secondStop.stderr}`).toBe(0); + expect(secondStop.stdout).toContain('already stopped'); + expect(fs.readFileSync(summaryPath, 'utf-8')).toBe(summaryBefore); + expect(fs.statSync(summaryPath).mtimeMs).toBe(summaryMtimeBefore); + expect(fs.readFileSync(tools.browserLog, 'utf-8')).toBe(browserLogBefore); + }, 30000); + + it('preserves unrelated listeners and cleans partial browser/server starts', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const occupiedPort = await freePort(); + const unrelatedPidFile = path.join(base, 'unrelated-listener.pid'); + const unrelated = spawn( + process.execPath, + [tools.serverScript, String(occupiedPort), unrelatedPidFile], + { detached: true, stdio: 'ignore' }, + ); + unrelated.unref(); + const unrelatedIdentity = captureProcessIdentity(unrelated.pid!); + if (!unrelatedIdentity) throw new Error('failed to capture unrelated listener'); + cleanupProcesses.push(unrelatedIdentity); + for (let attempt = 0; attempt < 80 && !fs.existsSync(unrelatedPidFile); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + for (let attempt = 0; attempt < 80 && !(await isPortOpen(occupiedPort)); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(await isPortOpen(occupiedPort)).toBe(true); + + const occupiedStart = runCli(audit, env, [ + 'start', + '--run', + `${shellQuote(process.execPath)} -e ${shellQuote('setInterval(() => {}, 1000)')}`, + '--port', + String(occupiedPort), + '--browser-executable', + tools.browserPath, + ]); + expect(occupiedStart.status).toBe(1); + expect(occupiedStart.stderr).toContain('already in use by a process ProofShot did not start'); + expect(processIsAlive(unrelated.pid!)).toBe(true); + expect(fs.existsSync(path.join(audit, 'proofshot-artifacts', '.session.json'))).toBe(false); + const stopAfterOccupiedFailure = runCli(audit, env, ['stop']); + expect(stopAfterOccupiedFailure.status).toBe(0); + expect(stopAfterOccupiedFailure.stdout).toContain('already stopped'); + expect(processIsAlive(unrelated.pid!)).toBe(true); + + const failedPort = await freePort(); + const failedServerPidFile = path.join(base, 'failed-server.pid'); + const failedServerCommand = [ + shellQuote(process.execPath), + shellQuote(tools.serverScript), + String(failedPort), + shellQuote(failedServerPidFile), + ].join(' '); + const failedStart = runCli( + audit, + isolatedEnvironment(audit, tools, { FAKE_AGENT_BROWSER_FAIL_OPEN: '1' }), + [ + 'start', + '--run', + failedServerCommand, + '--port', + String(failedPort), + '--browser-executable', + tools.browserPath, + ], + ); + expect(failedStart.status).toBe(1); + expect(failedStart.stderr).toContain('simulated browser open failure'); + const failedServerPid = Number(fs.readFileSync(failedServerPidFile, 'utf-8')); + await waitForProcessExit(failedServerPid); + expect(processIsAlive(unrelated.pid!)).toBe(true); + expect(fs.existsSync(path.join(audit, 'proofshot-artifacts', '.session.json'))).toBe(false); + + const calls = fs + .readFileSync(tools.browserLog, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const failedOpen = [...calls].reverse().find((call) => call.command === 'open'); + const failedDaemon = [...calls] + .reverse() + .find((call) => call.command === 'daemon' && call.session === failedOpen.session); + const failedSessionCalls = calls.filter((call) => call.session === failedOpen.session); + expect(failedSessionCalls.map((call) => call.command)).toEqual( + expect.arrayContaining(['open', 'record', 'close']), + ); + await waitForProcessExit(failedDaemon.daemonPid); + const failedBrowserPidPath = path.join( + failedOpen.socketDir, + `${failedOpen.session}.pid`, + ); + expect(fs.existsSync(failedBrowserPidPath)).toBe(false); + }, 30000); + + it('retains exact browser ownership across stop --no-close', async () => { + const { base, audit } = createAuditRoot(); + const tools = writeFixtureTools(base); + const env = isolatedEnvironment(audit, tools); + fs.mkdirSync(env.HOME!, { recursive: true }); + fs.writeFileSync( + path.join(audit, 'proofshot.config.json'), + JSON.stringify({ output: './proofshot-artifacts' }), + ); + + const start = runCli(audit, env, [ + 'start', + '--url', + 'https://example.invalid/retained-browser', + '--browser-executable', + tools.browserPath, + ]); + expect(start.status, `${start.stdout}\n${start.stderr}`).toBe(0); + const controlPath = path.join(audit, 'proofshot-artifacts', '.session.json'); + const initialState = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + cleanupProcesses.push(initialState.browserProcess); + + const retainedStop = runCli(audit, env, ['stop', '--no-close']); + expect(retainedStop.status, `${retainedStop.stdout}\n${retainedStop.stderr}`).toBe(0); + const retainedState = JSON.parse(fs.readFileSync(controlPath, 'utf-8')); + expect(retainedState).toMatchObject({ + recordingActive: false, + bundleComplete: true, + browserRetained: true, + browserProcess: initialState.browserProcess, + }); + expect(processIsAlive(initialState.browserProcess.pid)).toBe(true); + const summaryPath = path.join(initialState.sessionDir, 'SUMMARY.md'); + const summaryBefore = fs.readFileSync(summaryPath, 'utf-8'); + const summaryMtimeBefore = fs.statSync(summaryPath).mtimeMs; + + const finalStop = runCli(audit, env, ['stop']); + expect(finalStop.status, `${finalStop.stdout}\n${finalStop.stderr}`).toBe(0); + expect(finalStop.stdout).toContain('Retained browser closed'); + await waitForProcessExit(initialState.browserProcess.pid); + expect(fs.existsSync(controlPath)).toBe(false); + expect(fs.readFileSync(summaryPath, 'utf-8')).toBe(summaryBefore); + expect(fs.statSync(summaryPath).mtimeMs).toBe(summaryMtimeBefore); + cleanupProcesses.splice(cleanupProcesses.indexOf(initialState.browserProcess), 1); + }, 30000); +}); diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 96632ae..2fd8a1b 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -11,10 +11,17 @@ const mocks = vi.hoisted(() => ({ generateTimestamp: vi.fn(), generateSessionDirName: vi.fn(), saveSession: vi.fn(), + loadSession: vi.fn(), hasActiveSession: vi.fn(), clearSession: vi.fn(), generateAgentBrowserSessionName: vi.fn(), + resolveSessionControlDir: vi.fn(), writeMetadata: vi.fn(), + discoverBrowserExecutable: vi.fn(), + browserSetupError: vi.fn(), + prepareAgentBrowserSocketDir: vi.fn(), + captureAgentBrowserProcessIdentity: vi.fn(), + cleanupFailedStart: vi.fn(), execSync: vi.fn(), })); @@ -35,6 +42,16 @@ vi.mock('../browser/capture.js', () => ({ startRecording: mocks.startRecording, })); +vi.mock('../browser/discovery.js', () => ({ + discoverBrowserExecutable: mocks.discoverBrowserExecutable, + browserSetupError: mocks.browserSetupError, +})); + +vi.mock('../browser/runtime.js', () => ({ + prepareAgentBrowserSocketDir: mocks.prepareAgentBrowserSocketDir, + captureAgentBrowserProcessIdentity: mocks.captureAgentBrowserProcessIdentity, +})); + vi.mock('../artifacts/bundle.js', () => ({ ensureOutputDir: mocks.ensureOutputDir, generateTimestamp: mocks.generateTimestamp, @@ -43,9 +60,15 @@ vi.mock('../artifacts/bundle.js', () => ({ vi.mock('../session/state.js', () => ({ saveSession: mocks.saveSession, + loadSession: mocks.loadSession, hasActiveSession: mocks.hasActiveSession, clearSession: mocks.clearSession, generateAgentBrowserSessionName: mocks.generateAgentBrowserSessionName, + resolveSessionControlDir: mocks.resolveSessionControlDir, +})); + +vi.mock('../session/lifecycle.js', () => ({ + cleanupFailedStart: mocks.cleanupFailedStart, })); vi.mock('../session/metadata.js', () => ({ @@ -76,9 +99,20 @@ describe('startCommand', () => { }, }); mocks.hasActiveSession.mockReturnValue(false); + mocks.loadSession.mockReturnValue(null); + mocks.resolveSessionControlDir.mockReturnValue('/project/proofshot-artifacts'); mocks.generateTimestamp.mockReturnValue('2026-04-08_07-28-00'); mocks.generateSessionDirName.mockReturnValue('2026-04-08_07-28-00_test'); - mocks.generateAgentBrowserSessionName.mockReturnValue('proofshot-2026-04-08_07-28-00'); + mocks.generateAgentBrowserSessionName.mockReturnValue('ps-2026-04-deadbeef1234'); + mocks.prepareAgentBrowserSocketDir.mockReturnValue('/run/user/1000/proofshot'); + mocks.discoverBrowserExecutable.mockReturnValue('/usr/bin/chromium'); + mocks.captureAgentBrowserProcessIdentity.mockReturnValue({ + pid: 4001, + processGroupId: 4001, + sessionId: 4001, + startTime: '12345', + }); + mocks.cleanupFailedStart.mockResolvedValue(undefined); mocks.execSync.mockImplementation((command: string) => { if (command === 'git branch --show-current') return 'main'; if (command === 'git rev-parse HEAD') return 'deadbeef'; @@ -92,7 +126,7 @@ describe('startCommand', () => { Object.values(mocks).forEach((mock) => mock.mockReset()); }); - it('closes the browser when recording never starts after all retries', async () => { + it('cleans the owned session when recording never starts after all retries', async () => { mocks.startRecording.mockImplementation(() => { throw new Error('Recording session could not be initialized'); }); @@ -102,11 +136,12 @@ describe('startCommand', () => { await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); expect(mocks.startRecording).toHaveBeenCalledTimes(3); - expect(mocks.closeBrowser).toHaveBeenCalledTimes(1); - expect(mocks.saveSession).not.toHaveBeenCalled(); + expect(mocks.cleanupFailedStart).toHaveBeenCalledTimes(1); + expect(mocks.saveSession).toHaveBeenCalled(); + expect(mocks.clearSession).toHaveBeenCalledWith('/project/proofshot-artifacts'); }); - it('does not try to stop recording when recording never started', async () => { + it('clears discoverable control state when recording never starts', async () => { mocks.startRecording.mockImplementation(() => { throw new Error('Recording already active'); }); @@ -116,7 +151,8 @@ describe('startCommand', () => { await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); expect(mocks.startRecording).toHaveBeenCalledTimes(3); - expect(mocks.closeBrowser).toHaveBeenCalledTimes(1); + expect(mocks.cleanupFailedStart).toHaveBeenCalledTimes(1); + expect(mocks.clearSession).toHaveBeenCalledWith('/project/proofshot-artifacts'); }); it('closes the session-scoped browser when browser open fails', async () => { @@ -127,8 +163,31 @@ describe('startCommand', () => { const commandPromise = startCommand({}).catch((error) => error); await expect(commandPromise).resolves.toMatchObject({ message: 'process.exit:1' }); - expect(mocks.closeBrowser).toHaveBeenCalledTimes(1); + expect(mocks.cleanupFailedStart).toHaveBeenCalledTimes(1); expect(mocks.startRecording).not.toHaveBeenCalled(); - expect(mocks.saveSession).not.toHaveBeenCalled(); + expect(mocks.clearSession).toHaveBeenCalledWith('/project/proofshot-artifacts'); + }); + + it('persists the intended target and stable control path with custom evidence output', async () => { + await startCommand({ + output: '/audit/custom-evidence', + url: 'http://127.0.0.1:43171/getting-started', + }); + + expect(mocks.openBrowser).toHaveBeenCalledWith( + 'http://127.0.0.1:43171/getting-started', + { width: 1280, height: 720 }, + true, + 'ps-2026-04-deadbeef1234', + expect.objectContaining({ executablePath: '/usr/bin/chromium' }), + ); + const finalState = mocks.saveSession.mock.calls.at(-1)?.[0]; + expect(finalState).toMatchObject({ + outputDir: '/audit/custom-evidence', + targetUrl: 'http://127.0.0.1:43171/getting-started', + recordingActive: true, + agentBrowserSocketDir: '/run/user/1000/proofshot', + }); + expect(mocks.saveSession.mock.calls.every((call) => call[1] === '/project/proofshot-artifacts')).toBe(true); }); }); diff --git a/src/commands/start.ts b/src/commands/start.ts index f9632ac..5cb1dba 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -4,15 +4,24 @@ import { execSync } from 'child_process'; import { loadConfig } from '../utils/config.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; import { ensureDevServer } from '../server/start.js'; -import { closeBrowser, openBrowser } from '../browser/session.js'; +import { openBrowser } from '../browser/session.js'; import { startRecording } from '../browser/capture.js'; +import { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js'; +import { + captureAgentBrowserProcessIdentity, + prepareAgentBrowserSocketDir, +} from '../browser/runtime.js'; import { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js'; import { saveSession, + loadSession, hasActiveSession, clearSession, generateAgentBrowserSessionName, + resolveSessionControlDir, + type SessionState, } from '../session/state.js'; +import { cleanupFailedStart } from '../session/lifecycle.js'; import { writeMetadata } from '../session/metadata.js'; interface StartOptions { @@ -22,23 +31,26 @@ interface StartOptions { headed?: boolean; output?: string; url?: string; + browserExecutable?: string; force?: boolean; } export async function startCommand(options: StartOptions): Promise { const config = loadConfig(); - setAgentBrowserDefaults({ configPath: config.browser.configPath }); - if (options.port) config.devServer.port = options.port; - if (options.output) config.output = options.output; - if (options.headed !== undefined) config.headless = !options.headed; + const controlDir = resolveSessionControlDir(config.output); - const outputDir = path.resolve(config.output); - const timestamp = generateTimestamp(); - - if (hasActiveSession(outputDir)) { + if (hasActiveSession(controlDir)) { if (options.force) { - clearSession(outputDir); - console.log(chalk.yellow('⚠') + chalk.dim(' Cleared stale session')); + const existingSession = loadSession(controlDir); + if (existingSession) { + setAgentBrowserDefaults({ + configPath: existingSession.agentBrowserConfigPath || config.browser.configPath, + socketDir: existingSession.agentBrowserSocketDir, + }); + await cleanupFailedStart(existingSession); + } + clearSession(controlDir); + console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session')); } else { console.log( chalk.yellow('⚠ A session is already active.') + @@ -48,11 +60,40 @@ export async function startCommand(options: StartOptions): Promise { } } - ensureOutputDir(outputDir); + if (options.port) config.devServer.port = options.port; + if (options.output) config.output = options.output; + if (options.headed !== undefined) config.headless = !options.headed; + const outputDir = path.resolve(config.output); + const timestamp = generateTimestamp(); const sessionDirName = generateSessionDirName(timestamp, options.description || null); const sessionDir = path.join(outputDir, sessionDirName); const sessionName = generateAgentBrowserSessionName(timestamp); + let socketDir: string; + let browserExecutable: string | null; + + 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: any) { + console.error(chalk.red('✗') + ` 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 = path.join(sessionDir, 'session.webm'); @@ -84,96 +125,111 @@ export async function startCommand(options: StartOptions): Promise { description: options.description || null, }); - let serverAlreadyRunning = true; + const baseUrl = `http://localhost:${config.devServer.port}`; + const openUrl = options.url || baseUrl; + const session: SessionState = { + startedAt: new Date().toISOString(), + description: options.description || null, + outputDir, + sessionDir, + sessionName, + videoPath, + serverErrorLog, + port: config.devServer.port, + serverCommand: options.run || null, + serverAlreadyRunning: !options.run, + recordingActive: false, + bundleComplete: false, + browserRetained: false, + videoTrimComplete: false, + trimOffsetSec: 0, + sessionLogAdjusted: false, + consoleEvidenceAvailable: false, + consoleErrorCount: 0, + targetUrl: openUrl, + agentBrowserSocketDir: socketDir, + agentBrowserConfigPath: config.browser.configPath, + serverProcess: null, + browserProcess: null, + viewport: { width: config.viewport.width, height: config.viewport.height }, + }; + saveSession(session, controlDir); - if (options.run) { - console.log(chalk.dim(`Starting: ${options.run}`)); - try { - await ensureDevServer( + let failureContext = 'start the session'; + try { + if (options.run) { + failureContext = 'start dev server'; + console.log(chalk.dim(`Starting: ${options.run}`)); + const server = await ensureDevServer( options.run, config.devServer.port, config.devServer.startupTimeout, serverErrorLog, ); - serverAlreadyRunning = false; + session.serverAlreadyRunning = false; + session.serverProcess = server.process; + saveSession(session, controlDir); console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`); console.log(chalk.dim(` Server logs → ${serverErrorLog}`)); - } catch (error: any) { - console.error(chalk.red('✗') + ` Failed to start dev server: ${error.message}`); - process.exit(1); + } else { + console.log(chalk.dim('No --run provided, assuming server is already running')); } - } else { - console.log(chalk.dim('No --run provided, assuming server is already running')); - } - const baseUrl = `http://localhost:${config.devServer.port}`; - const openUrl = options.url || baseUrl; - - console.log(chalk.dim('Opening browser...')); - try { + failureContext = 'open browser'; + console.log(chalk.dim('Opening browser...')); 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}.`, + ); + } + saveSession(session, controlDir); console.log(chalk.green('✓') + ' Browser ready'); - } catch (error: any) { - closeBrowser(); - console.error( - chalk.red('✗') + - ` Failed to open browser: ${error.message}\n` + - chalk.dim('Make sure agent-browser is installed: npm install -g agent-browser'), - ); - process.exit(1); - } - const RECORDING_RETRIES = 3; - const RETRY_DELAY_MS = 2000; - let recordingStarted = false; - let lastError: any; - - for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) { - try { - startRecording(videoPath, sessionName); - recordingStarted = true; - console.log(chalk.green('✓') + ' Recording started'); - break; - } catch (error: any) { - lastError = error; - if (attempt < RECORDING_RETRIES) { - console.log( - chalk.yellow('⚠') + - ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`, - ); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + failureContext = 'initialize recording'; + const RECORDING_RETRIES = 3; + const RETRY_DELAY_MS = 2000; + let recordingStarted = false; + let lastError: any; + + for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) { + try { + startRecording(videoPath, sessionName); + recordingStarted = true; + console.log(chalk.green('✓') + ' Recording started'); + break; + } catch (error: any) { + lastError = error; + if (attempt < RECORDING_RETRIES) { + console.log( + chalk.yellow('⚠') + + ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`, + ); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } } } - } - if (!recordingStarted) { - closeBrowser(); + if (!recordingStarted) { + throw new Error( + `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`, + ); + } + } catch (error: any) { + await cleanupFailedStart(session); + clearSession(controlDir); console.error( chalk.red('✗') + - ` Failed to initialize recording after ${RECORDING_RETRIES} attempts: ${lastError?.message}\n` + - chalk.dim('Recording is required — ProofShot cannot proceed without video capture.\n') + - chalk.dim('Troubleshooting:\n') + - chalk.dim(' 1. Make sure agent-browser is installed and running\n') + - chalk.dim(' 2. Try "proofshot clean" then re-run "proofshot start"\n') + - chalk.dim(' 3. If the port was already in use, stop the old server first'), + ` Failed to ${failureContext}: ${error.message}\n` + + chalk.dim('All processes started by this ProofShot attempt were cleaned up.'), ); process.exit(1); + return; } - saveSession({ - startedAt: new Date().toISOString(), - description: options.description || null, - outputDir, - sessionDir, - sessionName, - videoPath, - serverErrorLog, - port: config.devServer.port, - serverCommand: options.run || null, - serverAlreadyRunning, - recordingActive: true, - viewport: { width: config.viewport.width, height: config.viewport.height }, - }); + session.recordingActive = true; + saveSession(session, controlDir); console.log(''); console.log(chalk.green.bold('✅ ProofShot session started')); @@ -181,6 +237,7 @@ export async function startCommand(options: StartOptions): Promise { console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`); console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`); console.log(`Session: ${chalk.dim(sessionName)}`); + console.log(`Target: ${chalk.dim(openUrl)}`); console.log(`Recording: ${chalk.dim(videoPath)}`); console.log(`Errors log: ${chalk.dim(serverErrorLog)}`); diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts new file mode 100644 index 0000000..b0e68d7 --- /dev/null +++ b/src/commands/stop.test.ts @@ -0,0 +1,221 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + loadSession: vi.fn(), + clearSession: vi.fn(), + resolveSessionControlDir: vi.fn(), + saveSession: vi.fn(), + stopRecording: vi.fn(), + getConsoleErrors: vi.fn(), + getConsoleOutput: vi.fn(), + getConsoleOutputJson: vi.fn(), + stopOwnedBrowser: vi.fn(), + stopOwnedServer: vi.fn(), + canAddressOwnedBrowserSession: vi.fn(), + writeViewer: vi.fn(), + extractServerErrors: vi.fn(), + loadSessionLog: vi.fn(), + estimateTokenUsage: vi.fn(), + execSync: vi.fn(), +})); + +vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); +vi.mock('../session/state.js', () => ({ + loadSession: mocks.loadSession, + clearSession: mocks.clearSession, + resolveSessionControlDir: mocks.resolveSessionControlDir, + saveSession: mocks.saveSession, +})); +vi.mock('../browser/capture.js', () => ({ stopRecording: mocks.stopRecording })); +vi.mock('../browser/session.js', () => ({ + getConsoleErrors: mocks.getConsoleErrors, + getConsoleOutput: mocks.getConsoleOutput, + getConsoleOutputJson: mocks.getConsoleOutputJson, +})); +vi.mock('../session/lifecycle.js', () => ({ + canAddressOwnedBrowserSession: mocks.canAddressOwnedBrowserSession, + stopOwnedBrowser: mocks.stopOwnedBrowser, + stopOwnedServer: mocks.stopOwnedServer, +})); +vi.mock('../artifacts/viewer.js', () => ({ writeViewer: mocks.writeViewer })); +vi.mock('../utils/error-patterns.js', () => ({ extractServerErrors: mocks.extractServerErrors })); +vi.mock('./exec.js', () => ({ loadSessionLog: mocks.loadSessionLog })); +vi.mock('../utils/token-usage.js', () => ({ estimateTokenUsage: mocks.estimateTokenUsage })); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execSync: mocks.execSync }; +}); + +import { stopCommand } from './stop.js'; + +let root: string; +let session: any; + +beforeEach(() => { + const cache = path.join(os.userInfo().homedir, '.cache'); + fs.mkdirSync(cache, { recursive: true }); + root = fs.mkdtempSync(path.join(cache, 'proofshot-stop-test-')); + const sessionDir = path.join(root, 'custom-evidence', 'session'); + fs.mkdirSync(sessionDir, { recursive: true }); + session = { + startedAt: new Date(Date.now() - 1000).toISOString(), + description: 'retry bundle', + outputDir: path.join(root, 'custom-evidence'), + sessionDir, + sessionName: 'ps-retry-deadbeef1234', + videoPath: path.join(sessionDir, 'session.webm'), + serverErrorLog: path.join(sessionDir, 'server.log'), + port: 3000, + serverCommand: 'npm run dev', + serverAlreadyRunning: false, + recordingActive: true, + bundleComplete: false, + browserRetained: false, + videoTrimComplete: false, + trimOffsetSec: 0, + sessionLogAdjusted: false, + consoleEvidenceAvailable: false, + consoleErrorCount: 0, + serverProcess: { pid: 1001, processGroupId: 1001, sessionId: 1001, startTime: '1' }, + browserProcess: { pid: 1002, processGroupId: 1002, sessionId: 1002, startTime: '2' }, + }; + fs.writeFileSync(session.serverErrorLog, `${Date.now()}\tserver ready\n`); + + mocks.loadConfig.mockReturnValue({ output: './proofshot-artifacts', browser: {} }); + mocks.resolveSessionControlDir.mockReturnValue(path.join(root, 'proofshot-artifacts')); + mocks.loadSession.mockImplementation(() => session); + mocks.getConsoleErrors.mockReturnValue('No errors'); + mocks.getConsoleOutput.mockReturnValue('console evidence'); + mocks.getConsoleOutputJson.mockReturnValue([]); + mocks.extractServerErrors.mockReturnValue([]); + mocks.loadSessionLog.mockReturnValue([]); + mocks.estimateTokenUsage.mockReturnValue(null); + mocks.execSync.mockReturnValue(''); + mocks.stopOwnedBrowser.mockResolvedValue(undefined); + mocks.stopOwnedServer.mockResolvedValue(undefined); + mocks.canAddressOwnedBrowserSession.mockReturnValue(true); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + Object.values(mocks).forEach((mock) => mock.mockReset()); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('stopCommand retryability', () => { + it('keeps state after a bundle failure and retries without replacing a valid summary', async () => { + fs.writeFileSync(session.videoPath, 'nonempty-original-video'); + const sessionLogPath = path.join(session.sessionDir, 'session-log.json'); + fs.writeFileSync( + sessionLogPath, + JSON.stringify([ + { action: 'open target', relativeTimeSec: 10, timestamp: session.startedAt }, + { action: 'screenshot proof.png', relativeTimeSec: 20, timestamp: session.startedAt }, + ]), + ); + mocks.loadSessionLog.mockImplementation(() => + JSON.parse(fs.readFileSync(sessionLogPath, 'utf-8')), + ); + let trimCalls = 0; + mocks.execSync.mockImplementation((command: string) => { + if (command === 'ffmpeg -version') return ''; + if (command.startsWith('ffmpeg -i ')) { + trimCalls += 1; + fs.writeFileSync(session.videoPath, `trimmed-video-${trimCalls}`); + return ''; + } + throw new Error(`unexpected command: ${command}`); + }); + mocks.writeViewer.mockImplementationOnce(() => { + throw new Error('simulated viewer write failure'); + }); + + await expect(stopCommand({})).rejects.toThrow('simulated viewer write failure'); + expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); + expect(mocks.stopOwnedServer).toHaveBeenCalledWith(session); + expect(mocks.clearSession).not.toHaveBeenCalled(); + expect(mocks.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ + recordingActive: false, + bundleComplete: false, + videoTrimComplete: true, + trimOffsetSec: 5, + sessionLogAdjusted: true, + }), + path.join(root, 'proofshot-artifacts'), + ); + expect(trimCalls).toBe(1); + expect(fs.readFileSync(session.videoPath, 'utf-8')).toBe('trimmed-video-1'); + expect(JSON.parse(fs.readFileSync(sessionLogPath, 'utf-8')).map((entry: any) => entry.relativeTimeSec)).toEqual([5, 15]); + + const summaryPath = path.join(session.sessionDir, 'SUMMARY.md'); + const summaryBefore = fs.readFileSync(summaryPath, 'utf-8'); + const summaryMtimeBefore = fs.statSync(summaryPath).mtimeMs; + mocks.writeViewer.mockReturnValue(path.join(session.sessionDir, 'viewer.html')); + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + + await stopCommand({}); + + expect(mocks.writeViewer).toHaveBeenCalledTimes(2); + expect(trimCalls).toBe(1); + expect(fs.readFileSync(session.videoPath, 'utf-8')).toBe('trimmed-video-1'); + expect(JSON.parse(fs.readFileSync(sessionLogPath, 'utf-8')).map((entry: any) => entry.relativeTimeSec)).toEqual([5, 15]); + expect(mocks.writeViewer.mock.calls.at(-1)?.[1].entries.map((entry: any) => entry.relativeTimeSec)).toEqual([5, 15]); + expect(mocks.writeViewer.mock.calls.at(-1)?.[1]).toMatchObject({ + consoleEvidenceAvailable: true, + consoleErrorCount: 0, + consoleOutput: 'console evidence', + }); + expect(mocks.clearSession).toHaveBeenCalledWith(path.join(root, 'proofshot-artifacts')); + expect(fs.readFileSync(summaryPath, 'utf-8')).toBe(summaryBefore); + expect(fs.statSync(summaryPath).mtimeMs).toBe(summaryMtimeBefore); + }); + + it('skips every session-addressed browser command when identity is mismatched', async () => { + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + mocks.writeViewer.mockReturnValue(path.join(session.sessionDir, 'viewer.html')); + + await stopCommand({}); + + expect(mocks.getConsoleErrors).not.toHaveBeenCalled(); + expect(mocks.getConsoleOutput).not.toHaveBeenCalled(); + expect(mocks.getConsoleOutputJson).not.toHaveBeenCalled(); + expect(mocks.stopRecording).not.toHaveBeenCalled(); + expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); + expect(mocks.stopOwnedServer).toHaveBeenCalledWith(session); + expect(mocks.clearSession).toHaveBeenCalled(); + expect(mocks.writeViewer).toHaveBeenCalledWith( + session.sessionDir, + expect.objectContaining({ consoleEvidenceAvailable: false }), + ); + const summary = fs.readFileSync(path.join(session.sessionDir, 'SUMMARY.md'), 'utf-8'); + expect(summary).toContain('console evidence was unavailable'); + expect(summary).not.toContain('No console errors detected'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Browser ownership could not be verified'), + ); + }); + + it('does not claim a retained browser was closed when its identity mismatches', async () => { + session.bundleComplete = true; + session.browserRetained = true; + session.recordingActive = false; + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + + await stopCommand({}); + + expect(mocks.stopOwnedBrowser).toHaveBeenCalledWith(session); + expect(mocks.clearSession).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('skipped session-name close'), + ); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Retained browser closed'), + ); + }); +}); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 2600ed3..a32261e 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -1,12 +1,23 @@ import * as fs from 'fs'; import * as path from 'path'; +import { randomUUID } from 'crypto'; import { execSync } from 'child_process'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; -import { closeBrowser, getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js'; +import { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js'; import { stopRecording } from '../browser/capture.js'; -import { loadSession, clearSession } from '../session/state.js'; +import { + loadSession, + clearSession, + resolveSessionControlDir, + saveSession, +} from '../session/state.js'; +import { + canAddressOwnedBrowserSession, + stopOwnedBrowser, + stopOwnedServer, +} from '../session/lifecycle.js'; import { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js'; import { extractServerErrors } from '../utils/error-patterns.js'; import { loadSessionLog } from './exec.js'; @@ -55,57 +66,127 @@ interface StopOptions { export async function stopCommand(options: StopOptions): Promise { const config = loadConfig(); - setAgentBrowserDefaults({ configPath: config.browser.configPath }); - const outputDir = path.resolve(config.output); + const controlDir = resolveSessionControlDir(config.output); // Load session state - const session = loadSession(outputDir); + const session = loadSession(controlDir); if (!session) { - console.error( - chalk.red('✗') + - ' No active session found.\n' + - chalk.dim('Run "proofshot start" first.'), + console.log( + chalk.dim('No active session found; all owned processes are already stopped.'), ); - process.exit(1); + return; } + setAgentBrowserDefaults({ + configPath: session.agentBrowserConfigPath || config.browser.configPath, + socketDir: session.agentBrowserSocketDir, + }); + if (session.bundleComplete) { + if (session.browserRetained && !options.noClose) { + console.log(chalk.dim('Closing retained browser...')); + const browserSessionAddressable = canAddressOwnedBrowserSession(session); + await stopOwnedBrowser(session); + session.browserRetained = false; + clearSession(controlDir); + if (browserSessionAddressable) { + console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.'); + } else { + console.log( + chalk.yellow('⚠') + + ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.', + ); + } + } else if (session.browserRetained) { + console.log( + chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'), + ); + } else { + clearSession(controlDir); + console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.')); + } + return; + } + + const retryingStoppedSession = !session.recordingActive; + const recordingWasActive = session.recordingActive; const startTime = new Date(session.startedAt).getTime(); const durationMs = Date.now() - startTime; const durationSec = Math.round(durationMs / 1000); + const browserSessionAvailable = canAddressOwnedBrowserSession(session); + + const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true; + if (!browserSessionAvailable && priorConsoleEvidenceAvailable) { + console.log( + chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'), + ); + } else if (!browserSessionAvailable) { + console.log( + chalk.yellow('⚠') + + ' Browser ownership could not be verified; skipping console and recording commands.\n' + + chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'), + ); + } // Step 1: Collect console errors and output console.log(chalk.dim('Collecting errors...')); let consoleErrors = ''; let consoleOutput = ''; let consoleEntries: TimestampedLogEntry[] = []; - try { - consoleErrors = getConsoleErrors(session.sessionName); - consoleOutput = getConsoleOutput(session.sessionName); - // Get timestamped console messages for viewer sync - const consoleMessages = getConsoleOutputJson(session.sessionName); - consoleEntries = consoleMessages.map((msg) => ({ - text: `[${msg.type}] ${msg.text}`, - relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))), - })); - } catch { - // Browser may already be closed + if (browserSessionAvailable) { + try { + consoleErrors = getConsoleErrors(session.sessionName); + consoleOutput = getConsoleOutput(session.sessionName); + // Get timestamped console messages for viewer sync + const consoleMessages = getConsoleOutputJson(session.sessionName); + consoleEntries = consoleMessages.map((msg) => ({ + text: `[${msg.type}] ${msg.text}`, + relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))), + })); + } catch { + // Browser may already be closed + } } // Write console output to file (before closing browser) if (consoleOutput.trim()) { fs.writeFileSync(path.join(session.sessionDir, 'console-output.log'), consoleOutput); + } else if (priorConsoleEvidenceAvailable) { + const savedConsoleOutput = path.join(session.sessionDir, 'console-output.log'); + if (fs.existsSync(savedConsoleOutput)) { + consoleOutput = fs.readFileSync(savedConsoleOutput, 'utf-8'); + } } // Step 2: Stop recording console.log(chalk.dim('Stopping recording...')); - stopRecording(session.sessionName); + if (browserSessionAvailable) { + stopRecording(session.sessionName); + } + session.recordingActive = false; + saveSession(session, controlDir); // Step 3: Close browser (unless --no-close) + let cleanupError: unknown; if (!options.noClose) { console.log(chalk.dim('Closing browser...')); - closeBrowser(session.sessionName); + try { + await stopOwnedBrowser(session); + } catch (error) { + cleanupError = error; + } } + // Step 3.5: Stop only the detached process session created by this start. + if (session.serverProcess) { + console.log(chalk.dim('Stopping dev server...')); + try { + await stopOwnedServer(session); + } catch (error) { + cleanupError ||= error; + } + } + if (cleanupError) throw cleanupError; + // Step 4: Read server log (with timestamp parsing) let serverLog = ''; let serverEntries: TimestampedLogEntry[] = []; @@ -126,22 +207,40 @@ export async function stopCommand(options: StopOptions): Promise { // Step 5.5: Trim video dead time const sessionLog = loadSessionLog(sessionDir); - let trimOffsetSec = 0; - if (fs.existsSync(session.videoPath)) { - trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog); - } else if (session.recordingActive) { - console.log( - chalk.yellow('⚠') + - ' Recording was active but no video file was produced.\n' + - chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'), - ); + let trimOffsetSec = session.trimOffsetSec ?? 0; + if (!session.videoTrimComplete) { + if (fs.existsSync(session.videoPath)) { + trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog); + } else if (recordingWasActive) { + console.log( + chalk.yellow('⚠') + + ' Recording was active but no video file was produced.\n' + + chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'), + ); + } + session.videoTrimComplete = true; + session.trimOffsetSec = trimOffsetSec; + saveSession(session, controlDir); } // Step 6: Count errors const consoleErrorLines = consoleErrors .split('\n') .filter((l) => l.trim() && l.trim() !== 'No errors'); - const consoleErrorCount = consoleErrorLines.length > 0 && consoleErrors.trim() !== '' ? consoleErrorLines.length : 0; + const observedConsoleErrorCount = + consoleErrorLines.length > 0 && consoleErrors.trim() !== '' + ? consoleErrorLines.length + : 0; + const consoleEvidenceAvailable = + browserSessionAvailable || priorConsoleEvidenceAvailable; + const consoleErrorCount = browserSessionAvailable + ? observedConsoleErrorCount + : session.consoleErrorCount ?? 0; + if (browserSessionAvailable) { + session.consoleEvidenceAvailable = true; + session.consoleErrorCount = consoleErrorCount; + saveSession(session, controlDir); + } // Extract errors from server log using multi-language patterns const serverErrorLines = extractServerErrors(serverLog); @@ -160,28 +259,35 @@ export async function stopCommand(options: StopOptions): Promise { screenshots, consoleErrors, consoleErrorCount, + consoleEvidenceAvailable, serverLog, serverErrorCount, tokenUsage, durationSec, outputDir: sessionDir, }); - fs.writeFileSync(summaryPath, summary); + if (!retryingStoppedSession || !fs.existsSync(summaryPath)) { + writeTextFileAtomically(summaryPath, summary); + } // Step 7.5: Generate interactive viewer (if session log exists) // Adjust session log timestamps to match the trimmed video - const viewerEntries = - trimOffsetSec > 0 - ? sessionLog.map((e) => ({ - ...e, - relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)), - })) - : sessionLog; + let viewerEntries = sessionLog; + if (trimOffsetSec > 0 && !session.sessionLogAdjusted) { + viewerEntries = sessionLog.map((e) => ({ + ...e, + relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)), + })); + } // Write adjusted log back to disk so timestamps match the trimmed video - if (trimOffsetSec > 0 && viewerEntries.length > 0) { + if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) { const logPath = path.join(sessionDir, 'session-log.json'); - fs.writeFileSync(logPath, JSON.stringify(viewerEntries, null, 2) + '\n'); + writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\n'); + } + if (!session.sessionLogAdjusted) { + session.sessionLogAdjusted = true; + saveSession(session, controlDir); } // Apply trimOffsetSec to log entries (same adjustment as session log) @@ -199,6 +305,7 @@ export async function stopCommand(options: StopOptions): Promise { durationSec, videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null, consoleErrorCount, + consoleEvidenceAvailable, serverErrorCount, consoleOutput, serverLog, @@ -208,8 +315,14 @@ export async function stopCommand(options: StopOptions): Promise { tokenUsage, }); - // Step 8: Clear session state - clearSession(outputDir); + // Step 8: Retain exact browser ownership only when explicitly requested. + session.bundleComplete = true; + session.browserRetained = Boolean(options.noClose); + if (session.browserRetained) { + saveSession(session, controlDir); + } else { + clearSession(controlDir); + } // Step 9: Print results console.log(''); @@ -228,7 +341,13 @@ export async function stopCommand(options: StopOptions): Promise { } console.log(''); console.log( - `Console errors: ${consoleErrorCount === 0 ? chalk.green('0') : chalk.red(String(consoleErrorCount))}`, + `Console errors: ${ + !consoleEvidenceAvailable + ? chalk.yellow('unavailable') + : consoleErrorCount === 0 + ? chalk.green('0') + : chalk.red(String(consoleErrorCount)) + }`, ); console.log( `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`, @@ -236,6 +355,9 @@ export async function stopCommand(options: StopOptions): Promise { console.log(`Duration: ${durationSec} seconds`); console.log(''); console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`); + if (session.browserRetained) { + console.log(chalk.dim('Browser retained. Run "proofshot stop" later to close this exact session.')); + } // If errors were found, print them for immediate feedback if (consoleErrorCount > 0) { @@ -261,6 +383,16 @@ export async function stopCommand(options: StopOptions): Promise { } } +function writeTextFileAtomically(filePath: string, contents: string): void { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporaryPath, contents); + fs.renameSync(temporaryPath, filePath); + } finally { + if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath); + } +} + interface SummaryData { description: string | null; serverCommand: string | null; @@ -269,6 +401,7 @@ interface SummaryData { screenshots: string[]; consoleErrors: string; consoleErrorCount: number; + consoleEvidenceAvailable: boolean; serverLog: string; serverErrorCount: number; tokenUsage?: TokenUsage | null; @@ -318,7 +451,9 @@ Full session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationS md += `## Console Errors `; - if (data.consoleErrorCount === 0) { + if (!data.consoleEvidenceAvailable) { + md += `Browser ownership could not be verified, so console evidence was unavailable.\n\n`; + } else if (data.consoleErrorCount === 0) { md += `No console errors detected.\n\n`; } else { md += `${data.consoleErrorCount} error(s) detected:\n\n\`\`\`\n${data.consoleErrors}\n\`\`\`\n\n`; diff --git a/src/server/start.test.ts b/src/server/start.test.ts new file mode 100644 index 0000000..206567c --- /dev/null +++ b/src/server/start.test.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { isPortOpen } from '../utils/port.js'; +import { terminateOwnedProcessTree, type ProcessIdentity } from '../utils/process.js'; +import { ensureDevServer } from './start.js'; + +const roots: string[] = []; +const ownedProcesses: ProcessIdentity[] = []; + +function createRoot(): string { + const cache = path.join(os.userInfo().homedir, '.cache'); + fs.mkdirSync(cache, { recursive: true }); + const root = fs.mkdtempSync(path.join(cache, 'proofshot-server-test-')); + roots.push(root); + return root; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +afterEach(async () => { + for (const identity of ownedProcesses.splice(0)) { + await terminateOwnedProcessTree(identity, { graceMs: 200 }); + } + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('ensureDevServer', () => { + it('fails actionably without killing an unrelated occupied listener', async () => { + const listener = http.createServer((_request, response) => response.end('unrelated')); + await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve)); + const address = listener.address(); + if (!address || typeof address === 'string') throw new Error('missing listener port'); + + try { + await expect( + ensureDevServer( + `${shellQuote(process.execPath)} -e ${shellQuote('process.exit(99)')}`, + address.port, + 250, + path.join(createRoot(), 'server.log'), + ), + ).rejects.toThrow(/already in use by a process ProofShot did not start/); + expect(listener.listening).toBe(true); + } finally { + await new Promise((resolve) => listener.close(() => resolve())); + } + }); + + it('persists an exact supervisor identity and keeps epoch-tab server logs', async () => { + const root = createRoot(); + const scriptPath = path.join(root, 'server.mjs'); + const logPath = path.join(root, 'server.log'); + fs.writeFileSync( + scriptPath, + [ + "import http from 'node:http';", + 'const port = Number(process.argv[2]);', + "const server = http.createServer((_req, res) => res.end('ok'));", + "server.listen(port, '127.0.0.1', () => console.log('server-ready'));", + ].join('\n'), + ); + + const probe = http.createServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const address = probe.address(); + if (!address || typeof address === 'string') throw new Error('missing probe port'); + const port = address.port; + await new Promise((resolve) => probe.close(() => resolve())); + + const result = await ensureDevServer( + `${shellQuote(process.execPath)} ${shellQuote(scriptPath)} ${port}`, + port, + 3000, + logPath, + ); + ownedProcesses.push(result.process); + + expect(result.process.processGroupId).toBe(result.process.pid); + expect(result.process.sessionId).toBe(result.process.pid); + expect(fs.readFileSync(logPath, 'utf-8')).toMatch(/^\d{13}\tserver-ready$/m); + + await terminateOwnedProcessTree(result.process, { graceMs: 300 }); + ownedProcesses.pop(); + for (let attempt = 0; attempt < 40 && (await isPortOpen(port)); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(await isPortOpen(port)).toBe(false); + }); +}); diff --git a/src/server/start.ts b/src/server/start.ts index 1ad1f61..499ce7f 100644 --- a/src/server/start.ts +++ b/src/server/start.ts @@ -1,60 +1,59 @@ import * as fs from 'fs'; -import { Transform } from 'stream'; +import { spawn } from 'child_process'; import { isPortOpen, waitForPort } from '../utils/port.js'; import { - findPidsListeningOnPort, - killPids, - spawnShellCommand, + captureProcessIdentity, + getShellExecutable, + terminateOwnedProcessTree, terminateProcessTree, + type ProcessIdentity, } from '../utils/process.js'; export interface ServerStartResult { alreadyRunning: boolean; port: number; + process: ProcessIdentity; } -/** - * Kill whatever process is listening on the given port. - * Retries up to 3 times to ensure the port is actually freed. - * Returns true if something was killed. - */ -async function killPort(port: number): Promise { - let killed = false; - for (let attempt = 0; attempt < 3; attempt++) { - const pids = findPidsListeningOnPort(port); - if (pids.length > 0) { - killed = killPids(pids) || killed; - } - - // Wait for the OS to release the port - await new Promise((r) => setTimeout(r, 1000)); - if (!(await isPortOpen(port))) return killed; - } - return killed; -} - -/** - * Create a Transform stream that prepends an epoch-ms timestamp to each line. - * Format: "1720612345678\toriginal line\n" - */ -function createTimestampTransform(): Transform { +// A detached supervisor keeps timestamping server output after the short-lived +// `proofshot start` process exits. It and the server share one new process +// session, whose immutable identity is persisted for exact later cleanup. +const 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 = ''; - return new Transform({ - transform(chunk, _encoding, callback) { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop()!; - for (const line of lines) { - this.push(`${Date.now()}\t${line}\n`); - } - callback(); - }, - flush(callback) { - if (buffer) this.push(`${Date.now()}\t${buffer}\n`); - callback(); - }, + 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); +}); +`; /** * Start a dev server command and wait for it to be ready. @@ -67,45 +66,50 @@ export async function ensureDevServer( startupTimeout: number, logPath: string, ): Promise { - // If port is occupied, kill the existing process — the user explicitly - // asked proofshot to own the server via --run. + // Port ownership is not session ownership. Never kill an unrelated listener. if (await isPortOpen(port)) { - const killed = await killPort(port); - if (killed) { - process.stderr.write(`Port ${port} was in use — killed existing process\n`); - } - // Final check — if still occupied, fail fast with a clear message - if (await isPortOpen(port)) { - throw new Error( - `Port ${port} is still in use after attempting to kill the process.\n` + - `Manually stop whatever is running on port ${port} and retry.`, - ); - } + throw new Error( + `Port ${port} is already in use by a process ProofShot did not start.\n` + + 'Choose another port or stop that process explicitly, then retry.', + ); } - const proc = spawnShellCommand(command, { - cwd: process.cwd(), - stdio: ['ignore', 'pipe', 'pipe'], + // Ensure log creation errors surface before launching the detached runner. + const logFd = fs.openSync(logPath, 'a'); + fs.closeSync(logFd); + const proc = spawn(process.execPath, [ + '-e', + SERVER_RUNNER_SOURCE, + command, + process.cwd(), + logPath, + getShellExecutable(), + ], { + stdio: 'ignore', detached: true, }); - const logStream = fs.createWriteStream(logPath, { flags: 'a' }); - const tsOut = createTimestampTransform(); - const tsErr = createTimestampTransform(); - proc.stdout?.pipe(tsOut).pipe(logStream, { end: false }); - proc.stderr?.pipe(tsErr).pipe(logStream, { end: false }); - proc.unref(); + let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null; + for (let attempt = 0; !processIdentity && attempt < 5; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null; + } - try { - await waitForPort(port, startupTimeout); - } catch (error) { - // Clean up the spawned process if it failed to start on the expected port + if (!processIdentity || processIdentity.sessionId !== processIdentity.pid) { try { if (proc.pid) terminateProcessTree(proc.pid); } catch { - // Already exited + // The child may already have exited. } + throw new Error('ProofShot could not record an exact identity for the dev server process.'); + } + + try { + await waitForPort(port, startupTimeout); + } catch (error) { + // Clean up the spawned process if it failed to start on the expected port + await terminateOwnedProcessTree(processIdentity); throw new Error( `Failed to start dev server with "${command}" on port ${port}.\n` + `Make sure the command is correct and the port is available.\n` + @@ -116,5 +120,5 @@ export async function ensureDevServer( // Small delay for stability await new Promise((resolve) => setTimeout(resolve, 1000)); - return { alreadyRunning: false, port }; + return { alreadyRunning: false, port, process: processIdentity }; } diff --git a/src/session/lifecycle.test.ts b/src/session/lifecycle.test.ts new file mode 100644 index 0000000..21b65fa --- /dev/null +++ b/src/session/lifecycle.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + captureAgentBrowserProcessIdentity: vi.fn(), + closeBrowser: vi.fn(), + stopRecording: vi.fn(), + ownedProcessTreeIsAlive: vi.fn(), + processIdentityMatches: vi.fn(), + terminateOwnedProcessTree: vi.fn(), +})); + +vi.mock('../browser/runtime.js', () => ({ + captureAgentBrowserProcessIdentity: mocks.captureAgentBrowserProcessIdentity, +})); +vi.mock('../browser/session.js', () => ({ closeBrowser: mocks.closeBrowser })); +vi.mock('../browser/capture.js', () => ({ stopRecording: mocks.stopRecording })); +vi.mock('../utils/process.js', () => ({ + ownedProcessTreeIsAlive: mocks.ownedProcessTreeIsAlive, + processIdentityMatches: mocks.processIdentityMatches, + terminateOwnedProcessTree: mocks.terminateOwnedProcessTree, +})); + +import { + canAddressOwnedBrowserSession, + cleanupFailedStart, + stopOwnedBrowser, +} from './lifecycle.js'; + +const persistedIdentity = { + pid: 12001, + processGroupId: 12001, + sessionId: 12001, + startTime: 'original-start', +}; + +function session(browserProcess: typeof persistedIdentity | null = persistedIdentity): any { + return { + sessionName: 'ps-owned-session', + agentBrowserSocketDir: '/run/user/1000/proofshot/agent-browser', + browserProcess, + serverProcess: null, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.processIdentityMatches.mockReturnValue(true); + mocks.ownedProcessTreeIsAlive.mockReturnValue(false); + mocks.terminateOwnedProcessTree.mockResolvedValue(true); +}); + +describe('owned browser lifecycle', () => { + it('does not address a recycled session name when the persisted identity mismatches', async () => { + mocks.processIdentityMatches.mockReturnValue(false); + const state = session(); + + expect(canAddressOwnedBrowserSession(state)).toBe(false); + await stopOwnedBrowser(state); + await cleanupFailedStart(state); + + expect(mocks.captureAgentBrowserProcessIdentity).not.toHaveBeenCalled(); + expect(mocks.closeBrowser).not.toHaveBeenCalled(); + expect(mocks.stopRecording).not.toHaveBeenCalled(); + expect(mocks.terminateOwnedProcessTree).toHaveBeenCalledWith(persistedIdentity); + }); + + it('allows a matching persisted identity and legacy state captured from its PID file', async () => { + const legacyIdentity = { + ...persistedIdentity, + pid: 12002, + processGroupId: 12002, + sessionId: 12002, + }; + mocks.captureAgentBrowserProcessIdentity.mockReturnValue(legacyIdentity); + + expect(canAddressOwnedBrowserSession(session())).toBe(true); + await stopOwnedBrowser(session()); + await stopOwnedBrowser(session(null)); + + expect(mocks.closeBrowser).toHaveBeenNthCalledWith(1, 'ps-owned-session'); + expect(mocks.closeBrowser).toHaveBeenNthCalledWith(2, 'ps-owned-session'); + expect(mocks.terminateOwnedProcessTree).toHaveBeenNthCalledWith(1, persistedIdentity); + expect(mocks.terminateOwnedProcessTree).toHaveBeenNthCalledWith(2, legacyIdentity); + }); +}); diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts new file mode 100644 index 0000000..ad137b7 --- /dev/null +++ b/src/session/lifecycle.ts @@ -0,0 +1,78 @@ +import { stopRecording } from '../browser/capture.js'; +import { + captureAgentBrowserProcessIdentity, +} from '../browser/runtime.js'; +import { closeBrowser } from '../browser/session.js'; +import { + ownedProcessTreeIsAlive, + processIdentityMatches, + terminateOwnedProcessTree, + type ProcessIdentity, +} from '../utils/process.js'; +import type { SessionState } from './state.js'; + +function resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null { + return ( + session.browserProcess || + (session.agentBrowserSocketDir + ? captureAgentBrowserProcessIdentity( + session.agentBrowserSocketDir, + session.sessionName, + ) + : null) + ); +} + +/** + * Whether it is safe to address this agent-browser session by socket/name. + * Persisted immutable identity always wins: a mismatched PID must never fall + * back to a possibly reused session-name PID file. Legacy state without an + * identity may adopt the exact current identity from that file. + */ +export function canAddressOwnedBrowserSession(session: SessionState): boolean { + const identity = resolveOwnedBrowserIdentity(session); + return Boolean(identity && processIdentityMatches(identity)); +} + +export async function stopOwnedBrowser(session: SessionState): Promise { + const identity = resolveOwnedBrowserIdentity(session); + + // The graceful CLI command is name/socket addressed, so issue it only while + // the persisted immutable identity still matches. Exact tree termination + // below remains safe when the leader has exited or its PID was recycled. + if (identity && processIdentityMatches(identity)) { + closeBrowser(session.sessionName); + } + await terminateOwnedProcessTree(identity); + if (identity && ownedProcessTreeIsAlive(identity)) { + throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`); + } +} + +export async function stopOwnedServer(session: SessionState): Promise { + await terminateOwnedProcessTree(session.serverProcess); + if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) { + throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`); + } +} + +export async function cleanupFailedStart(session: SessionState): Promise { + // Recording may have started even when its CLI call returned an error. Both + // operations are session-scoped and best effort. Never address a session + // name unless its daemon still has the identity captured by this start. + if (canAddressOwnedBrowserSession(session)) { + stopRecording(session.sessionName); + } + let cleanupError: unknown; + try { + await stopOwnedBrowser(session); + } catch (error) { + cleanupError = error; + } + try { + await stopOwnedServer(session); + } catch (error) { + cleanupError ||= error; + } + if (cleanupError) throw cleanupError; +} diff --git a/src/session/state.test.ts b/src/session/state.test.ts index 9a3409b..1108f99 100644 --- a/src/session/state.test.ts +++ b/src/session/state.test.ts @@ -2,15 +2,28 @@ import { describe, expect, it } from 'vitest'; import { generateAgentBrowserSessionName } from './state.js'; describe('generateAgentBrowserSessionName', () => { - it('prefixes ProofShot session names consistently', () => { - expect(generateAgentBrowserSessionName('2026-04-07_22-30-00')).toBe( - 'proofshot-2026-04-07_22-30-00', + it('creates a short, deterministic name when a nonce is supplied', () => { + const name = generateAgentBrowserSessionName('2026-04-07_22-30-00', 'test-nonce'); + expect(name).toMatch(/^ps-2026-04-[a-f0-9]{12}$/); + expect(name).toBe( + generateAgentBrowserSessionName('2026-04-07_22-30-00', 'test-nonce'), ); + expect(name.length).toBeLessThanOrEqual(24); }); - it('normalizes unsafe characters', () => { - expect(generateAgentBrowserSessionName("April 7 review / O'Connor")).toBe( - 'proofshot-april-7-review-o-connor', + it('normalizes unsafe characters and keeps concurrent runs collision-safe', () => { + const first = generateAgentBrowserSessionName("April 7 review / O'Connor", 'one'); + const second = generateAgentBrowserSessionName("April 7 review / O'Connor", 'two'); + expect(first).toMatch(/^ps-april-7-[a-f0-9]{12}$/); + expect(second).not.toBe(first); + expect(first).not.toMatch(/[^a-z0-9_-]/); + }); + + it('does not expose a long seed in the socket-facing name', () => { + expect( + generateAgentBrowserSessionName('x'.repeat(500), 'bounded'), + ).toMatch( + /^ps-x{8}-[a-f0-9]{12}$/, ); }); }); diff --git a/src/session/state.ts b/src/session/state.ts index 7cbccf9..be91c22 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -1,5 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { createHash, randomUUID } from 'crypto'; +import type { ProcessIdentity } from '../utils/process.js'; const SESSION_FILENAME = '.session.json'; @@ -15,23 +17,54 @@ export interface SessionState { serverCommand: string | null; serverAlreadyRunning: boolean; recordingActive: boolean; + bundleComplete?: boolean; + browserRetained?: boolean; + videoTrimComplete?: boolean; + trimOffsetSec?: number; + sessionLogAdjusted?: boolean; + consoleEvidenceAvailable?: boolean; + consoleErrorCount?: number; + targetUrl?: string; + agentBrowserSocketDir?: string; + agentBrowserConfigPath?: string; + serverProcess?: ProcessIdentity | null; + browserProcess?: ProcessIdentity | null; viewport?: { width: number; height: number }; } +/** + * Resolve the stable control directory for a project. + * + * CLI-only `--output` overrides choose where evidence is written, but active + * control state remains in the configured/default output directory so a later + * `proofshot exec` or `proofshot stop` process can always find it. + */ +export function resolveSessionControlDir( + configuredOutput: string, + cwd = process.cwd(), +): string { + return path.resolve(cwd, configuredOutput); +} + /** * Write session state to disk. */ -export function saveSession(state: SessionState): void { - const sessionPath = path.join(state.outputDir, SESSION_FILENAME); - fs.writeFileSync(sessionPath, JSON.stringify(state, null, 2) + '\n'); +export function saveSession(state: SessionState, controlDir = state.outputDir): void { + fs.mkdirSync(controlDir, { recursive: true }); + const sessionPath = path.join(controlDir, SESSION_FILENAME); + const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\n', { + mode: 0o600, + }); + fs.renameSync(temporaryPath, sessionPath); } /** * Read session state from disk. * Returns null if no active session. */ -export function loadSession(outputDir: string): SessionState | null { - const sessionPath = path.join(outputDir, SESSION_FILENAME); +export function loadSession(controlDir: string): SessionState | null { + const sessionPath = path.join(controlDir, SESSION_FILENAME); if (!fs.existsSync(sessionPath)) return null; try { return JSON.parse(fs.readFileSync(sessionPath, 'utf-8')); @@ -43,15 +76,15 @@ export function loadSession(outputDir: string): SessionState | null { /** * Check if a session is currently active. */ -export function hasActiveSession(outputDir: string): boolean { - return fs.existsSync(path.join(outputDir, SESSION_FILENAME)); +export function hasActiveSession(controlDir: string): boolean { + return fs.existsSync(path.join(controlDir, SESSION_FILENAME)); } /** * Delete the session state file (called after stop). */ -export function clearSession(outputDir: string): void { - const sessionPath = path.join(outputDir, SESSION_FILENAME); +export function clearSession(controlDir: string): void { + const sessionPath = path.join(controlDir, SESSION_FILENAME); if (fs.existsSync(sessionPath)) { fs.unlinkSync(sessionPath); } @@ -60,12 +93,20 @@ export function clearSession(outputDir: string): void { /** * Generate a deterministic agent-browser session name for a ProofShot run. */ -export function generateAgentBrowserSessionName(seed: string): string { +export function generateAgentBrowserSessionName( + seed: string, + nonce = randomUUID(), +): string { const normalized = seed .toLowerCase() .replace(/[^a-z0-9-_]+/g, '-') .replace(/^-+|-+$/g, '') - .slice(0, 48); + .slice(0, 8) + .replace(/-+$/g, ''); + const digest = createHash('sha256') + .update(`${seed}\0${nonce}`) + .digest('hex') + .slice(0, 12); - return normalized ? `proofshot-${normalized}` : 'proofshot'; + return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`; } diff --git a/src/utils/exec.ts b/src/utils/exec.ts index a864017..0dfac1b 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -14,17 +14,27 @@ export class ProofShotError extends Error { export interface AgentBrowserCommandOptions { configPath?: string; session?: string; + socketDir?: string; timeoutMs?: number; } -let defaultAgentBrowserOptions: Pick = {}; +let defaultAgentBrowserOptions: Pick = {}; export function setAgentBrowserDefaults( - options: Pick, + options: Pick, ): void { defaultAgentBrowserOptions = { ...options }; } +export function getAgentBrowserEnvironment( + options: Pick = {}, +): NodeJS.ProcessEnv { + const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir; + return socketDir + ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir } + : { ...process.env }; +} + function shellQuote(value: string): string { const escaped = value.replace(/'/g, "'\\''"); return `'${escaped}'`; @@ -62,6 +72,7 @@ export function ab( encoding: 'utf-8', timeout: options.timeoutMs ?? 30000, stdio: ['pipe', 'pipe', 'pipe'], + env: getAgentBrowserEnvironment(options), }).trim(); } catch (error: any) { const stderr = error?.stderr?.toString?.() || ''; diff --git a/src/utils/process.test.ts b/src/utils/process.test.ts index 8d73db5..d88e169 100644 --- a/src/utils/process.test.ts +++ b/src/utils/process.test.ts @@ -1,11 +1,32 @@ +import * as fs from 'fs'; +import { spawn } from 'child_process'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + captureProcessIdentity, findExecutablePath, getShellExecutable, + parseLinuxProcStat, parseWindowsNetstatOutput, readCommandVersion, + terminateOwnedProcessTree, } from './process.js'; +function waitForExit(pid: number, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const poll = () => { + if (!captureProcessIdentity(pid)) { + resolve(); + } else if (Date.now() >= deadline) { + reject(new Error(`process ${pid} did not exit`)); + } else { + setTimeout(poll, 25); + } + }; + poll(); + }); +} + describe('getShellExecutable', () => { it('uses cmd.exe on Windows when ComSpec is missing', () => { expect(getShellExecutable('win32', {})).toBe('cmd.exe'); @@ -62,3 +83,53 @@ describe('readCommandVersion', () => { expect(execSpy).toHaveBeenCalledWith('ffmpeg --version', expect.any(Object)); }); }); + +describe('process ownership', () => { + it('parses immutable Linux ownership fields', () => { + if (process.platform !== 'linux') return; + const stat = fs.readFileSync(`/proc/${process.pid}/stat`, 'utf-8'); + const identity = parseLinuxProcStat(stat); + expect(identity).toMatchObject({ pid: process.pid }); + expect(identity?.processGroupId).toBeGreaterThan(0); + expect(identity?.sessionId).toBeGreaterThan(0); + expect(identity?.startTime).toMatch(/^\d+$/); + }); + + it('terminates only the exact detached process session it owns', async () => { + if (process.platform === 'win32') return; + const owned = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: 'ignore', + }); + const unrelated = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: 'ignore', + }); + owned.unref(); + unrelated.unref(); + + const ownedIdentity = captureProcessIdentity(owned.pid!); + const unrelatedIdentity = captureProcessIdentity(unrelated.pid!); + expect(ownedIdentity?.sessionId).toBe(owned.pid); + expect(unrelatedIdentity?.sessionId).toBe(unrelated.pid); + + try { + await expect( + terminateOwnedProcessTree(ownedIdentity, { graceMs: 200 }), + ).resolves.toBe(true); + await waitForExit(owned.pid!); + expect(captureProcessIdentity(unrelated.pid!)).not.toBeNull(); + + await expect( + terminateOwnedProcessTree( + ownedIdentity && { ...ownedIdentity, startTime: `${ownedIdentity.startTime}-reused` }, + { graceMs: 20 }, + ), + ).resolves.toBe(false); + expect(captureProcessIdentity(unrelated.pid!)).not.toBeNull(); + } finally { + await terminateOwnedProcessTree(unrelatedIdentity, { graceMs: 200 }); + await waitForExit(unrelated.pid!); + } + }); +}); diff --git a/src/utils/process.ts b/src/utils/process.ts index 7d13cb5..a4a709d 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -1,7 +1,34 @@ -import { execSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process'; +import * as fs from 'fs'; +import { + execFileSync, + execSync, + spawn, + type ChildProcess, + type SpawnOptions, +} from 'child_process'; type ExecSyncLike = typeof execSync; +/** + * Immutable identity for a process which started an isolated process session. + * + * A PID alone is not sufficient ownership proof because the operating system + * can reuse it. `startTime` lets cleanup reject a recycled PID, while the + * process/session group ids let ProofShot terminate only descendants created + * by the detached process it started. + */ +export interface ProcessIdentity { + pid: number; + processGroupId: number; + sessionId: number; + startTime: string; +} + +export interface TerminateProcessTreeOptions { + graceMs?: number; + pollIntervalMs?: number; +} + export function getShellExecutable( platform = process.platform, env: NodeJS.ProcessEnv = process.env, @@ -23,6 +50,215 @@ export function spawnShellCommand( }); } +/** Parse the ownership fields from Linux `/proc//stat`. */ +export function parseLinuxProcStat(stat: string): ProcessIdentity | null { + 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 }; +} + +/** + * Capture the current immutable identity for a process. + * Returns null when the process is already gone or cannot be inspected. + */ +export function captureProcessIdentity(pid: number): ProcessIdentity | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + + if (process.platform === 'linux') { + try { + return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8')); + } catch { + return null; + } + } + + if (process.platform !== 'win32') { + try { + const output = execFileSync( + 'ps', + ['-o', 'pgid=', '-o', 'sid=', '-o', 'lstart=', '-p', String(pid)], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, + ).trim(); + const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/); + if (!match) return null; + return { + pid, + processGroupId: Number(match[1]), + sessionId: Number(match[2]), + startTime: match[3], + }; + } catch { + return null; + } + } + + // Windows has no /proc-style start token available through Node. Keep the + // identity scoped to the exact PID; taskkill below still targets only it and + // its descendants rather than using a command-name match. + try { + process.kill(pid, 0); + return { pid, processGroupId: pid, sessionId: pid, startTime: `pid:${pid}` }; + } catch { + return null; + } +} + +export function processIdentityMatches(identity: ProcessIdentity): boolean { + const current = captureProcessIdentity(identity.pid); + return Boolean(current && identitiesMatch(current, identity)); +} + +function identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean { + return ( + left.pid === right.pid && + left.processGroupId === right.processGroupId && + left.sessionId === right.sessionId && + left.startTime === right.startTime + ); +} + +function listProcessGroupsInSession(sessionId: number): number[] { + const groups = new Set(); + + if (process.platform === 'linux') { + let entries: string[] = []; + try { + entries = fs.readdirSync('/proc'); + } catch { + return []; + } + + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + try { + const identity = parseLinuxProcStat( + fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'), + ); + if (identity?.sessionId === sessionId) { + groups.add(identity.processGroupId); + } + } catch { + // The process may exit while /proc is being scanned. + } + } + return [...groups]; + } + + if (process.platform !== 'win32') { + try { + const output = execFileSync('ps', ['-axo', 'pgid=,sid='], { + 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]; +} + +export function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean { + if (process.platform === 'win32') return processIdentityMatches(identity); + + const current = captureProcessIdentity(identity.pid); + if (current && !identitiesMatch(current, identity)) return false; + + return listProcessGroupsInSession(identity.sessionId).length > 0; +} + +function signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean { + if (process.platform === 'win32') return false; + + const current = captureProcessIdentity(identity.pid); + if (current && !identitiesMatch(current, identity)) return false; + + // Detached children created by ProofShot are session leaders. If that leader + // has already exited, its session id cannot be reused while descendants from + // that session remain, so scanning the recorded session stays ownership-safe. + if (identity.sessionId !== identity.pid) 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 { + // A group can exit between discovery and signalling. + } + } + return signalled; +} + +/** + * Terminate only the detached process session represented by `identity`. + * Missing/already-dead processes are an idempotent no-op. A recycled PID is + * rejected rather than widening cleanup to a name or port match. + */ +export async function terminateOwnedProcessTree( + identity: ProcessIdentity | null | undefined, + options: TerminateProcessTreeOptions = {}, +): Promise { + 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((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + if (ownedProcessTreeIsAlive(identity)) { + signalOwnedTree(identity, 'SIGKILL'); + const killDeadline = Date.now() + 500; + while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + return true; +} + export function parseWindowsNetstatOutput(output: string, port: number): number[] { const pids = new Set(); From f71514bf0dbe6a7c754a740ce7d09ba7b181b95a Mon Sep 17 00:00:00 2001 From: justinTM <9123665+justinTM@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:47:14 -0700 Subject: [PATCH 02/21] fix: close isolated session review gaps --- src/commands/lifecycle.integration.test.ts | 10 +++-- src/commands/stop.test.ts | 43 ++++++++++++++++++++++ src/commands/stop.ts | 41 +++++++++++++++++---- src/utils/config.test.ts | 12 ++++++ src/utils/config.ts | 4 ++ src/utils/process.ts | 17 ++++++--- 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/commands/lifecycle.integration.test.ts b/src/commands/lifecycle.integration.test.ts index 2ee54f8..657756a 100644 --- a/src/commands/lifecycle.integration.test.ts +++ b/src/commands/lifecycle.integration.test.ts @@ -291,7 +291,9 @@ describe('isolated CLI lifecycle', () => { expect(processIsAlive(ownedServerPid)).toBe(true); expect(processIsAlive(unrelated.pid!)).toBe(true); - const execResult = runCli(audit, env, ['exec', 'get', 'url']); + const nestedCwd = path.join(audit, 'nested', 'consumer'); + fs.mkdirSync(nestedCwd, { recursive: true }); + const execResult = runCli(nestedCwd, env, ['exec', 'get', 'url']); expect(execResult.status, `${execResult.stdout}\n${execResult.stderr}`).toBe(0); expect(execResult.stdout.trim()).toBe(intendedUrl); expect(execResult.stdout).not.toContain('about:blank'); @@ -316,7 +318,7 @@ describe('isolated CLI lifecycle', () => { }, }; fs.writeFileSync(controlPath, JSON.stringify(mismatchedState, null, 2) + '\n'); - const mismatchedExec = runCli(audit, env, ['exec', 'get', 'url']); + const mismatchedExec = runCli(nestedCwd, env, ['exec', 'get', 'url']); expect(mismatchedExec.status).toBe(1); expect(mismatchedExec.stderr).toContain( 'Browser ownership no longer matches this ProofShot session', @@ -326,7 +328,7 @@ describe('isolated CLI lifecycle', () => { ); fs.writeFileSync(controlPath, JSON.stringify(state, null, 2) + '\n'); - const stop = runCli(audit, env, ['stop']); + const stop = runCli(nestedCwd, env, ['stop']); expect(stop.status, `${stop.stdout}\n${stop.stderr}`).toBe(0); expect(fs.existsSync(controlPath)).toBe(false); await waitForProcessExit(ownedServerPid); @@ -341,7 +343,7 @@ describe('isolated CLI lifecycle', () => { const summaryMtimeBefore = fs.statSync(summaryPath).mtimeMs; const browserLogBefore = fs.readFileSync(tools.browserLog, 'utf-8'); - const secondStop = runCli(audit, env, ['stop']); + const secondStop = runCli(nestedCwd, env, ['stop']); expect(secondStop.status, `${secondStop.stdout}\n${secondStop.stderr}`).toBe(0); expect(secondStop.stdout).toContain('already stopped'); expect(fs.readFileSync(summaryPath, 'utf-8')).toBe(summaryBefore); diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index b0e68d7..56dedfc 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -176,6 +176,49 @@ describe('stopCommand retryability', () => { expect(fs.statSync(summaryPath).mtimeMs).toBe(summaryMtimeBefore); }); + it('persists collected console evidence before cleanup failure and reuses it', async () => { + mocks.getConsoleErrors.mockReturnValue('synthetic console failure'); + mocks.getConsoleOutput.mockReturnValue('captured before cleanup'); + mocks.getConsoleOutputJson.mockReturnValue([ + { type: 'error', text: 'synthetic console failure', timestamp: Date.now() }, + ]); + mocks.stopOwnedServer.mockRejectedValueOnce(new Error('simulated server cleanup failure')); + + await expect(stopCommand({})).rejects.toThrow('simulated server cleanup failure'); + + expect(session).toMatchObject({ + recordingActive: false, + consoleEvidenceAvailable: true, + consoleErrorCount: 1, + }); + expect(fs.readFileSync(path.join(session.sessionDir, 'console-errors.log'), 'utf-8')).toBe( + 'synthetic console failure', + ); + expect(fs.readFileSync(path.join(session.sessionDir, 'console-output.log'), 'utf-8')).toBe( + 'captured before cleanup', + ); + + mocks.canAddressOwnedBrowserSession.mockReturnValue(false); + mocks.stopOwnedServer.mockResolvedValue(undefined); + mocks.writeViewer.mockReturnValue(path.join(session.sessionDir, 'viewer.html')); + await stopCommand({}); + + expect(mocks.writeViewer).toHaveBeenCalledWith( + session.sessionDir, + expect.objectContaining({ + consoleEvidenceAvailable: true, + consoleErrorCount: 1, + consoleOutput: 'captured before cleanup', + consoleEntries: [ + expect.objectContaining({ text: '[error] synthetic console failure' }), + ], + }), + ); + const summary = fs.readFileSync(path.join(session.sessionDir, 'SUMMARY.md'), 'utf-8'); + expect(summary).toContain('1 error(s) detected'); + expect(summary).toContain('synthetic console failure'); + }); + it('skips every session-addressed browser command when identity is mismatched', async () => { mocks.canAddressOwnedBrowserSession.mockReturnValue(false); mocks.writeViewer.mockReturnValue(path.join(session.sessionDir, 'viewer.html')); diff --git a/src/commands/stop.ts b/src/commands/stop.ts index a32261e..a407a91 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -132,6 +132,9 @@ export async function stopCommand(options: StopOptions): Promise { let consoleErrors = ''; let consoleOutput = ''; let consoleEntries: TimestampedLogEntry[] = []; + const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log'); + const consoleOutputPath = path.join(session.sessionDir, 'console-output.log'); + const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json'); if (browserSessionAvailable) { try { consoleErrors = getConsoleErrors(session.sessionName); @@ -145,15 +148,37 @@ export async function stopCommand(options: StopOptions): Promise { } catch { // Browser may already be closed } - } - - // Write console output to file (before closing browser) - if (consoleOutput.trim()) { - fs.writeFileSync(path.join(session.sessionDir, 'console-output.log'), consoleOutput); + writeTextFileAtomically(consoleErrorsPath, consoleErrors); + writeTextFileAtomically(consoleOutputPath, consoleOutput); + writeTextFileAtomically( + consoleEntriesPath, + JSON.stringify(consoleEntries, null, 2) + '\n', + ); + const capturedErrorLines = consoleErrors + .split('\n') + .filter((line) => line.trim() && line.trim() !== 'No errors'); + session.consoleEvidenceAvailable = true; + session.consoleErrorCount = + capturedErrorLines.length > 0 && consoleErrors.trim() !== '' + ? capturedErrorLines.length + : 0; + // Persist evidence before any cleanup step can fail. A retry must not turn + // successfully collected browser facts into an "unavailable" claim. + saveSession(session, controlDir); } else if (priorConsoleEvidenceAvailable) { - const savedConsoleOutput = path.join(session.sessionDir, 'console-output.log'); - if (fs.existsSync(savedConsoleOutput)) { - consoleOutput = fs.readFileSync(savedConsoleOutput, 'utf-8'); + if (fs.existsSync(consoleErrorsPath)) { + consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8'); + } + if (fs.existsSync(consoleOutputPath)) { + consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8'); + } + if (fs.existsSync(consoleEntriesPath)) { + try { + const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8')); + if (Array.isArray(savedEntries)) consoleEntries = savedEntries; + } catch { + // Keep the persisted availability/count; only the optional timeline is absent. + } } } diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts index 5bd1bfd..4ed95a4 100644 --- a/src/utils/config.test.ts +++ b/src/utils/config.test.ts @@ -40,4 +40,16 @@ describe('loadConfig', () => { ignoreHttpsErrors: false, }); }); + + it('resolves control output against the ancestor config from a subdirectory', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proofshot-output-path-')); + const nested = path.join(tempDir, 'nested', 'consumer'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'proofshot.config.json'), + JSON.stringify({ output: './project-proof' }), + ); + + expect(loadConfig(nested).output).toBe(path.join(tempDir, 'project-proof')); + }); }); diff --git a/src/utils/config.ts b/src/utils/config.ts index 4209ceb..b0d4a37 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -77,6 +77,10 @@ export function loadConfig(startDir?: string): ProofShotConfig { return { ...DEFAULT_CONFIG, ...parsed, + output: path.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, diff --git a/src/utils/process.ts b/src/utils/process.ts index a4a709d..2a9f5e6 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -108,12 +108,19 @@ export function captureProcessIdentity(pid: number): ProcessIdentity | null { } } - // Windows has no /proc-style start token available through Node. Keep the - // identity scoped to the exact PID; taskkill below still targets only it and - // its descendants rather than using a command-name match. + // PowerShell exposes the process creation timestamp. If that immutable token + // cannot be read, refuse ownership instead of treating a reusable PID as + // sufficient proof for taskkill /T. try { - process.kill(pid, 0); - return { pid, processGroupId: pid, sessionId: pid, startTime: `pid:${pid}` }; + 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; } From f5ceeba5cc3e534a85feec1834b313e9ed23d72f Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:38:55 -0600 Subject: [PATCH 03/21] fix(lifecycle): make isolated process ownership portable Use exact process-group ownership on macOS, retain session ownership on Linux, and add pull-request build/test coverage so the PR 45 baseline is verifiable across supported Unix hosts. Co-authored-by: cursoragent --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++ src/browser/runtime.ts | 5 ++- src/server/start.test.ts | 9 +++-- src/server/start.ts | 3 +- src/utils/process.test.ts | 20 +++++++++- src/utils/process.ts | 79 +++++++++++++++++++++++++++++++++------ 6 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b4c9e79 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +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 + + - name: Run tests + run: npm test diff --git a/src/browser/runtime.ts b/src/browser/runtime.ts index b106d3f..ef352ce 100644 --- a/src/browser/runtime.ts +++ b/src/browser/runtime.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { captureProcessIdentity, + isDetachedProcessIdentity, type ProcessIdentity, } from '../utils/process.js'; @@ -52,7 +53,7 @@ export function prepareAgentBrowserSocketDir( ? path.resolve(explicit) : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR ? path.join(runtimeRoot, 'proofshot', 'agent-browser') - : path.join(runtimeRoot, '.cache', 'proofshot', 'run', 'agent-browser'); + : path.join('/tmp', `proofshot-${uid}`, 'agent-browser'); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); assertOwnedDirectory(directory); @@ -81,7 +82,7 @@ export function captureAgentBrowserProcessIdentity( const pidPath = path.join(socketDir, `${sessionName}.pid`); const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim()); const identity = captureProcessIdentity(pid); - if (!identity || identity.sessionId !== identity.pid) return null; + if (!identity || !isDetachedProcessIdentity(identity)) return null; return identity; } catch { return null; diff --git a/src/server/start.test.ts b/src/server/start.test.ts index 206567c..e10abfd 100644 --- a/src/server/start.test.ts +++ b/src/server/start.test.ts @@ -4,7 +4,11 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import { isPortOpen } from '../utils/port.js'; -import { terminateOwnedProcessTree, type ProcessIdentity } from '../utils/process.js'; +import { + isDetachedProcessIdentity, + terminateOwnedProcessTree, + type ProcessIdentity, +} from '../utils/process.js'; import { ensureDevServer } from './start.js'; const roots: string[] = []; @@ -82,8 +86,7 @@ describe('ensureDevServer', () => { ); ownedProcesses.push(result.process); - expect(result.process.processGroupId).toBe(result.process.pid); - expect(result.process.sessionId).toBe(result.process.pid); + expect(isDetachedProcessIdentity(result.process)).toBe(true); expect(fs.readFileSync(logPath, 'utf-8')).toMatch(/^\d{13}\tserver-ready$/m); await terminateOwnedProcessTree(result.process, { graceMs: 300 }); diff --git a/src/server/start.ts b/src/server/start.ts index 499ce7f..c5d54bb 100644 --- a/src/server/start.ts +++ b/src/server/start.ts @@ -4,6 +4,7 @@ import { isPortOpen, waitForPort } from '../utils/port.js'; import { captureProcessIdentity, getShellExecutable, + isDetachedProcessIdentity, terminateOwnedProcessTree, terminateProcessTree, type ProcessIdentity, @@ -96,7 +97,7 @@ export async function ensureDevServer( processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null; } - if (!processIdentity || processIdentity.sessionId !== processIdentity.pid) { + if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) { try { if (proc.pid) terminateProcessTree(proc.pid); } catch { diff --git a/src/utils/process.test.ts b/src/utils/process.test.ts index d88e169..3b81b50 100644 --- a/src/utils/process.test.ts +++ b/src/utils/process.test.ts @@ -5,7 +5,9 @@ import { captureProcessIdentity, findExecutablePath, getShellExecutable, + isDetachedProcessIdentity, parseLinuxProcStat, + parseUnixProcessIdentity, parseWindowsNetstatOutput, readCommandVersion, terminateOwnedProcessTree, @@ -95,6 +97,20 @@ describe('process ownership', () => { expect(identity?.startTime).toMatch(/^\d+$/); }); + it('parses macOS process ownership fields', () => { + expect( + parseUnixProcessIdentity( + 321, + ' 321 0 Sun Aug 9 19:35:36 2026 \n', + ), + ).toEqual({ + pid: 321, + processGroupId: 321, + sessionId: 0, + startTime: 'Sun Aug 9 19:35:36 2026', + }); + }); + it('terminates only the exact detached process session it owns', async () => { if (process.platform === 'win32') return; const owned = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { @@ -110,8 +126,8 @@ describe('process ownership', () => { const ownedIdentity = captureProcessIdentity(owned.pid!); const unrelatedIdentity = captureProcessIdentity(unrelated.pid!); - expect(ownedIdentity?.sessionId).toBe(owned.pid); - expect(unrelatedIdentity?.sessionId).toBe(unrelated.pid); + expect(ownedIdentity && isDetachedProcessIdentity(ownedIdentity)).toBe(true); + expect(unrelatedIdentity && isDetachedProcessIdentity(unrelatedIdentity)).toBe(true); try { await expect( diff --git a/src/utils/process.ts b/src/utils/process.ts index 2a9f5e6..80a73bd 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -73,6 +73,44 @@ export function parseLinuxProcStat(stat: string): ProcessIdentity | null { return { pid, processGroupId, sessionId, startTime }; } +/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */ +export function parseUnixProcessIdentity( + pid: number, + output: string, +): ProcessIdentity | null { + 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 }; +} + +/** + * Detached children are session leaders on Linux and process-group leaders on + * macOS, whose ps implementation reports a zero session id. + */ +export function isDetachedProcessIdentity( + identity: ProcessIdentity, + platform = process.platform, +): boolean { + if (platform === 'darwin') { + return identity.processGroupId === identity.pid; + } + return identity.sessionId === identity.pid; +} + /** * Capture the current immutable identity for a process. * Returns null when the process is already gone or cannot be inspected. @@ -90,19 +128,13 @@ export function captureProcessIdentity(pid: number): ProcessIdentity | null { if (process.platform !== 'win32') { try { + const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid='; const output = execFileSync( 'ps', - ['-o', 'pgid=', '-o', 'sid=', '-o', 'lstart=', '-p', String(pid)], + ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, - ).trim(); - const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/); - if (!match) return null; - return { - pid, - processGroupId: Number(match[1]), - sessionId: Number(match[2]), - startTime: match[3], - }; + ); + return parseUnixProcessIdentity(pid, output); } catch { return null; } @@ -169,7 +201,8 @@ function listProcessGroupsInSession(sessionId: number): number[] { if (process.platform !== 'win32') { try { - const output = execFileSync('ps', ['-axo', 'pgid=,sid='], { + const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid='; + const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); @@ -187,12 +220,24 @@ function listProcessGroupsInSession(sessionId: number): number[] { return [...groups]; } +function processGroupIsAlive(processGroupId: number): boolean { + try { + process.kill(-processGroupId, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + export function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean { if (process.platform === 'win32') return processIdentityMatches(identity); const current = captureProcessIdentity(identity.pid); if (current && !identitiesMatch(current, identity)) return false; + if (process.platform === 'darwin') { + return processGroupIsAlive(identity.processGroupId); + } return listProcessGroupsInSession(identity.sessionId).length > 0; } @@ -202,10 +247,20 @@ function signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boo const current = captureProcessIdentity(identity.pid); if (current && !identitiesMatch(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; + } + } + // Detached children created by ProofShot are session leaders. If that leader // has already exited, its session id cannot be reused while descendants from // that session remain, so scanning the recorded session stays ownership-safe. - if (identity.sessionId !== identity.pid) return false; const groups = listProcessGroupsInSession(identity.sessionId); if (groups.length === 0) return false; From edf9decc8a5240b900933bc17db4f6dae28706f0 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:40:09 -0600 Subject: [PATCH 04/21] fix(artifacts): preserve valid recorded evidence Persist the actual browser mode and viewport, and validate trimmed media before replacing the original so failed or empty FFmpeg output cannot destroy usable proof. Co-authored-by: cursoragent --- src/commands/start.ts | 2 + src/commands/stop.test.ts | 121 +++++++++++++++++++++++++++++++++++--- src/commands/stop.ts | 66 +++++++++++++++------ src/session/state.ts | 2 + 4 files changed, 165 insertions(+), 26 deletions(-) diff --git a/src/commands/start.ts b/src/commands/start.ts index 5cb1dba..671de4e 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -129,6 +129,7 @@ export async function startCommand(options: StartOptions): Promise { const openUrl = options.url || baseUrl; const session: SessionState = { startedAt: new Date().toISOString(), + startDirectory: process.cwd(), description: options.description || null, outputDir, sessionDir, @@ -147,6 +148,7 @@ export async function startCommand(options: StartOptions): Promise { consoleEvidenceAvailable: false, consoleErrorCount: 0, targetUrl: openUrl, + headless: config.headless, agentBrowserSocketDir: socketDir, agentBrowserConfigPath: config.browser.configPath, serverProcess: null, diff --git a/src/commands/stop.test.ts b/src/commands/stop.test.ts index 56dedfc..889c3c4 100644 --- a/src/commands/stop.test.ts +++ b/src/commands/stop.test.ts @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({ extractServerErrors: vi.fn(), loadSessionLog: vi.fn(), estimateTokenUsage: vi.fn(), - execSync: vi.fn(), + execFileSync: vi.fn(), })); vi.mock('../utils/config.js', () => ({ loadConfig: mocks.loadConfig })); @@ -47,10 +47,15 @@ vi.mock('./exec.js', () => ({ loadSessionLog: mocks.loadSessionLog })); vi.mock('../utils/token-usage.js', () => ({ estimateTokenUsage: mocks.estimateTokenUsage })); vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, execSync: mocks.execSync }; + return { ...actual, execFileSync: mocks.execFileSync }; }); -import { stopCommand } from './stop.js'; +import { + generateProofSummary, + stopCommand, + trimVideo, + type SummaryData, +} from './stop.js'; let root: string; let session: any; @@ -63,6 +68,7 @@ beforeEach(() => { fs.mkdirSync(sessionDir, { recursive: true }); session = { startedAt: new Date(Date.now() - 1000).toISOString(), + startDirectory: path.join(root, 'project'), description: 'retry bundle', outputDir: path.join(root, 'custom-evidence'), sessionDir, @@ -80,12 +86,19 @@ beforeEach(() => { sessionLogAdjusted: false, consoleEvidenceAvailable: false, consoleErrorCount: 0, + headless: false, + viewport: { width: 2560, height: 1440 }, serverProcess: { pid: 1001, processGroupId: 1001, sessionId: 1001, startTime: '1' }, browserProcess: { pid: 1002, processGroupId: 1002, sessionId: 1002, startTime: '2' }, }; fs.writeFileSync(session.serverErrorLog, `${Date.now()}\tserver ready\n`); - mocks.loadConfig.mockReturnValue({ output: './proofshot-artifacts', browser: {} }); + mocks.loadConfig.mockReturnValue({ + output: './proofshot-artifacts', + browser: {}, + headless: true, + viewport: { width: 1280, height: 720 }, + }); mocks.resolveSessionControlDir.mockReturnValue(path.join(root, 'proofshot-artifacts')); mocks.loadSession.mockImplementation(() => session); mocks.getConsoleErrors.mockReturnValue('No errors'); @@ -94,7 +107,7 @@ beforeEach(() => { mocks.extractServerErrors.mockReturnValue([]); mocks.loadSessionLog.mockReturnValue([]); mocks.estimateTokenUsage.mockReturnValue(null); - mocks.execSync.mockReturnValue(''); + mocks.execFileSync.mockReturnValue(''); mocks.stopOwnedBrowser.mockResolvedValue(undefined); mocks.stopOwnedServer.mockResolvedValue(undefined); mocks.canAddressOwnedBrowserSession.mockReturnValue(true); @@ -122,14 +135,15 @@ describe('stopCommand retryability', () => { JSON.parse(fs.readFileSync(sessionLogPath, 'utf-8')), ); let trimCalls = 0; - mocks.execSync.mockImplementation((command: string) => { - if (command === 'ffmpeg -version') return ''; - if (command.startsWith('ffmpeg -i ')) { + mocks.execFileSync.mockImplementation((command: string, args: string[]) => { + if (command === 'ffmpeg' && args[0] === '-version') return ''; + if (command === 'ffmpeg' && args.includes('-abort_on')) { trimCalls += 1; fs.writeFileSync(session.videoPath, `trimmed-video-${trimCalls}`); return ''; } - throw new Error(`unexpected command: ${command}`); + if (command === 'ffmpeg' && args[0] === '-v') return ''; + throw new Error(`unexpected command: ${command} ${args.join(' ')}`); }); mocks.writeViewer.mockImplementationOnce(() => { throw new Error('simulated viewer write failure'); @@ -262,3 +276,92 @@ describe('stopCommand retryability', () => { ); }); }); + +describe('stop artifacts', () => { + it('reports the recorded viewport and browser mode', () => { + const summary = generateProofSummary(buildSummaryData()); + + expect(summary).toContain('**Project:** project'); + expect(summary).toContain('- Browser: Chromium (headed)'); + expect(summary).toContain('- Viewport: 2560x1440'); + }); + + it('restores the original video when trimming leaves partial output', () => { + const videoPath = path.join(root, 'session.webm'); + fs.writeFileSync(videoPath, 'original-video'); + let trimArgs: string[] = []; + + mocks.execFileSync.mockImplementation((_command: string, args: string[]) => { + if (args[0] === '-version') { + return ''; + } + trimArgs = args; + fs.writeFileSync(videoPath, 'partial-video'); + throw new Error('empty output'); + }); + + const trimOffset = trimVideo( + videoPath, + [], + root, + 0, + [ + { action: 'open', relativeTimeSec: 10, timestamp: '2026-07-16T18:00:10.000Z' }, + { action: 'click', relativeTimeSec: 20, timestamp: '2026-07-16T18:00:20.000Z' }, + ], + ); + + expect(trimOffset).toBe(0); + expect(trimArgs).toContain('-abort_on'); + expect(fs.readFileSync(videoPath, 'utf-8')).toBe('original-video'); + expect(fs.existsSync(path.join(root, 'session-raw.webm'))).toBe(false); + }); + + it('restores the original video when FFmpeg exits successfully with empty output', () => { + const videoPath = path.join(root, 'session.webm'); + fs.writeFileSync(videoPath, 'original-video'); + + mocks.execFileSync.mockImplementation((_command: string, args: string[]) => { + if (args[0] === '-version') { + return ''; + } + fs.writeFileSync(videoPath, ''); + return ''; + }); + + const trimOffset = trimVideo( + videoPath, + [], + root, + 0, + [ + { action: 'open', relativeTimeSec: 10, timestamp: '2026-07-16T18:00:10.000Z' }, + { action: 'click', relativeTimeSec: 20, timestamp: '2026-07-16T18:00:20.000Z' }, + ], + ); + + expect(trimOffset).toBe(0); + expect(fs.readFileSync(videoPath, 'utf-8')).toBe('original-video'); + }); +}); + +function buildSummaryData(): SummaryData { + return { + projectDirectory: path.join(root, 'project'), + description: 'artifact verification', + serverCommand: 'npm run dev', + port: 4173, + headless: false, + viewport: { width: 2560, height: 1440 }, + videoPath: path.join(root, 'session.webm'), + screenshots: [], + consoleErrors: '', + consoleErrorCount: 0, + consoleEvidenceAvailable: true, + serverLog: '', + serverErrorCount: 0, + tokenUsage: null, + durationSec: 30, + outputDir: root, + }; +} diff --git a/src/commands/stop.ts b/src/commands/stop.ts index a407a91..48601ba 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import chalk from 'chalk'; import { loadConfig } from '../utils/config.js'; import { setAgentBrowserDefaults } from '../utils/exec.js'; @@ -20,7 +20,7 @@ import { } from '../session/lifecycle.js'; import { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js'; import { extractServerErrors } from '../utils/error-patterns.js'; -import { loadSessionLog } from './exec.js'; +import { loadSessionLog, type SessionLogEntry } from './exec.js'; import { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js'; /** @@ -277,9 +277,12 @@ export async function stopCommand(options: StopOptions): Promise { // Step 7: Generate SUMMARY.md const summaryPath = path.join(sessionDir, 'SUMMARY.md'); const summary = generateProofSummary({ + projectDirectory: session.startDirectory || process.cwd(), description: session.description, serverCommand: session.serverCommand, port: session.port, + headless: session.headless ?? config.headless ?? true, + viewport: session.viewport || config.viewport || { width: 1280, height: 720 }, videoPath: session.videoPath, screenshots, consoleErrors, @@ -418,10 +421,13 @@ function writeTextFileAtomically(filePath: string, contents: string): void { } } -interface SummaryData { +export interface SummaryData { + projectDirectory: string; description: string | null; serverCommand: string | null; port: number; + headless: boolean; + viewport: { width: number; height: number }; videoPath: string; screenshots: string[]; consoleErrors: string; @@ -434,9 +440,9 @@ interface SummaryData { outputDir: string; } -function generateProofSummary(data: SummaryData): string { +export function generateProofSummary(data: SummaryData): string { const date = new Date().toISOString().replace('T', ' ').slice(0, 19); - const projectName = path.basename(process.cwd()); + const projectName = path.basename(data.projectDirectory); let md = `# ProofShot Verification Report @@ -505,8 +511,8 @@ Full session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationS // Environment md += `## Environment -- Browser: Chromium (headless) -- Viewport: 1280x720 +- Browser: Chromium (${data.headless ? 'headless' : 'headed'}) +- Viewport: ${data.viewport.width}x${data.viewport.height} - Duration: ${data.durationSec} seconds `; @@ -522,12 +528,12 @@ Full session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationS * * Buffers: 5s before first action, 3s after last action. */ -function trimVideo( +export function trimVideo( videoPath: string, screenshots: string[], outputDir: string, recordingStartMs: number, - sessionLog: import('./exec.js').SessionLogEntry[], + sessionLog: SessionLogEntry[], ): number { let firstActionSec: number | null = null; let lastActionSec: number | null = null; @@ -567,7 +573,7 @@ function trimVideo( // Check if ffmpeg is available try { - execSync('ffmpeg -version', { stdio: 'pipe' }); + execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' }); } catch { console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.')); return 0; @@ -583,10 +589,25 @@ function trimVideo( // Rename original to -raw fs.renameSync(videoPath, rawPath); - execSync( - `ffmpeg -i "${rawPath}" -ss ${trimStartSec.toFixed(2)} -to ${trimEndSec.toFixed(2)} -c copy "${videoPath}"`, + execFileSync( + 'ffmpeg', + [ + '-y', + '-i', + rawPath, + '-ss', + trimStartSec.toFixed(2), + '-to', + trimEndSec.toFixed(2), + '-c', + 'copy', + '-abort_on', + 'empty_output', + videoPath, + ], { stdio: 'pipe', timeout: 60000 }, ); + validateTrimmedVideo(videoPath); // Remove raw file on success fs.unlinkSync(rawPath); @@ -595,14 +616,25 @@ function trimVideo( return trimStartSec; } catch { // Restore original if trimming failed + if (fs.existsSync(videoPath)) { + fs.unlinkSync(videoPath); + } if (fs.existsSync(rawPath)) { - if (!fs.existsSync(videoPath)) { - fs.renameSync(rawPath, videoPath); - } else { - fs.unlinkSync(rawPath); - } + fs.renameSync(rawPath, videoPath); } console.log(chalk.dim('Video trimming failed, keeping original')); return 0; } } + +function validateTrimmedVideo(videoPath: string): void { + if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) { + throw new Error('FFmpeg produced an empty video'); + } + + execFileSync( + 'ffmpeg', + ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'], + { stdio: 'pipe', timeout: 60000 }, + ); +} diff --git a/src/session/state.ts b/src/session/state.ts index be91c22..184559e 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -7,6 +7,7 @@ const SESSION_FILENAME = '.session.json'; export interface SessionState { startedAt: string; + startDirectory?: string; description: string | null; outputDir: string; sessionDir: string; @@ -25,6 +26,7 @@ export interface SessionState { consoleEvidenceAvailable?: boolean; consoleErrorCount?: number; targetUrl?: string; + headless?: boolean; agentBrowserSocketDir?: string; agentBrowserConfigPath?: string; serverProcess?: ProcessIdentity | null; From 02d12e8f117fd214452a7a40ad6b6b694cfc46cd Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:42:17 -0600 Subject: [PATCH 05/21] fix(packaging): support Git and tarball installs Build Git dependencies when development tooling is present and ship a verified prebuilt fallback so global installs still expose the CLI when npm omits dev dependencies. Co-authored-by: cursoragent --- dist/bin/proofshot.js | 4491 ++++++++++++++++++++++++++++++++++++ dist/bin/proofshot.js.map | 1 + dist/src/index.d.ts | 234 ++ dist/src/index.js | 4509 +++++++++++++++++++++++++++++++++++++ dist/src/index.js.map | 1 + package-lock.json | 4 +- package.json | 3 +- prepare.js | 26 + 8 files changed, 9266 insertions(+), 3 deletions(-) create mode 100755 dist/bin/proofshot.js create mode 100644 dist/bin/proofshot.js.map create mode 100644 dist/src/index.d.ts create mode 100644 dist/src/index.js create mode 100644 dist/src/index.js.map create mode 100644 prepare.js diff --git a/dist/bin/proofshot.js b/dist/bin/proofshot.js new file mode 100755 index 0000000..9c159e0 --- /dev/null +++ b/dist/bin/proofshot.js @@ -0,0 +1,4491 @@ +#!/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 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 console + server errors, and generates +a SUMMARY.md with video, screenshots, and error report. + +### 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 +\`\`\` + +This uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. 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 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 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((resolve10) => { + 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"); + resolve10([]); + return; + } + if (key === "\r" || key === "\n") { + stdin.setRawMode(false); + stdin.removeListener("data", onData); + stdin.pause(); + process.stdout.write("\r\x1B[K\n"); + resolve10(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 path8 from "path"; +import chalk2 from "chalk"; +import { execSync as execSync4 } from "child_process"; + +// 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 + } +}; +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); + const configDir = path3.dirname(configPath); + const resolvedBrowser = { + ...DEFAULT_CONFIG.browser, + ...parsed.browser + }; + if (resolvedBrowser.configPath) { + resolvedBrowser.configPath = path3.resolve(configDir, resolvedBrowser.configPath); + } + 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 + }; + } catch { + return { ...DEFAULT_CONFIG }; + } +} + +// 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 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 { + return parseLinuxProcStat(fs4.readFileSync(`/proc/${pid}/stat`, "utf-8")); + } 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"] } + ); + return parseUnixProcessIdentity(pid, output); + } 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 && identitiesMatch(current, identity)); +} +function identitiesMatch(left, right) { + return left.pid === right.pid && left.processGroupId === right.processGroupId && left.sessionId === right.sessionId && left.startTime === right.startTime; +} +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 && !identitiesMatch(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 && !identitiesMatch(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((resolve10) => setTimeout(resolve10, pollIntervalMs)); + } + if (ownedProcessTreeIsAlive(identity)) { + signalOwnedTree(identity, "SIGKILL"); + const killDeadline = Date.now() + 500; + while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) { + await new Promise((resolve10) => setTimeout(resolve10, pollIntervalMs)); + } + } + 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 socketDir ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir } : { ...process.env }; +} +function shellQuote(value) { + const escaped = value.replace(/'/g, "'\\''"); + return `'${escaped}'`; +} +function buildAgentBrowserCommand(command, options = {}) { + const mergedOptions = { + ...defaultAgentBrowserOptions, + ...options + }; + const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : ""; + const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(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((resolve10) => { + const socket = new net.Socket(); + socket.setTimeout(1e3); + socket.on("connect", () => { + socket.destroy(); + resolve10(true); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve10(false); + }); + socket.on("error", () => { + socket.destroy(); + resolve10(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) { + 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((resolve10) => setTimeout(resolve10, 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."); + } + 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((resolve10) => setTimeout(resolve10, 1e3)); + return { alreadyRunning: false, port, process: processIdentity }; +} + +// 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 ${url}${suffix}`; +} +function openBrowser(url, viewport, headless = true, sessionName, browserConfig) { + ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 6e4, session: sessionName }); + ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName }); +} +function closeBrowser(sessionName) { + try { + ab("close", { session: sessionName }); + } catch { + } +} +function getConsoleErrors(sessionName) { + try { + return ab("errors", { session: sessionName }); + } catch { + return ""; + } +} +function getConsoleOutput(sessionName) { + try { + return ab("console", { session: sessionName }); + } catch { + return ""; + } +} +function getConsoleOutputJson(sessionName) { + try { + const raw = ab("console --json", { session: sessionName }); + const parsed = JSON.parse(raw); + const messages = parsed?.data?.messages ?? parsed; + return Array.isArray(messages) ? messages : []; + } 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 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 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)); + 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"); + for (const home of homes) candidates.push(...cachedBrowserCandidates(home)); + } + 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; + } +} + +// 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 { + return null; + } +} +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/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 && processIdentityMatches(identity)) { + closeBrowser(session.sessionName); + } + await terminateOwnedProcessTree(identity); + if (identity && ownedProcessTreeIsAlive(identity)) { + throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`); + } +} +async function stopOwnedServer(session) { + await terminateOwnedProcessTree(session.serverProcess); + if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) { + throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`); + } +} +async function cleanupFailedStart(session) { + if (canAddressOwnedBrowserSession(session)) { + stopRecording(session.sessionName); + } + let cleanupError; + try { + await stopOwnedBrowser(session); + } catch (error) { + cleanupError = error; + } + try { + await stopOwnedServer(session); + } catch (error) { + cleanupError ||= error; + } + if (cleanupError) throw cleanupError; +} + +// src/session/metadata.ts +import * as fs10 from "fs"; +import * as path7 from "path"; +var METADATA_FILENAME = "metadata.json"; +function writeMetadata(sessionDir, metadata) { + const metadataPath = path7.join(sessionDir, METADATA_FILENAME); + fs10.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + "\n"); +} +function loadMetadata(sessionDir) { + const metadataPath = path7.join(sessionDir, METADATA_FILENAME); + if (!fs10.existsSync(metadataPath)) return null; + try { + return JSON.parse(fs10.readFileSync(metadataPath, "utf-8")); + } catch { + return null; + } +} +function findSessionsForBranch(outputDir, branch) { + if (!fs10.existsSync(outputDir)) return []; + const entries = fs10.readdirSync(outputDir, { withFileTypes: true }); + const matches = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const sessionDir = path7.join(outputDir, entry.name); + const metadata = loadMetadata(sessionDir); + if (metadata && metadata.branch === branch) { + matches.push({ dir: sessionDir, startedAt: metadata.startedAt }); + } + } + matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + return matches.map((m) => m.dir); +} + +// 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); + } + 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 = path8.resolve(config.output); + const timestamp = generateTimestamp(); + const sessionDirName = generateSessionDirName(timestamp, options.description || null); + const sessionDir = path8.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 = path8.join(sessionDir, "session.webm"); + const serverErrorLog = path8.join(sessionDir, "server.log"); + let branch = ""; + let commitSha = ""; + try { + branch = execSync4("git branch --show-current", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch { + } + try { + commitSha = execSync4("git rev-parse HEAD", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch { + } + writeMetadata(sessionDir, { + branch, + commitSha, + 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(), + description: options.description || null, + outputDir, + sessionDir, + sessionName, + videoPath, + serverErrorLog, + port: config.devServer.port, + serverCommand: options.run || null, + serverAlreadyRunning: !options.run, + recordingActive: 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, + viewport: { width: config.viewport.width, height: config.viewport.height } + }; + saveSession(session, controlDir); + let failureContext = "start the session"; + try { + 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 + ); + session.serverAlreadyRunning = false; + session.serverProcess = server.process; + saveSession(session, controlDir); + console.log(chalk2.green("\u2713") + ` Dev server started on :${config.devServer.port}`); + console.log(chalk2.dim(` Server logs \u2192 ${serverErrorLog}`)); + } else { + console.log(chalk2.dim("No --run provided, assuming server is already running")); + } + failureContext = "open browser"; + console.log(chalk2.dim("Opening browser...")); + 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}.` + ); + } + saveSession(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); + 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((resolve10) => setTimeout(resolve10, RETRY_DELAY_MS)); + } + } + } + if (!recordingStarted) { + throw new Error( + `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}` + ); + } + } catch (error) { + await cleanupFailedStart(session); + clearSession(controlDir); + console.error( + chalk2.red("\u2717") + ` Failed to ${failureContext}: ${error.message} +` + chalk2.dim("All processes started by this ProofShot attempt were cleaned up.") + ); + process.exit(1); + return; + } + session.recordingActive = true; + saveSession(session, controlDir); + 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")}`); +} + +// src/commands/stop.ts +import * as fs14 from "fs"; +import * as path12 from "path"; +import { randomUUID as randomUUID2 } from "crypto"; +import { execFileSync as execFileSync2 } from "child_process"; +import chalk3 from "chalk"; + +// src/artifacts/viewer.ts +import * as fs11 from "fs"; +import * as path9 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 time = formatTime(Math.max(0, 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) { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function serializeEntries(entries) { + return JSON.stringify(entries).replace(/<\//g, "<\\/"); +} +function generateViewer(data) { + const date = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19); + const stepsHtml = data.entries.map((entry, i) => { + const icon = getActionIcon(entry.action); + const time = formatTime(entry.relativeTimeSec); + const action = escapeHtml(entry.action); + return `
+ ${i + 1} + ${icon} +
+ ${action} +
+ ${time} +
`; + }).join("\n"); + const descriptionHtml = data.description ? `

${escapeHtml(data.description)}

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

No video recorded

Screenshots are available in the timeline

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

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

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

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

' : ""}` : '

No console output captured

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

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

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

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

' : ""}` : '

No server log captured

'; + } + const consoleLineCount = data.consoleEntries && data.consoleEntries.length > 0 ? data.consoleEntries.length : (data.consoleOutput ?? "").split("\n").filter((l) => l.trim()).length; + const serverLineCount = data.serverEntries && data.serverEntries.length > 0 ? data.serverEntries.length : (data.serverLog ?? "").split("\n").filter((l) => l.trim()).length; + return ` + + + + + ProofShot \u2014 Verification Report + + + +
+

ProofShot Verification

+ ${descriptionHtml} +

${escapeHtml(date)} · ${data.durationSec}s

+
+ + +
+ ${tokenUsageHtml} +
+
+
+ ${videoPanelHtml} +
+
+
+ + + +
+ +
+
+
+${stepsHtml} +
+ + +
+
+ + +`; +} +function writeViewer(outputDir, data) { + let entries = data.entries; + if (!entries) { + const logPath = path9.join(outputDir, "session-log.json"); + if (!fs11.existsSync(logPath)) return null; + try { + entries = JSON.parse(fs11.readFileSync(logPath, "utf-8")); + } catch { + return null; + } + } + if (!entries || entries.length === 0) return null; + const html = generateViewer({ ...data, entries }); + const viewerPath = path9.join(outputDir, "viewer.html"); + fs11.writeFileSync(viewerPath, html); + return viewerPath; +} + +// src/utils/error-patterns.ts +var PATTERNS = [ + { + name: "JavaScript / Node.js", + patterns: [ + /\bError:/, + // TypeError: x is not a function + /\bERR[_!]/, + // npm ERR!, ERR_MODULE_NOT_FOUND + /\bEACCES\b|\bENOENT\b|\bEADDRINUSE\b/, + // System errors + /\bat\s+.+\(.+:\d+:\d+\)/, + // Stack trace: at fn (file.js:10:5) + /Unhandled.+rejection/i + // Unhandled promise rejection + ] + }, + { + name: "Python", + patterns: [ + /Traceback \(most recent call last\)/, + /^\s*File ".+", line \d+/, + // Stack trace line + /\w+Error:/, + // ValueError:, KeyError:, etc. + /\w+Exception:/ + // Django ImproperlyConfigured, etc. + ] + }, + { + name: "Ruby / Rails", + patterns: [ + /\w+Error \(.+\)/, + // ActionController::RoutingError (...) + /from .+:\d+:in `.+'/, + // Stack trace + /FATAL --/, + // Rails logger FATAL level + /Errno::\w+/ + // Errno::ENOENT + ] + }, + { + name: "Go", + patterns: [ + /^panic:/, + // Go panic + /^goroutine \d+/, + // Goroutine stack dump + /runtime error:/ + ] + }, + { + name: "Java / Kotlin", + patterns: [ + /Exception in thread/, + // Exception in thread "main" + /\w+Exception:/, + // NullPointerException: + /\bat\s+[\w.$]+\(.+:\d+\)/, + // at com.example.Main(Main.java:10) + /Caused by:/ + ] + }, + { + name: "Rust", + patterns: [ + /thread '.+' panicked at/, + // thread 'main' panicked at + /error\[E\d+\]/ + // Compiler error: error[E0308] + ] + }, + { + name: "PHP", + patterns: [ + /PHP\s+(Fatal|Parse|Warning)\s+error:/i, + /Stack trace:/, + /thrown in .+ on line \d+/ + ] + }, + { + name: "C# / .NET", + patterns: [ + /Unhandled exception/, + /\w+Exception:/, + /at .+ in .+:line \d+/ + // Stack trace + ] + }, + { + name: "Elixir / Phoenix", + patterns: [ + /\*\* \(\w+\)/, + // ** (EXIT), ** (RuntimeError) + /\(exit\) an exception was raised/ + ] + }, + { + name: "Generic", + patterns: [ + /\bFATAL\b/, + // Common log level + /\bCRITICAL\b/, + // Common log level + /\bSegmentation fault\b/, + /\bcore dumped\b/, + /\bout of memory\b/i + ] + } +]; +function extractServerErrors(log) { + if (!log.trim()) return []; + const allPatterns = PATTERNS.flatMap((lp) => lp.patterns); + return log.split("\n").filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return false; + return allPatterns.some((p) => p.test(trimmed)); + }); +} + +// src/commands/exec.ts +import * as fs12 from "fs"; +import * as path10 from "path"; +import { execSync as execSync5 } from "child_process"; +var SESSION_LOG_FILENAME = "session-log.json"; +function loadSessionLog(sessionDir) { + const logPath = path10.join(sessionDir, SESSION_LOG_FILENAME); + if (!fs12.existsSync(logPath)) return []; + try { + return JSON.parse(fs12.readFileSync(logPath, "utf-8")); + } catch { + return []; + } +} +function resolveScreenshotPath(args, sessionDir) { + if (args[0] !== "screenshot" || args.length < 2) return args; + const screenshotPath = args[args.length - 1]; + if (path10.isAbsolute(screenshotPath)) return args; + const resolved = path10.join(sessionDir, screenshotPath); + return [...args.slice(0, -1), resolved]; +} +function buildShellCommand(args, sessionName) { + if (args[0] === "eval" && args.length > 1) { + const jsCode = args.slice(1).join(" "); + const escaped = jsCode.replace(/'/g, "'\\''"); + return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName }); + } + const quotedArgs = args.map((arg) => { + if (/[(){}[\]$`!#&|;<>*? "'\\]/.test(arg)) { + const escaped = arg.replace(/'/g, "'\\''"); + return `'${escaped}'`; + } + return arg; + }); + return buildAgentBrowserCommand(quotedArgs.join(" "), { session: sessionName }); +} +function parseElementRef(args) { + for (const arg of args) { + const match = arg.match(/@e\d+/); + if (match) return match[0]; + } + return null; +} +function captureElementData(ref, viewport, sessionName) { + try { + let bbox = null; + let label = ""; + let elemId = ""; + try { + elemId = ab(`get attr ${ref} id`, { session: sessionName }); + } catch { + } + if (elemId) { + try { + const raw = ab(`get box '#${elemId}'`, { session: sessionName }); + bbox = JSON.parse(raw); + } catch { + } + try { + const raw = ab( + `eval "document.getElementById('${elemId}')?.labels?.[0]?.textContent||document.getElementById('${elemId}')?.placeholder||document.getElementById('${elemId}')?.getAttribute('aria-label')||''"`, + { session: sessionName } + ); + label = JSON.parse(raw) || ""; + } catch { + } + } + if (!bbox) { + try { + label = ab(`get text ${ref}`, { session: sessionName }); + } catch { + } + if (!label) { + try { + label = ab(`get attr ${ref} placeholder`, { session: sessionName }); + } catch { + } + } + if (!label) { + try { + label = ab(`get attr ${ref} aria-label`, { session: sessionName }); + } catch { + } + } + if (!label) { + try { + label = ab(`get attr ${ref} name`, { session: sessionName }); + } catch { + } + } + if (label) { + try { + const escaped = label.replace(/'/g, "\\'"); + const raw = ab(`get box 'text=${escaped}'`, { session: sessionName }); + bbox = JSON.parse(raw); + } catch { + } + } + } + if (!bbox) return null; + return { + label: label || "", + bbox: { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }, + viewport + }; + } catch { + return null; + } +} +function isRefTargetedAction(args) { + const cmd = args[0]?.toLowerCase(); + return (cmd === "click" || cmd === "fill" || cmd === "type") && parseElementRef(args) !== null; +} +async function execCommand(args) { + const action = args.join(" "); + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + setAgentBrowserDefaults({ + configPath: session?.agentBrowserConfigPath || config.browser.configPath, + socketDir: session?.agentBrowserSocketDir + }); + if (session && !session.recordingActive) { + console.error( + 'Error: Session has no active recording. Video capture is required.\nRun "proofshot stop" to end this session, then start a new one.' + ); + process.exit(1); + } + if (session && !canAddressOwnedBrowserSession(session)) { + console.error( + "Error: Browser ownership no longer matches this ProofShot session.\nRefusing to address a possibly reused agent-browser session name." + ); + process.exit(1); + return; + } + let resolvedArgs = args; + if (session) { + resolvedArgs = resolveScreenshotPath(args, session.sessionDir); + } + let elementData; + if (session && isRefTargetedAction(args)) { + const ref = parseElementRef(args); + const viewport = session.viewport || { width: 1280, height: 720 }; + const captured = captureElementData(ref, viewport, session.sessionName); + if (captured) elementData = captured; + } + if (session) { + const now = /* @__PURE__ */ new Date(); + const startTime = new Date(session.startedAt).getTime(); + const relativeTimeSec = parseFloat(((now.getTime() - startTime) / 1e3).toFixed(1)); + const entry = { + action, + relativeTimeSec, + timestamp: now.toISOString() + }; + if (elementData) { + entry.element = elementData; + } + const logPath = path10.join(session.sessionDir, SESSION_LOG_FILENAME); + const entries = loadSessionLog(session.sessionDir); + entries.push(entry); + fs12.writeFileSync(logPath, JSON.stringify(entries, null, 2) + "\n"); + } + const shellCmd = buildShellCommand(resolvedArgs, session?.sessionName); + try { + const result = execSync5(shellCmd, { + encoding: "utf-8", + timeout: 6e4, + stdio: ["pipe", "pipe", "pipe"], + env: getAgentBrowserEnvironment() + }); + if (result.trim()) { + process.stdout.write(result); + if (!result.endsWith("\n")) { + process.stdout.write("\n"); + } + } + } catch (error) { + const stderr = error?.stderr?.toString?.() || ""; + const stdout = error?.stdout?.toString?.() || ""; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + process.exit(error?.status || 1); + } + if (session && args[0] === "set" && args[1] === "viewport") { + try { + const vpJson = ab("eval 'JSON.stringify({width: window.innerWidth, height: window.innerHeight})'", { + session: session.sessionName + }); + const vp = JSON.parse(vpJson); + session.viewport = { width: vp.width, height: vp.height }; + saveSession(session, controlDir); + } catch { + } + } +} + +// src/utils/token-usage.ts +import * as fs13 from "fs"; +import * as path11 from "path"; +import * as os4 from "os"; +function estimateTokenUsage(sessionDir, startTimeMs, endTimeMs) { + const claudeUsage = tryClaudeCodeLogs(startTimeMs, endTimeMs); + if (claudeUsage) return claudeUsage; + return estimateFromContent(sessionDir); +} +function tryClaudeCodeLogs(startTimeMs, endTimeMs) { + const claudeDir = path11.join(os4.homedir(), ".claude", "sessions"); + if (!fs13.existsSync(claudeDir)) return null; + try { + const files = fs13.readdirSync(claudeDir).filter((f) => f.endsWith(".json")); + for (const file of files) { + const data = JSON.parse(fs13.readFileSync(path11.join(claudeDir, file), "utf-8")); + const sessionStart = new Date(data.startedAt).getTime(); + if (sessionStart >= startTimeMs - 6e4 && sessionStart <= endTimeMs + 6e4) { + if (data.totalInputTokens != null || data.totalOutputTokens != null || data.usage) { + const inputTokens = data.totalInputTokens ?? data.usage?.inputTokens ?? 0; + const outputTokens = data.totalOutputTokens ?? data.usage?.outputTokens ?? 0; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + estimatedCost: 0, + model: data.model || "claude", + source: "claude-logs" + }; + } + } + } + } catch { + } + return null; +} +function estimateFromContent(sessionDir) { + const logPath = path11.join(sessionDir, "session-log.json"); + if (!fs13.existsSync(logPath)) return null; + try { + const entries = JSON.parse(fs13.readFileSync(logPath, "utf-8")); + if (!Array.isArray(entries) || entries.length === 0) return null; + const actionCount = entries.length; + const inputTokens = actionCount * 500; + const outputTokens = actionCount * 300; + const totalTokens = inputTokens + outputTokens; + const estimatedCost = (inputTokens * 3 + outputTokens * 15) / 1e6; + return { + inputTokens, + outputTokens, + totalTokens, + estimatedCost, + model: "estimated", + source: "estimated" + }; + } catch { + return null; + } +} +function formatTokenUsage(usage) { + const fmt = (n) => n.toLocaleString(); + let result = ""; + result += `- Input tokens: ~${fmt(usage.inputTokens)} +`; + result += `- Output tokens: ~${fmt(usage.outputTokens)} +`; + result += `- Total tokens: ~${fmt(usage.totalTokens)} +`; + if (usage.estimatedCost > 0) { + result += `- Estimated cost: ~$${usage.estimatedCost.toFixed(4)} +`; + } + if (usage.source === "estimated") { + result += `- Source: estimated from ${usage.model === "estimated" ? "session activity" : usage.model} +`; + } + return result; +} + +// src/commands/stop.ts +function parseTimestampedServerLog(raw, startTimeMs) { + if (!raw.trim()) return { entries: [], cleanText: "" }; + const lines = raw.split("\n").filter((l) => l.trim()); + const entries = []; + const cleanLines = []; + for (const line of lines) { + const tabIdx = line.indexOf(" "); + if (tabIdx > 0) { + const epochStr = line.slice(0, tabIdx); + const epochMs = parseInt(epochStr, 10); + if (!isNaN(epochMs) && epochMs > 1e12) { + const text = line.slice(tabIdx + 1); + entries.push({ + text, + relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1e3).toFixed(1))) + }); + cleanLines.push(text); + continue; + } + } + entries.push({ text: line, relativeTimeSec: -1 }); + cleanLines.push(line); + } + return { entries, cleanText: cleanLines.join("\n") }; +} +async function stopCommand(options) { + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + if (!session) { + console.log( + chalk3.dim("No active session found; all owned processes are already stopped.") + ); + return; + } + setAgentBrowserDefaults({ + configPath: session.agentBrowserConfigPath || config.browser.configPath, + socketDir: session.agentBrowserSocketDir + }); + if (session.bundleComplete) { + if (session.browserRetained && !options.noClose) { + console.log(chalk3.dim("Closing retained browser...")); + const browserSessionAddressable = canAddressOwnedBrowserSession(session); + await stopOwnedBrowser(session); + session.browserRetained = false; + clearSession(controlDir); + if (browserSessionAddressable) { + console.log(chalk3.green("\u2713") + " Retained browser closed; proof artifacts were already bundled."); + } else { + console.log( + chalk3.yellow("\u26A0") + " Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup." + ); + } + } else if (session.browserRetained) { + console.log( + chalk3.dim("Proof artifacts are already bundled; the owned browser remains intentionally open.") + ); + } else { + clearSession(controlDir); + console.log(chalk3.dim("Proof artifacts are already bundled and all owned processes are stopped.")); + } + return; + } + const retryingStoppedSession = !session.recordingActive; + const recordingWasActive = session.recordingActive; + const startTime = new Date(session.startedAt).getTime(); + const durationMs = Date.now() - startTime; + const durationSec = Math.round(durationMs / 1e3); + const browserSessionAvailable = canAddressOwnedBrowserSession(session); + const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true; + if (!browserSessionAvailable && priorConsoleEvidenceAvailable) { + console.log( + chalk3.dim("Browser already stopped; reusing console evidence collected before cleanup.") + ); + } else if (!browserSessionAvailable) { + console.log( + chalk3.yellow("\u26A0") + " Browser ownership could not be verified; skipping console and recording commands.\n" + chalk3.dim(" Browser evidence may be incomplete; exact recorded-process cleanup will still run.") + ); + } + console.log(chalk3.dim("Collecting errors...")); + let consoleErrors = ""; + let consoleOutput = ""; + let consoleEntries = []; + const consoleErrorsPath = path12.join(session.sessionDir, "console-errors.log"); + const consoleOutputPath = path12.join(session.sessionDir, "console-output.log"); + const consoleEntriesPath = path12.join(session.sessionDir, "console-entries.json"); + if (browserSessionAvailable) { + try { + consoleErrors = getConsoleErrors(session.sessionName); + consoleOutput = getConsoleOutput(session.sessionName); + const consoleMessages = getConsoleOutputJson(session.sessionName); + consoleEntries = consoleMessages.map((msg) => ({ + text: `[${msg.type}] ${msg.text}`, + relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1e3).toFixed(1))) + })); + } catch { + } + writeTextFileAtomically(consoleErrorsPath, consoleErrors); + writeTextFileAtomically(consoleOutputPath, consoleOutput); + writeTextFileAtomically( + consoleEntriesPath, + JSON.stringify(consoleEntries, null, 2) + "\n" + ); + const capturedErrorLines = consoleErrors.split("\n").filter((line) => line.trim() && line.trim() !== "No errors"); + session.consoleEvidenceAvailable = true; + session.consoleErrorCount = capturedErrorLines.length > 0 && consoleErrors.trim() !== "" ? capturedErrorLines.length : 0; + saveSession(session, controlDir); + } else if (priorConsoleEvidenceAvailable) { + if (fs14.existsSync(consoleErrorsPath)) { + consoleErrors = fs14.readFileSync(consoleErrorsPath, "utf-8"); + } + if (fs14.existsSync(consoleOutputPath)) { + consoleOutput = fs14.readFileSync(consoleOutputPath, "utf-8"); + } + if (fs14.existsSync(consoleEntriesPath)) { + try { + const savedEntries = JSON.parse(fs14.readFileSync(consoleEntriesPath, "utf-8")); + if (Array.isArray(savedEntries)) consoleEntries = savedEntries; + } catch { + } + } + } + console.log(chalk3.dim("Stopping recording...")); + if (browserSessionAvailable) { + stopRecording(session.sessionName); + } + session.recordingActive = false; + saveSession(session, controlDir); + let cleanupError; + if (!options.noClose) { + console.log(chalk3.dim("Closing browser...")); + try { + await stopOwnedBrowser(session); + } catch (error) { + cleanupError = error; + } + } + if (session.serverProcess) { + console.log(chalk3.dim("Stopping dev server...")); + try { + await stopOwnedServer(session); + } catch (error) { + cleanupError ||= error; + } + } + if (cleanupError) throw cleanupError; + let serverLog = ""; + let serverEntries = []; + if (fs14.existsSync(session.serverErrorLog)) { + const rawServerLog = fs14.readFileSync(session.serverErrorLog, "utf-8"); + const parsed = parseTimestampedServerLog(rawServerLog, startTime); + serverLog = parsed.cleanText; + serverEntries = parsed.entries; + } + const sessionDir = session.sessionDir; + const screenshots = fs14.existsSync(sessionDir) ? fs14.readdirSync(sessionDir).filter((f) => f.endsWith(".png")) : []; + const sessionLog = loadSessionLog(sessionDir); + let trimOffsetSec = session.trimOffsetSec ?? 0; + if (!session.videoTrimComplete) { + if (fs14.existsSync(session.videoPath)) { + trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog); + } else if (recordingWasActive) { + console.log( + chalk3.yellow("\u26A0") + " Recording was active but no video file was produced.\n" + chalk3.dim(" The screencast may have been interrupted. Screenshots and logs are still saved.") + ); + } + session.videoTrimComplete = true; + session.trimOffsetSec = trimOffsetSec; + saveSession(session, controlDir); + } + const consoleErrorLines = consoleErrors.split("\n").filter((l) => l.trim() && l.trim() !== "No errors"); + const observedConsoleErrorCount = consoleErrorLines.length > 0 && consoleErrors.trim() !== "" ? consoleErrorLines.length : 0; + const consoleEvidenceAvailable = browserSessionAvailable || priorConsoleEvidenceAvailable; + const consoleErrorCount = browserSessionAvailable ? observedConsoleErrorCount : session.consoleErrorCount ?? 0; + if (browserSessionAvailable) { + session.consoleEvidenceAvailable = true; + session.consoleErrorCount = consoleErrorCount; + saveSession(session, controlDir); + } + const serverErrorLines = extractServerErrors(serverLog); + const serverErrorCount = serverErrorLines.length; + const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now()); + const summaryPath = path12.join(sessionDir, "SUMMARY.md"); + const summary = generateProofSummary({ + projectDirectory: session.startDirectory || process.cwd(), + description: session.description, + serverCommand: session.serverCommand, + port: session.port, + headless: session.headless ?? config.headless ?? true, + viewport: session.viewport || config.viewport || { width: 1280, height: 720 }, + videoPath: session.videoPath, + screenshots, + consoleErrors, + consoleErrorCount, + consoleEvidenceAvailable, + serverLog, + serverErrorCount, + tokenUsage, + durationSec, + outputDir: sessionDir + }); + if (!retryingStoppedSession || !fs14.existsSync(summaryPath)) { + writeTextFileAtomically(summaryPath, summary); + } + let viewerEntries = sessionLog; + if (trimOffsetSec > 0 && !session.sessionLogAdjusted) { + viewerEntries = sessionLog.map((e) => ({ + ...e, + relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) + })); + } + if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) { + const logPath = path12.join(sessionDir, "session-log.json"); + writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + "\n"); + } + if (!session.sessionLogAdjusted) { + session.sessionLogAdjusted = true; + saveSession(session, controlDir); + } + const adjustTime = (e) => trimOffsetSec > 0 ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) } : e; + const viewerConsoleEntries = consoleEntries.map(adjustTime); + const viewerServerEntries = serverEntries.map(adjustTime); + const viewerPath = writeViewer(sessionDir, { + description: session.description, + serverCommand: session.serverCommand, + durationSec, + videoFilename: fs14.existsSync(session.videoPath) ? path12.basename(session.videoPath) : null, + consoleErrorCount, + consoleEvidenceAvailable, + serverErrorCount, + consoleOutput, + serverLog, + consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : void 0, + serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : void 0, + entries: viewerEntries.length > 0 ? viewerEntries : void 0, + tokenUsage + }); + session.bundleComplete = true; + session.browserRetained = Boolean(options.noClose); + if (session.browserRetained) { + saveSession(session, controlDir); + } else { + clearSession(controlDir); + } + console.log(""); + console.log(chalk3.green.bold("\u2705 ProofShot verification complete")); + console.log(""); + if (fs14.existsSync(session.videoPath)) { + console.log(`\u{1F4F9} Video: ${chalk3.dim(session.videoPath)} (${durationSec}s)`); + } + console.log(`\u{1F4F8} Screenshots: ${screenshots.length} captured`); + console.log(`\u{1F4DD} Summary: ${chalk3.dim(summaryPath)}`); + if (viewerPath) { + console.log(`\u{1F3AC} Viewer: ${chalk3.dim(viewerPath)}`); + } else { + console.log(chalk3.dim('Tip: Use "proofshot exec" instead of "agent-browser" to get an interactive timeline viewer.')); + } + console.log(""); + console.log( + `Console errors: ${!consoleEvidenceAvailable ? chalk3.yellow("unavailable") : consoleErrorCount === 0 ? chalk3.green("0") : chalk3.red(String(consoleErrorCount))}` + ); + console.log( + `Server errors: ${serverErrorCount === 0 ? chalk3.green("0") : chalk3.red(String(serverErrorCount))}` + ); + console.log(`Duration: ${durationSec} seconds`); + console.log(""); + console.log(`Proof artifacts saved to ${chalk3.dim(sessionDir)}`); + if (session.browserRetained) { + console.log(chalk3.dim('Browser retained. Run "proofshot stop" later to close this exact session.')); + } + if (consoleErrorCount > 0) { + console.log(""); + console.log(chalk3.red.bold("Console Errors:")); + for (const line of consoleErrorLines.slice(0, 10)) { + console.log(chalk3.red(` ${line}`)); + } + if (consoleErrorLines.length > 10) { + console.log(chalk3.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`)); + } + } + if (serverErrorCount > 0) { + console.log(""); + console.log(chalk3.red.bold("Server Errors:")); + for (const line of serverErrorLines.slice(0, 10)) { + console.log(chalk3.red(` ${line}`)); + } + if (serverErrorLines.length > 10) { + console.log(chalk3.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`)); + } + } +} +function writeTextFileAtomically(filePath, contents) { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID2()}.tmp`; + try { + fs14.writeFileSync(temporaryPath, contents); + fs14.renameSync(temporaryPath, filePath); + } finally { + if (fs14.existsSync(temporaryPath)) fs14.unlinkSync(temporaryPath); + } +} +function generateProofSummary(data) { + const date = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19); + const projectName = path12.basename(data.projectDirectory); + let md = `# ProofShot Verification Report + +**Date:** ${date} +**Project:** ${projectName} +**Dev Server:** ${data.serverCommand ? data.serverCommand : "external"} on localhost:${data.port} + +`; + if (data.description) { + md += `## What Was Verified + +${data.description} + +`; + } + const relativeVideo = path12.basename(data.videoPath); + md += `## Video Recording + +Full session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s) + +`; + if (data.screenshots.length > 0) { + md += `## Screenshots + +`; + for (const ss of data.screenshots) { + md += `![${ss}](./${ss}) + +`; + } + } + md += `## Console Errors + +`; + if (!data.consoleEvidenceAvailable) { + md += `Browser ownership could not be verified, so console evidence was unavailable. + +`; + } else if (data.consoleErrorCount === 0) { + md += `No console errors detected. + +`; + } else { + md += `${data.consoleErrorCount} error(s) detected: + +\`\`\` +${data.consoleErrors} +\`\`\` + +`; + } + md += `## Server Errors + +`; + if (data.serverErrorCount === 0) { + md += `No server errors detected. + +`; + } else { + md += `${data.serverErrorCount} error(s) detected: + +\`\`\` +${data.serverLog.slice(0, 5e3)} +\`\`\` + +`; + if (data.serverLog.length > 5e3) { + md += `_(truncated \u2014 see server.log for full output)_ + +`; + } + } + if (data.tokenUsage) { + md += `## Token Usage (Estimated) + +`; + md += formatTokenUsage(data.tokenUsage); + md += "\n"; + } + md += `## Environment +- Browser: Chromium (${data.headless ? "headless" : "headed"}) +- Viewport: ${data.viewport.width}x${data.viewport.height} +- Duration: ${data.durationSec} seconds +`; + return md; +} +function trimVideo(videoPath, screenshots, outputDir, recordingStartMs, sessionLog) { + let firstActionSec = null; + let lastActionSec = null; + if (sessionLog.length > 0) { + firstActionSec = sessionLog[0].relativeTimeSec; + lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec; + } else if (screenshots.length > 0) { + const timestamps = screenshots.map((f) => { + try { + return fs14.statSync(path12.join(outputDir, f)).birthtimeMs; + } catch { + return null; + } + }).filter((t) => t !== null && t >= recordingStartMs); + if (timestamps.length === 0) return 0; + firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1e3; + lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1e3; + } + if (firstActionSec === null || lastActionSec === null) return 0; + const BUFFER_BEFORE = 5; + const BUFFER_AFTER = 3; + const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE); + const trimEndSec = lastActionSec + BUFFER_AFTER; + if (trimEndSec - trimStartSec < 5) return 0; + try { + execFileSync2("ffmpeg", ["-version"], { stdio: "pipe" }); + } catch { + console.log(chalk3.dim("Tip: Install ffmpeg to auto-trim dead time from videos.")); + return 0; + } + const dir = path12.dirname(videoPath); + const ext = path12.extname(videoPath); + const base = path12.basename(videoPath, ext); + const rawPath = path12.join(dir, `${base}-raw${ext}`); + try { + fs14.renameSync(videoPath, rawPath); + execFileSync2( + "ffmpeg", + [ + "-y", + "-i", + rawPath, + "-ss", + trimStartSec.toFixed(2), + "-to", + trimEndSec.toFixed(2), + "-c", + "copy", + "-abort_on", + "empty_output", + videoPath + ], + { stdio: "pipe", timeout: 6e4 } + ); + validateTrimmedVideo(videoPath); + fs14.unlinkSync(rawPath); + const trimmedDuration = Math.round(trimEndSec - trimStartSec); + console.log(chalk3.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`)); + return trimStartSec; + } catch { + if (fs14.existsSync(videoPath)) { + fs14.unlinkSync(videoPath); + } + if (fs14.existsSync(rawPath)) { + fs14.renameSync(rawPath, videoPath); + } + console.log(chalk3.dim("Video trimming failed, keeping original")); + return 0; + } +} +function validateTrimmedVideo(videoPath) { + if (!fs14.existsSync(videoPath) || fs14.statSync(videoPath).size === 0) { + throw new Error("FFmpeg produced an empty video"); + } + execFileSync2( + "ffmpeg", + ["-v", "error", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"], + { stdio: "pipe", timeout: 6e4 } + ); +} + +// src/commands/diff.ts +import * as fs15 from "fs"; +import * as path13 from "path"; +import chalk4 from "chalk"; +async function diffCommand(options) { + const config = loadConfig(); + const currentDir = path13.resolve(config.output); + const baselineDir = path13.resolve(options.baseline); + if (!fs15.existsSync(baselineDir)) { + console.error(chalk4.red("\u2717") + ` Baseline directory not found: ${baselineDir}`); + process.exit(1); + } + if (!fs15.existsSync(currentDir)) { + console.error( + chalk4.red("\u2717") + ` Current artifacts not found: ${currentDir} +` + chalk4.dim('Run "proofshot verify" first to generate screenshots.') + ); + process.exit(1); + } + const baselineFiles = fs15.readdirSync(baselineDir).filter((f) => f.startsWith("page-") && f.endsWith(".png")); + const currentFiles = fs15.readdirSync(currentDir).filter((f) => f.startsWith("page-") && f.endsWith(".png")); + if (baselineFiles.length === 0) { + console.error(chalk4.red("\u2717") + " No baseline screenshots found (looking for page-*.png)"); + process.exit(1); + } + const diffDir = path13.join(currentDir, "diffs"); + fs15.mkdirSync(diffDir, { recursive: true }); + console.log(chalk4.dim("Comparing screenshots...\n")); + let hasChanges = false; + for (const file of baselineFiles) { + const baselinePath = path13.join(baselineDir, file); + const currentPath = path13.join(currentDir, file); + const diffPath = path13.join(diffDir, `diff-${file}`); + if (!fs15.existsSync(currentPath)) { + console.log(chalk4.yellow("\u26A0") + ` ${file}: no matching current screenshot (page removed?)`); + continue; + } + const mismatch = diffScreenshots(baselinePath, currentPath, diffPath); + if (mismatch === null) { + console.log(chalk4.yellow("\u26A0") + ` ${file}: could not compare`); + } else if (mismatch === 0) { + console.log(chalk4.green("\u2713") + ` ${file}: identical`); + } else { + hasChanges = true; + console.log( + chalk4.red("\u2717") + ` ${file}: ${chalk4.bold(`${mismatch.toFixed(2)}%`)} changed \u2192 ${chalk4.dim(diffPath)}` + ); + } + } + for (const file of currentFiles) { + if (!baselineFiles.includes(file)) { + console.log(chalk4.cyan("+") + ` ${file}: new page (no baseline)`); + hasChanges = true; + } + } + console.log(""); + if (hasChanges) { + console.log(chalk4.yellow("Visual changes detected.") + ` Diff images saved to ${chalk4.dim(diffDir)}`); + } else { + console.log(chalk4.green("No visual changes detected.")); + } +} + +// src/commands/clean.ts +import * as fs16 from "fs"; +import * as path14 from "path"; +import chalk5 from "chalk"; +async function cleanCommand() { + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const outputDir = path14.resolve(config.output); + if (hasActiveSession(controlDir)) { + console.error( + chalk5.red("\u2717") + " Cannot clean while a ProofShot session owns browser or server processes.\n" + chalk5.dim('Run "proofshot stop" first so exact cleanup metadata is preserved.') + ); + process.exit(1); + return; + } + if (!fs16.existsSync(outputDir)) { + console.log(chalk5.dim("Nothing to clean \u2014 no artifacts directory found.")); + return; + } + fs16.rmSync(outputDir, { recursive: true, force: true }); + console.log(chalk5.green("\u2713") + ` Removed ${chalk5.dim(outputDir)}`); +} + +// src/commands/pr.ts +import * as fs18 from "fs"; +import * as path16 from "path"; +import { execSync as execSync7 } from "child_process"; +import chalk6 from "chalk"; + +// src/utils/github.ts +import * as fs17 from "fs"; +import * as path15 from "path"; +import { execSync as execSync6 } from "child_process"; +var GITHUB_API_VERSION = "2022-11-28"; +var DEFAULT_ARTIFACTS_BRANCH = "proofshot-artifacts"; +function getGitHubToken() { + const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (envToken) return envToken.trim(); + try { + return execSync6("gh auth token", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch (error) { + throw new ProofShotError( + "GitHub CLI (gh) is not installed or not authenticated.\nInstall: https://cli.github.com\nThen run: gh auth login", + error + ); + } +} +async function getRepoInfo(token) { + let nwo; + try { + nwo = execSync6("gh repo view --json nameWithOwner -q .nameWithOwner", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch (error) { + throw new ProofShotError( + "Could not determine GitHub repository. Are you in a git repo with a GitHub remote?", + error + ); + } + const [owner, repo] = nwo.split("/"); + const repoResponse = await githubApi(`repos/${owner}/${repo}`, token); + return { + owner, + repo, + id: repoResponse.id, + defaultBranch: repoResponse.default_branch, + isPrivate: repoResponse.private + }; +} +function getPRNumber(explicitPR) { + if (explicitPR) { + if (!/^\d+$/.test(explicitPR)) { + throw new ProofShotError(`Invalid PR number: ${explicitPR}`); + } + const num = parseInt(explicitPR, 10); + try { + execSync6(`gh pr view ${num} --json number -q .number`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }); + } catch { + throw new ProofShotError(`PR #${num} not found or not accessible.`); + } + return num; + } + try { + const numStr = execSync6("gh pr view --json number -q .number", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + return parseInt(numStr, 10); + } catch { + throw new ProofShotError( + "No PR found for the current branch.\nEither specify a PR number: proofshot pr 42\nOr create a PR first: gh pr create" + ); + } +} +function getContentType(filePath) { + const ext = path15.extname(filePath).toLowerCase(); + switch (ext) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".webm": + return "video/webm"; + case ".mp4": + return "video/mp4"; + default: + return "application/octet-stream"; + } +} +async function uploadAsset(filePath, token, repoId) { + const fileName = path15.basename(filePath); + const fileSize = fs17.statSync(filePath).size; + const contentType = getContentType(filePath); + const policyResponse = await fetch("https://github.com/upload/policies/assets", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `token ${token}` + }, + body: JSON.stringify({ + name: fileName, + size: fileSize, + content_type: contentType, + repository_id: repoId + }) + }); + if (!policyResponse.ok) { + const body = await policyResponse.text(); + if ([401, 403, 422].includes(policyResponse.status)) { + throw new ProofShotError( + `GitHub web attachment upload failed (${policyResponse.status}). +ProofShot's "github-web-attachments" provider uses GitHub's internal /upload/policies/assets endpoint, which may reject browser-based gh OAuth auth. +Try one of: + - proofshot pr --upload-provider repo-contents + - export GH_TOKEN= and retry + - proofshot pr --dry-run +GitHub response: ${body}` + ); + } + throw new ProofShotError( + `GitHub upload policy request failed (${policyResponse.status}): ${body}` + ); + } + const policy = await policyResponse.json(); + const fileBuffer = fs17.readFileSync(filePath); + const formData = new FormData(); + for (const [key, value] of Object.entries(policy.form)) { + formData.append(key, value); + } + const blob = new Blob([fileBuffer], { type: contentType }); + formData.append("file", blob, fileName); + const uploadResponse = await fetch(policy.upload_url, { + method: "POST", + body: formData + }); + if (!uploadResponse.ok && uploadResponse.status !== 204 && uploadResponse.status !== 201) { + throw new ProofShotError( + `File upload failed (${uploadResponse.status}): ${await uploadResponse.text()}` + ); + } + return { + url: policy.asset.href, + name: fileName + }; +} +async function uploadAssets(options) { + if (options.uploadProvider === "repo-contents") { + return uploadAssetsToRepoContents(options); + } + return uploadAssetsToWebAttachments(options); +} +async function uploadAssetsToWebAttachments(options) { + const results = /* @__PURE__ */ new Map(); + const { filePaths, token, repo, onProgress } = options; + for (let i = 0; i < filePaths.length; i += 1) { + const filePath = filePaths[i]; + const fileName = path15.basename(filePath); + onProgress?.(i + 1, filePaths.length, fileName); + try { + const asset = await uploadAsset(filePath, token, repo.id); + results.set(filePath, asset); + } catch (error) { + console.error(` Failed to upload ${fileName}: ${error.message}`); + } + } + return results; +} +async function uploadAssetsToRepoContents(options) { + const results = /* @__PURE__ */ new Map(); + const artifactsBranch = options.artifactsBranch || DEFAULT_ARTIFACTS_BRANCH; + await ensureArtifactsBranch(options.repo, artifactsBranch, options.token); + for (let i = 0; i < options.filePaths.length; i += 1) { + const filePath = options.filePaths[i]; + const fileName = path15.basename(filePath); + options.onProgress?.(i + 1, options.filePaths.length, fileName); + try { + const content = fs17.readFileSync(filePath, "base64"); + const uploadPath = path15.posix.join( + options.uploadRoot, + path15.basename(path15.dirname(filePath)), + fileName + ); + await githubApi( + `repos/${options.repo.owner}/${options.repo.repo}/contents/${encodePath(uploadPath)}`, + options.token, + { + method: "PUT", + body: JSON.stringify({ + message: `proofshot: add ${uploadPath}`, + content, + branch: artifactsBranch + }) + } + ); + results.set(filePath, { + url: buildBlobUrl(options.repo, artifactsBranch, uploadPath), + name: fileName + }); + } catch (error) { + console.error(` Failed to upload ${fileName}: ${error.message}`); + } + } + return results; +} +async function ensureArtifactsBranch(repo, branch, token) { + try { + await githubApi( + `repos/${repo.owner}/${repo.repo}/git/ref/heads/${encodeURIComponent(branch)}`, + token + ); + return; + } catch (error) { + const message = error.message; + if (!message.includes("(404)")) throw error; + } + const baseRef = await githubApi( + `repos/${repo.owner}/${repo.repo}/git/ref/heads/${encodeURIComponent(repo.defaultBranch)}`, + token + ); + await githubApi(`repos/${repo.owner}/${repo.repo}/git/refs`, token, { + method: "POST", + body: JSON.stringify({ + ref: `refs/heads/${branch}`, + sha: baseRef.object.sha + }) + }); +} +function buildBlobUrl(repo, branch, filePath) { + const encodedBranch = encodeURIComponent(branch); + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + return `https://github.com/${repo.owner}/${repo.repo}/blob/${encodedBranch}/${encodedPath}?raw=1`; +} +function encodePath(filePath) { + return filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); +} +async function githubApi(apiPath, token, init = {}) { + const response = await fetch(`https://api.github.com/${apiPath}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": GITHUB_API_VERSION, + ...init.body ? { "Content-Type": "application/json" } : {}, + ...init.headers || {} + } + }); + if (!response.ok) { + const body = await response.text(); + throw new ProofShotError(`GitHub API request failed (${response.status}): ${body}`); + } + if (response.status === 204) { + return void 0; + } + return await response.json(); +} +function postPRComment(prNumber, body) { + try { + execSync6(`gh pr comment ${prNumber} --body-file -`, { + input: body, + encoding: "utf-8", + timeout: 12e4, + stdio: ["pipe", "pipe", "pipe"] + }); + } catch (error) { + const stderr = error?.stderr?.toString?.() || ""; + throw new ProofShotError(`Failed to post PR comment: ${stderr}`, error); + } +} + +// src/artifacts/pr-format.ts +function formatPRComment(data) { + let md = `## ProofShot Verification + +`; + if (data.description) { + md += `> ${data.description} + +`; + } + const status = data.errorCount === 0 ? "\u2705 No errors detected" : `\u26A0\uFE0F ${data.errorCount} error(s) detected`; + md += `${status} + +`; + if (data.video) { + md += `### Recording + +`; + if (data.video.renderMode === "embed") { + md += `${data.video.url} + +`; + } else { + md += `[Session recording](${data.video.url}) + +`; + } + } + if (data.screenshots.size > 0) { + md += `### Screenshots + +`; + if (data.screenshots.size <= 3) { + for (const [filename, url] of data.screenshots) { + const label = filename.replace(/\.png$/, "").replace(/^step-/, ""); + md += `**${label}** + +`; + md += `![${label}](${url}) + +`; + } + } else { + md += `
+View ${data.screenshots.size} screenshots + +`; + for (const [filename, url] of data.screenshots) { + const label = filename.replace(/\.png$/, "").replace(/^step-/, ""); + md += `**${label}** + +![${label}](${url}) + +`; + } + md += `
+ +`; + } + } + md += `--- +`; + md += ``; + md += `Branch: \`${data.branch}\``; + if (data.commitSha) { + md += ` \xB7 Commit: \`${data.commitSha.slice(0, 7)}\``; + } + md += ` \xB7 ${data.sessionCount} session(s)`; + md += ` +`; + md += `Generated by [ProofShot](https://github.com/AmElmo/proofshot) +`; + return md; +} + +// src/commands/pr.ts +async function prCommand(options) { + const config = loadConfig(); + const outputDir = path16.resolve(config.output); + const uploadProvider = normalizeUploadProvider(options.uploadProvider); + const artifactsBranch = options.artifactsBranch || "proofshot-artifacts"; + let branch; + try { + branch = execSync7("git branch --show-current", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + } catch { + console.error(chalk6.red("\u2717") + " Not in a git repository."); + process.exit(1); + } + if (!branch) { + console.error(chalk6.red("\u2717") + " Detached HEAD \u2014 cannot determine branch."); + process.exit(1); + } + console.log(chalk6.dim(`Branch: ${branch}`)); + const sessionDirs = findSessionsForBranch(outputDir, branch); + if (sessionDirs.length === 0) { + console.error( + chalk6.red("\u2717") + ` No ProofShot sessions found for branch "${branch}". +` + chalk6.dim('Run "proofshot start" and "proofshot stop" first.') + ); + process.exit(1); + } + console.log(chalk6.dim(`Found ${sessionDirs.length} session(s) for this branch`)); + const screenshotPaths = []; + let videoPath = null; + let errorCount = 0; + let latestCommitSha = ""; + let description = null; + for (const sessionDir of sessionDirs) { + const metadata = loadMetadata(sessionDir); + if (metadata) { + if (!description && metadata.description) description = metadata.description; + if (metadata.commitSha) latestCommitSha = metadata.commitSha; + } + const files = fs18.readdirSync(sessionDir); + for (const f of files) { + if (f.endsWith(".png")) { + screenshotPaths.push(path16.join(sessionDir, f)); + } + } + if (!videoPath) { + for (const f of files) { + if (f === "session.webm" || f === "session.mp4") { + videoPath = path16.join(sessionDir, f); + break; + } + } + } + const summaryPath = path16.join(sessionDir, "SUMMARY.md"); + if (fs18.existsSync(summaryPath)) { + const summary = fs18.readFileSync(summaryPath, "utf-8"); + const errorMatch = summary.match(/(\d+)\s+error/gi); + if (errorMatch) { + for (const m of errorMatch) { + const num = parseInt(m, 10); + if (!isNaN(num)) errorCount += num; + } + } + } + } + if (videoPath && videoPath.endsWith(".webm")) { + const mp4Path = videoPath.replace(/\.webm$/, ".mp4"); + if (fs18.existsSync(mp4Path)) { + videoPath = mp4Path; + } else { + try { + execSync7("ffmpeg -version", { stdio: "pipe" }); + console.log(chalk6.dim("Converting video to .mp4...")); + execSync7( + `ffmpeg -i "${videoPath}" -c:v libx264 -preset fast -crf 23 -an "${mp4Path}"`, + { stdio: "pipe", timeout: 12e4 } + ); + videoPath = mp4Path; + console.log(chalk6.green("\u2713") + " Video converted to .mp4"); + } catch { + console.log(chalk6.dim("ffmpeg not available \u2014 uploading .webm directly")); + } + } + } + if (options.dryRun) { + const screenshotMap2 = /* @__PURE__ */ new Map(); + for (const ssPath of screenshotPaths) { + const label = screenshotLabel(ssPath); + screenshotMap2.set(label, `https://github.com/user-attachments/assets/<${label}>`); + } + const commentData2 = { + description, + sessionCount: sessionDirs.length, + screenshots: screenshotMap2, + video: videoPath ? { + url: `https://github.com/user-attachments/assets/<${path16.basename(videoPath)}>`, + renderMode: "embed" + } : null, + errorCount, + branch, + commitSha: latestCommitSha + }; + console.log(""); + console.log(chalk6.yellow("--- Dry run (not posted) ---")); + console.log(formatPRComment(commentData2)); + return; + } + const prNumber = getPRNumber(options.prNumber); + console.log(chalk6.dim(`Target PR: #${prNumber}`)); + const token = getGitHubToken(); + const repoInfo = await getRepoInfo(token); + const filesToUpload = [...screenshotPaths]; + if (videoPath) filesToUpload.push(videoPath); + const uploadRoot = buildUploadRoot(branch, prNumber, latestCommitSha); + console.log(chalk6.dim(`Upload provider: ${uploadProvider}`)); + if (uploadProvider === "repo-contents") { + console.log(chalk6.dim(`Artifacts branch: ${artifactsBranch}`)); + } + console.log(chalk6.dim(`Uploading ${filesToUpload.length} artifact(s)...`)); + const uploaded = await uploadAssets({ + filePaths: filesToUpload, + token, + repo: repoInfo, + uploadProvider, + uploadRoot, + artifactsBranch, + onProgress: (current, total, fileName) => { + console.log(chalk6.dim(` [${current}/${total}] ${fileName}`)); + } + }); + const screenshotMap = /* @__PURE__ */ new Map(); + let failedUploads = 0; + for (const ssPath of screenshotPaths) { + const asset = uploaded.get(ssPath); + if (asset) { + screenshotMap.set(screenshotLabel(ssPath), asset.url); + } else { + failedUploads++; + } + } + let video = null; + if (videoPath) { + const videoAsset = uploaded.get(videoPath); + if (videoAsset) { + video = { + url: videoAsset.url, + renderMode: uploadProvider === "repo-contents" ? "link" : "embed" + }; + } else failedUploads++; + } + if (failedUploads > 0) { + console.log(chalk6.yellow(`\u26A0 ${failedUploads} artifact(s) failed to upload`)); + } + if (filesToUpload.length > 0 && uploaded.size === 0) { + console.error( + chalk6.red("\u2717") + " All artifact uploads failed. PR comment was not posted.\n" + chalk6.dim( + uploadProvider === "github-web-attachments" ? 'Retry with "proofshot pr --upload-provider repo-contents" or use "proofshot pr --dry-run".' : 'Retry with "proofshot pr --dry-run" to inspect the generated markdown.' + ) + ); + process.exit(1); + } + const commentData = { + description, + sessionCount: sessionDirs.length, + screenshots: screenshotMap, + video, + errorCount, + branch, + commitSha: latestCommitSha + }; + const commentBody = formatPRComment(commentData); + console.log(chalk6.dim("Posting PR comment...")); + postPRComment(prNumber, commentBody); + console.log(""); + console.log(chalk6.green.bold(`\u2705 Posted ProofShot verification to PR #${prNumber}`)); + console.log( + chalk6.dim(` ${screenshotMap.size} screenshot(s), ${video ? "1 video" : "no video"}`) + ); +} +function screenshotLabel(ssPath) { + const sessionDir = path16.basename(path16.dirname(ssPath)); + const fileName = path16.basename(ssPath); + return `${sessionDir}/${fileName}`; +} +function buildUploadRoot(branch, prNumber, commitSha) { + const sanitizedBranch = branch.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "branch"; + const sha = commitSha ? commitSha.slice(0, 7) : "unknown-sha"; + const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); + return path16.posix.join("proofshot", `pr-${prNumber}`, sanitizedBranch, `${timestamp}-${sha}`); +} +function normalizeUploadProvider(provider) { + if (!provider || provider === "repo-contents" || provider === "github-web-attachments") { + return provider || "repo-contents"; + } + console.error( + chalk6.red("\u2717") + ` Invalid upload provider "${provider}". Use "repo-contents" or "github-web-attachments".` + ); + process.exit(1); +} + +// src/commands/doctor.ts +import chalk7 from "chalk"; + +// src/version.ts +var PROOFSHOT_VERSION = true ? "1.6.0" : readPackageVersion(); + +// src/commands/doctor.ts +function statusLabel(ok, text) { + return ok ? `${chalk7.green("\u2713")} ${text}` : `${chalk7.yellow("\u26A0")} ${text}`; +} +function printLine(label, value) { + console.log(`${label.padEnd(14)} ${value}`); +} +async function doctorCommand() { + const configPath = findConfigPath(); + const config = loadConfig(); + const controlDir = resolveSessionControlDir(config.output); + const session = loadSession(controlDir); + const agentBrowserPath = findExecutablePath("agent-browser"); + const ffmpegPath = findExecutablePath("ffmpeg"); + const agentBrowserVersion = readCommandVersion("agent-browser"); + const ffmpegVersion = readCommandVersion("ffmpeg"); + console.log(chalk7.bold("ProofShot Doctor")); + console.log(""); + printLine("ProofShot", PROOFSHOT_VERSION); + printLine("Config", configPath || chalk7.dim("not found")); + printLine("Output", config.output); + printLine("Control state", controlDir); + printLine("Browser mode", config.headless ? "headless" : "headed"); + printLine("Viewport", `${config.viewport.width}x${config.viewport.height}`); + console.log(""); + console.log(statusLabel(Boolean(agentBrowserPath), "agent-browser")); + printLine("Path", agentBrowserPath || chalk7.dim("not found")); + printLine("Version", agentBrowserVersion || chalk7.dim("not available")); + console.log(""); + console.log(statusLabel(Boolean(ffmpegPath), "ffmpeg")); + printLine("Path", ffmpegPath || chalk7.dim("not found")); + printLine("Version", ffmpegVersion || chalk7.dim("not available")); + console.log(""); + console.log(statusLabel(Boolean(session), "active session")); + if (session) { + printLine("Session dir", session.sessionDir); + printLine("Recording", session.recordingActive ? "active" : "stopped"); + printLine("Port", String(session.port)); + if (session.targetUrl) printLine("Target", session.targetUrl); + } else { + printLine("Session dir", chalk7.dim("none")); + } +} + +// src/cli.ts +function createCLI() { + const program2 = new Command(); + program2.name("proofshot").description("Visual verification for AI coding agents").version(PROOFSHOT_VERSION); + program2.command("install").description("Install ProofShot skills at user level for all detected AI coding tools").option("--only ", "Only install for these tools (comma-separated: claude,codex,cursor,gemini,windsurf,opencode)").option("--skip ", "Skip these tools (comma-separated)").option("--force", "Overwrite existing skill files even if unchanged").action(async (options) => { + await installCommand(options); + }); + program2.command("start").description("Start a verification session: browser, recording, error capture").option("--description ", "What is being verified (included in the proof report)").option("--port ", "Override detected port", parseInt).option("--run ", "Start this command and capture its logs").option("--headed", "Show browser window for debugging").option("--output ", "Custom output directory").option("--url ", "Open this URL instead of the root").option("--browser-executable ", "Use this Chrome/Chromium executable").option("--force", "Override a stale session without running stop first").action(async (options) => { + await startCommand(options); + }); + program2.command("stop").description("Stop session: stop recording, collect errors, bundle proof artifacts").option("--no-close", "Don't close the browser (keep it open for further use)").action(async (options) => { + await stopCommand({ noClose: options.close === false }); + }); + program2.command("diff").description("Compare current screenshots against a baseline").requiredOption("--baseline ", "Directory with baseline screenshots").action(async (options) => { + await diffCommand(options); + }); + program2.command("clean").description("Remove artifact files").action(async () => { + await cleanCommand(); + }); + program2.command("doctor").description("Inspect the local ProofShot environment and active session state").action(async () => { + await doctorCommand(); + }); + program2.command("pr").description("Upload session artifacts and post a ProofShot comment on a GitHub PR").argument("[pr-number]", "PR number (auto-detects from current branch if omitted)").option("--dry-run", "Generate the comment markdown without posting").option( + "--upload-provider ", + "Artifact upload backend: repo-contents or github-web-attachments", + "repo-contents" + ).option( + "--artifacts-branch ", + "Git branch used by the repo-contents upload provider", + "proofshot-artifacts" + ).action(async (prNumber, options) => { + await prCommand({ prNumber, ...options }); + }); + program2.command("exec").description("Run an agent-browser command with logging (use instead of agent-browser directly)").argument("", "agent-browser command and arguments").allowUnknownOption().action(async (args) => { + await execCommand(args); + }); + return program2; +} + +// bin/proofshot.ts +var program = createCLI(); +program.parse(); +//# sourceMappingURL=proofshot.js.map \ No newline at end of file diff --git a/dist/bin/proofshot.js.map b/dist/bin/proofshot.js.map new file mode 100644 index 0000000..0e56e39 --- /dev/null +++ b/dist/bin/proofshot.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/cli.ts","../../src/commands/install.ts","../../src/utils/skills.ts","../../src/commands/start.ts","../../src/utils/config.ts","../../src/utils/exec.ts","../../src/utils/process.ts","../../src/server/start.ts","../../src/utils/port.ts","../../src/browser/session.ts","../../src/browser/capture.ts","../../src/browser/discovery.ts","../../src/browser/runtime.ts","../../src/artifacts/bundle.ts","../../src/session/state.ts","../../src/session/lifecycle.ts","../../src/session/metadata.ts","../../src/commands/stop.ts","../../src/artifacts/viewer.ts","../../src/utils/error-patterns.ts","../../src/commands/exec.ts","../../src/utils/token-usage.ts","../../src/commands/diff.ts","../../src/commands/clean.ts","../../src/commands/pr.ts","../../src/utils/github.ts","../../src/artifacts/pr-format.ts","../../src/commands/doctor.ts","../../src/version.ts","../../bin/proofshot.ts"],"sourcesContent":["import { Command } from 'commander';\nimport { installCommand } from './commands/install.js';\nimport { startCommand } from './commands/start.js';\nimport { stopCommand } from './commands/stop.js';\nimport { diffCommand } from './commands/diff.js';\nimport { cleanCommand } from './commands/clean.js';\nimport { prCommand } from './commands/pr.js';\nimport { execCommand } from './commands/exec.js';\nimport { doctorCommand } from './commands/doctor.js';\nimport { PROOFSHOT_VERSION } from './version.js';\n\nexport function createCLI(): Command {\n const program = new Command();\n\n program\n .name('proofshot')\n .description('Visual verification for AI coding agents')\n .version(PROOFSHOT_VERSION);\n\n program\n .command('install')\n .description('Install ProofShot skills at user level for all detected AI coding tools')\n .option('--only ', 'Only install for these tools (comma-separated: claude,codex,cursor,gemini,windsurf,opencode)')\n .option('--skip ', 'Skip these tools (comma-separated)')\n .option('--force', 'Overwrite existing skill files even if unchanged')\n .action(async (options) => {\n await installCommand(options);\n });\n\n program\n .command('start')\n .description('Start a verification session: browser, recording, error capture')\n .option('--description ', 'What is being verified (included in the proof report)')\n .option('--port ', 'Override detected port', parseInt)\n .option('--run ', 'Start this command and capture its logs')\n .option('--headed', 'Show browser window for debugging')\n .option('--output ', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n saveSession(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n await cleanupFailedStart(session);\n clearSession(controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n process.exit(1);\n return;\n }\n\n session.recordingActive = true;\n saveSession(session, controlDir);\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return { alreadyRunning: false, port, process: processIdentity };\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n let cleanupError: unknown;\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearSession(controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearSession(controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n saveSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n saveSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) throw cleanupError;\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n saveSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n saveSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n saveSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n saveSession(session, controlDir);\n } else {\n clearSession(controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=", "Custom output directory").option("--url ", "Open this URL instead of the root").option("--browser-executable ", "Use this Chrome/Chromium executable").option("--force", "Override a stale session without running stop first").action(async (options) => { + await startCommand(options); + }); + program.command("stop").description("Stop session: stop recording, collect errors, bundle proof artifacts").option("--no-close", "Don't close the browser (keep it open for further use)").action(async (options) => { + await stopCommand({ noClose: options.close === false }); + }); + program.command("diff").description("Compare current screenshots against a baseline").requiredOption("--baseline ", "Directory with baseline screenshots").action(async (options) => { + await diffCommand(options); + }); + program.command("clean").description("Remove artifact files").action(async () => { + await cleanCommand(); + }); + program.command("doctor").description("Inspect the local ProofShot environment and active session state").action(async () => { + await doctorCommand(); + }); + program.command("pr").description("Upload session artifacts and post a ProofShot comment on a GitHub PR").argument("[pr-number]", "PR number (auto-detects from current branch if omitted)").option("--dry-run", "Generate the comment markdown without posting").option( + "--upload-provider ", + "Artifact upload backend: repo-contents or github-web-attachments", + "repo-contents" + ).option( + "--artifacts-branch ", + "Git branch used by the repo-contents upload provider", + "proofshot-artifacts" + ).action(async (prNumber, options) => { + await prCommand({ prNumber, ...options }); + }); + program.command("exec").description("Run an agent-browser command with logging (use instead of agent-browser directly)").argument("", "agent-browser command and arguments").allowUnknownOption().action(async (args) => { + await execCommand(args); + }); + return program; +} +export { + ProofShotError, + ab, + createCLI, + ensureDevServer, + findSessionsForBranch, + formatPRComment, + generateViewer, + installCommand, + isPortOpen, + loadConfig, + loadMetadata, + loadSession, + saveSession, + waitForPort, + writeConfig, + writeMetadata, + writeViewer +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/src/index.js.map b/dist/src/index.js.map new file mode 100644 index 0000000..4a6072e --- /dev/null +++ b/dist/src/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/cli.ts","../../src/commands/install.ts","../../src/utils/skills.ts","../../src/commands/start.ts","../../src/utils/config.ts","../../src/utils/exec.ts","../../src/utils/process.ts","../../src/server/start.ts","../../src/utils/port.ts","../../src/browser/session.ts","../../src/browser/capture.ts","../../src/browser/discovery.ts","../../src/browser/runtime.ts","../../src/artifacts/bundle.ts","../../src/session/state.ts","../../src/session/lifecycle.ts","../../src/session/metadata.ts","../../src/commands/stop.ts","../../src/artifacts/viewer.ts","../../src/utils/error-patterns.ts","../../src/commands/exec.ts","../../src/utils/token-usage.ts","../../src/commands/diff.ts","../../src/commands/clean.ts","../../src/commands/pr.ts","../../src/utils/github.ts","../../src/artifacts/pr-format.ts","../../src/commands/doctor.ts","../../src/version.ts"],"sourcesContent":["import { Command } from 'commander';\nimport { installCommand } from './commands/install.js';\nimport { startCommand } from './commands/start.js';\nimport { stopCommand } from './commands/stop.js';\nimport { diffCommand } from './commands/diff.js';\nimport { cleanCommand } from './commands/clean.js';\nimport { prCommand } from './commands/pr.js';\nimport { execCommand } from './commands/exec.js';\nimport { doctorCommand } from './commands/doctor.js';\nimport { PROOFSHOT_VERSION } from './version.js';\n\nexport function createCLI(): Command {\n const program = new Command();\n\n program\n .name('proofshot')\n .description('Visual verification for AI coding agents')\n .version(PROOFSHOT_VERSION);\n\n program\n .command('install')\n .description('Install ProofShot skills at user level for all detected AI coding tools')\n .option('--only ', 'Only install for these tools (comma-separated: claude,codex,cursor,gemini,windsurf,opencode)')\n .option('--skip ', 'Skip these tools (comma-separated)')\n .option('--force', 'Overwrite existing skill files even if unchanged')\n .action(async (options) => {\n await installCommand(options);\n });\n\n program\n .command('start')\n .description('Start a verification session: browser, recording, error capture')\n .option('--description ', 'What is being verified (included in the proof report)')\n .option('--port ', 'Override detected port', parseInt)\n .option('--run ', 'Start this command and capture its logs')\n .option('--headed', 'Show browser window for debugging')\n .option('--output ', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n saveSession(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n await cleanupFailedStart(session);\n clearSession(controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n process.exit(1);\n return;\n }\n\n session.recordingActive = true;\n saveSession(session, controlDir);\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return { alreadyRunning: false, port, process: processIdentity };\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n let cleanupError: unknown;\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearSession(controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearSession(controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n saveSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n saveSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) throw cleanupError;\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n saveSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n saveSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n saveSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n saveSession(session, controlDir);\n } else {\n clearSession(controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n saveSession(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n await cleanupFailedStart(session);\n clearSession(controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n process.exit(1);\n return;\n }\n\n session.recordingActive = true;\n saveSession(session, controlDir);\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return { alreadyRunning: false, port, process: processIdentity };\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n let cleanupError: unknown;\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearSession(controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearSession(controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n saveSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n saveSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) throw cleanupError;\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n saveSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n saveSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n saveSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n saveSession(session, controlDir);\n } else {\n clearSession(controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.homedir(),\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n saveSession(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n saveSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n await cleanupFailedStart(session);\n clearSession(controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n process.exit(1);\n return;\n }\n\n session.recordingActive = true;\n saveSession(session, controlDir);\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return { alreadyRunning: false, port, process: processIdentity };\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n let cleanupError: unknown;\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearSession(controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearSession(controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n saveSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n saveSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) throw cleanupError;\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n saveSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n saveSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n saveSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n saveSession(session, controlDir);\n } else {\n clearSession(controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.homedir(),\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.homedir(),\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\nimport { startOwnedEnvironment } from '../environment/runtime.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n environment: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run && config.environment) {\n throw new Error('Use either --run or config.environment, not both.');\n }\n if (config.environment || (config.logs?.sources || []).some((source) => source.kind === 'file')) {\n failureContext = 'start environment';\n session.environment = await startOwnedEnvironment(\n config.environment,\n config.logs || {},\n sessionDir,\n sessionName,\n new Date(session.startedAt).getTime(),\n (environmentState) => {\n session.environment = environmentState;\n persistOwnedSession(session, controlDir);\n },\n );\n console.log(chalk.green('✓') + ' Environment and log capture started');\n }\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else if (!config.environment) {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type {\n EnvironmentConfig,\n LogsConfig,\n LogSourceConfig,\n} from '../environment/types.js';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n environment?: EnvironmentConfig;\n logs: LogsConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n logs: {\n stripAnsi: true,\n maxBytesPerSource: 5 * 1024 * 1024,\n sources: [],\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n const environment = resolveEnvironmentConfig(parsed.environment, configDir);\n const logs = resolveLogsConfig(parsed.logs, configDir);\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n environment,\n logs,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\nfunction resolveEnvironmentConfig(\n value: unknown,\n configDir: string,\n): EnvironmentConfig | undefined {\n if (typeof value !== 'object' || value === null) {\n return undefined;\n }\n const environment = value as EnvironmentConfig;\n if (environment.kind === 'tmux') {\n const launch =\n environment.launch.kind === 'panes'\n ? {\n ...environment.launch,\n panes: environment.launch.panes.map((pane) => ({\n ...pane,\n cwd: path.resolve(configDir, pane.cwd || environment.cwd || '.'),\n })),\n }\n : environment.launch;\n return {\n ...environment,\n cwd: path.resolve(configDir, environment.cwd || '.'),\n connection: environment.connection?.socket\n ? {\n ...environment.connection,\n socket: path.resolve(configDir, environment.connection.socket),\n }\n : environment.connection,\n launch,\n };\n }\n if (environment.kind === 'processes') {\n return {\n ...environment,\n commands: environment.commands.map((command) => ({\n ...command,\n cwd: path.resolve(configDir, command.cwd || '.'),\n })),\n };\n }\n return undefined;\n}\n\nfunction resolveLogsConfig(value: unknown, configDir: string): LogsConfig {\n const logs =\n typeof value === 'object' && value !== null\n ? (value as LogsConfig)\n : DEFAULT_CONFIG.logs;\n const sources: LogSourceConfig[] = (logs.sources || []).map((source) =>\n source.kind === 'file'\n ? { ...source, path: path.resolve(configDir, source.path) }\n : source,\n );\n return {\n ...DEFAULT_CONFIG.logs,\n ...logs,\n sources,\n };\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport async function terminateOwnedProcess(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity || !processIdentityMatches(identity)) {\n return false;\n }\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n try {\n process.kill(identity.pid, 'SIGTERM');\n } catch {\n return false;\n }\n\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && processIdentityMatches(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n if (processIdentityMatches(identity)) {\n try {\n process.kill(identity.pid, 'SIGKILL');\n } catch {\n return false;\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { EnvironmentState } from '../environment/types.js';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n environment?: EnvironmentState | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import * as fs from 'fs';\nimport * as net from 'net';\nimport * as path from 'path';\nimport { startFileCapture, startProcessCapture } from './workers.js';\nimport { startTmuxEnvironment, stopTmuxEnvironment } from './tmux.js';\nimport type {\n EnvironmentConfig,\n EnvironmentState,\n LogSourceConfig,\n LogsConfig,\n ProcessDefinition,\n ProcessEnvironmentState,\n ReadinessCheck,\n ResolvedLogSourceState,\n} from './types.js';\nimport {\n ownedProcessTreeIsAlive,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\nexport async function startOwnedEnvironment(\n environment: EnvironmentConfig | undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const fileSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'file',\n );\n if (!environment && fileSources.length === 0) {\n return null;\n }\n\n let state: EnvironmentState;\n if (environment?.kind === 'tmux') {\n state = await startTmuxEnvironment(\n environment,\n logs,\n sessionDir,\n sessionName,\n startTimeMs,\n onState,\n );\n } else {\n state = await startProcessEnvironment(\n environment?.kind === 'processes' ? environment.commands : [],\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n }\n\n try {\n state = await attachFileSources(\n state,\n fileSources,\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n if (environment) {\n await waitForReadiness(environment.readiness || []);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nexport async function stopOwnedEnvironment(\n state: EnvironmentState | null | undefined,\n): Promise {\n if (!state) {\n return;\n }\n switch (state.kind) {\n case 'tmux':\n await stopTmuxEnvironment(state);\n return;\n case 'processes':\n for (const capture of state.processes) {\n await terminateOwnedProcessTree(capture.process, { graceMs: 1000 });\n if (ownedProcessTreeIsAlive(capture.process)) {\n throw new Error(`Environment process ${capture.sourceId} did not stop.`);\n }\n }\n return;\n default: {\n const exhaustiveState: never = state;\n return exhaustiveState;\n }\n }\n}\n\nasync function startProcessEnvironment(\n definitions: ProcessDefinition[],\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n const configuredSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'process',\n );\n const sources =\n configuredSources.length > 0\n ? configuredSources\n : definitions.map((definition) => ({\n id: definition.id,\n title: definition.title,\n group: definition.group,\n kind: 'process' as const,\n processId: definition.id,\n include: undefined,\n exclude: undefined,\n }));\n validateUniqueIds(sources.map((source) => source.id));\n\n let state: ProcessEnvironmentState = {\n kind: 'processes',\n evidencePath,\n sources: [],\n processes: [],\n };\n onState(state);\n try {\n for (const sourceConfig of sources) {\n const definition = definitions.find(\n (candidate) => candidate.id === sourceConfig.processId,\n );\n if (!definition) {\n throw new Error(\n `Log source ${sourceConfig.id} references unknown process ${sourceConfig.processId}.`,\n );\n }\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title: sourceConfig.title || definition.title || definition.id,\n group: sourceConfig.group || definition.group || 'environment',\n kind: 'process',\n stream: 'stdout',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n const process = await startProcessCapture(\n definition,\n source,\n evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state = {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, process],\n };\n onState(state);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nasync function attachFileSources(\n state: EnvironmentState,\n fileSources: Array>,\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n if (fileSources.length === 0) {\n return state;\n }\n const knownIds = new Set(state.sources.map((source) => source.id));\n const logsDir = path.join(sessionDir, 'logs');\n for (const fileSource of fileSources) {\n if (knownIds.has(fileSource.id)) {\n throw new Error(`Duplicate log source id: ${fileSource.id}`);\n }\n knownIds.add(fileSource.id);\n const source: ResolvedLogSourceState = {\n id: fileSource.id,\n title: fileSource.title || path.basename(fileSource.path),\n group: fileSource.group || 'environment',\n kind: 'file',\n stream: 'file',\n logPath: path.join(logsDir, `${fileSource.id}.log`),\n include: fileSource.include,\n exclude: fileSource.exclude,\n };\n const capture = await startFileCapture(\n fileSource.path,\n source,\n state.evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state =\n state.kind === 'tmux'\n ? {\n ...state,\n sources: [...state.sources, source],\n captures: [...state.captures, capture],\n }\n : {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, capture],\n };\n onState(state);\n }\n return state;\n}\n\nasync function waitForReadiness(checks: ReadinessCheck[]): Promise {\n for (const check of checks) {\n const timeoutMs = check.timeoutMs || 30 * 1000;\n const deadline = Date.now() + timeoutMs;\n let lastError = 'not ready';\n while (Date.now() < deadline) {\n try {\n if (check.kind === 'http') {\n const response = await fetch(check.url, {\n signal: AbortSignal.timeout(Math.min(2000, timeoutMs)),\n });\n if (response.ok) {\n lastError = '';\n break;\n }\n lastError = `HTTP ${response.status}`;\n } else {\n await connectTcp(check.host || '127.0.0.1', check.port);\n lastError = '';\n break;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n }\n await new Promise((resolve) => setTimeout(resolve, 100));\n }\n if (lastError) {\n const target =\n check.kind === 'http'\n ? check.url\n : `${check.host || '127.0.0.1'}:${check.port}`;\n throw new Error(`Environment readiness failed for ${target}: ${lastError}`);\n }\n }\n}\n\nfunction connectTcp(host: string, port: number): Promise {\n return new Promise((resolve, reject) => {\n const socket = net.createConnection({ host, port });\n const timer = setTimeout(() => {\n socket.destroy();\n reject(new Error('TCP readiness timed out'));\n }, 2000);\n socket.once('connect', () => {\n clearTimeout(timer);\n socket.end();\n resolve();\n });\n socket.once('error', (error) => {\n clearTimeout(timer);\n reject(error);\n });\n });\n}\n\nfunction validateUniqueIds(ids: string[]): void {\n const seen = new Set();\n for (const id of ids) {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n if (seen.has(id)) {\n throw new Error(`Duplicate log source id: ${id}`);\n }\n seen.add(id);\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { spawn } from 'child_process';\nimport { appendEvidenceEvent, normalizeLogText } from './evidence.js';\nimport type {\n CaptureProcessState,\n EvidenceEvent,\n ProcessDefinition,\n ResolvedLogSourceState,\n} from './types.js';\nimport { captureProcessIdentity, type ProcessIdentity } from '../utils/process.js';\n\ntype WorkerConfig = {\n evidencePath: string;\n logPath: string;\n pidFile?: string;\n startTimeMs: number;\n maxBytes: number;\n stripAnsi: boolean;\n source: ResolvedLogSourceState;\n command?: string;\n cwd?: string;\n env?: Record;\n offset?: number;\n};\n\nconst COMMON_WORKER_SOURCE = String.raw`\nconst fs = require('fs');\nconst config = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));\nconst ansiPattern = /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst controlPattern = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\nlet bytesWritten = 0;\nlet truncated = false;\nfunction normalize(text) {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(controlPattern, '');\n return config.stripAnsi ? normalized.replace(ansiPattern, '') : normalized;\n}\nfunction writeEvent(text, stream, segment = 'live', extra = {}) {\n const normalized = normalize(text);\n if (normalized.length === 0) return;\n const bytes = Buffer.byteLength(normalized);\n if (bytesWritten + bytes > config.maxBytes) {\n if (!truncated) {\n truncated = true;\n const now = Date.now();\n const event = {\n version: 1,\n origin: 'environment',\n group: config.source.group,\n sourceId: config.source.id,\n sourceTitle: config.source.title,\n stream,\n segment,\n timestamp: new Date(now).toISOString(),\n relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000),\n text: '[ProofShot capture truncated at configured byte limit]',\n truncated: true,\n };\n fs.appendFileSync(config.evidencePath, JSON.stringify(event) + '\\n');\n fs.appendFileSync(config.logPath, event.text + '\\n');\n }\n return;\n }\n bytesWritten += bytes;\n const now = Date.now();\n const event = {\n version: 1,\n origin: 'environment',\n group: config.source.group,\n sourceId: config.source.id,\n sourceTitle: config.source.title,\n stream,\n segment,\n timestamp: new Date(now).toISOString(),\n relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000),\n text: normalized,\n ...extra,\n };\n fs.appendFileSync(config.evidencePath, JSON.stringify(event) + '\\n');\n fs.appendFileSync(config.logPath, normalized + '\\n');\n}\nfunction attachLines(stream, streamName) {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString().replace(/\\r\\n?/g, '\\n');\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) writeEvent(line, streamName);\n });\n stream.on('end', () => {\n if (buffer.length > 0) writeEvent(buffer, streamName);\n buffer = '';\n });\n}\nif (config.pidFile) {\n fs.writeFileSync(config.pidFile, String(process.pid), { mode: 0o600 });\n}\nfunction removePidFile() {\n if (config.pidFile) {\n try { fs.unlinkSync(config.pidFile); } catch {}\n }\n}\n`;\n\nconst TMUX_PIPE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nattachLines(process.stdin, 'pty');\nprocess.stdin.on('end', () => {\n removePidFile();\n process.exit(0);\n});\nprocess.on('SIGTERM', () => {\n removePidFile();\n process.exit(0);\n});\n`;\n\nconst PROCESS_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nconst { spawn } = require('child_process');\nconst child = spawn(config.command, {\n cwd: config.cwd,\n env: { ...process.env, ...config.env },\n shell: process.env.SHELL || '/bin/sh',\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nattachLines(child.stdout, 'stdout');\nattachLines(child.stderr, 'stderr');\nchild.on('error', (error) => writeEvent(error.stack || error.message || String(error), 'stderr'));\nchild.on('close', (code) => {\n writeEvent('[process exited with code ' + (code == null ? 'unknown' : code) + ']', 'stderr');\n removePidFile();\n process.exit(code == null ? 1 : code);\n});\nfor (const signal of ['SIGINT', 'SIGTERM']) {\n process.on(signal, () => {\n try { child.kill(signal); } catch {}\n });\n}\n`;\n\nconst FILE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nlet offset = config.offset || 0;\nlet buffered = '';\nfunction readAvailable() {\n let stat;\n try {\n stat = fs.statSync(config.filePath);\n } catch {\n return;\n }\n if (stat.size < offset) {\n offset = 0;\n writeEvent('[file rotated or truncated]', 'file', 'live', { captureGap: true });\n }\n if (stat.size === offset) return;\n const length = stat.size - offset;\n const fd = fs.openSync(config.filePath, 'r');\n const buffer = Buffer.alloc(length);\n fs.readSync(fd, buffer, 0, length, offset);\n fs.closeSync(fd);\n offset = stat.size;\n buffered += buffer.toString();\n const lines = buffered.split('\\\\n');\n buffered = lines.pop() || '';\n for (const line of lines) writeEvent(line, 'file');\n}\nconst timer = setInterval(readAvailable, 100);\nfunction stop() {\n clearInterval(timer);\n if (buffered.length > 0) writeEvent(buffered, 'file');\n removePidFile();\n process.exit(0);\n}\nprocess.on('SIGINT', stop);\nprocess.on('SIGTERM', stop);\n`;\n\nexport function buildTmuxPipeCommand(config: WorkerConfig): string {\n const encodedConfig = encodeConfig(config);\n return [\n shellQuote(process.execPath),\n '-e',\n shellQuote(TMUX_PIPE_RUNNER_SOURCE),\n shellQuote(encodedConfig),\n ].join(' ');\n}\n\nexport async function waitForCaptureProcess(\n sourceId: string,\n pidFile: string,\n timeoutMs = 2000,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = readPidIdentity(pidFile);\n if (identity) {\n return { sourceId, process: identity, pidFile };\n }\n await new Promise((resolve) => setTimeout(resolve, 25));\n } while (Date.now() < deadline);\n\n throw new Error(`ProofShot could not capture the log helper identity for ${sourceId}.`);\n}\n\nexport async function startProcessCapture(\n definition: ProcessDefinition,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n const config: WorkerConfig = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes,\n stripAnsi,\n source,\n command: definition.command,\n cwd: definition.cwd,\n env: definition.env,\n };\n return startDetachedWorker(source.id, pidFile, PROCESS_RUNNER_SOURCE, config);\n}\n\nexport async function startFileCapture(\n filePath: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n let offset = 0;\n if (fs.existsSync(filePath)) {\n const raw = fs.readFileSync(filePath, 'utf-8');\n appendHistory(\n raw,\n source,\n evidencePath,\n maxBytes,\n stripAnsi,\n 'file',\n );\n offset = fs.statSync(filePath).size;\n }\n\n const config = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes,\n stripAnsi,\n source,\n offset,\n filePath,\n };\n return startDetachedWorker(source.id, pidFile, FILE_RUNNER_SOURCE, config);\n}\n\nexport function appendHistory(\n raw: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n maxBytes: number,\n stripAnsi: boolean,\n stream: EvidenceEvent['stream'],\n): void {\n const normalized = normalizeLogText(raw, stripAnsi);\n const buffer = Buffer.from(normalized);\n const truncated = buffer.byteLength > maxBytes;\n const retained = truncated\n ? buffer.subarray(Math.max(0, buffer.byteLength - maxBytes)).toString('utf-8')\n : normalized;\n const lines = retained.split('\\n').filter((line) => line.length > 0);\n fs.appendFileSync(source.logPath, retained + (retained.endsWith('\\n') ? '' : '\\n'));\n for (const line of lines) {\n appendEvidenceEvent(evidencePath, {\n version: 1,\n origin: 'environment',\n group: source.group,\n sourceId: source.id,\n sourceTitle: source.title,\n stream,\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: line,\n truncated: truncated || undefined,\n });\n }\n}\n\nexport function createWorkerConfig(params: WorkerConfig): WorkerConfig {\n return params;\n}\n\nasync function startDetachedWorker(\n sourceId: string,\n pidFile: string,\n workerSource: string,\n config: object,\n): Promise {\n fs.mkdirSync(path.dirname(pidFile), { recursive: true });\n const errorFd = fs.openSync(`${pidFile}.stderr`, 'a', 0o600);\n const worker = spawn(process.execPath, ['-e', workerSource, encodeConfig(config)], {\n detached: true,\n stdio: ['ignore', 'ignore', errorFd],\n });\n fs.closeSync(errorFd);\n worker.unref();\n\n let identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n for (let attempt = 0; !identity && attempt < 20; attempt += 1) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n }\n if (!identity) {\n try {\n if (worker.pid) {\n process.kill(-worker.pid, 'SIGKILL');\n }\n } catch {\n // The worker may already have exited.\n }\n throw new Error(`ProofShot could not capture the runner identity for ${sourceId}.`);\n }\n return { sourceId, process: identity, pidFile };\n}\n\nfunction readPidIdentity(pidFile: string): ProcessIdentity | null {\n try {\n const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());\n return captureProcessIdentity(pid);\n } catch {\n return null;\n }\n}\n\nfunction encodeConfig(config: object): string {\n return Buffer.from(JSON.stringify(config)).toString('base64');\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n","import * as fs from 'fs';\nimport type { EvidenceEvent } from './types.js';\n\nconst ANSI_PATTERN =\n // eslint-disable-next-line no-control-regex\n /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst CONTROL_PATTERN = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\n\nexport function normalizeLogText(text: string, stripAnsi = true): string {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(CONTROL_PATTERN, '');\n return stripAnsi ? normalized.replace(ANSI_PATTERN, '') : normalized;\n}\n\nexport function appendEvidenceEvent(filePath: string, event: EvidenceEvent): void {\n fs.appendFileSync(filePath, JSON.stringify(event) + '\\n');\n}\n\nexport function loadEvidenceEvents(filePath: string): EvidenceEvent[] {\n if (!fs.existsSync(filePath)) {\n return [];\n }\n\n return fs\n .readFileSync(filePath, 'utf-8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => {\n try {\n return JSON.parse(line) as EvidenceEvent;\n } catch {\n return null;\n }\n })\n .filter((event): event is EvidenceEvent => event !== null);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execFileSync } from 'child_process';\nimport {\n appendHistory,\n buildTmuxPipeCommand,\n createWorkerConfig,\n waitForCaptureProcess,\n} from './workers.js';\nimport { appendEvidenceEvent } from './evidence.js';\nimport type {\n ExternalTmuxConnection,\n LogSourceConfig,\n LogsConfig,\n ResolvedLogSourceState,\n TmuxEnvironmentConfig,\n TmuxEnvironmentState,\n TmuxPaneState,\n} from './types.js';\nimport {\n captureProcessIdentity,\n processIdentityMatches,\n spawnShellCommand,\n terminateOwnedProcess,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\ntype PaneMapping = {\n key: string;\n paneId: string;\n title?: string;\n group?: string;\n};\n\ntype TmuxConnection = {\n socketPath: string;\n sessionName: string;\n paneMappings: PaneMapping[];\n ownsServer: boolean;\n ownsSession: boolean;\n};\n\nexport async function startTmuxEnvironment(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n proofShotSessionName: string,\n startTimeMs: number,\n onState: (state: TmuxEnvironmentState) => void,\n): Promise {\n assertTmuxAvailable();\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n const captureDir = path.join(sessionDir, '.capture');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.mkdirSync(captureDir, { recursive: true, mode: 0o700 });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n let state: TmuxEnvironmentState | null = null;\n let connection: TmuxConnection;\n try {\n connection =\n config.launch.kind === 'panes'\n ? startOwnedTmux(config, proofShotSessionName, (startedConnection) => {\n state = createTmuxState(\n config,\n startedConnection,\n evidencePath,\n );\n onState(state);\n })\n : await startExternalTmux(config);\n if (!state) {\n state = createTmuxState(config, connection, evidencePath);\n onState(state);\n }\n } catch (error) {\n if (state) {\n await stopTmuxEnvironment(state).catch(() => {});\n }\n throw error;\n }\n\n try {\n const tmuxSources = resolveTmuxSources(config, logs, connection);\n const panes = tmuxSources.map(({ config: sourceConfig, mapping }) =>\n resolvePane(\n connection.socketPath,\n sourceConfig,\n mapping,\n logsDir,\n ),\n );\n disambiguateTitles(panes);\n state = { ...state, panes, sources: panes.map((pane) => pane.source) };\n onState(state);\n\n for (const pane of panes) {\n const pipeStatus = tmuxExec(connection.socketPath, [\n 'display-message',\n '-p',\n '-t',\n pane.pane.paneId,\n '#{pane_pipe}',\n ]);\n if (pipeStatus === '1') {\n throw new Error(\n `tmux pane ${pane.pane.paneId} already has a pipe-pane consumer.`,\n );\n }\n\n const pidFile = path.join(captureDir, `${pane.source.id}.pid`);\n const workerConfig = createWorkerConfig({\n evidencePath,\n logPath: pane.source.logPath,\n pidFile,\n startTimeMs,\n maxBytes: logs.maxBytesPerSource || 5 * 1024 * 1024,\n stripAnsi: logs.stripAnsi !== false,\n source: pane.source,\n });\n tmuxExec(connection.socketPath, [\n 'pipe-pane',\n '-t',\n pane.pane.paneId,\n buildTmuxPipeCommand(workerConfig),\n ]);\n const history = tmuxExec(connection.socketPath, [\n 'capture-pane',\n '-p',\n '-S',\n '-',\n '-t',\n pane.pane.paneId,\n ]);\n appendHistory(\n history,\n pane.source,\n evidencePath,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n 'pty',\n );\n appendEvidenceEvent(evidencePath, {\n version: 1,\n origin: 'environment',\n group: pane.source.group,\n sourceId: pane.source.id,\n sourceTitle: pane.source.title,\n stream: 'pty',\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: '[tmux history/live capture boundary]',\n captureGap: true,\n });\n const capture = await waitForCaptureProcess(pane.source.id, pidFile);\n state = { ...state, captures: [...state.captures, capture] };\n onState(state);\n }\n return state;\n } catch (error) {\n await stopTmuxEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nexport async function stopTmuxEnvironment(\n state: TmuxEnvironmentState,\n): Promise {\n if (\n !processIdentityMatches(state.serverProcess) &&\n !fs.existsSync(state.socket.path) &&\n state.captures.every((capture) => !processIdentityMatches(capture.process))\n ) {\n return;\n }\n assertSocketIdentity(state);\n if (!processIdentityMatches(state.serverProcess)) {\n throw new Error('tmux server identity changed; refusing widened cleanup.');\n }\n\n if (state.stopCommand) {\n await runCommand(state.stopCommand, state.stopCwd || process.cwd());\n } else if (state.ownsSession && tmuxHasSession(state)) {\n tmuxExec(state.socket.path, ['kill-session', '-t', state.sessionName]);\n } else {\n for (const pane of state.panes) {\n try {\n tmuxExec(state.socket.path, ['pipe-pane', '-t', pane.paneId]);\n } catch {\n // A launcher-provided shutdown may already have removed the pane.\n }\n }\n }\n\n for (const capture of state.captures) {\n await terminateOwnedProcess(capture.process, { graceMs: 500 });\n if (processIdentityMatches(capture.process)) {\n throw new Error(`Log helper for ${capture.sourceId} did not stop.`);\n }\n }\n\n if (state.ownsServer && processIdentityMatches(state.serverProcess)) {\n try {\n tmuxExec(state.socket.path, ['kill-server']);\n } catch {\n await terminateOwnedProcessTree(state.serverProcess, { graceMs: 500 });\n }\n }\n if (state.ownsServer && processIdentityMatches(state.serverProcess)) {\n throw new Error('Owned tmux server did not stop.');\n }\n if (state.ownsServer && fs.existsSync(state.socket.path)) {\n const currentSocket = captureSocketIdentity(state.socket.path);\n if (\n currentSocket.inode !== state.socket.inode ||\n currentSocket.uid !== state.socket.uid\n ) {\n throw new Error('tmux socket changed before final cleanup.');\n }\n fs.unlinkSync(state.socket.path);\n }\n if (state.ownsSession && tmuxHasSession(state)) {\n throw new Error(`Owned tmux session ${state.sessionName} did not stop.`);\n }\n}\n\nfunction startOwnedTmux(\n config: TmuxEnvironmentConfig,\n proofShotSessionName: string,\n onStarted: (connection: TmuxConnection) => void,\n): TmuxConnection {\n if (config.launch.kind !== 'panes' || config.launch.panes.length === 0) {\n throw new Error('tmux pane launch requires at least one pane.');\n }\n const uid = process.getuid?.() ?? process.pid;\n const socketDir = path.join('/tmp', `proofshot-${uid}`, 'tmux');\n fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });\n const socketPath = path.join(socketDir, `${proofShotSessionName}.sock`);\n if (fs.existsSync(socketPath)) {\n throw new Error(`Refusing to reuse an existing tmux socket: ${socketPath}`);\n }\n\n const sessionName = config.launch.sessionName || proofShotSessionName;\n const [firstPane, ...remainingPanes] = config.launch.panes;\n const first = parsePaneOutput(\n tmuxExec(socketPath, [\n 'new-session',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-s',\n sessionName,\n '-n',\n 'environment',\n '-c',\n firstPane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(firstPane),\n ]),\n );\n const mappings: PaneMapping[] = [\n {\n key: firstPane.id,\n paneId: first.paneId,\n title: firstPane.title,\n group: firstPane.group,\n },\n ];\n configurePane(socketPath, first.paneId, firstPane.id, firstPane.title);\n onStarted({\n socketPath,\n sessionName,\n paneMappings: [...mappings],\n ownsServer: true,\n ownsSession: true,\n });\n\n for (const pane of remainingPanes) {\n const created = parsePaneOutput(\n tmuxExec(socketPath, [\n 'split-window',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-t',\n `${sessionName}:environment`,\n '-c',\n pane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(pane),\n ]),\n );\n configurePane(socketPath, created.paneId, pane.id, pane.title);\n mappings.push({\n key: pane.id,\n paneId: created.paneId,\n title: pane.title,\n group: pane.group,\n });\n }\n tmuxExec(socketPath, ['select-layout', '-t', `${sessionName}:environment`, 'tiled']);\n return {\n socketPath,\n sessionName,\n paneMappings: mappings,\n ownsServer: true,\n ownsSession: true,\n };\n}\n\nfunction createTmuxState(\n config: TmuxEnvironmentConfig,\n connection: TmuxConnection,\n evidencePath: string,\n): TmuxEnvironmentState {\n const serverPid = Number(\n tmuxExec(connection.socketPath, ['display-message', '-p', '#{pid}']),\n );\n const serverProcess = captureProcessIdentity(serverPid);\n if (!serverProcess) {\n throw new Error('ProofShot could not capture the exact tmux server identity.');\n }\n return {\n kind: 'tmux',\n evidencePath,\n sources: [],\n socket: captureSocketIdentity(connection.socketPath),\n serverProcess,\n sessionName: connection.sessionName,\n ownsServer: connection.ownsServer,\n ownsSession: connection.ownsSession,\n panes: [],\n captures: [],\n stopCommand:\n config.launch.kind === 'external-command'\n ? config.launch.stopCommand\n : undefined,\n stopCwd: config.cwd,\n };\n}\n\nasync function startExternalTmux(\n config: TmuxEnvironmentConfig,\n): Promise {\n if (config.launch.kind !== 'external-command' || !config.connection) {\n throw new Error('External tmux launch requires a connection contract.');\n }\n const hintedSocket = config.connection.socket;\n const socketExistedBefore = hintedSocket ? fs.existsSync(hintedSocket) : true;\n const output = await runCommand(\n config.launch.command,\n config.cwd || process.cwd(),\n );\n const parsed =\n config.connection.format === 'json'\n ? parseJsonConnection(output)\n : parseAttachCommand(output);\n const ownsCreatedSocket =\n hintedSocket !== undefined &&\n path.resolve(hintedSocket) === path.resolve(parsed.socketPath) &&\n !socketExistedBefore;\n return {\n ...parsed,\n ownsServer: ownsCreatedSocket,\n ownsSession: ownsCreatedSocket,\n };\n}\n\nfunction resolveTmuxSources(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n connection: TmuxConnection,\n): Array<{\n config: Extract;\n mapping?: PaneMapping;\n}> {\n const configured = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'tmux-pane',\n );\n if (configured.length > 0) {\n return configured.map((source) => ({\n config: source,\n mapping:\n 'connectionKey' in source.match\n ? connection.paneMappings.find(\n (mapping) => mapping.key === source.match.connectionKey,\n )\n : undefined,\n }));\n }\n if (config.launch.kind !== 'panes') {\n return [];\n }\n return connection.paneMappings.map((mapping) => ({\n config: {\n id: mapping.key,\n title: mapping.title,\n group: mapping.group,\n kind: 'tmux-pane',\n match: { connectionKey: mapping.key },\n },\n mapping,\n }));\n}\n\nfunction resolvePane(\n socketPath: string,\n sourceConfig: Extract,\n mapping: PaneMapping | undefined,\n logsDir: string,\n): { pane: TmuxPaneState; source: ResolvedLogSourceState } {\n let target: string;\n if ('connectionKey' in sourceConfig.match) {\n if (!mapping) {\n throw new Error(\n `No tmux pane mapping matched connection key \"${sourceConfig.match.connectionKey}\".`,\n );\n }\n target = mapping.paneId;\n } else if ('tag' in sourceConfig.match) {\n const matches = tmuxExec(socketPath, [\n 'list-panes',\n '-a',\n '-F',\n '#{pane_id}\\t#{@proofshot-source}',\n ])\n .split('\\n')\n .filter((line) => line.split('\\t')[1] === sourceConfig.match.tag);\n if (matches.length !== 1) {\n throw new Error(\n `Expected one tmux pane tagged \"${sourceConfig.match.tag}\", found ${matches.length}.`,\n );\n }\n target = matches[0].split('\\t')[0];\n } else {\n target = sourceConfig.match.target;\n }\n\n const fields = tmuxExec(socketPath, [\n 'display-message',\n '-p',\n '-t',\n target,\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}\\t#{pane_title}\\t#{session_name}:#{window_name}.#{pane_index}',\n ]).split('\\t');\n if (fields.length !== 5) {\n throw new Error(`Could not resolve tmux pane metadata for ${target}.`);\n }\n const paneIndex = Number(fields[1]);\n const tmuxTitle = fields[3].trim();\n const title =\n sourceConfig.title ||\n mapping?.title ||\n (tmuxTitle.length > 0 ? tmuxTitle : `Pane ${paneIndex}`);\n const group = sourceConfig.group || mapping?.group || 'environment';\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title,\n group,\n kind: 'tmux-pane',\n stream: 'pty',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n return {\n source,\n pane: {\n paneId: fields[0],\n paneIndex,\n panePid: Number(fields[2]),\n sourceId: source.id,\n title,\n group,\n target: fields[4],\n },\n };\n}\n\nfunction disambiguateTitles(\n panes: Array<{ pane: TmuxPaneState; source: ResolvedLogSourceState }>,\n): void {\n const counts = new Map();\n for (const pane of panes) {\n counts.set(pane.source.title, (counts.get(pane.source.title) || 0) + 1);\n }\n for (const pane of panes) {\n if ((counts.get(pane.source.title) || 0) > 1) {\n const title = `${pane.source.title} (Pane ${pane.pane.paneIndex})`;\n pane.source.title = title;\n pane.pane.title = title;\n }\n }\n}\n\nfunction configurePane(\n socketPath: string,\n paneId: string,\n sourceId: string,\n title?: string,\n): void {\n validateId(sourceId);\n tmuxExec(socketPath, [\n 'set-option',\n '-p',\n '-t',\n paneId,\n '@proofshot-source',\n sourceId,\n ]);\n if (title) {\n tmuxExec(socketPath, ['select-pane', '-t', paneId, '-T', title]);\n }\n}\n\nfunction buildPaneCommand(\n pane: { command: string; env?: Record },\n): string {\n const assignments = Object.entries(pane.env || {}).map(([key, value]) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\n throw new Error(`Invalid environment variable name: ${key}`);\n }\n return `${key}=${shellQuote(value)}`;\n });\n return assignments.length > 0\n ? `env ${assignments.join(' ')} ${pane.command}`\n : pane.command;\n}\n\nfunction parsePaneOutput(output: string): {\n paneId: string;\n paneIndex: number;\n panePid: number;\n} {\n const [paneId, paneIndex, panePid] = output.split('\\t');\n if (!paneId || !Number.isInteger(Number(paneIndex)) || !Number.isInteger(Number(panePid))) {\n throw new Error(`Unexpected tmux pane output: ${output}`);\n }\n return { paneId, paneIndex: Number(paneIndex), panePid: Number(panePid) };\n}\n\nfunction parseJsonConnection(output: string): TmuxConnection {\n const parsed = JSON.parse(output) as ExternalTmuxConnection;\n if (\n !parsed.tmux ||\n !path.isAbsolute(parsed.tmux.socket) ||\n typeof parsed.tmux.session !== 'string'\n ) {\n throw new Error('External launcher returned invalid tmux JSON.');\n }\n return {\n socketPath: parsed.tmux.socket,\n sessionName: parsed.tmux.session,\n paneMappings: parsed.tmux.panes || [],\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction parseAttachCommand(output: string): TmuxConnection {\n const match = output.match(\n /tmux\\s+(?:(-S)\\s+(\\S+)|(-L)\\s+(\\S+)).*?attach(?:-session)?\\s+-t\\s+(\\S+)/,\n );\n if (!match) {\n throw new Error('External launcher did not emit a supported tmux attach command.');\n }\n const flag = match[1] || match[3];\n const value = stripShellQuotes(match[2] || match[4]);\n const sessionName = stripShellQuotes(match[5]);\n const socketPath =\n flag === '-S'\n ? path.resolve(value)\n : execFileSync(\n 'tmux',\n ['-L', value, 'display-message', '-p', '#{socket_path}'],\n { encoding: 'utf-8' },\n ).trim();\n return {\n socketPath,\n sessionName,\n paneMappings: [],\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction tmuxExec(socketPath: string, args: string[]): string {\n return execFileSync('tmux', ['-S', socketPath, ...args], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trimEnd();\n}\n\nfunction tmuxHasSession(state: TmuxEnvironmentState): boolean {\n if (!processIdentityMatches(state.serverProcess)) {\n return false;\n }\n try {\n tmuxExec(state.socket.path, ['has-session', '-t', state.sessionName]);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function runCommand(command: string, cwd: string): Promise {\n const child = spawnShellCommand(command, {\n cwd,\n detached: true,\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n const identity = child.pid ? captureProcessIdentity(child.pid) : null;\n if (!identity) {\n throw new Error('ProofShot could not capture the external launcher identity.');\n }\n let stdout = '';\n let stderr = '';\n child.stdout?.on('data', (chunk) => {\n stdout += chunk.toString();\n });\n child.stderr?.on('data', (chunk) => {\n stderr += chunk.toString();\n });\n const exitCode = await new Promise((resolve, reject) => {\n child.once('error', reject);\n child.once('close', resolve);\n });\n if (exitCode !== 0) {\n await terminateOwnedProcessTree(identity);\n throw new Error(\n `External environment command failed with code ${String(exitCode)}: ${stderr.trim()}`,\n );\n }\n return stdout.trim();\n}\n\nfunction captureSocketIdentity(socketPath: string): {\n path: string;\n inode: number;\n uid: number;\n} {\n const stat = fs.lstatSync(socketPath);\n if (!stat.isSocket() || stat.isSymbolicLink()) {\n throw new Error(`tmux socket is not an owned Unix socket: ${socketPath}`);\n }\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(`tmux socket is owned by uid ${stat.uid}, expected ${uid}.`);\n }\n return { path: socketPath, inode: stat.ino, uid: stat.uid };\n}\n\nfunction assertSocketIdentity(state: TmuxEnvironmentState): void {\n if (!fs.existsSync(state.socket.path)) {\n if (!processIdentityMatches(state.serverProcess)) {\n return;\n }\n throw new Error('Owned tmux socket disappeared while its server is still alive.');\n }\n const current = captureSocketIdentity(state.socket.path);\n if (current.inode !== state.socket.inode || current.uid !== state.socket.uid) {\n throw new Error('tmux socket identity changed; refusing widened cleanup.');\n }\n}\n\nfunction assertTmuxAvailable(): void {\n try {\n execFileSync('tmux', ['-V'], { stdio: 'pipe' });\n } catch {\n throw new Error('tmux is required for environment.kind \"tmux\".');\n }\n}\n\nfunction validateId(id: string): void {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n\nfunction stripShellQuotes(value: string): string {\n return value.replace(/^(['\"])(.*)\\1$/, '$2');\n}\n","import { stopRecording } from '../browser/capture.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport {\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedEnvironment(session.environment);\n } catch (error) {\n cleanupError ||= error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.homedir(),\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n if (session.environment) {\n console.log(chalk.dim('Stopping environment...'));\n try {\n await stopOwnedEnvironment(session.environment);\n session.environment = null;\n persistOwnedSession(session, controlDir);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import { stopRecording } from '../browser/capture.js';\nimport {\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.homedir(),\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=', 'Custom output directory')\n .option('--url ', 'Open this URL instead of the root')\n .option('--browser-executable ', 'Use this Chrome/Chromium executable')\n .option('--force', 'Override a stale session without running stop first')\n .action(async (options) => {\n await startCommand(options);\n });\n\n program\n .command('stop')\n .description('Stop session: stop recording, collect errors, bundle proof artifacts')\n .option('--no-close', 'Don\\'t close the browser (keep it open for further use)')\n .action(async (options) => {\n await stopCommand({ noClose: options.close === false });\n });\n\n program\n .command('diff')\n .description('Compare current screenshots against a baseline')\n .requiredOption('--baseline ', 'Directory with baseline screenshots')\n .action(async (options) => {\n await diffCommand(options);\n });\n\n program\n .command('clean')\n .description('Remove artifact files')\n .action(async () => {\n await cleanCommand();\n });\n\n program\n .command('doctor')\n .description('Inspect the local ProofShot environment and active session state')\n .action(async () => {\n await doctorCommand();\n });\n\n program\n .command('pr')\n .description('Upload session artifacts and post a ProofShot comment on a GitHub PR')\n .argument('[pr-number]', 'PR number (auto-detects from current branch if omitted)')\n .option('--dry-run', 'Generate the comment markdown without posting')\n .option(\n '--upload-provider ',\n 'Artifact upload backend: repo-contents or github-web-attachments',\n 'repo-contents',\n )\n .option(\n '--artifacts-branch ',\n 'Git branch used by the repo-contents upload provider',\n 'proofshot-artifacts',\n )\n .action(async (prNumber, options) => {\n await prCommand({ prNumber, ...options });\n });\n\n program\n .command('exec')\n .description('Run an agent-browser command with logging (use instead of agent-browser directly)')\n .argument('', 'agent-browser command and arguments')\n .allowUnknownOption()\n .action(async (args) => {\n await execCommand(args);\n });\n\n const session = program\n .command('session')\n .description('List and recover registered ProofShot sessions');\n\n session\n .command('list')\n .description('List all registered ProofShot sessions')\n .option('--json', 'Output machine-readable JSON')\n .action(async (options) => {\n await sessionListCommand(options);\n });\n\n session\n .command('clean')\n .description('Retry exact cleanup for recoverable ProofShot sessions')\n .option('--session ', 'Clean one exact registered session')\n .option('--all', 'Clean every registered session')\n .action(async (options) => {\n await sessionCleanCommand(options);\n });\n\n return program;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as readline from 'readline';\nimport { execSync } from 'child_process';\nimport chalk from 'chalk';\nimport { readBundledSkill, getInlineSkillContent } from '../utils/skills.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype ToolName =\n | 'claude'\n | 'cursor'\n | 'codex'\n | 'gemini'\n | 'windsurf'\n | 'opencode';\n\ntype SkillTarget =\n | { strategy: 'file'; relativePath: string }\n | { strategy: 'append'; relativePath: string };\n\ninterface ToolDefinition {\n name: ToolName;\n displayName: string;\n binaryName: string;\n configDir: string;\n skillTarget: SkillTarget;\n /** Path inside the bundled skills/ directory */\n bundledSkill: string;\n /** Fallback agent key for inline content generation */\n inlineAgent: string;\n}\n\ninterface InstallResult {\n tool: ToolName;\n displayName: string;\n status: 'installed' | 'updated' | 'skipped' | 'failed';\n path: string;\n message?: string;\n}\n\nexport interface InstallOptions {\n only?: string;\n skip?: string;\n force?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MARKER_START = '';\nconst MARKER_END = '';\n\n// ---------------------------------------------------------------------------\n// Tool registry\n// ---------------------------------------------------------------------------\n\nfunction getToolDefinitions(): ToolDefinition[] {\n const home = os.homedir();\n return [\n {\n name: 'claude',\n displayName: 'Claude Code',\n binaryName: 'claude',\n configDir: path.join(home, '.claude'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'claude/SKILL.md',\n inlineAgent: 'claude',\n },\n {\n name: 'cursor',\n displayName: 'Cursor',\n binaryName: 'cursor',\n configDir: path.join(home, '.cursor'),\n skillTarget: { strategy: 'file', relativePath: 'rules/proofshot.mdc' },\n bundledSkill: 'cursor/proofshot.mdc',\n inlineAgent: 'cursor',\n },\n {\n name: 'codex',\n displayName: 'Codex (OpenAI)',\n binaryName: 'codex',\n configDir: path.join(home, '.codex'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'codex/SKILL.md',\n inlineAgent: 'codex',\n },\n {\n name: 'gemini',\n displayName: 'Gemini CLI',\n binaryName: 'gemini',\n configDir: path.join(home, '.gemini'),\n skillTarget: { strategy: 'append', relativePath: 'GEMINI.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'windsurf',\n displayName: 'Windsurf',\n binaryName: 'windsurf',\n configDir: path.join(home, '.codeium', 'windsurf'),\n skillTarget: { strategy: 'append', relativePath: 'memories/global_rules.md' },\n bundledSkill: 'generic/PROOFSHOT.md',\n inlineAgent: 'generic',\n },\n {\n name: 'opencode',\n displayName: 'OpenCode',\n binaryName: 'opencode',\n configDir: path.join(home, '.config', 'opencode'),\n skillTarget: { strategy: 'file', relativePath: 'skills/proofshot/SKILL.md' },\n bundledSkill: 'opencode/SKILL.md',\n inlineAgent: 'codex',\n },\n ];\n}\n\n// ---------------------------------------------------------------------------\n// Detection\n// ---------------------------------------------------------------------------\n\nfunction isBinaryAvailable(binaryName: string): boolean {\n const cmd = process.platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;\n try {\n execSync(cmd, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction detectInstalledTools(): ToolDefinition[] {\n return getToolDefinitions().filter(\n (tool) => isBinaryAvailable(tool.binaryName) || fs.existsSync(tool.configDir),\n );\n}\n\nfunction filterTools(\n detected: ToolDefinition[],\n only?: string,\n skip?: string,\n): ToolDefinition[] {\n let tools = detected;\n if (only) {\n const onlySet = new Set(only.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => onlySet.has(t.name));\n }\n if (skip) {\n const skipSet = new Set(skip.split(',').map((s) => s.trim().toLowerCase()));\n tools = tools.filter((t) => !skipSet.has(t.name));\n }\n return tools;\n}\n\n// ---------------------------------------------------------------------------\n// Content resolution\n// ---------------------------------------------------------------------------\n\nfunction getSkillContent(tool: ToolDefinition): string {\n return readBundledSkill(tool.bundledSkill) ?? getInlineSkillContent(tool.inlineAgent);\n}\n\n// ---------------------------------------------------------------------------\n// Installation strategies\n// ---------------------------------------------------------------------------\n\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction installFile(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const exists = fs.existsSync(targetPath);\n if (exists && !force) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n if (existing === content) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n }\n\n fs.writeFileSync(targetPath, content);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: exists ? 'updated' : 'installed',\n path: targetPath,\n };\n}\n\nfunction installAppend(\n tool: ToolDefinition,\n targetPath: string,\n content: string,\n force: boolean,\n): InstallResult {\n const markedContent = `${MARKER_START}\\n${content}\\n${MARKER_END}`;\n const exists = fs.existsSync(targetPath);\n\n if (exists) {\n const existing = fs.readFileSync(targetPath, 'utf-8');\n\n if (existing.includes(MARKER_START)) {\n // Replace existing marked block\n const regex = new RegExp(\n `${escapeRegex(MARKER_START)}[\\\\s\\\\S]*?${escapeRegex(MARKER_END)}`,\n );\n const updated = existing.replace(regex, markedContent);\n\n if (updated === existing && !force) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'skipped',\n path: targetPath,\n message: 'Already up to date',\n };\n }\n\n fs.writeFileSync(targetPath, updated);\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'updated',\n path: targetPath,\n };\n }\n\n // No markers found — append\n fs.appendFileSync(targetPath, '\\n\\n' + markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n }\n\n // File does not exist — create\n fs.writeFileSync(targetPath, markedContent + '\\n');\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'installed',\n path: targetPath,\n };\n}\n\nfunction installForTool(tool: ToolDefinition, force: boolean): InstallResult {\n const content = getSkillContent(tool);\n const targetPath = path.join(tool.configDir, tool.skillTarget.relativePath);\n const targetDir = path.dirname(targetPath);\n\n try {\n fs.mkdirSync(targetDir, { recursive: true });\n\n if (tool.skillTarget.strategy === 'file') {\n return installFile(tool, targetPath, content, force);\n } else {\n return installAppend(tool, targetPath, content, force);\n }\n } catch (error: any) {\n return {\n tool: tool.name,\n displayName: tool.displayName,\n status: 'failed',\n path: targetPath,\n message: error.message,\n };\n }\n}\n\n// ---------------------------------------------------------------------------\n// Interactive prompt\n// ---------------------------------------------------------------------------\n\nfunction checkboxSelect(tools: ToolDefinition[]): Promise {\n return new Promise((resolve) => {\n const selected = new Array(tools.length).fill(true);\n let cursor = 0;\n\n function render() {\n // Move cursor up to overwrite previous render (except first)\n if (renderCount > 0) {\n process.stdout.write(`\\x1b[${tools.length + 2}A`);\n }\n renderCount++;\n\n console.log(chalk.bold('Select tools to install:'));\n console.log('');\n for (let i = 0; i < tools.length; i++) {\n const check = selected[i] ? chalk.green('[x]') : chalk.dim('[ ]');\n const label = tools[i].displayName;\n const pointer = i === cursor ? chalk.green('> ') : ' ';\n console.log(`${pointer}${check} ${label}`);\n }\n }\n\n let renderCount = 0;\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n\n const stdin = process.stdin;\n stdin.setRawMode(true);\n stdin.resume();\n stdin.setEncoding('utf-8');\n\n function onData(key: string) {\n // Ctrl+C\n if (key === '\\x03') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve([]);\n return;\n }\n\n // Enter\n if (key === '\\r' || key === '\\n') {\n stdin.setRawMode(false);\n stdin.removeListener('data', onData);\n stdin.pause();\n // Clear the hint line and move down\n process.stdout.write('\\r\\x1b[K\\n');\n resolve(tools.filter((_, i) => selected[i]));\n return;\n }\n\n // Space — toggle\n if (key === ' ') {\n selected[cursor] = !selected[cursor];\n // Move up to re-render hint, then re-render\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow up\n if (key === '\\x1b[A') {\n cursor = (cursor - 1 + tools.length) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n\n // Arrow down\n if (key === '\\x1b[B') {\n cursor = (cursor + 1) % tools.length;\n process.stdout.write('\\r\\x1b[K');\n process.stdout.write(`\\x1b[1A`);\n render();\n console.log('');\n process.stdout.write(chalk.dim(' ↑/↓ navigate · space toggle · enter confirm'));\n return;\n }\n }\n\n stdin.on('data', onData);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Main command\n// ---------------------------------------------------------------------------\n\nexport async function installCommand(options: InstallOptions): Promise {\n const allDetected = detectInstalledTools();\n const tools = filterTools(allDetected, options.only, options.skip);\n\n if (tools.length === 0) {\n if (options.only || options.skip) {\n console.log(chalk.yellow('No matching AI tools found after applying filters.'));\n console.log(\n chalk.dim(\n 'Detected tools: ' + (allDetected.map((t) => t.name).join(', ') || 'none'),\n ),\n );\n } else {\n console.log(chalk.yellow('No AI coding tools detected on this machine.'));\n console.log(chalk.dim('Looked for: claude, cursor, codex, gemini, windsurf, opencode'));\n }\n return;\n }\n\n // Interactive selection (or install all if non-interactive)\n let selectedTools = tools;\n if (process.stdin.isTTY) {\n console.log('');\n const picked = await checkboxSelect(tools);\n if (picked.length === 0) {\n console.log(chalk.dim('Aborted.'));\n return;\n }\n selectedTools = picked;\n } else {\n console.log('');\n console.log(chalk.bold('Detected AI coding tools:'));\n console.log('');\n for (const tool of tools) {\n console.log(` ${chalk.green('\\u25cf')} ${tool.displayName}`);\n }\n console.log('');\n }\n\n // Install for each tool\n const results: InstallResult[] = [];\n for (const tool of selectedTools) {\n const result = installForTool(tool, !!options.force);\n results.push(result);\n\n const icon =\n result.status === 'failed'\n ? chalk.red('\\u2717')\n : result.status === 'skipped'\n ? chalk.dim('\\u2013')\n : chalk.green('\\u2713');\n const statusText =\n result.status === 'installed'\n ? 'Installed'\n : result.status === 'updated'\n ? 'Updated'\n : result.status === 'skipped'\n ? 'Skipped'\n : 'Failed';\n const suffix = result.message ? chalk.dim(` (${result.message})`) : '';\n\n console.log(`${icon} ${tool.displayName}: ${statusText}${suffix}`);\n if (result.status !== 'failed') {\n console.log(chalk.dim(` \\u2192 ${result.path}`));\n } else if (result.message) {\n console.log(chalk.red(` ${result.message}`));\n }\n }\n\n // Summary\n const installed = results.filter(\n (r) => r.status === 'installed' || r.status === 'updated',\n ).length;\n const failed = results.filter((r) => r.status === 'failed').length;\n console.log('');\n\n if (failed > 0) {\n console.log(chalk.yellow(`Done. ${installed} installed, ${failed} failed.`));\n } else if (installed > 0) {\n console.log(chalk.green(`Done! ProofShot skills installed for ${installed} tool(s).`));\n console.log('');\n console.log(`You're all set! In any project, tell your AI agent:`);\n console.log('');\n console.log(chalk.white(` \"Verify the changes visually with proofshot\"`));\n console.log('');\n } else {\n console.log(chalk.dim('All tools already up to date.'));\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * Resolve the directory where bundled skill files are shipped.\n */\nexport function getSkillsDir(): string {\n return path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..', '..', 'skills',\n );\n}\n\n/**\n * Read a bundled skill file. Returns the content string, or null if not found.\n */\nexport function readBundledSkill(relativePath: string): string | null {\n try {\n return fs.readFileSync(path.join(getSkillsDir(), relativePath), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/**\n * Generate inline skill content as a fallback when bundled files aren't available.\n */\nexport function getInlineSkillContent(agent: string): string {\n if (agent === 'claude' || agent === 'codex') {\n return `---\nname: proofshot\ndescription: Visual verification of UI features. Use after building or modifying any\n UI component, page, or visual feature. Starts a verification session with video\n recording and error capture, then you drive the browser to test, then stop to\n bundle proof artifacts for the human.\nallowed-tools: Bash(proofshot:*), Bash(agent-browser:*)\n---\n\n# ProofShot — Visual Verification Workflow\n\n## When to use\n\nUse ProofShot after:\n- Building a new UI feature or page\n- Modifying existing UI components\n- Fixing a visual bug\n- Any change that affects what the user sees\n\n## The workflow (always follow these 3 steps)\n\n### Step 1: Start the session\n\n\\`\\`\\`bash\nproofshot start --run \"your-dev-command\" --port PORT --description \"what you are about to verify\"\n\\`\\`\\`\n\nThis opens a browser and begins recording. If --run is provided, it also starts and captures your dev server output.\nIf the server is already running, omit --run (no server logs captured).\nThe description appears in the proof report for the human.\n\n### Step 2: Drive the browser and test\n\nUse proofshot exec to navigate, interact, and verify:\n\n\\`\\`\\`bash\nproofshot exec snapshot -i # See interactive elements\nproofshot exec open http://localhost:PORT/page # Navigate to a page\nproofshot exec click @e3 # Click a button\nproofshot exec fill @e2 \"test@example.com\" # Fill a form field\nproofshot exec screenshot step-NAME.png # Capture key moments\n\\`\\`\\`\n\nTake screenshots at important moments — these become the visual proof.\nVerify what you expect to see by reading the snapshot output.\n\n### Step 3: Stop and bundle the proof\n\n\\`\\`\\`bash\nproofshot stop\n\\`\\`\\`\n\nThis stops recording, collects console + server errors, and generates\na SUMMARY.md with video, screenshots, and error report.\n\n### Step 4 (optional): Post proof to the PR\n\n\\`\\`\\`bash\nproofshot pr # Auto-detect PR from current branch\nproofshot pr 42 # Target a specific PR number\n\\`\\`\\`\n\nThis uploads screenshots and video to GitHub and posts a formatted comment on the PR with inline media. Requires \\`gh\\` CLI to be authenticated.\nDefault upload mode uses the official GitHub contents API on a \\`proofshot-artifacts\\` branch. For GitHub-hosted attachment URLs, use \\`proofshot pr --upload-provider github-web-attachments\\`.\n\n## Tips\n\n- Always include a meaningful --description so the human knows what was tested\n- Take screenshots before AND after key actions (e.g., before form submit, after redirect)\n- If you find errors during verification, fix them and re-run the workflow\n- Use \\`proofshot pr\\` after stopping to attach proof directly to the pull request\n`;\n }\n\n if (agent === 'cursor') {\n return `---\ndescription: Visual verification of UI changes using ProofShot\nglobs: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.vue\", \"**/*.svelte\", \"**/*.html\"]\n---\n\nAfter modifying UI files, visually verify changes with this workflow:\n\n1. Start session: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Drive browser: Use \\`proofshot exec\\` commands to navigate, click, fill forms, and take screenshots\n3. Stop session: \\`proofshot stop\\` to bundle video + screenshots + error report\n4. (Optional) Post to PR: \\`proofshot pr\\` to upload proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n`;\n }\n\n // Generic / gemini / windsurf\n return `# ProofShot Visual Verification\n\nAfter building or modifying UI features, verify with this workflow:\n\n1. Start: \\`proofshot start --run \"your-dev-command\" --port PORT --description \"what you are verifying\"\\`\n If the server is already running, omit --run.\n2. Test: Use \\`proofshot exec\\` to navigate, click, fill forms, take screenshots\n3. Stop: \\`proofshot stop\\` — bundles video, screenshots, and error report\n4. (Optional) Post to PR: \\`proofshot pr\\` — uploads proof to the GitHub PR\n Default provider uses the official contents API. Use \\`--upload-provider github-web-attachments\\` only if you specifically want GitHub attachment URLs.\n\nKey proofshot exec commands:\n- \\`proofshot exec snapshot -i\\` — see interactive elements\n- \\`proofshot exec click @e3\\` — click an element\n- \\`proofshot exec fill @e2 \"text\"\\` — fill a form field\n- \\`proofshot exec screenshot step.png\\` — capture a moment\n\nArtifacts saved to ./proofshot-artifacts/ including video, screenshots, errors, and summary.\n`;\n}\n","import * as path from 'path';\nimport chalk from 'chalk';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { ensureDevServer } from '../server/start.js';\nimport { openBrowser } from '../browser/session.js';\nimport { startRecording } from '../browser/capture.js';\nimport { discoverBrowserExecutable, browserSetupError } from '../browser/discovery.js';\nimport {\n captureAgentBrowserProcessIdentity,\n prepareAgentBrowserSocketDir,\n} from '../browser/runtime.js';\nimport { ensureOutputDir, generateTimestamp, generateSessionDirName } from '../artifacts/bundle.js';\nimport {\n saveSession,\n loadSession,\n hasActiveSession,\n clearSession,\n generateAgentBrowserSessionName,\n resolveSessionControlDir,\n type SessionState,\n} from '../session/state.js';\nimport { cleanupFailedStart } from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { writeMetadata } from '../session/metadata.js';\nimport { startOwnedEnvironment } from '../environment/runtime.js';\n\ninterface StartOptions {\n description?: string;\n port?: number;\n run?: string;\n headed?: boolean;\n output?: string;\n url?: string;\n browserExecutable?: string;\n force?: boolean;\n}\n\nexport async function startCommand(options: StartOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n if (hasActiveSession(controlDir)) {\n if (options.force) {\n const existingSession = loadSession(controlDir);\n if (existingSession) {\n setAgentBrowserDefaults({\n configPath: existingSession.agentBrowserConfigPath || config.browser.configPath,\n socketDir: existingSession.agentBrowserSocketDir,\n });\n await cleanupFailedStart(existingSession);\n unregisterSession(existingSession.sessionName);\n }\n clearSession(controlDir);\n console.log(chalk.yellow('⚠') + chalk.dim(' Cleaned up the previous session'));\n } else {\n console.log(\n chalk.yellow('⚠ A session is already active.') +\n chalk.dim(' Run \"proofshot stop\" first, or use --force to override.'),\n );\n return;\n }\n }\n\n if (options.port) config.devServer.port = options.port;\n if (options.output) config.output = options.output;\n if (options.headed !== undefined) config.headless = !options.headed;\n\n const outputDir = path.resolve(config.output);\n const timestamp = generateTimestamp();\n const sessionDirName = generateSessionDirName(timestamp, options.description || null);\n const sessionDir = path.join(outputDir, sessionDirName);\n const sessionName = generateAgentBrowserSessionName(timestamp);\n let socketDir: string;\n let browserExecutable: string | null;\n\n try {\n socketDir = prepareAgentBrowserSocketDir(sessionName);\n browserExecutable = discoverBrowserExecutable({\n configuredPath: options.browserExecutable || config.browser.executablePath,\n });\n if (\n !browserExecutable &&\n !process.env.AGENT_BROWSER_PROVIDER &&\n !process.env.AGENT_BROWSER_CDP\n ) {\n throw browserSetupError();\n }\n } catch (error: any) {\n console.error(chalk.red('✗') + ` Browser preflight failed: ${error.message}`);\n process.exit(1);\n return;\n }\n\n if (browserExecutable) config.browser.executablePath = browserExecutable;\n setAgentBrowserDefaults({ configPath: config.browser.configPath, socketDir });\n\n ensureOutputDir(outputDir);\n ensureOutputDir(sessionDir);\n\n const videoPath = path.join(sessionDir, 'session.webm');\n const serverErrorLog = path.join(sessionDir, 'server.log');\n\n let branch = '';\n let commitSha = '';\n try {\n branch = execSync('git branch --show-current', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n try {\n commitSha = execSync('git rev-parse HEAD', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch {\n // Non-fatal outside a git repo.\n }\n\n writeMetadata(sessionDir, {\n branch,\n commitSha,\n startedAt: new Date().toISOString(),\n description: options.description || null,\n });\n\n const baseUrl = `http://localhost:${config.devServer.port}`;\n const openUrl = options.url || baseUrl;\n const session: SessionState = {\n startedAt: new Date().toISOString(),\n startDirectory: process.cwd(),\n lifecycleStatus: 'starting',\n cleanupError: null,\n description: options.description || null,\n outputDir,\n sessionDir,\n sessionName,\n videoPath,\n serverErrorLog,\n port: config.devServer.port,\n serverCommand: options.run || null,\n serverAlreadyRunning: !options.run,\n recordingActive: false,\n browserLaunchAttempted: false,\n bundleComplete: false,\n browserRetained: false,\n videoTrimComplete: false,\n trimOffsetSec: 0,\n sessionLogAdjusted: false,\n consoleEvidenceAvailable: false,\n consoleErrorCount: 0,\n targetUrl: openUrl,\n headless: config.headless,\n agentBrowserSocketDir: socketDir,\n agentBrowserConfigPath: config.browser.configPath,\n serverProcess: null,\n browserProcess: null,\n environment: null,\n viewport: { width: config.viewport.width, height: config.viewport.height },\n };\n persistOwnedSession(session, controlDir);\n const signalHandlers = installStartSignalHandlers(session, controlDir);\n\n let failureContext = 'start the session';\n try {\n if (options.run && config.environment) {\n throw new Error('Use either --run or config.environment, not both.');\n }\n if (config.environment || (config.logs?.sources || []).some((source) => source.kind === 'file')) {\n failureContext = 'start environment';\n session.environment = await startOwnedEnvironment(\n config.environment,\n config.logs || {},\n sessionDir,\n sessionName,\n new Date(session.startedAt).getTime(),\n (environmentState) => {\n session.environment = environmentState;\n persistOwnedSession(session, controlDir);\n },\n );\n console.log(chalk.green('✓') + ' Environment and log capture started');\n }\n if (options.run) {\n failureContext = 'start dev server';\n console.log(chalk.dim(`Starting: ${options.run}`));\n const server = await ensureDevServer(\n options.run,\n config.devServer.port,\n config.devServer.startupTimeout,\n serverErrorLog,\n (startedServer) => {\n session.serverAlreadyRunning = false;\n session.serverProcess = startedServer.process;\n persistOwnedSession(session, controlDir);\n },\n );\n session.serverAlreadyRunning = false;\n session.serverProcess = server.process;\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ` Dev server started on :${config.devServer.port}`);\n console.log(chalk.dim(` Server logs → ${serverErrorLog}`));\n } else if (!config.environment) {\n console.log(chalk.dim('No --run provided, assuming server is already running'));\n }\n\n failureContext = 'open browser';\n console.log(chalk.dim('Opening browser...'));\n session.browserLaunchAttempted = true;\n persistOwnedSession(session, controlDir);\n openBrowser(openUrl, config.viewport, config.headless, sessionName, config.browser);\n session.browserProcess = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (!session.browserProcess) {\n throw new Error(\n `Could not record the exact agent-browser daemon identity for session ${sessionName}.`,\n );\n }\n persistOwnedSession(session, controlDir);\n console.log(chalk.green('✓') + ' Browser ready');\n\n failureContext = 'initialize recording';\n const RECORDING_RETRIES = 3;\n const RETRY_DELAY_MS = 2000;\n let recordingStarted = false;\n let lastError: any;\n\n for (let attempt = 1; attempt <= RECORDING_RETRIES; attempt++) {\n try {\n startRecording(videoPath, sessionName);\n recordingStarted = true;\n console.log(chalk.green('✓') + ' Recording started');\n break;\n } catch (error: any) {\n lastError = error;\n if (attempt < RECORDING_RETRIES) {\n console.log(\n chalk.yellow('⚠') +\n ` Recording failed (attempt ${attempt}/${RECORDING_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`,\n );\n await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n }\n }\n }\n\n if (!recordingStarted) {\n throw new Error(\n `Recording did not start after ${RECORDING_RETRIES} attempts: ${lastError?.message}`,\n );\n }\n } catch (error: any) {\n if (signalHandlers.isHandling()) {\n return;\n }\n signalHandlers.remove();\n const interruptionSignal = getTerminationSignal(error);\n try {\n await cleanupFailedStart(session);\n clearOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.dim('All processes started by this ProofShot attempt were cleaned up.'),\n );\n } catch (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n console.error(\n chalk.red('✗') +\n ` Failed to ${failureContext}: ${error.message}\\n` +\n chalk.yellow(`Cleanup is incomplete: ${session.cleanupError}\\n`) +\n chalk.dim(`Run \"proofshot session clean --session ${session.sessionName}\" to retry.`),\n );\n }\n process.exit(\n interruptionSignal === 'SIGINT'\n ? 130\n : interruptionSignal === 'SIGTERM'\n ? 143\n : 1,\n );\n return;\n }\n\n session.recordingActive = true;\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n signalHandlers.remove();\n\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot session started'));\n console.log('');\n console.log(`Server: ${options.run ? chalk.cyan(options.run) : chalk.dim('external')} on :${config.devServer.port}`);\n console.log(`Browser: Chromium (${config.headless ? 'headless' : 'headed'})`);\n console.log(`Session: ${chalk.dim(sessionName)}`);\n console.log(`Target: ${chalk.dim(openUrl)}`);\n console.log(`Recording: ${chalk.dim(videoPath)}`);\n console.log(`Errors log: ${chalk.dim(serverErrorLog)}`);\n\n if (options.description) {\n console.log(`Verifying: ${chalk.white(options.description)}`);\n }\n\n console.log('');\n console.log(chalk.dim('Use proofshot exec to navigate and test:'));\n console.log(chalk.dim(' proofshot exec snapshot -i # See interactive elements'));\n console.log(chalk.dim(' proofshot exec click @e3 # Click an element'));\n console.log(chalk.dim(' proofshot exec fill @e2 \"text\" # Fill a form field'));\n console.log(chalk.dim(' proofshot exec screenshot step.png # Capture a moment'));\n console.log('');\n console.log(`When done, run: ${chalk.white('proofshot stop')}`);\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nfunction installStartSignalHandlers(\n session: SessionState,\n controlDir: string,\n): { isHandling: () => boolean; remove: () => void } {\n let handlingSignal = false;\n const handlers = new Map void>();\n\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n const handler = (): void => {\n if (handlingSignal) {\n return;\n }\n handlingSignal = true;\n void cleanupFailedStart(session)\n .then(() => {\n clearOwnedSession(session, controlDir);\n process.exit(signal === 'SIGINT' ? 130 : 143);\n })\n .catch((error) => {\n session.lifecycleStatus = 'recovery';\n session.cleanupError = error instanceof Error ? error.message : String(error);\n persistOwnedSession(session, controlDir);\n process.exit(1);\n });\n };\n handlers.set(signal, handler);\n process.once(signal, handler);\n }\n\n return {\n isHandling: (): boolean => handlingSignal,\n remove: (): void => {\n for (const [signal, handler] of handlers) {\n process.removeListener(signal, handler);\n }\n },\n };\n}\n\nfunction getTerminationSignal(error: unknown): NodeJS.Signals | null {\n let current = error;\n for (let depth = 0; depth < 4; depth += 1) {\n if (typeof current !== 'object' || current === null) {\n return null;\n }\n const candidate = current as { cause?: unknown; signal?: unknown };\n if (candidate.signal === 'SIGINT' || candidate.signal === 'SIGTERM') {\n return candidate.signal;\n }\n current = candidate.cause;\n }\n return null;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type {\n EnvironmentConfig,\n LogsConfig,\n LogSourceConfig,\n} from '../environment/types.js';\n\nexport interface DevServerConfig {\n port: number;\n startupTimeout: number;\n}\n\nexport interface ViewportConfig {\n width: number;\n height: number;\n}\n\nexport interface BrowserConfig {\n configPath?: string;\n executablePath?: string;\n ignoreHttpsErrors: boolean;\n}\n\nexport interface ProofShotConfig {\n devServer: DevServerConfig;\n output: string;\n defaultPages: string[];\n viewport: ViewportConfig;\n headless: boolean;\n browser: BrowserConfig;\n environment?: EnvironmentConfig;\n logs: LogsConfig;\n}\n\nconst CONFIG_FILENAME = 'proofshot.config.json';\n\nconst DEFAULT_CONFIG: ProofShotConfig = {\n devServer: {\n port: 3000,\n startupTimeout: 30000,\n },\n output: './proofshot-artifacts',\n defaultPages: ['/'],\n viewport: { width: 1280, height: 720 },\n headless: true,\n browser: {\n ignoreHttpsErrors: false,\n },\n logs: {\n stripAnsi: true,\n maxBytesPerSource: 5 * 1024 * 1024,\n sources: [],\n },\n};\n\n/**\n * Find the config file by walking up from cwd.\n */\nexport function findConfigPath(startDir?: string): string | null {\n let dir = startDir || process.cwd();\n while (true) {\n const configPath = path.join(dir, CONFIG_FILENAME);\n if (fs.existsSync(configPath)) return configPath;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Load config from disk, merging with defaults.\n */\nexport function loadConfig(startDir?: string): ProofShotConfig {\n const configPath = findConfigPath(startDir);\n if (!configPath) return { ...DEFAULT_CONFIG };\n\n try {\n const raw = fs.readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw);\n const configDir = path.dirname(configPath);\n const resolvedBrowser = {\n ...DEFAULT_CONFIG.browser,\n ...parsed.browser,\n };\n if (resolvedBrowser.configPath) {\n resolvedBrowser.configPath = path.resolve(configDir, resolvedBrowser.configPath);\n }\n const environment = resolveEnvironmentConfig(parsed.environment, configDir);\n const logs = resolveLogsConfig(parsed.logs, configDir);\n return {\n ...DEFAULT_CONFIG,\n ...parsed,\n output: path.resolve(\n configDir,\n typeof parsed.output === 'string' ? parsed.output : DEFAULT_CONFIG.output,\n ),\n devServer: { ...DEFAULT_CONFIG.devServer, ...parsed.devServer },\n viewport: { ...DEFAULT_CONFIG.viewport, ...parsed.viewport },\n browser: resolvedBrowser,\n environment,\n logs,\n };\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\nfunction resolveEnvironmentConfig(\n value: unknown,\n configDir: string,\n): EnvironmentConfig | undefined {\n if (typeof value !== 'object' || value === null) {\n return undefined;\n }\n const environment = value as EnvironmentConfig;\n if (environment.kind === 'tmux') {\n const launch =\n environment.launch.kind === 'panes'\n ? {\n ...environment.launch,\n panes: environment.launch.panes.map((pane) => ({\n ...pane,\n cwd: path.resolve(configDir, pane.cwd || environment.cwd || '.'),\n })),\n }\n : environment.launch;\n return {\n ...environment,\n cwd: path.resolve(configDir, environment.cwd || '.'),\n connection: environment.connection?.socket\n ? {\n ...environment.connection,\n socket: path.resolve(configDir, environment.connection.socket),\n }\n : environment.connection,\n launch,\n };\n }\n if (environment.kind === 'processes') {\n return {\n ...environment,\n commands: environment.commands.map((command) => ({\n ...command,\n cwd: path.resolve(configDir, command.cwd || '.'),\n })),\n };\n }\n return undefined;\n}\n\nfunction resolveLogsConfig(value: unknown, configDir: string): LogsConfig {\n const logs =\n typeof value === 'object' && value !== null\n ? (value as LogsConfig)\n : DEFAULT_CONFIG.logs;\n const sources: LogSourceConfig[] = (logs.sources || []).map((source) =>\n source.kind === 'file'\n ? { ...source, path: path.resolve(configDir, source.path) }\n : source,\n );\n return {\n ...DEFAULT_CONFIG.logs,\n ...logs,\n sources,\n };\n}\n\n/**\n * Write config to disk.\n */\nexport function writeConfig(\n config: ProofShotConfig,\n dir?: string,\n): string {\n const configPath = path.join(dir || process.cwd(), CONFIG_FILENAME);\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n return configPath;\n}\n\n/**\n * Check if a config file exists in the current project.\n */\nexport function configExists(dir?: string): boolean {\n return findConfigPath(dir) !== null;\n}\n","import { execSync, type ChildProcess } from 'child_process';\nimport { spawnShellCommand } from './process.js';\n\nexport class ProofShotError extends Error {\n constructor(\n message: string,\n public cause?: unknown,\n ) {\n super(message);\n this.name = 'ProofShotError';\n }\n}\n\nexport interface AgentBrowserCommandOptions {\n configPath?: string;\n session?: string;\n socketDir?: string;\n timeoutMs?: number;\n}\n\nlet defaultAgentBrowserOptions: Pick = {};\n\nexport function setAgentBrowserDefaults(\n options: Pick,\n): void {\n defaultAgentBrowserOptions = { ...options };\n}\n\nexport function getAgentBrowserEnvironment(\n options: Pick = {},\n): NodeJS.ProcessEnv {\n const socketDir = options.socketDir ?? defaultAgentBrowserOptions.socketDir;\n return socketDir\n ? { ...process.env, AGENT_BROWSER_SOCKET_DIR: socketDir }\n : { ...process.env };\n}\n\nfunction shellQuote(value: string): string {\n const escaped = value.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n}\n\nexport function buildAgentBrowserCommand(\n command: string,\n options: Pick = {},\n): string {\n const mergedOptions = {\n ...defaultAgentBrowserOptions,\n ...options,\n };\n const configFlag = mergedOptions.configPath ? ` --config ${shellQuote(mergedOptions.configPath)}` : '';\n const sessionFlag = mergedOptions.session ? ` --session ${shellQuote(mergedOptions.session)}` : '';\n return `agent-browser${configFlag}${sessionFlag} ${command}`;\n}\n\n/**\n * Execute an agent-browser command via CLI.\n * agent-browser uses a Rust CLI + persistent Node.js daemon architecture,\n * so calling it via CLI is the intended usage pattern.\n */\nexport function ab(\n command: string,\n timeoutOrOptions: number | AgentBrowserCommandOptions = 30000,\n): string {\n const options =\n typeof timeoutOrOptions === 'number'\n ? { timeoutMs: timeoutOrOptions }\n : timeoutOrOptions;\n const fullCommand = buildAgentBrowserCommand(command, options);\n try {\n return execSync(fullCommand, {\n encoding: 'utf-8',\n timeout: options.timeoutMs ?? 30000,\n stdio: ['pipe', 'pipe', 'pipe'],\n env: getAgentBrowserEnvironment(options),\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n const message = stderr || error?.message || 'Unknown error';\n throw new ProofShotError(\n `Browser command failed: ${fullCommand}\\n${message}`,\n error,\n );\n }\n}\n\nexport function exec(command: string, timeoutMs = 30000): string {\n try {\n return execSync(command, {\n encoding: 'utf-8',\n timeout: timeoutMs,\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (error: any) {\n const stderr = error?.stderr?.toString?.() || '';\n throw new ProofShotError(`Command failed: ${command}\\n${stderr}`, error);\n }\n}\n\nexport function spawnBackground(\n command: string,\n cwd?: string,\n): ChildProcess {\n const proc = spawnShellCommand(command, {\n cwd: cwd || process.cwd(),\n stdio: ['ignore', 'pipe', 'pipe'],\n detached: true,\n });\n proc.unref();\n return proc;\n}\n","import * as fs from 'fs';\nimport {\n execFileSync,\n execSync,\n spawn,\n type ChildProcess,\n type SpawnOptions,\n} from 'child_process';\n\ntype ExecSyncLike = typeof execSync;\n\n/**\n * Immutable identity for a process which started an isolated process session.\n *\n * A PID alone is not sufficient ownership proof because the operating system\n * can reuse it. `startTime` lets cleanup reject a recycled PID, while the\n * process/session group ids let ProofShot terminate only descendants created\n * by the detached process it started.\n */\nexport interface ProcessIdentity {\n pid: number;\n processGroupId: number;\n sessionId: number;\n startTime: string;\n}\n\nexport interface TerminateProcessTreeOptions {\n graceMs?: number;\n pollIntervalMs?: number;\n}\n\nexport function getShellExecutable(\n platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n if (platform === 'win32') {\n return env.ComSpec || 'cmd.exe';\n }\n\n return env.SHELL || '/bin/sh';\n}\n\nexport function spawnShellCommand(\n command: string,\n options: Omit = {},\n): ChildProcess {\n return spawn(command, {\n ...options,\n shell: getShellExecutable(),\n });\n}\n\n/** Parse the ownership fields from Linux `/proc//stat`. */\nexport function parseLinuxProcStat(stat: string): ProcessIdentity | null {\n const closeParen = stat.lastIndexOf(')');\n if (closeParen < 0) return null;\n\n const pid = Number(stat.slice(0, stat.indexOf(' ')));\n const fields = stat.slice(closeParen + 2).trim().split(/\\s+/);\n const processGroupId = Number(fields[2]);\n const sessionId = Number(fields[3]);\n const startTime = fields[19];\n\n if (\n !Number.isInteger(pid) ||\n !Number.isInteger(processGroupId) ||\n !Number.isInteger(sessionId) ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/** Parse the ownership fields emitted by BSD/POSIX ps implementations. */\nexport function parseUnixProcessIdentity(\n pid: number,\n output: string,\n): ProcessIdentity | null {\n const match = output.trim().match(/^(\\d+)\\s+(\\d+)\\s+(.+)$/);\n if (!match) return null;\n\n const processGroupId = Number(match[1]);\n const sessionId = Number(match[2]);\n const startTime = match[3];\n if (\n !Number.isInteger(processGroupId) ||\n processGroupId <= 0 ||\n !Number.isInteger(sessionId) ||\n sessionId < 0 ||\n !startTime\n ) {\n return null;\n }\n\n return { pid, processGroupId, sessionId, startTime };\n}\n\n/**\n * Detached children are session leaders on Linux and process-group leaders on\n * macOS, whose ps implementation reports a zero session id.\n */\nexport function isDetachedProcessIdentity(\n identity: ProcessIdentity,\n platform = process.platform,\n): boolean {\n if (platform === 'darwin') {\n return identity.processGroupId === identity.pid;\n }\n return identity.sessionId === identity.pid;\n}\n\n/**\n * Capture the current immutable identity for a process.\n * Returns null when the process is already gone or cannot be inspected.\n */\nexport function captureProcessIdentity(pid: number): ProcessIdentity | null {\n if (!Number.isInteger(pid) || pid <= 0) return null;\n\n if (process.platform === 'linux') {\n try {\n return parseLinuxProcStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync(\n 'ps',\n ['-o', 'pgid=', '-o', sessionField, '-o', 'lstart=', '-p', String(pid)],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n );\n return parseUnixProcessIdentity(pid, output);\n } catch {\n return null;\n }\n }\n\n // PowerShell exposes the process creation timestamp. If that immutable token\n // cannot be read, refuse ownership instead of treating a reusable PID as\n // sufficient proof for taskkill /T.\n try {\n const script =\n `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;\n const startTime = execFileSync(\n 'powershell.exe',\n ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n if (!/^\\d+$/.test(startTime)) return null;\n return { pid, processGroupId: pid, sessionId: pid, startTime };\n } catch {\n return null;\n }\n}\n\nexport function processIdentityMatches(identity: ProcessIdentity): boolean {\n const current = captureProcessIdentity(identity.pid);\n return Boolean(current && identitiesMatch(current, identity));\n}\n\nfunction identitiesMatch(left: ProcessIdentity, right: ProcessIdentity): boolean {\n return (\n left.pid === right.pid &&\n left.processGroupId === right.processGroupId &&\n left.sessionId === right.sessionId &&\n left.startTime === right.startTime\n );\n}\n\nfunction listProcessGroupsInSession(sessionId: number): number[] {\n const groups = new Set();\n\n if (process.platform === 'linux') {\n let entries: string[] = [];\n try {\n entries = fs.readdirSync('/proc');\n } catch {\n return [];\n }\n\n for (const entry of entries) {\n if (!/^\\d+$/.test(entry)) continue;\n try {\n const identity = parseLinuxProcStat(\n fs.readFileSync(`/proc/${entry}/stat`, 'utf-8'),\n );\n if (identity?.sessionId === sessionId) {\n groups.add(identity.processGroupId);\n }\n } catch {\n // The process may exit while /proc is being scanned.\n }\n }\n return [...groups];\n }\n\n if (process.platform !== 'win32') {\n try {\n const sessionField = process.platform === 'darwin' ? 'sess=' : 'sid=';\n const output = execFileSync('ps', ['-axo', `pgid=,${sessionField}`], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n for (const line of output.split(/\\r?\\n/)) {\n const match = line.trim().match(/^(\\d+)\\s+(\\d+)$/);\n if (match && Number(match[2]) === sessionId) {\n groups.add(Number(match[1]));\n }\n }\n } catch {\n return [];\n }\n }\n\n return [...groups];\n}\n\nfunction processGroupIsAlive(processGroupId: number): boolean {\n try {\n process.kill(-processGroupId, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n}\n\nexport function ownedProcessTreeIsAlive(identity: ProcessIdentity): boolean {\n if (process.platform === 'win32') return processIdentityMatches(identity);\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (process.platform === 'darwin') {\n return processGroupIsAlive(identity.processGroupId);\n }\n return listProcessGroupsInSession(identity.sessionId).length > 0;\n}\n\nfunction signalOwnedTree(identity: ProcessIdentity, signal: NodeJS.Signals): boolean {\n if (process.platform === 'win32') return false;\n\n const current = captureProcessIdentity(identity.pid);\n if (current && !identitiesMatch(current, identity)) return false;\n\n if (!isDetachedProcessIdentity(identity)) return false;\n if (process.platform === 'darwin') {\n if (!processGroupIsAlive(identity.processGroupId)) return false;\n try {\n process.kill(-identity.processGroupId, signal);\n return true;\n } catch {\n return false;\n }\n }\n\n // Detached children created by ProofShot are session leaders. If that leader\n // has already exited, its session id cannot be reused while descendants from\n // that session remain, so scanning the recorded session stays ownership-safe.\n const groups = listProcessGroupsInSession(identity.sessionId);\n if (groups.length === 0) return false;\n\n let signalled = false;\n for (const groupId of groups) {\n if (!Number.isInteger(groupId) || groupId <= 0) continue;\n try {\n process.kill(-groupId, signal);\n signalled = true;\n } catch {\n // A group can exit between discovery and signalling.\n }\n }\n return signalled;\n}\n\n/**\n * Terminate only the detached process session represented by `identity`.\n * Missing/already-dead processes are an idempotent no-op. A recycled PID is\n * rejected rather than widening cleanup to a name or port match.\n */\nexport async function terminateOwnedProcessTree(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity) return false;\n\n if (process.platform === 'win32') {\n if (!processIdentityMatches(identity)) return false;\n try {\n execFileSync('taskkill', ['/F', '/T', '/PID', String(identity.pid)], {\n stdio: 'pipe',\n });\n return true;\n } catch {\n return false;\n }\n }\n\n if (!ownedProcessTreeIsAlive(identity)) return false;\n const signalled = signalOwnedTree(identity, 'SIGTERM');\n if (!signalled) return false;\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n\n if (ownedProcessTreeIsAlive(identity)) {\n signalOwnedTree(identity, 'SIGKILL');\n const killDeadline = Date.now() + 500;\n while (Date.now() < killDeadline && ownedProcessTreeIsAlive(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n return true;\n}\n\nexport async function terminateOwnedProcess(\n identity: ProcessIdentity | null | undefined,\n options: TerminateProcessTreeOptions = {},\n): Promise {\n if (!identity || !processIdentityMatches(identity)) {\n return false;\n }\n\n const graceMs = options.graceMs ?? 1500;\n const pollIntervalMs = options.pollIntervalMs ?? 50;\n try {\n process.kill(identity.pid, 'SIGTERM');\n } catch {\n return false;\n }\n\n const deadline = Date.now() + graceMs;\n while (Date.now() < deadline && processIdentityMatches(identity)) {\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n if (processIdentityMatches(identity)) {\n try {\n process.kill(identity.pid, 'SIGKILL');\n } catch {\n return false;\n }\n }\n return true;\n}\n\nexport function parseWindowsNetstatOutput(output: string, port: number): number[] {\n const pids = new Set();\n\n for (const rawLine of output.split(/\\r?\\n/)) {\n const line = rawLine.trim();\n if (!line.startsWith('TCP')) continue;\n\n const columns = line.split(/\\s+/);\n if (columns.length < 5) continue;\n\n const localAddress = columns[1];\n const state = columns[3];\n const pid = Number(columns[4]);\n const match = localAddress.match(/:(\\d+)$/);\n\n if (state !== 'LISTENING' || !match || !Number.isInteger(pid)) continue;\n if (Number(match[1]) === port) {\n pids.add(pid);\n }\n }\n\n return [...pids];\n}\n\nexport function findPidsListeningOnPort(port: number): number[] {\n try {\n if (process.platform === 'win32') {\n const output = execSync('netstat -ano -p tcp', {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return parseWindowsNetstatOutput(output, port);\n }\n\n const output = execSync(`lsof -ti:${port}`, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n\n return output\n .split(/\\r?\\n/)\n .map((pid) => Number(pid))\n .filter((pid) => Number.isInteger(pid));\n } catch {\n return [];\n }\n}\n\nexport function killPids(pids: number[]): boolean {\n if (pids.length === 0) return false;\n\n try {\n if (process.platform === 'win32') {\n const pidArgs = pids.map((pid) => `/PID ${pid}`).join(' ');\n execSync(`taskkill /F /T ${pidArgs}`, { stdio: 'pipe' });\n return true;\n }\n\n execSync(`kill -9 ${pids.join(' ')}`, { stdio: 'pipe' });\n return true;\n } catch {\n return false;\n }\n}\n\nexport function terminateProcessTree(pid: number): void {\n if (process.platform === 'win32') {\n execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });\n return;\n }\n\n process.kill(-pid, 'SIGKILL');\n}\n\nexport function findExecutablePath(\n command: string,\n platform = process.platform,\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const lookupCommand = platform === 'win32' ? `where ${command}` : `command -v ${command}`;\n const output = execFn(lookupCommand, {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n\nexport function readCommandVersion(\n command: string,\n args: string[] = ['--version'],\n execFn: ExecSyncLike = execSync,\n): string | null {\n try {\n const output = execFn([command, ...args].join(' '), {\n encoding: 'utf-8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return output.split(/\\r?\\n/)[0] || null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport { spawn } from 'child_process';\nimport { isPortOpen, waitForPort } from '../utils/port.js';\nimport {\n captureProcessIdentity,\n getShellExecutable,\n isDetachedProcessIdentity,\n terminateOwnedProcessTree,\n terminateProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport interface ServerStartResult {\n alreadyRunning: boolean;\n port: number;\n process: ProcessIdentity;\n}\n\n// A detached supervisor keeps timestamping server output after the short-lived\n// `proofshot start` process exits. It and the server share one new process\n// session, whose immutable identity is persisted for exact later cleanup.\nconst SERVER_RUNNER_SOURCE = String.raw`\nconst fs = require('fs');\nconst { spawn } = require('child_process');\nconst [command, cwd, logPath, shell] = process.argv.slice(1);\nconst fd = fs.openSync(logPath, 'a');\nlet closed = false;\nconst write = (text) => {\n if (!closed) fs.writeSync(fd, Date.now() + '\\t' + text + '\\n');\n};\nconst child = spawn(command, {\n cwd,\n shell,\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nconst attach = (stream) => {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString();\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) write(line);\n });\n stream.on('end', () => {\n if (buffer) write(buffer);\n buffer = '';\n });\n};\nattach(child.stdout);\nattach(child.stderr);\nchild.on('error', (error) => write(error.stack || error.message || String(error)));\nchild.on('close', (code) => {\n closed = true;\n fs.closeSync(fd);\n process.exit(code == null ? 1 : code);\n});\n`;\n\n/**\n * Start a dev server command and wait for it to be ready.\n * Only called when the agent provides a --run command.\n * Pipes stdout/stderr to logPath for server error capture.\n */\nexport async function ensureDevServer(\n command: string,\n port: number,\n startupTimeout: number,\n logPath: string,\n onStarted?: (result: ServerStartResult) => void,\n): Promise {\n // Port ownership is not session ownership. Never kill an unrelated listener.\n if (await isPortOpen(port)) {\n throw new Error(\n `Port ${port} is already in use by a process ProofShot did not start.\\n` +\n 'Choose another port or stop that process explicitly, then retry.',\n );\n }\n\n // Ensure log creation errors surface before launching the detached runner.\n const logFd = fs.openSync(logPath, 'a');\n fs.closeSync(logFd);\n const proc = spawn(process.execPath, [\n '-e',\n SERVER_RUNNER_SOURCE,\n command,\n process.cwd(),\n logPath,\n getShellExecutable(),\n ], {\n stdio: 'ignore',\n detached: true,\n });\n\n proc.unref();\n let processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n for (let attempt = 0; !processIdentity && attempt < 5; attempt++) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n processIdentity = proc.pid ? captureProcessIdentity(proc.pid) : null;\n }\n\n if (!processIdentity || !isDetachedProcessIdentity(processIdentity)) {\n try {\n if (proc.pid) terminateProcessTree(proc.pid);\n } catch {\n // The child may already have exited.\n }\n throw new Error('ProofShot could not record an exact identity for the dev server process.');\n }\n const result = { alreadyRunning: false, port, process: processIdentity };\n try {\n onStarted?.(result);\n } catch (error) {\n await terminateOwnedProcessTree(processIdentity);\n throw error;\n }\n\n try {\n await waitForPort(port, startupTimeout);\n } catch (error) {\n // Clean up the spawned process if it failed to start on the expected port\n await terminateOwnedProcessTree(processIdentity);\n throw new Error(\n `Failed to start dev server with \"${command}\" on port ${port}.\\n` +\n `Make sure the command is correct and the port is available.\\n` +\n `Original error: ${error instanceof Error ? error.message : error}`,\n );\n }\n\n // Small delay for stability\n await new Promise((resolve) => setTimeout(resolve, 1000));\n\n return result;\n}\n","import * as net from 'net';\n\n/**\n * Check if a port is currently open (something is listening on it).\n * Checks both IPv4 and IPv6 to handle servers that listen on either.\n */\nexport async function isPortOpen(port: number, host = 'localhost'): Promise {\n // Try the specified host first\n if (await tryConnect(port, host)) return true;\n // If host is localhost, also explicitly try both address families\n if (host === 'localhost') {\n const results = await Promise.all([\n tryConnect(port, '127.0.0.1'),\n tryConnect(port, '::1'),\n ]);\n return results.some(Boolean);\n }\n return false;\n}\n\nfunction tryConnect(port: number, host: string): Promise {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n socket.setTimeout(1000);\n\n socket.on('connect', () => {\n socket.destroy();\n resolve(true);\n });\n\n socket.on('timeout', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.on('error', () => {\n socket.destroy();\n resolve(false);\n });\n\n socket.connect(port, host);\n });\n}\n\n/**\n * Wait for a port to become open, polling every intervalMs.\n */\nexport async function waitForPort(\n port: number,\n timeoutMs = 30000,\n intervalMs = 500,\n): Promise {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await isPortOpen(port)) return;\n await new Promise((r) => setTimeout(r, intervalMs));\n }\n throw new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`);\n}\n","import { ab, ProofShotError } from '../utils/exec.js';\nimport type { BrowserConfig, ViewportConfig } from '../utils/config.js';\n\nexport function buildOpenBrowserCommand(\n url: string,\n headless = true,\n browserConfig?: BrowserConfig,\n): string {\n const flags: string[] = [];\n\n if (!headless) flags.push('--headed');\n if (browserConfig?.ignoreHttpsErrors) flags.push('--ignore-https-errors');\n if (browserConfig?.executablePath) flags.push(`--executable-path \"${browserConfig.executablePath.replace(/\"/g, '\\\\\"')}\"`);\n\n const suffix = flags.length > 0 ? ` ${flags.join(' ')}` : '';\n return `open ${url}${suffix}`;\n}\n\n/**\n * Initialize a browser session.\n * Opens the browser and sets viewport dimensions.\n */\nexport function openBrowser(\n url: string,\n viewport: ViewportConfig,\n headless = true,\n sessionName?: string,\n browserConfig?: BrowserConfig,\n): void {\n ab(buildOpenBrowserCommand(url, headless, browserConfig), { timeoutMs: 60000, session: sessionName });\n ab(`set viewport ${viewport.width} ${viewport.height}`, { session: sessionName });\n}\n\n/**\n * Close the browser session.\n */\nexport function closeBrowser(sessionName?: string): void {\n try {\n ab('close', { session: sessionName });\n } catch {\n // Browser may already be closed — that's fine\n }\n}\n\n/**\n * Check if agent-browser is installed and accessible.\n */\nexport function checkAgentBrowser(): boolean {\n try {\n ab('--version', 5000);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get any console errors from the current page.\n */\nexport function getConsoleErrors(sessionName?: string): string {\n try {\n return ab('errors', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get console output from the current page.\n */\nexport function getConsoleOutput(sessionName?: string): string {\n try {\n return ab('console', { session: sessionName });\n } catch {\n return '';\n }\n}\n\nexport interface ConsoleMessage {\n text: string;\n timestamp: number; // epoch ms\n type: string; // log, warn, error, etc.\n}\n\n/**\n * Get console output as structured JSON with per-message timestamps.\n */\nexport function getConsoleOutputJson(sessionName?: string): ConsoleMessage[] {\n try {\n const raw = ab('console --json', { session: sessionName });\n const parsed = JSON.parse(raw);\n // agent-browser wraps JSON output: {success, data: {messages: [...]}, error}\n const messages = parsed?.data?.messages ?? parsed;\n return Array.isArray(messages) ? messages : [];\n } catch {\n return [];\n }\n}\n\n/**\n * Get the current page title.\n */\nexport function getPageTitle(sessionName?: string): string {\n try {\n return ab('get title', { session: sessionName });\n } catch {\n return '';\n }\n}\n\n/**\n * Get the current page URL.\n */\nexport function getPageUrl(sessionName?: string): string {\n try {\n return ab('get url', { session: sessionName });\n } catch {\n return '';\n }\n}\n","import { ab } from '../utils/exec.js';\n\n/**\n * Start video recording to the given file path.\n */\nexport function startRecording(outputPath: string, sessionName?: string): void {\n ab(`record start ${outputPath}`, { timeoutMs: 10000, session: sessionName });\n}\n\n/**\n * Stop the current recording.\n */\nexport function stopRecording(sessionName?: string): void {\n try {\n ab('record stop', { timeoutMs: 15000, session: sessionName });\n } catch {\n // Recording may not have started — that's fine\n }\n}\n\n/**\n * Take a screenshot and save to the given path.\n */\nexport function takeScreenshot(outputPath: string, fullPage = true, sessionName?: string): void {\n const fullFlag = fullPage ? ' --full' : '';\n ab(`screenshot ${outputPath}${fullFlag}`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Take an annotated screenshot (labels interactive elements).\n */\nexport function takeAnnotatedScreenshot(outputPath: string, sessionName?: string): void {\n ab(`screenshot ${outputPath} --annotate`, { timeoutMs: 15000, session: sessionName });\n}\n\n/**\n * Compare two screenshots and output a diff image.\n * Returns the mismatch percentage, or null if diff failed.\n */\nexport function diffScreenshots(\n baseline: string,\n current: string,\n outputPath: string,\n sessionName?: string,\n): number | null {\n try {\n const result = ab(`diff screenshot ${baseline} ${current} ${outputPath}`, {\n timeoutMs: 15000,\n session: sessionName,\n });\n // Parse mismatch percentage from output\n const match = result.match(/([\\d.]+)%/);\n return match ? parseFloat(match[1]) : null;\n } catch {\n return null;\n }\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { findExecutablePath } from '../utils/process.js';\n\nexport interface BrowserDiscoveryOptions {\n configuredPath?: string;\n env?: NodeJS.ProcessEnv;\n accountHome?: string;\n platform?: NodeJS.Platform;\n findExecutable?: typeof findExecutablePath;\n}\n\nfunction isExecutable(filePath: string): boolean {\n try {\n const stat = fs.statSync(filePath);\n if (!stat.isFile()) return false;\n fs.accessSync(filePath, fs.constants.R_OK | fs.constants.X_OK);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sortedDirectories(root: string): string[] {\n try {\n return fs\n .readdirSync(root, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name)\n .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));\n } catch {\n return [];\n }\n}\n\nfunction cachedBrowserCandidates(home: string): string[] {\n const candidates: string[] = [];\n const agentBrowserRoot = path.join(home, '.agent-browser', 'browsers');\n for (const directory of sortedDirectories(agentBrowserRoot)) {\n candidates.push(\n path.join(agentBrowserRoot, directory, 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(agentBrowserRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n\n const playwrightRoot = path.join(home, '.cache', 'ms-playwright');\n for (const directory of sortedDirectories(playwrightRoot)) {\n if (!directory.startsWith('chromium')) continue;\n candidates.push(\n path.join(playwrightRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-linux', 'chrome'),\n path.join(playwrightRoot, directory, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),\n );\n }\n\n const puppeteerRoot = path.join(home, '.cache', 'puppeteer', 'chrome');\n for (const directory of sortedDirectories(puppeteerRoot)) {\n candidates.push(\n path.join(puppeteerRoot, directory, 'chrome-linux64', 'chrome'),\n path.join(puppeteerRoot, directory, 'chrome-linux', 'chrome'),\n );\n }\n return candidates;\n}\n\nfunction accountHomeDirectory(): string | undefined {\n try {\n return os.userInfo().homedir;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Find a Chrome/Chromium executable without assuming that `$HOME` is the\n * account's real home directory. No profile, cookies, or storage are reused.\n */\nexport function discoverBrowserExecutable(\n options: BrowserDiscoveryOptions = {},\n): string | null {\n const env = options.env ?? process.env;\n const platform = options.platform ?? process.platform;\n const executableLookup = options.findExecutable ?? findExecutablePath;\n const explicit = options.configuredPath || env.AGENT_BROWSER_EXECUTABLE_PATH;\n\n if (explicit) {\n const resolved = path.resolve(explicit);\n if (!isExecutable(resolved)) {\n throw new Error(\n `Browser executable is not runnable: ${resolved}\\n` +\n `Retry with: proofshot start --browser-executable ${JSON.stringify(resolved)}`,\n );\n }\n return resolved;\n }\n\n const commandNames =\n platform === 'darwin'\n ? ['google-chrome', 'chromium']\n : platform === 'win32'\n ? ['chrome', 'msedge']\n : ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];\n for (const command of commandNames) {\n const executable = executableLookup(command, platform);\n if (executable && isExecutable(executable)) return executable;\n }\n\n const homes = new Set();\n if (env.HOME) homes.add(path.resolve(env.HOME));\n const accountHome = options.accountHome ?? accountHomeDirectory();\n if (accountHome) homes.add(path.resolve(accountHome));\n\n const candidates: string[] = [];\n if (platform === 'darwin') {\n candidates.push(\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n );\n } else if (platform === 'win32') {\n for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA]) {\n if (!root) continue;\n candidates.push(\n path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n );\n }\n } else {\n candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser');\n for (const home of homes) candidates.push(...cachedBrowserCandidates(home));\n }\n\n return candidates.find(isExecutable) ?? null;\n}\n\nexport function browserSetupError(): Error {\n return new Error(\n 'No runnable Chrome/Chromium executable was found for this environment.\\n' +\n 'Run `agent-browser install` in this environment, then retry `proofshot start`.',\n );\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport {\n captureProcessIdentity,\n isDetachedProcessIdentity,\n type ProcessIdentity,\n} from '../utils/process.js';\n\nexport const UNIX_SOCKET_PATH_MAX_BYTES = 103;\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`Agent-browser socket path is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `Agent-browser socket directory is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) fs.chmodSync(directory, 0o700);\n}\n\n/**\n * Prepare a short, user-owned socket directory that is stable across the\n * separate `start`, `exec`, and `stop` CLI processes in one environment.\n */\nexport function prepareAgentBrowserSocketDir(\n sessionName: string,\n env: NodeJS.ProcessEnv = process.env,\n accountHome = os.userInfo().homedir,\n): string {\n const uid = process.getuid?.() ?? process.pid;\n const explicit = env.AGENT_BROWSER_SOCKET_DIR;\n const systemRuntime = `/run/user/${uid}`;\n let runtimeRoot = accountHome;\n if (!explicit && env.XDG_RUNTIME_DIR && path.isAbsolute(env.XDG_RUNTIME_DIR)) {\n runtimeRoot = env.XDG_RUNTIME_DIR;\n } else if (!explicit && fs.existsSync(systemRuntime)) {\n try {\n assertOwnedDirectory(systemRuntime);\n runtimeRoot = systemRuntime;\n } catch {\n // Fall back to the real account home, independently of isolated $HOME.\n }\n }\n const directory = explicit\n ? path.resolve(explicit)\n : runtimeRoot === systemRuntime || runtimeRoot === env.XDG_RUNTIME_DIR\n ? path.join(runtimeRoot, 'proofshot', 'agent-browser')\n : path.join('/tmp', `proofshot-${uid}`, 'agent-browser');\n\n fs.mkdirSync(directory, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(directory);\n\n const socketPath = path.join(directory, `${sessionName}.sock`);\n const byteLength = Buffer.byteLength(socketPath);\n if (byteLength > UNIX_SOCKET_PATH_MAX_BYTES) {\n throw new Error(\n `Agent-browser socket path is ${byteLength} bytes (max ${UNIX_SOCKET_PATH_MAX_BYTES}): ${socketPath}\\n` +\n 'Set AGENT_BROWSER_SOCKET_DIR to a shorter user-owned directory and retry.',\n );\n }\n\n return directory;\n}\n\n/** Read the exact daemon PID written for this isolated agent-browser session. */\nexport function captureAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n): ProcessIdentity | null {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) return null;\n\n try {\n assertOwnedDirectory(socketDir);\n const pidPath = path.join(socketDir, `${sessionName}.pid`);\n const pid = Number(fs.readFileSync(pidPath, 'utf-8').trim());\n const identity = captureProcessIdentity(pid);\n if (!identity || !isDetachedProcessIdentity(identity)) return null;\n return identity;\n } catch {\n return null;\n }\n}\n\nexport async function waitForAgentBrowserProcessIdentity(\n socketDir: string,\n sessionName: string,\n timeoutMs = 2000,\n pollIntervalMs = 25,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = captureAgentBrowserProcessIdentity(socketDir, sessionName);\n if (identity) {\n return identity;\n }\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n } while (Date.now() < deadline);\n\n return captureAgentBrowserProcessIdentity(socketDir, sessionName);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nexport interface PageResult {\n page: string;\n title: string;\n url: string;\n screenshotPath: string;\n snapshot: string;\n errors: string;\n consoleOutput: string;\n}\n\nexport interface VerificationResult {\n pageResults: PageResult[];\n videoPath: string | null;\n outputDir: string;\n timestamp: string;\n framework: string;\n port: number;\n serverAlreadyRunning: boolean;\n durationMs: number;\n}\n\n/**\n * Ensure the output directory exists.\n */\nexport function ensureOutputDir(outputDir: string): void {\n fs.mkdirSync(outputDir, { recursive: true });\n}\n\n/**\n * Slugify a page path for use in filenames.\n * \"/\" -> \"home\", \"/dashboard\" -> \"dashboard\", \"/settings/profile\" -> \"settings-profile\"\n */\nexport function slugifyPage(pagePath: string): string {\n if (pagePath === '/' || pagePath === '') return 'home';\n return pagePath\n .replace(/^\\//, '')\n .replace(/\\/$/, '')\n .replace(/\\//g, '-')\n .replace(/[^a-zA-Z0-9-_]/g, '');\n}\n\n/**\n * Generate a timestamp string for filenames.\n */\nexport function generateTimestamp(): string {\n return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);\n}\n\n/**\n * Generate a session folder name from timestamp and optional description.\n * e.g. \"2026-02-27_14-22-09_verify-settings-page\" or \"2026-02-27_14-22-09\"\n */\nexport function generateSessionDirName(timestamp: string, description: string | null): string {\n if (!description) return timestamp;\n const slug = description\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 40)\n .replace(/-$/, '');\n return slug ? `${timestamp}_${slug}` : timestamp;\n}\n\n/**\n * Count interactive elements from a snapshot string.\n */\nexport function countInteractiveElements(snapshot: string): {\n buttons: number;\n links: number;\n forms: number;\n inputs: number;\n} {\n const buttons = (snapshot.match(/button/gi) || []).length;\n const links = (snapshot.match(/link| line.trim()).length;\n}\n\n/**\n * Count console warnings from console output.\n */\nexport function countWarnings(consoleOutput: string): number {\n if (!consoleOutput) return 0;\n return (consoleOutput.match(/warn/gi) || []).length;\n}\n\n/**\n * Get all artifact file paths in the output directory.\n */\nexport function listArtifacts(outputDir: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n return fs.readdirSync(outputDir).map((f) => path.join(outputDir, f));\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash, randomUUID } from 'crypto';\nimport type { EnvironmentState } from '../environment/types.js';\nimport type { ProcessIdentity } from '../utils/process.js';\n\nconst SESSION_FILENAME = '.session.json';\n\nexport interface SessionState {\n startedAt: string;\n startDirectory?: string;\n lifecycleStatus?: 'starting' | 'active' | 'stopping' | 'recovery';\n cleanupError?: string | null;\n description: string | null;\n outputDir: string;\n sessionDir: string;\n sessionName: string;\n videoPath: string;\n serverErrorLog: string;\n port: number;\n serverCommand: string | null;\n serverAlreadyRunning: boolean;\n recordingActive: boolean;\n browserLaunchAttempted?: boolean;\n bundleComplete?: boolean;\n browserRetained?: boolean;\n videoTrimComplete?: boolean;\n trimOffsetSec?: number;\n sessionLogAdjusted?: boolean;\n consoleEvidenceAvailable?: boolean;\n consoleErrorCount?: number;\n targetUrl?: string;\n headless?: boolean;\n agentBrowserSocketDir?: string;\n agentBrowserConfigPath?: string;\n serverProcess?: ProcessIdentity | null;\n browserProcess?: ProcessIdentity | null;\n environment?: EnvironmentState | null;\n viewport?: { width: number; height: number };\n}\n\n/**\n * Resolve the stable control directory for a project.\n *\n * CLI-only `--output` overrides choose where evidence is written, but active\n * control state remains in the configured/default output directory so a later\n * `proofshot exec` or `proofshot stop` process can always find it.\n */\nexport function resolveSessionControlDir(\n configuredOutput: string,\n cwd = process.cwd(),\n): string {\n return path.resolve(cwd, configuredOutput);\n}\n\n/**\n * Write session state to disk.\n */\nexport function saveSession(state: SessionState, controlDir = state.outputDir): void {\n fs.mkdirSync(controlDir, { recursive: true });\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n const temporaryPath = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;\n fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, sessionPath);\n}\n\n/**\n * Read session state from disk.\n * Returns null if no active session.\n */\nexport function loadSession(controlDir: string): SessionState | null {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (!fs.existsSync(sessionPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a session is currently active.\n */\nexport function hasActiveSession(controlDir: string): boolean {\n return fs.existsSync(path.join(controlDir, SESSION_FILENAME));\n}\n\n/**\n * Delete the session state file (called after stop).\n */\nexport function clearSession(controlDir: string): void {\n const sessionPath = path.join(controlDir, SESSION_FILENAME);\n if (fs.existsSync(sessionPath)) {\n fs.unlinkSync(sessionPath);\n }\n}\n\n/**\n * Generate a deterministic agent-browser session name for a ProofShot run.\n */\nexport function generateAgentBrowserSessionName(\n seed: string,\n nonce = randomUUID(),\n): string {\n const normalized = seed\n .toLowerCase()\n .replace(/[^a-z0-9-_]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 8)\n .replace(/-+$/g, '');\n const digest = createHash('sha256')\n .update(`${seed}\\0${nonce}`)\n .digest('hex')\n .slice(0, 12);\n\n return normalized ? `ps-${normalized}-${digest}` : `ps-${digest}`;\n}\n","import * as fs from 'fs';\nimport * as net from 'net';\nimport * as path from 'path';\nimport { startFileCapture, startProcessCapture } from './workers.js';\nimport { startTmuxEnvironment, stopTmuxEnvironment } from './tmux.js';\nimport type {\n EnvironmentConfig,\n EnvironmentState,\n LogSourceConfig,\n LogsConfig,\n ProcessDefinition,\n ProcessEnvironmentState,\n ReadinessCheck,\n ResolvedLogSourceState,\n} from './types.js';\nimport {\n ownedProcessTreeIsAlive,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\nexport async function startOwnedEnvironment(\n environment: EnvironmentConfig | undefined,\n logs: LogsConfig,\n sessionDir: string,\n sessionName: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const fileSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'file',\n );\n if (!environment && fileSources.length === 0) {\n return null;\n }\n\n let state: EnvironmentState;\n if (environment?.kind === 'tmux') {\n state = await startTmuxEnvironment(\n environment,\n logs,\n sessionDir,\n sessionName,\n startTimeMs,\n onState,\n );\n } else {\n state = await startProcessEnvironment(\n environment?.kind === 'processes' ? environment.commands : [],\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n }\n\n try {\n state = await attachFileSources(\n state,\n fileSources,\n logs,\n sessionDir,\n startTimeMs,\n onState,\n );\n if (environment) {\n await waitForReadiness(environment.readiness || []);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nexport async function stopOwnedEnvironment(\n state: EnvironmentState | null | undefined,\n): Promise {\n if (!state) {\n return;\n }\n switch (state.kind) {\n case 'tmux':\n await stopTmuxEnvironment(state);\n return;\n case 'processes':\n for (const capture of state.processes) {\n await terminateOwnedProcessTree(capture.process, { graceMs: 1000 });\n if (ownedProcessTreeIsAlive(capture.process)) {\n throw new Error(`Environment process ${capture.sourceId} did not stop.`);\n }\n }\n return;\n default: {\n const exhaustiveState: never = state;\n return exhaustiveState;\n }\n }\n}\n\nasync function startProcessEnvironment(\n definitions: ProcessDefinition[],\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n const configuredSources = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'process',\n );\n const sources =\n configuredSources.length > 0\n ? configuredSources\n : definitions.map((definition) => ({\n id: definition.id,\n title: definition.title,\n group: definition.group,\n kind: 'process' as const,\n processId: definition.id,\n include: undefined,\n exclude: undefined,\n }));\n validateUniqueIds(sources.map((source) => source.id));\n\n let state: ProcessEnvironmentState = {\n kind: 'processes',\n evidencePath,\n sources: [],\n processes: [],\n };\n onState(state);\n try {\n for (const sourceConfig of sources) {\n const definition = definitions.find(\n (candidate) => candidate.id === sourceConfig.processId,\n );\n if (!definition) {\n throw new Error(\n `Log source ${sourceConfig.id} references unknown process ${sourceConfig.processId}.`,\n );\n }\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title: sourceConfig.title || definition.title || definition.id,\n group: sourceConfig.group || definition.group || 'environment',\n kind: 'process',\n stream: 'stdout',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n const process = await startProcessCapture(\n definition,\n source,\n evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state = {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, process],\n };\n onState(state);\n }\n return state;\n } catch (error) {\n await stopOwnedEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nasync function attachFileSources(\n state: EnvironmentState,\n fileSources: Array>,\n logs: LogsConfig,\n sessionDir: string,\n startTimeMs: number,\n onState: (state: EnvironmentState) => void,\n): Promise {\n if (fileSources.length === 0) {\n return state;\n }\n const knownIds = new Set(state.sources.map((source) => source.id));\n const logsDir = path.join(sessionDir, 'logs');\n for (const fileSource of fileSources) {\n if (knownIds.has(fileSource.id)) {\n throw new Error(`Duplicate log source id: ${fileSource.id}`);\n }\n knownIds.add(fileSource.id);\n const source: ResolvedLogSourceState = {\n id: fileSource.id,\n title: fileSource.title || path.basename(fileSource.path),\n group: fileSource.group || 'environment',\n kind: 'file',\n stream: 'file',\n logPath: path.join(logsDir, `${fileSource.id}.log`),\n include: fileSource.include,\n exclude: fileSource.exclude,\n };\n const capture = await startFileCapture(\n fileSource.path,\n source,\n state.evidencePath,\n startTimeMs,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n );\n state =\n state.kind === 'tmux'\n ? {\n ...state,\n sources: [...state.sources, source],\n captures: [...state.captures, capture],\n }\n : {\n ...state,\n sources: [...state.sources, source],\n processes: [...state.processes, capture],\n };\n onState(state);\n }\n return state;\n}\n\nasync function waitForReadiness(checks: ReadinessCheck[]): Promise {\n for (const check of checks) {\n const timeoutMs = check.timeoutMs || 30 * 1000;\n const deadline = Date.now() + timeoutMs;\n let lastError = 'not ready';\n while (Date.now() < deadline) {\n try {\n if (check.kind === 'http') {\n const response = await fetch(check.url, {\n signal: AbortSignal.timeout(Math.min(2000, timeoutMs)),\n });\n if (response.ok) {\n lastError = '';\n break;\n }\n lastError = `HTTP ${response.status}`;\n } else {\n await connectTcp(check.host || '127.0.0.1', check.port);\n lastError = '';\n break;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n }\n await new Promise((resolve) => setTimeout(resolve, 100));\n }\n if (lastError) {\n const target =\n check.kind === 'http'\n ? check.url\n : `${check.host || '127.0.0.1'}:${check.port}`;\n throw new Error(`Environment readiness failed for ${target}: ${lastError}`);\n }\n }\n}\n\nfunction connectTcp(host: string, port: number): Promise {\n return new Promise((resolve, reject) => {\n const socket = net.createConnection({ host, port });\n const timer = setTimeout(() => {\n socket.destroy();\n reject(new Error('TCP readiness timed out'));\n }, 2000);\n socket.once('connect', () => {\n clearTimeout(timer);\n socket.end();\n resolve();\n });\n socket.once('error', (error) => {\n clearTimeout(timer);\n reject(error);\n });\n });\n}\n\nfunction validateUniqueIds(ids: string[]): void {\n const seen = new Set();\n for (const id of ids) {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n if (seen.has(id)) {\n throw new Error(`Duplicate log source id: ${id}`);\n }\n seen.add(id);\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { spawn } from 'child_process';\nimport { appendEvidenceEvent, normalizeLogText } from './evidence.js';\nimport type {\n CaptureProcessState,\n EvidenceEvent,\n ProcessDefinition,\n ResolvedLogSourceState,\n} from './types.js';\nimport { captureProcessIdentity, type ProcessIdentity } from '../utils/process.js';\n\ntype WorkerConfig = {\n evidencePath: string;\n logPath: string;\n pidFile?: string;\n startTimeMs: number;\n maxBytes: number;\n stripAnsi: boolean;\n source: ResolvedLogSourceState;\n command?: string;\n cwd?: string;\n env?: Record;\n offset?: number;\n};\n\nconst COMMON_WORKER_SOURCE = String.raw`\nconst fs = require('fs');\nconst config = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));\nconst ansiPattern = /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst controlPattern = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\nlet bytesWritten = 0;\nlet truncated = false;\nfunction normalize(text) {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(controlPattern, '');\n return config.stripAnsi ? normalized.replace(ansiPattern, '') : normalized;\n}\nfunction writeEvent(text, stream, segment = 'live', extra = {}) {\n const normalized = normalize(text);\n if (normalized.length === 0) return;\n const bytes = Buffer.byteLength(normalized);\n if (bytesWritten + bytes > config.maxBytes) {\n if (!truncated) {\n truncated = true;\n const now = Date.now();\n const event = {\n version: 1,\n origin: 'environment',\n group: config.source.group,\n sourceId: config.source.id,\n sourceTitle: config.source.title,\n stream,\n segment,\n timestamp: new Date(now).toISOString(),\n relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000),\n text: '[ProofShot capture truncated at configured byte limit]',\n truncated: true,\n };\n fs.appendFileSync(config.evidencePath, JSON.stringify(event) + '\\n');\n fs.appendFileSync(config.logPath, event.text + '\\n');\n }\n return;\n }\n bytesWritten += bytes;\n const now = Date.now();\n const event = {\n version: 1,\n origin: 'environment',\n group: config.source.group,\n sourceId: config.source.id,\n sourceTitle: config.source.title,\n stream,\n segment,\n timestamp: new Date(now).toISOString(),\n relativeTimeSec: Math.max(0, (now - config.startTimeMs) / 1000),\n text: normalized,\n ...extra,\n };\n fs.appendFileSync(config.evidencePath, JSON.stringify(event) + '\\n');\n fs.appendFileSync(config.logPath, normalized + '\\n');\n}\nfunction attachLines(stream, streamName) {\n let buffer = '';\n stream.on('data', (chunk) => {\n buffer += chunk.toString().replace(/\\r\\n?/g, '\\n');\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) writeEvent(line, streamName);\n });\n stream.on('end', () => {\n if (buffer.length > 0) writeEvent(buffer, streamName);\n buffer = '';\n });\n}\nif (config.pidFile) {\n fs.writeFileSync(config.pidFile, String(process.pid), { mode: 0o600 });\n}\nfunction removePidFile() {\n if (config.pidFile) {\n try { fs.unlinkSync(config.pidFile); } catch {}\n }\n}\n`;\n\nconst TMUX_PIPE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nattachLines(process.stdin, 'pty');\nprocess.stdin.on('end', () => {\n removePidFile();\n process.exit(0);\n});\nprocess.on('SIGTERM', () => {\n removePidFile();\n process.exit(0);\n});\n`;\n\nconst PROCESS_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nconst { spawn } = require('child_process');\nconst child = spawn(config.command, {\n cwd: config.cwd,\n env: { ...process.env, ...config.env },\n shell: process.env.SHELL || '/bin/sh',\n stdio: ['ignore', 'pipe', 'pipe'],\n});\nattachLines(child.stdout, 'stdout');\nattachLines(child.stderr, 'stderr');\nchild.on('error', (error) => writeEvent(error.stack || error.message || String(error), 'stderr'));\nchild.on('close', (code) => {\n writeEvent('[process exited with code ' + (code == null ? 'unknown' : code) + ']', 'stderr');\n removePidFile();\n process.exit(code == null ? 1 : code);\n});\nfor (const signal of ['SIGINT', 'SIGTERM']) {\n process.on(signal, () => {\n try { child.kill(signal); } catch {}\n });\n}\n`;\n\nconst FILE_RUNNER_SOURCE = `${COMMON_WORKER_SOURCE}\nlet offset = config.offset || 0;\nlet buffered = '';\nfunction readAvailable() {\n let stat;\n try {\n stat = fs.statSync(config.filePath);\n } catch {\n return;\n }\n if (stat.size < offset) {\n offset = 0;\n writeEvent('[file rotated or truncated]', 'file', 'live', { captureGap: true });\n }\n if (stat.size === offset) return;\n const length = stat.size - offset;\n const fd = fs.openSync(config.filePath, 'r');\n const buffer = Buffer.alloc(length);\n fs.readSync(fd, buffer, 0, length, offset);\n fs.closeSync(fd);\n offset = stat.size;\n buffered += buffer.toString();\n const lines = buffered.split('\\\\n');\n buffered = lines.pop() || '';\n for (const line of lines) writeEvent(line, 'file');\n}\nconst timer = setInterval(readAvailable, 100);\nfunction stop() {\n clearInterval(timer);\n if (buffered.length > 0) writeEvent(buffered, 'file');\n removePidFile();\n process.exit(0);\n}\nprocess.on('SIGINT', stop);\nprocess.on('SIGTERM', stop);\n`;\n\nexport function buildTmuxPipeCommand(config: WorkerConfig): string {\n const encodedConfig = encodeConfig(config);\n return [\n shellQuote(process.execPath),\n '-e',\n shellQuote(TMUX_PIPE_RUNNER_SOURCE),\n shellQuote(encodedConfig),\n ].join(' ');\n}\n\nexport async function waitForCaptureProcess(\n sourceId: string,\n pidFile: string,\n timeoutMs = 2000,\n): Promise {\n const deadline = Date.now() + timeoutMs;\n do {\n const identity = readPidIdentity(pidFile);\n if (identity) {\n return { sourceId, process: identity, pidFile };\n }\n await new Promise((resolve) => setTimeout(resolve, 25));\n } while (Date.now() < deadline);\n\n throw new Error(`ProofShot could not capture the log helper identity for ${sourceId}.`);\n}\n\nexport async function startProcessCapture(\n definition: ProcessDefinition,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n const config: WorkerConfig = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes,\n stripAnsi,\n source,\n command: definition.command,\n cwd: definition.cwd,\n env: definition.env,\n };\n return startDetachedWorker(source.id, pidFile, PROCESS_RUNNER_SOURCE, config);\n}\n\nexport async function startFileCapture(\n filePath: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n startTimeMs: number,\n maxBytes: number,\n stripAnsi: boolean,\n): Promise {\n const pidFile = `${source.logPath}.pid`;\n let offset = 0;\n if (fs.existsSync(filePath)) {\n const raw = fs.readFileSync(filePath, 'utf-8');\n appendHistory(\n raw,\n source,\n evidencePath,\n maxBytes,\n stripAnsi,\n 'file',\n );\n offset = fs.statSync(filePath).size;\n }\n\n const config = {\n evidencePath,\n logPath: source.logPath,\n pidFile,\n startTimeMs,\n maxBytes,\n stripAnsi,\n source,\n offset,\n filePath,\n };\n return startDetachedWorker(source.id, pidFile, FILE_RUNNER_SOURCE, config);\n}\n\nexport function appendHistory(\n raw: string,\n source: ResolvedLogSourceState,\n evidencePath: string,\n maxBytes: number,\n stripAnsi: boolean,\n stream: EvidenceEvent['stream'],\n): void {\n const normalized = normalizeLogText(raw, stripAnsi);\n const buffer = Buffer.from(normalized);\n const truncated = buffer.byteLength > maxBytes;\n const retained = truncated\n ? buffer.subarray(Math.max(0, buffer.byteLength - maxBytes)).toString('utf-8')\n : normalized;\n const lines = retained.split('\\n').filter((line) => line.length > 0);\n fs.appendFileSync(source.logPath, retained + (retained.endsWith('\\n') ? '' : '\\n'));\n for (const line of lines) {\n appendEvidenceEvent(evidencePath, {\n version: 1,\n origin: 'environment',\n group: source.group,\n sourceId: source.id,\n sourceTitle: source.title,\n stream,\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: line,\n truncated: truncated || undefined,\n });\n }\n}\n\nexport function createWorkerConfig(params: WorkerConfig): WorkerConfig {\n return params;\n}\n\nasync function startDetachedWorker(\n sourceId: string,\n pidFile: string,\n workerSource: string,\n config: object,\n): Promise {\n fs.mkdirSync(path.dirname(pidFile), { recursive: true });\n const errorFd = fs.openSync(`${pidFile}.stderr`, 'a', 0o600);\n const worker = spawn(process.execPath, ['-e', workerSource, encodeConfig(config)], {\n detached: true,\n stdio: ['ignore', 'ignore', errorFd],\n });\n fs.closeSync(errorFd);\n worker.unref();\n\n let identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n for (let attempt = 0; !identity && attempt < 20; attempt += 1) {\n await new Promise((resolve) => setTimeout(resolve, 10));\n identity = worker.pid ? captureProcessIdentity(worker.pid) : null;\n }\n if (!identity) {\n try {\n if (worker.pid) {\n process.kill(-worker.pid, 'SIGKILL');\n }\n } catch {\n // The worker may already have exited.\n }\n throw new Error(`ProofShot could not capture the runner identity for ${sourceId}.`);\n }\n return { sourceId, process: identity, pidFile };\n}\n\nfunction readPidIdentity(pidFile: string): ProcessIdentity | null {\n try {\n const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());\n return captureProcessIdentity(pid);\n } catch {\n return null;\n }\n}\n\nfunction encodeConfig(config: object): string {\n return Buffer.from(JSON.stringify(config)).toString('base64');\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n","import * as fs from 'fs';\nimport type { EvidenceEvent } from './types.js';\n\nconst ANSI_PATTERN =\n // eslint-disable-next-line no-control-regex\n /[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*)?\\u0007)|(?:(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;\nconst CONTROL_PATTERN = /[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001A\\u001C-\\u001F\\u007F]/g;\n\nexport function normalizeLogText(text: string, stripAnsi = true): string {\n const normalized = text.replace(/\\r\\n?/g, '\\n').replace(CONTROL_PATTERN, '');\n return stripAnsi ? normalized.replace(ANSI_PATTERN, '') : normalized;\n}\n\nexport function appendEvidenceEvent(filePath: string, event: EvidenceEvent): void {\n fs.appendFileSync(filePath, JSON.stringify(event) + '\\n');\n}\n\nexport function loadEvidenceEvents(filePath: string): EvidenceEvent[] {\n if (!fs.existsSync(filePath)) {\n return [];\n }\n\n return fs\n .readFileSync(filePath, 'utf-8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => {\n try {\n return JSON.parse(line) as EvidenceEvent;\n } catch {\n return null;\n }\n })\n .filter((event): event is EvidenceEvent => event !== null);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execFileSync } from 'child_process';\nimport {\n appendHistory,\n buildTmuxPipeCommand,\n createWorkerConfig,\n waitForCaptureProcess,\n} from './workers.js';\nimport { appendEvidenceEvent } from './evidence.js';\nimport type {\n ExternalTmuxConnection,\n LogSourceConfig,\n LogsConfig,\n ResolvedLogSourceState,\n TmuxEnvironmentConfig,\n TmuxEnvironmentState,\n TmuxPaneState,\n} from './types.js';\nimport {\n captureProcessIdentity,\n processIdentityMatches,\n spawnShellCommand,\n terminateOwnedProcess,\n terminateOwnedProcessTree,\n} from '../utils/process.js';\n\ntype PaneMapping = {\n key: string;\n paneId: string;\n title?: string;\n group?: string;\n};\n\ntype TmuxConnection = {\n socketPath: string;\n sessionName: string;\n paneMappings: PaneMapping[];\n ownsServer: boolean;\n ownsSession: boolean;\n};\n\nexport async function startTmuxEnvironment(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n sessionDir: string,\n proofShotSessionName: string,\n startTimeMs: number,\n onState: (state: TmuxEnvironmentState) => void,\n): Promise {\n assertTmuxAvailable();\n const evidencePath = path.join(sessionDir, 'environment.ndjson');\n const logsDir = path.join(sessionDir, 'logs');\n const captureDir = path.join(sessionDir, '.capture');\n fs.mkdirSync(logsDir, { recursive: true });\n fs.mkdirSync(captureDir, { recursive: true, mode: 0o700 });\n fs.writeFileSync(evidencePath, '', { flag: 'a', mode: 0o600 });\n\n let state: TmuxEnvironmentState | null = null;\n let connection: TmuxConnection;\n try {\n connection =\n config.launch.kind === 'panes'\n ? startOwnedTmux(config, proofShotSessionName, (startedConnection) => {\n state = createTmuxState(\n config,\n startedConnection,\n evidencePath,\n );\n onState(state);\n })\n : await startExternalTmux(config);\n if (!state) {\n state = createTmuxState(config, connection, evidencePath);\n onState(state);\n }\n } catch (error) {\n if (state) {\n await stopTmuxEnvironment(state).catch(() => {});\n }\n throw error;\n }\n\n try {\n const tmuxSources = resolveTmuxSources(config, logs, connection);\n const panes = tmuxSources.map(({ config: sourceConfig, mapping }) =>\n resolvePane(\n connection.socketPath,\n sourceConfig,\n mapping,\n logsDir,\n ),\n );\n disambiguateTitles(panes);\n state = { ...state, panes, sources: panes.map((pane) => pane.source) };\n onState(state);\n\n for (const pane of panes) {\n const pipeStatus = tmuxExec(connection.socketPath, [\n 'display-message',\n '-p',\n '-t',\n pane.pane.paneId,\n '#{pane_pipe}',\n ]);\n if (pipeStatus === '1') {\n throw new Error(\n `tmux pane ${pane.pane.paneId} already has a pipe-pane consumer.`,\n );\n }\n\n const pidFile = path.join(captureDir, `${pane.source.id}.pid`);\n const workerConfig = createWorkerConfig({\n evidencePath,\n logPath: pane.source.logPath,\n pidFile,\n startTimeMs,\n maxBytes: logs.maxBytesPerSource || 5 * 1024 * 1024,\n stripAnsi: logs.stripAnsi !== false,\n source: pane.source,\n });\n tmuxExec(connection.socketPath, [\n 'pipe-pane',\n '-t',\n pane.pane.paneId,\n buildTmuxPipeCommand(workerConfig),\n ]);\n const history = tmuxExec(connection.socketPath, [\n 'capture-pane',\n '-p',\n '-S',\n '-',\n '-t',\n pane.pane.paneId,\n ]);\n appendHistory(\n history,\n pane.source,\n evidencePath,\n logs.maxBytesPerSource || 5 * 1024 * 1024,\n logs.stripAnsi !== false,\n 'pty',\n );\n appendEvidenceEvent(evidencePath, {\n version: 1,\n origin: 'environment',\n group: pane.source.group,\n sourceId: pane.source.id,\n sourceTitle: pane.source.title,\n stream: 'pty',\n segment: 'history',\n timestamp: null,\n relativeTimeSec: null,\n text: '[tmux history/live capture boundary]',\n captureGap: true,\n });\n const capture = await waitForCaptureProcess(pane.source.id, pidFile);\n state = { ...state, captures: [...state.captures, capture] };\n onState(state);\n }\n return state;\n } catch (error) {\n await stopTmuxEnvironment(state).catch(() => {});\n throw error;\n }\n}\n\nexport async function stopTmuxEnvironment(\n state: TmuxEnvironmentState,\n): Promise {\n if (\n !processIdentityMatches(state.serverProcess) &&\n !fs.existsSync(state.socket.path) &&\n state.captures.every((capture) => !processIdentityMatches(capture.process))\n ) {\n return;\n }\n assertSocketIdentity(state);\n if (!processIdentityMatches(state.serverProcess)) {\n throw new Error('tmux server identity changed; refusing widened cleanup.');\n }\n\n if (state.stopCommand) {\n await runCommand(state.stopCommand, state.stopCwd || process.cwd());\n } else if (state.ownsSession && tmuxHasSession(state)) {\n tmuxExec(state.socket.path, ['kill-session', '-t', state.sessionName]);\n } else {\n for (const pane of state.panes) {\n try {\n tmuxExec(state.socket.path, ['pipe-pane', '-t', pane.paneId]);\n } catch {\n // A launcher-provided shutdown may already have removed the pane.\n }\n }\n }\n\n for (const capture of state.captures) {\n await terminateOwnedProcess(capture.process, { graceMs: 500 });\n if (processIdentityMatches(capture.process)) {\n throw new Error(`Log helper for ${capture.sourceId} did not stop.`);\n }\n }\n\n if (state.ownsServer && processIdentityMatches(state.serverProcess)) {\n try {\n tmuxExec(state.socket.path, ['kill-server']);\n } catch {\n await terminateOwnedProcessTree(state.serverProcess, { graceMs: 500 });\n }\n }\n if (state.ownsServer && processIdentityMatches(state.serverProcess)) {\n throw new Error('Owned tmux server did not stop.');\n }\n if (state.ownsServer && fs.existsSync(state.socket.path)) {\n const currentSocket = captureSocketIdentity(state.socket.path);\n if (\n currentSocket.inode !== state.socket.inode ||\n currentSocket.uid !== state.socket.uid\n ) {\n throw new Error('tmux socket changed before final cleanup.');\n }\n fs.unlinkSync(state.socket.path);\n }\n if (state.ownsSession && tmuxHasSession(state)) {\n throw new Error(`Owned tmux session ${state.sessionName} did not stop.`);\n }\n}\n\nfunction startOwnedTmux(\n config: TmuxEnvironmentConfig,\n proofShotSessionName: string,\n onStarted: (connection: TmuxConnection) => void,\n): TmuxConnection {\n if (config.launch.kind !== 'panes' || config.launch.panes.length === 0) {\n throw new Error('tmux pane launch requires at least one pane.');\n }\n const uid = process.getuid?.() ?? process.pid;\n const socketDir = path.join('/tmp', `proofshot-${uid}`, 'tmux');\n fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });\n const socketPath = path.join(socketDir, `${proofShotSessionName}.sock`);\n if (fs.existsSync(socketPath)) {\n throw new Error(`Refusing to reuse an existing tmux socket: ${socketPath}`);\n }\n\n const sessionName = config.launch.sessionName || proofShotSessionName;\n const [firstPane, ...remainingPanes] = config.launch.panes;\n const first = parsePaneOutput(\n tmuxExec(socketPath, [\n 'new-session',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-s',\n sessionName,\n '-n',\n 'environment',\n '-c',\n firstPane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(firstPane),\n ]),\n );\n const mappings: PaneMapping[] = [\n {\n key: firstPane.id,\n paneId: first.paneId,\n title: firstPane.title,\n group: firstPane.group,\n },\n ];\n configurePane(socketPath, first.paneId, firstPane.id, firstPane.title);\n onStarted({\n socketPath,\n sessionName,\n paneMappings: [...mappings],\n ownsServer: true,\n ownsSession: true,\n });\n\n for (const pane of remainingPanes) {\n const created = parsePaneOutput(\n tmuxExec(socketPath, [\n 'split-window',\n '-d',\n '-P',\n '-F',\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}',\n '-t',\n `${sessionName}:environment`,\n '-c',\n pane.cwd || config.cwd || process.cwd(),\n buildPaneCommand(pane),\n ]),\n );\n configurePane(socketPath, created.paneId, pane.id, pane.title);\n mappings.push({\n key: pane.id,\n paneId: created.paneId,\n title: pane.title,\n group: pane.group,\n });\n }\n tmuxExec(socketPath, ['select-layout', '-t', `${sessionName}:environment`, 'tiled']);\n return {\n socketPath,\n sessionName,\n paneMappings: mappings,\n ownsServer: true,\n ownsSession: true,\n };\n}\n\nfunction createTmuxState(\n config: TmuxEnvironmentConfig,\n connection: TmuxConnection,\n evidencePath: string,\n): TmuxEnvironmentState {\n const serverPid = Number(\n tmuxExec(connection.socketPath, ['display-message', '-p', '#{pid}']),\n );\n const serverProcess = captureProcessIdentity(serverPid);\n if (!serverProcess) {\n throw new Error('ProofShot could not capture the exact tmux server identity.');\n }\n return {\n kind: 'tmux',\n evidencePath,\n sources: [],\n socket: captureSocketIdentity(connection.socketPath),\n serverProcess,\n sessionName: connection.sessionName,\n ownsServer: connection.ownsServer,\n ownsSession: connection.ownsSession,\n panes: [],\n captures: [],\n stopCommand:\n config.launch.kind === 'external-command'\n ? config.launch.stopCommand\n : undefined,\n stopCwd: config.cwd,\n };\n}\n\nasync function startExternalTmux(\n config: TmuxEnvironmentConfig,\n): Promise {\n if (config.launch.kind !== 'external-command' || !config.connection) {\n throw new Error('External tmux launch requires a connection contract.');\n }\n const hintedSocket = config.connection.socket;\n const socketExistedBefore = hintedSocket ? fs.existsSync(hintedSocket) : true;\n const output = await runCommand(\n config.launch.command,\n config.cwd || process.cwd(),\n );\n const parsed =\n config.connection.format === 'json'\n ? parseJsonConnection(output)\n : parseAttachCommand(output);\n const ownsCreatedSocket =\n hintedSocket !== undefined &&\n path.resolve(hintedSocket) === path.resolve(parsed.socketPath) &&\n !socketExistedBefore;\n return {\n ...parsed,\n ownsServer: ownsCreatedSocket,\n ownsSession: ownsCreatedSocket,\n };\n}\n\nfunction resolveTmuxSources(\n config: TmuxEnvironmentConfig,\n logs: LogsConfig,\n connection: TmuxConnection,\n): Array<{\n config: Extract;\n mapping?: PaneMapping;\n}> {\n const configured = (logs.sources || []).filter(\n (source): source is Extract =>\n source.kind === 'tmux-pane',\n );\n if (configured.length > 0) {\n return configured.map((source) => ({\n config: source,\n mapping:\n 'connectionKey' in source.match\n ? connection.paneMappings.find(\n (mapping) => mapping.key === source.match.connectionKey,\n )\n : undefined,\n }));\n }\n if (config.launch.kind !== 'panes') {\n return [];\n }\n return connection.paneMappings.map((mapping) => ({\n config: {\n id: mapping.key,\n title: mapping.title,\n group: mapping.group,\n kind: 'tmux-pane',\n match: { connectionKey: mapping.key },\n },\n mapping,\n }));\n}\n\nfunction resolvePane(\n socketPath: string,\n sourceConfig: Extract,\n mapping: PaneMapping | undefined,\n logsDir: string,\n): { pane: TmuxPaneState; source: ResolvedLogSourceState } {\n let target: string;\n if ('connectionKey' in sourceConfig.match) {\n if (!mapping) {\n throw new Error(\n `No tmux pane mapping matched connection key \"${sourceConfig.match.connectionKey}\".`,\n );\n }\n target = mapping.paneId;\n } else if ('tag' in sourceConfig.match) {\n const matches = tmuxExec(socketPath, [\n 'list-panes',\n '-a',\n '-F',\n '#{pane_id}\\t#{@proofshot-source}',\n ])\n .split('\\n')\n .filter((line) => line.split('\\t')[1] === sourceConfig.match.tag);\n if (matches.length !== 1) {\n throw new Error(\n `Expected one tmux pane tagged \"${sourceConfig.match.tag}\", found ${matches.length}.`,\n );\n }\n target = matches[0].split('\\t')[0];\n } else {\n target = sourceConfig.match.target;\n }\n\n const fields = tmuxExec(socketPath, [\n 'display-message',\n '-p',\n '-t',\n target,\n '#{pane_id}\\t#{pane_index}\\t#{pane_pid}\\t#{pane_title}\\t#{session_name}:#{window_name}.#{pane_index}',\n ]).split('\\t');\n if (fields.length !== 5) {\n throw new Error(`Could not resolve tmux pane metadata for ${target}.`);\n }\n const paneIndex = Number(fields[1]);\n const tmuxTitle = fields[3].trim();\n const title =\n sourceConfig.title ||\n mapping?.title ||\n (tmuxTitle.length > 0 ? tmuxTitle : `Pane ${paneIndex}`);\n const group = sourceConfig.group || mapping?.group || 'environment';\n const source: ResolvedLogSourceState = {\n id: sourceConfig.id,\n title,\n group,\n kind: 'tmux-pane',\n stream: 'pty',\n logPath: path.join(logsDir, `${sourceConfig.id}.log`),\n include: sourceConfig.include,\n exclude: sourceConfig.exclude,\n };\n return {\n source,\n pane: {\n paneId: fields[0],\n paneIndex,\n panePid: Number(fields[2]),\n sourceId: source.id,\n title,\n group,\n target: fields[4],\n },\n };\n}\n\nfunction disambiguateTitles(\n panes: Array<{ pane: TmuxPaneState; source: ResolvedLogSourceState }>,\n): void {\n const counts = new Map();\n for (const pane of panes) {\n counts.set(pane.source.title, (counts.get(pane.source.title) || 0) + 1);\n }\n for (const pane of panes) {\n if ((counts.get(pane.source.title) || 0) > 1) {\n const title = `${pane.source.title} (Pane ${pane.pane.paneIndex})`;\n pane.source.title = title;\n pane.pane.title = title;\n }\n }\n}\n\nfunction configurePane(\n socketPath: string,\n paneId: string,\n sourceId: string,\n title?: string,\n): void {\n validateId(sourceId);\n tmuxExec(socketPath, [\n 'set-option',\n '-p',\n '-t',\n paneId,\n '@proofshot-source',\n sourceId,\n ]);\n if (title) {\n tmuxExec(socketPath, ['select-pane', '-t', paneId, '-T', title]);\n }\n}\n\nfunction buildPaneCommand(\n pane: { command: string; env?: Record },\n): string {\n const assignments = Object.entries(pane.env || {}).map(([key, value]) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {\n throw new Error(`Invalid environment variable name: ${key}`);\n }\n return `${key}=${shellQuote(value)}`;\n });\n return assignments.length > 0\n ? `env ${assignments.join(' ')} ${pane.command}`\n : pane.command;\n}\n\nfunction parsePaneOutput(output: string): {\n paneId: string;\n paneIndex: number;\n panePid: number;\n} {\n const [paneId, paneIndex, panePid] = output.split('\\t');\n if (!paneId || !Number.isInteger(Number(paneIndex)) || !Number.isInteger(Number(panePid))) {\n throw new Error(`Unexpected tmux pane output: ${output}`);\n }\n return { paneId, paneIndex: Number(paneIndex), panePid: Number(panePid) };\n}\n\nfunction parseJsonConnection(output: string): TmuxConnection {\n const parsed = JSON.parse(output) as ExternalTmuxConnection;\n if (\n !parsed.tmux ||\n !path.isAbsolute(parsed.tmux.socket) ||\n typeof parsed.tmux.session !== 'string'\n ) {\n throw new Error('External launcher returned invalid tmux JSON.');\n }\n return {\n socketPath: parsed.tmux.socket,\n sessionName: parsed.tmux.session,\n paneMappings: parsed.tmux.panes || [],\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction parseAttachCommand(output: string): TmuxConnection {\n const match = output.match(\n /tmux\\s+(?:(-S)\\s+(\\S+)|(-L)\\s+(\\S+)).*?attach(?:-session)?\\s+-t\\s+(\\S+)/,\n );\n if (!match) {\n throw new Error('External launcher did not emit a supported tmux attach command.');\n }\n const flag = match[1] || match[3];\n const value = stripShellQuotes(match[2] || match[4]);\n const sessionName = stripShellQuotes(match[5]);\n const socketPath =\n flag === '-S'\n ? path.resolve(value)\n : execFileSync(\n 'tmux',\n ['-L', value, 'display-message', '-p', '#{socket_path}'],\n { encoding: 'utf-8' },\n ).trim();\n return {\n socketPath,\n sessionName,\n paneMappings: [],\n ownsServer: false,\n ownsSession: false,\n };\n}\n\nfunction tmuxExec(socketPath: string, args: string[]): string {\n return execFileSync('tmux', ['-S', socketPath, ...args], {\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'pipe'],\n }).trimEnd();\n}\n\nfunction tmuxHasSession(state: TmuxEnvironmentState): boolean {\n if (!processIdentityMatches(state.serverProcess)) {\n return false;\n }\n try {\n tmuxExec(state.socket.path, ['has-session', '-t', state.sessionName]);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function runCommand(command: string, cwd: string): Promise {\n const child = spawnShellCommand(command, {\n cwd,\n detached: true,\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n const identity = child.pid ? captureProcessIdentity(child.pid) : null;\n if (!identity) {\n throw new Error('ProofShot could not capture the external launcher identity.');\n }\n let stdout = '';\n let stderr = '';\n child.stdout?.on('data', (chunk) => {\n stdout += chunk.toString();\n });\n child.stderr?.on('data', (chunk) => {\n stderr += chunk.toString();\n });\n const exitCode = await new Promise((resolve, reject) => {\n child.once('error', reject);\n child.once('close', resolve);\n });\n if (exitCode !== 0) {\n await terminateOwnedProcessTree(identity);\n throw new Error(\n `External environment command failed with code ${String(exitCode)}: ${stderr.trim()}`,\n );\n }\n return stdout.trim();\n}\n\nfunction captureSocketIdentity(socketPath: string): {\n path: string;\n inode: number;\n uid: number;\n} {\n const stat = fs.lstatSync(socketPath);\n if (!stat.isSocket() || stat.isSymbolicLink()) {\n throw new Error(`tmux socket is not an owned Unix socket: ${socketPath}`);\n }\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(`tmux socket is owned by uid ${stat.uid}, expected ${uid}.`);\n }\n return { path: socketPath, inode: stat.ino, uid: stat.uid };\n}\n\nfunction assertSocketIdentity(state: TmuxEnvironmentState): void {\n if (!fs.existsSync(state.socket.path)) {\n if (!processIdentityMatches(state.serverProcess)) {\n return;\n }\n throw new Error('Owned tmux socket disappeared while its server is still alive.');\n }\n const current = captureSocketIdentity(state.socket.path);\n if (current.inode !== state.socket.inode || current.uid !== state.socket.uid) {\n throw new Error('tmux socket identity changed; refusing widened cleanup.');\n }\n}\n\nfunction assertTmuxAvailable(): void {\n try {\n execFileSync('tmux', ['-V'], { stdio: 'pipe' });\n } catch {\n throw new Error('tmux is required for environment.kind \"tmux\".');\n }\n}\n\nfunction validateId(id: string): void {\n if (!/^[A-Za-z0-9_-]+$/.test(id)) {\n throw new Error(`Invalid log source id: ${id}`);\n }\n}\n\nfunction shellQuote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`;\n}\n\nfunction stripShellQuotes(value: string): string {\n return value.replace(/^(['\"])(.*)\\1$/, '$2');\n}\n","import { stopRecording } from '../browser/capture.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport {\n captureAgentBrowserProcessIdentity,\n waitForAgentBrowserProcessIdentity,\n} from '../browser/runtime.js';\nimport { closeBrowser } from '../browser/session.js';\nimport {\n ownedProcessTreeIsAlive,\n processIdentityMatches,\n terminateOwnedProcessTree,\n type ProcessIdentity,\n} from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nfunction resolveOwnedBrowserIdentity(session: SessionState): ProcessIdentity | null {\n return (\n session.browserProcess ||\n (session.agentBrowserSocketDir\n ? captureAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n )\n : null)\n );\n}\n\n/**\n * Whether it is safe to address this agent-browser session by socket/name.\n * Persisted immutable identity always wins: a mismatched PID must never fall\n * back to a possibly reused session-name PID file. Legacy state without an\n * identity may adopt the exact current identity from that file.\n */\nexport function canAddressOwnedBrowserSession(session: SessionState): boolean {\n const identity = resolveOwnedBrowserIdentity(session);\n return Boolean(identity && processIdentityMatches(identity));\n}\n\nexport async function stopOwnedBrowser(session: SessionState): Promise {\n const identity = resolveOwnedBrowserIdentity(session);\n\n // The graceful CLI command is name/socket addressed, so issue it only while\n // the persisted immutable identity still matches. Exact tree termination\n // below remains safe when the leader has exited or its PID was recycled.\n if (identity && processIdentityMatches(identity)) {\n closeBrowser(session.sessionName);\n }\n await terminateOwnedProcessTree(identity);\n if (identity && ownedProcessTreeIsAlive(identity)) {\n throw new Error(`Owned browser process session ${identity.sessionId} did not stop.`);\n }\n}\n\nexport async function stopOwnedServer(session: SessionState): Promise {\n await terminateOwnedProcessTree(session.serverProcess);\n if (session.serverProcess && ownedProcessTreeIsAlive(session.serverProcess)) {\n throw new Error(`Owned server process session ${session.serverProcess.sessionId} did not stop.`);\n }\n}\n\nexport async function cleanupFailedStart(session: SessionState): Promise {\n let cleanupError: unknown;\n if (\n !session.browserProcess &&\n session.browserLaunchAttempted &&\n session.agentBrowserSocketDir\n ) {\n session.browserProcess = await waitForAgentBrowserProcessIdentity(\n session.agentBrowserSocketDir,\n session.sessionName,\n );\n }\n if (session.browserLaunchAttempted && !session.browserProcess) {\n cleanupError = new Error(\n `Could not recover exact browser ownership for ${session.sessionName}; cleanup state was retained.`,\n );\n }\n\n // Recording may have started even when its CLI call returned an error. Both\n // operations are session-scoped and best effort. Never address a session\n // name unless its daemon still has the identity captured by this start.\n if (canAddressOwnedBrowserSession(session)) {\n stopRecording(session.sessionName);\n }\n if (session.browserProcess || !session.browserLaunchAttempted) {\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n try {\n await stopOwnedEnvironment(session.environment);\n } catch (error) {\n cleanupError ||= error;\n }\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n if (cleanupError) throw cleanupError;\n}\n","import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport type { ProcessIdentity } from '../utils/process.js';\nimport type { SessionState } from './state.js';\n\nconst SESSION_REGISTRY_DIRECTORY = 'sessions';\n\nexport function getSessionRegistryDir(\n env: NodeJS.ProcessEnv = process.env,\n homeDir = os.homedir(),\n): string {\n const stateHome = env.XDG_STATE_HOME || path.join(homeDir, '.local', 'state');\n return path.join(stateHome, 'proofshot', SESSION_REGISTRY_DIRECTORY);\n}\n\nexport function registerSession(\n session: SessionState,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(session.sessionName);\n prepareRegistryDirectory(registryDir);\n const registryPath = getRegistryPath(session.sessionName, registryDir);\n const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, JSON.stringify(session, null, 2) + '\\n', {\n mode: 0o600,\n });\n fs.renameSync(temporaryPath, registryPath);\n } finally {\n if (fs.existsSync(temporaryPath)) {\n fs.unlinkSync(temporaryPath);\n }\n }\n}\n\nexport function unregisterSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): void {\n validateSessionName(sessionName);\n const registryPath = getRegistryPath(sessionName, registryDir);\n if (fs.existsSync(registryPath)) {\n fs.unlinkSync(registryPath);\n }\n}\n\nexport function listRegisteredSessions(\n registryDir = getSessionRegistryDir(),\n): SessionState[] {\n if (!fs.existsSync(registryDir)) {\n return [];\n }\n assertOwnedDirectory(registryDir);\n\n return fs\n .readdirSync(registryDir)\n .filter((fileName) => fileName.endsWith('.json'))\n .map((fileName) => readRegisteredSession(path.join(registryDir, fileName)))\n .filter((session): session is SessionState => session !== null)\n .sort((left, right) => right.startedAt.localeCompare(left.startedAt));\n}\n\nexport function getRegisteredSession(\n sessionName: string,\n registryDir = getSessionRegistryDir(),\n): SessionState | null {\n validateSessionName(sessionName);\n return readRegisteredSession(getRegistryPath(sessionName, registryDir));\n}\n\nfunction prepareRegistryDirectory(registryDir: string): void {\n fs.mkdirSync(registryDir, { recursive: true, mode: 0o700 });\n assertOwnedDirectory(registryDir);\n}\n\nfunction assertOwnedDirectory(directory: string): void {\n const stat = fs.lstatSync(directory);\n if (!stat.isDirectory() || stat.isSymbolicLink()) {\n throw new Error(`ProofShot session registry is not a real directory: ${directory}`);\n }\n\n const uid = process.getuid?.();\n if (uid !== undefined && stat.uid !== uid) {\n throw new Error(\n `ProofShot session registry is owned by uid ${stat.uid}, expected ${uid}: ${directory}`,\n );\n }\n fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);\n if (uid !== undefined) {\n fs.chmodSync(directory, 0o700);\n }\n}\n\nfunction getRegistryPath(sessionName: string, registryDir: string): string {\n return path.join(registryDir, `${sessionName}.json`);\n}\n\nfunction validateSessionName(sessionName: string): void {\n if (!/^[a-zA-Z0-9_-]+$/.test(sessionName)) {\n throw new Error(`Invalid ProofShot session name: ${sessionName}`);\n }\n}\n\nfunction readRegisteredSession(registryPath: string): SessionState | null {\n try {\n const stat = fs.lstatSync(registryPath);\n if (!stat.isFile() || stat.isSymbolicLink()) {\n return null;\n }\n const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf-8')) as unknown;\n return isSessionState(parsed) ? parsed : null;\n } catch {\n return null;\n }\n}\n\nfunction isSessionState(value: unknown): value is SessionState {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n\n const session = value as Record;\n return (\n typeof session.startedAt === 'string' &&\n (typeof session.description === 'string' || session.description === null) &&\n typeof session.outputDir === 'string' &&\n typeof session.sessionDir === 'string' &&\n typeof session.sessionName === 'string' &&\n typeof session.videoPath === 'string' &&\n typeof session.serverErrorLog === 'string' &&\n typeof session.port === 'number' &&\n (typeof session.serverCommand === 'string' || session.serverCommand === null) &&\n typeof session.serverAlreadyRunning === 'boolean' &&\n typeof session.recordingActive === 'boolean' &&\n isOptionalProcessIdentity(session.serverProcess) &&\n isOptionalProcessIdentity(session.browserProcess)\n );\n}\n\nfunction isOptionalProcessIdentity(value: unknown): boolean {\n if (value === undefined || value === null) {\n return true;\n }\n if (typeof value !== 'object') {\n return false;\n }\n\n const identity = value as Partial;\n return (\n Number.isInteger(identity.pid) &&\n Number.isInteger(identity.processGroupId) &&\n Number.isInteger(identity.sessionId) &&\n typeof identity.startTime === 'string'\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\n\nconst METADATA_FILENAME = 'metadata.json';\n\nexport interface SessionMetadata {\n branch: string;\n commitSha: string;\n startedAt: string;\n description: string | null;\n}\n\n/**\n * Write metadata.json into a session folder.\n * This file persists after proofshot stop (unlike .session.json).\n */\nexport function writeMetadata(sessionDir: string, metadata: SessionMetadata): void {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\\n');\n}\n\n/**\n * Read metadata.json from a session folder.\n * Returns null if the file doesn't exist or is malformed.\n */\nexport function loadMetadata(sessionDir: string): SessionMetadata | null {\n const metadataPath = path.join(sessionDir, METADATA_FILENAME);\n if (!fs.existsSync(metadataPath)) return null;\n try {\n return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));\n } catch {\n return null;\n }\n}\n\n/**\n * Find all session folders in the output directory that match a given branch.\n * Scans subdirectories for metadata.json, filters by branch name.\n * Returns session directories sorted newest first (by startedAt).\n */\nexport function findSessionsForBranch(outputDir: string, branch: string): string[] {\n if (!fs.existsSync(outputDir)) return [];\n\n const entries = fs.readdirSync(outputDir, { withFileTypes: true });\n const matches: { dir: string; startedAt: string }[] = [];\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const sessionDir = path.join(outputDir, entry.name);\n const metadata = loadMetadata(sessionDir);\n if (metadata && metadata.branch === branch) {\n matches.push({ dir: sessionDir, startedAt: metadata.startedAt });\n }\n }\n\n // Sort newest first\n matches.sort((a, b) => b.startedAt.localeCompare(a.startedAt));\n return matches.map((m) => m.dir);\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { randomUUID } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport { loadConfig } from '../utils/config.js';\nimport { setAgentBrowserDefaults } from '../utils/exec.js';\nimport { getConsoleErrors, getConsoleOutput, getConsoleOutputJson } from '../browser/session.js';\nimport { stopRecording } from '../browser/capture.js';\nimport {\n loadSession,\n clearSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport {\n canAddressOwnedBrowserSession,\n stopOwnedBrowser,\n stopOwnedServer,\n} from '../session/lifecycle.js';\nimport { registerSession, unregisterSession } from '../session/registry.js';\nimport { stopOwnedEnvironment } from '../environment/runtime.js';\nimport { writeViewer, type TimestampedLogEntry } from '../artifacts/viewer.js';\nimport { extractServerErrors } from '../utils/error-patterns.js';\nimport { loadSessionLog, type SessionLogEntry } from './exec.js';\nimport { estimateTokenUsage, formatTokenUsage, type TokenUsage } from '../utils/token-usage.js';\n\n/**\n * Parse server.log lines with \"epochMs\\ttext\" format.\n * Returns { entries (with relativeTimeSec), cleanText (timestamps stripped) }.\n */\nfunction parseTimestampedServerLog(\n raw: string,\n startTimeMs: number,\n): { entries: TimestampedLogEntry[]; cleanText: string } {\n if (!raw.trim()) return { entries: [], cleanText: '' };\n\n const lines = raw.split('\\n').filter((l) => l.trim());\n const entries: TimestampedLogEntry[] = [];\n const cleanLines: string[] = [];\n\n for (const line of lines) {\n const tabIdx = line.indexOf('\\t');\n if (tabIdx > 0) {\n const epochStr = line.slice(0, tabIdx);\n const epochMs = parseInt(epochStr, 10);\n if (!isNaN(epochMs) && epochMs > 1e12) {\n const text = line.slice(tabIdx + 1);\n entries.push({\n text,\n relativeTimeSec: Math.max(0, parseFloat(((epochMs - startTimeMs) / 1000).toFixed(1))),\n });\n cleanLines.push(text);\n continue;\n }\n }\n // Fallback: line without timestamp prefix\n entries.push({ text: line, relativeTimeSec: -1 });\n cleanLines.push(line);\n }\n\n return { entries, cleanText: cleanLines.join('\\n') };\n}\n\ninterface StopOptions {\n noClose?: boolean;\n}\n\nexport async function stopCommand(options: StopOptions): Promise {\n const config = loadConfig();\n const controlDir = resolveSessionControlDir(config.output);\n\n // Load session state\n const session = loadSession(controlDir);\n if (!session) {\n console.log(\n chalk.dim('No active session found; all owned processes are already stopped.'),\n );\n return;\n }\n setAgentBrowserDefaults({\n configPath: session.agentBrowserConfigPath || config.browser.configPath,\n socketDir: session.agentBrowserSocketDir,\n });\n\n if (session.bundleComplete) {\n if (session.browserRetained && !options.noClose) {\n console.log(chalk.dim('Closing retained browser...'));\n const browserSessionAddressable = canAddressOwnedBrowserSession(session);\n await stopOwnedBrowser(session);\n session.browserRetained = false;\n clearOwnedSession(session, controlDir);\n if (browserSessionAddressable) {\n console.log(chalk.green('✓') + ' Retained browser closed; proof artifacts were already bundled.');\n } else {\n console.log(\n chalk.yellow('⚠') +\n ' Retained browser ownership was no longer current; skipped session-name close and cleared control state after exact recorded-tree cleanup.',\n );\n }\n } else if (session.browserRetained) {\n console.log(\n chalk.dim('Proof artifacts are already bundled; the owned browser remains intentionally open.'),\n );\n } else {\n clearOwnedSession(session, controlDir);\n console.log(chalk.dim('Proof artifacts are already bundled and all owned processes are stopped.'));\n }\n return;\n }\n\n session.lifecycleStatus = 'stopping';\n session.cleanupError = null;\n persistOwnedSession(session, controlDir);\n const retryingStoppedSession = !session.recordingActive;\n const recordingWasActive = session.recordingActive;\n const startTime = new Date(session.startedAt).getTime();\n const durationMs = Date.now() - startTime;\n const durationSec = Math.round(durationMs / 1000);\n const browserSessionAvailable = canAddressOwnedBrowserSession(session);\n\n const priorConsoleEvidenceAvailable = session.consoleEvidenceAvailable === true;\n if (!browserSessionAvailable && priorConsoleEvidenceAvailable) {\n console.log(\n chalk.dim('Browser already stopped; reusing console evidence collected before cleanup.'),\n );\n } else if (!browserSessionAvailable) {\n console.log(\n chalk.yellow('⚠') +\n ' Browser ownership could not be verified; skipping console and recording commands.\\n' +\n chalk.dim(' Browser evidence may be incomplete; exact recorded-process cleanup will still run.'),\n );\n }\n\n // Step 1: Collect console errors and output\n console.log(chalk.dim('Collecting errors...'));\n let consoleErrors = '';\n let consoleOutput = '';\n let consoleEntries: TimestampedLogEntry[] = [];\n const consoleErrorsPath = path.join(session.sessionDir, 'console-errors.log');\n const consoleOutputPath = path.join(session.sessionDir, 'console-output.log');\n const consoleEntriesPath = path.join(session.sessionDir, 'console-entries.json');\n if (browserSessionAvailable) {\n try {\n consoleErrors = getConsoleErrors(session.sessionName);\n consoleOutput = getConsoleOutput(session.sessionName);\n // Get timestamped console messages for viewer sync\n const consoleMessages = getConsoleOutputJson(session.sessionName);\n consoleEntries = consoleMessages.map((msg) => ({\n text: `[${msg.type}] ${msg.text}`,\n relativeTimeSec: Math.max(0, parseFloat(((msg.timestamp - startTime) / 1000).toFixed(1))),\n }));\n } catch {\n // Browser may already be closed\n }\n writeTextFileAtomically(consoleErrorsPath, consoleErrors);\n writeTextFileAtomically(consoleOutputPath, consoleOutput);\n writeTextFileAtomically(\n consoleEntriesPath,\n JSON.stringify(consoleEntries, null, 2) + '\\n',\n );\n const capturedErrorLines = consoleErrors\n .split('\\n')\n .filter((line) => line.trim() && line.trim() !== 'No errors');\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount =\n capturedErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? capturedErrorLines.length\n : 0;\n // Persist evidence before any cleanup step can fail. A retry must not turn\n // successfully collected browser facts into an \"unavailable\" claim.\n persistOwnedSession(session, controlDir);\n } else if (priorConsoleEvidenceAvailable) {\n if (fs.existsSync(consoleErrorsPath)) {\n consoleErrors = fs.readFileSync(consoleErrorsPath, 'utf-8');\n }\n if (fs.existsSync(consoleOutputPath)) {\n consoleOutput = fs.readFileSync(consoleOutputPath, 'utf-8');\n }\n if (fs.existsSync(consoleEntriesPath)) {\n try {\n const savedEntries = JSON.parse(fs.readFileSync(consoleEntriesPath, 'utf-8'));\n if (Array.isArray(savedEntries)) consoleEntries = savedEntries;\n } catch {\n // Keep the persisted availability/count; only the optional timeline is absent.\n }\n }\n }\n\n // Step 2: Stop recording\n console.log(chalk.dim('Stopping recording...'));\n if (browserSessionAvailable) {\n stopRecording(session.sessionName);\n }\n session.recordingActive = false;\n persistOwnedSession(session, controlDir);\n\n // Step 3: Close browser (unless --no-close)\n let cleanupError: unknown;\n if (!options.noClose) {\n console.log(chalk.dim('Closing browser...'));\n try {\n await stopOwnedBrowser(session);\n } catch (error) {\n cleanupError = error;\n }\n }\n\n if (session.environment) {\n console.log(chalk.dim('Stopping environment...'));\n try {\n await stopOwnedEnvironment(session.environment);\n session.environment = null;\n persistOwnedSession(session, controlDir);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n\n // Step 3.5: Stop only the detached process session created by this start.\n if (session.serverProcess) {\n console.log(chalk.dim('Stopping dev server...'));\n try {\n await stopOwnedServer(session);\n } catch (error) {\n cleanupError ||= error;\n }\n }\n if (cleanupError) {\n session.lifecycleStatus = 'recovery';\n session.cleanupError =\n cleanupError instanceof Error ? cleanupError.message : String(cleanupError);\n persistOwnedSession(session, controlDir);\n throw cleanupError;\n }\n\n // Step 4: Read server log (with timestamp parsing)\n let serverLog = '';\n let serverEntries: TimestampedLogEntry[] = [];\n if (fs.existsSync(session.serverErrorLog)) {\n const rawServerLog = fs.readFileSync(session.serverErrorLog, 'utf-8');\n const parsed = parseTimestampedServerLog(rawServerLog, startTime);\n serverLog = parsed.cleanText;\n serverEntries = parsed.entries;\n }\n\n // Use session subfolder for all artifacts\n const sessionDir = session.sessionDir;\n\n // Step 5: Find all screenshots in session dir\n const screenshots = fs.existsSync(sessionDir)\n ? fs.readdirSync(sessionDir).filter((f) => f.endsWith('.png'))\n : [];\n\n // Step 5.5: Trim video dead time\n const sessionLog = loadSessionLog(sessionDir);\n let trimOffsetSec = session.trimOffsetSec ?? 0;\n if (!session.videoTrimComplete) {\n if (fs.existsSync(session.videoPath)) {\n trimOffsetSec = trimVideo(session.videoPath, screenshots, sessionDir, startTime, sessionLog);\n } else if (recordingWasActive) {\n console.log(\n chalk.yellow('⚠') +\n ' Recording was active but no video file was produced.\\n' +\n chalk.dim(' The screencast may have been interrupted. Screenshots and logs are still saved.'),\n );\n }\n session.videoTrimComplete = true;\n session.trimOffsetSec = trimOffsetSec;\n persistOwnedSession(session, controlDir);\n }\n\n // Step 6: Count errors\n const consoleErrorLines = consoleErrors\n .split('\\n')\n .filter((l) => l.trim() && l.trim() !== 'No errors');\n const observedConsoleErrorCount =\n consoleErrorLines.length > 0 && consoleErrors.trim() !== ''\n ? consoleErrorLines.length\n : 0;\n const consoleEvidenceAvailable =\n browserSessionAvailable || priorConsoleEvidenceAvailable;\n const consoleErrorCount = browserSessionAvailable\n ? observedConsoleErrorCount\n : session.consoleErrorCount ?? 0;\n if (browserSessionAvailable) {\n session.consoleEvidenceAvailable = true;\n session.consoleErrorCount = consoleErrorCount;\n persistOwnedSession(session, controlDir);\n }\n\n // Extract errors from server log using multi-language patterns\n const serverErrorLines = extractServerErrors(serverLog);\n const serverErrorCount = serverErrorLines.length;\n\n // Step 6.5: Estimate token usage\n const tokenUsage = estimateTokenUsage(session.sessionDir, startTime, Date.now());\n\n // Step 7: Generate SUMMARY.md\n const summaryPath = path.join(sessionDir, 'SUMMARY.md');\n const summary = generateProofSummary({\n projectDirectory: session.startDirectory || process.cwd(),\n description: session.description,\n serverCommand: session.serverCommand,\n port: session.port,\n headless: session.headless ?? config.headless ?? true,\n viewport: session.viewport || config.viewport || { width: 1280, height: 720 },\n videoPath: session.videoPath,\n screenshots,\n consoleErrors,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverLog,\n serverErrorCount,\n tokenUsage,\n durationSec,\n outputDir: sessionDir,\n });\n if (!retryingStoppedSession || !fs.existsSync(summaryPath)) {\n writeTextFileAtomically(summaryPath, summary);\n }\n\n // Step 7.5: Generate interactive viewer (if session log exists)\n // Adjust session log timestamps to match the trimmed video\n let viewerEntries = sessionLog;\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted) {\n viewerEntries = sessionLog.map((e) => ({\n ...e,\n relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)),\n }));\n }\n\n // Write adjusted log back to disk so timestamps match the trimmed video\n if (trimOffsetSec > 0 && !session.sessionLogAdjusted && viewerEntries.length > 0) {\n const logPath = path.join(sessionDir, 'session-log.json');\n writeTextFileAtomically(logPath, JSON.stringify(viewerEntries, null, 2) + '\\n');\n }\n if (!session.sessionLogAdjusted) {\n session.sessionLogAdjusted = true;\n persistOwnedSession(session, controlDir);\n }\n\n // Apply trimOffsetSec to log entries (same adjustment as session log)\n const adjustTime = (e: TimestampedLogEntry): TimestampedLogEntry =>\n trimOffsetSec > 0\n ? { ...e, relativeTimeSec: parseFloat((e.relativeTimeSec - trimOffsetSec).toFixed(1)) }\n : e;\n\n const viewerConsoleEntries = consoleEntries.map(adjustTime);\n const viewerServerEntries = serverEntries.map(adjustTime);\n\n const viewerPath = writeViewer(sessionDir, {\n description: session.description,\n serverCommand: session.serverCommand,\n durationSec,\n videoFilename: fs.existsSync(session.videoPath) ? path.basename(session.videoPath) : null,\n consoleErrorCount,\n consoleEvidenceAvailable,\n serverErrorCount,\n consoleOutput,\n serverLog,\n consoleEntries: viewerConsoleEntries.length > 0 ? viewerConsoleEntries : undefined,\n serverEntries: viewerServerEntries.length > 0 ? viewerServerEntries : undefined,\n entries: viewerEntries.length > 0 ? viewerEntries : undefined,\n tokenUsage,\n });\n\n // Step 8: Retain exact browser ownership only when explicitly requested.\n session.bundleComplete = true;\n session.browserRetained = Boolean(options.noClose);\n if (session.browserRetained) {\n session.lifecycleStatus = 'active';\n persistOwnedSession(session, controlDir);\n } else {\n clearOwnedSession(session, controlDir);\n }\n\n // Step 9: Print results\n console.log('');\n console.log(chalk.green.bold('✅ ProofShot verification complete'));\n console.log('');\n\n if (fs.existsSync(session.videoPath)) {\n console.log(`📹 Video: ${chalk.dim(session.videoPath)} (${durationSec}s)`);\n }\n console.log(`📸 Screenshots: ${screenshots.length} captured`);\n console.log(`📝 Summary: ${chalk.dim(summaryPath)}`);\n if (viewerPath) {\n console.log(`🎬 Viewer: ${chalk.dim(viewerPath)}`);\n } else {\n console.log(chalk.dim('Tip: Use \"proofshot exec\" instead of \"agent-browser\" to get an interactive timeline viewer.'));\n }\n console.log('');\n console.log(\n `Console errors: ${\n !consoleEvidenceAvailable\n ? chalk.yellow('unavailable')\n : consoleErrorCount === 0\n ? chalk.green('0')\n : chalk.red(String(consoleErrorCount))\n }`,\n );\n console.log(\n `Server errors: ${serverErrorCount === 0 ? chalk.green('0') : chalk.red(String(serverErrorCount))}`,\n );\n console.log(`Duration: ${durationSec} seconds`);\n console.log('');\n console.log(`Proof artifacts saved to ${chalk.dim(sessionDir)}`);\n if (session.browserRetained) {\n console.log(chalk.dim('Browser retained. Run \"proofshot stop\" later to close this exact session.'));\n }\n\n // If errors were found, print them for immediate feedback\n if (consoleErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Console Errors:'));\n for (const line of consoleErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (consoleErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${consoleErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n\n if (serverErrorCount > 0) {\n console.log('');\n console.log(chalk.red.bold('Server Errors:'));\n for (const line of serverErrorLines.slice(0, 10)) {\n console.log(chalk.red(` ${line}`));\n }\n if (serverErrorLines.length > 10) {\n console.log(chalk.dim(` ... and ${serverErrorLines.length - 10} more (see SUMMARY.md)`));\n }\n }\n}\n\nfunction writeTextFileAtomically(filePath: string, contents: string): void {\n const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n try {\n fs.writeFileSync(temporaryPath, contents);\n fs.renameSync(temporaryPath, filePath);\n } finally {\n if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);\n }\n}\n\nfunction persistOwnedSession(session: SessionState, controlDir: string): void {\n saveSession(session, controlDir);\n registerSession(session);\n}\n\nfunction clearOwnedSession(session: SessionState, controlDir: string): void {\n clearSession(controlDir);\n unregisterSession(session.sessionName);\n}\n\nexport interface SummaryData {\n projectDirectory: string;\n description: string | null;\n serverCommand: string | null;\n port: number;\n headless: boolean;\n viewport: { width: number; height: number };\n videoPath: string;\n screenshots: string[];\n consoleErrors: string;\n consoleErrorCount: number;\n consoleEvidenceAvailable: boolean;\n serverLog: string;\n serverErrorCount: number;\n tokenUsage?: TokenUsage | null;\n durationSec: number;\n outputDir: string;\n}\n\nexport function generateProofSummary(data: SummaryData): string {\n const date = new Date().toISOString().replace('T', ' ').slice(0, 19);\n const projectName = path.basename(data.projectDirectory);\n\n let md = `# ProofShot Verification Report\n\n**Date:** ${date}\n**Project:** ${projectName}\n**Dev Server:** ${data.serverCommand ? data.serverCommand : 'external'} on localhost:${data.port}\n\n`;\n\n if (data.description) {\n md += `## What Was Verified\n\n${data.description}\n\n`;\n }\n\n // Video\n const relativeVideo = path.basename(data.videoPath);\n md += `## Video Recording\n\nFull session recording: [${relativeVideo}](./${relativeVideo}) (${data.durationSec}s)\n\n`;\n\n // Screenshots\n if (data.screenshots.length > 0) {\n md += `## Screenshots\n\n`;\n for (const ss of data.screenshots) {\n md += `![${ss}](./${ss})\\n\\n`;\n }\n }\n\n // Console errors\n md += `## Console Errors\n\n`;\n if (!data.consoleEvidenceAvailable) {\n md += `Browser ownership could not be verified, so console evidence was unavailable.\\n\\n`;\n } else if (data.consoleErrorCount === 0) {\n md += `No console errors detected.\\n\\n`;\n } else {\n md += `${data.consoleErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.consoleErrors}\\n\\`\\`\\`\\n\\n`;\n }\n\n // Server errors\n md += `## Server Errors\n\n`;\n if (data.serverErrorCount === 0) {\n md += `No server errors detected.\\n\\n`;\n } else {\n md += `${data.serverErrorCount} error(s) detected:\\n\\n\\`\\`\\`\\n${data.serverLog.slice(0, 5000)}\\n\\`\\`\\`\\n\\n`;\n if (data.serverLog.length > 5000) {\n md += `_(truncated — see server.log for full output)_\\n\\n`;\n }\n }\n\n if (data.tokenUsage) {\n md += `## Token Usage (Estimated)\\n\\n`;\n md += formatTokenUsage(data.tokenUsage);\n md += '\\n';\n }\n\n // Environment\n md += `## Environment\n- Browser: Chromium (${data.headless ? 'headless' : 'headed'})\n- Viewport: ${data.viewport.width}x${data.viewport.height}\n- Duration: ${data.durationSec} seconds\n`;\n\n return md;\n}\n\n/**\n * Trim dead time from the beginning and end of the session video.\n *\n * Prefers session log timestamps (from `proofshot exec`) when available — these\n * give exact relative times for every action. Falls back to screenshot file\n * birth times when there's no session log.\n *\n * Buffers: 5s before first action, 3s after last action.\n */\nexport function trimVideo(\n videoPath: string,\n screenshots: string[],\n outputDir: string,\n recordingStartMs: number,\n sessionLog: SessionLogEntry[],\n): number {\n let firstActionSec: number | null = null;\n let lastActionSec: number | null = null;\n\n // Prefer session log timestamps (precise, not affected by stale files)\n if (sessionLog.length > 0) {\n firstActionSec = sessionLog[0].relativeTimeSec;\n lastActionSec = sessionLog[sessionLog.length - 1].relativeTimeSec;\n } else if (screenshots.length > 0) {\n // Fallback: use screenshot file birth times (only files created AFTER session start)\n const timestamps = screenshots\n .map((f) => {\n try {\n return fs.statSync(path.join(outputDir, f)).birthtimeMs;\n } catch {\n return null;\n }\n })\n .filter((t): t is number => t !== null && t >= recordingStartMs);\n\n if (timestamps.length === 0) return 0;\n\n firstActionSec = (Math.min(...timestamps) - recordingStartMs) / 1000;\n lastActionSec = (Math.max(...timestamps) - recordingStartMs) / 1000;\n }\n\n if (firstActionSec === null || lastActionSec === null) return 0;\n\n const BUFFER_BEFORE = 5;\n const BUFFER_AFTER = 3;\n\n const trimStartSec = Math.max(0, firstActionSec - BUFFER_BEFORE);\n const trimEndSec = lastActionSec + BUFFER_AFTER;\n\n // Don't trim very short videos\n if (trimEndSec - trimStartSec < 5) return 0;\n\n // Check if ffmpeg is available\n try {\n execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });\n } catch {\n console.log(chalk.dim('Tip: Install ffmpeg to auto-trim dead time from videos.'));\n return 0;\n }\n\n // Trim the video\n const dir = path.dirname(videoPath);\n const ext = path.extname(videoPath);\n const base = path.basename(videoPath, ext);\n const rawPath = path.join(dir, `${base}-raw${ext}`);\n\n try {\n // Rename original to -raw\n fs.renameSync(videoPath, rawPath);\n\n execFileSync(\n 'ffmpeg',\n [\n '-y',\n '-i',\n rawPath,\n '-ss',\n trimStartSec.toFixed(2),\n '-to',\n trimEndSec.toFixed(2),\n '-c',\n 'copy',\n '-abort_on',\n 'empty_output',\n videoPath,\n ],\n { stdio: 'pipe', timeout: 60000 },\n );\n validateTrimmedVideo(videoPath);\n\n // Remove raw file on success\n fs.unlinkSync(rawPath);\n const trimmedDuration = Math.round(trimEndSec - trimStartSec);\n console.log(chalk.dim(`Trimmed video to ${trimmedDuration}s (removed dead time)`));\n return trimStartSec;\n } catch {\n // Restore original if trimming failed\n if (fs.existsSync(videoPath)) {\n fs.unlinkSync(videoPath);\n }\n if (fs.existsSync(rawPath)) {\n fs.renameSync(rawPath, videoPath);\n }\n console.log(chalk.dim('Video trimming failed, keeping original'));\n return 0;\n }\n}\n\nfunction validateTrimmedVideo(videoPath: string): void {\n if (!fs.existsSync(videoPath) || fs.statSync(videoPath).size === 0) {\n throw new Error('FFmpeg produced an empty video');\n }\n\n execFileSync(\n 'ffmpeg',\n ['-v', 'error', '-i', videoPath, '-map', '0:v:0', '-frames:v', '1', '-f', 'null', '-'],\n { stdio: 'pipe', timeout: 60000 },\n );\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport type { SessionLogEntry } from '../commands/exec.js';\n\nexport interface TimestampedLogEntry {\n text: string;\n relativeTimeSec: number;\n}\n\ninterface ViewerData {\n description: string | null;\n serverCommand: string | null;\n durationSec: number;\n videoFilename: string | null;\n entries: SessionLogEntry[];\n consoleErrorCount: number;\n consoleEvidenceAvailable?: boolean;\n serverErrorCount: number;\n consoleOutput?: string;\n serverLog?: string;\n consoleEntries?: TimestampedLogEntry[];\n serverEntries?: TimestampedLogEntry[];\n tokenUsage?: {\n inputTokens: number;\n outputTokens: number;\n totalTokens: number;\n estimatedCost: number;\n source: string;\n } | null;\n}\n\n/** Maximum log size embedded in the viewer HTML (50 KB). */\nconst MAX_LOG_BYTES = 50 * 1024;\n\nfunction truncateLog(log: string, maxBytes: number): { text: string; truncated: boolean } {\n if (log.length <= maxBytes) return { text: log, truncated: false };\n const cut = log.slice(0, maxBytes);\n const lastNl = cut.lastIndexOf('\\n');\n return { text: lastNl > 0 ? cut.slice(0, lastNl) : cut, truncated: true };\n}\n\n/** Simple error-line detector for log highlighting. */\nfunction isErrorLine(line: string): boolean {\n const t = line.trim();\n if (!t) return false;\n return /\\bError:|ERR[_!]|FATAL\\b|CRITICAL\\b|panic:|Exception:|Traceback/i.test(t);\n}\n\n/** Build line-numbered HTML from raw log text, with error lines highlighted. */\nfunction buildLogLines(text: string): string {\n if (!text.trim()) return '';\n return text\n .split('\\n')\n .map((line, i) => {\n const num = i + 1;\n const cls = isErrorLine(line) ? 'log-line log-line-error' : 'log-line';\n return `${num}${escapeHtml(line)}`;\n })\n .join('\\n');\n}\n\n/** Maximum number of log entries embedded in the viewer to avoid DOM bloat. */\nconst MAX_LOG_ENTRIES = 2000;\n\n/** Build timestamped log lines with data-time attributes for video sync. */\nfunction buildTimestampedLogLines(entries: TimestampedLogEntry[]): { html: string; truncated: boolean } {\n if (entries.length === 0) return { html: '', truncated: false };\n const truncated = entries.length > MAX_LOG_ENTRIES;\n const capped = truncated ? entries.slice(0, MAX_LOG_ENTRIES) : entries;\n const html = capped\n .map((entry, i) => {\n const num = i + 1;\n const cls = isErrorLine(entry.text) ? 'log-line log-line-error' : 'log-line';\n const time = formatTime(Math.max(0, entry.relativeTimeSec));\n return `${time}${num}${escapeHtml(entry.text)}`;\n })\n .join('\\n');\n return { html, truncated };\n}\n\n/**\n * Map an action string to an icon character for the timeline.\n */\nfunction getActionIcon(action: string): string {\n const cmd = action.split(' ')[0].toLowerCase();\n switch (cmd) {\n case 'open':\n case 'navigate':\n return '\\u{1F9ED}'; // compass\n case 'click':\n return '\\u{1F5B1}'; // mouse\n case 'fill':\n case 'type':\n return '\\u2328'; // keyboard\n case 'screenshot':\n return '\\u{1F4F7}'; // camera\n case 'snapshot':\n return '\\u{1F441}'; // eye\n case 'scroll':\n return '\\u2195'; // scroll arrows\n case 'press':\n return '\\u2318'; // key\n default:\n return '\\u25B6'; // play\n }\n}\n\n/**\n * Format seconds as m:ss string.\n */\nfunction formatTime(sec: number): string {\n const m = Math.floor(sec / 60);\n const s = Math.floor(sec % 60);\n return `${m}:${s.toString().padStart(2, '0')}`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=
+ const timed = Number.isFinite(entry.relativeTimeSec); + const interaction = timed ? ` data-time="${entry.relativeTimeSec}" onclick="seekTo(${entry.relativeTimeSec})"` : ""; + return `
${i + 1} ${icon}
@@ -3035,14 +3119,14 @@ function generateViewer(data) { icon: getActionIcon(entry.action), action: entry.action, index: i - })) + })).filter((marker) => Number.isFinite(marker.time)) ); const scrubBarHtml = hasVideo ? `
- ${data.entries.map((entry, i) => { - const pct = data.durationSec > 0 ? entry.relativeTimeSec / data.durationSec * 100 : 0; + ${data.entries.filter((entry) => Number.isFinite(entry.relativeTimeSec)).map((entry, i) => { + const pct = timelineDurationSec > 0 ? entry.relativeTimeSec / timelineDurationSec * 100 : 0; const icon = getActionIcon(entry.action); return `
${icon}
`; }).join("\n ")} @@ -3077,6 +3161,38 @@ function generateViewer(data) { } const consoleLineCount = data.consoleEntries && data.consoleEntries.length > 0 ? data.consoleEntries.length : (data.consoleOutput ?? "").split("\n").filter((l) => l.trim()).length; const serverLineCount = data.serverEntries && data.serverEntries.length > 0 ? data.serverEntries.length : (data.serverLog ?? "").split("\n").filter((l) => l.trim()).length; + const evidencePanels = data.evidence ? buildEvidencePanels(data.evidence) : []; + const evidenceTabsHtml = evidencePanels.map( + (panel, index) => `` + ).join("\n "); + const evidenceContentsHtml = evidencePanels.map((panel, index) => { + const sourceIds = new Set(panel.events.map((event) => event.sourceId)); + const incidents = data.evidence?.incidents.filter( + (incident) => incident.sourceIds.some((sourceId) => sourceIds.has(sourceId)) + ) || []; + const summary = panel.summary; + const status = summary ? `${summary.hiddenLineCount} hidden \xB7 ${summary.truncationCount} truncated \xB7 ${summary.captureGapCount} capture gap(s)` : `${incidents.length} grouped incident(s)`; + const incidentsHtml = incidents.length > 0 ? `
${incidents.map( + (incident) => `
${incident.severity.toUpperCase()} \xD7 ${incident.count} ${escapeHtml(incident.message)}
` + ).join("")}
` : ""; + return ``; + }).join("\n "); + const environmentTabIndex = evidencePanels.findIndex( + (panel) => panel.key === "environment" + ); + const browserTabIndex = evidencePanels.findIndex( + (panel) => panel.key === "browser" + ); + const canonicalTabs = evidencePanels.length > 0; + const verdictStatus = data.verdict?.status || "INCOMPLETE"; + const verdictBadgeClass = verdictStatus === "PASS" ? "clean" : verdictStatus === "FAIL" ? "has-errors" : "unavailable"; + const mediaWarningHtml = data.evidence?.mediaTruncated ? `
Media ends ${Math.max(0, data.evidence.mediaDivergenceSec || 0).toFixed(1)}s before the canonical action timeline. Timeline events remain authoritative; seeks clamp to available media.
` : ""; return ` @@ -3492,6 +3608,7 @@ function generateViewer(data) { background: #161b22; z-index: 10; gap: 0; + overflow-x: auto; } .panel-tab { @@ -3584,6 +3701,25 @@ function generateViewer(data) { .log-line-error { background: rgba(248, 81, 73, 0.1); color: #f85149; } .log-line-error .log-ln { color: rgba(248, 81, 73, 0.5); } .log-line-error .log-time { color: rgba(248, 81, 73, 0.5); } + .log-boundary { + display: inline-block; + margin-right: 8px; + padding: 0 5px; + border: 1px solid #30363d; + border-radius: 8px; + color: #8b949e; + font-size: 10px; + } + .incident-list { padding: 8px 16px; border-bottom: 1px solid #21262d; } + .incident { padding: 5px 0; font-size: 12px; color: #d29922; } + .incident.fatal { color: #f85149; } + .media-warning { + padding: 8px 16px; + border-bottom: 1px solid #9e6a03; + background: rgba(187, 128, 9, 0.12); + color: #d29922; + font-size: 12px; + } .log-empty { padding: 32px 16px; @@ -3615,6 +3751,7 @@ function generateViewer(data) { .step:hover { background: #1c2128; } + .step.untimed { cursor: default; } .step.active { background: #1f2a37; @@ -3764,10 +3901,12 @@ function generateViewer(data) {

ProofShot Verification

${descriptionHtml} -

${escapeHtml(date)} · ${data.durationSec}s

+

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

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

No visible evidence for this source

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

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

'\n : ''\n }`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport type { SessionLogEntry } from '../commands/exec.js';\nimport { loadEvidenceEvents } from '../environment/evidence.js';\nimport type {\n EnvironmentState,\n EvidenceEvent,\n ResolvedLogSourceState,\n} from '../environment/types.js';\nimport type { TimestampedLogEntry } from './viewer.js';\n\nexport type VerdictStatus = 'PASS' | 'FAIL' | 'INCOMPLETE' | 'BLOCKED';\n\nexport type EvidenceSourceSummary = {\n id: string;\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n lineCount: number;\n hiddenLineCount: number;\n truncationCount: number;\n captureGapCount: number;\n incidentCount: number;\n};\n\nexport type EvidenceIncident = {\n id: string;\n severity: 'fatal' | 'error';\n group: string;\n message: string;\n count: number;\n sourceIds: string[];\n firstTimeSec: number | null;\n lastTimeSec: number | null;\n};\n\nexport type ScreenshotIntegrity = {\n file: string;\n sha256: string | null;\n validPng: boolean;\n size: number;\n};\n\nexport type CanonicalEvidence = {\n version: 1;\n sessionId: string;\n generatedAt: string;\n timelineDurationSec: number;\n mediaDurationSec: number | null;\n mediaDivergenceSec: number | null;\n mediaTruncated: boolean;\n actions: SessionLogEntry[];\n events: EvidenceEvent[];\n sources: EvidenceSourceSummary[];\n incidents: EvidenceIncident[];\n screenshots: ScreenshotIntegrity[];\n};\n\nexport type Verdict = {\n version: 1;\n status: VerdictStatus;\n reasons: string[];\n fatalIncidentCount: number;\n missingArtifacts: string[];\n duplicateScreenshotHashes: string[][];\n expectedSelectorFailures: string[];\n mediaTruncated: boolean;\n};\n\nexport type EvidenceBuildOptions = {\n sessionId: string;\n sessionDir: string;\n durationSec: number;\n videoPath: string;\n recordingWasActive: boolean;\n consoleEvidenceAvailable: boolean;\n actions: SessionLogEntry[];\n consoleEntries: TimestampedLogEntry[];\n serverEntries: TimestampedLogEntry[];\n environment?: EnvironmentState | null;\n};\n\nexport function writeCanonicalEvidence(\n options: EvidenceBuildOptions,\n): { evidence: CanonicalEvidence; verdict: Verdict } {\n const events = collectEvents(options);\n applyPresentationFilters(events, options.environment?.sources || []);\n const incidents = buildIncidents(events);\n const screenshots = inspectScreenshots(options.sessionDir);\n const mediaDurationSec = probeMediaDuration(options.videoPath);\n const actionDuration = options.actions\n .map((entry) => entry.relativeTimeSec)\n .filter(Number.isFinite)\n .reduce((maximum, current) => Math.max(maximum, current), 0);\n const timelineDurationSec = Math.max(options.durationSec, actionDuration);\n const mediaDivergenceSec =\n mediaDurationSec === null ? null : timelineDurationSec - mediaDurationSec;\n const mediaTruncated =\n mediaDivergenceSec !== null && mediaDivergenceSec > 1;\n const sources = buildSourceSummaries(\n events,\n incidents,\n );\n const evidence: CanonicalEvidence = {\n version: 1,\n sessionId: options.sessionId,\n generatedAt: new Date().toISOString(),\n timelineDurationSec,\n mediaDurationSec,\n mediaDivergenceSec,\n mediaTruncated,\n actions: options.actions,\n events,\n sources,\n incidents,\n screenshots,\n };\n const verdict = buildVerdict(options, evidence);\n fs.writeFileSync(\n path.join(options.sessionDir, 'evidence.json'),\n JSON.stringify(evidence, null, 2) + '\\n',\n );\n fs.writeFileSync(\n path.join(options.sessionDir, 'verdict.json'),\n JSON.stringify(verdict, null, 2) + '\\n',\n );\n return { evidence, verdict };\n}\n\nfunction collectEvents(options: EvidenceBuildOptions): EvidenceEvent[] {\n const environmentEvents =\n options.environment?.evidencePath &&\n fs.existsSync(options.environment.evidencePath)\n ? loadEvidenceEvents(options.environment.evidencePath)\n : [];\n if (environmentEvents.length === 0) {\n environmentEvents.push(\n ...options.serverEntries.map((entry) =>\n toEvidenceEvent(entry, {\n origin: 'environment',\n group: 'backend',\n sourceId: 'server',\n sourceTitle: 'Server',\n stream: 'stderr',\n }),\n ),\n );\n }\n\n const navigations = buildNavigations(options.actions);\n const browserEvents = options.consoleEntries.map((entry) => {\n const navigation = findNavigation(navigations, entry.relativeTimeSec);\n return toEvidenceEvent(entry, {\n origin: 'browser',\n group: 'browser',\n sourceId: navigation.id,\n sourceTitle: navigation.url,\n navigationId: navigation.id,\n pageUrl: navigation.url,\n stream: 'console',\n });\n });\n return [...environmentEvents, ...browserEvents];\n}\n\nfunction toEvidenceEvent(\n entry: TimestampedLogEntry,\n source: Pick<\n EvidenceEvent,\n | 'origin'\n | 'group'\n | 'sourceId'\n | 'sourceTitle'\n | 'stream'\n | 'navigationId'\n | 'pageUrl'\n >,\n): EvidenceEvent {\n return {\n version: 1,\n ...source,\n segment: 'live',\n timestamp: null,\n relativeTimeSec: Number.isFinite(entry.relativeTimeSec)\n ? entry.relativeTimeSec\n : null,\n text: entry.text,\n };\n}\n\nfunction buildNavigations(\n actions: SessionLogEntry[],\n): Array<{ id: string; url: string; startTimeSec: number }> {\n const navigations = actions\n .map((entry) => {\n const match = entry.action.match(/^(?:open|navigate)\\s+(\\S+)/i);\n return match && Number.isFinite(entry.relativeTimeSec)\n ? { url: match[1], startTimeSec: entry.relativeTimeSec }\n : null;\n })\n .filter(\n (\n navigation,\n ): navigation is { url: string; startTimeSec: number } =>\n navigation !== null,\n )\n .map((navigation, index) => ({\n id: `browser-nav-${index + 1}`,\n ...navigation,\n }));\n return navigations.length > 0\n ? navigations\n : [{ id: 'browser-nav-1', url: 'Browser', startTimeSec: 0 }];\n}\n\nfunction findNavigation(\n navigations: Array<{ id: string; url: string; startTimeSec: number }>,\n relativeTimeSec: number,\n): { id: string; url: string } {\n const timed = Number.isFinite(relativeTimeSec) ? relativeTimeSec : 0;\n return (\n [...navigations]\n .reverse()\n .find((navigation) => navigation.startTimeSec <= timed) ||\n navigations[0]\n );\n}\n\nfunction buildIncidents(events: EvidenceEvent[]): EvidenceIncident[] {\n const incidents = new Map<\n string,\n {\n severity: 'fatal' | 'error';\n group: string;\n message: string;\n count: number;\n sourceIds: Set;\n times: number[];\n }\n >();\n for (const event of events) {\n const severity = classifyIncident(event.text);\n if (!severity) {\n continue;\n }\n const message = normalizeIncident(event.text);\n const key = `${event.group}\\0${severity}\\0${message}`;\n const incident = incidents.get(key) || {\n severity,\n group: event.group,\n message,\n count: 0,\n sourceIds: new Set(),\n times: [],\n };\n incident.count += 1;\n incident.sourceIds.add(event.sourceId);\n if (event.relativeTimeSec !== null) {\n incident.times.push(event.relativeTimeSec);\n }\n incidents.set(key, incident);\n }\n\n return [...incidents.values()].map((incident, index) => ({\n id: `incident-${index + 1}`,\n severity: incident.severity,\n group: incident.group,\n message: incident.message,\n count: incident.count,\n sourceIds: [...incident.sourceIds],\n firstTimeSec:\n incident.times.length > 0 ? Math.min(...incident.times) : null,\n lastTimeSec:\n incident.times.length > 0 ? Math.max(...incident.times) : null,\n }));\n}\n\nfunction classifyIncident(text: string): 'fatal' | 'error' | null {\n if (/\\bFATAL\\b|\\bpanic:|uncaught exception|unhandled rejection/i.test(text)) {\n return 'fatal';\n }\n if (/\\bError:|ERR[_!]|Exception:|Traceback/i.test(text)) {\n return 'error';\n }\n return null;\n}\n\nfunction normalizeIncident(text: string): string {\n return text\n .replace(/\\b\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\b/g, '')\n .replace(/:\\d+:\\d+\\b/g, '::')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction buildSourceSummaries(\n events: EvidenceEvent[],\n incidents: EvidenceIncident[],\n): EvidenceSourceSummary[] {\n const sourceKeys = new Map<\n string,\n {\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n events: EvidenceEvent[];\n }\n >();\n for (const event of events) {\n const existing = sourceKeys.get(event.sourceId) || {\n title: event.sourceTitle,\n origin: event.origin,\n group: event.group,\n events: [],\n };\n existing.events.push(event);\n sourceKeys.set(event.sourceId, existing);\n }\n\n return [...sourceKeys.entries()].map(([id, source]) => {\n const hiddenLineCount = source.events.filter(\n (event) => event.presentationHidden,\n ).length;\n return {\n id,\n title: source.title,\n origin: source.origin,\n group: source.group,\n lineCount: source.events.length,\n hiddenLineCount,\n truncationCount: source.events.filter((event) => event.truncated).length,\n captureGapCount: source.events.filter((event) => event.captureGap).length,\n incidentCount: incidents.filter((incident) =>\n incident.sourceIds.includes(id),\n ).length,\n };\n });\n}\n\nfunction applyPresentationFilters(\n events: EvidenceEvent[],\n configuredSources: ResolvedLogSourceState[],\n): void {\n for (const event of events) {\n const config = configuredSources.find(\n (candidate) => candidate.id === event.sourceId,\n );\n if (isHidden(event.text, config)) {\n event.presentationHidden = true;\n }\n }\n}\n\nfunction isHidden(\n text: string,\n config: ResolvedLogSourceState | undefined,\n): boolean {\n if (!config) {\n return false;\n }\n if (\n config.include &&\n config.include.length > 0 &&\n !config.include.some((pattern) => text.includes(pattern))\n ) {\n return true;\n }\n return Boolean(config.exclude?.some((pattern) => text.includes(pattern)));\n}\n\nfunction inspectScreenshots(sessionDir: string): ScreenshotIntegrity[] {\n return fs\n .readdirSync(sessionDir)\n .filter((file) => file.endsWith('.png'))\n .sort()\n .map((file) => {\n const contents = fs.readFileSync(path.join(sessionDir, file));\n const validPng = isValidPng(contents);\n return {\n file,\n sha256: createHash('sha256').update(contents).digest('hex'),\n validPng,\n size: contents.length,\n };\n });\n}\n\nfunction isValidPng(contents: Buffer): boolean {\n if (\n contents.length < 33 ||\n !contents\n .subarray(0, 8)\n .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||\n contents.subarray(12, 16).toString('ascii') !== 'IHDR'\n ) {\n return false;\n }\n return contents.includes(Buffer.from('IEND', 'ascii'), contents.length - 16);\n}\n\nfunction buildVerdict(\n options: EvidenceBuildOptions,\n evidence: CanonicalEvidence,\n): Verdict {\n const missingArtifacts: string[] = [];\n if (options.recordingWasActive && !fs.existsSync(options.videoPath)) {\n missingArtifacts.push(path.basename(options.videoPath));\n }\n const screenshotFiles = new Set(\n evidence.screenshots.map((screenshot) => screenshot.file),\n );\n for (const action of options.actions) {\n const match = action.action.match(/^screenshot\\s+(.+)$/);\n if (match && !screenshotFiles.has(path.basename(match[1]))) {\n missingArtifacts.push(path.basename(match[1]));\n }\n }\n for (const screenshot of evidence.screenshots) {\n if (!screenshot.validPng || screenshot.size === 0) {\n missingArtifacts.push(screenshot.file);\n }\n }\n\n const hashes = new Map();\n for (const screenshot of evidence.screenshots) {\n if (screenshot.sha256 && screenshot.validPng) {\n const files = hashes.get(screenshot.sha256) || [];\n files.push(screenshot.file);\n hashes.set(screenshot.sha256, files);\n }\n }\n const duplicateScreenshotHashes = [...hashes.values()].filter(\n (files) => files.length > 1,\n );\n const expectedSelectorFailures = options.actions\n .filter(\n (action) =>\n action.expectedSelector && action.outcome === 'failed',\n )\n .map((action) => action.expectedSelector!);\n const fatalIncidentCount = evidence.incidents.filter(\n (incident) => incident.severity === 'fatal',\n ).length;\n const blockingReasons = options.consoleEvidenceAvailable\n ? []\n : ['Browser console evidence was unavailable.'];\n const failureReasons = [\n ...(fatalIncidentCount > 0\n ? [`${fatalIncidentCount} fatal incident(s) detected.`]\n : []),\n ...(expectedSelectorFailures.length > 0\n ? [`${expectedSelectorFailures.length} expected selector assertion(s) failed.`]\n : []),\n ...(duplicateScreenshotHashes.length > 0\n ? ['Duplicate key-frame screenshot hashes were detected.']\n : []),\n ];\n const incompleteReasons = [\n ...(missingArtifacts.length > 0\n ? [`${missingArtifacts.length} required artifact(s) were missing or invalid.`]\n : []),\n ...(evidence.mediaTruncated\n ? ['Recorded media ends before the canonical action timeline.']\n : []),\n ...(evidence.sources.some((source) => source.truncationCount > 0)\n ? ['One or more evidence sources were truncated.']\n : []),\n ];\n const status: VerdictStatus =\n blockingReasons.length > 0\n ? 'BLOCKED'\n : failureReasons.length > 0\n ? 'FAIL'\n : incompleteReasons.length > 0\n ? 'INCOMPLETE'\n : 'PASS';\n return {\n version: 1,\n status,\n reasons: [...blockingReasons, ...failureReasons, ...incompleteReasons],\n fatalIncidentCount,\n missingArtifacts: [...new Set(missingArtifacts)],\n duplicateScreenshotHashes,\n expectedSelectorFailures,\n mediaTruncated: evidence.mediaTruncated,\n };\n}\n\nfunction probeMediaDuration(videoPath: string): number | null {\n if (!fs.existsSync(videoPath)) {\n return null;\n }\n try {\n const output = execFileSync(\n 'ffprobe',\n [\n '-v',\n 'error',\n '-show_entries',\n 'format=duration',\n '-of',\n 'default=noprint_wrappers=1:nokey=1',\n videoPath,\n ],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n const duration = Number(output);\n return Number.isFinite(duration) ? duration : null;\n } catch {\n return null;\n }\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n outcome?: 'passed' | 'failed';\n expectedSelector?: string;\n error?: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\nexport function translateProofShotExecArgs(args: string[]): {\n agentBrowserArgs: string[];\n expectedSelector?: string;\n} {\n if (args[0] === 'assert-visible' && args.length > 1) {\n return {\n agentBrowserArgs: ['is', 'visible', ...args.slice(1)],\n expectedSelector: args.slice(1).join(' '),\n };\n }\n return { agentBrowserArgs: args };\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=
+ const timed = Number.isFinite(entry.relativeTimeSec); + const interaction = timed ? ` data-time="${entry.relativeTimeSec}" onclick="seekTo(${entry.relativeTimeSec})"` : ""; + return `
${i + 1} ${icon}
@@ -3038,14 +3122,14 @@ function generateViewer(data) { icon: getActionIcon(entry.action), action: entry.action, index: i - })) + })).filter((marker) => Number.isFinite(marker.time)) ); const scrubBarHtml = hasVideo ? `
- ${data.entries.map((entry, i) => { - const pct = data.durationSec > 0 ? entry.relativeTimeSec / data.durationSec * 100 : 0; + ${data.entries.filter((entry) => Number.isFinite(entry.relativeTimeSec)).map((entry, i) => { + const pct = timelineDurationSec > 0 ? entry.relativeTimeSec / timelineDurationSec * 100 : 0; const icon = getActionIcon(entry.action); return `
${icon}
`; }).join("\n ")} @@ -3080,6 +3164,38 @@ function generateViewer(data) { } const consoleLineCount = data.consoleEntries && data.consoleEntries.length > 0 ? data.consoleEntries.length : (data.consoleOutput ?? "").split("\n").filter((l) => l.trim()).length; const serverLineCount = data.serverEntries && data.serverEntries.length > 0 ? data.serverEntries.length : (data.serverLog ?? "").split("\n").filter((l) => l.trim()).length; + const evidencePanels = data.evidence ? buildEvidencePanels(data.evidence) : []; + const evidenceTabsHtml = evidencePanels.map( + (panel, index) => `` + ).join("\n "); + const evidenceContentsHtml = evidencePanels.map((panel, index) => { + const sourceIds = new Set(panel.events.map((event) => event.sourceId)); + const incidents = data.evidence?.incidents.filter( + (incident) => incident.sourceIds.some((sourceId) => sourceIds.has(sourceId)) + ) || []; + const summary = panel.summary; + const status = summary ? `${summary.hiddenLineCount} hidden \xB7 ${summary.truncationCount} truncated \xB7 ${summary.captureGapCount} capture gap(s)` : `${incidents.length} grouped incident(s)`; + const incidentsHtml = incidents.length > 0 ? `
${incidents.map( + (incident) => `
${incident.severity.toUpperCase()} \xD7 ${incident.count} ${escapeHtml(incident.message)}
` + ).join("")}
` : ""; + return ``; + }).join("\n "); + const environmentTabIndex = evidencePanels.findIndex( + (panel) => panel.key === "environment" + ); + const browserTabIndex = evidencePanels.findIndex( + (panel) => panel.key === "browser" + ); + const canonicalTabs = evidencePanels.length > 0; + const verdictStatus = data.verdict?.status || "INCOMPLETE"; + const verdictBadgeClass = verdictStatus === "PASS" ? "clean" : verdictStatus === "FAIL" ? "has-errors" : "unavailable"; + const mediaWarningHtml = data.evidence?.mediaTruncated ? `
Media ends ${Math.max(0, data.evidence.mediaDivergenceSec || 0).toFixed(1)}s before the canonical action timeline. Timeline events remain authoritative; seeks clamp to available media.
` : ""; return ` @@ -3495,6 +3611,7 @@ function generateViewer(data) { background: #161b22; z-index: 10; gap: 0; + overflow-x: auto; } .panel-tab { @@ -3587,6 +3704,25 @@ function generateViewer(data) { .log-line-error { background: rgba(248, 81, 73, 0.1); color: #f85149; } .log-line-error .log-ln { color: rgba(248, 81, 73, 0.5); } .log-line-error .log-time { color: rgba(248, 81, 73, 0.5); } + .log-boundary { + display: inline-block; + margin-right: 8px; + padding: 0 5px; + border: 1px solid #30363d; + border-radius: 8px; + color: #8b949e; + font-size: 10px; + } + .incident-list { padding: 8px 16px; border-bottom: 1px solid #21262d; } + .incident { padding: 5px 0; font-size: 12px; color: #d29922; } + .incident.fatal { color: #f85149; } + .media-warning { + padding: 8px 16px; + border-bottom: 1px solid #9e6a03; + background: rgba(187, 128, 9, 0.12); + color: #d29922; + font-size: 12px; + } .log-empty { padding: 32px 16px; @@ -3618,6 +3754,7 @@ function generateViewer(data) { .step:hover { background: #1c2128; } + .step.untimed { cursor: default; } .step.active { background: #1f2a37; @@ -3767,10 +3904,12 @@ function generateViewer(data) {

ProofShot Verification

${descriptionHtml} -

${escapeHtml(date)} · ${data.durationSec}s

+

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

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

No visible evidence for this source

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

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

'\n : ''\n }`;\n}\n\n/**\n * Escape HTML special characters.\n */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/**\n * Serialize session log entries to a JSON string safe for embedding in HTML \n\n`;\n}\n\n/**\n * Write the viewer HTML file to the output directory.\n * Returns the path to the generated file, or null if no session log exists.\n */\nexport function writeViewer(\n outputDir: string,\n data: Omit & { entries?: SessionLogEntry[] },\n): string | null {\n // Load session log if entries not provided\n let entries = data.entries;\n if (!entries) {\n const logPath = path.join(outputDir, 'session-log.json');\n if (!fs.existsSync(logPath)) return null;\n try {\n entries = JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return null;\n }\n }\n\n if (!entries || entries.length === 0) return null;\n\n const html = generateViewer({ ...data, entries });\n const viewerPath = path.join(outputDir, 'viewer.html');\n fs.writeFileSync(viewerPath, html);\n return viewerPath;\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { createHash } from 'crypto';\nimport { execFileSync } from 'child_process';\nimport type { SessionLogEntry } from '../commands/exec.js';\nimport { loadEvidenceEvents } from '../environment/evidence.js';\nimport type {\n EnvironmentState,\n EvidenceEvent,\n ResolvedLogSourceState,\n} from '../environment/types.js';\nimport type { TimestampedLogEntry } from './viewer.js';\n\nexport type VerdictStatus = 'PASS' | 'FAIL' | 'INCOMPLETE' | 'BLOCKED';\n\nexport type EvidenceSourceSummary = {\n id: string;\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n lineCount: number;\n hiddenLineCount: number;\n truncationCount: number;\n captureGapCount: number;\n incidentCount: number;\n};\n\nexport type EvidenceIncident = {\n id: string;\n severity: 'fatal' | 'error';\n group: string;\n message: string;\n count: number;\n sourceIds: string[];\n firstTimeSec: number | null;\n lastTimeSec: number | null;\n};\n\nexport type ScreenshotIntegrity = {\n file: string;\n sha256: string | null;\n validPng: boolean;\n size: number;\n};\n\nexport type CanonicalEvidence = {\n version: 1;\n sessionId: string;\n generatedAt: string;\n timelineDurationSec: number;\n mediaDurationSec: number | null;\n mediaDivergenceSec: number | null;\n mediaTruncated: boolean;\n actions: SessionLogEntry[];\n events: EvidenceEvent[];\n sources: EvidenceSourceSummary[];\n incidents: EvidenceIncident[];\n screenshots: ScreenshotIntegrity[];\n};\n\nexport type Verdict = {\n version: 1;\n status: VerdictStatus;\n reasons: string[];\n fatalIncidentCount: number;\n missingArtifacts: string[];\n duplicateScreenshotHashes: string[][];\n expectedSelectorFailures: string[];\n mediaTruncated: boolean;\n};\n\nexport type EvidenceBuildOptions = {\n sessionId: string;\n sessionDir: string;\n durationSec: number;\n videoPath: string;\n recordingWasActive: boolean;\n consoleEvidenceAvailable: boolean;\n actions: SessionLogEntry[];\n consoleEntries: TimestampedLogEntry[];\n serverEntries: TimestampedLogEntry[];\n environment?: EnvironmentState | null;\n};\n\nexport function writeCanonicalEvidence(\n options: EvidenceBuildOptions,\n): { evidence: CanonicalEvidence; verdict: Verdict } {\n const events = collectEvents(options);\n applyPresentationFilters(events, options.environment?.sources || []);\n const incidents = buildIncidents(events);\n const screenshots = inspectScreenshots(options.sessionDir);\n const mediaDurationSec = probeMediaDuration(options.videoPath);\n const actionDuration = options.actions\n .map((entry) => entry.relativeTimeSec)\n .filter(Number.isFinite)\n .reduce((maximum, current) => Math.max(maximum, current), 0);\n const timelineDurationSec = Math.max(options.durationSec, actionDuration);\n const mediaDivergenceSec =\n mediaDurationSec === null ? null : timelineDurationSec - mediaDurationSec;\n const mediaTruncated =\n mediaDivergenceSec !== null && mediaDivergenceSec > 1;\n const sources = buildSourceSummaries(\n events,\n incidents,\n );\n const evidence: CanonicalEvidence = {\n version: 1,\n sessionId: options.sessionId,\n generatedAt: new Date().toISOString(),\n timelineDurationSec,\n mediaDurationSec,\n mediaDivergenceSec,\n mediaTruncated,\n actions: options.actions,\n events,\n sources,\n incidents,\n screenshots,\n };\n const verdict = buildVerdict(options, evidence);\n fs.writeFileSync(\n path.join(options.sessionDir, 'evidence.json'),\n JSON.stringify(evidence, null, 2) + '\\n',\n );\n fs.writeFileSync(\n path.join(options.sessionDir, 'verdict.json'),\n JSON.stringify(verdict, null, 2) + '\\n',\n );\n return { evidence, verdict };\n}\n\nfunction collectEvents(options: EvidenceBuildOptions): EvidenceEvent[] {\n const environmentEvents =\n options.environment?.evidencePath &&\n fs.existsSync(options.environment.evidencePath)\n ? loadEvidenceEvents(options.environment.evidencePath)\n : [];\n if (environmentEvents.length === 0) {\n environmentEvents.push(\n ...options.serverEntries.map((entry) =>\n toEvidenceEvent(entry, {\n origin: 'environment',\n group: 'backend',\n sourceId: 'server',\n sourceTitle: 'Server',\n stream: 'stderr',\n }),\n ),\n );\n }\n\n const navigations = buildNavigations(options.actions);\n const browserEvents = options.consoleEntries.map((entry) => {\n const navigation = findNavigation(navigations, entry.relativeTimeSec);\n return toEvidenceEvent(entry, {\n origin: 'browser',\n group: 'browser',\n sourceId: navigation.id,\n sourceTitle: navigation.url,\n navigationId: navigation.id,\n pageUrl: navigation.url,\n stream: 'console',\n });\n });\n return [...environmentEvents, ...browserEvents];\n}\n\nfunction toEvidenceEvent(\n entry: TimestampedLogEntry,\n source: Pick<\n EvidenceEvent,\n | 'origin'\n | 'group'\n | 'sourceId'\n | 'sourceTitle'\n | 'stream'\n | 'navigationId'\n | 'pageUrl'\n >,\n): EvidenceEvent {\n return {\n version: 1,\n ...source,\n segment: 'live',\n timestamp: null,\n relativeTimeSec: Number.isFinite(entry.relativeTimeSec)\n ? entry.relativeTimeSec\n : null,\n text: entry.text,\n };\n}\n\nfunction buildNavigations(\n actions: SessionLogEntry[],\n): Array<{ id: string; url: string; startTimeSec: number }> {\n const navigations = actions\n .map((entry) => {\n const match = entry.action.match(/^(?:open|navigate)\\s+(\\S+)/i);\n return match && Number.isFinite(entry.relativeTimeSec)\n ? { url: match[1], startTimeSec: entry.relativeTimeSec }\n : null;\n })\n .filter(\n (\n navigation,\n ): navigation is { url: string; startTimeSec: number } =>\n navigation !== null,\n )\n .map((navigation, index) => ({\n id: `browser-nav-${index + 1}`,\n ...navigation,\n }));\n return navigations.length > 0\n ? navigations\n : [{ id: 'browser-nav-1', url: 'Browser', startTimeSec: 0 }];\n}\n\nfunction findNavigation(\n navigations: Array<{ id: string; url: string; startTimeSec: number }>,\n relativeTimeSec: number,\n): { id: string; url: string } {\n const timed = Number.isFinite(relativeTimeSec) ? relativeTimeSec : 0;\n return (\n [...navigations]\n .reverse()\n .find((navigation) => navigation.startTimeSec <= timed) ||\n navigations[0]\n );\n}\n\nfunction buildIncidents(events: EvidenceEvent[]): EvidenceIncident[] {\n const incidents = new Map<\n string,\n {\n severity: 'fatal' | 'error';\n group: string;\n message: string;\n count: number;\n sourceIds: Set;\n times: number[];\n }\n >();\n for (const event of events) {\n const severity = classifyIncident(event.text);\n if (!severity) {\n continue;\n }\n const message = normalizeIncident(event.text);\n const key = `${event.group}\\0${severity}\\0${message}`;\n const incident = incidents.get(key) || {\n severity,\n group: event.group,\n message,\n count: 0,\n sourceIds: new Set(),\n times: [],\n };\n incident.count += 1;\n incident.sourceIds.add(event.sourceId);\n if (event.relativeTimeSec !== null) {\n incident.times.push(event.relativeTimeSec);\n }\n incidents.set(key, incident);\n }\n\n return [...incidents.values()].map((incident, index) => ({\n id: `incident-${index + 1}`,\n severity: incident.severity,\n group: incident.group,\n message: incident.message,\n count: incident.count,\n sourceIds: [...incident.sourceIds],\n firstTimeSec:\n incident.times.length > 0 ? Math.min(...incident.times) : null,\n lastTimeSec:\n incident.times.length > 0 ? Math.max(...incident.times) : null,\n }));\n}\n\nfunction classifyIncident(text: string): 'fatal' | 'error' | null {\n if (/\\bFATAL\\b|\\bpanic:|uncaught exception|unhandled rejection/i.test(text)) {\n return 'fatal';\n }\n if (/\\bError:|ERR[_!]|Exception:|Traceback/i.test(text)) {\n return 'error';\n }\n return null;\n}\n\nfunction normalizeIncident(text: string): string {\n return text\n .replace(/\\b\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\b/g, '')\n .replace(/:\\d+:\\d+\\b/g, '::')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction buildSourceSummaries(\n events: EvidenceEvent[],\n incidents: EvidenceIncident[],\n): EvidenceSourceSummary[] {\n const sourceKeys = new Map<\n string,\n {\n title: string;\n origin: EvidenceEvent['origin'];\n group: string;\n events: EvidenceEvent[];\n }\n >();\n for (const event of events) {\n const existing = sourceKeys.get(event.sourceId) || {\n title: event.sourceTitle,\n origin: event.origin,\n group: event.group,\n events: [],\n };\n existing.events.push(event);\n sourceKeys.set(event.sourceId, existing);\n }\n\n return [...sourceKeys.entries()].map(([id, source]) => {\n const hiddenLineCount = source.events.filter(\n (event) => event.presentationHidden,\n ).length;\n return {\n id,\n title: source.title,\n origin: source.origin,\n group: source.group,\n lineCount: source.events.length,\n hiddenLineCount,\n truncationCount: source.events.filter((event) => event.truncated).length,\n captureGapCount: source.events.filter((event) => event.captureGap).length,\n incidentCount: incidents.filter((incident) =>\n incident.sourceIds.includes(id),\n ).length,\n };\n });\n}\n\nfunction applyPresentationFilters(\n events: EvidenceEvent[],\n configuredSources: ResolvedLogSourceState[],\n): void {\n for (const event of events) {\n const config = configuredSources.find(\n (candidate) => candidate.id === event.sourceId,\n );\n if (isHidden(event.text, config)) {\n event.presentationHidden = true;\n }\n }\n}\n\nfunction isHidden(\n text: string,\n config: ResolvedLogSourceState | undefined,\n): boolean {\n if (!config) {\n return false;\n }\n if (\n config.include &&\n config.include.length > 0 &&\n !config.include.some((pattern) => text.includes(pattern))\n ) {\n return true;\n }\n return Boolean(config.exclude?.some((pattern) => text.includes(pattern)));\n}\n\nfunction inspectScreenshots(sessionDir: string): ScreenshotIntegrity[] {\n return fs\n .readdirSync(sessionDir)\n .filter((file) => file.endsWith('.png'))\n .sort()\n .map((file) => {\n const contents = fs.readFileSync(path.join(sessionDir, file));\n const validPng = isValidPng(contents);\n return {\n file,\n sha256: createHash('sha256').update(contents).digest('hex'),\n validPng,\n size: contents.length,\n };\n });\n}\n\nfunction isValidPng(contents: Buffer): boolean {\n if (\n contents.length < 33 ||\n !contents\n .subarray(0, 8)\n .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||\n contents.subarray(12, 16).toString('ascii') !== 'IHDR'\n ) {\n return false;\n }\n return contents.includes(Buffer.from('IEND', 'ascii'), contents.length - 16);\n}\n\nfunction buildVerdict(\n options: EvidenceBuildOptions,\n evidence: CanonicalEvidence,\n): Verdict {\n const missingArtifacts: string[] = [];\n if (options.recordingWasActive && !fs.existsSync(options.videoPath)) {\n missingArtifacts.push(path.basename(options.videoPath));\n }\n const screenshotFiles = new Set(\n evidence.screenshots.map((screenshot) => screenshot.file),\n );\n for (const action of options.actions) {\n const match = action.action.match(/^screenshot\\s+(.+)$/);\n if (match && !screenshotFiles.has(path.basename(match[1]))) {\n missingArtifacts.push(path.basename(match[1]));\n }\n }\n for (const screenshot of evidence.screenshots) {\n if (!screenshot.validPng || screenshot.size === 0) {\n missingArtifacts.push(screenshot.file);\n }\n }\n\n const hashes = new Map();\n for (const screenshot of evidence.screenshots) {\n if (screenshot.sha256 && screenshot.validPng) {\n const files = hashes.get(screenshot.sha256) || [];\n files.push(screenshot.file);\n hashes.set(screenshot.sha256, files);\n }\n }\n const duplicateScreenshotHashes = [...hashes.values()].filter(\n (files) => files.length > 1,\n );\n const expectedSelectorFailures = options.actions\n .filter(\n (action) =>\n action.expectedSelector && action.outcome === 'failed',\n )\n .map((action) => action.expectedSelector!);\n const fatalIncidentCount = evidence.incidents.filter(\n (incident) => incident.severity === 'fatal',\n ).length;\n const blockingReasons = options.consoleEvidenceAvailable\n ? []\n : ['Browser console evidence was unavailable.'];\n const failureReasons = [\n ...(fatalIncidentCount > 0\n ? [`${fatalIncidentCount} fatal incident(s) detected.`]\n : []),\n ...(expectedSelectorFailures.length > 0\n ? [`${expectedSelectorFailures.length} expected selector assertion(s) failed.`]\n : []),\n ...(duplicateScreenshotHashes.length > 0\n ? ['Duplicate key-frame screenshot hashes were detected.']\n : []),\n ];\n const incompleteReasons = [\n ...(missingArtifacts.length > 0\n ? [`${missingArtifacts.length} required artifact(s) were missing or invalid.`]\n : []),\n ...(evidence.mediaTruncated\n ? ['Recorded media ends before the canonical action timeline.']\n : []),\n ...(evidence.sources.some((source) => source.truncationCount > 0)\n ? ['One or more evidence sources were truncated.']\n : []),\n ];\n const status: VerdictStatus =\n blockingReasons.length > 0\n ? 'BLOCKED'\n : failureReasons.length > 0\n ? 'FAIL'\n : incompleteReasons.length > 0\n ? 'INCOMPLETE'\n : 'PASS';\n return {\n version: 1,\n status,\n reasons: [...blockingReasons, ...failureReasons, ...incompleteReasons],\n fatalIncidentCount,\n missingArtifacts: [...new Set(missingArtifacts)],\n duplicateScreenshotHashes,\n expectedSelectorFailures,\n mediaTruncated: evidence.mediaTruncated,\n };\n}\n\nfunction probeMediaDuration(videoPath: string): number | null {\n if (!fs.existsSync(videoPath)) {\n return null;\n }\n try {\n const output = execFileSync(\n 'ffprobe',\n [\n '-v',\n 'error',\n '-show_entries',\n 'format=duration',\n '-of',\n 'default=noprint_wrappers=1:nokey=1',\n videoPath,\n ],\n { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },\n ).trim();\n const duration = Number(output);\n return Number.isFinite(duration) ? duration : null;\n } catch {\n return null;\n }\n}\n","/**\n * Language-agnostic error detection patterns for server log analysis.\n *\n * Each entry covers a language/runtime ecosystem. To add support for a new\n * language, append a new object to the PATTERNS array below.\n *\n * Patterns are tested against individual lines of server output. Each regex\n * keeps its own flags — some are case-sensitive on purpose to avoid matching\n * normal log lines like \"0 errors found\".\n */\n\ninterface LanguagePatterns {\n /** Human-readable language/runtime name */\n name: string;\n /** Regexes that match error lines in this language's typical output */\n patterns: RegExp[];\n}\n\n/**\n * Add new languages here. Each pattern should match lines that indicate\n * an actual error, not normal informational output.\n */\nconst PATTERNS: LanguagePatterns[] = [\n {\n name: 'JavaScript / Node.js',\n patterns: [\n /\\bError:/, // TypeError: x is not a function\n /\\bERR[_!]/, // npm ERR!, ERR_MODULE_NOT_FOUND\n /\\bEACCES\\b|\\bENOENT\\b|\\bEADDRINUSE\\b/, // System errors\n /\\bat\\s+.+\\(.+:\\d+:\\d+\\)/, // Stack trace: at fn (file.js:10:5)\n /Unhandled.+rejection/i, // Unhandled promise rejection\n ],\n },\n {\n name: 'Python',\n patterns: [\n /Traceback \\(most recent call last\\)/,\n /^\\s*File \".+\", line \\d+/, // Stack trace line\n /\\w+Error:/, // ValueError:, KeyError:, etc.\n /\\w+Exception:/, // Django ImproperlyConfigured, etc.\n ],\n },\n {\n name: 'Ruby / Rails',\n patterns: [\n /\\w+Error \\(.+\\)/, // ActionController::RoutingError (...)\n /from .+:\\d+:in `.+'/, // Stack trace\n /FATAL --/, // Rails logger FATAL level\n /Errno::\\w+/, // Errno::ENOENT\n ],\n },\n {\n name: 'Go',\n patterns: [\n /^panic:/, // Go panic\n /^goroutine \\d+/, // Goroutine stack dump\n /runtime error:/,\n ],\n },\n {\n name: 'Java / Kotlin',\n patterns: [\n /Exception in thread/, // Exception in thread \"main\"\n /\\w+Exception:/, // NullPointerException:\n /\\bat\\s+[\\w.$]+\\(.+:\\d+\\)/, // at com.example.Main(Main.java:10)\n /Caused by:/,\n ],\n },\n {\n name: 'Rust',\n patterns: [\n /thread '.+' panicked at/, // thread 'main' panicked at\n /error\\[E\\d+\\]/, // Compiler error: error[E0308]\n ],\n },\n {\n name: 'PHP',\n patterns: [\n /PHP\\s+(Fatal|Parse|Warning)\\s+error:/i,\n /Stack trace:/,\n /thrown in .+ on line \\d+/,\n ],\n },\n {\n name: 'C# / .NET',\n patterns: [\n /Unhandled exception/,\n /\\w+Exception:/,\n /at .+ in .+:line \\d+/, // Stack trace\n ],\n },\n {\n name: 'Elixir / Phoenix',\n patterns: [\n /\\*\\* \\(\\w+\\)/, // ** (EXIT), ** (RuntimeError)\n /\\(exit\\) an exception was raised/,\n ],\n },\n {\n name: 'Generic',\n patterns: [\n /\\bFATAL\\b/, // Common log level\n /\\bCRITICAL\\b/, // Common log level\n /\\bSegmentation fault\\b/,\n /\\bcore dumped\\b/,\n /\\bout of memory\\b/i,\n ],\n },\n];\n\n/**\n * Extract lines from server log output that look like errors.\n * Tests each line against all language patterns.\n */\nexport function extractServerErrors(log: string): string[] {\n if (!log.trim()) return [];\n const allPatterns = PATTERNS.flatMap((lp) => lp.patterns);\n return log.split('\\n').filter((line) => {\n const trimmed = line.trim();\n if (!trimmed) return false;\n return allPatterns.some((p) => p.test(trimmed));\n });\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { loadConfig } from '../utils/config.js';\nimport {\n ab,\n buildAgentBrowserCommand,\n getAgentBrowserEnvironment,\n setAgentBrowserDefaults,\n} from '../utils/exec.js';\nimport {\n loadSession,\n resolveSessionControlDir,\n saveSession,\n type SessionState,\n} from '../session/state.js';\nimport { canAddressOwnedBrowserSession } from '../session/lifecycle.js';\nimport { registerSession } from '../session/registry.js';\n\nconst SESSION_LOG_FILENAME = 'session-log.json';\n\nexport interface SessionLogEntry {\n action: string;\n relativeTimeSec: number;\n timestamp: string;\n outcome?: 'passed' | 'failed';\n expectedSelector?: string;\n error?: string;\n element?: {\n label: string;\n bbox: { x: number; y: number; width: number; height: number };\n viewport: { width: number; height: number };\n };\n}\n\n/**\n * Load existing session log entries from disk.\n */\nexport function loadSessionLog(sessionDir: string): SessionLogEntry[] {\n const logPath = path.join(sessionDir, SESSION_LOG_FILENAME);\n if (!fs.existsSync(logPath)) return [];\n try {\n return JSON.parse(fs.readFileSync(logPath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * For screenshot commands, resolve relative paths into the session directory\n * so agents can just say `proofshot exec screenshot step-name.png`.\n */\nfunction resolveScreenshotPath(args: string[], sessionDir: string): string[] {\n if (args[0] !== 'screenshot' || args.length < 2) return args;\n\n const screenshotPath = args[args.length - 1];\n // If it's already absolute, leave it alone\n if (path.isAbsolute(screenshotPath)) return args;\n\n // Resolve relative to session dir\n const resolved = path.join(sessionDir, screenshotPath);\n return [...args.slice(0, -1), resolved];\n}\n\n/**\n * Build the shell command string for agent-browser.\n *\n * For `eval` commands, we need to pass the JS code as a single quoted argument\n * to prevent the shell from interpreting parentheses, brackets, etc.\n * For other commands, simple joining is fine.\n */\nexport function buildShellCommand(args: string[], sessionName?: string): string {\n if (args[0] === 'eval' && args.length > 1) {\n const jsCode = args.slice(1).join(' ');\n const escaped = jsCode.replace(/'/g, \"'\\\\''\");\n return buildAgentBrowserCommand(`eval '${escaped}'`, { session: sessionName });\n }\n\n const quotedArgs = args.map((arg) => {\n if (/[(){}[\\]$`!#&|;<>*? \"'\\\\]/.test(arg)) {\n const escaped = arg.replace(/'/g, \"'\\\\''\");\n return `'${escaped}'`;\n }\n return arg;\n });\n return buildAgentBrowserCommand(quotedArgs.join(' '), { session: sessionName });\n}\n\nexport function translateProofShotExecArgs(args: string[]): {\n agentBrowserArgs: string[];\n expectedSelector?: string;\n} {\n if (args[0] === 'assert-visible' && args.length > 1) {\n return {\n agentBrowserArgs: ['is', 'visible', ...args.slice(1)],\n expectedSelector: args.slice(1).join(' '),\n };\n }\n return { agentBrowserArgs: args };\n}\n\n/**\n * Parse an element ref (@eN) from command args.\n */\nfunction parseElementRef(args: string[]): string | null {\n for (const arg of args) {\n const match = arg.match(/@e\\d+/);\n if (match) return match[0];\n }\n return null;\n}\n\n/**\n * Capture element bounding box and label before action execution.\n *\n * agent-browser's `get box` doesn't support @eN refs, but `get text` and\n * `get attr` do. Strategy:\n * 1. Try `get attr @eN id` — if found, use `get box #` (reliable for inputs)\n * 2. Otherwise try `get text @eN` — use `get box \"text=
+ const timed = Number.isFinite(entry.relativeTimeSec); + const interaction = timed + ? ` data-time="${entry.relativeTimeSec}" onclick="seekTo(${entry.relativeTimeSec})"` + : ''; + return `
${i + 1} ${icon}
@@ -193,12 +317,14 @@ export function generateViewer(data: ViewerData): string { // Build marker data for the scrub bar const markersJson = JSON.stringify( - data.entries.map((entry, i) => ({ - time: entry.relativeTimeSec, - icon: getActionIcon(entry.action), - action: entry.action, - index: i, - })), + data.entries + .map((entry, i) => ({ + time: entry.relativeTimeSec, + icon: getActionIcon(entry.action), + action: entry.action, + index: i, + })) + .filter((marker) => Number.isFinite(marker.time)), ); const scrubBarHtml = hasVideo @@ -207,8 +333,12 @@ export function generateViewer(data: ViewerData): string {
${data.entries + .filter((entry) => Number.isFinite(entry.relativeTimeSec)) .map((entry, i) => { - const pct = data.durationSec > 0 ? (entry.relativeTimeSec / data.durationSec) * 100 : 0; + const pct = + timelineDurationSec > 0 + ? (entry.relativeTimeSec / timelineDurationSec) * 100 + : 0; const icon = getActionIcon(entry.action); return `
${icon}
`; }) @@ -264,6 +394,61 @@ export function generateViewer(data: ViewerData): string { data.serverEntries && data.serverEntries.length > 0 ? data.serverEntries.length : (data.serverLog ?? '').split('\n').filter((l) => l.trim()).length; + const evidencePanels = data.evidence + ? buildEvidencePanels(data.evidence) + : []; + const evidenceTabsHtml = evidencePanels + .map( + (panel, index) => + ``, + ) + .join('\n '); + const evidenceContentsHtml = evidencePanels + .map((panel, index) => { + const sourceIds = new Set(panel.events.map((event) => event.sourceId)); + const incidents = + data.evidence?.incidents.filter((incident) => + incident.sourceIds.some((sourceId) => sourceIds.has(sourceId)), + ) || []; + const summary = panel.summary; + const status = summary + ? `${summary.hiddenLineCount} hidden · ${summary.truncationCount} truncated · ${summary.captureGapCount} capture gap(s)` + : `${incidents.length} grouped incident(s)`; + const incidentsHtml = + incidents.length > 0 + ? `
${incidents + .map( + (incident) => + `
${incident.severity.toUpperCase()} × ${incident.count} ${escapeHtml(incident.message)}
`, + ) + .join('')}
` + : ''; + return ``; + }) + .join('\n '); + const environmentTabIndex = evidencePanels.findIndex( + (panel) => panel.key === 'environment', + ); + const browserTabIndex = evidencePanels.findIndex( + (panel) => panel.key === 'browser', + ); + const canonicalTabs = evidencePanels.length > 0; + const verdictStatus = data.verdict?.status || 'INCOMPLETE'; + const verdictBadgeClass = + verdictStatus === 'PASS' + ? 'clean' + : verdictStatus === 'FAIL' + ? 'has-errors' + : 'unavailable'; + const mediaWarningHtml = data.evidence?.mediaTruncated + ? `
Media ends ${Math.max(0, data.evidence.mediaDivergenceSec || 0).toFixed(1)}s before the canonical action timeline. Timeline events remain authoritative; seeks clamp to available media.
` + : ''; return ` @@ -680,6 +865,7 @@ export function generateViewer(data: ViewerData): string { background: #161b22; z-index: 10; gap: 0; + overflow-x: auto; } .panel-tab { @@ -772,6 +958,25 @@ export function generateViewer(data: ViewerData): string { .log-line-error { background: rgba(248, 81, 73, 0.1); color: #f85149; } .log-line-error .log-ln { color: rgba(248, 81, 73, 0.5); } .log-line-error .log-time { color: rgba(248, 81, 73, 0.5); } + .log-boundary { + display: inline-block; + margin-right: 8px; + padding: 0 5px; + border: 1px solid #30363d; + border-radius: 8px; + color: #8b949e; + font-size: 10px; + } + .incident-list { padding: 8px 16px; border-bottom: 1px solid #21262d; } + .incident { padding: 5px 0; font-size: 12px; color: #d29922; } + .incident.fatal { color: #f85149; } + .media-warning { + padding: 8px 16px; + border-bottom: 1px solid #9e6a03; + background: rgba(187, 128, 9, 0.12); + color: #d29922; + font-size: 12px; + } .log-empty { padding: 32px 16px; @@ -803,6 +1008,7 @@ export function generateViewer(data: ViewerData): string { .step:hover { background: #1c2128; } + .step.untimed { cursor: default; } .step.active { background: #1f2a37; @@ -952,10 +1158,16 @@ export function generateViewer(data: ViewerData): string {

ProofShot Verification

${descriptionHtml} -

${escapeHtml(date)} · ${data.durationSec}s

+

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No video recorded

Screenshots are available in the timeline

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No video recorded

Screenshots are available in the timeline

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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

No visible evidence for this source

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

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

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