The full design lives in the architect's bead block (
gc bd show td-1i30ih). This file is the engineer's-eye summary of decisions that affect implementation.
- Backend: Node 20 + Express + TypeScript.
- Frontend: React 18 + Vite + TypeScript + Tailwind.
- Shared types:
tools/admin-dashboard/shared/exports the wire shapes (Session, Bead, Mail, Events). Imported by both backend and frontend. When agcAPI field-shape changes, a compile error surfaces the breakage instead of an undefined at runtime — the biggest 6-month maintainability investment for a project of this size.
Rejected alternatives:
- Python + FastAPI — deploy messier (venv vs system), no shared types, slower to build this shape.
- Go — wrong coupling direction (admin tool shouldn't carry gc-the-orchestrator's lang dep).
- Direct-from-frontend (no backend) — doesn't work; peek + git + system-health need shell-exec.
Architect addendum td-wisp-ijk7g (mechanic td-wisp-e1v14) corrected the earlier reading: /v0/city/{name}/events/stream IS SSE today (the /stream suffix; the previous probe missed it). gc supervisor also serves a permissive CORS policy that echoes the request Origin, so the browser can new EventSource(...) directly against it.
What this collapses:
- No backend cursor-poll indirection.
- No backend-emitted SSE wrapper.
frontend/src/hooks/useGcEvents.ts::useGcEventRefresh(prefixes, onMatch)opens anEventSourcedirectly againsthttp://127.0.0.1:8372/v0/city/thriva-dev/events/stream, with?after=<lastEventId>for resume on reconnect, and exponential-backoff retry capped at 30 s.- The backend exposes
/api/config/gc-supervisorso the frontend gets the supervisor URL from one source of truth (no hardcoding in two places). - Agents page subscribes to
session.*events → table refreshes live. Beads page subscribes tobead.*events → table refreshes live. Both pages show a smalllive/connecting/offlinepill so the SSE state is visible. - Belt-and-braces still applies: every panel has a manual Refresh button for the tab-sleep / laptop-close case.
- Activity (
/activity): hardcoded git-log "view" enum (recent-main,recent-all,today,this-week) on/api/git/commits?view=<enum>. The args list lives entirely inexec.ts::GIT_LOG_VIEWS— the user picks a view name, not git args.git logruns against/home/charlie/thriva(overridable viaTHRIVA_ADMIN_GIT_REPO)./api/buildsparses/home/charlie/thriva/.dev-deploy-logline-by-line, classifying each entry intook/failed/in-progress/unknownand surfacing the.dev-deploy-FAILEDmarker as a banner pill. - Health (
/health): three cards — admin process state (pid/uptime/rss/heap/node version), host state (cpus, 1/5/15 load, mem free, host uptime), gc supervisor's own/v0/city/{name}/healthresponse (status/version/uptime). 30 s auto-refresh while tab is visible. Below the cards: a dolt-noms 24 h trend sparkline pulled from/api/dolt-noms/trend.
The ring buffer scaffolding is wired (backend/src/routes/dolt.ts): 144 slots, 10-minute sampling cadence. The actual metric source (sampleDoltNomsSize()) is a stub — mechanic surgical-ask is filed for "expose a dolt-noms metric endpoint or document where to read the disk size." Until that lands, /api/dolt-noms/trend returns {samples: [], available: false, source: null} and the Health page renders a calm "metric source pending" panel instead of fake zeros. Once mechanic ships the source, the only code change is swapping sampleDoltNomsSize() — the endpoint shape doesn't move.
Same architect addendum: GET /v0/city/{name}/session/{id}/transcript returns structured JSON with turns: [{role, text}, ...]. The dashboard fetches the transcript via the backend's GcClient.fetchTranscript, sanitises each turn's text server-side (ANSI/OSC/control-char strip, per-turn 16 KB cap, total 256 KB cap), and the frontend renders each turn as a role-tagged block.
Why we still go through the backend for peek (rather than calling gc direct from the browser):
- The frontend's CSRF / audit posture stays uniform across read + write paths.
- Server-side sanitisation is the load-bearing XSS defence; doing it in one place (
routes/sessions.ts::buildTranscriptResult) avoids the temptation to skip it on a client-only path. - Future SSE upgrade for live-tail can swap from the polled transcript to the streaming endpoint without re-architecting the consumer.
Three load-bearing reasons (per security_researcher td-wisp-eb0pn + senior_developer td-wisp-uvmru):
- Adoption-as-symmetry is a smell. The Services card on the gc dashboard is correctly empty for this city.
[[services]]is underexercised. Admin dashboard is too Charlie-critical to be the first adopter of an untested lifecycle primitive.- Inverted dependency. gc-managed services restart with the gc-supervisor — but the dashboard is exactly what Charlie wants open when gc is misbehaving. Dashboard must outlive supervisor outages.
systemd is boring, well-understood, and journalctl-debuggable. ExecStartPre includes a port-in-use check (senior_developer gotcha #5). Revisit [[services]] in v1+ when it has battle-tested adopters elsewhere.
Charlie (browser)
│
│ HTTP/loopback :8081
▼
┌──────────────────────┐
│ Express server │ ← single process, supervised by systemd
│ - /api/* │
│ - SPA at / │ (express.static, immutable cache on hashed assets)
│ - SSE at /api/events│ (Phase C)
│ - Audit → events.jsonl
└──────────┬───────────┘
│
├── HTTP → gc supervisor (:8372) — reads
│
└── spawn() → `gc` CLI — whitelisted writes
- Browser ↔ backend: same-origin, Host-allowlist, Origin check, CSP, CSRF on writes. See
SECURITY.md. - Backend ↔ gc supervisor: loopback HTTP. Trusts the supervisor's responses (typed via shared/types, but no signature verification).
- Backend ↔ shell (
gcCLI): whitelisted commands only,shell: false, clean env, param schemas. SeeSECURITY.md.
Five views ship in three milestones. Each milestone has an acceptance gate:
- Phase A (this commit) — skeleton + Agents view + Beads view. Gate: Charlie can identify any session's state + peek tmux content without a shell; can see filtered beads + claim/close from the browser.
- Phase B — Mail with identity-switching (view-as-X, sends-as-owner via separate router). Gate: operator can read any agent's thread cross-agent; verify every send logs
actor=<owner>where<owner>isGC_CITY_OWNER_ALIAS(default'human', setcharliefor the Charlie deploy). - Phase C — Activity (commits + builds) + Health (process + dolt-noms 24 h trend) + SSE wiring. Gate: Charlie can spot the refinery's last merge + memory pressure trend without terminal.
Internal tool — the "anti-scope-reduction reflex" doesn't apply here. The five views are loosely coupled; phasing is logical build order, not feature cuts.
Remove the tools/admin-dashboard/ subtree + the systemd unit. No persistent state to clean up. The audit log entries written to .gc/events.jsonl are read-only signal and won't break gc itself.
- PIN-quick-path for parent mode is not this project (different bead).
- Per-event-class notification opt-out is not this project.
- TanStack Table — premature dep at our scale; the in-house
<Table>covers sortable columns + filter chips + click-row in <200 LOC. - xterm.js for peek — overkill (no need for terminal emulation, just a snapshot view).
ansi_up(~3 KB) is sufficient. - Light theme / system-pref auto — Charlie can request in v1 if dark-default bites.