The LOGS page gained a fourth sub-tab (AUDIT) surfacing the
agent-initiated privileged-action audit trail. Document the sub-tab
(lazy fetch, filterable table, outcome/denied badge colouring, the
latest-N-of-total clamp, 30s relative-time ticker) alongside BUILD /
AGENT / SYSTEM, add #audit to the hash-routing list, and add the
GET /api/audit-log endpoint to the API reference.
Adds an AUDIT sub-tab to /logs.html (alongside BUILD / AGENT / SYSTEM),
consuming GET /api/audit-log ({ entries, total }). A read-only filterable
table: when / agent / action / target / outcome / detail, newest-first,
with a 'latest 500 of N' header from total and a client-side substring
filter. Outcome badges colour ok green / err red, with an err whose detail
starts 'denied:' rendered amber + labelled 'denied' (capability refusal
reads distinct from an execution failure). Lazy-fetched on tab show (like
SYSTEM); a 30s ticker keeps the relative timestamps honest.
The audit_log store + endpoint landed in the prior audit-log backend work;
this is the operator-visible surface for it. Resolves#1647.
argus review: the four public Result-returning methods (open, list_recent,
count_total, vacuum) need # Errors sections per the hive Rust doc rules;
open had no doc comment. Added all four.
Per iris's dashboard-side ask: return { entries, total } instead of a
bare array so the UI can show 'latest 500 of N' rather than silently
capping at the clamp. Adds AuditLog::count_total().
Adds a durable, operator-visible audit trail of privileged operations
hive-c0re performs on behalf of an agent — the ones that cross the
agent/operator trust boundary. First entry: infra-container restarts via
the infra_admin-gated `restart` tool, which until now were recorded only
as a hive-priv journal trace.
Backend:
- new `audit_log` module: sqlite-backed store (audit_log.sqlite, same dir
as build_logs) with schema (ts/agent/action/target/outcome/detail),
best-effort `record`, `list_recent` (clamped 500), 90-day `vacuum`, and
a process-singleton handle mirroring build_logs.
- Coordinator opens + installs the handle; main spawns the hourly vacuum.
- agent_server::handle_restart_infra records every attempt (ok, error, and
capability-denied) via the global handle — best-effort, never fails the
underlying action.
- dashboard: `GET /api/audit-log` returns recent entries as JSON.
Scope is deliberately agent-initiated privileged actions only (not every
PrivRequest — token writes + nspawn edits are constant lifecycle noise).
Extensible: future agent-initiated priv ops record via the same handle.
Unit tests cover record/list ordering, the 500 clamp, and retention vacuum.
The dashboard *surface* (an AUDIT view consuming /api/audit-log) is a
frontend follow-up coordinated with iris.
The dashboard LOAD tab always showed "no running agent containers":
container_stats::gather() looked for each agent's cgroup at
machine.slice/machine-h\x2d<name>.scope, but that path never exists.
nixos-container runs `systemd-nspawn --keep-unit` with
`Slice = "machine.slice"` (nixpkgs virtualisation/nixos-containers.nix),
so --keep-unit means no separate machined `machine-<name>.scope` is
created — the container's cgroup IS the launching service unit,
`container@<machine>.service`, under machine.slice. systemd-machined
still logs "New machine <name>" (registration), which is what made the
scope-path assumption look plausible, but the cgroup stays on the unit.
Fix scope_dir to machine.slice/container@<machine>.service and drop the
\x2d escaping (the service unit name is used verbatim in the cgroup dir;
the journal shows the literal `container@h-<agent>.service`). Adds a unit
test pinning the path.
Clicking "update & rebuild" (and cancel / respawn / purge) on /core.html
navigated to the bare `ok` response page instead of submitting async. The
C0R3 page (split out of the dashboard) carries `data-async` forms but its
bundle never had the global submit interceptor — that handler lived inline
in tabs.js, so only the dashboard bundle had it. The forms POSTed natively
and the browser followed the response.
Fix: lift the `data-async` submit handler out of tabs.js into a shared
`bindAsyncForms(onSuccess)` in common.js (which already owns the `form`
helper that builds these forms), and call it from both pages:
- tabs.js: `bindAsyncForms(() => refreshState())` — behaviour-preserving
(same handler, now imported).
- core.js: add a `refreshState()` (re-fetch /api/state + re-render) used
for the cold load and as the post-submit refresh, and call
`bindAsyncForms(() => refreshState())` at boot.
Forms with `data-no-refresh` (e.g. meta-update, which gets its update via
the meta SSE events) skip the refresh, same as before.
The per-agent stats page gained a "first-turn ctx" summary chip (the
input tokens of the most recent fresh session's first turn — a
system-prompt/CLAUDE.md sprawl proxy), but agent.md's chip-row
description didn't mention it. Add it next to the reminder chips, noting
the stats.rs derive and the inert-until-capture behaviour (omitted from
JSON until the sessions / turn_stats.session_id capture has rows).
The C0R3 page PR moved the SYST3M tab off the dashboard onto the
standalone /core.html page, leaving the web-ui docs describing a tab that
no longer exists.
- dashboard.md: retitle the "SYST3M tab" section to "C0R3 page
(/core.html)" and reframe its intro — it's now a standalone page off the
H0M3 hub with a createTabStrip sub-tab nav (default R3BU1LD QU3U3), its
own core.js bundle on /dashboard/stream; the dashboard keeps only the
rebuild-queue state for the SW4RM "building…" badges.
- correct the chrome tab-strip list to the actual in-page tabs
(SW4RM / Y3R C4LL / P3RM1SS10NS / SCH3DUL3S) and note SYST3M / ST4TS /
S3TT1NGS / FL0W / L0GS / M4TR1X are all standalone pages off H0M3.
- fix the container-load poll reference (core.js, while the C0NT41N3R
L04D sub-tab is active) and the stray "SYST3M ›" panel references.
- drive-by: the schedules endpoint doc said "SYST3M scheduled-prompts
panel" — it's the SCH3DUL3S panel; corrected.
- web-ui.md index: same tab-list correction + C0R3 page pointer.
The "What the Rust side does" section predated the isolated-container
egress fix and omitted two now-implemented mechanisms:
- HOST_ADDRESS=<bridge-ip> in the nspawn conf — load-bearing for the
container's default route (nixos-container only installs `default via`
when HOST_ADDRESS is non-empty; bridge mode skips host-side route setup).
- the resolver wiring: hive-priv's bridge-DNS marker + the
hyperhive-isolated-dns oneshot rewriting resolv.conf to the bridge
dnsmasq (nixos-container re-copies the host's resolv.conf each start, so
the fix is runtime, ordered before the harness).
Doc-only; brings network.md in line with the merged behavior.
Follow-up to the isolated-container egress fix. The hyperhive-isolated-dns
oneshot (which rewrites resolv.conf to the bridge resolver) was ordered
before network-online.target + tea-login, but NOT relative to the harness
(hive-ag3nt) or the matrix daemon. hive-ag3nt only declares
`after network.target`, so on first boot its first-turn api.anthropic.com
lookup could race ahead of the resolv.conf rewrite and error (self-heals
next turn, but flaps the first one).
Add hive-ag3nt.service + hive-matrix-daemon.service to the oneshot's
`before` so DNS is fixed before any network consumer starts. The matrix
entry is a harmless no-op when matrix is disabled (unit absent).
Caught by damocles in review of the parent PR.
When isolateContainers=true, claude (and all egress) broke in every
container: agents came up with an IP but no way off the bridge subnet.
Two container-side gaps, both confirmed against nixpkgs
nixos-containers.nix:
1. No default route. hive-priv wrote HOST_ADDRESS= empty in the nspawn
conf. nixos-container's container-side setup only installs
`ip route add default via $HOST_ADDRESS` when HOST_ADDRESS is
non-empty, so the container had an address but no gateway -> nothing
off-subnet (incl. api.anthropic.com) was reachable. Fix: write
HOST_ADDRESS=<bridge-ip>. In bridge mode the host-side address/route
setup is skipped, so this only affects the container's default route.
2. No usable resolver. nixos-container copies the host's /etc/resolv.conf
into the container at every start; the host resolver (e.g. 127.0.0.53)
is unreachable from a private netns and isn't authoritative for the
hive's own zones. Fix: hive-priv drops a marker carrying the gateway
IP only when isolated, and a new harness-base oneshot
(hyperhive-isolated-dns) rewrites resolv.conf to point at the bridge
dnsmasq. Inert in shared-netns mode (no marker), so the shared
container toplevel does the right thing in both modes.
The gateway IP is the address part of HIVE_NETWORK_SUBNET (the bridge IP
verbatim, honouring a non-.1 operator override), via a new validated
bridge_gateway_ip() helper with unit tests.
Unblocks defaulting isolation on.
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 /logs.html header puts a flex:1 sub-tab strip (`.logs-tabbar`) next
to the ← home back-link inside the flex `.page-header`. The strip is
itself a flex container, so its default `min-width: auto` keeps it at its
intrinsic content width and — without an explicit nowrap on the parent —
it gets pushed onto its own row under the back-link.
Mirror the working dashboard chrome (`.dashboard-chrome` / `.tabbar`,
which sets `flex-wrap: nowrap`): make `.page-header` explicitly nowrap and
give `.logs-tabbar` `min-width: 0` so it shrinks to share the row instead
of wrapping. Affects every standalone page that uses `.page-header`
(flow / logs / stats / settings); the nowrap is correct for all of them.
Folds the SYST3M-tab removal into the same change that adds /core.html
(per operator review — it should be one PR, not an additive page + a
follow-up). Drops the tab + pane from dashboard.html and ~600 lines of
now-dead renderers/wiring from tabs.js: renderMetaInputs, renderRebuildQueue
+ renderQueueEntry + the rebuild-queue row cache / glyph maps / fingerprint,
renderTombstones, the container-load poll (cload* + start/stopContainerLoadPolling),
the two rqe-* elapsed tickers, the meta/tombstone state + sync + apply
handlers, their SSE dispatch entries, and the SYST3M tab-count.
Deliberately KEPT in tabs.js: rebuildQueueState + syncRebuildQueueFromSnapshot
+ applyRebuildQueueChanged + inFlightOpsByAgent — these don't render the
SYST3M panel, they drive the "building…" / "meta-updating…" badges on the
SW4RM agent cards, so the dashboard still subscribes to rebuild_queue_changed
to refresh those badges (applyRebuildQueueChanged now only re-renders
containers, no panel render).
core.css still imports dashboard.css for the shared section styles; splitting
those out + de-duping the renderers into a shared module remains a follow-up.
Carves the dashboard's SYST3M tab content out into a standalone page at
/core.html, reached from the H0M3 hub, with a createTabStrip sub-tab
strip (default = Rebuild Queue): rebuild queue, meta inputs, kept state,
container load. Same minimal-chrome standalone-page pattern as
/logs.html (← home back-link + sub-tab nav).
This is the additive first step: the new page is its own esbuild bundle
that cold-loads /api/state and subscribes to /dashboard/stream for the
same live rebuild_queue_changed / meta_inputs_changed / meta_update_running
/ tombstones_changed events the dashboard uses. The four section
renderers are ported from tabs.js; the dashboard's SYST3M tab is left in
place and untouched, so this PR cannot regress the existing dashboard.
Removing the now-duplicate SYST3M tab + de-duplicating the renderers
(into a shared module) + splitting the shared section CSS out of
dashboard.css is the deliberate follow-up.
core.css imports dashboard.css wholesale (transitional) so the ported
sections render identically; dashboard.css does not import common.css so
nothing double-loads. build.mjs gains core.js / core.css / core.html
entries; no Rust change (hive-c0re serves dist/ via ServeDir, and the
/dashboard/stream + /dashboard/history routes are registered ahead of
the fallback).
Page name "C0R3" is a placeholder pending the operator's pick — trivially
renamed (the /core.html URL + the C0R3 label).
Replace the unfiltered hyperhiveFlake = "${self}" (handed to hive-c0re
as the flake path it builds agent containers from) with a named,
explicitly-filtered source derivation that drops files no nix or cargo
derivation reads: docs/, scripts/, and root-level *.md. Editing those no
longer changes the hyperhiveFlake store path, so it no longer forces a
rebuild of every agent container.
This is the per-package source-filter pattern (operator request on
#1114): each real source is a named filtered derivation used as src,
not an inline filter at the use site. Companion to the existing rust
cleanSrc (crane cargo-source filter); supersedes the inline nixSrc
approach in the older #1114 branch.
Safe: hive-c0re consumes hyperhiveFlake as a runtime path string and
builds packages/nixosConfigurations from it (nix build <path>#…), which
never forces the checks that reference ./docs — and no package, module,
or container config reads docs/scripts/root-md.
Follow-up to the turn-time feature: the terminal now stamps a wall-clock
time on the turn-start / turn-end rows and an elapsed duration on
turn-end, sourced from a per-event `ts` (unix seconds) that the backend
serializes as a flattened sibling of the event tag on both the live SSE
frame and each history row.
- terminal-rendering.md: add the `.turn-time` row to the taxonomy table
+ a note on the ts source and the graceful degradation when a frame
omits ts.
- web-ui/agent.md: note the ts sibling on the emitted events, and the
time/duration suffixes on the TurnStart / TurnEnd renderings.
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.
H0M3 had tiles for every operator surface except the forge. Add a
Forge tile that links straight to the hive-forge web UI. It mirrors
the Matrix tile's gating: hidden by default, revealed by home.js only
when state.forge_present is true, with the href filled from
state.forge_public_url (the gateway-served vhost) or the direct :3000
port fallback — the same precedence the dashboard uses for forge links.
Operators without a forge never see a dead link.
Drop 'Phase 3 of the matrix-MCP work' (circular inside the matrix-mcp
crate) + the vague issue-thread pointer; keep the self-contained note
that it mirrors damocles-daemon's v0 set.
Clean the legacy backlog so the tracker-tag lint can become a required
gate (mara's warn-during-cleanup -> full-tree-enforcement path). Rewrite
the ~33 real `closes/see #NNN` provenance refs in doc-comments to prose
across hive-forge, hive-c0re, hive-ag3nt, hive-matrix-mcp, hive-sh4re,
and add a `lint:allow` line marker to check-issue-refs.sh for genuine
non-tracker `#<digits>` (a hash-digit heading-detection test input).
Tree is now lint-clean; tracker-tag lint ready to promote to required.
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.
Match the harness reader landed in #1600 (configured_effort reads
HIVE_DEFAULT_EFFORT, mirroring HIVE_DEFAULT_MODEL). Was
HYPERHIVE_EFFORT_LEVEL in the first cut; damocles standardized the
name on the HIVE_DEFAULT_* convention.
Per-agent enum option (medium|high|xhigh, default medium) rendered into
the HYPERHIVE_EFFORT_LEVEL env var. The harness resolves effort as
operator-override-file -> this env -> medium and passes it to
claude --effort at turn launch (harness read+apply tracked separately).
Same declarative-default + runtime-override shape as hyperhive.model.
Umbrella: operator-controlled claude effort.
Per mara's review: instead of measuring the banner height in JS and
offsetting the chrome's sticky top, put the warning banner and the page
chrome in the same sticky div so they stack naturally.
common.js builds the wrapper: ensureStickyTop() wraps the page's chrome
(.dashboard-chrome / .page-header) in a single .sticky-top container and
injects the warning banner as its first child. The banner and the chrome
are no longer individually sticky — .sticky-top owns the stickiness, so
they pin together in one context instead of two top:0 stickies colliding
(the banner used to overlay the tab bar). Pages without a chrome (the
H0M3 hub) get a banner-only sticky region. No per-page markup needed; no
JS height measurement. Build green.