scsh 1.9.5

Scoped Skills Helper — preflight a git repo and run its scoped skills in ephemeral containers.
# Session browser daemon

`scsh` can run a small HTTP server on **localhost** (default port **7274** — `scsh` on a
numeric keypad) that collects events from every `scsh run` and renders them as an expandable
web UI. Terminal scrollback is painful for parallel container output; the session browser is
the easier way to read build logs, harness output, and per-skill results.

## Commands

```console
scsh daemon start      # persistent — runs until scsh daemon stop
scsh daemon stop       # stop the daemon
scsh daemon restart    # stop then start (persistent)
scsh daemon status     # exit 0 when the daemon is listening
```

During `scsh run`, if no persistent daemon is already running, `scsh` auto-starts an
**ephemeral** daemon. That daemon stays up while runs are active (with periodic pings) and
shuts down **five minutes** after the last client disconnects.

Every `scsh run` gets a session id of six random lowercase letters. When the run finishes, the
last line printed is the deep link URL (reachable while a daemon is listening on that port;
start `scsh daemon start` for durable post-run browsing, or rely on persisted state after
`scsh daemon restart`):

```text
http://127.0.0.1:7274/session/abcdef
```

Open it in a browser to see image builds and skills as collapsible sections, with timestamped
harness output and container names.

## What is collected

| Event | Source |
| --- | --- |
| Image build start / output / success / failure | Live board build rows |
| Skill clone / harness phases | Proc notes |
| Container start / stop | Named container around each skill |
| Every stdout/stderr line | Build tail + harness tee (`scsh-run.log` stream) |
| Terminal recording (`.cast`) | asciinema PTY recording of each harness (see below) |

## Terminal recordings (asciinema)

Every harness runs inside a real PTY recorded by asciinema (size from the `terminal:`
block in `.scsh.yml`, default **200×50**). For **claude, codex, and cursor** the recording
is the genuine end-to-end interactive TUI — the same screen a human would see. The
`scsh-tui-record` script (baked into the base image) runs the harness in a tmux session,
records the attached screen, and — when the skill's result file appears — sends the quit
keys (`/exit`, double Ctrl-C) and ends the session. opencode and grok record their headless
output streams.

There is **no screen-scraping**: every consent, trust, and login prompt is skipped ahead
of time by a flag or seeded config, so the recording is clean and a stuck harness surfaces
as a timeout (a real setup bug) rather than being auto-clicked. Per harness:

- **claude**`--permission-mode acceptEdits` (the `bypassPermissions` consent screen has
  no non-interactive escape); onboarding + workspace trust seeded into the forwarded
  `.claude.json`.
- **codex**`--dangerously-bypass-approvals-and-sandbox`; `trust_level = "trusted"`
  appended to the forwarded `config.toml`.
- **cursor**`--force`; its `~/.cursor/projects/<repo-slug>/.workspace-trusted` marker
  pre-created in-container (`--trust` is print-mode-only, and there is no config key).

Missing/invalid credentials fail fast with a clear "log in on the host" error before any
container starts — scsh never tries to drive a login screen.

Each recorded skill is shown as an **inline player** in the session page (build rows keep
their text log). The player (vendored asciinema-player, `fit:'both'`) has:

- **Playback** — play/pause, timeline scrubbing, and native keyboard: **space** pause,
  **←/→** seek, **&lt;/&gt;** speed, **[/]** jump between chapters (click the player first
  to focus it).
- **Fullscreen** — fills the viewport, fitting the terminal both horizontally and vertically.
- **Chapters** — if a cast has been annotated (below), its chapters show as **markers on the
  timeline** (YouTube-style) plus clickable chapter chips, with a one-sentence **summary**
  above the player.
- **Link at time** — copies a deep link to the standalone player at the current timestamp
  (`/cast/{session}/{proc}/play#t=<seconds>`).
- **⬇ .cast** — downloads the raw asciicast v2. Works **mid-run**: the recording is NDJSON,
  so the daemon serves the bytes written so far (truncated to the last complete line).

## In-progress recordings

A run that just started has a registered cast with no complete frames yet — the player
does not error on it. Instead:

- **Placeholder** — until the recording has at least one complete event line, the player
  box (inline embed and standalone page alike) shows a calm *"Recording in progress — no
  frames yet."* note. The moment frames exist, it upgrades to a real player in place.
- **Reload suggestion** — while a proc runs, the daemon probes its recording cheaply on
  the WebSocket tick (a stat plus a tail-parse from a cached offset — tolerant of a
  truncated trailing line) and pushes `cast_growth` messages: `{ "type": "cast_growth",
  "session", "proc", "duration", "running" }`. The player shows an unobtrusive
  *"Recording grew: +Ns (loaded Xs, available Ys)"* banner whose Reload button re-fetches
  the cast and re-creates the player at your current position. No client-side polling —
  the server pushes, and only while someone is subscribed.
- **Live mode** — a **● Live** toggle (visible only while the proc runs) follows the tail:
  each growth notification reloads the cast, seeks to where the previous load ended, and
  plays the newly appended output. When the proc finishes, a final `running: false` notice
  ends live mode cleanly — one last reload picks up the complete cast and the toggle is
  disabled.

All of it degrades gracefully without the WebSocket: pages still load, finished casts
play exactly as before, and the manual ↻ reload button keeps working.

## Cast chapters & summaries (cursor / Composer)

After a run, if the `cursor-agent` CLI and a cursor login are present on the host, `scsh`
annotates each new recording: it renders the cast to a compact timestamped transcript, asks
cursor-agent on the **Composer** model for a one-sentence summary plus 3–8 chapters, and
writes a `<cast>.chapters.json` sidecar next to the recording. The player loads it from
`GET /cast/{session}/{proc}/chapters` (returns `{}` when absent). Annotate on demand with:

```console
scsh annotate-cast tmp/casts/<recording>.cast     # override model via SCSH_ANNOTATE_MODEL
```

## Artifact formats

**Recording — asciicast v2** (`*.cast`). The [asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/)
format: a header object on the first line, then one JSON array per line (NDJSON) — an output
event is `[<seconds:number>, "o", "<text:string>"]`. Being line-delimited is what makes a
partial file valid mid-run.

```jsonc
{"version": 2, "width": 200, "height": 50, "timestamp": 1783108212, "env": {"TERM": "xterm-256color"}}
[0.12, "o", "[?25lstarting…\r\n"]
[1.34, "o", "done\r\n"]
```

**Annotation sidecar** (`<cast-stem>.chapters.json`). Written by the annotation pass, served
at `/cast/{session}/{proc}/chapters`. The player uses `summary` for the caption and each
chapter as a timeline marker / jump target:

```jsonc
{
  "summary": "One sentence describing what the session did.",  // string, required
  "chapters": [                                                 // ascending by t; first is 0; [] allowed
    { "t": 0,    "title": "Startup" },      // t: seconds in (number, may be fractional); title: short label
    { "t": 6.5,  "title": "Read the skill" },
    { "t": 9.2,  "title": "Write result JSON" }
  ]
}
```

Field names are `snake_case`; the Rust source of truth for the shape is `CastAnnotation` /
`Chapter` in `src/annotate.rs`. An absent sidecar is served as `{}` (no summary, no chapters).

## Where artifacts live

While the container runs, the cast is served straight from the run dir
(`<run_dir>/tmp/scsh-run.log.cast`, bind-mounted and growing live). When the skill ends,
`scsh run` copies each run's artifacts into the caller repo's gitignored `tmp/`, all sharing
one `<skill>-<YYYYMMDD-HHMMSS>-utc-<nonce>` stem so a run's cast and logs correlate by name:

| Artifact | Path |
| --- | --- |
| Recording | `tmp/casts/<stem>.cast` |
| Annotation sidecar | `tmp/casts/<stem>.chapters.json` |
| Harness run log | `tmp/logs/<stem>.log` |
| Verbose debug log | `tmp/logs/<stem>.debug.log` (claude/grok) · `tmp/logs/<stem>.last.log` (codex) |

The timestamp alone is not unique — every skill in one `scsh run` shares it — so the random
nonce keeps same-second runs from overwriting each other. Logs are kept for **every** run
(including failures, when they matter most). scsh never deletes these copies; clean
`tmp/casts/` and `tmp/logs/` whenever you like.

## Configuration

| Variable | Default | Meaning |
| --- | --- | --- |
| `SCSH_DAEMON_PORT` | `7274` | HTTP listen port (localhost only) |
| `SCSH_HOME` | `~/.scsh` | Dir for the persistent session store (`daemon-<port>.redb`) |

## Where state lives

The daemon persists its session store in an embedded **redb** database at
**`~/.scsh/daemon-<port>.redb`** (override the `~/.scsh` dir with `SCSH_HOME`). Each session
is one row, so a mutation writes just that session — not a rewrite of the whole store. Only
the daemon opens the DB (redb allows one process at a time); the CLI reads the daemon's mode
from a tiny cross-process marker instead.

Runtime files — the PID lock and the mode marker (`daemon-<port>.pid`, `daemon-<port>.mode`)
and the prune queue — live under the **system temp dir** `$TMPDIR/scsh-daemon/`. Session
history survives a `daemon restart`; the daemon's own uptime/client state starts fresh.

## API (for scripts)

- `GET /` — HTML session index
- `GET /session/{id}` — HTML session detail
- `GET /cast/{session}/{proc}` — asciicast v2 recording (valid partial file mid-run);
  `?dl=1` for a download attachment
- `GET /cast/{session}/{proc}/play` — HTML player page (scrub, pause, `#t=…` deep links)
- `GET /assets/asciinema-player.{js,css}` — vendored player assets
- `GET /api/v1/sessions` — JSON session id list
- `GET /api/v1/session/{id}` — JSON session detail
- `POST /api/v1/session/start`, `/register`, `/deregister`, `/ping`, `/proc/*`, `/container`
  — event ingestion (used by `scsh run`); `/proc/cast` registers a proc's recording path

## Assumptions

- **Assumed:** Port 7274 is acceptable as the default (`scsh` keypad mnemonic); override
  with `SCSH_DAEMON_PORT`.
- **Assumed:** Localhost-only binding is sufficient — no auth layer on the HTTP server.
- **Assumed:** Ephemeral idle timeout is five minutes with no connected `scsh run` clients.
- **Assumed:** Session ids are six lowercase `[a-z]` letters, matching Apple-container
  nonce style.
- **Assumed:** The daemon is best-effort — if it cannot start, `scsh run` still proceeds
  without the browser URL.

## Resetting the store

The daemon retains up to 200 sessions (each proc keeping up to 5000 output lines) in the
redb store. Because it writes only the sessions that changed — not the whole store each tick
— the store stays small and event POSTs don't stall (this replaced the earlier scheme that
re-serialized one growing JSON file and could reach tens of megabytes). To wipe session
history, stop the daemon and delete its DB:

```console
scsh daemon stop
rm ~/.scsh/daemon-${SCSH_DAEMON_PORT:-7274}.redb
scsh daemon start
```

This clears session history only; `.cast` recordings live in each repo's `tmp/casts/`
and are unaffected.

## Demo

```console
./scripts/daemon-demo.sh
```

Or manually:

```console
cargo build --release
./target/release/scsh daemon start
./target/release/scsh daemon status
# open http://127.0.0.1:7274/ after a scsh run
./target/release/scsh daemon stop
```

## Tests

```console
cargo test
```

Integration tests cover `daemon start` / `status` / `restart` / `stop` on localhost. Unit tests cover
the event model, JSON roundtrip, and session id format.

## Manual verification (`scsh run` → browser)

Automated tests do not drive a full attended `scsh run` with browser attach. From the
**`scsh` repo root** after `cargo build`, capture the binary you just built:

```console
export SCSH_BIN="$PWD/target/debug/scsh"
```

The steps below use `$SCSH_BIN` so they work after `cd` into a scratch directory.

1. `$SCSH_BIN daemon stop` (clean slate) then `$SCSH_BIN daemon start`.
2. In a **fresh scratch directory**, scaffold a demo project: `$SCSH_BIN init-demo-project`
   (or use any git repo that already has a `.scsh.yml` with a short profile). Then run
   `$SCSH_BIN run` in that directory and note the session URL printed on stderr
   (or open `http://127.0.0.1:7274/`).
3. Confirm the browser shows the session, proc rows appear as skills run, harness
   output streams into the proc panel, and proc status updates to ✓/✗ on finish.
4. When the run ends, confirm the session moves to “ended” on the index page.
5. `$SCSH_BIN daemon restart` — daemon comes back and `GET /` still serves the index page.
6. `$SCSH_BIN daemon stop` — daemon exits and the port is closed.

For ephemeral mode, skip step 1: a short `$SCSH_BIN run` alone should spawn the
daemon, attach, and shut it down after the run disconnects and the idle timeout elapses.
If idle shutdown does not run, use `$SCSH_BIN daemon stop` as cleanup.

7. Remove the scratch directory and any daemon artifacts under the system temp dir, for example:

   ```console
   rm -rf "$SCRATCH_DIR"
   rm -f  ~/.scsh/daemon-${SCSH_DAEMON_PORT:-7274}.redb
   rm -f "$TMPDIR/scsh-daemon/daemon-${SCSH_DAEMON_PORT:-7274}.pid" \
         "$TMPDIR/scsh-daemon/daemon-${SCSH_DAEMON_PORT:-7274}.mode"
   ```