# The Booth

A dead-simple standing web server for shuttling **ephemeral files** between the
operator and CC sessions — A/B renders, smoke-test screenshots, audio/video
samples, or anything you want to hand off. It works both directions:

- **Session → operator:** a session drops a folder of files on disk; the Booth
  renders it as a browsable "booth".
- **Operator/anyone → pickup:** upload files through the browser (or `curl -F`)
  and get a **human-readable pickup id** like `4-wombat` or `star-84`.

Either way it **wipes 24h after the last activity**. No database — the
filesystem *is* the state.

- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while
  you're touching it, self-destructs 24h after you stop)

## How a session posts

A booth is **just a folder** under the data dir. Three ways, cheapest first:

```bash
# 1. On nh3-dev — the helper (services/booth/scripts/booth):
booth add my-run  out/a.png out/b.png       # creates booth + copies, prints URL
booth new  my-run                            # empty booth, then cp/mv into ~/booth-data/my-run/
booth url  my-run                            # just print the URL
booth ls                                     # list booths
booth rm   my-run                            # wipe now (TTL would anyway)

# 2. On nh3-dev — raw, no helper:
mkdir -p ~/booth-data/my-run && cp out/*.png ~/booth-data/my-run/
#   -> http://10.100.10.50:8090/b/my-run/

# 3. From another host — rsync into the data dir:
rsync -a ./out/  nh3-dev:booth-data/my-run/
```

Then hand the operator `http://10.100.10.50:8090/b/my-run/`.

## Kept boards — the one exception to the 24h rule

A booth containing a **`.forever`** dotfile is **never swept**, and renders in
its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no
countdown, no one-click wipe). Everything else is unchanged: the default is
still ephemeral, so nobody inherits a cleanup chore they didn't ask for.

```bash
booth keep   my-board      # drop the sentinel — exempt from the sweep, forever
booth unkeep my-board      # release the pin — the board rejoins the sweep
booth rm     my-board      # delete it NOW (works on kept boards; says so when it was kept)

booth links                # list the standing link board: row number, entry id, the row
booth unlink 3             # remove row 3
booth unlink 8b40e0a5      # or remove by entry id (what the web UI's × posts)
```

It is just a file, so the manual forms work identically and are the honest
mental model:

```bash
touch ~/booth-data/my-board/.forever    # keep
rm    ~/booth-data/my-board/.forever    # unkeep
rm -rf ~/booth-data/my-board            # delete outright, whenever you like
```

**Why this exists:** agent sessions hand the operator URLs — a booth of renders,
a PR, a dashboard — and they drown in terminal scrollback. Kept boards are where
those go instead.

### The standing link board

```bash
booth link <url> [description]
```

Appends one line to the **`links`** board (`$BOOTH_LINKS_BOARD`, default
`links`), creating it and marking it kept on first use. Each entry carries
provenance — who posted it and when — because a bare URL is unreadable three
days later. `links.md` renders as a readable page in the booth.

The append is a single `printf` of a single line to an `O_APPEND` fd, which is
atomic under `PIPE_BUF` on POSIX. That matters here specifically: many agents
post to one board, and interleaved half-lines would be the obvious failure.

Deliberately **not** a database. The board is a markdown file — editable with
any editor, greppable, and trivially prunable by hand, which is the whole point
of the Booth's filesystem-is-the-state model.

## Upload for pickup

The reverse direction — put files in through the web, pick them up by id:

- **Browser:** the index page has an *Upload files for pickup* panel
  (drag-drop or click). Submit → you land on a booth with a **human-readable
  id** (`4-wombat`, `star-84`) whose files each have a ⬇ download link.
- **curl (a remote session with no ssh to nh3-dev can use this too):**
  ```bash
  curl -sS -i -F 'files=@out/a.png' -F 'files=@out/b.png' \
    http://10.100.10.50:8090/upload | grep -i location
  #   Location: /b/star-84/   <- the pickup id
  ```
- **Pick up** at `http://10.100.10.50:8090/b/<id>/` (download links), or on
  nh3-dev straight off disk at `~/booth-data/<id>/`.

Uploads are stamped as pickup booths (a `⬆ pickup` badge in the UI) and expire
on the same 24h TTL. Limits: `BOOTH_MAX_FILES` files (default 50) and
`BOOTH_MAX_UPLOAD_MB` total per submission (default 1024); filenames are reduced
to a safe basename (no path traversal).

## What a booth renders

- **Has its own `index.html`?** → served **verbatim** (its relative assets —
  `chart.png`, `report.css` — resolve out of the same folder). Build whatever
  page you want.
- **No `index.html`?** → **auto-gallery** of the folder's media:
  - images (`png jpg jpeg gif webp avif svg bmp`) → `<img>` (click → full-screen
    viewer with **Fit** / **1:1** — the toggle only appears when the image is
    larger than the viewport — plus download and ✕/Esc back to the gallery)
  - video (`webm mp4 ogv m4v mov`) → `<video controls>`
  - audio (`mp3 wav ogg flac m4a opus aac`) → `<audio controls>`
  - anything else → a download link
- **Captions:** a `<file>.txt` or same-stem `<stem>.txt` sidecar is folded in as
  that item's caption — the natural way to label an A/B pair:
  ```
  a.png              b.png
  a.txt  "baseline"  b.png.txt  "cudaMallocAsync (winner)"
  ```

## Routes

| Route | Purpose |
|---|---|
| `GET /` | Index — one card per booth (newest first), with expiry countdown |
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
| `GET /b/<name>/<file>` | Serve a file out of the booth |
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
| `POST /b/<name>/unlink` | Remove ONE row from a link board (form field `entry` = content id) |
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |


### The standing link board

A booth containing `links.md` is the fleet's **standing link board**: every
agent session appends operator-facing URLs to it so they outlive the terminal
scrollback that would bury them. It is the one booth where the useful
granularity is the **row**, not the folder — a dead link has to be removable
without taking the other thirty with it.

It renders as real UI, not a markdown blob: each row shows the description,
URL and provenance (who posted it, when), with a copy button and a per-row ×.

```bash
booth links                # row number, entry id, raw row
booth unlink 3             # by row number
booth unlink 8b40e0a5      # by entry id — what the × posts
```

**Rows are addressed by CONTENT ID, never by position.** The board is
append-only and multi-writer: another session can post between the moment you
list it and the moment you remove a row, so an index would delete a neighbour.
An id either matches the row you saw or matches nothing. A row number typed at
the CLI is resolved to its id *before* anything is deleted.

An id is exactly 8 hex characters, which is how the CLI tells ids from row
numbers — roughly one id in forty is all digits, so "is it numeric" is not a
safe test.

Appends (`booth link`) and prunes (`booth unlink`, the ×) take the same
`flock` on `.links.lock`, so a post cannot be lost inside a prune's
read-modify-write window.

### Deleting a kept board

Kept boards have no × in the UI on purpose — a one-click wipe next to the
durable stuff is a footgun. But *deliberate* must not mean *impossible*, which
is what it meant until 2026-08-23: the only routes out were ssh or a
hand-written API call.

Now it is two deliberate steps. **Release** on the kept card drops the
sentinel and the board moves to the ephemeral lane, where the × already lives;
wipe it from there. Release is reversible — press keep again and nothing was
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
tells you it was kept.

**Do not "unkeep and let it expire."** Removing the sentinel *bumps the booth
directory's mtime*, and a booth's age is the newest mtime in its tree — so a
released board's clock **resets** and it survives another full TTL.
Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when
you mean now.

## Ops

Runs as a **user-level** systemd service on nh3-dev (no root, no Docker),
alongside the other fleet sidecars (herald, zellij-web, ttyd).

```bash
systemctl --user status booth.service
systemctl --user restart booth.service
journalctl --user -u booth.service -f          # sweeper logs "[booth] swept …"
```

Config is env in the unit (`booth.service`):
`BOOTH_DATA_DIR`, `BOOTH_TTL_HOURS`, `BOOTH_HOST_LABEL`, `BOOTH_SWEEP_INTERVAL_MIN`,
`BOOTH_MAX_UPLOAD_MB` (default 1024), `BOOTH_MAX_FILES` (default 50).

### Install / update

```bash
cd services/booth
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart   # runtime deps
cp booth.service ~/.config/systemd/user/booth.service
systemctl --user daemon-reload && systemctl --user enable --now booth.service
```

Code runs straight from this checkout (the unit's `WorkingDirectory` /
`ExecStart` point here), so "deploy an update" = edit + `systemctl --user
restart booth.service`.

### Tests

```bash
cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest -q
```

## Notes / non-goals

- **No auth.** LAN/WG-internal only, ephemeral content — don't drop secrets in a
  booth, and note anyone on the LAN can upload (bounded by the size/file limits).
  Uploaded files are served back with their own content-type, so an uploaded
  `index.html` renders as a page (a feature for custom reports; keep it in mind).
- Booth names with `/`, `..`, or a leading `.` are rejected; file serving and
  uploaded filenames are guarded against path traversal and symlink escape.
