Apply contraction fixes across ~40 doc files (setup, integrations, lifecycle, networking, scheduler, swarm, tools, trust-boundary, UI, etc.). Skipped 14 hits: - 10 where words appear in ALL CAPS for deliberate emphasis (is NOT, do NOT, etc.) - 4 where text could not be safely located due to markdown formatting or column position Applied via systematic scan with checks for fenced code blocks, inline code spans, and intentional caps. Preserves sentence-initial capitalization throughout.
336 lines
18 KiB
Markdown
336 lines
18 KiB
Markdown
# Shape (shared by both)
|
|
|
|
> Part of [Web UI](README.md). See also:
|
|
> [Dashboard layout](dashboard.md) · [Per-agent page](agent.md)
|
|
|
|
## Shared routes
|
|
|
|
- `GET /` → `index.html` from the bundled frontend dist (see
|
|
`frontend/`). Both binaries' routers declare their dynamic
|
|
endpoints first and then `fallback_service(ServeDir::new(...))`
|
|
pointed at `HIVE_STATIC_DIR` — anything not matched by an API or
|
|
action route is served from the dist. Dashboard dist lives at
|
|
`${frontend}/dashboard`; per-agent dist is the merged
|
|
`hyperhive.frontend.mergedDist` (default agent dist + per-agent
|
|
`extraFiles` overlay).
|
|
- `GET /static/*` → bundled CSS + JS produced by esbuild
|
|
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
|
|
pull the shared terminal pane + Catppuccin palette + typography
|
|
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
|
|
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
|
|
loaded by every page, plus a page-specific bundle —
|
|
`dashboard.css` / `flow.css` / `logs.css` / `home.css` /
|
|
`stats.css` / `core.css` / `builds.css` /
|
|
`credentials.css`); `common.css` inlines `@hive/shared`'s
|
|
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
|
`pill.css` via esbuild's `@import` resolution.
|
|
`terminal.js` exports `{ create, linkify }` as ES module
|
|
members; a back-compat shim in the IIFE bodies still exposes
|
|
a `window.HiveTerminal` global for callers that need it. The dashboard's
|
|
`#msgflow` and the per-agent `#live` log are both backed by
|
|
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
|
history backfill, SSE plumbing all live there. Each page
|
|
registers a kind→renderer map; unknown kinds fall through to
|
|
a JSON-dump note row. Bare `http(s)://` URLs in row text are
|
|
turned into clickable new-tab links by `linkify` (text-node
|
|
based, no `innerHTML` — XSS-safe); markdown bodies go through
|
|
`marked` (npm dep, replacing the vendored UMD bundle) and then
|
|
`DOMPurify.sanitize()` before hitting `innerHTML` — untrusted row
|
|
text (peer-agent / matrix-relayed message bodies, agent-authored
|
|
state files) can carry arbitrary HTML/script via markdown, so the
|
|
markdown path is sanitized rather than XSS-safe by construction the
|
|
way the text-node `linkify` path is. Rendered `<a>`s are rewritten
|
|
to `target="_blank"`.
|
|
- `GET /api/state` → JSON snapshot the JS app renders into the
|
|
DOM. Includes a top-level `seq` (the dashboard event channel's
|
|
high-water mark at the moment the snapshot was assembled);
|
|
clients use it to dedupe their buffered SSE traffic against
|
|
the snapshot (drop frames with `seq <= snapshot.seq`).
|
|
- `GET /api/dashboard/stream` (dashboard) / `GET /events/stream`
|
|
(per-agent) → `text/event-stream` SSE for live updates. The
|
|
dashboard stream carries broker `Sent` / `Delivered` (mirrored
|
|
by a forwarder task from the broker's intra-process channel)
|
|
plus mutation events (`approval_added` / `approval_resolved`,
|
|
`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
|
|
matching backfill endpoint is `GET /api/dashboard/history` (last
|
|
~200 broker messages wrapped in `{ seq, events }`) on the
|
|
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
|
also wrapped in `{ seq, events }`) on the agent.
|
|
**One unified channel**: browsers cap concurrent SSE
|
|
connections per origin (~6 in Chrome). Using one channel per
|
|
domain would exhaust this budget on a live hive; dispatching
|
|
by `kind` on the client is a one-liner. Per-domain splits are
|
|
reserved for high-volume sub-streams most consumers skip (none
|
|
exist yet). The broker's intra-process channel stays separate
|
|
from the dashboard channel to avoid coupling `recv_blocking_batch`
|
|
(hot path inside the harness turn loop) to presentation concerns.
|
|
**SSE multiplexing**: the dashboard uses a
|
|
`SharedWorker` (`stream-worker.js`) to hold one upstream
|
|
`EventSource` per URL. All same-origin tabs share this worker
|
|
— a second dashboard tab joins the existing connection rather
|
|
than opening a duplicate. The worker fans SSE events out to
|
|
each subscribed tab via `MessagePort`; on bfcache restore the
|
|
page re-subscribes (gets a synthetic `open` event immediately
|
|
if the upstream is already connected). Falls back gracefully
|
|
when `SharedWorker` is unavailable (e.g. some private-mode
|
|
browsers). **Worker-death self-heal**: Firefox kills
|
|
"idle" SharedWorkers under memory pressure with no client-side
|
|
signal — the port silently goes no-op. The worker now pings
|
|
every connected port every 30s; the client bumps a
|
|
last-activity timestamp on every message (incl. pings, which
|
|
carry no URL — bumped before the URL filter). A
|
|
visibility-gated watchdog polls every 15s and, if the page is
|
|
visible AND has active subs AND hasn't heard from the worker
|
|
in >90s (three missed pings), presumes the worker dead and
|
|
re-subscribes on a fresh `SharedWorker` port (same code path
|
|
bfcache-restore uses). Recovery is per-tab; pings are
|
|
invisible on the healthy path.
|
|
|
|
## Shared terminal pane
|
|
|
|
Both surfaces' scrollable log streams (`#msgflow` on the dashboard,
|
|
`#live` on the per-agent page) are backed by the shared terminal
|
|
factory in `@hive/shared/terminal.js`. The factory wires up
|
|
sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and
|
|
SSE replay. Pages register a `kind → renderer` map; unknown kinds
|
|
fall through to a JSON-dump note row. The factory ships three row
|
|
shapes the renderers call:
|
|
|
|
- `api.row(cls, text, icon?)` — single-line row with an inline `linkify`
|
|
pass over the text; optional `icon` lands in a fixed-width `.row-glyph`
|
|
cell so glyphs of varying rendered width all align.
|
|
- `api.details(cls, summary, body, icon?)` — collapsible `<details>` with
|
|
a `<pre>` body (used by long tool-results and stack traces).
|
|
- `api.detailsDiff(cls, summary, body, icon?)` — same shape, splits the
|
|
body on newlines and tags each line as `diff-add` / `diff-del` /
|
|
`diff-ctx` so the renderer's diff bodies get coloured without
|
|
emitting raw HTML.
|
|
- `api.placeholder(text)` — replaces the log with a single muted row,
|
|
cleared automatically when the next real row arrives.
|
|
- `api.fromHistory` — `true` while backfill replay is running; renderers
|
|
use it to suppress live-only side effects.
|
|
|
|
**`create(opts)` options:** `logEl` (log container element), `streamUrl`
|
|
(SSE endpoint), `historyUrl?` (optional replay endpoint — skipped if absent
|
|
or if the fetch fails), `renderers` (kind→fn map; unknown kinds fall through
|
|
to `renderers._default`, itself defaulting to a JSON-dump note row),
|
|
`onLiveEvent?(ev)` (live-only side effects), `onAnyEvent?(ev, {fromHistory})`
|
|
(runs for both replay and live — use for derived views that need the full
|
|
event picture), `onBackfillDone?(count)` (one-shot after replay; `count=0` on
|
|
failed/skipped fetch), `onStreamOpen?()` (fires on every EventSource
|
|
(re)connect — use to re-sync snapshot-derived state after a reconnect gap),
|
|
`pillAnchor?` (parent element for the "↓ N new" pill; defaults to
|
|
`logEl.parentElement`), `expandDetails?` (boolean or zero-arg function
|
|
returning one, re-read on every `api.details`/`api.detailsDiff` call rather
|
|
than captured once — lets a page default otherwise-collapsed panels open
|
|
per a live browser-local preference; renderers that force a row open
|
|
regardless, e.g. message-bearing tool_use, are unaffected either way).
|
|
|
|
**Sticky-bottom + snap animation.** `stickToBottom` is the
|
|
operator's intent: true means "keep snapping to bottom on every
|
|
mutation", false means "I scrolled up, leave me alone". The flag
|
|
flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
|
|
from the bottom. New rows then either snap to bottom (when sticky)
|
|
or bump the unseen-count and surface the "↓ N new" pill. The snap
|
|
is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
|
|
default `behavior: 'smooth'` ~500ms reads as "still smooth, but
|
|
visibly slow"; 140ms feels snap-y while still reading as motion
|
|
rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
|
|
short-circuit to instant — animating a 12px nudge would just be
|
|
jitter. Each new snap cancels the previous `requestAnimationFrame`
|
|
so a burst of mutations coalesces into one ride to the latest
|
|
bottom; the per-frame step re-reads `scrollHeight - clientHeight`
|
|
so mutations landing mid-animation extend the destination smoothly
|
|
rather than land short.
|
|
|
|
**Mid-animation scroll-event guard.** The scroll handler's
|
|
`isNearBottom` check would flip `stickToBottom` false mid-snap as
|
|
the smooth animation eases through positions that are technically
|
|
"not near bottom yet", which would strand the operator partway. A
|
|
`smoothScrollingUntil` timestamp gates the scroll handler — set to
|
|
the animation end + ~80ms headroom, re-armed on each fresh snap.
|
|
Programmatic `scrollTop` writes (the animation's per-frame update)
|
|
fire scroll events that the gate swallows. One caveat: if live events
|
|
arrive faster than the animation window (< 220ms apart), the
|
|
MutationObserver keeps re-firing `snapToBottom()` which perpetually
|
|
re-arms the gate — trapping the operator at the bottom with no way
|
|
to scroll up or trigger `loadMore()`. Safety valve: even inside the
|
|
guard the handler checks `isNearBottom()`; if the operator has
|
|
scrolled away from the bottom, `stickToBottom` is immediately forced
|
|
false. The MO's `if (stickToBottom)` check then stops re-arming
|
|
snaps, and the gate expires within ≤220ms.
|
|
|
|
**Post-append `MutationObserver`.** Renderers commonly call
|
|
`api.row(cls, text)` to create the row shell then append more
|
|
children (badges, multi-line bodies, tool result panes) after the
|
|
factory returned. The initial sticky-snap fires off the row's
|
|
empty shape; the renderer's later appends grow the row past the
|
|
visible bottom. A `MutationObserver` on the log subtree fires once
|
|
per microtask after each batch of synchronous mutations and snaps
|
|
again when `stickToBottom` is true. Programmatic `scrollTop`
|
|
writes don't re-trigger the observer (scroll isn't a DOM
|
|
mutation), so no feedback loop. The pre-append
|
|
`nearBottomBeforeAppend` snapshot is still useful — it keeps the
|
|
initial visual lag to one frame instead of one microtask + frame.
|
|
|
|
**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
|
|
subscribes to `streamUrl` (live tail). Both endpoints return
|
|
`{ seq, events }` so the client can dedupe — events with
|
|
`seq <= snapshot.seq` from the SSE stream are dropped silently
|
|
(the snapshot already covers them). History rows render with a
|
|
`.no-anim` class so they don't stagger in like live events. The
|
|
optional `streamFactory(url)` callback lets the dashboard hand
|
|
the factory a `SharedWorker`-backed `EventSource` facade (so
|
|
multiple tabs share one upstream connection — see _SSE
|
|
multiplexing_ above); when omitted, the factory falls back to a
|
|
plain `new EventSource(url)`.
|
|
|
|
**`linkify` (text-node based).** Bare `http(s)://` URLs in row
|
|
text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
|
|
inside a fresh text node, so the autolinker never touches
|
|
`innerHTML` and untrusted row content can't smuggle markup. The
|
|
trailing-punctuation strip keeps `.,;:` outside the link surface.
|
|
Markdown bodies go through `marked` separately, then
|
|
`DOMPurify.sanitize()` on the resulting HTML before it's assigned to
|
|
`innerHTML` (see `mdNode` in `app.js` / `common.js`) — this path is
|
|
sanitized, not text-node-safe like `linkify`, since markdown can
|
|
carry raw HTML. Rendered `<a>`s get the same target rewrite.
|
|
|
|
The JS app handles all `form[data-async]` submissions via a delegated
|
|
listener: read `data-confirm`, swap the button to a spinner, POST
|
|
`application/x-www-form-urlencoded`, re-enable the button on success
|
|
(refreshState may keep the form mounted, so we don't rely on a
|
|
re-render), call `refreshState()`. State shapes live in
|
|
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
|
`web_ui/state.rs::StateSnapshot` — when
|
|
adding state fields, plumb through the snapshot struct and the
|
|
relevant domain module (`swarm.js`, `call.js`, etc.) render function.
|
|
|
|
**Focus preservation:** `refreshState` checks whether
|
|
`document.activeElement` sits inside one of the managed sections
|
|
and, if so, skips the refresh (defers 2s). The operator never has
|
|
the form yanked out from under them mid-type; the update lands as
|
|
soon as they blur.
|
|
|
|
**Atomic section repaint:** every managed-section renderer goes
|
|
through `paintAtomic(liveRoot, build)`: the builder appends into
|
|
a fresh `DocumentFragment` (off-DOM) and the commit is one
|
|
`replaceChildren` call, so the intermediate empty state is never
|
|
visible — clearing and re-appending directly into the live root can
|
|
leave it empty for a paint (on async builders) or a whole frame (on
|
|
large synchronous ones), which reads as a visible flash on every
|
|
poll cycle. Builders receive the fragment as their
|
|
`root`, so existing renderer code carries over unchanged; early
|
|
returns inside the builder still commit whatever was appended
|
|
before they returned.
|
|
|
|
**Keyed DOM caching:** for sections whose rows hold interactive state
|
|
(textarea drafts, checkboxes, focused inputs) `paintAtomic` isn't
|
|
enough — wiping and rebuilding still destroys the state even if the
|
|
flash is hidden. The keyed pattern keeps a `Map<id, {el, fingerprint}>`
|
|
where the fingerprint is `JSON.stringify({...visible fields...})`.
|
|
On each render: cache-hit rows are reused verbatim (preserving
|
|
textarea draft, checkbox state, and event listeners); only cache-miss
|
|
rows are rebuilt and inserted. Used for: containers (`containerRowCache`),
|
|
rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus and meta-input
|
|
checkboxes use a lighter snapshot-then-restore pattern (snapshot
|
|
before `replaceChildren`, restore after) since they're single
|
|
values rather than per-row caches.
|
|
|
|
**`<details>` open-state preservation:** any collapsible element
|
|
tagged with `data-restore-key="<stable-key>"` survives the
|
|
refresh. `snapshotOpenDetails()` walks managed sections before
|
|
render, `restoreOpenDetails()` re-applies after. Long-content
|
|
drill-ins (file previews, diffs, journald logs) now open in the
|
|
**side panel** (see below) rather than expanding inline. The
|
|
dashboard's own answered-questions history list was the only
|
|
`<details data-restore-key>` consumer and is gone (hyperhive#3721)
|
|
— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have
|
|
nothing to restore, kept in place for the next collapsible section
|
|
that needs it rather than ripped out along with their one caller.
|
|
|
|
**Side panel (dashboard):** long content opens in a drawer that
|
|
swipes in from the right — a singleton `#side-panel` with a
|
|
titled header, a close button, and a scrollable body. Closes on
|
|
the button, a backdrop click, or `Escape`. `Panel.open(title,
|
|
node)` swaps the body; the JS builders for file previews,
|
|
approval diffs, and journald logs all render into it. **The
|
|
drawer width is drag-to-resize**: a thin 6px hit-strip on
|
|
the left edge captures pointer events, resizes the drawer in
|
|
real-time (pointer capture keeps dragging even if the cursor
|
|
outpaces the handle), and persists the chosen width to
|
|
`localStorage` (key `hyperhive:side-panel-width`) so it
|
|
survives page reload. Width is clamped to CSS `min-width: 320px`
|
|
/ `max-width: 96vw`; the viewport-resize handler re-clamps
|
|
persisted values after a window shrink. File
|
|
previews are type-aware:
|
|
|
|
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
|
|
tabbed view: `rendered` (default) is the vendored `marked`
|
|
bundle (`GET /static/marked.js`), `plain` is the raw source.
|
|
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
|
|
`rendered` shows the image via an `<img>` `data:` URI (the
|
|
browser's secure static mode, so an untrusted SVG can't run
|
|
scripts), `source` shows the raw markup.
|
|
- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` /
|
|
`.bmp` / `.ico` / `.avif`) — render as an `<img>` pointed at
|
|
`/api/state-file`, which serves them as binary with their
|
|
real content-type (text files stay UTF-8-lossy `text/plain`).
|
|
- **Everything else** — raw text in a `<pre>`.
|
|
|
|
## Listener bind
|
|
|
|
Both bind their TCP listener with `SO_REUSEADDR` via
|
|
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse`
|
|
(exponential backoff capped at 2s) so an nspawn restart that races
|
|
the previous process's socket release resolves itself, but the two
|
|
binaries differ on the attempt budget. The dashboard's
|
|
`bind_with_retry` (`hive-c0re/src/dashboard/mod.rs`) has **no
|
|
attempt cap** — retrying forever is deliberate, since genuine port
|
|
collisions are preflighted host-side (`lifecycle::{spawn,rebuild}`
|
|
refuses with a clear error, surfaced on the dashboard as a banner),
|
|
so at this layer a persistent `AddrInUse` always reflects a
|
|
recoverable stale socket. Its first 12 attempts log at WARN; after
|
|
that the level drops to INFO so a long-held stale socket doesn't
|
|
flood the journal. The per-agent UI's `bind_with_retry`
|
|
(`hive-agent/src/web_ui/mod.rs`) instead gives up after
|
|
`MAX_BIND_ATTEMPTS` (12) and returns the `AddrInUse` error rather
|
|
than looping forever.
|
|
|
|
The per-agent UI optionally binds a `UnixListener` instead of
|
|
TCP when `HIVE_WEB_SOCKET` is set — the unix-socket transition
|
|
mechanics (per-agent `/run/hive-agent/<name>/` bind-mount,
|
|
`.bound` marker filtering, `agent-sockets.json` consumer on the
|
|
gateway side) live in [`docs/networking/gateway.md::Per-agent unix-socket
|
|
upstream`](../networking/gateway.md). The env var is opt-in per agent so the
|
|
two modes coexist while sub-agents transition.
|
|
|
|
## Per-agent relative paths
|
|
|
|
The per-agent UI uses **document-relative paths everywhere** for
|
|
assets, API calls, form actions, and the screen WebSocket. Bare
|
|
references like `static/main.js`, `api/state`, `events/stream`,
|
|
`screen/ws` resolve against `document.baseURI` — the page's URL
|
|
without its last path segment.
|
|
|
|
That makes the page work under any prefix the agent ends up mounted
|
|
at without rebuilding the dist. The cases that matter:
|
|
|
|
| served at | `api/state` resolves to |
|
|
| ------------------------------------------------ | ----------------------- |
|
|
| `/` (own port, today's shape) | `/api/state` |
|
|
| `/agent/iris/` (gateway-prefixed) | `/agent/iris/api/state` |
|
|
| `/agent/iris/stats` (subpage, no trailing slash) | `/agent/iris/api/state` |
|
|
|
|
The gateway upstream config strips the prefix before forwarding to
|
|
the per-agent server, so the agent's Rust routes (`api/state`,
|
|
`events/stream`, `screen/ws`, `login/start`, …) keep their absolute
|
|
paths server-side. Only the browser-facing URLs are gated on the
|
|
mount prefix.
|
|
|
|
Subpages (`stats`, `screen`) are served without a trailing slash so
|
|
the relative-path resolution stays correct: `static/stats.js` from
|
|
`/stats` becomes `/static/stats.js` (last segment `stats` gets
|
|
replaced), not `/stats/static/stats.js`. Adding a trailing slash to
|
|
those routes would break the resolution; either keep them
|
|
slash-less or use `<base href>` injection at serve time.
|