# 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
| 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, **</>** 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).
- **⬇ .html** — downloads the recording as **one self-contained offline HTML player page**
(the same rendering `scsh export-cast` does, chapters sidecar folded in when present),
named `<cast stem>.html`. Hidden until the recording has at least one complete frame —
the export endpoint 404s on a frameless cast.
- **⬇ session .html** — in the session-page header: downloads **the entire session as one
self-contained offline page** (`scsh-session-<id>.html`) — a summary header plus every
recording embedded as its own per-cast export page (annotated casts keep their summary
and chapters; procs without a recording become note rows). Shown whenever any proc has a
registered cast.
## 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:
| 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
| `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 /cast/{session}/{proc}/export.html` — the recording rendered as one self-contained
offline HTML player page (identical to `scsh export-cast` output; the chapters sidecar is
folded in when present, and a malformed sidecar exports without chapters). Served as a
download attachment named `<cast stem>.html`; 404 with an actionable body until the
recording has at least one complete frame
- `GET /session/{id}/export.html` — the ENTIRE session as one self-contained offline HTML
page: a summary header plus every recording embedded as its per-cast export page (iframe
`srcdoc` composition; procs without a recording become note rows). Served as a download
attachment named `scsh-session-{id}.html`; 404 with an actionable body when the session
has no exportable recording yet
- `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
- `GET /api/v1/images` — JSON status of every scsh image (base + one per harness) on the
detected runtime: exists, up-to-date (fingerprint match), created, size (created/size are
`null` on Apple `container`, which has no inspect formatter)
- `POST /api/v1/images/build` — body `{"harnesses": [name…], "rebuild_base": bool, "force":
bool}` (all optional; no harnesses = all). Spawns a detached `scsh build-images --session
<id>`, pre-creates that session, and returns `{"ok":true,"session":id}` so the caller can
deep-link it. One build at a time — a concurrent request gets 409.
- `POST /api/v1/session/start`, `/register`, `/deregister`, `/ping`, `/proc/*`, `/container`
— event ingestion (used by `scsh run`); `/proc/cast` registers a proc's recording path
## Images panel
The session index page ends with an **images** table: one row per scsh image (`scsh-base`
plus one per harness), with its status — **up to date** (fingerprint matches this scsh
build's embedded Dockerfile), **stale** (exists but fingerprint differs, e.g. after an scsh
upgrade), or **missing**. Select rows and press **Build selected** (or **Build all**);
optional toggles force-rebuild the base image (`--no-cache`) or rebuild images that are
already up to date. The buttons call `POST /api/v1/images/build` and navigate straight to
the spawned `scsh build-images` session, where each image build streams as a proc row —
the same view a run's build rows get.
## 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"
```