Files
esh-pfi-infrastructure/services/booth
vh 76fdf45925 feat(booth): pin/favorite, multi-select delete, newest-first link board
The standing link board grew from a flat oldest-first list with a per-row
× into a manageable board: newest links lead, favorites stay on top, and
several dead links can go in one pass.

- Ordering: order_for_display() renders pinned rows first, then newest-first
  within each group (the board is an append log, so newest = most recently
  posted — the row you usually came to grab).
- Pin/favorite: a per-row ★ toggles pinned state via POST /b/<name>/pin.
  State lives in a .pins sidecar dotfile (one content id per line), NOT
  inline in links.md — so links.md stays a pure atomic-append log (many
  sessions post concurrently) and a row's content id never changes just
  because it was pinned. remove_link_entry drops a removed row's pin;
  orphaned pins are inert (renderer only stars a live id).
- Multi-select delete: checkboxes feed POST /b/<name>/unlink-many (repeated
  'sel' content ids), with a select-all box and a live count. The per-row ×
  stays for single removal.
- One <form> with formaction buttons, so checkboxes, ×, ★, and bulk delete
  coexist without nested forms AND all work with JS off; JS only adds
  select-all and the live count. Per-row × confirm reads desc/url from
  data-* attrs, so an arbitrary posted description can't break into the JS.
- Every action is keyed by content id, never row position — same race-safety
  the existing × has, extended to the bulk path.
- Fixed pre-existing undefined --fg/--bg CSS refs in the board styles.

Tests: +19 (pins round-trip, ordering, orphan-inert, remove-unpins, /pin
and /unlink-many endpoints, board render + order). Full suite 102 passing.
Deployed to nh3-dev booth.service; verified live (newest-first, pin
round-trip, bulk delete) against the real 31-row board with no data loss.
2026-09-06 02:29:22 -07:00
..

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:

# 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.

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:

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.

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):
    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)
POST /b/<name>/unlink-many Remove SEVERAL rows — the multi-select delete (repeated form field sel = content ids)
POST /b/<name>/pin Toggle a row's pinned/favorite state (form field entry = content id)
DELETE /b/<name> Wipe a booth (curl/API)
GET /healthz {ok, ttl_hours, booths} — Homepage siteMonitor target

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 ×.

Order: pinned first, then newest on top. The board is an append log, so the most recently posted link leads — the one you almost certainly came to grab. Rows you want to keep in view regardless of churn get the (pin/favorite), which floats them to a group at the very top; click it again to unpin. The header shows N pinned when any are.

Multi-select delete. Tick the checkbox on any set of rows and hit 🗑 delete to remove them all in one go (with a count confirmation). The select-all box in the header toggles the lot. The per-row × is still there for a single quick removal. Everything — checkboxes, ×, ★, bulk delete — works with JavaScript off (plain form POSTs via formaction); JS only adds select-all and the live count.

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. The multi-select delete (/unlink-many) carries the same guarantee per selected id.

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.

Pin state lives in a .pins sidecar (one content id per line), never inline in links.md. That keeps links.md a pure append log — booth link stays a single atomic write, which is what lets many sessions post concurrently — and means pinning a row never changes its content id. A pin whose row is later removed is dropped automatically; a pin orphaned by a hand-edit is inert (the renderer only stars a row a live id still matches). Pins are a UI action; there is no booth pin CLI yet.

Appends (booth link) and prunes (booth unlink, unlink-many, the ×) take the same flock on .links.lock, and pin toggles take it too, so a post cannot be lost inside a prune's or a toggle'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).

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

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

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.