Skip to content

feat(environment): capture multi-source environment logs with owned cleanup - #47

Closed
alvarohulse wants to merge 11 commits into
AmElmo:mainfrom
alvarohulse:AmElmo/multi-source-environment
Closed

alvarohulse wants to merge 11 commits into
AmElmo:mainfrom
alvarohulse:AmElmo/multi-source-environment

Conversation

@alvarohulse

@alvarohulse alvarohulse commented Aug 10, 2026

Copy link
Copy Markdown

Intent

Contribute multi-source environment capture to upstream ProofShot from current upstream main. Support safely owned tmux panes, attach-only external tmux sessions, direct process stdout and stderr, file history and live tails, readiness checks, bounded canonical evidence, exact immutable process and socket cleanup, and persisted recovery state when cleanup is incomplete. Also replace Cursor's global ProofShot rule installation with a personal Cursor skill while preserving custom legacy rule content only after the new skill is written successfully. Keep viewer, verdict, publication, and global session-inventory changes out of scope. The process-ownership primitives overlap the lifecycle foundation proposed in upstream PR 45 only where required for safe environment cleanup. Revalidate the existing open PR and regenerate its body using the deployed detailed pipeline-evidence renderer.

Notes

Scope note: exact process-ownership helpers overlap the lifecycle foundation proposed in #45 because multi-process and tmux cleanup cannot safely rely on reusable PIDs, ports, or names. This PR does not include the session registry/recovery commands, viewer/verdict work, or publication changes from the larger fork.

What Changed

  • Adds a new src/environment/ module and environment / logs config keys that capture evidence from owned tmux panes, attach-only external tmux sessions, direct processes (stdout and stderr kept separate), and file history plus live tails — with readiness checks, ANSI normalization, and bounded output written to a canonical environment.ndjson plus per-source logs/*.log. start launches and validates this environment (rejecting --run combined with config.environment), and stop reports captured sources in SUMMARY.md.
  • Records exact immutable process and socket identities in .session.json and adds process-ownership helpers in src/utils/process.ts, so start --force, stop, and clean release owned resources through releaseActiveSessionEnvironment, retain recovery state when cleanup is incomplete (loading config via loadConfigForTeardown so an invalid config cannot strand resources), and make stop exit non-zero after detecting a mid-session capture gap.
  • Replaces Cursor's global rule install (~/.cursor/rules/proofshot.mdc) with a personal skill at ~/.cursor/skills/proofshot/SKILL.md, renaming any existing legacy rule to .migrated only after the new skill writes successfully; adds unit and real-tmux integration tests (with a CI tmux install step) and refreshes the README, architecture, and docs pages.

Risk Assessment

✅ Low: The final delta is a single correctly-placed line plus a targeted regression test and a doc update, and every finding raised across four review rounds is now resolved with source-verifiable fixes and coverage.

Testing

I re-ran the targeted suites for the changed areas and hit two failures up front: the tmux history/live assertion in runtime.test.ts and the detached-process ownership test in process.test.ts. Both turned out to be test timing fragility rather than product bugs — a diagnostic run measured real capture-attach latency at ~0.4s against the test's 2s window, and the ownership test legitimately needs more than vitest's 5s default because ownership checks shell out to ps on every poll — so I made the live marker repeat with a live-segment-aware wait and gave the ownership tests the repo's standard 15s timeout; the suites then passed, including a re-run under deliberate CPU load. For product evidence I built the CLI and drove real proofshot start/exec/stop sessions: a tmux run capturing two owned panes plus a file tail with HTTP readiness, a direct-process run showing stdout and stderr kept apart, an attach-only run against a pre-existing user tmux session that survived stop untouched, a killed-pane run that recorded a capture gap and exited non-zero, a readiness failure that aborted start and released everything, and a bounded-evidence run that truncated at the configured byte budget. I also installed the Cursor skill into a sandboxed HOME, confirming the legacy rule is preserved only after the skill is written and stays active when that write fails. Cleanup was verified against the exact recorded socket, server, pane, and worker identities. Only the persisted-recovery-state-on-failed-cleanup path lacks end-to-end evidence; it remains unit-tested.

Evidence: Evidence index for all end-to-end runs
# ProofShot multi-source environment capture — end-to-end evidence

All runs used the built CLI (`npm run build` → `dist/bin/proofshot.js`) against throwaway demo apps in `app/`.

| Intent surface | Run | Evidence |
|---|---|---|
| Safely owned tmux panes + file history/live tail + HTTP readiness | `e2e-tmux/` | `transcript-tmux-start.txt`, `transcript-tmux-stop.txt`, `evidence-summary-tmux.txt`, `session-state-tmux.json` |
| Exact immutable process/socket cleanup | `e2e-tmux/` | `cleanup-tmux.txt`, `cleanup-processes.txt` |
| Direct process stdout vs stderr + TCP readiness | `e2e-processes/` | `evidence-summary-processes.txt`, `stderr-events-processes.txt`, `transcript-processes-*.txt` |
| Attach-only external tmux session is never terminated | `e2e-attach/` | `transcript-attach.txt`, `session-state-attach.json` |
| Capture-gap detection → non-zero `stop` exit | `e2e-tmux/…_capture-gap-when-a-pane-dies/` | `transcript-gap.txt`, `transcript-gap-stop.txt`, that session's `SUMMARY.md` |
| Readiness gate fails `start` and releases owned processes | `e2e-readiness/` | `transcript-readiness-failure.txt` |
| Bounded canonical evidence (`logs.maxBytesPerSource`) | `e2e-bounded/` | `evidence-summary-bounded.txt` |
| Cursor global rule → personal Cursor skill (legacy preserved only on success) | `home-cursor/`, `home-cursor-fail/` | `transcript-install-cursor.txt`, `transcript-install-cursor-failure.txt` |

Reviewer-visible session artifacts live under each `e2e-*/proofshot-artifacts/<session>/`:
`environment.ndjson` (canonical evidence), `logs/*.log`, `SUMMARY.md`, `viewer.html`, `session.webm`, `step-home.png`.

Helper scripts written for this validation: `summarize-evidence.mjs`, `check-cleanup.sh`, `attach-timing.mjs`
(`attach-timing.mjs` measured tmux pane capture-attach latency at ~0.4s, which is what identified the racy
2-second window in the pre-existing `runtime.test.ts` history/live assertion).
Evidence: Multi-source capture: 3 sources, history vs live, groups, ANSI stripped

sourceId | title | group | stream | segment => events frontend-web | Web server | frontend | pty | history => 2 backend-api | API worker | backend | pty | history => 2 worker-file | worker.log tail | backend | file | history => 2 frontend-web | Web server | frontend | pty | live => 9 backend-api | API worker | backend | pty | live => 65 worker-file | worker.log tail | backend | file | live => 64 total events: 144 events still containing an ANSI escape: 0 events flagged captureGap: 0 --- frontend-web history --- [history] [frontend/frontend-web/pty] t=null [web] listening on http://127.0.0.1:4173&#10;[history] [frontend/frontend-web/pty] t=null [tmux history/live capture boundary] --- frontend-web live --- [live] [frontend/frontend-web/pty] t=0.58s [web] GET /health [live] [frontend/frontend-web/pty] t=3.73s [web] GET / --- worker-file history --- [history] [backend/worker-file/file] t=null [worker] job -1 completed (before proofshot start) --- worker-file live --- [live] [backend/worker-file/file] t=0.98s [worker] job 1 completed

sourceId | title | group | stream | segment  =>  events
frontend-web | Web server | frontend | pty | history  =>  2
backend-api | API worker | backend | pty | history  =>  2
worker-file | worker.log tail | backend | file | history  =>  2
frontend-web | Web server | frontend | pty | live  =>  9
backend-api | API worker | backend | pty | live  =>  65
worker-file | worker.log tail | backend | file | live  =>  64

total events: 144
events still containing an ANSI escape: 0
events flagged captureGap: 0
events flagged truncated: 0

--- frontend-web history ---
[history] [frontend/frontend-web/pty] t=null [web] listening on http://127.0.0.1:4173
[history] [frontend/frontend-web/pty] t=null [tmux history/live capture boundary]

--- frontend-web live ---
[live] [frontend/frontend-web/pty] t=0.58s [web] GET /health
[live] [frontend/frontend-web/pty] t=3.73s [web] GET /
[live] [frontend/frontend-web/pty] t=3.77s [web] GET /favicon.ico
[live] [frontend/frontend-web/pty] t=4.93s [web] GET /

--- backend-api history ---
[history] [backend/backend-api/pty] t=null [api] worker started
[history] [backend/backend-api/pty] t=null [tmux history/live capture boundary]

--- backend-api live ---
[live] [backend/backend-api/pty] t=0.96s [api] processed job 1
[live] [backend/backend-api/pty] t=1.66s [api] processed job 2
[live] [backend/backend-api/pty] t=2.36s [api] processed job 3
[live] [backend/backend-api/pty] t=2.36s [api] ReferenceError: demo failure while processing job 3

--- worker-file history ---
[history] [backend/worker-file/file] t=null [worker] job -2 completed (before proofshot start)
[history] [backend/worker-file/file] t=null [worker] job -1 completed (before proofshot start)

--- worker-file live ---
[live] [backend/worker-file/file] t=0.98s [worker] job 1 completed
[live] [backend/worker-file/file] t=1.69s [worker] job 2 completed
[live] [backend/worker-file/file] t=2.40s [worker] job 3 completed
[live] [backend/worker-file/file] t=3.12s [worker] job 4 completed
Evidence: Exact cleanup after stop (recorded socket / server / pane identities)

recorded tmux socket : /tmp/proofshot-502/tmux/proofshot-2026-08-11_02-55-39.sock socket file exists after stop: false recorded tmux server pid : 33366 alive: false recorded pane frontend-web pid 33367 alive: false recorded pane backend-api pid 33438 alive: false --- leftover proofshot tmux sockets --- (empty) --- leftover capture workers --- none

recorded tmux socket        : /tmp/proofshot-502/tmux/proofshot-2026-08-11_02-55-39.sock
socket file exists after stop: false
recorded tmux server pid     : 33366 alive: false
recorded pane frontend-web pid 33367 alive: false
recorded pane backend-api pid 33438 alive: false
Evidence: Attach-only external tmux session survives stop

--- BEFORE proofshot start --- server pid=38170 session=devbox pane=%0 pane_pid=38171 pane_pipe=0 ✓ Environment and log capture started --- ProofShot ownership claim over the external session --- ownsServer: false | ownsSession: false | sessionName: devbox recorded stopCommand: (none — attach-only) --- DURING session --- pane=%0 pane_pipe=1 --- AFTER proofshot stop: external server and pane survive, pipe detached --- server pid=38170 session=devbox pane=%0 pane_pid=38171 pane_pipe=0 socket still present: yes

--- BEFORE proofshot start: external tmux server pid / panes ---
server pid=38170
session=devbox pane=%0 pane_pid=38171 pane_pipe=0
✓ Environment and log capture started
No --run provided, assuming server is already running
Opening browser...
✓ Browser ready
✓ Recording started


--- ProofShot ownership claim over the external session ---
ownsServer: false | ownsSession: false | sessionName: devbox
recorded stopCommand: (none — attach-only)
--- DURING session: pipe attached to the external pane ---
pane=%0 pane_pipe=1
Collecting errors...
Stopping recording...
Closing browser...
Stopping environment capture...
Trimmed video to 8s (removed dead time)

--- AFTER proofshot stop: external tmux server and pane survive, pipe detached ---
server pid=38170
session=devbox pane=%0 pane_pid=38171 pane_pipe=0
socket still present: yes
Evidence: Capture gap: stop reports the gap and exits non-zero

❌ ProofShot verification incomplete — environment capture stopped early 📚 Environment: 3 source(s) → .../logs (incomplete) Capture gaps: tmux pane %1 for "backend-api" exited or lost its capture pipe before "proofshot stop" — logs/backend-api.log is incomplete. Recorded with captureGap in .../environment.ndjson STOP EXIT=1 environment.ndjson row: {"version":1,"origin":"environment","group":"backend","sourceId":"backend-api","stream":"pty","segment":"live","relativeTimeSec":10.01,"text":"[tmux pane stopped feeding ProofShot before the session ended; later output was not recorded]","captureGap":true}

Collecting errors...
Stopping recording...
Closing browser...
Stopping environment capture...

❌ ProofShot verification incomplete — environment capture stopped early

📹 Video:         /private/var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/e2e-tmux/proofshot-artifacts/2026-08-11_02-59-35_capture-gap-when-a-pane-dies/session.webm (8s)
📸 Screenshots:   0 captured
📚 Environment:   3 source(s) → /private/var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/e2e-tmux/proofshot-artifacts/2026-08-11_02-59-35_capture-gap-when-a-pane-dies/logs (incomplete)
📝 Summary:       /private/var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/e2e-tmux/proofshot-artifacts/2026-08-11_02-59-35_capture-gap-when-a-pane-dies/SUMMARY.md
Tip: Use "proofshot exec" instead of "agent-browser" to get an interactive timeline viewer.

Console errors:   0
Server errors:    0
Duration:         8 seconds

Proof artifacts saved to /private/var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/e2e-tmux/proofshot-artifacts/2026-08-11_02-59-35_capture-gap-when-a-pane-dies

Capture gaps:
  tmux pane %1 for "backend-api" exited or lost its capture pipe before "proofshot stop" — logs/backend-api.log is incomplete.
  Recorded with captureGap in /private/var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/e2e-tmux/proofshot-artifacts/2026-08-11_02-59-35_capture-gap-when-a-pane-dies/environment.ndjson
Evidence: Direct process stdout/stderr kept separate

--- stderr-stream events (kept separate from stdout) --- [live] [backend/backend-api/stderr] t=2.28s [api] ReferenceError: demo failure while processing job 3 [live] [frontend/frontend-web/stderr] t=16.23s [process stopped by ProofShot] [live] [backend/backend-api/stderr] t=16.27s [process stopped by ProofShot]

--- stderr-stream events (kept separate from stdout) ---
[live] [backend/backend-api/stderr] t=2.28s [api] ReferenceError: demo failure while processing job 3
[live] [frontend/frontend-web/stderr] t=16.23s [process stopped by ProofShot]
[live] [backend/backend-api/stderr] t=16.27s [process stopped by ProofShot]
Evidence: Cursor skill install; legacy rule preserved only after success

BEFORE: ~/.cursor/rules/proofshot.mdc ✓ Cursor: Installed (Legacy rule preserved at ~/.cursor/rules/proofshot.mdc.migrated) → ~/.cursor/skills/proofshot/SKILL.md AFTER: ~/.cursor/rules/proofshot.mdc.migrated, ~/.cursor/skills/proofshot/SKILL.md (installed file identical to bundled skills/cursor/SKILL.md; custom legacy content intact) FAILURE CASE (skills path blocked): ✗ Cursor: Failed (ENOTDIR ... mkdir .../.cursor/skills/proofshot) AFTER: ~/.cursor/rules/proofshot.mdc <- legacy rule still active, not renamed


Detected AI coding tools:

  ● Cursor

✓ Cursor: Installed (Legacy rule preserved at /var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/home-cursor/.cursor/rules/proofshot.mdc.migrated)
  → /var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/home-cursor/.cursor/skills/proofshot/SKILL.md

Done! ProofShot skills installed for 1 tool(s).

You're all set! In any project, tell your AI agent:

  "Verify the changes visually with proofshot"

--- installed skill: ~/.cursor/skills/proofshot/SKILL.md ---
---
name: proofshot
description: Visually verifies UI changes with browser recordings, screenshots, console output, and named environment logs. Use after building or modifying user-facing features.
---

# ProofShot visual verification

Use ProofShot after changing UI behavior:

1. Start a session:
   `proofshot start --run "your-dev-command" --port PORT --description "what you are verifying"`
   Use `proofshot.config.json` environment and log sources instead of `--run` when verification needs multiple processes, tmux panes, or file tails.
2. Drive the browser with `proofshot exec`:
   - `proofshot exec snapshot -i`
   - `proofshot exec click @e3`
   - `proofshot exec fill @e2 "text"`
   - `proofshot exec screenshot step.png`
3. Stop and bundle evidence:
   `proofshot stop`

Take screenshots before and after important actions. Read the browser snapshot and captured logs to verify the expected behavior, then fix and repeat if evidence contains errors.

--- preserved legacy rule: ~/.cursor/rules/proofshot.mdc.migrated (custom content intact) ---
---
description: ProofShot (legacy global rule)
alwaysApply: true
---

# My hand-edited ProofShot rule

Always run proofshot before claiming a UI change works.
Custom team note: attach the viewer link in the PR.
Evidence: Bounded canonical evidence with logs.maxBytesPerSource = 4096

4006 bytes environment.ndjson 1260 bytes logs/backend-noisy.log 142 bytes logs/frontend-web.log truncation events: [backend-noisy/stdout] [ProofShot capture truncated at configured byte limit]

--- logs.maxBytesPerSource = 4096 (per source, canonical evidence + plain log combined) ---
4006 bytes  proofshot-artifacts/2026-08-11_03-01-18_bounded-evidence/environment.ndjson
1260 bytes  proofshot-artifacts/2026-08-11_03-01-18_bounded-evidence/logs/backend-noisy.log
142 bytes  proofshot-artifacts/2026-08-11_03-01-18_bounded-evidence/logs/frontend-web.log

truncation events:
  [backend-noisy/stdout] [ProofShot capture truncated at configured byte limit]
total events: 12
Evidence: Readiness gate fails start and releases owned processes

✗ Failed to start environment capture: Environment readiness failed for http://127.0.0.1:4177/health: fetch failed start exit=1 --- leftover worker processes --- none --- session state file --- (.session.json removed)

✗ Failed to start environment capture: Environment readiness failed for http://127.0.0.1:4177/health: fetch failed
start exit=1
.
..
2026-08-11_03-01-03_readiness-gate
- Evidence: Browser screenshot of the app served from the owned tmux pane (local file: /var/folders/xh/qnn4w61965q9ml19t2yy0fk00000gp/T/no-mistakes-evidence/01KZQ7ED5Q9MYXN4D3SYDPBA0X/e2e-tmux/proofshot-artifacts/2026-08-11_02-55-39_multi-source-environment-capture/step-home.png)
Evidence: Generated SUMMARY.md with Environment Logs and Capture Gaps sections
# ProofShot Verification Report

**Date:** 2026-08-11 02:59:45
**Project:** e2e-tmux
**Dev Server:** external on localhost:4173

## What Was Verified

capture gap when a pane dies

## Video Recording

Full session recording: [session.webm](./session.webm) (8s)

## Console Errors

No console errors detected.

## Server Errors

No server errors detected.

## Environment Logs

- **Web server** (frontend/tmux-pane): [frontend-web.log](./logs/frontend-web.log)
- **API worker** (backend/tmux-pane): [backend-api.log](./logs/backend-api.log)
- **worker.log tail** (backend/file): [worker-file.log](./logs/worker-file.log)

## Capture Gaps

Environment capture stopped early, so the logs above are incomplete:

- tmux pane %1 for "backend-api" exited or lost its capture pipe before "proofshot stop" — logs/backend-api.log is incomplete.

Each gap is recorded in `environment.ndjson` with `captureGap: true`.

## Environment
- Browser: Chromium (headless)
- Viewport: 1280x720
- Duration: 8 seconds
- Outcome: ⚠️ 3 issues (2 warnings, 1 info) across 1 run (31m25s)

Pipeline

Updates from git push no-mistakes

Step Agent (via) Nested agents
Review r1 claude (harness_cli) none
Review r2 claude (harness_cli) none
Review r2 claude (harness_cli) none
Review r3 claude (harness_cli) none
Review r3 claude (harness_cli) none
Review r4 claude (harness_cli) none
Review r4 claude (harness_cli) none
Build r1 claude (harness_cli) none
Test r1 claude (harness_cli) none
Document r1 claude (harness_cli) none
✅ **Intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped
  • ⚠️ README.md - merge conflict rebasing onto origin/main
  • ⚠️ content/docs/reference/configuration.mdx - merge conflict rebasing onto origin/main
  • ⚠️ src/commands/start.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/commands/stop.test.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/commands/stop.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/environment/runtime.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/environment/tmux.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/environment/types.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/index.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/session/state.ts - merge conflict rebasing onto origin/main
  • ⚠️ src/utils/config.ts - merge conflict rebasing onto origin/main
🔧 **Review** - 9 issues found → auto-fixed (3) ✅
  • ⚠️ src/utils/process.ts:138 - captureBootToken() runs sysctl -n kern.boottime without pinning TZ, and that output ends in a locale/timezone-formatted date (verified locally: the same boot prints Tue Aug 4 10:41:45 2026, 16:41:45 under TZ=UTC, and Wed Aug 5 01:41:45 under TZ=Asia/Tokyo). The bootId is embedded in every recorded ProcessIdentity, and processIdentitiesMatch compares it with ===. Sibling capture code in the same file already guards against exactly this by passing env: { ...process.env, TZ: &#39;UTC&#39; } to ps -o lstart=, so the omission is inconsistent within the module. Fix: pin TZ='UTC' for the sysctl call, or parse only the sec = &lt;n&gt; field.
  • ⚠️ src/environment/evidence.ts:10 - normalizeLogText strips CONTROL_PATTERN (which includes BEL, ^G, inside -�) before ANSI_PATTERN runs, but ANSI_PATTERN's OSC branch requires BEL as its terminator. Removing BEL first breaks OSC matching, and the fallback CSI branch then consumes only a short prefix, leaving residue spliced onto the next real log line. Verified against the actual patterns in this file: ESC]0;my-title BEL ready in 431 ms normalizes to "y-titleready in 431 ms" today, vs "ready in 431 ms" when ANSI is stripped first. Shells, npm scripts, and vite set terminal titles constantly inside tmux panes, so most pane evidence and logs/*.log are affected. The same bug is duplicated in the inlined worker normalize() at src/environment/workers.ts:42; both copies need the order swapped (ANSI first, then control chars).
  • ⚠️ src/environment/tmux.ts:189 - capture-pane -p -S - requests the pane's entire scrollback through tmuxExec (src/environment/tmux-command.ts:22), which calls execFileSync without a maxBuffer override, so Node's 1 MB default applies. The design explicitly budgets far more than that for history: logs.maxBytesPerSource defaults to 5 MB and historyBudget is half of it (2.5 MB), so the intended history size exceeds the transport limit. Attach-only external panes are the case that actually has deep scrollback, and users routinely raise tmux history-limit to 10k-100k lines. Once the capture exceeds 1 MB, execFileSync throws ENOBUFS, startTmuxEnvironment's catch runs stopTmuxEnvironment, and proofshot start fails outright on an otherwise valid config. Fix: pass maxBuffer (at least the history budget) to tmuxExec, and/or bound the request with -S -&lt;lines&gt;.
  • ⚠️ src/environment/tmux-launch.ts:158 - startExternalTmux computes ownsCreatedSocket purely from "hinted socket existed before this start", never consulting attachOnly, which is only used to relax the stopCommand validation above it. With config { launch: { kind: &#39;external-command&#39;, ... }, connection: { ownership: &#39;attach&#39;, socket: &#39;/tmp/dev.sock&#39; } }, if /tmp/dev.sock does not exist when start runs and the launcher brings it up, ownsServer/ownsSession become true and stopTmuxEnvironment (src/environment/tmux-cleanup.ts:79) runs tmux kill-server against the user's own dev tmux server. That contradicts the documented contract added in this change: content/docs/reference/configuration.mdx says "Attach-only launchers are never killed." The two documented rules in that paragraph are in tension (own-what-you-created vs never-kill-attach), so the author should pick which one wins rather than have me choose.
  • ⚠️ src/commands/stop.ts:124 - stop releases the environment but never verifies that each capture worker was alive for the whole session, so a worker that died mid-session produces evidence that looks complete: no captureGap marker, no warning, and the summary still reports "N source(s)" with a link to a silently truncated log. The healthFailures field exists on TmuxEnvironmentState and ProcessEnvironmentState (src/environment/types.ts:127,137) but is never assigned or read anywhere, which suggests this check was intended and not wired up. The path is proven by the change's own test (src/environment/runtime.test.ts: 'records truncation and cleans descendants after a coordinator dies'), which SIGKILLs a capture coordinator and then asserts stopOwnedEnvironment resolves cleanly. This is the one gap that contradicts the invariant docs/architecture.md now states: "A silently dropped log source produces evidence that looks complete but is not ... so both are errors." Deciding whether that becomes a warning, a captureGap event, or a hard stop failure is a product call.
  • ⚠️ src/environment/runtime.test.ts:39 - Three of the new integration tests spawn real tmux servers and reach assertTmuxAvailable() (src/environment/tmux.ts:68), which throws 'tmux is required for environment.kind "tmux"' when the binary is absent. There is no skip guard and tmux is not declared as a dev prerequisite in package.json, README, or CLAUDE.md. .github/workflows/publish.yml gates the release job on npm test, so any runner image or contributor machine without tmux fails the suite rather than skipping the tmux-specific coverage. Resolution is a choice between installing tmux in the workflow and guarding the tests (describe.skipIf), so it needs the author's call.
  • ℹ️ src/environment/tmux-command.ts:41 - runCommand spawns the external launcher detached, then throws when captureProcessIdentity returns null, without killing the child it just created. The pid is known and was spawned microseconds earlier, so there is no recycle risk in killing it; startDetachedWorker (src/environment/workers.ts:377) handles the identical case correctly by process.kill(-worker.pid, &#39;SIGKILL&#39;) before throwing. As written, a transient ps failure leaves an unmanaged launcher plus whatever tmux resources it creates, with nothing recorded in .session.json - the exact orphan this module is built to prevent.
  • ℹ️ src/environment/tmux-launch.ts:139 - The stopCommand guard reads !attachOnly &amp;&amp; ((!hintedSocket &amp;&amp; !stopCommand) || (socketExistedBefore &amp;&amp; !stopCommand)), but socketExistedBefore is initialized to true whenever hintedSocket is undefined (line 136), so the first disjunct can never be the deciding branch. It collapses to !attachOnly &amp;&amp; socketExistedBefore &amp;&amp; !config.launch.stopCommand with identical behavior.
  • ℹ️ src/environment/workers.ts:392 - createWorkerConfig is an exported identity function (return params) whose only caller (src/environment/tmux.ts:162) uses it to type an object literal. Exporting the WorkerConfig type and annotating the literal directly removes the indirection and the misleading 'create' name.

🔧 Fix: Fix evidence normalization, tmux ownership, and capture-health reporting
2 issues (1 warning, 1 info) still open:

  • ⚠️ src/environment/runtime.ts:129 - The new capture-health contract detects a mid-session gap as "pid file survives a dead process" (runtime.ts:127-129, documented in CLAUDE.md and docs/architecture.md), but the tmux pipe worker removes its pid file on a clean stdin EOF (src/environment/workers.ts:119-122), and a pane whose command exits mid-session produces exactly that EOF. Concrete path: environment.kind 'tmux', launch.kind 'panes', pane 'api' running npm run api; the command crashes at t+30s; tmux destroys the pane (default remain-on-exit off); pipe-pane's fd closes; the worker runs removePidFile() then exit(0); recordCaptureHealthFailures hits if (!fs.existsSync(capture.pidFile)) continue and returns []; stop exits 0 and SUMMARY.md links logs/api.log as evidence with no captureGap row. The direct-process path does not have this hole - PROCESS_RUNNER writes '[process exited with code N]' into evidence before exiting - so the tmux path is the only one where a dead source is silent. This is the same failure class the round-1 fix was authorized to close ("rather than presenting incomplete evidence as successful"), still reachable. Earliest shared boundary that already holds what is needed: recordCaptureHealthFailures runs before stopTmuxEnvironment detaches the pipes, and TmuxPaneState already carries paneId and captureAttached, so verifying each recorded pane still exists with #{pane_pipe} still 1 closes it there rather than adding a second symptom patch. The worker itself cannot distinguish this EOF from the stop-time detach, so pushing the check into the worker would not work.
  • ℹ️ src/environment/tmux.ts:74 - releaseUnverifiedTmux still reads config.launch.stopCommand unconditionally (tmux.ts:72-76) and runs it on the failed-start path, without the attach guard that createTmuxState gained in this commit (config.connection?.ownership !== &#39;attach&#39;). validateConfig now rejects stopCommand alongside ownership 'attach', so the CLI path is safe, but startOwnedEnvironment is exported as public API from src/index.ts:14 and accepts an EnvironmentConfig directly, bypassing validateConfig entirely. A programmatic caller passing { ownership: &#39;attach&#39;, stopCommand: ... } would have its user-owned environment shut down on a start failure, contradicting the newly documented rule that attach-only environments are never terminated. Adding the same one-line ownership check here keeps the two ownership-sensitive teardown paths consistent.

🔧 Fix: Detect dead tmux panes; guard attach-only unverified teardown
1 warning still open:

  • ⚠️ src/environment/tmux-cleanup.ts:51 - The new pane-health signal is only valid before teardown detaches the pipes, but nothing marks panes as detached, so a second stop run reports every pane as a capture gap that never happened. captureAttached is set true at attach (src/environment/tmux.ts:258,263) and never cleared anywhere; the detach loop at tmux-cleanup.ts:51-59 runs at the top of the teardown block, before every step that can push an error. Concrete path: (1) stop run 1 - recordCaptureHealthFailures returns clean, stopOwnedEnvironment detaches all pipes, then a later step fails (e.g. 'Log helper for api did not stop.' at tmux-cleanup.ts:75, or kill-session, or the owned-server termination); the AggregateError reaches src/commands/stop.ts:141, which calls saveSession with captureAttached still true and exits 1 telling the user to run proofshot stop again. (2) Run 2 - findUnpipedPanes (src/environment/tmux.ts:49) sees the server identity still matching (typically the same condition that made run 1 fail) and the socket matching, so it queries each pane and reads #{pane_pipe} = '0' for all of them, because run 1 detached them. Every pane is reported as 'exited or lost its capture pipe', fabricated captureGap rows are appended to environment.ndjson, healthFailures is set, and stop exits non-zero a second time. This inverts the invariant the feature exists to protect - canonical evidence now asserts a gap that did not occur - and it lands on the documented recovery flow (content/docs/reference/cli.mdx: 'Resolve the reported resource and run proofshot stop again'). start --force and clean reach the same state through releaseActiveSessionEnvironment. Earliest shared boundary: the detach loop already iterates exactly these panes and mutates the same state object stop.ts persists on failure, so setting pane.captureAttached = false after each successful pipe-pane detach closes it there and mirrors how the flag is set on attach - no second check elsewhere is needed.

🔧 Fix: Clear pane capture flag on detach to prevent false gaps
✅ Re-checked - no issues remain.

✅ **Build** - passed

✅ No issues found.

⚠️ **Test** - 3 issues (2 warnings, 1 info)
  • ℹ️ src/session/teardown.ts - The "persisted recovery state when cleanup is incomplete" branch of the intent is covered only by unit tests (start.test.ts retains recovery state / refuses forced override, stop.test.ts retains session state on cleanup failure). I could not force a real teardown failure end-to-end without an unkillable process, so there is no CLI-level artifact for that path. Everything else in the intent has end-to-end evidence.
  • ⚠️ src/environment/runtime.test.ts - existing test file modified by agent: src/environment/runtime.test.ts
  • ⚠️ src/utils/process.test.ts - existing test file modified by agent: src/utils/process.test.ts
  • npx vitest run src/environment src/commands/start.test.ts src/commands/stop.test.ts src/commands/install.test.ts src/utils/config.test.ts src/utils/process.test.ts src/utils/errors.test.ts (52 tests, all pass after the test fixes)
  • npx vitest run src/environment/runtime.test.ts src/utils/process.test.ts re-run under 6 concurrent CPU-load workers to confirm the de-flake holds
  • node attach-timing.mjs — measured tmux pane capture-attach latency (~0.4s) to prove the 2s window in the old assertion was a test race, not a product defect
  • npm run build then end-to-end proofshot start / exec open / exec screenshot / stop in e2e-tmux/ with 2 owned tmux panes + file tail + HTTP readiness
  • End-to-end proofshot start/stop in e2e-processes/ with direct processes (stdout vs stderr separation) + TCP readiness
  • End-to-end attach-only run in e2e-attach/ against a pre-existing user tmux session; compared server pid, pane pid, #{pane_pipe} and socket before/during/after stop
  • tmux kill-pane mid-session then proofshot stop — verified capture-gap event, SUMMARY.md gap section, and exit code 1
  • proofshot start with an unreachable readiness URL — verified exit 1, no leftover worker processes, session state cleared
  • proofshot start/stop with logs.maxBytesPerSource: 4096 against a noisy process — verified bounded artifact sizes and the truncation event
  • HOME=&lt;sandbox&gt; proofshot install --only cursor over a legacy ~/.cursor/rules/proofshot.mdc, and again with the skills path blocked, to check legacy preservation ordering
  • zsh check-cleanup.sh + ps/socket checks confirming recorded tmux server, pane, and capture-worker identities are all released after stop
⚠️ **Document** - 2 infos
  • ℹ️ content/docs/guides/install-skills.mdx:52 - The agent skill install-location table is duplicated in README.md and content/docs/guides/install-skills.mdx; this change had to synchronize the Cursor row into both copies, and the docs-site copy is already drifting — it still omits the OpenCode row that README carries. Collapsing one copy to a pointer at the other (README owning the user-facing summary, the guide owning the deep detail) would stop the next agent-support change from having to edit two places. Left as-is: deduplicating cross-surface tables is a documentation-architecture change beyond this pass.
  • ℹ️ issues/02-interactive-timeline-viewer.md:231 - issues/02-interactive-timeline-viewer.md lists skills/cursor/proofshot.mdc in its files-to-change table; this change deleted that file. The issues/ directory is a historical record of completed planning work — like proofshot-spec.md, which this change explicitly banner-marked as historical. Adding the same marker to issues/ (or removing them) is a judgment call about whether that directory is durable documentation, so it was left untouched rather than expanded into a broader historical-doc sweep.
⚠️ **Lint** - 1 warning
  • ⚠️ src/commands/pr.ts:276 - npx tsc --noEmit fails with TS2322 at src/commands/pr.ts:276normalizeUploadProvider returns provider || &#39;repo-contents&#39; typed as string where GitHubUploadProvider is required. Pre-existing: src/commands/pr.ts is byte-identical between the base and target commits, so this is not caused by this change. CI does not gate on tsc --noEmit (it runs tsup + vitest), so this is latent rather than build-breaking. Not fixed here because the fix is a functional narrowing in untouched code and lies outside this change's scope.
✅ **Push** - passed

✅ No issues found.

alvarohulse and others added 7 commits August 10, 2026 14:01
Own tmux panes, direct processes, and file tails with exact cleanup identities so verification sessions retain bounded, recoverable environment evidence.

Co-authored-by: cursoragent <cursoragent@cursor.com>
Use Cursor's personal skill directory and preserve legacy rule customizations only after the replacement skill is written successfully.

Co-authored-by: cursoragent <cursoragent@cursor.com>
@alvarohulse
alvarohulse marked this pull request as draft August 10, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant