Phase 2 of hyperhive#2196. The backend now pre-computes enrichment fields
on every SSE event (stream_enrich.rs); the frontend reads them directly
instead of running its own dispatch logic.
Removed (~330 lines of JS):
- fmtArgsGeneric / TOOL_ICONS / toolIcon / fmtRoom / fmtUser / fmtToolUse
renderStream changes:
- system events: dispatch on v._category (drop/thinking_tok/note/details)
+ v._summary / v._body instead of per-subtype if-chains; status tick
still overrides label client-side when stateName === 'compacting' since
elapsed time is a wall-clock value the backend cannot know at emit time
- tool_use: use c._category === 'rich' for rich-renderer routing,
c._icon / c._summary for flat rows
renderRichToolUse: toolIcon(name) -> c._icon (from backend enrichment)
stream_enrich.rs: also stamp _category: 'drop' on top-level
type=result / type=rate_limit_event so the frontend can use a single
_category check instead of separate type-based early returns
During a compaction pass, bare `status` ticks from claude are the only
live signal (no dedicated progress event exists in the headless stream-json).
Previously these rendered as a coalescing `⚙ status` row — uninformative
while waiting for a long compact to finish.
The harness emits `TurnStateChanged { state: Compacting, since_unix }`
immediately before invoking `session.compact()` for both the turn-end and
idle-session manual-compact paths. The frontend already tracks this in
`stateName` / `stateSince` for the state badge (`📦 compacting · Xs`);
now the terminal's `status` tick renderer reads the same two variables so
consecutive ticks show `⚙ compact · Xs…` (elapsed, in-place updating via
the existing coalescer) instead of the unhelpful `⚙ status`.
Result: what used to look like
⚙ status
⚙ compact · manual · 37k→2k tokens · 40.1s
now looks like
⚙ compact · 3s… (updating in place)
⚙ compact · manual · 37k→2k tokens · 40.1s
Auto-compact (triggered by hive-claude's InfiniteSession policy internally,
no TurnState::Compacting set by the harness) continues to show `⚙ status`
as before.
Addresses #2276 (follow-up to status-tick coalescing in #2335).
Extends GET /api/journal/{name} to also accept the four hive infra
container names (hive-ci, hive-forge, hive-gateway, hive-matrix —
hive_priv_sock::InfraContainer is the allowlist), reusing the same
journalctl -M / hive-priv delegation path already used for agent
containers. Infra containers don't run the per-agent hive daemons, so
the unit filter is skipped for them — always the full machine journal.
Frontend: the AGENT tab's agent selector now lists infra containers
in a separate optgroup (sourced from /api/state's existing
infra_containers field), and disables the unit-filter select when one
is chosen.
inFlightOpsByAgent() read e.agent, a DAG-level field that no longer
exists (agent moved to per-node when DAGs became agent-per-node). So
the SW4RM tab's 'building...'/'meta-updating...' badges never matched
any real agent, and multi-agent DAGs (a startup sweep's MetaLock
cascade, a hive-wide restart) showed nothing at all on the per-agent
cards even while the rebuild queue clearly had them in flight.
Derive each agent's in-flight kind+state from its own node(s) within
the entry instead: a DAG can be 'running' overall while a given
agent's subgraph hasn't started (still queued behind an earlier node
in its chain), so per-node state is also more accurate than the old
per-DAG state for the badge, not just more available.
nodeComponents() split a DAG into weakly-connected components via deps
edges, but post #2476/#2450 every agent's rebuild subgraph hangs off a
shared MetaLock node via AfterOk, so the whole meta-update cascade is one
connected component and rendered as a single wall-of-chips line.
Add a second pass (splitFanOut) that further splits a component's
topo-ordered nodes on out-degree>1 points: a node with more than one
direct dependent renders as its own one-node line, and each dependent
becomes the root of an independent line. Purely deps-structure-driven,
same as the existing WCC split - no agent-field grouping involved. A
component with no fan-out (the common single-agent case) is unaffected.
Since the boot sweep (#2450) and meta-update cascade (#2476) became
single DAGs that grow subgraphs in-place, nothing constructs the old
fan-out anchors/parents anymore:
- NodeKind::Noop (the old boot_root grouping anchor) — no constructors.
- Template::StartupSweep / Source::StartupSweep (the old fan-out parent
template + cascade-child source) — replaced by Template::Boot and
Source::AutoUpdate/MetaUpdate respectively.
Drops the three variants + their as_str arms + the Noop executor arm, and
refreshes the stale fan-out/anchor doc comments (Boot/MetaUpdate/Source
docs, coordinator.md, dashboard.md). Frontend: the queue-kind glyph moves
from the dead startup_sweep to boot (which had none), and the dead
rqe-source-startup_sweep style is dropped.
No behaviour change — pure dead-variant removal.
Dashboard AGENT log tab sent unit=hive-ag3nt.service (the crate/dir
name) but the actual systemd unit is hive-agent.service, so every
fetch 400'd against the backend allow-list. Fixed the dropdown value
and, per the same issue's request, added the other per-agent daemons
(hive-mcp-http, hive-bash-daemon, hive-matrix-daemon) as selectable
units, plus hive-priv.service on the SYSTEM tab's host-daemon list.
Extended both backend allow-lists (post_journal / post_journal_host)
to match, and fixed a stale systemd.services.hive-ag3nt doc reference
in agent-hierarchy.md that had the same crate/unit-name confusion.
With the meta-update cascade (#2476) and startup sweep (#2450) folded
into single DAGs that grow per-agent subgraphs via append_subgraph,
nothing links parent/child DAGs anymore — parent_id is dead.
hive-c0re: drop parent_id from Dag/DagSpec (+ the DagView copy); delete
append_children and cancel_children (no callers); simplify trim_history
(no more terminal-parent-with-live-children guard — a one-big-DAG is
terminal only when its whole graph settles); drop the rebuild() parent_id
param; QueueDag returns just the polled DAG (no fan-out children to
gather). hive-sh4re: drop the DagView.parent_id wire field.
frontend: a multi-step op is one DAG now, so renderRebuildQueue drops the
childrenOf/orphans cross-DAG grouping and renders each entry flat; its
per-agent subgraphs render as nodes within the one row (split by deps).
Removed the dead rqe-child style + isChild plumbing.
Docs + the child-DAG queue tests updated/removed to match.
mara's review comment: the frontend shouldn't apply its own grouping
logic on top of the DAG — it should render the structure the backend
already provides. The actual structure is the nodes' deps graph, not
the incidental n.agent field.
Replace the group-by-agent heuristic with nodeComponents(): splits
entry.nodes into weakly-connected components via the deps edges
(undirected reachability), then topo-sorts each component (Kahn's
algorithm) so a chain renders in true dependency order. A DAG made of
independent per-agent subgraphs (no cross-agent deps) still comes back
as separate components — same visual result for today's templates —
but the split is now driven by what the backend actually encodes, and
naturally extends to any future non-agent-aligned branching. Agent
name is still shown as a per-component label, but purely as adjunct
info sourced from that component's own nodes, not the grouping key.
renderQueueEntry flattened entry.nodes into one arrow-joined chain
regardless of which agent each node belongs to. A multi-agent DAG
(e.g. hivectl restart --graceful with several agents) runs independent
per-agent subgraphs concurrently, no cross-agent deps, so joining them
all into one sequential-looking chain misrepresented the actual DAG
shape (mara's report: 'expected one dag that forks after the start
node into the per agent sub dags').
Group nodes by n.agent (stable, first-seen order) and render one
.rqe-nodes line per agent, with a small agent-label chip when the DAG
spans more than one. Single-agent DAGs (the common case) collapse back
to exactly the prior one-line render — no visible change there.
Restart now offers the same graceful-vs-hard choice stop already has:
single-agent menu item and the bulk-select action bar both grow a
'restart gracefully' checkbox that routes through the existing
submit::graceful_restart DAG (signal -> drain -> stop -> reconcile)
instead of a hard restart. Backend gains a ?graceful=true query param
on POST /api/restart/<name>, mirroring post_kill's shape (renamed
KillParams -> GracefulParams since it's now shared).
- builds.js NODE_KIND_LABEL: 'set wanted' (and the previously-missing
'noop') so the new head node renders with a label, not the raw kind.
- docs/coordinator.md + templates.rs module doc: the power-op DAG shapes
now show the head SetWanted node; SetWanted added to the lease-needing
list with the atomicity rationale.
Review (iris/argus): the dashboard rebuild live-log header labels one
specific node's log stream (liveNode, keyed by its build_log_id), so it
should show that node's own agent — entryAgents(running) listed every
agent in the DAG, which would mislabel a single agent's log once DAGs
span multiple. The other entryAgents() sites (row label, cancel-confirm,
fingerprint) are correct whole-DAG summaries and unchanged.
DagView no longer has a DAG-level agent, so consumers derive it from the
per-node agents:
- hivectl dag_progress.rs: dag_agents(d) helper (distinct node agents,
comma-joined) in place of d.agent.
- dashboard builds.js: entryAgents(entry) helper likewise for the
rebuild-queue card + live-log header + cancel confirms.
- docs/coordinator.md: lease prose (node-agent-keyed, global per agent),
wire shape (NodeView.agent, no DagView.agent), and dropped the removed
dedup section.
Per mara's feedback on PR #2407 ("better: you can also provide url in
dashboard, same as with matrix, no host config"), drops
services.hyperhive.extraForges and the admin-API mint/revoke flow
entirely. The operator now creates a token on the external forge
themselves and pastes a label + base URL + access token into the
dashboard's FORGES tab, the same shape as the GitHub PAT flow plus the
base-URL field from the matrix extra-account flow. hive-c0re only ever
writes/deletes two local files per account (forge-<label>-token,
forge-<label>.json sidecar for the URL) via hive-priv — no remote
account creation, no admin token, no revoke-on-the-remote-side, no nix
config to enumerate.
- nix/host-modules/hive-forge/default.nix: removed the extraForges
option, its label-format assertion, and the HYPERHIVE_EXTRA_FORGES
env forwarding.
- hive-c0re/src/forge/extra.rs: deleted (REST admin-API provisioning,
no longer needed).
- hive-c0re/src/dashboard/extra_forges.rs: GET /api/extra-forges?
agent= lists an agent's stored forges by scanning its state dir
(mirrors matrix_accounts.rs's filename-scan listing), POST
/api/extra-forge-account (agent/label/base_url/token/
action=add|remove) stores or removes an account.
- hive-sh4re/priv_proto.rs + hive-priv/main.rs: new
WriteAgentExtraForgeAccount/DeleteAgentExtraForgeAccount priv
requests (adds base_url, writes/deletes a JSON sidecar alongside the
token).
- hive-c0re/src/priv_client.rs: matching wrapper functions.
- frontend/packages/dashboard/src/credentials.{html,js}: FORGES tab is
a per-agent list + add-account paste form (label/base_url/token), no
grant/revoke-from-catalog UI.
- docs/web-ui/dashboard.md: FORGES tab section rewritten.
Supersedes the design in PR #2407 (already approved+green on the old
admin-API model) — opening as a fresh PR against the same issues
rather than force-pushing over the approved one.
New POST /api/infra-container/{name}/{action} dashboard route (start/
stop/restart on hive-ci/hive-forge/hive-gateway/hive-matrix), reusing
the existing priv_client::control_infra_container helper the
infra_admin agent path already uses, plus an audit_log entry per
attempt. Adds infra_containers to the /api/state StateSnapshot (name +
live running status via systemctl is-active). New 1NFR4 sub-tab on the
C0R3 dashboard page: one row per infra container with a running/
stopped badge and start/stop/restart buttons, polled every 5s while
the sub-tab is open.
thinkingTokensRow/statusRow/pluginInstallRow were three copy-pasted
module-level row/text pairs implementing the same 'collapse repeated
ticks into one updating row' pattern. Extracted a small makeCoalescer(cls,
icon) factory returning an update(api, text) closure; each call site is
now a single line instead of the duplicated guard-and-update-or-create
block against api.mutableRow.
Part 2 of hyperhive#1970 (backend/CLI landed in PR #2378). Renames
/matrix-accounts.html -> /credentials.html and restructures it with a
sub-tab strip (MATRIX / GITHUB), reusing the shared @hive/shared/tabs.js
tab strip already used by /logs.html.
MATRIX tab carries over the existing account list + login form
unchanged. GITHUB tab adds a single-PAT provisioning form: status line
(present/absent, read from GET /api/github-account), a security-warning
banner (dedicated bot account + minimally-scoped token), a link to
generate a PAT at github.com/settings/tokens, and a paste-token form
posting to POST /api/github-account. Both tabs share one agent picker.
Updated docs/web-ui.md + docs/web-ui/dashboard.md to describe the new
page shape, and the H0M3 hub tile (index.html) to point at the renamed
page.
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.
- deploy-window gate (meta::exclusive) + path-limited meta commits:
a perm/lock/topology commit can no longer sweep an ApprovalDeploy's
staged flake.lock and neuter abort_deploy (regression test included)
- cancel surfaces now buffer terminal roll-ups the scheduler drains,
so a queued approval DAG cancelled by the operator resolves its
approval instead of dangling, and cancelled power ops revert their
wanted flip to the observed state
- hivectl restart / restart-all ride the queue (lease serialization,
transient guard) and restart sets wanted=Up like the old kill+start
- exactly one Rebuilt event per rebuild DAG, emitted at terminal
- StopForUpdate pre-seeds a missing agent_power row from the pre-stop
observation so a rebuild can't strand an unknown agent offline
- history trim keeps terminal fan-out parents with live children
- audit_log back on db::open; swarm.js badge for reconcile DAGs
each queue card now shows its DAG's node chain (per-node state, step,
build-log link), fixing 'queue jumps don't show on the dashboard'.
live-log panel keys off the running node's log. new
services.hyperhive.c0re.buildSlots option (default 1) threads the
concurrent nix-build count into serve.json.
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.
Two operator-visible strings and one code comment still referenced 'the
manager' in the approval card UI:
- init_config card description: 'manager customises agent.nix before
spawn' -> 'submitting agent customises agent.nix before spawn'
- deny form data-prompt: 'sent to manager' -> 'sent to submitter'
(the deny note goes to the ApprovalResolved submitter, not a fixed
manager role)
- code comment: 'surfaced to the manager via' -> 'surfaced to the
submitting agent via'
- builds.html meta text: 'manager learns each outcome' -> 'the
submitting agent learns each outcome'
The 'manager' role is no longer structural — root-ness is topological.
Any agent with the approvals tool group can submit; the note/event routes
to the submitter.
The QueueKind enum has graceful_stop, start, and stop variants but the
dashboard QUEUE_KIND_GLYPH map only covered rebuild/meta_update/spawn/
destroy/restart/startup_sweep/perm_change — the three missing kinds
rendered as '?' in the build queue UI.
This became visible with the deferred start-after-rebuild change: a fast-lane
Start entry now appears as a child of its Rebuild parent, but showed the
fallback '?' glyph.
Glyphs assigned: graceful_stop=⏹, start=▶, stop=■.
Also update the dashboard.md kind-glyph list to include all nine kinds and
note that deferred start-after-rebuild entries also use parent_id grouping.
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.