claude emits a started tick then a completed tick per plugin install;
previously each got its own row (loading... then done) instead of one
line updating in place. Same coalescing pattern already used for
thinking_tokens and the generic status tick.
Claude's generic `system/status` subtype carries no detail beyond the
bare label, and previously each tick got its own note row. During a
compaction pass (which emits a burst of these with no other signal
before the completed compact_boundary) this looked like a wall of
identical noise followed by silence, making a routine compaction look
stuck.
Collapse consecutive status ticks into one updating row, same pattern
already used for the thinking_tokens counter.
Both mdNode implementations (agent UI app.js, dashboard common.js)
assigned marked.parse() output straight to innerHTML with no
sanitizer. marked v5+ dropped its built-in sanitize option, and there
was no DOMPurify anywhere in frontend/, so markdown containing raw
HTML/script tags rendered live in the browser.
Both sinks receive untrusted input in practice: the agent UI's mdNode
renders recv tool_result bodies, assistant prose, and send/ask/answer
payloads sourced from peer agents and matrix-relayed messages (the
documented prompt-injection adversary); the dashboard's mdNode renders
agent-authored .md files served verbatim by GET /api/state-file
(the endpoint validates path, not content). Since the per-agent UI and
dashboard are same-origin behind the gateway with operator-authority
endpoints (approve/spawn/rebuild/destroy/answer-question), injected
script would run with the operator's session.
Fix: DOMPurify.sanitize() the marked.parse() output at both sinks
before assigning to innerHTML. Added dompurify as a dependency to
both the agent and dashboard npm workspaces, recomputed npmDepsHash
in nix/frontend.nix for the updated lockfile. Also corrected
docs/web-ui/shape.md, which claimed the markdown-rendering path was
XSS-safe by construction the same way the text-node-based linkify
path is — it isn't; it's safe because it's sanitized.
CSP hardening for the dashboard (no unsafe-inline) is a separate,
larger backend change (response headers in hive-c0re) and is left as
a fast-follow rather than folded into this fix.
xhigh and max are distinct reasoning-effort levels (see EFFORT_LEVELS
in events.rs); labelling xhigh as '(max)' implied they were the same
level, per issue #2258.
Empty arrays are truthy in JS so 'if (input.targets_add)' would emit
'+0 tgt' for an explicit []. Add .length guard so zero-element lists
are silently skipped — consistent with fmtArgsGeneric's [N] handling.
The MCP schema defaults both fields to null, making this theoretical,
but the guard is cleaner.
Scheduling tools and the two remaining request_* tools fell through to
fmtArgsGeneric. They already had appropriate icons (⏱️ / 📦) but the
arg display was multi-field verbose.
New fmtToolUse cases:
- request_init_config: 'request_init_config* iris'
- request_update_meta_inputs: 'request_update_meta_inputs* [nixpkgs, …]'
(or 'all' when inputs list is empty)
- list_schedules: 'list_schedules*()' (no args, explicit)
- cancel_schedule: 'cancel_schedule* #42 all' or '#42 [iris, dmatrix]'
- fire_schedule_now: 'fire_schedule_now* #42'
- edit_schedule: 'edit_schedule* #42 · body · interval · +2 tgt'
(lists which fields are being changed, not their values)
- request_schedule_prompt: 'request_schedule_prompt* → iris at 14:00Z +3600s'
Closes#2201
join_room and open_dm shared a fall-through case with
(fmtRoom(input.room) || fmtUser(input.user_id) || '?'). fmtRoom()
returns the string '?' when input.room is undefined — truthy — so
the fmtUser() fallback was never reached and open_dm always displayed
'open_dm* ?'.
Split into two separate cases: join_room reads input.room, open_dm
reads input.user_id. Both degrade to '?' via fmtRoom/fmtUser's own
null guard.
`mcp__hyperhive__remind` calls previously rendered as the generic
`fmtArgsGeneric` output — with a multi-field input that showed
`message: "..." · delay_seconds: 300`, burying the message after
a verbose field name.
New format: `remind* +5m "check on PR..."` (or `at HH:MMZ` for
absolute timestamps). The timing renders first so it's scannable
at a glance, followed by the first 60 chars of the message body.
Works for all three input shapes: delay_seconds, at_unix_timestamp,
and file_path-only (shows the path as the preview).
Several MCP tools appeared with the generic wrench icon (🔧) making
them hard to identify in the scrollback. fmtToolUse also lacked
specific formatters for some frequently-used tools, falling through
to fmtArgsGeneric.
Icons added:
- ack_until → ✅ (mark-as-read semantics)
- get_logs → 📜 (log viewer)
- get_host_journal → 📜 (journal reader)
fmtToolUse cases added:
- ack_until → "ack_until* ≤N" (message-id bound)
- get_logs → "get_logs* <agent> [NL]"
- get_host_journal → "get_host_journal* <container|unit> [/grep/] [NL]"
- restart/start/update → "restart* <name>" etc. (previously used
fmtArgsGeneric; now match kill's pattern)
Closes#2188.
compact_boundary events currently render as the generic "⚙ compact_boundary"
muted note. The event carries useful metadata — pre/post token counts,
duration and trigger — that are invisible to the operator.
With this change the row reads:
· ⚙ compact · manual · 772k→6k tokens · 101s
Fields rendered (all guarded — missing fields are silently omitted):
- trigger ("manual" or "auto")
- pre_tokens→post_tokens (formatted with k/M suffixes)
- duration_ms (ms or s)
Closes#2187.
Currently both system subtypes fall through to the generic muted
note renderer (⚙ <subtype>), giving the operator no insight into
what is happening.
plugin_install:
Render status explicitly — "loading…" on started, "✓ done" on
completed — so a slow plugin boot is visible in the scrollback
instead of two identical cryptic rows.
commands_changed:
Render the count of available slash commands in the summary row
and expand to the full list in a collapsible details block. The
list is most useful right after a fresh plugin_install so the
operator can see exactly which /commands are now on offer.
Both handlers sit immediately before the generic catch-all in the
system-subtype dispatch in renderStream, preserving the existing
fall-through for other subtypes (context_window_exceeded etc.).
Closes#2183.
fix(dashboard): update 'view queue' link to /builds.html
The build queue moved to its own /builds.html page. The queue-summary
'view queue →' link in tabs.js still pointed at /core.html, so clicking
it landed on the wrong page.
fix(broker): filter agent inbox to unread (acked_at IS NULL)
recent_for was returning all messages regardless of ack state, so the
agent inbox showed everything even after 'mark all read'. Now filters
to acked_at IS NULL — mirroring exactly what mark_all_read drains —
so the inbox empties on reload after the operator drains it.
When a tool call fails, claude wraps the result text in
<tool_use_error>...</tool_use_error> XML tags. The terminal was
displaying these raw, producing output like:
'<tool_use_error>File has not been read yet.</tool_use_error>'
Fix renderToolResult in app.js:
- Check c.is_error on the tool_result content block.
- Strip the <tool_use_error>...</tool_use_error> wrapper from the text.
- Render error results with a '✗' prefix under '.tool-result.error'
(flat, ≤120c) or '.tool-result-block.error' (<details>, longer text).
Add .live .tool-result.error { color: var(--red); } to terminal.css
so error results are visually distinct (red, same as turn-end-fail).
Update terminal-rendering.md row taxonomy to document the two new
error row classes.
Closes#2104.
Adds a Window::All option to the per-agent stats page and the hive-wide
rollup, selectable as a new 'all' tab on both.
- hive-ag3nt (per-agent, time-bucketed): All ranges from MIN(started_at)
to now (fallback to now on an empty table) with an adaptive bucket
width laddered by span — hourly <=2d, daily <=90d, weekly <=2y, 30-day
beyond — so the trend series stays bounded (~<=104 buckets) at any age.
- hive-c0re (swarm rollup, not time-bucketed): All sets from=0 so the
aggregate covers every recorded turn across all agents.
- frontend: an 'all' button on both the agent stats and dashboard hive
stats window selectors (createTabStrip + fetch already pass the window
string through, so no JS change needed).
cargo check passes on both crates; FE builds clean. Fixes#1919.
Per mara: the agent sub-pages should use the same back-link nav as the
dashboard's standalone pages, while the live terminal page stays as-is (no
tabs of each other, not an SPA).
Pull the shared `@hive/shared/chrome.css` into the agent bundle (via agent.css's
existing @import line; esbuild inlines it into the one dist/static/agent.css all
agent pages link), then:
- stats.html: replace the ASCII banner + bespoke `.stats-nav` with the shared
`page-header` (← live back-link + dashboard link + title). Keeps the #back-link
/ #dashboard-link / #title ids stats.js drives, and the time-window picker +
charts below are unchanged.
- screen.html: replace the bespoke `#toolbar` nav with the same `page-header`
(← agent back-link + title), keeping the fit / match / debug controls + the
status text in the bar.
The live terminal (index.html) is untouched. The now-unused `.banner` /
`.stats-nav` / `#toolbar` rules in agent.css are left for a follow-up prune to
keep this diff focused on the markup. Agent build clean.
Closes#1874.
Replaces the first-character-glyph + negative-text-indent trick (which let a
wide emoji or a leading disclosure caret knock the icon out of column) with a
genuine icon cell.
terminal.js: row() / details() / detailsDiff() take an optional `icon` that
goes in a fixed-width `.row-glyph` element (inline-block, 1.4em). Details
summaries wrap their text in a `.summary-text` span; the disclosure caret
moves to `.summary-text::before` so it leads the text, not the icon — keeping
the icon in the shared column. terminal.css carries the cell + caret rules.
app.js passes the per-tool emoji as `icon` for the flat tool-use row and every
expandable tool summary (Write/Edit/send/ask/answer/bash) plus the 💭 thinking
row, instead of string-prefixing it. A details `🖥️` now lines up under a flat
row's `🧠` regardless of emoji width. Doc: terminal-rendering.md layout
contract updated. Closes#1844.
claude streams a running 'estimated_tokens' counter as system/thinking_tokens
events — many per turn (thousands in a long turn). renderStream rendered each
as a '⚙ thinking_tokens' note, flooding the terminal scrollback. Coalesce
consecutive ticks into a single '🧠 thinking … ~N tokens' note row that
updates in place; reuse the row only while it's still the last one rendered
(nextElementSibling == null) so any other event starts a fresh one. Closes
#1818.
Two fixes from review:
- Liveness: /api/state isn't polled while online (only during login), so
hooking refreshBashTasks to it only populated on cold load. Bash tasks
start + finish asynchronously between turns, so add a light ~4s interval
to keep the tasks pill live; the /api/state-time call now just does the
first-paint populate. Doc note corrected to match.
- Move the blocking dir scan + per-file reads in /api/bash-tasks off the
async executor via tokio::task::spawn_blocking (damocles nit).
Adds a 'tasks' header pill (hidden at zero, like inbox/loose-ends) that
opens a side-panel flyout listing the agent's in-flight bash tasks from
GET /api/bash-tasks. Each row shows status (running/queued), task id,
elapsed time, and a truncated single-line command preview. Polled on the
same /api/state cycle as loose-ends (tasks complete async between turns, so
the count stays live); clicking the pill opens the flyout. Snapshot-only
v1 — SSE live-push is a possible follow-up.
Builds the read/surface half of the per-session first-turn-tokens metric
(the capture — sessions table + turn_stats.session_id — landed separately).
A fresh claude session's first turn pays the full static prefix (system
prompt + tools + CLAUDE.md + first wake) as uncached input, so its
input_tokens is a clean proxy for prompt / CLAUDE.md sprawl — watching it
over time surfaces creep.
- stats.rs: add `Snapshot.first_turn_ctx: Option<u64>` populated by
`read_first_turn_ctx` — the agreed per-session derive (first turn,
`ORDER BY started_at LIMIT 1`, of the most recent session that started
in the window). Inert-until-capture: `.ok()` maps both "no fresh
session yet" and "older db without the sessions table" to None, the
same decoupling as read_bash_breakdown; the field is skipped from the
JSON when None. Pre-capture rows have a NULL session_id and are excluded.
- agent stats.js: add a "first-turn ctx" summary chip, guarded on a
numeric value so it stays hidden until capture has data.
clippy + cargo fmt clean; agent bundle builds.
The per-agent terminal marks turn boundaries (◆ TURN ← / ✓ turn) but
shows no time. Append a wall-clock HH:MM:SS to both the turn-start and
turn-end rows, plus the elapsed duration on turn-end, rendered as dim
metadata so the boundary glyph stays the focus.
This is the frontend half of the feature; it reads a per-event `ts`
(unix seconds) off the turn_start / turn_end events. The read is guarded
on a numeric `ts`, so until the harness surfaces per-event timestamps
the rows render exactly as before (inert-until-capture). Once the
backend attaches `ts` to the history rows + live SSE frame, the times
light up automatically for both live and scrollback, with no further
frontend change.
Adds a reasoning-effort quick-picker to the agent page's overflow menu,
a direct sibling of the existing model quick-picker, wired to the backend
seam from #1597/#1600: /api/state carries effort + available_efforts, the
picker POSTs { effort } to /api/effort (operator-only, mirroring
/api/model), and live updates arrive via the effort_changed SSE event.
The available levels are the backend's to declare — the frontend holds no
hard-coded list; availableEfforts is seeded from state.available_efforts
on cold-load and the picker section is omitted until the backend supplies
the set. Mirrors the model picker otherwise: postEffort(),
renderEffortChip() (updates the new amber effort-chip + button active
states), the cold-load seed, and the effort_changed handler. Build green.
Applies on the next claude session (operator uses the existing
clear-session), per the backend contract.
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.
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.
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).
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).
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.
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.
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.
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).
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.
When the agent page is accessed via hive-gateway (path is /agent/<name>/)
the dashboard lives at the same origin's root (/), not at the direct
TCP port. Both app.js (overflow menu / setHeader) and stats.js
(dashboard-link anchor) previously hardcoded http://hostname:port which
is wrong or unreachable behind HTTPS TLS termination.
Detection: if location.pathname starts with '/agent/', use
location.origin + '/' as the dashboard base. Otherwise fall back to the
original direct-TCP construction for non-gateway deploys.
The backend already fetches ReminderStats from the broker RPC and
includes it in the /api/stats snapshot, but the frontend was not
rendering it. Add three chips to the summary row when the field is
present: reminders scheduled / delivered / pending.
The catch-all renderer was dumping raw JSON (in loud orange) for
claude's system events like api_retry — producing noise like:
! {"attempt":1,"error":"unknown","subtype":"api_retry",...}
Replace the single 'init' filter with a full system-event block:
- api_retry → muted note: '⚠ api retry 1/10 · unknown · 502ms'
- api_error → amber note: '✗ api error · <message>'
- other subtypes (context_window_exceeded, etc.) → '⚙ <subtype>'
The loud orange sys catch-all is now reserved for truly unrecognised
top-level event types, not routine operational noise.
Without a viewport meta tag, some browsers (notably Firefox with
Fingerprinting Protection active) internally read screen.availWidth /
screen.availHeight to compute the default viewport size, producing the
console warning 'Fingerprinting Protection is altering screen.availWidth
and screen.availHeight'.
Fix:
- Add <meta name="viewport" content="width=device-width, initial-scale=1">
to all five pages that were missing it (dashboard index/flow/logs, agent
index/stats). screen.html already had it.
- Replace window.innerWidth with document.documentElement.clientWidth in
the side-panel drag-resize code in common.js. clientWidth returns the
actual CSS layout viewport width and is not rounded by Firefox's
Fingerprinting Protection, making the drag calculation correct even
with privacy.resistFingerprinting enabled.