# harness-web-ui — Design

audience: AI coding agents first. Contract-level: seams + decisions, NOT code bodies.
slug: `harness-web-ui` · date: 2026-06-30

## Purpose

A **local single-user Web dashboard** to watch and steer running harness agents — sub-project **3 of 3** of the
Mega Plan Harness brief (1 = Control & Observability API `harness-control-api`, 2 = OKF KB `okf-kb`). It binds the
two prior sub-projects: it consumes the control-api's run/observe/steer/kill wire and the OKF format/CLI, and adds
nothing to the harness core except a presentation + interaction layer.

Screens (from the brief): **Overview** (job tiles + status badges + hover CTAs), **Job** (metadata + DAG of
waves→tasks→agents), **Agent** (chatbot-style live view + send-prompt + killswitch + full detail), **OKF KB**
management, **Adapter setup** (enable/disable, per-adapter model selection, health).

## Ground truth (probed 2026-06-30 — do not re-derive)

- **`~/Projects/platform`** = pnpm + Turbo monorepo, 56 `@platform-modules/*` packages, 3 apps — all apps are
  **Astro + `@astrojs/react` + Tailwind**, importing modules via `workspace:*`.
- **Dashboard's full `@platform-modules/*` closure = 3 packages**, all already PUBLISHED to GitHub Packages:
  `ui-primitives@0.3.0` (DashboardShell, DataList, Table, Card, Tabs, Dialog, DropdownMenu, Toast, Badge, Button,
  Tooltip, Skeleton, ErrorState, Field, Input, Select, … 28 components), `ui-tokens@0.2.0` (theme-engine +
  variants), `ui-editor@0.1.0` (rich editor — optional, OKF authoring). Registry:
  `@platform-modules:registry=https://npm.pkg.github.com`, auth via `NODE_AUTH_TOKEN`.
- **No graph/DAG/flow viz exists anywhere in platform** → the DAG view needs an external dep.
- **`realtime-react` is WebSocket-only** (`useChannel(url, …)` opens ONE WS per url). It is NOT used here — see
  Architecture Decisions (it would discard control-api's SSE `seq`/`Last-Event-ID` resume).
- **`ai` module is NOT used** — the agent "chat" talks to a running CLI agent over the control-api transport, not
  an LLM SDK.
- **control-api wire (sub-project 1, `harness-control-api`)** is the upstream contract this binds: REST routes
  keyed by `:runId`; global run registry `~/.harness/runs/<runId>.json` = `{runId,slug,repoRoot,project,repo,
  branch,worktree,runstateDir,created}` (+ liveness record carrying the `sock` path); per-run supervisor unix
  socket `$HARNESS_HOME/sock/<runId>.sock` serving REST + an SSE event stream whose `seq` is the 1-based logfile
  line ordinal and which resumes via `Last-Event-ID`; steer allowed only on non-terminal journal states
  (`leased|implemented|gated|reviewed`), `committed` → 409; codex verdict seat is observe+kill, steer-forbidden.
- **OKF (sub-project 2, `okf-kb`)** is the KB-page contract: `bin/okf` verbs `project|capture|sync|new|index|
  promote|doctor` (exit 0/2/3); store = single-concept markdown + YAML frontmatter; `index.md` progressive
  navigation.

## Stack & dependencies

- **App:** Astro + `@astrojs/react` + Tailwind, at `mega-plan-harness/web/` (resolved decision — all 3
  sub-projects in one repo). Astro **Node adapter** (`@astrojs/node`, standalone) so the same process serves the
  SPA and the gateway endpoints.
- **Platform (GitHub Packages, pinned semver):** `@platform-modules/ui-primitives ^0.3`, `ui-tokens ^0.2`,
  `ui-editor ^0.1` (optional). `web/.npmrc` scopes `@platform-modules:registry=https://npm.pkg.github.com`;
  `NODE_AUTH_TOKEN` supplied by the developer's env (never committed).
- **New deps (the platform gaps):** `@xyflow/react` (DAG view), `elkjs` or `dagre` (auto-layout for the wave
  graph), `react-markdown` + `remark-gfm` (agent messages + OKF rendering), `zustand` (client run-store; same lib
  platform's `i18n-react` already uses).
- **Zero new backend infra** — no DB, no Cloudflare, no auth. The gateway is plain Node inside Astro; all state is
  read live from the harness (registry + sockets + `okf`). Matches the harness "runs anywhere" invariant.

## Architecture

```
  browser SPA (React islands)
     │  HTTP (fetch) + SSE (EventSource)        ALL to 127.0.0.1:<port>
     ▼
  Astro Node server  (web/, single local process)  ── the GATEWAY ──
     │  reads  ~/.harness/runs/<runId>.json  (run discovery + liveness + sock path)
     │  proxies per-run unix socket  $HARNESS_HOME/sock/<runId>.sock   (REST + SSE)
     │  spawns  bin/okf  for the KB page
     │  reads/writes adapter config (harness adapter-config seam — see component 8 / GAP)
     ├─► run A .sock   (events SSE · steer · kill · journal)
     ├─► run B .sock
     ├─► okf CLI
     └─► static SPA assets
  no auth · single user · 127.0.0.1 only
```

**Transport contract (load-bearing):** **SSE down, POST up.** Two stream tiers, to stay under the HTTP/1.1
~6-connection-per-origin cap (the local Node adapter is h2-less plaintext):
- **Overview → ONE multiplexed summary stream** `GET /api/runs/events`: lightweight cross-run status-change frames
  (`{runId, status, liveness, doneCount, taskCount}`) for ALL not-done runs. The Overview never opens a stream
  per tile — one connection drives every badge.
- **Detail (Job / Agent) → one per-run stream** `GET /api/runs/:runId/events`, opened only on drill-in and closed
  on navigate-away. So at most ~2 connections are live (summary + the open detail), never N.

The gateway dials the relevant unix socket(s), consumes the control-api SSE stream(s), and relays frames
**preserving `seq`**. On reconnect the browser sends `Last-Event-ID`; the gateway forwards it **upstream** and the
control-api (sole owner of the logfile + `seq`) performs the replay — the gateway only pipes, never re-tails (see
Error handling). Interaction (steer / kill / lifecycle) is plain `POST`. No WebSocket anywhere.

## Components & seams

### 1. Gateway endpoints — `web/src/pages/api/**` (Astro server routes; the local bridge)

Browser-facing HTTP that fronts the per-run unix sockets + `okf`. Pin the routes & shapes (these are IO contracts,
not bodies):

```
GET  /api/runs                        → 200 [{runId, slug, project, repo, branch, worktree, liveness, created}]
                                          (enumerate ~/.harness/runs/*.json + liveness probe of each sock)
GET  /api/runs/:runId                 → 200 {meta, journal: {tasks:[{id,wave,phase,desc,status,deps,agentId?}]}}
                                          (meta from registry; journal snapshot from the run's REST)
GET  /api/runs/:runId/events          → SSE proxy of the run socket's event stream; relays seq + Last-Event-ID
POST /api/runs/:runId/tasks/:taskId/steer   {text}            → forward to control-api steer; 409 if committed
POST /api/runs/:runId/tasks/:taskId/redispatch               → forward; transitions task back to leased
POST /api/runs/:runId/agents/:agentId/kill                   → forward kill (allowed on all seats incl. codex)
GET  /api/okf/tree    [?scope=]       → `okf index` projection (progressive index.md tree as JSON)
GET  /api/okf/file    ?path=          → raw concept markdown + parsed frontmatter
POST /api/okf/file    {path, body}    → write concept (atomic) + `okf index`; validates frontmatter via `okf doctor`
POST /api/okf/sync                    → `okf sync`
GET  /api/adapters                    → adapter registry + health (see component 8)
POST /api/adapters/:id                {enabled, models, config}  → persist adapter config (see component 8 GAP)
```

- **runId, not slug, is the key** (slugs collide cross-project — control-api decision). The SPA never sees unix
  socket paths; the gateway resolves `sock` from the liveness record.
- **Fail-closed:** dead/absent socket → `502 {detail, liveness:"dead"}` (never a hung request). Registry pointer
  missing → `404`. `okf` non-zero exit → surface stderr + exit code as `500`/`422` (no silent partial).
- The gateway is a **thin relay + shape-normalizer**, holds no authoritative state (the harness owns truth).

### 2. Client run-store — `web/src/lib/run-store.ts` (Zustand; single client-truth)

`runId → {meta, tasks, waves, agents:{agentId→{pid,runtime,idle,seat,status,settings}}, messages, liveness}`.
Seeded by `GET /api/runs/:runId`, then **patched by SSE events** (the control-api event vocabulary maps to store
mutations). Selectors feed every view; no view fetches the socket directly. Pin: the store is the only place SSE
frames are applied, so reconnect/replay idempotency lives in one spot (apply-by-`seq`, dedupe ≤ last applied).

### 3. SSE client hook — `web/src/lib/use-run-stream.ts` (the transport gap, built here)

`useRunStream(runId): {connected, lastSeq}` — wraps native `EventSource('/api/runs/:runId/events')`, parses each
frame, dispatches into the run-store, and relies on the browser's built-in `Last-Event-ID` reconnect (no manual
backoff lib). Replaces `realtime-react` (WS). Deep: views are blind to transport; swapping SSE↔WS later touches
only this hook + the run-store apply path.

### 4. DAG view — `web/src/islands/Dag.tsx` (`@xyflow/react` + layout lib)

Renders the workflow as an interactive graph: **wave = node, dep = edge**, auto-laid-out (elk/dagre, left→right).
Node click expands the wave to its tasks; task → its agent (routes to Agent view); status drives node/badge color
(Running/Paused/Error/Done/Interrupted). Custom node components built from `ui-primitives` (Card/Badge/Tooltip).
Pin the node data shape derived from the journal:
```
WaveNode  = { id:`w<n>`, kind:'wave', status, taskCount, doneCount }
TaskNode  = { id:taskId, kind:'task', wave, phase, desc, status, agentId? }
edge      = { source, target }   // from task.deps (task→task) and wave ordering
```
Used by both the Job page (full) and the Overview tiles (static mini-preview — same renderer, `fitView`,
interaction disabled).

### 5. Overview page — `web/src/pages/index.astro` + `Overview.tsx` island

Tile grid (`ui-primitives` Card/DataList) of not-yet-done jobs from `GET /api/runs`. Each tile: a static DAG
mini-preview (component 4), a **status badge**, and **status-conditional hover CTAs** with SVG icons — pin the
mapping: `Running → [Pause, Info]`, `Paused → [Start, Delete, Info]`, `Error → [Start, Delete, Info]`,
`Interrupted → [Start, Delete, Info]`, `Done → [Info]`. CTA → POST lifecycle (resume = redispatch leased task /
the run's start; pause/kill = component 1). Tile click → Job page.

### 6. Job page — `web/src/pages/runs/[runId].astro` + `Job.tsx` island

Run metadata panel — the fields the control-api registry record actually carries (project, repo, branch,
worktree, runstateDir, slug, created) + `land_mode` from the run's journal `meta`. The brief's "type" field has no
source in the registry record and is **omitted** unless the control-api later adds it. Plus the full interactive
DAG (component 4) + per-wave status badges. Wave expand → tasks; task → Agent view link.

### 7. Agent view — `web/src/pages/runs/[runId]/agents/[agentId].astro` + `AgentChat.tsx` island

Chatbot-style timeline built from `ui-primitives` (no chat module exists): renders the agent's message/event
history (`react-markdown` + gfm) live from the run-store (SSE-fed, filtered to `agentId`); a detail rail shows
pid / runtime / idle / seat+tier / settings / **killswitch**; an input composer POSTs `…/steer` (text only —
attachment send is a conscious Non-goal v1, see Non-goals). Steer composer is **disabled when the agent's seat is steer-forbidden**
(codex verdict) or the task is `committed`, with the 409 reason surfaced inline (mirror the control-api rule, fail
visibly rather than POST-and-error).

### 8. Adapter setup page — `web/src/pages/adapters.astro` + `Adapters.tsx` island

Lists every adapter (claude / codex / cursor / opencode / openrouter / …), with enable/disable, per-adapter
configuration, **granular model selection** (e.g. OpenRouter exposes hundreds — operator picks the 1–2 usable),
and a **health** indicator. Reads/writes via `GET|POST /api/adapters`.

**Upstream contract:** binds the harness-core **adapter-config** surface specced separately in
`docs/specs/2026-06-30-harness-adapter-config-design.md` (slug `harness-adapter-config`) — the adapter descriptor
`{id, kind, enabled, models:[…], availableModels?, config, health}`, the global config store, and the per-adapter
health probe live there (a `bin/harness-adapter` CLI the gateway shells to, mirroring how the OKF page shells to
`bin/okf`). **This component is build-gated on that spec landing**; the other 7 bind only the already-specced
control-api + OKF contracts and are unblocked.

## Data flow

`GET /api/runs` paints the Overview from the registry → user opens a Job → `GET /api/runs/:runId` seeds the
run-store and `useRunStream` opens the SSE proxy → DAG + agents update live as frames arrive (applied by `seq`) →
user opens an Agent, reads the live timeline, types a prompt → `POST …/steer` → gateway forwards to the run socket
→ the resulting agent activity streams back over the same SSE channel into the timeline. OKF page reads/writes
through `okf` via the gateway; Adapter page reads/writes the adapter-config seam.

## Error handling

Fail-closed throughout. Dead socket → `502` + the run shows `liveness:dead` (no spinner-forever). SSE drop → the
browser auto-reconnects with `Last-Event-ID`; the gateway **forwards that id upstream** on the re-dialed socket and
the control-api (sole owner of the logfile + `seq`) replays frames whose `seq` exceeds it — the gateway never
re-tails the logfile itself, so seq-replay logic stays single-sourced in the control-api. The run-store also
dedupes by `seq` so any double-delivery is idempotent. Steer on a committed/forbidden target →
the composer is pre-disabled; if a race still POSTs, the `409` reason renders inline. `okf` validation failure →
the KB editor shows `okf doctor` output, write is rejected (atomic — never a partial concept). Gateway never
caches authoritative state, so a harness restart self-heals on the next poll/stream.

## Testing strategy

- **Gateway proxy:** fixture registry + a fake unix socket emitting a known SSE script → assert `/api/runs`,
  `/api/runs/:runId`, and that `/events` relays frames with `seq` intact; dead socket → `502`; `Last-Event-ID`
  replay returns only `seq >` client id.
- **run-store:** apply an out-of-order + duplicate `seq` stream → state converges, no double-apply.
- **DAG:** journal fixture (multi-wave, deps, mixed statuses) → assert node/edge graph + status→color mapping;
  Overview mini-preview renders non-interactive.
- **Agent view:** steer-forbidden seat / committed task → composer disabled + reason shown; allowed → POST fires.
- **OKF page:** read tree from a fixture store; reject a write that fails `okf doctor`; accept a clean write.
- **Adapter page:** (once the seam lands) enable/disable + model-select round-trips through `/api/adapters`;
  health reflects a stubbed probe.
- **Island smoke:** each island mounts under the Astro Node adapter against a mocked gateway.

## Non-goals (v1 — conscious deferrals)

- **Attachment send to agents.** The brief lists "text and attachments according to agent capability"; v1 ships
  text-only steer (the control-api steer wire carries `{text}`). Attachment upload needs a capability descriptor
  per seat + an upload/transport contract the control-api does not expose — deferred until that seam exists.
- **No auth / no remote access / no multi-user.** Resolved local-single-user topology (see Architecture Decisions).
- **No persistence layer of its own.** All state is read live from the harness; the dashboard stores nothing.

## Architecture Decisions

- **SSE-native, NOT `realtime-react` (WebSocket).** The control-api stream is SSE with a position-derived `seq`
  and `Last-Event-ID` resume; browser `EventSource` gives auto-reconnect + replay for free and the proxy preserves
  `seq` end-to-end. `realtime-react` would force a WS bridge in the gateway and re-implement replay over WS,
  discarding the upstream resume design. Interaction is half-duplex (SSE down + POST up), so full-duplex WS buys
  nothing. One thin `use-run-stream` hook is the only cost. Deep.
- **`@xyflow/react` for the DAG, NOT hand-rolled SVG.** Platform has zero graph-viz; the brief's centerpiece is an
  interactive, expandable, pan/zoom node graph — exactly React Flow's domain (custom nodes from `ui-primitives`,
  auto-layout via elk/dagre). Industry standard; hand-rolling pan/zoom/hit-testing is reinventing a mature wheel.
  Medium-deep (the journal→graph mapping is ours; rendering/interaction is the lib's).
- **App in `mega-plan-harness/web/`, platform via GitHub Packages (resolved decision).** All 3 sub-projects in one
  repo; the 3 required platform packages are already published, so semver consumption is clean (no publish/link
  friction). `web/`'s heavy toolchain is isolated in its own subdir, not polluting the zero-dep harness core.
- **Gateway = Astro Node-adapter server endpoints, NOT a separate gateway process.** One local process serves the
  SPA and proxies the sockets/`okf` — fewer moving parts, single port, matches the local-single-user topology. The
  gateway is a stateless relay; the harness remains source of truth.
- **No auth / no remote / no DB (local single-user).** Resolved topology decision; keeps the dashboard zero-infra
  like the rest of the harness. Reversible — an auth layer + remote agent can wrap it later without touching the
  views.
- **Adapter-config + health owned by a separate harness-core spec, NOT invented in the UI.** Per the brief's "tell
  the user what's missing", this missing seam was surfaced and resolved: a small harness-core spec
  (`harness-adapter-config`) owns the descriptor + config store + health probe as a `bin/harness-adapter` CLI, and
  the dashboard consumes it via the gateway exactly as it consumes `bin/okf`. Component 8 is build-gated on that
  spec landing; the UI fabricates no config storage of its own. The other 7 components are unblocked.
- **Rejected — reuse `@platform-modules/ai` for the agent chat.** The chat drives a running CLI agent through the
  control-api, not an LLM SDK; `ai` solves a different problem and would mislead the implementation. Excluded.
- **Rejected — SSR-render the views / use platform backend modules (realtime, jobs, cache, auth, db).** Those are
  Cloudflare-Workers + Drizzle bound; the harness is local zero-infra. The dashboard SSRs nothing beyond the
  gateway endpoints and reads live harness state. Excluded as infra-incompatible.
