First step of splitting the 4.3k-line tabs.js monolith into per-tab
modules. The live agent roster (containersState) is the one piece of
genuinely cross-domain state — read by the container tree, the
capabilities + tool-group matrices, the schedule target-chips, the
container-load poll, the selection bar and the operator inbox. Move it
(and its snapshot-sync) into a new dashboard/state.js that each domain
module imports, so the upcoming per-tab extractions have a single source
of truth to depend on instead of a shared IIFE closure.
Minimal by design — only the roster moves; the container row cache and
the apply-handlers (which also re-render) stay with the container domain.
Behaviour-preserving: the imported Map is mutated in place exactly as
before. esbuild inlines state.js into the existing tabs.js bundle, so the
static output is unchanged. Build green.
The dashboard tabbar was the styling outlier: a folder-tab metaphor
(top-rounded corners, transparent side borders overlapping the strip
border, and a box-shadow lift on the active tab) while every standalone
page's sub-tabs use the flat shared .hive-tab pill. Restyle .tabbar .tab
to the same pill family — drop the lift shadow, folder border and
top-only radius; active tab now uses the filled var(--border) background
like .hive-tab--active.
Kept a touch more weight than the bare sub-tab (larger 0.92em font, bold
labels, count pills) since this is the dashboard's primary nav, per the
operator's 'less fancy but not too minimal' steer. CSS-only, no markup
or behaviour change.
The standalone pages (/flow, /logs, /stats, /settings) all share the same
sticky back-link header, but its CSS lived in the dashboard's common.css
under the misnomer `.logs-*` (the comment even noted it was used by more
than logs). Promote it to a first-class shared component.
- new @hive/shared/chrome.css with neutral `.page-header` / `.page-back` /
`.page-title` (cohering with the `.page-content` gutter wrapper);
exported from the shared package and @imported into common.css next to
base/terminal/tabs.css.
- rename the four pages' header markup `.logs-*` → `.page-*`.
- drop the old `.logs-*` chrome block from common.css; refresh the stale
doc comments in settings.css / stats.css / dashboard.css.
CSS-only, behaviour/visual-neutral (same rules, new names + home). The
dashboard keeps its own richer sticky `.dashboard-chrome`; unifying the
per-agent UI's `.stats-nav` chrome is a larger follow-up, deferred.
Part of #1464 step 3.
Follow-up from #1532. Previously `body.dashboard-shell` / `body.home-shell`
carried the 1.5em horizontal gutter, and full-width chrome broke out of it
with negative margins (`.dashboard-chrome { margin: 0 -1.5em }`, the
`#server-warnings { margin: 0 -1.5em }` override).
Invert it: `<body>` is now full-bleed, and the padded page content lives in
a single inner `.page-content` wrapper that carries the gutter. The
server-warnings banner, the sticky tab strip, and the footer then span the
full width for free — so the `-1.5em` breakout hacks are gone.
- `.page-content { padding: 0 1.5em }` is a shared primitive in common.css
(the dashboard + H0M3 both opt in by wrapping their content; FL0W / L0GS /
ST4TS / S3TT1NGS stay full-bleed with their own `.<page>-main` padding).
- dashboard.html / index.html wrap their content in `.page-content`.
- dashboard.css / home.css: drop the body horizontal gutter (keep
`padding-bottom` for foot breathing room); `.dashboard-chrome` margin
`0 -1.5em 1em` → `0 0 1em`.
- common.css: drop the `#server-warnings` breakout override.
Behaviour/visual-neutral by intent (content gutter unchanged; chrome +
banner already rendered full-width via the old breakout). Worth a gui
screenshot-diff to confirm — esp. the now-full-width footer divider.
Last fold-in of #1464 step 2.
Extracts the hive-wide turn-stats rollup out of the dashboard tab strip
into a standalone /stats.html page, reached from the H0M3 hub — same
minimal-chrome pattern as /flow.html and /logs.html. The dashboard tab
strip is now purely operational.
- new stats.{html,css,js}; stats.js holds the moved render JS and
fetches /api/stats-hive on load + window change.
- migrate the window selector (#hive-stats-windows) from a bespoke
data-w/.active toggle to the shared createTabStrip — now hash-routed
(#1h / #24h / …) and deep-linkable, matching the per-agent /stats page.
- drop the ST4TS tab + pane from dashboard.html and the hive-stats render
block + the stats->refreshHiveStats lazy-load from tabs.js.
- move the shared .hive-stats-table to common.css (the dashboard SYST3M >
C0NT41N3R L04D table still uses it); the ST4TS-only window/chip/bar
styles go to stats.css.
- add a Stats tile to the H0M3 hub (and drop the now-stale "stats" from
the Dashboard tile desc); wire build.mjs + the nix/frontend.nix manifest.
Second slice of #1464 step 2; follows the /settings.html extraction.
Extracts the operator-local preferences (browser-notification toggle)
out of the dashboard tab strip into a standalone /settings.html page,
reached from the H0M3 hub — same minimal-chrome pattern as /flow.html
and /logs.html. The dashboard tab strip is now purely operational.
- new settings.{html,css,js}; settings.js binds NOTIF + renders the
shared server-warnings banner (matching the other standalone pages).
- drop the S3TT1NGS tab + pane from dashboard.html; the NOTIF.bind()
toggle wiring moves to settings.js. The dashboard keeps NOTIF.show()
(approvals / questions) — it reads the same browser permission +
localStorage mute flag the settings page sets, so firing still works.
- move .notif-row / .btn-notif from dashboard.css to settings.css.
- add a Settings tile to the H0M3 hub; wire the new entries into
build.mjs and update the nix/frontend.nix asset manifest comment.
First slice of #1464 step 2; the ST4TS page extraction follows separately.
The dashboard's tab strip now runs on the shared createTabStrip
(@hive/shared/tabs.js) — the third and final consumer of #1464 step 1
(after logs + the agent window selector).
- activateTab is reduced to the per-tab side-effects only
(body.dataset.activeTab, the selection-bar gate, the lazy-loads);
createTabStrip owns the active tab/pane toggle + aria-selected + hash
routing, calling activateTab via onShow. Panes switch via the `hidden`
attribute (was a `.tab-pane-active` class) — the markup carries `hidden`
so there's no flash before the script runs.
- the responsive overflow menu is DROPPED per the design call (move panes
to their own pages rather than hide them in a dropdown). Removes ~150
lines: updateTabbarOverflow, closeOverflowMenu, the click/keydown
handlers, the ResizeObserver + MutationObserver, and syncTabFromHash.
S3TT1NGS is a regular strip tab now (no data-overflow).
- CSS: .tab.active becomes .tab.hive-tab--active; pane visibility is now
.tab-pane[hidden] { display: none }; the overflow dropdown styles are
gone. Count pills (setTabCount/refreshTabCounts) are unchanged.
Behaviour-preserving except the deliberate overflow removal; aria-selected
is now standardised on the tabs.
Replaces the agent stats page's bespoke window-tab click handler with the
shared createTabStrip (@hive/shared/tabs.js). The selector is now
hash-routed — #1h / #24h / … become deep-linkable and survive
back/forward navigation.
- window buttons use data-tab (was data-w); the strip resolves them by
that convention and toggles .hive-tab--active + aria-selected.
- onShow(w) sets currentWindow + reloads stats; the strip's initial show
fires it once, so the explicit loadStats() in DOMContentLoaded is
dropped (avoids a double fetch).
- the selector keeps its distinct pill look — agent.css now targets
.window-tabs button.hive-tab--active (was .active), not the shared
.hive-tab text-tab base. It's a pure data-selector with no panes;
createTabStrip skips the absent [data-tab-pane] gracefully.
Behaviour-preserving; aria-selected is now standardised on the buttons.
Removes the standalone P33RS tab + pane. Peer hives now render as a
headline section at the bottom of the SW4RM pane; the swarm/hive name
moves off the cramped strip above the tabbar into a headline at the top
of SW4RM. Both are quiet when nothing's configured:
- swarm/hive identity → `#swarm-identity` h2 in the SW4RM pane (hidden
until hive_name/swarm_name are set), replacing the `#hive-identity`
banner-thin line above the tabbar.
- peer hives → `#peers-block` headline + `#peers-section` cards in the
SW4RM pane. When no peers are federated the headline block is hidden
entirely and a grey "no peer hives configured" note shows in its place.
- drops `peers` from the tab list + the per-refresh peers-tab gating;
renderPeerHives now toggles its own headline.
No backend change — still reads `state.peer_hives` + `hive_name` /
`swarm_name`. Build green.
Adds @hive/shared/tabs.js — createTabStrip(tabbar, {defaultId, onShow}):
a hash-routed tab strip that resolves controls by `[data-tab="<id>"]`
inside the passed container and panels by `[data-tab-pane="<id>"]`,
toggling `.hive-tab--active` + aria-selected + the panel's `hidden`, and
firing onShow(id) for per-tab side-effects. Always hash-routed (deep-
linkable + back/forward), no element-resolver callbacks (convention over
config), per the #1464 design review.
Wires it up: @hive/shared exports `./tabs.js` + `./tabs.css`, and
common.css @imports tabs.css (component structure, not a swap target, so
inlining is fine — unlike theme.css).
Migrates the logs sub-tabs as the first consumer: markup uses
`.hive-tab`/`data-tab`/`data-tab-pane`, logs.css drops the duplicated
base styles (keeps only its `flex:1` layout delta), and logs.js swaps its
activeTab/showTab/hashchange for createTabStrip (onShow lazy-loads the
SYSTEM tab). Behaviour-preserving. aria-selected is now standardised.
First piece of the generic tabs component: @hive/shared/tabs.css carries
the base .hive-tabbar / .hive-tab / .hive-tab--active styles (lifted from
the logs sub-tab pattern, the most generic of the three tab UIs). Colours
use the semantic theme vars so it inherits theme-swap safety.
Not yet consumed — the createTabStrip JS helper + the per-call-site
migrations (logs, agent window-tabs, dashboard tabbar) follow. Count
pills + the dashboard's responsive overflow menu will layer on top in
the per-page CSS rather than the shared base.
The banner is prepended as a direct child of <body>, so on pages that
pad the body gutter (the dashboard + H0M3 use a 1.5em horizontal gutter)
it inherited 1.5em gaps left and right. Break out of that padding with
negative margins matching the gutter — the same trick .dashboard-chrome
uses — scoped to the dashboard + home shells. FL0W + L0GS are full-bleed
already (no body padding), so they're unchanged.
Split the palette into two standalone stylesheets:
- colors.css — the 16 base16 slots (--base00..--base0F). This is the
entire theme swap contract; a generator (e.g. a stylix base16 scheme,
which is natively base00-base0F) replaces only this file.
- theme.css — the semantic layer (--bg, --purple, …) derived from the
base16 slots via var()/color-mix. Never changes on a swap.
Every page links colors.css then theme.css; theme.css does NOT @import
colors.css (that would re-bake the slots into it) — they're separate
dist outputs so a swap touches just colors.css. Pixel-identical refactor:
base16 defaults are Catppuccin Mocha and the three off-slot vars
(--crust, --muted, --subtext0) derive via color-mix reproducing their
exact prior hexes.
Wires colors.css through both build.mjs CSS entry lists, the
@hive/shared exports map, and all 7 page templates. css-vars.md + the
frontend.nix output-list comment updated.
Replace ~92 hardcoded Catppuccin rgba literals across the dashboard,
agent, and shared terminal stylesheets with color-mix() over the theme
:root vars, so a stylix-generated theme.css recolours the whole UI, not
just the elements that already referenced the vars. Pixel-identical
under the default palette (same RGB + alpha; color-mix(in srgb, C N%,
transparent) == rgba(C, N/100)).
Also fixes four var(--mauve, #cba6f7) usages: --mauve is undefined (the
var is --purple), so they were silently falling back to the hardcoded
hex and would not have recoloured on a theme swap. The bare crust hex in
terminal.css and the checkerboard-gradient hexes in common.css are
var-derived too.
Pure black/white drop-shadow/frost scrims are left as literals (not
theme colours). Two off-palette surface tints (dim-purple schedule rows,
warm-amber inline-button hover) are also left, with a comment, pending a
dedicated named var. Correct-var fallbacks like var(--red, #f38ba8) are
left as-is since they already recolour via the var.
Per operator request: instead of a disk-specific alert, surface a generic
server-warnings banner at the very top of every page, so new system
warnings can be added backend-side with no frontend change.
- hive-c0re `host_stats`: `server_warnings() -> Vec<ServerWarning>`
(`{ kind, level, message }`). The threshold logic lives server-side; the
host disk-pressure check (a `statvfs` probe of `/nix`: ≥85% used → warn,
≥95% → crit) is the first and only producer today. No new deps (libc).
- `/api/state` carries `server_warnings` (replaces the disk-specific
field). Empty when all clear.
- frontend: `renderServerWarnings` / `initServerWarnings` in `common.js`
inject a sticky top-of-<body> banner and render the list, coloured by
`level`. Wired on every page — dashboard (live, via refreshState),
FL0W, L0GS, H0M3. No per-warning frontend code; adding a warning kind
is a pure backend change.
cargo check/clippy/fmt + npm run build green. Closes#1518.
Swarm-level companion to the per-agent favorite-tools doughnut (#1433).
Aggregates each agent's bash_commands(ts, head) table (written by
hive-bash-mcp) across the whole hive and surfaces the top-10 most-run
command heads on the dashboard ST4TS tab, alongside the existing model
mix.
- hive_stats.rs: AgentAgg gains a `bash` head→count map, filled by a new
guarded `read_bash_heads()` that reuses read_agent's read-only
connection. A missing `bash_commands` table (capture hasn't run for
that agent) or any read error yields an empty map — isolated from
read_agent's error path so it never drops an agent from the rollup.
HiveStats gains `bash_mix: Vec<KeyCount>` (busiest-first, top 10).
Unit tests cover the per-head tally + window cutoff and the
missing-table degrade-to-empty path (in-memory sqlite).
- dashboard: a "favorite tools (bash commands across the swarm)" CSS-bar
list on the ST4TS pane, mirroring the model-mix bars. Header + list
stay hidden until bash_mix has data, so a fresh hive shows no empty
block. (Dashboard ships no chart lib — bars, not a doughnut.)
- docs: dashboard.md ST4TS section documents the new rollup.
Closes#1449. Inert until the hive-bash-mcp capture (#1448, merged) has
recorded data across agents.
Two small follow-ups after the modelPrices PR (#1442):
- nix/modules/hive-c0re.nix: the `modelPrices` option `example` still
showed the old 5-minute sonnet `cache_write = 3.75`; the default is now
the 1-hour TTL `6.0`. Bump the example to match so it doesn't mislead.
- frontend/packages/agent/agent.css: remove the dead `.stats-empty-note`
selector (no element uses it — the stats empty state paints on canvas).
Follow-up to the route swap (#1501): now that every separate page is
reachable from the H0M3 hub, the dashboard tab strip should hold only
real in-page tabs, not the "→" links out to other pages (mara: "remove
links to the pages from dashboard - only actual tabs stay here").
- dashboard.html: remove the `tab-matrix`, `tab-flow`, and `tab-logs`
`.tab-link` entries (and the stale `tab-count-flow` pill slot — the
operator inbox moved to Y3R C4LL). The dashboard strip is now SW4RM /
Y3R C4LL / SYST3M / P3RM1SS10NS / SCH3DUL3S / P33RS / ST4TS / S3TT1NGS.
- tabs.js: drop the now-dead `tab-matrix` matrix_gui_enabled gate (the
H0M3 Matrix tile is gated by home.js instead) and the obsolete
`#tab-count-flow` comment. Kept the contextual deep-links into the log
viewer (an agent's logs, a build entry's log) — those are functional
content links, not navigation chrome.
- flow.css: remove the dead `.dashboard-chrome.flow-chrome` +
`.tabbar .tab.active.tab-link` rules — the flow page uses a slim
back-link header, not the dashboard tabbar, and `.tab-link` no longer
exists anywhere.
npm run build green (flow.css 1.8kb). No residual references.
Step 2 of the nav restructure: make the H0M3 menu hub the landing page
at / and move the dashboard SPA to /dashboard.html, with every surface
linking back to the hub.
Mechanism (frontend-only, no host-side change — confirmed against the
ServeDir route table): the dashboard's ServeDir fallback serves
index.html at / via append_index_html_on_directories, and there is no
SPA path-routing catch-all to break. The dashboard SPA is served as the
plain file dashboard.html so it never shadows the exact-match
/dashboard/stream + /dashboard/history SSE routes registered before the
fallback.
- Swap the HTML entry files: the H0M3 page becomes index.html (loads
home.js → served at /), and the dashboard SPA becomes dashboard.html
(loads tabs.js → served at /dashboard.html). build.mjs copies the new
set; JS bundle names are unchanged (referenced by absolute /static/
paths, independent of the HTML filename).
- H0M3 Dashboard tile now points at /dashboard.html.
- The dashboard gains a "← home" back-link in its chrome; flow.html and
logs.html relabel their back-link from "← dashboard" to "← home"
(href stays / — which is the hub now). Pages link to the hub, not to
each other.
- Agent page (app.js + stats.js): the "↑ dashboard" link now targets
/dashboard.html. The API base (rebuild / answer-question /
mark-all-read POSTs) stays the origin root, unchanged.
- Comment-only: tabs.js / flow.js / common.js references to the
dashboard's old index.html filename updated to dashboard.html.
Note for review: git renders the file swap as a deleted home.html + an
added dashboard.html + a heavily-modified index.html, because index.html
exists on both sides with swapped content. It's a content swap, not a
rewrite — the built dist/ is verified (index.html→home.js, dashboard.html
→tabs.js).
Deferred to a follow-up: removing the FL0W / L0GS / M4TR1X "→" page-links
from the dashboard tab strip (touches tabs.js gating/overflow), and the
shared reusable chrome component (a later step).
The operator inbox moved to the dashboard's Y3R C4LL tab in #1469
(◆ 1NB0X ◆ section, with per-message + mark-all read). FL0W goes back
to being the pure event firehose, so remove its now-redundant inbox UI:
- flow.js: drop the operatorInbox store, inboxAppendFromEvent,
buildInboxListNode, renderInbox, the inbox-pill click wiring, and the
onAnyEvent hook that fed them. The side panel was only used for the
inbox flyout on this page, so drop Panel.bind() + the Panel import too.
- flow.html: remove the inbox pill, the offscreen inbox-section div, and
the side-panel markup.
- flow.css: remove the .flow-pill* and .flow-inbox-headless rules.
- home.html: Flow tile desc → "live all-agents message firehose".
- docs/web-ui/dashboard.md: drop the FL0W 0PER4T0R 1NB0X section, point
at Y3R C4LL, and fix the count-pill + /op-send descriptions.
The agent filter, sent→delivered collapse, compose box, and OS
notifications on operator-bound traffic are unaffected.
Agents that `send(to: "operator")` were easy to miss — they only
surfaced on the FL0W firehose with no read-state (#1469). Surface them
on the Y3R C4LL ("things waiting on you") tab as a proper inbox.
Backend:
- broker: `unread_for_recipient(recipient, limit)` — unacked messages
for a recipient, newest-first. Mirrors `mark_all_read`'s filter
EXACTLY (`recipient = ?1 AND acked_at IS NULL`, no `delivered_at`
condition) so everything listed is exactly what mark-read clears —
operator rows never get `delivered_at` set (no agent-socket recv).
- dashboard: `GET /api/operator-inbox` → `{ messages: [...] }` (id,
from, body, at, in_reply_to, validated file_refs). Mark-read reuses
the existing `POST /api/agent/operator/mark-all-read` (the route
format-validates the name; "operator" passes; `mark_all_read`
already acks `to="operator"` rows).
Frontend (Y3R C4LL):
- New ◆ 1NB0X ◆ section listing unread messages (sender · time · body,
path-linkified) + a "✓ mark all read" button.
- Cold-loaded on page load + on tab activation; appended live from the
broker `sent` stream (deduped on row id); cleared on mark-all-read.
- Unread count folds into the Y3R C4LL tab pill + the browser-title
`(N)` prefix, so messages are visible from any tab.
Removing the now-redundant FL0W operator-inbox UI is a clean follow-up
(deferred to avoid a flow.js conflict with the in-flight #1473).
Backend (broker + route) is host-side — @damocles to review per plan.
Closes#1469.
Adds an agent filter to the FL0W header (#1473): a select listing the
live agents narrows the timeline to messages involving the chosen agent
(matched on `from` OR `to`). Each message row now carries
`data-from`/`data-to`; non-matching rows get `.flow-hidden`. New rows
pick up the active filter at render time; changing the filter re-scans
existing rows. The selection persists in localStorage so a reload or
tab-switch keeps the view. The dropdown is populated from the live
container list (and stays current on container add/remove); a saved
selection survives even if that agent isn't currently listed.
Pure frontend; composes with the sent+delivered collapse (the surviving
collapsed row keeps its `data-from`/`data-to`). Closes#1473.
When a message directly wakes its recipient, the broker emits `sent`
then `delivered` for the same row id ~instantly, rendering two
near-identical lines on the FL0W timeline. Track each recent `sent`
row and, when its `delivered` lands within COLLAPSE_SECS (3s), upgrade
that row in place (arrow → green ✓, title "sent + delivered") instead
of adding a second line. A delivery that arrives later (recipient was
busy) still renders as its own row, so genuine delivery latency stays
visible.
`Sent` and `Delivered` share the same broker row id
(dashboard_events.rs), so matching is exact. The recentSent map is
bounded to 256 entries. Reply threading is unaffected (msgRowMap still
resolves the surviving row).
Closes#1472.
Per mara on #1464: keep l33t only in page headlines, not link labels
(we were overdoing it). H0M3's tiles now read Dashboard / Flow / Logs /
Matrix; the page banner headline stays H0M3.
First step of the dashboard-tab consolidation (#1464): a static menu
page linking to every top-level surface, so the tab strip can later
shed its `→` page-links and the dashboard can graduate Stats/Settings
to their own pages.
- dashboard/src/home.{html,css,js}: a responsive grid of link tiles
(D4SHB04RD, FL0W, L0GS, M4TR1X). Pure portal — no tabbar/SSE. Colours
from the shared theme.css, base typography from common.css. The
Matrix tile is hidden until home.js confirms `matrix_gui_enabled`
(same gating as the dashboard's M4TR1X tab); home.js also fills the
swarm/hive identity line.
- build.mjs: emit home.{html,css,js}.
Served at `/home.html` for now (additive — reachable via the existing
ServeDir, links to surfaces at their current routes). Promoting it to
`/` (and relocating the dashboard to `/dashboard` + wiring `← home`
back-links) is the next step — a route swap that touches hive-c0re's
static router, coordinating with damocles. Deliberately decoupled so
this page ships standalone without conflicting with the in-flight
tabs.js change (#1449/#1451).
Part of #1464.
Phase 1 of stylix integration (#1457): extract the Catppuccin palette
into a dedicated, separately-linked stylesheet so a theme swap can
replace just that file without rebuilding the rest of the frontend.
- shared/src/theme.css (new): the `:root` palette, moved out of
base.css (which now holds only the body typography it references).
- shared/package.json: export `./theme.css`.
- dashboard/src/theme.css + agent/src/theme.css (new): one-line
re-exports of @hive/shared/theme.css so each package's esbuild emits
its own standalone `dist/static/theme.css` (palette is NOT inlined
into the page bundles).
- both build.mjs: add theme.css to the CSS build list.
- every page (dashboard index/flow/logs, agent index/stats/screen):
link `theme.css` first, ahead of the page CSS, so the `:root` vars
resolve for everything.
- docs/web-ui/css-vars.md: document the split + the no-rebuild rationale.
Behaviour-neutral — same colours, just relocated. Verified both
`npm run build` outputs: theme.css emits standalone (383b) with the
palette; no `--*` palette defs duplicated into common.css/agent.css.
Phase 2 (nix derivation that swaps theme.css from stylix colours) is a
follow-up; touches nix/frontend.nix, coordinating with damocles.
Part of #1457.
Four selectors defined in dashboard.css but referenced nowhere in the
repo (verified against all html/js/rs, including dynamically-built class
names):
- .role-m1nd / .role-ag3nt — old per-role colouring; only `.role-pending`
is still applied (`'role role-pending'`), and there's no `'role-' +`
builder, so the m1nd/ag3nt variants are unreachable.
- .journal-body — the journald viewer is now a link to the dedicated
logs.html page (`menuLink('journal logs →', …)`), not an inline panel.
- .talkform (+ its select/input/placeholder/focus rules) — old operator
T4LK form styling, no longer emitted.
Part of #1450. Pure dead-code removal, no behaviour change; dashboard
bundle still builds (dashboard.css 36.9kb → 36.1kb).
The surfacing half of the per-agent favorite-tools stat. Reads a
`bash_commands(ts INTEGER NOT NULL, head TEXT NOT NULL)` table from the
per-agent turn-stats.sqlite — one row per bash task, written by
hive-bash-mcp (the capture half, landing separately) — and rolls up the
top-10 command heads for a doughnut, mirroring the existing
tool_breakdown.
- stats.rs: new `Snapshot.bash_breakdown` + `read_bash_breakdown()`.
The read is guarded: a missing `bash_commands` table (capture hasn't
shipped / agent hasn't run a bash task) maps to an empty list, never
an error — the snapshot degrades gracefully. Unit tests cover both
the absent-table and populated cases (incl. window cutoff + ordering).
- frontend: a "favorite tools (bash)" doughnut card on the agent /stats
page, kept hidden until bash_breakdown has data so it never shows a
permanently-empty doughnut while capture is pending.
Part of #1433 (does not close it — pairs with the hive-bash-mcp capture
half). Inert until the capture lands; merge order with it is irrelevant.
C0NT41N3R L04D on the SYST3M tab + GET /api/container-resources.
Backend (hive-c0re/src/container_stats.rs): reads cgroup v2 cpu.stat +
memory.{current,peak,max} for each running agent machine
(machine-h\x2d<name>.scope under machine.slice), read-only/world-
readable so no hive-priv. CPU is a two-sample (~200ms) host-normalised
percentage; one shared sleep covers all agents. Skips agents whose
scope dir is absent (= not running). Network omitted: agents share the
host netns, no per-container counter.
Frontend: a polled C0NT41N3R L04D table on SYST3M (agent / cpu / mem /
peak / limit with meter bars), reusing the ST4TS table style. Polls
/api/container-resources every 5s only while the tab is active.
Backend reviewed-in-principle by damocles (path escaping + cpu delta
math); ping for the on-host sign-off.
Builds the dashboard UI on the /api/stats-hive endpoint: a new ST4TS
tab showing swarm totals (active agents, turns, tokens, labelled est
cost), a busiest-first per-agent table, and a model-mix bar list.
Plain tables/CSS bars — no chart lib in the dashboard bundle; per-agent
trend charts stay on each agent's own /stats page. Fetched on tab
activation + window change (pull-only, no SSE).
Also adds conn.busy_timeout(500ms) in hive_stats read_agent per review:
turn_stats is rollback-journal, so a read landing mid-INSERT would hit
SQLITE_BUSY and silently drop that active agent — wait the write out.
First slice of #1424 (per-agent /stats enrichments):
- Backend: add per-bucket result_counts to the stats Snapshot (mirrors
model_counts), so result outcomes can be charted over time, not just
as a window total.
- Frontend: two new summary chips — cache hit-rate % (cached input vs
all input-side tokens) and avg tokens/turn — both derived from the
existing per-bucket token sums. Plus a stacked result-trend chart so
error / rate-limit / compaction spikes are visible across the window.
Hive-wide aggregate, cost estimate, and container resource load land in
follow-up PRs.
Reminder due-at labels ("in 3m 45s") and schedule next-fire cells
were computed once at render time and never updated. For short-horizon
reminders the stale count was visually misleading.
Both renderers now stamp data-due-at=<unix> on their respective
elements (.reminder-due / .sched-due). A shared 1s setInterval
ticker walks all live elements and rewrites textContent using
fmtDuration / fmtAgo — same zero-re-render pattern as the
existing question TTL chip ticker.
web_ui.rs: add available_models() helper that reads HIVE_AVAILABLE_MODELS
(comma-separated, injected by services.hyperhive.availableModels nix option).
Falls back to ["haiku", "sonnet", "opus"] when absent or empty. Field added
to StateSnapshot so the frontend receives the list on cold-load.
app.js: replace hardcoded MODEL_ALIASES array with availableModels module var.
Seeded from state.available_models before setHeader/populateOverflowMenu on
first /api/state load. Well-known aliases (haiku/sonnet/opus) still get their
descriptive labels; operator-declared custom model names show the name itself.
Implements the frontend + web_ui.rs side of issue #1359 (nix option shipped
separately in PR #1360 by atlas).
renderMetaInputs snapshots the set of checked data-meta-input values before
calling root.replaceChildren(), then restores them after rebuilding the list.
Without this, a MetaInputsChanged SSE event (e.g. fired when a concurrent
meta-update completes) would silently wipe any checkboxes the operator had
ticked but not yet submitted — forcing them to re-select their inputs.
Approval cards render their age label ("requested N ago") at
render time from a cold /api/state or an approval_added /
approval_resolved SSE event. An approval sitting pending for
an hour would show a stale "0s ago" unless one of those events
fired in the meantime.
Fix: stamp data-requested-at=<unix> on each .approval-ts span.
A 1s setInterval ticker reads all live chips, recomputes the
relative time via fmtAgo, and toggles .stale (amber highlight)
at exactly the 1-hour threshold — no longer dependent on
the next full re-render cycle to apply the amber colour.
Previously only the 'all' chip showed a count (all · N). The operator
had to click each chip to see how many questions were in each category.
Now every chip shows its own count:
all · 5 @operator · 2 @peer · 3 @agentname · 1
This lets the operator see the distribution at a glance and navigate
directly to whichever category has pending items without guessing.
Stamp data-rqe-enqueued / data-rqe-finished / data-rqe-state on queued
and terminal rqe-when spans. A 30s ticker updates them in place so
'queued 2m ago' and 'done 5m ago' labels advance as time passes.
Complement to the keyed rebuild-queue row cache: rows now persist
across snapshots, so without a ticker these static fmtAgo labels
would become arbitrarily stale. The running-entry elapsed ticker
(data-rqe-elapsed, 1s interval) already handled that state;
this fills the gap for queued and terminal states.
Apply the same fingerprint-cache pattern as the container row cache to
the rebuild-queue list. Maintain rebuildQueueRowCache (Map<id, {el,
fingerprint}>) so renderRebuildQueue can reuse <li> nodes whose state
hasn't changed across rebuild_queue_changed snapshots.
The rebuild queue emits a full snapshot on every mutation (a single
rebuild emits queued → running (+ step changes) → done/failed — each
transition is an event). Before this change every event caused a full
replaceChildren() wipe of the list. After this change only the row(s)
whose fingerprint changed get rebuilt; the rest survive intact.
The elapsed-time ticker (data-rqe-elapsed + 1s setInterval) already
updates running-entry timestamps in-place, so ticking elapsed seconds
don't require a re-render and are excluded from the fingerprint.
DOM order is reconciled via insertBefore with no wipe, same as the
container cache.
renderQuestions() was calling root.replaceChildren() on every
question_added / question_resolved SSE event, wiping all <li>
elements including any textarea the operator was typing into.
Add questionRowCache (Map<id, {el, fingerprint}>):
- questionRowFingerprint encodes the static fields that determine
the <li> DOM structure (asker, target, asked_at, deadline_at,
question text, options, multi)
- buildQuestionLi extracts the <li>-building code so it can be
called only when the fingerprint changes
- renderQuestions reuses cached <li> nodes for unchanged questions
and evicts entries for resolved/cancelled questions
Effect: when a new question arrives while the operator is typing a
reply, the existing <li> is reused — the textarea value, radio
selection, and checkbox state are all preserved.
The history <details> open state is also saved before and restored
after the replaceChildren call, so the answered-history panel does
not collapse when a live question event fires.
renderApprovals() called root.replaceChildren() on every
approval_added / approval_resolved SSE event, wiping the spawn
form's name input if the operator had started typing a new agent
name.
Save the current input value before the wipe and restore it into
the freshly-built input element. The form itself is still rebuilt
(no keyed-cache needed here — approval cards have no user inputs),
so all other behaviour is unchanged.
Adds a navigation entry in the per-agent ⋮ overflow menu that opens the
AGENT log tab pre-filtered to that container. The link uses the ?agent= URL
param introduced by the accompanying logs.js change — the operator lands
directly in the journal viewer without having to pick an agent from the
dropdown.
Placed between the rebuild action and the destructive destroy/purge block
so it's reachable but clearly separated from dangerous actions.
Adds ?agent=name and ?unit=svc URL params to pre-select the agent on
the AGENT tab (used by the per-agent overflow menu journal logs link).
Last-fetched timestamp chip shows how stale the output is.
Agent selection is NOT persisted to localStorage — URL params only.
Adds three one-click model shortcuts (haiku / sonnet / opus) at the
bottom of the agent overflow menu (⋯), below a separator and a small
'model' section label.
- The active model alias is highlighted with .active (purple border +
tint) so the operator can see at a glance which model is current.
Matching tolerates both short aliases (haiku) and full API names
(claude-3-5-haiku-20241022) via an endsWith check.
- Clicking the active model is a no-op (closes menu, no POST).
- Clicking a different alias calls postModel() and closes the menu.
- renderModelChip() keeps the picker buttons in sync on every
/api/state refresh without rebuilding the menu.
Before this change the operator had to type /model <name> in the
compose box. The overflow menu provides a discoverability path for
operators who don't remember slash commands.
On refreshState, the hive-identity title update (fired once when
hive_name/swarm_name are configured) now reads any existing (N) prefix
and preserves it, so the title doesn't briefly drop back to the bare
identity string before refreshTabCounts re-applies the prefix.
When approvals or questions are waiting, document.title is prefixed
with (N) so the operator can see activity in a background browser
tab without switching windows. Clears to the base title when zero.
Implemented in refreshTabCounts (1s tick): strips any existing
prefix before re-applying so the hive-identity title update (once
on state load) composes cleanly.
Stamp data-set-at on .status-age spans so the '(set N ago)' label
stays accurate as time passes. Previously the age was computed once
when the async dashboard-state fetch completed and never updated.
With the keyed container row cache, rows persist much longer between
rebuilds (no more full rebuild on every SSE event), making stale
status-age labels more noticeable. The 30s ticker fixes this without
requiring a full row rebuild or re-fetch.
Maintain a module-level containerRowCache (Map<name, {el, fingerprint}>)
that preserves <li> elements across renderContainers calls. Each row's
fingerprint encodes everything that affects its rendered output:
container running/login/update/reminder state, derived pending/opRunning
labels, tree position (depth, isLast, ancestorIsLast), selection, agent
question counts, and link-base context.
When the fingerprint is unchanged the existing DOM node is reused:
- no replaceChildren wipe for stable rows
- the async dashboard-state fetch (nav strip, ctx badge, status text)
is skipped — previously-fetched data stays in place
- DOM order is reconciled via insertBefore with zero layout work for
in-place nodes
Before this change every SSE event (container_changed, rebuild_queue_
changed, transient_set/cleared, question_added/resolved) caused a full
wipe + rebuild of the entire container list, triggering N concurrent
/api/dashboard-state fetches where N is the number of running agents.
After this change only the rows whose state actually changed are
rebuilt; the rest survive intact across re-renders.
If the operator had an agent:foo filter active and all of foo's questions
were resolved, foo's chip disappears from the filter row on the next
render — but the stored filter value is still agent:foo. The section
then shows "no questions match this filter" with no active chip visible,
leaving the operator confused.
Fix: compute the set of valid filter values (all, operator, peer, plus
one agent:<name> per current participant) before rendering. If the stored
value is not in the set, silently reset it to 'all'. Write directly to
localStorage rather than via setQuestionsFilter() to avoid a re-entrant
renderQuestions() call.
When a question_added or question_resolved SSE event fires, the handler
calls renderQuestions() directly — bypassing the snapshotOpenDetails /
restoreOpenDetails pair that refreshState wraps around it. This caused
the answered-history <details> (data-restore-key="q-history") to
collapse every time a question arrived or was resolved while the
operator had it open.
Fix: call snapshotOpenDetails() at the start of renderQuestions() and
restoreOpenDetails() at the end. The double-restore in the refreshState
path is harmless — re-opening an already-open <details> is idempotent,
and restoreOpenDetails is a no-op when the snapshot set is empty.