Compare commits

...
Author SHA1 Message Date
damocles
e04b6075e5 harness-base: hyperhive.web.useUnixSocket option to flip HIVE_WEB_SOCKET (#815)
closes #815. last harness-side piece of the #784 phase 2 rollout.

new option `hyperhive.web.useUnixSocket` (default false). When true,
the harness service env gets HIVE_WEB_SOCKET set to the canonical
/run/hive-agent/${userName}/web.sock path — making web_ui::serve
bind a UnixListener (PR #800) instead of TCP. Path matches
hive_c0re::agent_sockets::socket_path_for(name) so the c0re
bind-mount (#813) and the gateway's eventual upstream config all
derive from the same canonical shape (no triangulation drift).

Default false so an agent's web UI keeps binding TCP until the
per-agent flip is explicit. Rollout shape:
1. flip one canary agent (atlas volunteered) to true via agent.nix
2. validate atlas's gateway-side step 3 against that canary
3. flip remaining agents per-agent as the gateway side soaks
4. eventually drop this option once everyone's on unix (step 4 of #784)

Sub-agent-only by design — manager UI serves at / via the c0re
dashboard upstream, never via /agent/<name>/, so the per-agent
unix path is irrelevant for it (the env var is set unconditionally
for code simplicity; the manager's bind socket would just sit
unused).
2026-05-31 16:09:31 +02:00
iris
a443108be5 hive-sh4re + docs: extract broker delivery/ack/requeue prose (#717 batch 1) 2026-05-31 15:53:00 +02:00
iris
33683f2de2 shared/terminal.css: drop stale hive-fr0nt ref; point at docs (#714) 2026-05-31 15:49:53 +02:00
lexis
68c6a5ae19 docs(CLAUDE.md): add matrix.md to file index + reading paths (follow-up to #811) 2026-05-31 15:49:42 +02:00
iris
7bb55bca43 docs/agent-hierarchy.md: scrub self-ref cookies (#719 batch 5) 2026-05-31 15:49:37 +02:00
iris
bd6b48a883 docs/persistence.md: scrub self-ref cookies (#719 batch 4) 2026-05-31 15:49:32 +02:00
iris
680d13b9e9 docs/gotchas.md: scrub self-ref cookies (#719 batch 3) 2026-05-31 15:46:02 +02:00
atlas
8a50f36c0c docs/agent-hierarchy.md: extract harness systemd unit shape (#718 batch 4)
Move the harness systemd unit rationale (role-driven unit name,
manager-only forge defaults, PATH wrapper-dir trick, env vars,
standalone-eval fallbacks, RuntimeDirectory + User= reasoning)
from `nix/templates/harness-base.nix` to a new
`docs/agent-hierarchy.md::Harness systemd unit shape (per-role)`
section.

In-code comments trim to short purpose statements + pointers; the
PATH /bin auto-append behaviour is already documented in
docs/gotchas.md, so the harness file just cross-refs both.

`description = ''…''` blocks (operator-facing options docs)
preserved per iris #718.

`nix flake check` clean; `nix fmt` clean (after formatter pass).
2026-05-31 15:44:41 +02:00
damocles
35a7ff03b7 hive-c0re: bind-mount /run/hive-agent/<name>/ per sub-agent (#784 phase 2 step 2b)
builds on step 2a (#809). lifecycle::set_nspawn_flags now adds a
--bind={socket_dir}:{socket_dir} flag per sub-agent so the harness's
HIVE_WEB_SOCKET bind (PR #800) lives in a dir both the agent
container and the host can see.

design (matches #809's a1a601d explanation):
- bind the SUBDIR, not the socket file. file bind-mounts drop on
  unlink; the harness's bind_unix unlinks any stale socket before
  binding, so a file bind would land the new socket in the agent's
  private namespace, invisible to the gateway. dir bind keeps both
  sides on the same dir inode.
- per-agent dir (one /run/hive-agent/<name>/ per agent, not a shared
  /run/hive-agent/ mount). The agent's container only sees its own
  subdir — never siblings' (mara on #800).
- manager skipped — the manager's UI serves at / via the c0re
  dashboard upstream, not via /agent/<name>/, so it never needs the
  per-agent socket dir.

mkdir source defensively before bind: nspawn refuses to start when
the bind source is missing, and /run/hive-agent/ doesn't exist on
fresh hosts.

remaining work in this phase:
- step 3 (atlas): gateway proxy_pass http://unix:/run/hive-agent/<name>/web.sock:/
- per-agent: flip HIVE_WEB_SOCKET in agent.nix to opt in (separate PRs)
- step 4 (later): drop TCP fallback once everyone's flipped
2026-05-31 15:44:26 +02:00
iris
5ca96b8c85 docs: scrub self-ref cookies across 5 files (#719 batch 2)
All five files scrubbed to 0 issue-ref cookies. Most refs were
'(closes #NNN)' style attribution after closed work landed.

- docs/turn-loop.md: 6 → 0 (#598 unified binary, #419 auth-fail,
  #542 mtime resumption, #519 role markers, #442 spawn path,
  #474/#478 partial edit)
- docs/approvals.md: 5 → 0 (#317 flake validation, #250 withdraw,
  #441 rebuild-queue dispatch, #753 manager port hash, #425
  crash-watch grace)
- docs/terminal-rendering.md: 2 → 0 (#666 ask-operator inline)
- docs/conventions.md: 3 → 0 (#753 port hash, #692 parent
  sentinel, #486 reparenting)
- docs/security.md: 4 → 0 (#240 + #658 ×2 + #673/#678 attribution)

Combined with PR #810 (docs/web-ui.md 14→0), this PR closes the
small / medium files. Remaining: agent-hierarchy.md (13),
gotchas.md (13), persistence.md (13). gateway.md is atlas's lane.
2026-05-31 15:39:09 +02:00
atlas
a8d8159038 docs/matrix.md: extract hive-matrix prose (#718 batch 5)
New top-level matrix doc covering everything that doesn't fit
elsewhere:

- Container shape (nixos-container, shared host netns, name choice,
  state persistence) — sibling to gateway.md::hive-forge container shape.
- Identity vs API listener: serverName vs gatewayHost split with the
  #660 breaking change.
- Default-closed firewall + federation port 8448 caveat.
- Provisioning flow: registration token activation, bind-mount,
  LoadCredential, hive-c0re's per-agent register + access_token
  persistence. Captures #565 first-boot race + #644 / iris 8043
  ownership shape.
- Assertion rationale (serverName, gatewayHost == "").
- fluffychat-web build fixes (#685): Imaging.{js,wasm} emscripten
  derivation + dart compile worker fixup, build-CWD path lesson
  from #685 / #733.
- Sequencing history.

In-code # comments trim to short purpose statements + docs pointers.
description = '' blocks (operator-facing options docs) preserved per
iris #718. ~140 lines removed from hive-matrix.nix.

`nix flake check` clean; `nix fmt` clean.
2026-05-31 15:38:43 +02:00
iris
ac83404f1c docs/web-ui.md: scrub self-ref cookies (#719 batch 1)
14 issue-ref cookies removed from docs/web-ui.md. Most were
attribution refs to closed issues that landed the feature being
described — the prose around them already describes the current
behaviour, the cookie was just a 'this was issue NNN' breadcrumb.

Scrubbed:
- #753 (manager port hash 8100-8999)
- #233 (linkify XSS)
- #448 (SSE multiplexing SharedWorker)
- #515 (worker-death self-heal)
- #451 (side-panel drag-to-resize)
- #635 (matrix.gui.enable defaults)
- #609 + #15 (gateway re-root) → 'lives in docs/gateway.md'
- #66 (ctx badge thresholds)
- #541 (journald panel-body column-flex)
- #447 (rebuild-queue cancel)
- #559 (mark all read)
- #474 (schedules PATCH partial edit)
- #467 (schedules fire-now)
- #589 (hive-qualified label phase A)

docs/web-ui.md: 14 → 0 issue-ref cookies. Self-contained read.
2026-05-31 15:38:28 +02:00
damocles
91f5588134 hive-c0re: per-agent subdir socket layout (#809 design fix)
was: /run/hive-agent/<name>.sock (flat single-file bind-mount).
issue: file bind-mounts don't survive the harness's 'unlink stale
socket then bind(2) a new one' cycle. The unlink drops the bind
inside the container; the rebind happens in private container
namespace; host never sees the new inode → gateway can't connect.

now: /run/hive-agent/<name>/web.sock (per-agent SUBDIR + fixed
filename). Lifecycle bind-mounts the parent dir per agent (step 2b)
so both sides see the same dir inode; the socket appears on the
host the moment the harness binds it.

new helpers:
- AGENT_SOCKET_DIR const (parent, gateway binds this whole tree)
- SOCKET_FILENAME const ("web.sock")
- agent_dir_for(name) (per-agent subdir, lifecycle bind-mounts this)
- socket_path_for(name) (= agent_dir_for(name).join(SOCKET_FILENAME))

per-agent dir isolation also satisfies mara on #800 directly:
agent's container only sees its own subdir + socket, never siblings'.

8 tests now (added agent_dir_for_is_socket_parent invariant).
2026-05-31 15:38:04 +02:00
damocles
c7a8cec2b5 hive-c0re: agent_sockets.json writer (#784 phase 2, step 2a)
sibling to agent_ports.rs (#748 / #15). Writes
/var/lib/hyperhive/agent-sockets.json mapping agent name → unix
socket path under /run/hive-agent/<name>.sock — the source of truth
for the gateway's eventual unix-socket upstreams once it flips off
TCP per #784's transition plan.

shape mirrors agent_ports.rs:
- BTreeMap for deterministic JSON output
- atomic <path>.tmp + rename for partial-write safety
- idempotent (skip rewrite when content unchanged)
- MANAGER_NAME filter (gateway routes manager via dashboard upstream,
  not /agent/<n>/)

socket_path_for(name) is pure-deterministic so a name alone resolves
to the same path the c0re writes + the harness binds via
HIVE_WEB_SOCKET (post-PR #800).

hooked into meta::sync_agents alongside the existing
agent_ports::write call (same best-effort + non-fatal shape).

next: step 2b (lifecycle bind-mount /run/hive-agent/ into agent +
gateway containers, with per-agent isolation per mara's #800 nudge
'agents can only access their own sockets'). then atlas's step 3
(gateway proxy_pass http://unix:...). then step 4 drops TCP.
2026-05-31 15:38:04 +02:00
iris
dc8c71c687 dashboard: fix rebuild spinner shape (#804)
Mara reported the rebuild spinner rendered as a rotating L-corner
('_|' / '⌐') instead of a smooth orbiting arc. Two issues:

1. The previous version coloured TWO adjacent border sides amber
   (border-top + border-right). With the rest of the border
   transparent, this paints a sharp L-shape at the icon corner —
   which when rotated reads as a spinning border-corner, not a
   loading spinner.

2. @keyframes had only `to` defined. Safer to include explicit
   `from { transform: rotate(0deg) }` so any browser that doesn't
   default cleanly still picks up the rotation.

Fix is the classic CSS spinner shape: faint amber ring around the
full icon perimeter (`border: 2px solid rgba(250, 179, 135, 0.2)`)
with one brighter top arc (`border-top-color: var(--amber)`) that
rotates. Reads unambiguously as a loading indicator.

Closes #804.
2026-05-31 15:36:38 +02:00
iris
4cee50a3be agent/app.js: final cookie scrub — 0 cookies (#713 batch 3 final)
All 19 remaining cookies in app.js were attribution refs to
closed issues / PRs / past mara/argus reviews. Substance is
already in docs from earlier batches (PR #780). This batch
collapses the cookie attributions to brief inline notes pointing
at docs where appropriate.

- #360 side-panel duplication note → drop closed-issue framing
- #394 vibec0re header redesign (×5) → drop cookies, prose stays
- #589 hive-qualified label glyphic-title note → drop cookie
- #576/#542/#584 logout chain rationale → drop cookies, prose
  preserved (the WHY + .credentials.json + mcp-needs-auth-
  cache.json detail stays inline)
- #14 relative paths convention → docs/web-ui.md::Per-agent
  relative paths pointer
- #233 marked autolinks new-tab → drop cookie
- #559/#566 mark-all-read flyout (×3) → docs/web-ui.md::Per-agent
  page (Loose-ends flyout) pointer + drop attribution
- #666 ask→operator inline-answer slot (×2) → docs/web-ui.md::
  Per-agent page (Ask → operator inline-answer binding) pointer

app.js: 19 → 0 cookies (100% reduction). Combined with PR #780
+ batch 1 + 2, app.js is fully migrated for #713 scope.

Closes #713 — all agent UI files (app.js, agent.css, screen.html,
index.html, stats.js, stats.html) at 0 issue-ref cookies.
2026-05-31 15:36:23 +02:00
atlas
db2a48cde6 docs/gotchas.md: extract nix/{assets,docs,templates/weston-vnc} prose (#718 batch 3)
- assets.nix: cargo-cache-invalidation rationale → "Split asset
  derivations away from the rust workspace" section.
- templates/weston-vnc.nix: port allocation, weston bind-address
  quirk, PAM service name, Type=simple choice, idle-time=0 →
  "Weston VNC compositor (per-agent hyperhive.gui.enable)" section.
- docs/default.nix: rendering pipeline + subtree-pick + output-tree
  history → "Nix options reference" section.

In-code comments trimmed to short purpose statements + docs pointers.
description = '' blocks (operator-facing options docs) preserved per
iris #718.

`nix flake check` + `nix build .#docs` clean.
2026-05-31 15:24:39 +02:00
iris
f60a90d752 shared terminal + docs: migrate sticky-scroll + backfill prose (#714 batch 1)
Substantial prose migration from @hive/shared/terminal.js (the
shared HiveTerminal factory backing #msgflow + #live across both
dashboard and per-agent UIs) into a new docs subsection.

Added to docs/web-ui.md as a new ### Shared terminal pane
subsection under ## Shape (shared by both) — ~78 lines of new
substantive prose:

- **api shape**: row / details / detailsDiff factory contract
- **Sticky-bottom + snap animation**: stickToBottom semantics +
  140ms ease-out vs 500ms browser default + 24px short-circuit;
  per-frame target re-eval extends destination through
  mid-animation mutations
- **Mid-animation scroll-event guard**: smoothScrollingUntil
  timestamp swallows the rAF-driven scroll events so the eased
  positions don't flip stickToBottom false partway
- **Post-append MutationObserver**: catches renderer mutations
  after api.row returns (badges, multi-line bodies, tool panes)
  + why programmatic scrollTop writes don't feedback-loop
- **Backfill + SSE**: history/stream envelope shape (seq, events),
  kind-aware seq dedupe at the boundary, .no-anim during replay,
  optional streamFactory for SharedWorker integration
- **linkify**: text-node-only autolink, XSS-safe by construction,
  trailing-punctuation strip

Collapsed in terminal.js (cookies en passant):
- #400 (snap animation timing — closed) × 3 → docs pointers
- #393 (post-append MutationObserver — closed) × 1 → docs pointer
- #375 (pre-append nearBottom snapshot — closed) × 1 → docs pointer
- #448 (streamFactory SharedWorker hook — closed) × 1 → docs pointer
- #163 (seq dedupe + onStreamOpen resync — closed) × 2 → drop
  cookies; substance lives in docs
- #233 (linkify) × 1 → docs pointer + terminal.css cookie scrub

terminal.js: 8 → 0 #NNN cookies (100% reduction).
terminal.css: 1 → 0 issue-ref cookies (remaining 1 match is a
hex color literal).
Net effect: ~50 lines of substantive WHY-prose moved out of
shared frontend into docs/web-ui.md, where it documents the
factory's contract for both consumer pages.
2026-05-31 15:18:31 +02:00
iris
b15c534e67 tabs.js: final cookie scrub — 0 issue refs (#712 batch 14)
Final pass on tabs.js. All 6 remaining cookies were either
attribution refs to closed issues / past reviews or pure-history
mile-markers:

- #259 disabled meta-update state → drop attribution
- dashboard.rs#2170 backend handler line ref → drop the line
  number (handler still lives in dashboard.rs, line drifts)
- mara on #695 M0V3 picker any-selection → drop attribution
  (substance already captured in docs/web-ui.md::Selection bar)
- #541 journald scroll surface → drop the migration history
  framing, keep present-state behaviour
- argus on #471 textContent vs innerHTML review → drop the review
  attribution, keep the safer-pattern rationale inline
- #369 tab routing section header → drop the cookie, keep the
  section divider

tabs.js: 6 → 0 issue-ref cookies (100% reduction). Across
batches 1-14 (#712): 73 → 0 issue-ref cookies, with substantive
prose (Topology tree CSS geometry, Container row icon layout,
Pending-state derivation, Selection bar, R3BU1LD QU3U3 cancel +
step annotations, Approval card requested-at, SSE multiplexing
worker-death self-heal, FL0W page MESS4GE FL0W body layout)
migrated to docs/web-ui.md across the batches.

dashboard SPA files all at 0 cookies now (PR #795 covers the
other dashboard files; this PR covers the tabs.js residual).
2026-05-31 15:14:53 +02:00
damocles
0ef79b8032 harness: opt-in UnixListener bind via HIVE_WEB_SOCKET (#784 phase 1)
phase 1 of #784 (unix-domain agent web UI for #14 prerequisite).
backwards-compatible: when HIVE_WEB_SOCKET is set + non-empty, bind a
UnixListener at that path instead of the legacy TCP bind on HIVE_PORT.
empty env var treated as unset so a stray HIVE_WEB_SOCKET= doesn't
trap an un-bindable empty path.

bind_unix helper:
- mkdir -p the socket parent (covers first-boot fresh /run/hive-agent/
  bind-mount target)
- best-effort unlink of stale socket (clean exit removes it, crash
  leaves it; bind(2) refuses to overwrite)
- mode 0o660 so gateway peers in the same unix group can connect (the
  bind-mount source dir ACL is the real gate; perms are defence in
  depth)

axum 0.8's Listener trait covers tokio::net::UnixListener directly
(no extra feature/dep required).

next phases (separate PRs):
- step 2: c0re bind-mounts /run/hive-agent/ + writes agent-sockets.json
  alongside agent-ports.json
- step 3: gateway proxy_pass http://unix:… (atlas)
- step 4: drop TCP bind once gateway no longer needs it
2026-05-31 15:14:46 +02:00
damocles
224385af37 hive-forge: extract format_event helper; tests call it directly (argus 🟡 on #798)
was: `print_event` did the format + println inline; tests had a
parallel `captured` helper that re-implemented the dispatch and only
covered 4 of 14 event types. brittle — a new arm in `print_event`
silently went uncovered.

now: pure `format_event(ev: &Value) -> String` builds the line;
`print_event` is a thin wrapper that adds the trailing newline.
tests assert on `format_event` output directly so every new arm
gets test coverage by construction.

bonus: added 3 more test cases (assignees add/remove, pull_push
commit count + force-push, commit_ref sha truncation) since the
helper extraction made them cheap. 9 tests total, all green.
2026-05-31 15:14:08 +02:00
damocles
8f9866f5e1 hive-forge: timeline verb — issue/PR audit trail (closes #783)
last unstarted piece of the original #694 epic. agents kept falling
back to curl for 'who closed this?' / 'when was this labelled?'
archaeology because view + comments only surface the body + comments,
not the structured timeline events (label adds, assignments, closes,
reopens, pushes, commit refs, review submissions, milestone changes).

separate verb rather than view --timeline because:
- composes naturally with view <n> / comments <n>
- keeps existing verb output shapes stable (no script breakage)
- argus on #770 already noted view's output is busy

human-readable by default ('**actor @ ts**: <summary>'), --json for
raw piping. unknown event types fall through to a '[<type>]'
placeholder so a forge schema bump doesn't panic the verb.

--tail N is a follow-up: timeline endpoint doesn't expose a total-count
field so the count-then-page strategy from #770 doesn't apply
directly.

6 tests cover comment / label add/remove / close / unknown-type
placeholder / missing-user fallback.
2026-05-31 15:14:08 +02:00
damocles
7cc59c4338 hive-forge: update --tail field doc to match count-then-page impl (argus on #770) 2026-05-31 15:12:41 +02:00
damocles
f57cf916a1 hive-forge: count-then-page strategy for comments --tail (mara feedback #770)
read the issue's `comments` count first, compute which page contains the
tail, fetch only `ceil(n/50) + 1` pages. drops the TAIL_MAX_PAGES cap
entirely — it was paging from the WRONG end (first 1000 comments instead
of the last n) on long threads, defeating the whole purpose of --tail.
work is now bounded by n, not by thread length.
2026-05-31 15:12:41 +02:00
damocles
7d31b7def4 hive-forge: warn when --tail saturates the pagination cap (argus 🟡 on #770) 2026-05-31 15:12:41 +02:00
damocles
5f4494c239 hive-forge: comments --tail N flag (#694 part 3) — last N comments in chronological order 2026-05-31 15:12:41 +02:00
iris
f7e38c0b42 common.js + flow.js + index.html + flow.html: cookie scrub + doc pointers (#712 batch 13 cont.)
Final dashboard frontend cleanup. Four files at 0 cookies each.

common.js (11 → 0):
- #406 module-split history → drop, present-state intro
- #448 SharedWorker SSE pipe (×3) → docs/web-ui.md SSE multiplexing
  paragraph pointer
- #515 worker-death self-heal (×3) → docs pointer + brief inline
- #453 bfcache argus-nit (×2) → drop framing, keep inline why
- #451 drag-to-resize → docs/web-ui.md::Side panel pointer
- #233 marked autolinks new-tab → drop cookie

flow.js (8 → 0):
- #406 module-split history → drop
- #389 banner-in-footer → drop framing
- #375 stacking context → docs/web-ui.md::Per-agent page Terminal-wrap
- #408 server-side filter, #499 backend allow-list → drop cookies,
  keep inline why
- #448 SharedWorker per-URL keying → docs pointer
- #163 SSE catchup drift → drop cookie, keep inline why

index.html (13 → 0):
- #389 chrome history → drop
- #459 schedules, #607 matrix, #609 nginx-front, #15 → drop cookies,
  keep functional comments + docs/web-ui.md::Tab strip + gateway.md
  pointers for matrix
- #385 dropped C0NTAINERS heading → drop framing
- #460 reminders-moved-here → drop framing
- #444 SchedulesChanged SSE absence → drop cookie, keep inline why
- #443 selection bar → docs/web-ui.md::Selection bar pointer
- #564 fold-create-into-table → drop framing
- #369#issuecomment-3437 → drop attribution
- #406 step 3 bundle rename → drop history

flow.html (3 → 0):
- #389 slug + #362 pill pattern + #406 step 2 bundle → drop cookies,
  preserve functional descriptions

Total this batch (across all 4 files): **35 cookies scrubbed**.
Net effect: the dashboard SPA's HTML + JS comments all point at
docs/web-ui.md for substantive design context now, with inline
comments only retaining present-state operational descriptions.
2026-05-31 15:09:54 +02:00
iris
6c9b28903f dashboard.css + docs: migrate MESS4GE FL0W body layout + final cookie scrub (#712 batch 13)
Second dashboard.css batch — scrubs all remaining #NNN cookies
(post-batch-12 dashboard.css went from 51 → 36 raw matches, mostly
hex colors). This batch zeroes out the actual issue refs.

Moved to docs/web-ui.md::FL0W page:
- **MESS4GE FL0W (extended)**: msgrow flex-wrap row + body
  flex-basis: 100% + min-width: 0 rationale (~10 lines new prose).
  Without flex-basis: 100% the body sat inline eating whatever the
  chips left, starting ~30ch in on long timestamps + agent names
  and wrapping awkwardly. Pushing it to its own line lets the body
  use the full row width.

Collapsed in dashboard.css (en passant cookie scrubs across many
small comments):
- #485 msgrow body flex (→ FL0W docs pointer)
- #450 inbox max-height (→ inline rationale only, cookie dropped)
- #451 side-panel drag-to-resize × 2 (→ Side panel docs pointer)
- #369 dashboard chrome (→ Tab strip pointer)
- #416 full-width layout (→ inline rationale, cookie dropped)
- #389 slug banner footer (→ inline rationale, cookie dropped)
- #369#issuecomment-3437 + #362 + #383 + #383 + #362 flow.html
  section (→ FL0W page pointer + per-agent header pills pointer)
- #375 tail pill stacking context (→ Per-agent page Terminal-wrap
  pointer to existing docs)
- #66 ctx badge thresholds (→ Container row Line 1 pointer)
- #541 panel-body column flex × 2 (→ inline rationale, cookie dropped)
- #188 / #192 image preview (→ Side panel pointer)
- #275 bulk select × 2 (→ inline, cookies dropped)
- #259 background meta-update (→ inline, cookie dropped)
- #474 inline edit button + form (→ inline, cookies dropped)
- #466 interval composer (→ inline, cookie dropped)
- #535 schedules-as-table × 2 (→ SCH3DUL3S tab pointer + inline)
- #564 inline create row (→ inline, cookie dropped)
- #443 selection bar (→ Selection bar pointer)
- #486 M0V3 → <pick> (→ Selection bar pointer)
- #459 scheduled prompts tab section (→ SCH3DUL3S tab pointer)

dashboard.css: ~20 issue refs scrubbed in this batch + ~10 lines
substantive prose migrated to MESS4GE FL0W. Total dashboard.css
across batches 12 + 13: 51 raw matches → 0 issue-ref cookies
(only hex colors remain).
2026-05-31 15:09:54 +02:00
lexis
eccceeaadf docs(CLAUDE.md): add docs/gateway.md to file index + reading paths (follow-up to #775 #793) 2026-05-31 15:07:06 +02:00
atlas
24775845a3 nix/hive-gateway: static not-found + unreachable pages for /agent/<name>/ (#755)
mara on #755: "e.g. /agent/name should show an error page stating
that the agent could not be found if missing in json or that it is
not reachable if we get a connection error. we dont want a fully
generic fallback, only for routes already special cased in the
nginx config."

Adds two static HTML pages built at deploy time via
`pkgs.runCommand "hyperhive-agent-error-pages"`:

- **not-found.html** — served when `/agent/<unknown>/...` hits the
  bare `/agent/` catch-all. The catch-all `return 404`s, and
  `error_page 404 = /__hive_agent_not_found` rewrites to the static
  page.
- **unreachable.html** — served when `/agent/<known>/...` proxy_pass
  to the harness returns 502 / 503 / 504. `proxy_intercept_errors
  on` + `error_page 502 503 504 = /__hive_agent_unreachable` on each
  per-agent location block rewrites to the static page.

Mechanics:

- `agentErrorPagesDir` (in the `let` block) is a `runCommand` that
  emits two HTML files using a `<<EOF` heredoc — no template engine
  needed.
- Two `internal` nginx locations (`= /__hive_agent_not_found`,
  `= /__hive_agent_unreachable`) `alias` the exact files. `internal`
  keeps the URIs unreachable from direct operator request — only
  nginx's own error-handling can hit them.
- Per-agent location blocks pick up the `error_page` directive
  through the existing `lib.mapAttrs'` over `agentPortsTable`. No
  per-agent generated content; same static page for all.
- `/agent/` catch-all generates from a tiny optionalAttrs alongside
  the per-agent block — both are no-op when the agent table is
  empty (matches the pre-#15 shape).

Pages: minimal inline CSS, catppuccin palette matching the
dashboard (`#1e1e2e` bg, `#cdd6f4` text, `#cba6f7` not-found heading,
`#f9e2af` unreachable heading). No frontend-dist dependency — render
even when hive-c0re is down. Both link back to `/`.

Per mara's "only for routes already special cased" — scope stays
narrow. Forge / matrix / fluffychat keep nginx defaults; extending
the custom-error pattern to other vhosts is a separate follow-up
if/when needed.

Verified:
- nginx location attrset has `["/", "/agent/", "= /__hive_agent_not_found", "= /__hive_agent_unreachable"]`
- container toplevel builds clean (`nixos-system-hive-gateway-26.05pre-git`)
- `docs/gateway.md::Per-agent error pages` section captures the
  design + rationale + intentional narrowness

Closes #755.
2026-05-31 15:04:54 +02:00
atlas
ba5a6181fc docs/gateway.md: add host-side-services.forgejo conflict-avoidance to container-shape section (argus #793 🟡) 2026-05-31 15:04:02 +02:00
damocles
2ada0e22ca harness: collapse Surface::DEFAULT_LABEL → single 'hive' fallback (#788)
closes #788. drops the per-role HIVE_LABEL fallback const ('hive-ag3nt'
on AgentSurface, 'hm1nd' on ManagerSurface) and replaces both with a
single literal 'hive' at the env-var unwrap site. real deploys set
HIVE_LABEL unconditionally via the meta-flake envelope; the fallback
is unreachable in production and there's no semantic reason for it to
differ per role.

nix-side standalone-eval fallback (HIVE_LABEL = 'hm1nd' in the
manager systemd unit) stays — that's wired so 'nixos-rebuild against
nixosConfigurations.manager' produces a sensibly-labelled container
even without the meta-flake wrapper.

Surface trait now: FLAVOR + FORGE_IS_MANAGER + 7 async wire methods.
next steps per #778 roadmap: #691#786#789.
2026-05-31 15:02:28 +02:00
atlas
ea90814809 docs/gateway.md: extract hive-c0re + hive-forge prose blocks (#718 batch 2)
Continues #718 docs-extraction. Three more blocks moved to
`docs/gateway.md` (which already houses the gateway architecture
story from #775):

1. **Firewall posture (gateway on vs off)** — was a 22-line block
   above `networking.firewall = lib.mkIf ...` in hive-c0re.nix.
   Trimmed to 3-line ref. New `docs/gateway.md::Firewall posture
   (host-level)` section covers the gateway-on / gateway-off
   trade-off + why dashboard port stays loopback-only.

2. **`HIVE_FORGE_URL` loopback rationale** — was a 14-line block
   above the env-var assignment. Trimmed to 5-line ref. New
   `docs/gateway.md::HIVE_FORGE_URL: loopback for in-cluster,
   sub-domain for the operator` section covers the in-cluster vs
   external split + why agent containers can't use the sub-domain.

3. **hive-forge container shape** — was a 15-line top-of-`config`
   block in hive-forge.nix explaining the nixos-container + host
   netns choices. Trimmed to 4-line ref. New
   `docs/gateway.md::hive-forge container shape` section captures
   the same content with state-dir + wipe-via-destroy notes.

Net: hive-c0re.nix -29 lines, hive-forge.nix -11 lines, gateway.md
+44 lines. Same pattern as #782 (first pass) per iris's #10114
guidance — substantive WHY moves to docs as named sub-paragraphs,
in-code shrinks to `// see docs/<file>::<section>` refs.

Verified: `nix eval` on agent-base toplevel still resolves
cleanly; firewall posture unchanged (still 0 ports opened in the
gateway-on case + the same 8100..8999 range in the gateway-off
case).

Continues #718. Follow-up batches: remaining harness-base.nix
blocks, nix/docs/default.nix, nix/assets.nix, nix/templates/weston-vnc.nix.
2026-05-31 14:57:50 +02:00
damocles
df71d8deac harness: drop manager special case from plugins notify (#778, mara feedback)
mara on #778: 'remove the manager special case argus nitted about'.

`plugins::install_configured` no longer takes a `notify_recipient`
hardcoding "manager". Now returns a Vec<String> of failure messages;
serve_main<S> iterates them and routes each through S::send_to_parent
— the same <parent> sentinel failure-notify uses everywhere else
(#703). Manager plugin failures now reach operator via root → operator
fallback (improvement on the pre-PR silent-drop).

Also rename FORGE_MENTIONS_ONLY → FORGE_IS_MANAGER to fix the misnomer:
the boolean picks which wire enum (AgentRequest::Wake vs
ManagerRequest::Wake) the forge_notify poller uses, not anything about
mentions-only filtering (that's a separate nix-side option). Real fix
is to lift Surface into the lib crate and make forge_notify::run
generic; deferred to its own issue.

Net: -20 LOC.
2026-05-31 14:53:59 +02:00
damocles
c3b5f970c1 harness: collapse per-role helpers behind Surface trait (#692 v2)
mara on #778: "I would have expected the manager and agents to share
the exact same turn function, making one obsolete. I don't see that
in the code, why not?" — fair. went further.

introduces a Surface trait + AgentSurface / ManagerSurface zero-sized
impls wrapping the disjoint Request/Response enums + boot-time
constants (FLAVOR / DEFAULT_LABEL / PLUGINS_PARENT / FORGE_MENTIONS_ONLY).
the turn loop itself collapses to one generic implementation:

- serve_main<S> replaces agent_serve_main + manager_serve_main
- serve_loop<S> replaces agent_serve_loop + manager_serve_loop
- handle_turn<S> replaces handle_agent_turn + handle_manager_turn
- wake<S> replaces agent_wake + manager_wake

RecvOutcome enum decouples the per-role Response shape from the loop's
match arms so serve_loop never sees either enum.

main's dispatch picks the type parameter from HIVE_ROLE; everything
downstream is identical by construction.

net: -62 LOC vs main even with the new manager notify-on-failure +
continue-sentinel features kept.
2026-05-31 14:53:59 +02:00
damocles
9bcd6976fe harness: converge agent + manager turn paths (#692)
three shared helpers replace the duplicated pre-#598 patterns:

- `log_system_event` lifts the HelperEvent parse + bus emit out of
  handle_manager_turn so agents log QuestionAnswered/ContainerCrash/
  reparent notifications the same way (#692 part 1).
- `format_turn_failure` produces the failure-notification body using
  identity::qualified_label() instead of a label param threaded through
  three layers. drops `label` from handle_agent_turn, agent_serve_loop,
  agent_check_and_inject_continue.
- `consume_continue_sentinel` lifts the file-probe so both surfaces
  reuse it (#692 part 2 — sentinel now works for manager too).

agent_notify_manager_of_failure → agent_notify_parent_of_failure: routes
via the <parent> sentinel landed in #703 instead of the literal string
'manager'. mirrored on manager side; root-manager failures resolve to
operator via topology::resolve_recipient.

handle_*_turn signatures now identical modulo the wire-type prefix
(part 3 acceptance from the issue).
2026-05-31 14:53:59 +02:00
iris
9c72fd369a dashboard.css + docs: migrate topology indent + icon/pending prose (#712 batch 12)
dashboard.css carried many CSS WHY-blocks duplicating prose
already in docs/web-ui.md (after PRs #722/#724/#728/#776).
Substantive new migration: topology indent geometry. Remaining
cookies collapse to docs pointers.

Moved to docs/web-ui.md::Topology tree:
- Indent + lane geometry paragraph (~18 lines new prose):
  per-depth 1.8em ladder rationale + hardcoded six levels +
  CSS Values 5 attr() partial-support caveat (Chromium-only as
  of 2026) + lane center continuation bars + bottom: -0.4em
  prefix extension to merge sibling vertical lines + horizontal
  stub at icon midline.

Collapsed in dashboard.css:
- Topology indent block (#363/#361) → 3-line pointer
- Tree prefix lanes block (#388) → 3-line pointer
- Tree prefix depth-step ladder (~6 lines) → 4-line tighter
  paraphrase (lane-meets-icon rationale stays inline as it's
  immediate context for the values below)
- Live cards icon-left split (#177/#344) → 6-line tighter
  paraphrase + docs pointer
- Icon selection-toggle hover/focus (#443) → docs pointer
- Icon img absolute positioning (#177) → docs pointer
- Icon favicon fallback (#195/#202) → docs pointer
- Pending state queued vs running (#769) → docs pointer
- Icon-only nav strip (#262/#333) → 7-line tighter + docs pointer
- Approval requested-at chip (#272) → 2-line pointer
- In-flight step indicator (#437) → 4-line pointer
- M0V3 affordance (#486) → 3-line pointer
- Cancel-X queued (#575) → 5-line pointer

dashboard.css: 51 → 36 cookies (counting hex-color false positives;
~15 actual issue refs scrubbed). Net ~50 lines of CSS WHY-prose
collapsed to brief docs pointers; ~18 lines of new substantive
prose migrated to docs/web-ui.md::Topology tree.
2026-05-31 14:48:46 +02:00
iris
dfbaa2654b agent/index.html: collapse header comments to docs pointers (#713 batch 3 cont.)
3 #NNN cookies scrubbed from inline HTML comments. The header
two-row layout (#394) and the overflow-extra-click rationale
already live in docs/web-ui.md::Per-agent page (after PRs #780
and #781 batches 1-2). Each comment shrinks to a doc-pointer.

index.html: 3 → 0 #NNN cookies (100% reduction).
2026-05-31 14:47:32 +02:00
iris
815f6561b7 screen.html + docs: migrate canvas-sizing + RFB design prose (#713 batch 3)
Expanded the /screen endpoint description in docs/web-ui.md to
cover the substantive design rationale that lived in screen.html
comments. screen.html shrinks correspondingly.

Moved to docs/web-ui.md::Per-agent endpoints (GET /screen):
- Deliberate thinness — minimal RFB renderer; noVNC vendoring
  path called out for production-grade replacement.
- Fit-mode flex-item min-width:auto clamp: a flex item's automatic
  minimum size resolves to the canvas's intrinsic framebuffer
  resolution and clamps CSS max-* back up, making fit mode a
  silent no-op. The fix (flex: none + min-width: 0 + min-height: 0
  + explicit px sizing via relayoutCanvas()) is now documented.
- localStorage persistence for fit-mode (key screen-fit; default
  on).
- Pointer rescale in sendPointer keeps clicks accurate.
- ExtendedDesktopSize pseudo-encoding (-308 rect) gates the
  match-size button.

Collapsed in screen.html: 8 #NNN cookies scrubbed across all
inline comments. #133 (canvas-sizing fit bug — closed) ×5,
#52 (noVNC vendoring — closed) ×1, #14 (relative URL — closed
my piece) ×1, plus one CSS-block #133. Each comment shrinks to
a brief pointer.

screen.html: 8 → 0 #NNN cookies (100% reduction). Net ~26 lines
of substantive prose moved into docs/web-ui.md.
2026-05-31 14:47:32 +02:00
iris
b07d74b51b agent.css + docs: migrate icon sizing + popover :not([hidden]) prose (#713 batch 2)
agent.css carried 17 #NNN cookies, mostly attribution refs to the
#394 vibec0re overhaul (mara's full-screen redesign). Most were
not substantive WHY-prose — just "this exists because of #394"
breadcrumbs. The two genuine WHY-explanations move to docs.

Moved to docs/web-ui.md::Per-agent page:
- **Agent icon paragraph**: rewrote with explicit-em-sizing
  rationale (intrinsic <img> dimensions push parent flex container
  open via align-items: stretch height feedback) + the 5em ≈ 6em
  min-height - 0.5em padding × 2 derivation + align-self:
  flex-start sticks-to-top.
- **Overflow button paragraph**: added the :not([hidden]) display
  scoping rationale (UA stylesheet sets display:none on [hidden],
  but author display:flex would override — scope to :not([hidden])
  so the popover stays hidden until JS unhides).

Collapsed in agent.css: 17 cookies scrubbed across the file:
- #360 (full-screen vibec0re overhaul — closed) ×2: section
  header preface + side-panel section header
- #394 (vibec0re header redesign — closed) ×7: header height,
  main column, agent icon, meta-nav, overflow trigger, two
  orphaned-style tombstones
- #411 (popover scoping + icon align-start — closed) ×2: icon
  sticks-to-top + overflow popover scoping
- #568 (OAuth code mask + reveal — closed) ×1: show/hide toggle
- #666 (ask→operator inline-answer slot — closed) ×1: slot
  styling (substance partly in PR #780 docs section)
- #559 (mark all read header row — closed) ×1
- #376 (inbox row layout — closed) ×1: long-message wrap
- #375 (tail pill z-index — closed) ×1: collision fix

agent.css: 17 → 0 refs (100% reduction, fully migrated).
2026-05-31 14:47:32 +02:00
damocles
ea5f70629c harness-base: generic bash-env.sh + _bashEnvFragments accumulator (mara on #779)
mara: 'if we replace it with one thing, that should be named more
generic so we dont have to change it for future additions'.

extract the BASH_ENV plumbing into a shared shape:

- new internal option `hyperhive._bashEnvFragments` (types.lines)
  accumulates shell snippets across feature modules.
- file path is now `/etc/hyperhive/bash-env.sh` (was the
  cargo-specific bash-cargo-short.sh).
- the file + BASH_ENV + interactiveShellInit are gated on
  `_bashEnvFragments != """ so a fully feature-disabled agent has
  no overhead.

cargo function moves to a `lib.mkIf cargo.shortMessages` contribution
to `_bashEnvFragments` — same behaviour, no rename when the next
hook (nix-env helper, claude-cmd helpers, whatever) lands.
2026-05-31 14:46:29 +02:00
damocles
5f61528133 harness-base: cargo --message-format short by default (#777)
closes #777. saves tokens by collapsing per-crate progress lines into
warning/error summaries when claude (or the operator) runs cargo
inside an agent container.

implementation: /etc/hyperhive/bash-cargo-short.sh defines a 'cargo'
bash function that injects '--message-format short' on compile
subcommands (build/check/clippy/test/run/doc/bench/install/rustc/fix).
loaded via BASH_ENV in non-interactive shells (claude's Bash tool
runs 'bash -c') and via programs.bash.interactiveShellInit in
interactive shells (operator SSH inside the container).

handles the '+toolchain' selector (cargo +nightly build), skips
injection when the caller already passes --message-format (any
form), leaves third-party cargo-* subcommands alone.

new option: hyperhive.cargo.shortMessages (default true) — agents
that parse cargo json output should set false.
2026-05-31 14:46:29 +02:00
atlas
309879dba0 docs: extract 3 substantive harness-base.nix prose blocks (#718, first pass)
iris's #718 scope: move substantive design context from `#` comment
blocks in `nix/` to corresponding `docs/` files, leave short
references in code. iris handed it back to me on #10114 since
nix/ is my lane + #775 established the pattern.

First pass — three highest-density blocks in harness-base.nix:

1. **First-boot agent-user migration** (~70 lines → `~20 lines code +
   short ref` in the activation script). Substantive prose moves to
   new `docs/persistence.md::First-boot agent-user migration (post-#658)`
   section explaining the 4 steps the script performs + the eventual
   removability of the marker-guarded body.

2. **nix-daemon `sandbox-fallback = true`** (10-line block → 5-line
   ref). New `docs/gotchas.md::Containerized nix-daemon needs
   sandbox-fallback = true` section covers the user-namespaces
   rationale + nixpkgs-default override.

3. **Matrix daemon + token-arrival trigger** (~50 lines across two
   systemd units → ~10 lines code + short refs). New
   `docs/persistence.md::Matrix per-agent daemon + token-arrival
   trigger` covers the socket-path rationale, the runtime-dir
   ownership story, and the first-boot ordering pattern.

Net: harness-base.nix -84 lines, docs +74 lines. Substantive design
context moves to durable docs; in-code refs follow iris's pattern
from her #712 batches (`see docs/<file>::<section>`).

Follow-ups: hive-c0re.nix, hive-forge.nix, hive-matrix.nix (already
trimmed via #775 but a couple of remaining blocks could go), and
the smaller files in #718's scope table. Shipping this first to get
the pattern reviewed before larger batches.

Verified: `nix eval` on agent-base toplevel still resolves.
2026-05-31 14:44:12 +02:00
44 changed files with 3146 additions and 1849 deletions

View file

@ -295,6 +295,10 @@ docs/
boundary.md operator/agent trust model rationale
agent-hierarchy.md tree-shape topology design + manager-privilege audit (#361)
damocles-migration.md future migration plan for damocles → hyperhive
gateway.md nginx vhost map, matrix discovery flow, firewall posture,
HIVE_FORGE_URL loopback rationale (#764, #772, #793)
matrix.md matrix container shape, serverName/gatewayHost split,
firewall + federation, provisioning flow, fluffychat-web build
```
## Reading paths
@ -316,6 +320,11 @@ read them à la carte.
pattern."** → [`docs/conventions.md`](docs/conventions.md).
- **"Why does the nspawn flag look like that?"** →
[`docs/gotchas.md`](docs/gotchas.md).
- **"What nginx vhosts does the gateway serve? How does matrix
discovery work?"** → [`docs/gateway.md`](docs/gateway.md).
- **"How does the matrix-tuwunel container work? What about
fluffychat-web and per-agent matrix accounts?"** →
[`docs/matrix.md`](docs/matrix.md).
## Conventions & process

View file

@ -1,7 +1,7 @@
# Agent hierarchy & privileges
Design + audit doc for milestone #6 (the
[issue](http://localhost:3000/hyperhive/hyperhive/issues/361) tree).
Design + audit doc for the agent-privileges + tree-shape milestone
(the [issue tree](http://localhost:3000/hyperhive/hyperhive/issues/361)).
The implementation lands in pieces; this doc tracks what's done, what's
planned, and what currently special-cases the manager.
@ -20,7 +20,7 @@ Topology lives in the hive-c0re-owned **meta repo**, alongside
`null` = root-level agent. Today only the manager qualifies by default.
Other agents land under `"manager"` on first sync. Re-parenting is
operator-driven (#486 / #487):
operator-driven:
- CLI: `hive-c0re set-parent <child> --parent <new>` (or `--root` to
promote). Exactly one of `--parent` / `--root` is required.
@ -34,11 +34,11 @@ validation rules to a pure `apply_set_parent` helper. Refuses:
- self-parenting,
- cycles (32-hop ancestor walk, mirroring `is_descendant_of`).
Post-#743 the manager is reparentable like any other agent — the
"structurally root" carve-out was historical paranoia; the manager's
privileges live on its MCP socket, not its tree position, and the
cycle walk above catches the only real safety concern (moving the
manager under one of its own descendants).
The manager is reparentable like any other agent — there's no
"structurally root" carve-out; the manager's privileges live on its
MCP socket, not its tree position, and the cycle walk above catches
the only real safety concern (moving the manager under one of its
own descendants).
Idempotent no-op fast path skips the disk write when the parent is
already what's requested. After a successful write the surfaces call
@ -46,13 +46,12 @@ already what's requested. After a successful write the surfaces call
viewers see the tree repaint without polling
(`ContainerView.parent` is sourced from `topology.json`).
**Today's caveat (#361 follow-up):** the move is purely a JSON edit.
Only the top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
**Today's caveat:** the move is purely a JSON edit. Only the
top-level manager (`hm1nd`) gets `/var/lib/hyperhive/agents`
bind-mounted at `/agents` in its container, so sub-agents don't yet
see their would-be children's state. Once sub-manager bind mounts
land alongside #361 enforcement, `set_parent` grows a companion
umount-old / mount-new / restart-cascade step (tracked via the
cross-ref comment on #361).
land alongside cap enforcement, `set_parent` grows a companion
umount-old / mount-new / restart-cascade step.
### Why meta, not per-agent `agent.nix`
@ -78,7 +77,7 @@ where system-level facts live.
so the harness / claude prompts can see it.
4. **Surface**: `container_view::build_all` reads `topology.json` and
populates `ContainerView.parent: Option<String>` on every rescan.
The dashboard renders the field as a tree (#363 follow-up).
The dashboard renders the field as a tree.
## Target topology semantics
@ -150,7 +149,7 @@ Tree-shape version:
- `request_update_meta_inputs` is the only path that actually writes
`flake.lock`, gated by the cap; everyone else stays RO.
### D — drop legacy `/state` for manager ✓ done (#604)
### D — drop legacy `/state` for manager ✓ done
`lifecycle.rs` no longer binds `/state` for the manager.
`HYPERHIVE_STATE_DIR` is now injected uniformly via
@ -165,8 +164,8 @@ read.
- `prompts/system.md` with `<!-- role:agent -->` / `<!-- role:manager -->`
marker blocks, assembled by `hive_ag3nt::prompt::render` based on
flavor (closes #519). **Per-agent cap list** of what the agent can
do — already a single parametrised prompt; once #513 lands the
flavor. **Per-agent cap list** of what the agent can do — already
a single parametrised prompt; once per-agent cap groups land the
marker grammar grows `cap:<group>` blocks the renderer reads from
the per-agent ToolGroup set.
- `mcp.rs::Flavor::{Agent, Manager}` controls which MCP tools claude
@ -182,8 +181,7 @@ read.
descendants'.
- `operator_questions.rs` + `broker.rs`: "manager can cancel any
question" override on the owner check. **Topology** — agents can
moderate threads of their descendants. (per mara's
https://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3344)
moderate threads of their descendants.
- `reminder_scheduler.rs`: same override pattern for reminder cancel.
**Topology** — descendants only.
- `actions.rs`: `destroy` refuses to act on `MANAGER_NAME` (no
@ -196,9 +194,9 @@ read.
### G — sub-agents inside the same container
Future work mentioned in #361: when enabled for an agent, it can spawn
temporary "sub-agents" that run inside its own container. Lighter than
a full nspawn agent. Open questions, not yet wired:
Future work: when enabled for an agent, it can spawn temporary
"sub-agents" that run inside its own container. Lighter than a full
nspawn agent. Open questions, not yet wired:
- Inherit caps from parent, or take an explicit narrower set?
- Survive container restart, or always ephemeral?
@ -206,9 +204,102 @@ a full nspawn agent. Open questions, not yet wired:
- Filesystem: share parent's `/state` RW, or a sub-dir?
- Identity: distinct broker recipient name, or address the parent?
## Harness systemd unit shape (per-role)
One harness binary (`hive`), one `harness-base.nix` template, two
systemd units depending on `hyperhive.role`:
- `agent-base.nix` (`role = "agent"`) → `systemd.services.hive-ag3nt`
- `manager.nix` (`role = "manager"`) → `systemd.services.hive-m1nd`
The unit names diverge but the binary is the same. `HIVE_ROLE` env
var picks the surface at startup (agent vs manager); naming the
units after the historical per-role binaries keeps dashboard log
queries, ExecStartPre paths, and ancestor PR diffs working without a
rename cascade.
### Manager-only defaults
`harness-base.nix` flips these when `hyperhive.role == "manager"`,
via `lib.mkDefault` so any agent can invert if needed:
- `hyperhive.forge.keepSubscriptions = false`
- `hyperhive.forge.skipNotifyReasons = [ "subscribed" "participating" ]`
Skips the subscription / participation firehose so the manager's
inbox only carries direct mentions, reviews, and assignments. Sub-
agents keep the noisier defaults so they see anything aimed at the
repos they're working on.
### Standalone-eval fallbacks
`nixosConfigurations.manager` must build standalone (without the
meta-flake's per-agent flake.nix wrapper). For the manager unit
that means hardcoded `HIVE_PORT` / `HIVE_LABEL` env values:
- `HIVE_PORT = "8875"` — FNV-1a(`"hm1nd"`) % 900 + 8100, matching
`lifecycle::agent_web_port`. Sub-agents have the same shape via
the meta-flake-generated `applied/<name>/flake.nix`.
- `HIVE_LABEL = "hm1nd"` — container name; matches what `meta.rs`
injects at deploy time.
Real deploys never read these — `meta::render_flake` overrides them
via the generated wrapper. They exist so the manager
`nixosConfigurations` evaluates cleanly even outside the meta-flake
boundary.
### Environment variables set on the unit
- `HOME = /home/<userName>` — systemd defaults `HOME` to `/` for
services without `User=` set; with the per-agent user (#658) the
harness needs the right home so claude finds its bind-mounted
`~/.claude/` session dir.
- `HIVE_STATIC_DIR = <mergedDist>``tower_http::ServeDir` root for
the per-agent web UI; merged dist = agent default + every
`hyperhive.frontend.extraFiles` overlay.
- `HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive` — set
directly on the unit, **not** via `environment.variables`, because
the latter only populates `/etc/profile` which systemd services
don't inherit.
- `HIVE_ROLE = config.hyperhive.role` — picks the binary surface
(agent / manager) at startup.
### `PATH` setup (the wrapper-dir trick)
```nix
path = [ "/run/wrappers" "/run/current-system/sw" ];
```
`/run/wrappers` comes first so setuid wrappers (notably `sudo`)
resolve before bare nix-store binaries. NixOS's
`systemd.services.<unit>.path` appends `/bin` to every entry via
`lib.makeBinPath`; passing `/run/wrappers/bin` directly produces
`/run/wrappers/bin/bin` which doesn't exist (`docs/gotchas.md::
systemd.services.*.path appends /bin to every entry`). Post-#658
when the harness runs as the per-agent user this matters: without
the wrapper dir on PATH, `sudo` resolves to the un-setuid nix-store
binary and rejects with `must be owned by uid 0 and have the setuid
bit set` regardless of `hyperhive.user.passwordlessSudo`.
### `serviceConfig` highlights
- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary,
surface picked from `HIVE_ROLE`.
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
resilient across transient crashes without thundering retries.
- `RuntimeDirectory = "hive-config"``/run/hive-config/` owned by
`User=`, auto-cleared on stop. The harness writes regenerated
`claude-{mcp-config,settings,system-prompt}` files there
(`paths::config_dir`). Deliberately separate from `/run/hive`,
which the host bind-mounts in root-owned and which holds
hive-c0re's `mcp.sock` (#658 fixup).
- `User = Group = userName` — drops root inside the container; sudo
is the explicit escalation surface
(`hyperhive.user.passwordlessSudo`).
## Cross-references
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
- Dashboard render: [#363 "show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363)
- Audit table source: [comment 3335 on #361](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
- Milestone: ["Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
- Dashboard render: ["show agent topology in container list"](http://localhost:3000/hyperhive/hyperhive/issues/363)
- Audit table source: [milestone comment](http://localhost:3000/hyperhive/hyperhive/issues/361#issuecomment-3335)
- Operator/agent trust boundary (orthogonal axis): [`boundary.md`](boundary.md)

View file

@ -30,7 +30,7 @@ happens after a decision lands.
applied.
3a. **Flake validation (ApplyCommit only):** after the proposal tag
is planted, hive-c0re reads `proposal/<id>:flake.lock` and
runs two checks (closes #317). If either check fails, no
runs two checks. If either check fails, no
pending approval is created for the operator — the row is
marked failed and surfaces on the dashboard with the
validation message:
@ -66,7 +66,7 @@ happens after a decision lands.
### Withdrawing a pending approval
The manager can call `cancel_loose_end(kind: "approval", id)` to
withdraw an approval that hasn't been acted on yet (closes #250).
withdraw an approval that hasn't been acted on yet.
The row transitions to `ApprovalStatus::Cancelled` (distinct from
`Denied`/`Failed`), the dashboard pulls the card out of the
pending pane, and `ApprovalResolved { status: "cancelled" }` fires
@ -202,7 +202,7 @@ approval id to retry. Because tags are first-class git objects,
rejected and failed trees stay browsable forever — `git log
--tags` in the applied repo is the audit trail.
### Dispatch via `rebuild_queue` (#441)
### Dispatch via `rebuild_queue`
Long-running approval work — `ApplyCommit`, `UpdateMetaInputs`,
`Spawn` — no longer runs inline inside `actions::approve`. Instead
@ -344,7 +344,7 @@ Differences from sub-agents:
(vs `agent-base`).
- Container name is `hm1nd` (no `h-` prefix).
- Web UI port via `lifecycle::agent_web_port("hm1nd")` — same
FNV-1a hash as every other agent (8100..8999 range) since #753.
FNV-1a hash as every other agent (8100..8999 range).
- `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents`
`/agents` (RW) so the manager can edit per-agent proposed repos,
and `/var/lib/hyperhive/applied``/applied` (RO) so the manager
@ -424,8 +424,8 @@ regular claude turn so the manager can react. Variants
such transient was cleared in the last 30s (`RECENT_TRANSIENT_GRACE`
tombstone, three `POLL_INTERVAL`s — closes the race where a
lifecycle op finishes between two crash-watch polls and the
container shows briefly as "stopped without transient" before the
next start, #425). Manager can `start` it again or escalate.
container shows briefly as "stopped without transient" before
the next start). Manager can `start` it again or escalate.
- `NeedsLogin { agent }` — sub-agent has no claude session yet.
Manager can't act directly (interactive OAuth); typically flags
the operator.

View file

@ -10,7 +10,7 @@ exist because something already went wrong without them.
- The manager is `hm1nd` (no `h-` prefix, fixed name).
- `MAX_AGENT_NAME` in `lifecycle.rs` enforces the cap.
- Per-agent web UI port = `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE`
(8100..8999) for every agent including the manager (#753); dashboard
(8100..8999) for every agent including the manager; dashboard
`cfg.dashboardPort` (default 7000).
## Identity = socket
@ -32,12 +32,12 @@ angle-bracket and asterisk shapes below are structurally safe.
(`agent_server::handle_send` fans out via `Coordinator::broadcast_send`).
- `operator` — the human at the dashboard. Messages accumulate in the
inbox view; no agent ever `recv`'s them.
- `<parent>` — the sender's parent per `topology.json` (`#692`).
Rewritten at send time by `topology::resolve_recipient`: looks up
- `<parent>` — the sender's parent per `topology.json`. Rewritten at
send time by `topology::resolve_recipient`: looks up
`parent_of(sender)` and falls back to `operator` when the sender is
a root agent (or absent from topology entirely). Lets agents address
their parent without learning the label, so runtime reparenting
(`#486`) propagates with zero agent-side restart.
propagates with zero agent-side restart.
When the resolver rewrites `<parent>`, the broker stores the
*resolved* label as the message's recipient — the dashboard and
@ -54,6 +54,43 @@ each frame carries a `seq` field for the snapshot-dedupe dance
— change them in one place. The dashboard event vocabulary lives
in `hive-c0re::dashboard_events::DashboardEvent`.
### Broker delivery + ack cycle
`AgentRequest::Recv` is the only path that delivers messages to an
agent. Always returns a list (`Messages { messages }`) — empty when
nothing's pending, single-pop when `max = None` (default 1, the
single-message behaviour), batched up to `max` when caller asks for
more (server-side cap is 32; values above clamp silently).
`wait_seconds` long-polls for the first message; once one arrives —
or one is already pending — the call drains up to `max` in total
before returning, so a single `Recv` call coalesces a burst.
Per-row bookkeeping inside the broker:
- `delivered_at = NOW` set on every popped row.
- Each recipient has an in-memory `unacked_ids` list of every row
delivered since the last `AckTurn`.
- `redelivered = true` on a row if `RequeueInflight` resurfaced it
(the harness prepends a "may already be handled" hint when this
flag is set so the per-message warning is visible).
`AgentRequest::AckTurn` closes out the in-memory list — the harness
fires it after `TurnOutcome::Ok`, marking every message popped since
the last ack as fully handled. Claude doesn't see this surface; it's
strictly a harness↔broker pairing. On `TurnOutcome::Failed` the
harness intentionally skips the ack so the unacked rows stay
in-flight in the DB and get picked up by the next requeue sweep.
`AgentRequest::RequeueInflight` is the recovery pair: fired by the
harness exactly once at boot, before the serve loop starts. Catches
the crashed-mid-turn / OOM-killed / container-restarted cases where
a previous harness session popped messages but never drove them to
a clean turn-end. Resets `delivered_at` back to NULL on every
unacked row (so the next `Recv` pops them again), and remembers
each id in a per-recipient in-memory set so the next `Recv` can tag
the row with `redelivered: true`. Idempotent + cheap when there's
nothing in flight, so the at-boot fire is unconditional.
## Async forms
Dashboard + per-agent mutating forms carry `data-async`; a delegated

View file

@ -76,3 +76,92 @@ SSH for forge stays direct on `cfg.sshPort` — separate listener protocol, not
- #772 / #775 — fluffychat hops from `<hive>/matrix/` to `matrix.<hive>/`.
Next-up tracked separately: #14 (container netns isolation), TLS (#594).
## Firewall posture (host-level)
`hive-c0re.nix` opens the per-agent web-port range
`8100..8999` in the host firewall **only when
`services.hyperhive.gateway.enable = false`**. With the gateway on
(default), it's the sole external entry point and proxies to
`127.0.0.1:<port>` internally — leaving the per-agent ports
firewall-open would defeat the single-front-door story (closes
#621).
Manager hashes into the same range since #753 (no more
"manager pinned at 8000" special case), so one range opening covers
every container.
The dashboard port (`cfg.dashboardPort`, default 7000) is *not*
listed in either case — since #652 it binds `127.0.0.1` only, so a
firewall hole would be a no-op. Remote dashboard access flows
through the gateway. Operators who opt out of the gateway lose
external dashboard reach by design — the surface is privileged
(approve / deny / destroy) and must not be exposed without a real
reverse proxy in front.
## `HIVE_FORGE_URL`: loopback for in-cluster, sub-domain for the operator
Agents poll `HIVE_FORGE_URL` for Forgejo notifications + run all
`hive-forge` calls against it. `hive-c0re.nix` pins this to
`http://127.0.0.1:<forge.httpPort>` for the in-cluster path: every
agent container shares the host's network namespace, so loopback
reaches the forge container directly with no DNS lookup needed
(closes #761).
The post-#754 sub-domain default (`forge.<hive-domain>`) is for
**operator browsers + cross-host clients**, not in-cluster traffic.
Using the sub-domain URL inside agent containers would fail every
`hive-forge` invocation with "Name or service not known" — the
agent's nspawn doesn't have DNS for the external hostname.
## hive-forge container shape
Private Forgejo wrapped in a nixos-container (`hive-forge`, not
`h-*` — keeps c0re's lifecycle scanner out of the picture; the
operator manages it via the standard `nixos-container` CLI). The
container also keeps hive-forge from fighting any `services.forgejo`
the operator already runs on the host — separate systemd namespace,
separate state dir, separate port unless the operator deliberately
collides.
Container shares the host network namespace
(`privateNetwork = false`) so agents reach the forge at
`http://localhost:<httpPort>` without extra plumbing — nixos-container
is here for state + systemd-unit isolation, not network isolation.
State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/`
and survives container restart / host reboot. To wipe, destroy the
container.
## Per-agent error pages
`/agent/<name>/` requests hit two failure modes; both get static
HTML pages instead of nginx's default error chrome (#755):
- **Agent not found** (`/agent/<unknown>/...`) — name isn't in
`agentPortsTable`. nginx's prefix match falls back to the bare
`/agent/` catch-all, which `return 404`s and `error_page 404` rewrites
to `/__hive_agent_not_found` → serves `not-found.html` with a link
back to the dashboard.
- **Agent unreachable** (`502 / 503 / 504` from `proxy_pass`) — the
per-agent harness isn't responding (container restarting, crash
recovery, etc.). `proxy_intercept_errors on` + `error_page 502 503
504 = /__hive_agent_unreachable` rewrites to `unreachable.html`.
Both pages are built at deploy time via `pkgs.runCommand` (one nix
derivation `hyperhive-agent-error-pages` with `not-found.html` +
`unreachable.html` inside) and served via two `internal` nginx
locations with `alias` to the exact file. `internal` keeps the
files from being directly request-able by operators — only nginx's
own error-handling can reach them.
Page styling: minimal inline CSS matching the dashboard's catppuccin
palette (`#1e1e2e` bg, `#cdd6f4` text, `#cba6f7` heading). No
dependencies on the frontend dist — these pages render even when
hive-c0re itself is down.
Scope is intentionally narrow per mara on #755: "only for routes
already special cased in the nginx config". Other gateway routes
(forge / matrix / fluffychat) get nginx defaults — extending the
custom-error pattern there is a separate follow-up.

View file

@ -87,8 +87,7 @@ propagate in. Operators don't need to set anything on their side.
## Claude credentials are per-agent
`/var/lib/hyperhive/agents/<name>/claude/` bind-mounts to
`/home/<name>/.claude` (RW; was `/root/.claude` pre-#658 when every
harness ran as root). Sharing one dir across agents is NOT viable —
`/home/<name>/.claude` (RW). Sharing one dir across agents is NOT viable —
OAuth refresh tokens rotate, so any sibling refresh invalidates all
the others. Login flow runs from the per-agent web UI; creds persist
across `destroy`/recreate (`--purge` wipes them).
@ -96,8 +95,8 @@ across `destroy`/recreate (`--purge` wipes them).
## Persistent notes dir per agent
`/var/lib/hyperhive/agents/<name>/state/` bind-mounts to
`/agents/<name>/state` (RW; uniform for sub-agents + manager
post-#604, was `/state` pre-#604). The harness exposes the same path
`/agents/<name>/state` (RW; uniform for sub-agents + manager).
The harness exposes the same path
via `$HYPERHIVE_STATE_DIR`. System prompts tell agents to keep
durable knowledge here (`notes.md`, anything else). The harness also
writes its events log here (`hyperhive-events.sqlite`).
@ -112,8 +111,8 @@ unlucky. Operator resolves a collision by renaming the offending
agent (different hash → different port) and rebuilding. No state
file, no probing, no port-allocation drift — the value is
reproducible from just the name. Every agent — including the
manager — hashes into 8100..8999 via the same FNV-1a since #753;
dashboard at `cfg.dashboardPort` (default 7000).
manager — hashes into 8100..8999 via the same FNV-1a; dashboard
at `cfg.dashboardPort` (default 7000).
## Restart races on TCP bind
@ -146,13 +145,12 @@ files in subdirectories) fails with `EPERM`. Fix: pass
The naive nginx pattern for a path-prefix SPA (`try_files $uri $uri/
/matrix/index.html`) silently swallows asset 404s — a missing JS file
returns `index.html` with a 200, so the JS runtime never loads and the
page renders blank with no visible error (#685; fixed in PR #684;
#686 filed the follow-up edge-case, addressed in PR #729).
Extension allowlists (tried in #686 → PR #729) have the same maintenance
problem: any new file extension the SPA ships breaks silently.
page renders blank with no visible error. Extension allowlists (tried
as an alternative) have the same maintenance problem: any new file
extension the SPA ships breaks silently.
The correct pattern (landed in PR #729, `hive-gateway.nix`) keys the
fallback on the HTTP `Accept` header:
The pattern that works (`hive-gateway.nix`) keys the fallback on the
HTTP `Accept` header:
```nginx
# Outside the server block (appendHttpConfig):
@ -188,22 +186,22 @@ nix build /var/lib/hyperhive/meta#argus.config.system.build.toplevel
nix build /var/lib/hyperhive/meta#nixosConfigurations.argus.config.system.build.toplevel
```
`lifecycle::prebuild_toplevel` hit this in #721 (fixed in #738) by
constructing the attr path as `{flake_ref}.config…` — which produced
`meta#argus.config…` instead of `meta#nixosConfigurations.argus.config…`.
The fix: `split_once('#')` to separate flake path from name, then
template `{path}#nixosConfigurations.{name}.config.system.build.toplevel`.
`lifecycle::prebuild_toplevel` hit this once by constructing the attr
path as `{flake_ref}.config…` — which produced `meta#argus.config…`
instead of `meta#nixosConfigurations.argus.config…`. The fix:
`split_once('#')` to separate flake path from name, then template
`{path}#nixosConfigurations.{name}.config.system.build.toplevel`.
## `hive-forge`: prefer over raw curl pipelines
Every agent container has `hive-forge` in PATH (installed via
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary
since #280). Use it instead of ad-hoc curl pipelines:
`harness-base.nix`; lives in `/hive-forge` as a proper Rust binary).
Use it instead of ad-hoc curl pipelines:
```bash
hive-forge view 42 # title + body + comments
hive-forge comments 42 # list all comments (human-readable)
hive-forge --json comments 42 # same as above, JSON array (global flag, closes #421)
hive-forge --json comments 42 # same as above, JSON array (global flag)
hive-forge comment 42 --body "..." # post comment (inline body)
hive-forge comment 42 --body-file - <<EOF # ...or pipe a HEREDOC
multi-line body
@ -212,7 +210,7 @@ hive-forge assign 42 damocles
hive-forge close 42
hive-forge labels 42 add feature
hive-forge pr 42 # PR metadata as JSON
hive-forge pr-create --title "..." --head my-branch --push # also `git push forge my-branch`, suppressing the post-push "Create a pull request" hint (#222)
hive-forge pr-create --title "..." --head my-branch --push # also `git push forge my-branch`, suppressing the post-push "Create a pull request" hint
hive-forge diff 42 # unified diff (lockfile hunks collapsed by default)
hive-forge diff 42 --full # include unfiltered lockfile hunks
hive-forge branches deployed/ # filter branches by pattern
@ -227,3 +225,114 @@ hive-forge lint assignments # per-assignee open item count
Credentials come from `$HYPERHIVE_STATE_DIR/forge-token`; default
repo from `$HIVE_FORGE_REPO`, overridden per-invocation by the
global `-r/--repo` flag.
## Containerized nix-daemon needs `sandbox-fallback = true`
Agent containers bind-mount the host's nix-daemon socket. nspawn
containers don't get user-namespaces by default, so `nix build`
invocations *inside* the container can't set up the build sandbox
and fail outright if the host daemon's
`nix.settings.sandbox-fallback` is `false` (nixpkgs default).
`nix/templates/harness-base.nix` does `lib.mkForce true` so builds
fall back to unsandboxed local builds rather than failing. Security
implications: `docs/security.md`.
## Split asset derivations away from the rust workspace
`nix/assets.nix` builds the branding SVG/PNG family + claude
system-prompt template + claude-settings JSON as its own derivation,
separate from the hive-ag3nt / hive-c0re crates. Reason: when the
rust build's `src` was the whole repo tree, any tweak to
`branding/agent-configs.svg` or `hive-ag3nt/prompts/system.md`
invalidated the cargo cache and forced a full rebuild. crane (and
naersk before it) couldn't see "these inputs are unused by rust" on
its own — the split breaks the coupling at the derivation boundary.
The agent-configs PNG is rendered from the SVG via `rsvg-convert` at
build time; librsvg dependency lives here, not in the rust
derivation's `nativeBuildInputs`.
## Weston VNC compositor (per-agent `hyperhive.gui.enable`)
`nix/templates/weston-vnc.nix` adds an optional Weston Wayland
compositor with the VNC backend, surfaced as
`hyperhive.gui.enable = true` per-agent. The harness's
`/screen/ws` WebSocket relay (`docs/web-ui.md::Per-agent endpoints`)
connects to the compositor at `127.0.0.1:<vnc_port>`.
- **Port allocation**: deterministic FNV-1a of the agent name
(read from `/etc/hostname`, leading `h-` stripped) mapped into
`[15900, 16799]`. Mirrors the agent web-UI port pattern from
`docs/gotchas.md::Web UI ports collide on hash` — same FNV-1a
constant, different range. The compositor's startup script writes
`/etc/hyperhive/gui.json = {"vnc_port":N,"auth":"none"}` so the
harness reads the port at runtime; no nix-side / harness-side hash
duplication.
- **VNC bind address**: weston's VNC backend has no CLI
bind-address flag (unlike the RDP backend's `--address`), so the
listener binds `0.0.0.0`. The harness relay only connects via
`127.0.0.1`; the host firewall blocks the per-agent VNC port range
from external access. A future weston.ini `[vnc] address=` will
let us restrict the bind directly once upstream supports it.
- **PAM service name**: literal `weston-remote-access` — that's the
string libweston passes to `pam_start()` in `libweston/auth.c`.
Using `weston` falls back to the system default PAM stack and
rejects auth. The service is configured to `pam_permit.so` for
all three module types (auth / account / session) so the
browser's empty Apple-DH credentials (type 30) always pass —
neatvnc ≥ 0.9 calls the PAM auth callback regardless of
`weston.ini` `auth-method=none`, so the permit fallback is what
actually lets the empty-cred client through.
- **`Type = "simple"` (not `notify`)**: `switch-to-configuration`
must never block on weston signalling readiness. A misconfigured
weston degrades to a `Restart=on-failure` loop visible in
`journalctl`, it does not abort the `nixos-container update`.
Same reasoning as the `tea-login` unit in `harness-base.nix`.
- **`[core] idle-time=0`**: disables weston's 300-second idle
timeout. Without it the VNC desktop fades to black and
desktop-shell shows its click-to-unlock screen — useless for an
agent desktop viewed over `/screen`. `idle-time=0` updates the
idle timer with a 0ms delay, which
`wl_event_source_timer_update` treats as "disarm", so the
compositor never goes idle and never locks.
## Nix options reference (`nix/docs/default.nix`)
`pkgs.nixosOptionsDoc` over two evaluated module trees:
`hostEval` (a stub NixOS system loading `self.nixosModules.default`
with every hyperhive subsystem `mkForce false` so heavy build
inputs stay out of the eval) and `agentEval` (reuses the already-evaluated
`agent-base` container config so the per-agent options tree is
identical to what a real agent container sees).
Three output trees consumed by `flake.nix`:
- `docs-host` — operator-facing host module options
(`services.hyperhive.*`)
- `docs-agent` — per-agent harness options (`hyperhive.*`
declared in `nix/templates/harness-base.nix`)
- `docs` — bundled static site (`index.html` + `host.html` +
`agent.html`, plus `.md` source-of-truth versions of each
options page)
Rendering pipeline:
- CommonMark from `nixosOptionsDoc.optionsCommonMark` — source of
truth, kept as `.md` in the bundle.
- HTML via `pkgs.cmark-gfm` over the CommonMark, wrapped in a
minimal inline-CSS template. `cmark-gfm` (not plain `cmark`) so
any future tables / autolinks Just Work without revisiting.
- Inline `<style>` from `nix/docs/style.css` so the bundle is
single-file-per-page and nginx's `/options/` mount needs no MIME
setup for separate `.css` files and no cache-busting.
- Asset paths inside rendered HTML are all relative
(`./host.html`, etc.) so the bundle can mount at any URL prefix
without rewriting.
- `transformOptions` strips the nix-store prefix from option
declaration paths and rewrites them as forge URLs, so the
rendered docs link back to the source.
Host options live entirely under `services.hyperhive.*`. The
`pickSubtrees` filter is rooted at `["services" "hyperhive"]` so the
options tree picks up everything under that root — picking against
stray roots produces an empty tree and renders the host page as
template chrome with no `<h2>` headers.

199
docs/matrix.md Normal file
View file

@ -0,0 +1,199 @@
# hive-matrix
Private Matrix homeserver (matrix-tuwunel — the conduwuit
successor) wrapped in a nixos-container, plus optional fluffychat-web
client at `matrix.<hive>/`. Configured via
`services.hyperhive.matrix.*`; vhost routing lives in
[`gateway.md`](gateway.md).
## Container shape
Same shape as [`gateway.md::hive-forge container shape`](gateway.md):
- Container name `hive-matrix` (not `h-*`) so c0re's lifecycle
scanner ignores it; operator manages via the standard
`nixos-container` CLI.
- Keeps hive-matrix from fighting any `services.matrix-*` the
operator already runs on the host — separate systemd namespace,
separate state dir.
- Container shares the host network namespace
(`privateNetwork = false`) so agents reach tuwunel at
`http://localhost:<httpPort>` without extra plumbing — the
nixos-container is here for state + systemd-unit isolation, not
network isolation.
- Persistent state at
`/var/lib/nixos-containers/hive-matrix/var/lib/matrix-tuwunel/`
survives container restart / host reboot. To wipe, destroy the
container.
## Identity vs API listener: `serverName` vs `gatewayHost`
Two distinct hostnames:
- **`serverName`** — matrix-spec `server_name`, embedded
*irrevocably* in every `@user:<server_name>` and `!room:<server_name>`
identifier minted on this homeserver. Cannot be changed later
without abandoning every account and chat history. Defaults to the
bare `services.hyperhive.domain` per mara on #660; clients
auto-discover the actual API endpoint via the
`.well-known/matrix/{client,server}` routes the hive-gateway serves
at that domain.
- **`gatewayHost`** — the API listener hostname, where the gateway's
matrix vhost proxies `/_matrix/*` to tuwunel. Defaults to
`matrix.<services.hyperhive.domain>` (sub-domain shape per mara on
#749:9609). Set to `null` to skip the gateway vhost (tuwunel stays
direct on `httpPort`).
**Breaking change** (#660): `serverName` used to default to
`matrix.${services.hyperhive.domain}`. Existing homeservers must set
the option explicitly to preserve their pre-#660 user / room IDs
before rebuilding. The default flipped because the bare hive-domain
makes for cleaner matrix IDs and `.well-known` delegation hides the
sub-domain from the user-facing identifier.
## Default-closed firewall
`openFirewall` defaults to `false` (#651, secure-by-default): the
homeserver is reachable from the host + every agent container via
loopback either way (shared netns), so the firewall hole only
matters for access from *outside* the host. Flip to `true` when
announcing the homeserver to other hives or when an external matrix
client needs to reach the client-server API directly.
**Breaking change** (#651): used to default to `true`. Operators
relying on external reach must add
`services.hyperhive.matrix.openFirewall = true;` before rebuilding.
Federation port 8448 is intentionally not opened here — tuwunel
serves the federation API on the same `httpPort` as client-server
by default. Reaching it on 8448 needs either an explicit tuwunel
bind to that port OR a reverse-proxy + `.well-known/matrix/server`
delegation (the latter lives in `gateway.md::Discovery flow`).
## Provisioning flow (registration token)
Token-gated registration: hive-c0re holds the token, agents never
see it. The agent only receives the resulting `access_token`.
1. **System activation** writes a 32-byte random hex token (64
chars) to `cfg.registrationTokenFile`
(`/var/lib/hyperhive/matrix-register-token` by default), mode
`0600 root:root`, before any container start. Idempotent — only
writes when the file is missing or empty; always re-applies 0600
(normalises any 0640 / world-readable carry-over from
pre-LoadCredential deployments). This runs at activation time
(not first container start) to dodge the argus #565 race where
nspawn creates an empty file when the bind-mount target is
missing and tuwunel reads `registration_token_file=""` rejecting
every registration until next restart.
2. **Read-only bind-mount** maps the host file into the tuwunel
container at the same path.
3. **systemd `LoadCredential=`** inside the container copies the
bind-mounted file into
`/run/credentials/tuwunel.service/registration_token`, owned by
tuwunel's dynamic user with mode `0400`, at service start. The
host file stays `root:root 0600` — no `chown :tuwunel` /
`chmod 0640` / GID-pin gymnastics required (per iris on #644
8043, dropping the shape #649 originally shipped with). Keeps
`DynamicUser = true` + `PrivateUsers = true` intact.
4. tuwunel's `registration_token_file` points at the credentials
path, not the original bind-mount path.
5. **hive-c0re** uses the token to register each agent account via
the matrix-spec UIAA registration flow, persists the returned
`access_token` to `<agent-state>/matrix-token`. The agent's
matrix MCP client authenticates with that access_token and
never touches the shared registration token.
Initial rollout settings (#548):
- `allow_federation = true` at the protocol level so swarms can be
wired up later by extending `trustedServers` without a homeserver
restart. `trusted_servers = []` keeps it effectively closed
until peers are listed.
- `allow_registration = true` (required for the token flow to
engage). The absent
`yes_i_am_very_very_sure_…_open_registration_…` flag keeps the
server closed to anyone without the token.
- `allow_encryption = false` per operator call (#548). E2EE
re-enabling tracked at #551.
## Assertion rationale
Two `config.assertions` entries fail eval early rather than ship
surprising behaviour:
- **`hyperhiveDomain != null || cfg.serverName != null`** (mara on
#548) — `server_name` is embedded into every user / room ID
irrevocably; we refuse to spawn the homeserver with a bogus
server_name we can never change later.
- **`cfg.gatewayHost != ""`** (argus 🟡 on #764) — same footgun as
`forge.domain` (#754). Empty string renders `.<hive>`-shaped
garbage in both nginx `server_name` (treated as wildcard
catch-all, surprising) and `/etc/hosts` (invalid entry). `null`
is the right opt-out shape; empty string is rejected explicitly.
## fluffychat-web build fixes (#685)
`pkgs.fluffychat-web` ships from `flutter341.buildFlutterApplication`,
which has two upstream gaps for fluffychat's web target:
- The dart web-worker entry point (`web/native_executor.dart`) isn't
compiled — `buildFlutterApplication` only runs `flutter build web`
on the main entry.
- `native_imaging`'s C source isn't built — emscripten isn't a
flutter-builder native build input.
Both fixed in `nix/modules/hive-matrix.nix` via two derivations:
- **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the
`native_imaging` C source via `pkgs.emscripten`. Source comes
from `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging`
— already in the build closure of the flutter app, so no parallel
hash pin and version auto-syncs with nixpkgs bumps. Build closure
is ~3.6 GiB (emscripten LLVM); runtime closure is just the two
output files. `dontConfigure = true` because cmake runs inside
`js/Makefile` via `emcmake cmake`, not at the package root. The
build script needs `HOME` + `EM_CACHE` writable for emscripten's
on-demand sysroot build (libc, libc++ → wasm).
- **`fluffychat-web-fixed`** is `pkgs.fluffychat-web` plus a
`postInstall` patch that (a) compiles `web/native_executor.dart`
via `dart compile js` (dart from the flutter341 closure, no
incremental cost) and (b) installs `fluffychat-web-imaging`'s
outputs into `$out`.
Two non-obvious fixes from review history:
- **`make -C js`** instead of `cd js; make` (argus 🟡 on #697 v2)
— keeps the build-phase pwd at the source root so `installPhase`
doesn't have to know about the cd. Robust against future
reorders / `dontBuild`.
- **`web/native_executor.dart`** as a build-CWD-relative path,
*not* `$src/web/...` (#685 / #733 fixup) — `dart`'s
`package_config.json` walk-up needs to hit
`buildFlutterApplication`'s pub-get output (`.dart_tool/` in the
build CWD). Walking up from a read-only `$src/` store path finds
no `.dart_tool/` and errors with "Couldn't resolve the package
'matrix'". Confused two PRs.
Drop both derivations when nixpkgs's flutter builder grows worker
+ emcc support upstream.
Mount point is `matrix.<hive>/` (#772); upstream `--base-href "/"`
is correct at sub-domain root, no override.
## Sequencing history
- #548 — initial rollout (federation enabled, registration enabled,
E2EE disabled)
- #565 — first-boot empty-token race fix → activation-time token
generation
- #644 / iris 8043 / #649 — registration token ownership shape
(dropped chown/GID-pin; LoadCredential delivers as 0400 dynamic-user)
- #651`openFirewall` default flipped to `false`
- #660`serverName` default flipped to bare hive-domain (was
`matrix.<hive>`)
- #685 / #697 / #733 — fluffychat-web build fixes (Imaging emscripten,
native_executor dart worker, build-CWD path)
- #736 — fluffychat config.json inline JSON at sub-domain root
- #749 / #764 — gateway sub-domain shape verdict
- #772 / #775 — fluffychat hops from `<hive>/matrix/` to `matrix.<hive>/`

View file

@ -32,8 +32,8 @@ header/targets split:
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
answered via `Answer` request). Migrated via `ALTER TABLE ADD
COLUMN` against `pragma_table_info`.
- `scheduled_prompts` — recurring + one-shot prompt queue
(closes #444). `owner / body / interval_seconds (NULL = one-shot) /
- `scheduled_prompts` — recurring + one-shot prompt queue.
`owner / body / interval_seconds (NULL = one-shot) /
next_fire_at_unix / created_at_unix / source ("operator" or
"approval:<id>") / cancelled_at_unix / description`. `owner`
drives cancel-permission checks (operator vs the submitting
@ -101,9 +101,8 @@ Bin-loop helpers `build_row` + `record` land each row at
`turn_end`; writes are best-effort, a sqlite hiccup logs + lets
the turn loop continue.
No host-side vacuum yet — tracked as forge issue
[#10](http://localhost:3000/hyperhive/hyperhive/issues/10)
(target retention ~90 days, age-only sweep like events_vacuum).
No host-side vacuum yet — tracked separately. Target retention
~90 days, age-only sweep like events_vacuum.
### `/state/hyperhive-rate-limited` (per agent)
@ -136,13 +135,12 @@ Under `/var/lib/hyperhive/agents/<name>/`:
precise changes from the manager; RW into the manager via the
`/agents` tree bind.
- `claude/` — claude OAuth credentials, bind-mounted RW to
`/home/<name>/.claude` inside the container (post-#658 — was
`/root/.claude` pre-#658 when every harness ran as root).
`/home/<name>/.claude` inside the container.
- `state/` — durable notes, the events.sqlite db, and the
turn-stats sqlite db. Bind-mounted to `/agents/<name>/state`
inside the container (uniform for sub-agents + manager
post-#604). The `$HYPERHIVE_STATE_DIR` env var exposes the
same path to in-container scripts.
inside the container (uniform for sub-agents + manager).
The `$HYPERHIVE_STATE_DIR` env var exposes the same path to
in-container scripts.
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
applied repo. Tracks `flake.nix` (module-only boilerplate; never
@ -198,3 +196,66 @@ On startup, `Coordinator::register_agent` drops any prior socket
task before rebinding — idempotent so a hive-c0re restart followed
by `rebuild alice` recreates the agent's socket without a clean
reinstall.
## First-boot agent-user migration
The harness runs as a per-agent unix user inside the container
(`hyperhive.user.name`, defaults to the agent's logical label so each
container has a uniquely-named user). Operators with legacy root-owned
state dirs need a one-time data shuffle so they don't lose their claude
session.
`system.activationScripts.hive-agent-user-migrate` (in
`nix/templates/harness-base.nix`) runs on every activation,
marker-guarded so the substantive moves only happen once per
container lifetime:
1. **`${homeDir}` exists with the right ownership** — covers the
very first boot before `useradd`'s `createHome` has had a
chance to chown. Also re-applies on every rebuild in case the
meta-flake's per-agent name evolves (rare).
2. **Migrate any leftover `/root/.claude` content into
`${homeDir}/.claude`** — legacy `claude` wrote to root's
empty home; the bind mount didn't exist yet. Marker
(`/var/lib/hive-agent-user-migrated`) guards single-shot.
`cp -an` (no-clobber) so any pre-existing files at the new
location win — never blow over data already there.
3. **Chown the bind-mounted state dir** (`/agents/*/state`)
recursively so the agent user can read/write it. Wildcard
matches the single agent that container sees; `-h` skips
symlinks the agent might have planted.
4. **Chown the `~/.claude/` bind-mount** recursively. Legacy
`claude` wrote `.credentials.json` 0600 root:root; the
current harness reads `~/.claude/` as the agent user to decide
Online vs NeedsLogin in `login::has_session`. Without the
chown the existing credentials get silently treated as "no
session" and the operator re-prompts every boot.
The activation script will eventually become unnecessary once no
operators have legacy root-owned state dirs left to migrate; drop
the body + marker check at that point.
## Matrix per-agent daemon + token-arrival trigger
`hive-matrix-daemon` is a long-running matrix-sdk Client + sync
process per agent. Holds the unix socket the stdio
`hive-matrix-mcp` bridge talks to, emits hyperhive wake signals
on incoming room events via `/run/hive/mcp.sock`. Conditional on
`hyperhive.matrix.enable` (which both the daemon AND the
auto-injected `extraMcpServers.matrix` entry read).
Socket path lives inside the systemd-managed runtime dir
(`RuntimeDirectory = "hive-matrix"``/run/hive-matrix/`, owned by
the agent user) so the daemon can bind without needing root over
`/run/` itself. Both daemon + bridge agree on the path via the
`HIVE_MATRIX_SOCKET` env var.
**First-boot ordering**: hive-c0re provisions the matrix token AFTER
agent containers come up. Without the path-trigger sibling
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
/agents/*/state/matrix-token`), the daemon would exit 0 quietly the
first time it ran and the MCP would have no backend until the next
restart. The `.path` unit makes the appearance of the token re-fire
the service so the daemon comes alive in the same boot cycle as
provisioning. `matrix-avatar-sync.path` uses the same pattern for
the icon-upload oneshot.

View file

@ -1,6 +1,6 @@
# Security model
## Nix builds and credential isolation (issue #240)
## Nix builds and credential isolation
### Background
@ -19,12 +19,11 @@ any file in the container that the nixbld user can read.
**What is NOT exposed**:
- `/home/<name>/.claude/` — mode `0700`, owned by the per-agent
user `<name>` (post-#658 — was `/root/.claude` owned by root
pre-#658). nixbld users cannot read it.
user `<name>`. nixbld users cannot read it.
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
— written at mode `0600` by `hive-c0re/src/forge.rs` and chowned to the
per-agent uid:gid by `lifecycle::chown_to_agent` (post-#673/#678).
nixbld users cannot read it.
per-agent uid:gid by `lifecycle::chown_to_agent`. nixbld users
cannot read it.
**Policy**: all credential files written to agent state directories MUST be mode
`0600` or stricter. Do not create world-readable secret files in agent state dirs.

View file

@ -43,7 +43,7 @@ parent's negative pull.
| `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json |
| `.tool-use` `<details>` | `Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json |
| `.tool-use` `<details open>` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json |
| `.tool-use .ask-answer-inline-slot` | (sub-block under `ask → operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | #666 |
| `.tool-use .ask-answer-inline-slot` | (sub-block under `ask → operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | rich renderer |
| `.tool-result` (flat) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
| `.tool-result-block` `<details open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
@ -105,7 +105,7 @@ isn't in the built-in `fmtToolUse` switch:
This keeps `mcp__matrix__send_message` and similar from
dumping raw JSON.
## Inline ask-operator answer (#666)
## Inline ask-operator answer
When an agent calls `mcp__hyperhive__ask` with `to == "operator"`
(default), the rich tool-use renderer mounts an empty

View file

@ -6,8 +6,8 @@ claude has access to in return.
## The loop
Each agent harness (`hive serve`, with role picked from `$HIVE_ROLE`
`"agent"` for sub-agents, `"manager"` for the manager; post-#598
this is one binary not two) runs:
`"agent"` for sub-agents, `"manager"` for the manager — one
binary, not two) runs:
1. Long-poll `Recv` on its socket. The host-side broker
(`broker.rs::recv_blocking_batch`) returns immediately if there's
@ -31,8 +31,8 @@ this is one binary not two) runs:
(`Bus::emit_status("rate_limited")`), sleeps
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries.
The dashboard and per-agent page show a `⊘ rate limited` badge
while the harness is parked. **Auth-failed detection** (closes
#419): both stdout and stderr pumps also match
while the harness is parked. **Auth-failed detection**: both
stdout and stderr pumps also match
`AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.).
On match the harness writes `{state_dir}/hyperhive-needs-login`,
emits `needs_login_idle` status, requeues the inflight message
@ -40,7 +40,7 @@ this is one binary not two) runs:
the same path used at boot. The operator re-authenticates via
the per-agent web UI login flow; on success the sentinel is
cleared and the queued message drives the next turn normally.
**Mtime-snapshot resumption** (closes #542): `wait_for_login`
**Mtime-snapshot resumption**: `wait_for_login`
snapshots the `~/.claude/` dir (newest file mtime + file count)
at entry and only resumes when that snapshot advances — not
just when credentials exist on disk. This prevents a silent
@ -176,8 +176,8 @@ socket at `/run/hive/` once at startup:
- `claude-system-prompt.md` — rendered from
`hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`:
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
same for `role:manager`) gate the role-specific blocks (closes
#519); everything else is shared. Five placeholders are then
same for `role:manager`) gate the role-specific blocks; everything
else is shared. Five placeholders are then
substituted: `{label}` (short agent name), `{qualified_label}`
(hive-qualified `name@domain` form), `{operator_pronouns}`,
`{hive_identity}` (e.g. `` on hive `pr1ma` ``; empty when
@ -350,10 +350,9 @@ meta's.
`agent.nix`, commits the changes, and calls `request_apply_commit`
with the commit sha — the first ApplyCommit on a freshly-init'd
config creates the container. Fails if a proposed repo for this
name already exists. (The pre-#442 path through a separate
manager-side `request_spawn` was removed; operator can still
direct-spawn an empty agent from the dashboard's `◆ R3QU3ST SP4WN`
button which routes via `HostRequest::RequestSpawn`.)
name already exists. (The operator can also direct-spawn an empty
agent from the dashboard's `◆ R3QU3ST SP4WN` button, which routes
via `HostRequest::RequestSpawn`.)
- `kill(name)` — graceful stop. No approval required.
- `start(name)` — start a stopped sub-agent. No approval.
- `restart(name)` — stop + start. No approval.
@ -398,7 +397,7 @@ meta's.
approval (use `remind` for unapproved self-wake). Long downtime
fires once per recurring row on resume (catch-up clamp).
- `edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)`
partial-update a schedule (#474/#478). Pass only the fields to
partial-update a schedule. Pass only the fields to
change; absent fields are left alone. `targets_add` / `targets_remove`
mutate the recipient list in the same transaction; re-adding a
previously-cancelled target drops its tombstone + history (fresh

View file

@ -2,9 +2,9 @@
Two web surfaces share the same skeleton: the dashboard (port 7000)
and the per-agent UIs (every container — including the manager —
hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a,
since #753). Both are SPAs — `GET /` returns a static shell,
`/api/state` returns JSON, JS renders. No full-page reloads.
hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a).
Both are SPAs — `GET /` returns a static shell, `/api/state`
returns JSON, JS renders. No full-page reloads.
## Shape (shared by both)
@ -33,7 +33,7 @@ since #753). Both are SPAs — `GET /` returns a static shell,
based, no `innerHTML` — XSS-safe); markdown bodies get the
same treatment via `marked`'s autolink (npm dep, replacing the
vendored UMD bundle), with the rendered `<a>`s rewritten to
`target="_blank"` (issue #233).
`target="_blank"`.
- `GET /api/state` → JSON snapshot the JS app renders into the
DOM. Includes a top-level `seq` (the dashboard event channel's
high-water mark at the moment the snapshot was assembled);
@ -50,7 +50,7 @@ since #753). Both are SPAs — `GET /` returns a static shell,
~200 broker messages wrapped in `{ seq, events }`) on the
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
also wrapped in `{ seq, events }`) on the agent.
**SSE multiplexing** (#448): the dashboard uses a
**SSE multiplexing**: the dashboard uses a
`SharedWorker` (`stream-worker.js`) to hold one upstream
`EventSource` per URL. All same-origin tabs share this worker
— a second dashboard tab joins the existing connection rather
@ -59,7 +59,7 @@ since #753). Both are SPAs — `GET /` returns a static shell,
page re-subscribes (gets a synthetic `open` event immediately
if the upstream is already connected). Falls back gracefully
when `SharedWorker` is unavailable (e.g. some private-mode
browsers). **Worker-death self-heal** (#515): Firefox kills
browsers). **Worker-death self-heal**: Firefox kills
"idle" SharedWorkers under memory pressure with no client-side
signal — the port silently goes no-op. The worker now pings
every connected port every 30s; the client bumps a
@ -72,6 +72,84 @@ since #753). Both are SPAs — `GET /` returns a static shell,
bfcache-restore uses). Recovery is per-tab; pings are
invisible on the healthy path.
### Shared terminal pane
Both surfaces' scrollable log streams (`#msgflow` on the dashboard,
`#live` on the per-agent page) are backed by the shared terminal
factory in `@hive/shared/terminal.js`. The factory wires up
sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and
SSE replay. Pages register a `kind → renderer` map; unknown kinds
fall through to a JSON-dump note row. The factory ships three row
shapes the renderers call:
- `api.row(cls, text)` — single-line row with an inline `linkify`
pass over the text.
- `api.details(cls, summary, body)` — collapsible `<details>` with
a `<pre>` body (used by long tool-results and stack traces).
- `api.detailsDiff(cls, summary, body)` — same shape, splits the
body on newlines and tags each line as `diff-add` / `diff-del` /
`diff-ctx` so the renderer's diff bodies get coloured without
emitting raw HTML.
**Sticky-bottom + snap animation.** `stickToBottom` is the
operator's intent: true means "keep snapping to bottom on every
mutation", false means "I scrolled up, leave me alone". The flag
flips when a scroll event lands further than `NEAR_BOTTOM_PX = 48`
from the bottom. New rows then either snap to bottom (when sticky)
or bump the unseen-count and surface the "↓ N new" pill. The snap
is a brief 140ms ease-out (`SCROLL_ANIM_MS`) — the browser's
default `behavior: 'smooth'` ~500ms reads as "still smooth, but
visibly slow"; 140ms feels snap-y while still reading as motion
rather than a jump. Distances under `SCROLL_SNAP_PX = 24`
short-circuit to instant — animating a 12px nudge would just be
jitter. Each new snap cancels the previous `requestAnimationFrame`
so a burst of mutations coalesces into one ride to the latest
bottom; the per-frame step re-reads `scrollHeight - clientHeight`
so mutations landing mid-animation extend the destination smoothly
rather than land short.
**Mid-animation scroll-event guard.** The scroll handler's
`isNearBottom` check would flip `stickToBottom` false mid-snap as
the smooth animation eases through positions that are technically
"not near bottom yet", which would strand the operator partway. A
`smoothScrollingUntil` timestamp gates the scroll handler — set to
the animation end + ~80ms headroom, re-armed on each fresh snap.
Programmatic `scrollTop` writes (the animation's per-frame update)
fire scroll events that the gate swallows.
**Post-append `MutationObserver`.** Renderers commonly call
`api.row(cls, text)` to create the row shell then append more
children (badges, multi-line bodies, tool result panes) after the
factory returned. The initial sticky-snap fires off the row's
empty shape; the renderer's later appends grow the row past the
visible bottom. A `MutationObserver` on the log subtree fires once
per microtask after each batch of synchronous mutations and snaps
again when `stickToBottom` is true. Programmatic `scrollTop`
writes don't re-trigger the observer (scroll isn't a DOM
mutation), so no feedback loop. The pre-append
`nearBottomBeforeAppend` snapshot is still useful — it keeps the
initial visual lag to one frame instead of one microtask + frame.
**Backfill + SSE.** Cold load fetches `historyUrl` (replay), then
subscribes to `streamUrl` (live tail). Both endpoints return
`{ seq, events }` so the client can dedupe — events with
`seq <= snapshot.seq` from the SSE stream are dropped silently
(the snapshot already covers them). History rows render with a
`.no-anim` class so they don't stagger in like live events. The
optional `streamFactory(url)` callback lets the dashboard hand
the factory a `SharedWorker`-backed `EventSource` facade (so
multiple tabs share one upstream connection — see *SSE
multiplexing* above); when omitted, the factory falls back to a
plain `new EventSource(url)`.
**`linkify` (text-node based).** Bare `http(s)://` URLs in row
text get wrapped in `<a target="_blank" rel="noopener noreferrer">`
inside a fresh text node, so the autolinker never touches
`innerHTML` and untrusted row content can't smuggle markup. The
trailing-punctuation strip keeps `.,;:` outside the link surface.
Markdown bodies go through `marked` separately and get the same
target rewrite.
The JS app handles all `form[data-async]` submissions via a delegated
listener: read `data-confirm`, swap the button to a spinner, POST
`application/x-www-form-urlencoded`, re-enable the button on success
@ -116,7 +194,7 @@ titled header, a close button, and a scrollable body. Closes on
the button, a backdrop click, or `Escape`. `Panel.open(title,
node)` swaps the body; the JS builders for file previews,
approval diffs, and journald logs all render into it. **The
drawer width is drag-to-resize** (#451): a thin 6px hit-strip on
drawer width is drag-to-resize**: a thin 6px hit-strip on
the left edge captures pointer events, resizes the drawer in
real-time (pointer capture keeps dragging even if the cursor
outpaces the handle), and persists the chosen width to
@ -354,7 +432,7 @@ with `--base-href /matrix/`, swappable via
`services.hyperhive.matrix.gui.package`) served by the hive-gateway
nginx container at `/matrix/` when
`services.hyperhive.matrix.gui.enable` is on (defaults to
`matrix.enable`, #635). c0re signals availability via the
`matrix.enable`). c0re signals availability via the
`HIVE_MATRIX_GUI_ENABLED` env var → `state.matrix_gui_enabled` in
`/api/state`; the gateway does the actual static serving.
@ -364,7 +442,7 @@ in once with the in-host tuwunel homeserver URL
The unified nginx-front re-root to
`https://matrix.${hyperhive.domain}` + `.well-known/matrix/client`
auto-discovery is tracked in #609 (atlas's lane, post-#15).
auto-discovery lives in `docs/gateway.md` (atlas's lane).
### FL0W page (`/flow.html`)
@ -383,7 +461,14 @@ newest-first.
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
Cold load backfills the last ~200 messages from `/dashboard/history`;
live frames arrive on `/dashboard/stream`. Each row is one broker
event — `sent` or `delivered` — with `from → to: body`. Sticky-
event — `sent` or `delivered` — with `from → to: body`. The row is
a `flex-wrap: wrap` container holding ts / arrow / from / sep / to
chips inline; the **body wraps to its own full-width line below**
the chips (`flex: 1 1 100%`) so the body always gets the full row
width down to the content edge — long timestamps + agent names
used to push the body ~30ch in and force awkward narrow-column
wraps. `min-width: 0` keeps `word-break: break-word` effective so
the body doesn't force the row wider than its container. Sticky-
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
terminal-style compose box: `@name` picks the recipient (sticky via
localStorage; auto-complete from the live container list, Tab/Enter
@ -482,7 +567,7 @@ fetch entirely.
`ContainerView.context_window_tokens`; the badge goes yellow
≥ 50% and red ≥ 75% of that window (the harness compaction
watermarks). When the window can't be resolved the badge falls
back to fixed 100k / 150k thresholds. (issue #66)
back to fixed 100k / 150k thresholds.
- Line 2: status badges only (no per-card action buttons — actions
moved to the **selection bar**, see below).
- Line 3: drill-in triggers —
@ -492,7 +577,7 @@ fetch entirely.
A unit dropdown (harness service / full machine journal) and
a refresh button live in the panel. The panel uses a column-flex
layout so the `<pre>` log surface fills the full remaining panel
height (#541); scroll happens inside the `<pre>`, not the side
height; scroll happens inside the `<pre>`, not the side
panel body.
- Plain navigation links (config repo, forge profile,
`dashboardLinks` extras) now live in the icon-only nav strip
@ -538,6 +623,26 @@ needed). The joint at the row's own depth column is `├` (more
siblings below) or `└` (last sibling at this depth — vertical
stops at the row's icon midline).
**Indent + lane geometry.** Each depth level shifts the row right
by `1.8em` (the lane width). The per-depth ladders are hardcoded
for six levels — enough for any plausible hive topology, and the
typed `attr()` function from CSS Values 5 that would collapse
this to one rule is still partial-support (Chromium-only as of
2026). The `.tree-prefix` span sits absolutely positioned with
`left: -<depth>*1.8em` so its right edge meets the row content
(the icon) and its leftmost lane lines up with top-level rows'
icons at `x = 0`. Each `.tree-lane` is `flex: 0 0 1.8em` so all
lanes have equal width. Continuation bars are drawn at lane
center (`left: 0.6em`, `border-left: 1px solid currentColor`,
`top: 0; bottom: 0`) and extend through `.containers { gap: 0.4em }`
into the next sibling's prefix (`bottom: -0.4em` on the prefix
span itself) so adjacent ancestor lines visually merge into one
unbroken vertical line. The horizontal stub at a row's own joint
lands at the icon midline so the L/T meets the icon edge cleanly.
When every container has `parent = null` (pre-topology state) the
`[data-depth]` attribute is absent on every row and these rules
are no-ops — the layout reads exactly like the legacy flat list.
### Selection bar
Per-card action buttons (`R3ST4RT` / `ST0P` / `ST4RT` / `R3BU1LD` /
@ -650,13 +755,13 @@ not ours.
the sentinel `[cancelled]`. Same code path as a real answer.
- `POST /request-spawn` — queue a Spawn approval.
- `POST /update-all` — rebuild every stale container.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
(#447). Refuses `Running` / terminal-state entries (in-flight
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
Refuses `Running` / terminal-state entries (in-flight
rebuilds can't be safely interrupted). Always 200; body is
`{"cancelled": true}` on a successful flip or
`{"cancelled": false}` when the entry was not in `Queued` state.
- `POST /api/agent/{name}/mark-all-read` — ack all pending broker
messages for `{name}` (#559). Backfills `delivered_at` for rows
messages for `{name}`. Backfills `delivered_at` for rows
not yet delivered and sets `acked_at = now`. Returns
`{ "marked": N }`. Agent name validated against
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
@ -705,7 +810,7 @@ not ours.
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
Agent-initiated schedules go through the approval queue instead
(manager MCP `request_schedule_prompt`).
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
- `PATCH /api/schedules/{id}` — partial edit. JSON body
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
targets_add?, targets_remove? }`.
Missing key = "leave alone"; explicit `null` on
@ -723,7 +828,7 @@ not ours.
`{ targets?: ["name", …] }` cancels just those recipients;
absent or empty body cancels the whole schedule.
- `POST /api/schedules/{id}/fire-now` — out-of-band manual
pulse (#467). Fires the schedule body once immediately to
pulse. Fires the schedule body once immediately to
every active target. Recurring schedules: `next_fire_at_unix`
is untouched; the regular cadence continues. One-shots: the
schedule is consumed (cancelled) after the manual fan-out.
@ -807,13 +912,17 @@ Three fixed-position layers frame a full-viewport terminal:
**Fixed-overlay header** (`<header class="agent-header">`): frosted
glass — `backdrop-filter: blur` lets scrolled terminal rows show
through. Three flex columns (#394 redesign):
through. Three flex columns:
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
identity anchor (5em, `width: 5em; aspect-ratio: 1;
align-self: flex-start` — capped so a tall state-row doesn't
inflate the icon, #411). Falls back to the dimmed hyperhive mark
on load error.
identity anchor — `width: 5em; height: 5em` with explicit pixel
sizing so the `<img>`'s intrinsic (large) dimensions don't push
the parent flex container open via `align-items: stretch`-driven
height feedback. 5em ≈ header content area (header `min-height: 6em`
minus `2 × 0.5em` padding). `align-self: flex-start` keeps the
icon stuck to the top so a state-row line-wrap doesn't drag it
down with it. Falls back to the dimmed hyperhive mark on load
error.
- **Main column** (`.agent-header-main`): two rows.
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) +
meta-nav (`<nav id="meta-links">`). Meta-nav renders
@ -861,8 +970,11 @@ through. Three flex columns (#394 redesign):
(POST confirm → `POST /api/logout`; SIGINTs any in-flight turn,
wipes OAuth credential files, flips the agent to `needs_login`
— session history preserved). All destructive actions require
one extra click to acknowledge (#394 — rare ops shouldn't live
in the primary state strip).
one extra click to acknowledge — rare ops shouldn't live in the
primary state strip. The popover's display rules are scoped to
`:not([hidden])` so the `[hidden]` HTML attribute's UA `display:
none` isn't overridden by the author CSS's `display: flex` —
the popover stays hidden until JS removes the attribute.
`/api/state` is fetched once on cold load (+ while
`status === 'needs_login_in_progress'`); all other updates arrive via
@ -871,7 +983,7 @@ tooltip, and `qualified_label` — the hive-qualified agent name
(`name@domain` form when `HYPERHIVE_HIVE_DOMAIN` is set, otherwise
just `name`). The frontend uses `qualified_label` to set the browser
tab title so two tabs from different hives are distinguishable; the
header `<h2 id="title">` stays short (#589 phase A).
header `<h2 id="title">` stays short.
**Main content** (`<main class="agent-main">`): fills the viewport
and scrolls behind the fixed header + footer.
@ -1048,14 +1160,26 @@ shaped).
`wait_for_login` entry.
- `GET /events/history` — replay buffer for the terminal.
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
renderer). Only accessible when `hyperhive.gui.enable = true`
in the agent's `agent.nix`; the harness shows a 🖥 screen link
in the state row when `gui_vnc_port` is present. Toolbar:
`⤢ fit` CSS-downscales the canvas to the window; `⤡ match size`
sends an RFB `SetDesktopSize` request so the server (weston)
changes its real output resolution to the window dimensions —
enabled once the server advertises the `ExtendedDesktopSize`
pseudo-encoding (issue #133).
renderer — deliberately thin, just enough to display the
desktop + forward pointer + keyboard. A production-grade viewer
would vendor noVNC; this file ships the minimal in-tree variant).
Only accessible when `hyperhive.gui.enable = true` in the agent's
`agent.nix`; the harness shows a 🖥 screen link in the state row
when `gui_vnc_port` is present. Toolbar: `⤢ fit` CSS-downscales
the canvas to the window via `relayoutCanvas()` setting explicit
pixel dimensions on the canvas — *not* CSS `max-width/max-height`,
because a flex item's automatic minimum size (`min-width: auto`
resolves to the canvas's intrinsic framebuffer resolution) silently
clamps `max-*` back up, making fit mode a no-op that just centred +
clipped the oversized canvas. The fit-mode rules pin the canvas
with `flex: none; min-width: 0; min-height: 0` so the JS-set size
sticks. `⤡ match size` sends an RFB `SetDesktopSize` request so the
server (weston) changes its real output resolution to the window
dimensions; enabled once the server advertises the
`ExtendedDesktopSize` pseudo-encoding (`-308` rect in the header).
Fit-mode state persists in `localStorage` (`screen-fit`); default
is on. Pointer coordinates are rescaled in `sendPointer` so clicks
land on the right pixel regardless of CSS scale.
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from

View file

@ -3,18 +3,16 @@
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
/* full-screen vibec0re overhaul (issue #360)
Layout shape: fixed-position frosted-glass header at top, fixed-
position composer at bottom, full-viewport terminal in between.
The terminal scrolls its text passes BENEATH the floating
header/composer with backdrop-filter blur for the frosted look.
Inbox + loose-ends move into the side-panel flyout; header pills
surface their counts as the only chrome they get. */
/* full-screen layout overrides
The agent page mounts a full-viewport terminal under a fixed
frosted-glass header + composer pair. See docs/web-ui.md::Per-agent
page for the layer / pill / side-panel structure; rules below
override the in-page defaults from @hive/shared. */
:root {
/* Bumped to 6em (#394) so the agent icon can be a full-height
square identity anchor without crowding the two-row main column
(title + nav-links on top, state strip below). */
/* 6em min-height accommodates the full-height agent icon (5em +
padding) alongside the two-row main column (title + nav-links
on top, state strip below). */
--agent-header-h: 6em;
--agent-composer-h: 3.6em;
--agent-frost-bg: rgba(30, 30, 46, 0.72);
@ -86,7 +84,7 @@ body.agent-shell {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
/* Main column: title row on top, state strip below (#394). Centred
/* Main column: title row on top, state strip below. Centred
vertically against the full-height icon on the left. */
.agent-header-main {
display: flex;
@ -140,12 +138,10 @@ h2, h3 {
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
}
.agent-icon {
/* Square identity anchor (#394 mara's spec). Explicit em sizing
so the <img>'s intrinsic (large) dimensions don't push the
parent flex container open via `align-items: stretch`-driven
height feedback. Width = header content area (header min-h 6em
- 2 × 0.5em padding 5em). Sticks to the top so a state-row
wrap doesn't drag the icon down with it. (#411) */
/* Square identity anchor explicit 5em sizing + align-self.
See docs/web-ui.md::Per-agent page (Agent icon) for the
intrinsic-dim-pushes-parent-flex-open + sticks-to-top
rationale. */
width: 5em;
height: 5em;
flex-shrink: 0;
@ -156,9 +152,9 @@ h2, h3 {
}
/* Meta-nav links (stats / screen / forge / dashboard / extras)
no underline (#394 mara's spec); hover lights with cyan glow +
subtle background tint. Reads as a row of soft tabs rather than
default-styled inline anchors. */
no underline; hover lights with cyan glow + subtle background
tint. Reads as a row of soft tabs rather than default-styled
inline anchors. */
.agent-nav-link {
color: var(--cyan);
text-decoration: none;
@ -176,7 +172,7 @@ h2, h3 {
}
/* Overflow menu trigger `` round button on the right of the
pills row. Quiet by default, lights on hover / open (#394). */
pills row. Quiet by default, lights on hover / open. */
.overflow-btn {
background: transparent;
border: 1px solid var(--purple-dim);
@ -200,13 +196,11 @@ h2, h3 {
box-shadow: 0 0 10px -2px var(--purple);
}
/* Overflow popover rebuild + new-session (and the dashboard
back-link, prepended in app.js setHeader). Positioned in JS so
the menu's top-right corner anchors under the trigger button.
`:not([hidden])` scoping (#411): the `[hidden]` HTML attribute
sets `display: none` via the UA stylesheet, but author CSS's
`display: flex` would override that. Scope display rules so
they apply only when the menu is unhidden. */
/* Overflow popover rebuild + new-session + logout (and the
dashboard back-link, prepended in app.js setHeader). Positioned
in JS so the menu's top-right corner anchors under the trigger
button. See docs/web-ui.md::Per-agent page (Overflow button)
for the `:not([hidden])` scoping rationale. */
.overflow-menu:not([hidden]) {
display: flex;
flex-direction: column;
@ -395,14 +389,12 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
}
.btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; }
/* `.btn-rebuild` was the per-agent header chip moved into the
overflow menu in #394 (`.overflow-item-rebuild` covers it now).
The dashboard has its own `.btn-rebuild` rule for the per-row
R3BU1LD form on the SW4RM tab; this one was specific to the
per-agent header.
`.btn-send` was a green send-button variant orphaned since
the dashboard's compose form was retired; no live consumer left
in either the agent or dashboard tree. */
/* Orphaned rules left here as a tombstone so a search for the
class name finds them. Live consumers gone:
- `.btn-rebuild` was a per-agent header chip (covered now by
`.overflow-item-rebuild` in the overflow menu).
- `.btn-send` was a green send-button variant the dashboard's
compose form that used it is retired. */
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.sendform input {
font-family: inherit; font-size: 1em;
@ -424,10 +416,10 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
}
.loginform input:focus { outline: 1px solid var(--purple); }
/* #568: show / hide toggle for the masked OAuth-code input. Quiet
by default (muted border + transparent bg), lights amber on
hover / when pressed (aria-pressed="true") so the operator
sees at a glance whether the code is currently visible. */
/* Show / hide toggle for the masked OAuth-code input. Quiet by
default (muted border + transparent bg), lights amber on hover
/ when pressed (`aria-pressed="true"`) so the operator sees at
a glance whether the code is currently visible. */
.loginform-reveal {
font-family: inherit;
font-size: 1em;
@ -489,7 +481,7 @@ pre.diff {
/* Inbox / loose-ends rows: header (from / sep / ts) on one line,
body on its own line below gives the body the full panel width
instead of squeezing it into a fourth grid column that wrapped
long messages over many narrow lines (issue #376). */
long messages over many narrow lines. */
.agent-inbox li {
padding: 0.4em 0;
display: block;
@ -552,15 +544,16 @@ pre.diff {
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
/* #666: inline answer slot mounted under each `ask operator` row
in the live terminal stream. Mirrors the side-panel `.answer-form`
/* Inline answer slot mounted under each `ask operator` row in
the live terminal stream. Mirrors the side-panel `.answer-form`
look-and-feel same textarea, same send button so the operator
doesn't have to context-switch between "answering in the panel" and
"answering inline". Empty slot collapses to nothing (no margin) so
pre-bind rows stay tidy; populated slot gets a thin top divider to
separate the question body from the form. Resolved tag (the
struck-through `[answered ]`) replaces the form once the
question's been answered. */
doesn't have to context-switch between "answering in the panel"
and "answering inline". Empty slot collapses to nothing (no
margin) so pre-bind rows stay tidy; populated slot gets a thin
top divider to separate the question body from the form. A
`[resolved]` tag (neutral, covers answered / cancelled /
TTL-expired uniformly) replaces the form once the question
leaves the pending list. */
.live .ask-answer-inline-slot:empty { display: none; }
.live .ask-answer-inline-slot {
margin-top: 0.5em;
@ -613,10 +606,10 @@ pre.diff {
text-decoration-color: var(--muted);
}
/* #559: "mark all read" header row sits above the recent-messages
list in the inbox side-panel flyout. Same look as the answer-form
button (mauve hover, bg-elev background) so they read as part of
the same affordance family. */
/* "mark all read" header row sits above the recent-messages list
in the inbox side-panel flyout. Same look as the answer-form
button (mauve hover, bg-elev background) so they read as part
of the same affordance family. */
.agent-inbox .inbox-mark-all-row {
display: flex;
gap: 0.6em;
@ -697,8 +690,9 @@ pre.diff {
text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); }
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
/* Orphaned in #394 `.btn-dashlink` chip beside the title moved
into the overflow menu (`.overflow-item-dashboard` covers it). */
/* Orphaned tombstone `.btn-dashlink` chip that lived beside the
title was moved into the overflow menu (`.overflow-item-dashboard`
covers it now). */
.btn-cancel-turn {
font-family: inherit;
font-size: 0.8em;
@ -716,8 +710,8 @@ pre.diff {
background: rgba(243, 139, 168, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
/* Orphaned in #394 `.btn-new-session` round-pill moved into the
overflow menu (`.overflow-item-new-session` covers it; the
/* Orphaned tombstone `.btn-new-session` round-pill moved into
the overflow menu (`.overflow-item-new-session` covers it; the
`:disabled` opacity treatment lives on the shared
`.overflow-item:disabled` rule). */
.state-badge {
@ -793,7 +787,10 @@ pre.diff {
/* Tail pill ( N new): nudged up so it floats clear of the composer
rather than colliding with the frosted bar. z-index bumped above
the composer (z-30) so the pill sits on the top layer instead of
being clipped by the floating chrome (issue #375). */
being clipped by the floating chrome. (Pill is anchored in
`.agent-main` rather than `.terminal-wrap` see
docs/web-ui.md::Per-agent page Terminal-wrap paragraph for the
backdrop-filter stacking-context rationale.) */
.agent-main .tail-pill {
bottom: calc(var(--agent-composer-h) + 0.6em);
z-index: 35;
@ -843,10 +840,9 @@ pre.diff {
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
/* side panel (singleton drawer)
Inbox + loose-ends details open here instead of expanding inline
(issue #360). Copy of the dashboard's side-panel pattern
candidate for extraction into @hive/shared once both surfaces
stabilize. */
Inbox + loose-ends details open here instead of expanding inline.
Copy of the dashboard's side-panel pattern candidate for
extraction into @hive/shared once both surfaces stabilize. */
.side-panel {
position: fixed;
inset: 0;

View file

@ -79,8 +79,7 @@ window.marked = marked;
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
// Shared shape with the dashboard's panel. Candidate for extraction
// into @hive/shared in a follow-up — keeping the duplication for
// now to land #360 without simultaneously refactoring the dashboard.
// into @hive/shared once both surfaces stabilise.
const Panel = (() => {
const root = $('side-panel');
const titleEl = $('side-panel-title');
@ -141,13 +140,12 @@ window.marked = marked;
// ─── state rendering ────────────────────────────────────────────────────
function setHeader(label, qualifiedLabel, dashboardPort) {
const title = $('title');
// Title is now just the glowing identity glyph — DASHB04RD,
// R3BU1LD, NEW SESSION all live in the overflow `⋯` menu now
// (#394). Glow + uppercase styling from h2 / .agent-header-title-row.
// The glyphic title stays short (no @hive suffix) — the hive
// qualifier lives on the second row's `qualified` chip + the
// browser tab title so the cinematic header reads cleanly at a
// glance (#589).
// Title is just the glowing identity glyph — DASHB04RD, R3BU1LD,
// NEW SESSION live in the overflow `⋯` menu. Glow + uppercase
// styling from h2 / .agent-header-title-row. The glyphic title
// stays short (no @hive suffix) — the hive qualifier lives on
// the second row's `qualified` chip + the browser tab title so
// the cinematic header reads cleanly at a glance.
title.textContent = `${label}`;
// Document title carries the qualified name so the browser's tab
// bar disambiguates between same-named agents on different hives
@ -160,20 +158,17 @@ window.marked = marked;
populateOverflowMenu(label, dashUrl);
}
// Overflow popover: dashboard back-link + rebuild + new-session.
// Per #394 mara's spec — rebuild + new-session both moved off the
// header strip into the `⋯` menu (rare actions, both destructive
// enough to warrant one extra click; the operator rebuilds from
// the host dashboard normally). Dashboard link also slotted in so
// every "leave this page" action lives in one menu.
// Overflow popover: dashboard back-link + rebuild + new-session +
// logout. Rare + destructive actions live here behind one extra
// click (the operator rebuilds from the host dashboard normally).
// See docs/web-ui.md::Per-agent page (Overflow button).
let overflowMenuPopulated = false;
function populateOverflowMenu(label, dashUrl) {
const menu = $('overflow-menu');
if (!menu) return;
menu.replaceChildren();
// ↑ dashboard — host dashboard back-link (was `.btn-dashlink`
// beside the title pre-#394).
// ↑ dashboard — host dashboard back-link.
menu.append(el('a', {
class: 'overflow-item overflow-item-dashboard',
href: dashUrl,
@ -227,15 +222,14 @@ window.marked = marked;
});
menu.append(newSessBtn);
// 🔓 logout (#576) — SIGINTs claude, wipes the credentials dir,
// flips the harness LoginState to NeedsLogin. The turn loop's
// next iteration parks in wait_for_login; a fresh `claude auth
// login` from the dashboard re-arms it (#542 mtime resumption).
// Operator has to re-paste OAuth creds on the login screen after,
// but the --continue session history is preserved (#584 narrowed
// the backend wipe to just .credentials.json + mcp-needs-auth-
// cache.json) — so the agent picks up where it left off on the
// next turn after re-login.
// 🔓 logout — SIGINTs claude, wipes the credentials dir, flips
// the harness LoginState to NeedsLogin. The turn loop's next
// iteration parks in wait_for_login; a fresh `claude auth login`
// from the dashboard re-arms it. Operator has to re-paste OAuth
// creds on the login screen after, but the --continue session
// history is preserved (the backend wipe is narrowed to just
// .credentials.json + mcp-needs-auth-cache.json) — so the agent
// picks up where it left off on the next turn after re-login.
const logoutBtn = el('button', {
type: 'button',
class: 'overflow-item overflow-item-logout',
@ -464,8 +458,9 @@ window.marked = marked;
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
}
}
// First arg is the URL path (relative to document base, #14);
// second is the slash-command label rendered in the local note.
// First arg is the URL path (relative to document base — see
// docs/web-ui.md::Per-agent relative paths); second is the
// slash-command label rendered in the local note.
const postCancelTurn = () => postSimple('api/cancel', '/cancel');
const postCompact = () => postSimple('api/compact', '/compact');
const postNewSession = () => postSimple('api/new-session', '/new-session');
@ -485,7 +480,7 @@ window.marked = marked;
return true;
case '/clear':
termAPI.clear();
// #666: detached `ask → operator` rows no longer have a live
// Detached `ask → operator` rows no longer have a live
// mount-point in the DOM — drop their slots so subsequent
// loose-ends reconciliation doesn't walk dead references.
pendingAskBinds.length = 0;
@ -837,12 +832,11 @@ window.marked = marked;
return wrap;
}
/** #559: "mark all read" affordance for the agent's inbox flyout.
* Returns a DOM row containing a button + an inline status pill.
* POSTs to the host dashboard's `/api/agent/{name}/mark-all-read`
* (damocles PR #566) and surfaces the `{ marked: N }` count back to
* the operator. Re-runs `onCleared` on success so the caller can
* refresh whatever state it owns. */
/** "mark all read" affordance for the agent's inbox flyout
* see docs/web-ui.md::Per-agent page (Loose-ends flyout) for the
* cross-origin POST + count rendering. Returns a DOM row
* containing a button + an inline status pill; re-runs
* `onCleared` on success so the caller can refresh its own state. */
function buildInboxMarkAllRow(label, onCleared) {
const status = el('span', { class: 'inbox-mark-status' });
const btn = el('button', {
@ -896,12 +890,12 @@ window.marked = marked;
'inbox empty.'));
return wrap;
}
// #559: "mark all read" header row drains the host broker's
// pending + delivered-unacked rows for this agent (damocles PR
// #566). Visible rows here are the most-recent-N regardless of
// ack state, so the list itself doesn't visually empty on click —
// the status pill confirms the drain count, and the next
// turn_start's "unread" badge will read zero.
// "mark all read" header row drains the host broker's pending +
// delivered-unacked rows for this agent. Visible rows here are
// the most-recent-N regardless of ack state, so the list itself
// doesn't visually empty on click — the status pill confirms
// the drain count, and the next turn_start's "unread" badge
// will read zero.
wrap.append(buildInboxMarkAllRow(currentLabel, () => {
// Refresh state so any UI surface that DOES depend on
// delivery state (eg future per-status filters) picks up
@ -1029,10 +1023,6 @@ window.marked = marked;
});
})();
// (#394) — `↻ new session` button moved into the overflow `⋯`
// menu and wired by `populateOverflowMenu()` above. Previously
// wired here as a static `#new-session-btn` in index.html.
// Track banner activity by reference-counting in-flight turns. A turn
// can begin while the previous turn_end is still in the pipeline (rare
// but happens on tight wake cycles), so we count rather than toggle.
@ -1074,9 +1064,8 @@ window.marked = marked;
rel: 'noopener',
title: lnk.label || '',
});
// Layout gap comes from `.agent-nav { gap }` (#394) — drop
// the legacy per-link inline `marginLeft`. The trailing `→`
// is the "leaves this page" affordance.
// Layout gap comes from `.agent-nav { gap }`. The trailing
// `→` is the "leaves this page" affordance.
a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
metaLinks.append(a);
});
@ -1154,7 +1143,7 @@ window.marked = marked;
marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = marked.parse(src);
// marked autolinks URLs but leaves them same-tab — open them
// externally so a click never unloads the terminal. (issue #233)
// externally so a click never unloads the terminal.
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
@ -1306,15 +1295,10 @@ window.marked = marked;
const d = detailsOpenMd(api, 'tool-use',
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
q);
// #666: when the ask targets the operator, mount an inline
// answer slot in the live terminal so the operator doesn't
// need to open the loose-ends side panel (or jump to Y3R C4LL
// on the dashboard) to respond. The slot starts empty and
// gets populated by `reconcileAskBinds()` once the
// loose-ends fetch identifies a matching pending question
// (by asker == this agent + question text). Resolved
// questions render as a struck-through [answered ✓] tag
// instead of a form.
// When the ask targets the operator, mount an inline answer
// slot in the live terminal — see docs/web-ui.md::Per-agent
// page (Ask → operator inline-answer binding) for the slot
// registry + reconciler + [resolved] semantics.
if (to === 'operator') {
const slot = el('div', { class: 'ask-answer-inline-slot' });
// Stash the question text on the slot so the reconciler
@ -1356,12 +1340,13 @@ window.marked = marked;
: (c.content || '');
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
// #666: when an ask's tool_result lands the broker has just
// persisted the question with its assigned id. Refresh loose
// ends so `reconcileAskBinds` finds the new entry and mounts
// the inline answer form under the rendered ask row. Skipped
// during history replay (the question's likely long-resolved;
// turn_end refresh on cold-load already covers reconciliation).
// When an ask's tool_result lands the broker has just
// persisted the question with its assigned id. Refresh
// loose-ends so reconcileAskBinds finds the new entry and
// mounts the inline answer form under the rendered ask row.
// Skipped during history replay (the question's likely
// long-resolved; turn_end refresh on cold-load covers
// reconciliation).
if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) {
refreshLooseEnds();
}

View file

@ -8,16 +8,10 @@
</head>
<body class="agent-shell">
<!-- Fixed-overlay header (#394 redesign): two-row layout in the
main column — row 1 carries the title + meta-nav, row 2 carries
the live state strip. The agent icon eats the full header
height on the left as the identity anchor; flyout pills + an
overflow menu trigger sit on the right. Frosted glass over the
terminal — backdrop-filter blur shows the scrolled terminal
text behind.
All asset / API hrefs are relative — see docs/web-ui.md::Per-agent
relative paths for why (#14). -->
<!-- Fixed-overlay header — see docs/web-ui.md::Per-agent page
for the three-column layout (icon · main · pills). All asset
/ API hrefs are relative — see docs/web-ui.md::Per-agent
relative paths for the document-baseURI resolution model. -->
<header class="agent-header" id="agent-header">
<img class="agent-icon" src="icon" alt="">
@ -44,9 +38,9 @@
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
hidden until their list is non-empty; the overflow `⋯` is
always visible (rebuild + new-session live inside it per
#394 — both rare, both destructive, both deserve one extra
click). -->
always visible. Rebuild + new-session + logout live inside
it — see docs/web-ui.md::Per-agent page (Overflow button)
for the rare-destructive-extra-click rationale. -->
<div class="agent-header-pills">
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
title="open inbox flyout">

View file

@ -62,13 +62,10 @@ html, body { height: 100%; background: var(--base); color: var(--text);
fit the wrap) and clip any sub-pixel rounding overflow. */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
canvas { display: block; cursor: default; }
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
The canvas is a flex item, and flex items default to
min-width/min-height: auto — which resolves to the canvas's intrinsic
framebuffer resolution and clamps the JS-set size straight back up,
defeating the downscale (the bug behind #133 round 1). Pin the canvas
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
flex: none stops flex grow/shrink from fighting it. */
/* Pin the canvas to exactly the size relayoutCanvas() sets. See
docs/web-ui.md::Per-agent endpoints (GET /screen) for the
flex-item min-width:auto clamp that made fit mode a silent
no-op before this pinning. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
#msg {
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
@ -93,13 +90,11 @@ canvas { display: block; cursor: default; }
<div id="debug-log"></div>
<script>
// Minimal RFB-over-WebSocket renderer.
// Connects to /screen/ws on the same host; the harness relays raw
// RFB bytes to the VNC server running inside the container.
//
// This is a deliberately thin implementation — enough to display the
// desktop and forward pointer + keyboard events. For a production-grade
// viewer, replace with noVNC (issue #52 vendors the full bundle).
// Minimal RFB-over-WebSocket renderer. Connects to `screen/ws` on
// the same host; the harness relays raw RFB bytes to the VNC server
// running inside the container. See docs/web-ui.md::Per-agent
// endpoints (GET /screen) for the design rationale + canvas-sizing
// implementation notes.
(function () {
'use strict';
@ -123,18 +118,13 @@ canvas { display: block; cursor: default; }
});
// --- Fit-to-window toggle ---
// Scales the canvas down so the whole desktop is visible without
// scrolling. The canvas's intrinsic resolution (width/height attrs)
// is untouched — only its CSS display size changes, set explicitly
// by relayoutCanvas(). Pointer coordinates are rescaled in
// sendPointer to stay accurate. Persisted in localStorage; default
// is fit-on.
// See docs/web-ui.md::Per-agent endpoints (GET /screen) for the
// localStorage persistence + canvas-intrinsic-resolution +
// pointer-rescale model.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
// Size the canvas. In fit mode, scale down (never up) to the wrap,
// preserving aspect ratio. Explicit px sizing rather than CSS
// max-width/max-height: on a flex item those are overridden by the
// automatic minimum size, so fit mode was a silent no-op — the
// oversized canvas just got centred and clipped (issue #133).
// Scale down (never up) to the wrap, preserving aspect ratio.
// Explicit px sizing — see GET /screen docs for the flex-item
// min-width:auto clamp this avoids.
function relayoutCanvas() {
if (fitMode && canvas.width && canvas.height
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
@ -166,9 +156,9 @@ canvas { display: block; cursor: default; }
// --- Match-size: resize the remote desktop to this window ---
// Sends an RFB SetDesktopSize request so the VNC server (weston)
// changes its actual output resolution to match the browser
// viewport — sharper than fit-mode's CSS downscale. The button is
// enabled only once the server has advertised the ExtendedDesktopSize
// pseudo-encoding (a -308 rect). (issue #133)
// viewport — sharper than fit-mode's CSS downscale. Gated on the
// server's ExtendedDesktopSize advert; see docs/web-ui.md::Per-agent
// endpoints (GET /screen).
let extDesktopSupported = false;
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
matchBtn.addEventListener('click', () => {
@ -206,10 +196,10 @@ canvas { display: block; cursor: default; }
}
// --- WebSocket connection ---
// Path-relative so the agent page mounted under a prefix
// (e.g. /agent/<name>/screen via nginx, #14) still hits the right
// upstream. `document.baseURI` resolves against the page's URL;
// swapping protocol on top gives ws(s)://host/<prefix>/screen/ws.
// Path-relative URL — `document.baseURI` resolves against the
// page's URL so the agent page mounted under a nginx prefix
// (e.g. /agent/<name>/screen) still hits the right upstream.
// Swap protocol on top gives ws(s)://host/<prefix>/screen/ws.
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = new URL('screen/ws', document.baseURI);
wsUrl.protocol = proto + ':';
@ -573,8 +563,8 @@ canvas { display: block; cursor: default; }
canvas.height = fbH;
relayoutCanvas();
setStatus('connected', 'connected');
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
// server reports (and accepts) desktop-size changes. (issue #133)
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so
// the server reports (and accepts) desktop-size changes.
sendSetEncodings([0, -308]);
// Request full framebuffer update
requestUpdate(0, 0, 0, fbW, fbH);
@ -618,7 +608,7 @@ canvas { display: block; cursor: default; }
} else if (enc === EXT_DESKTOP_SIZE_U32) {
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
// header's x = change reason, y = request status. (issue #133)
// header's x = change reason, y = request status.
const nScreens = peekByte();
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
const body = drainTo(4 + nScreens * 16);
@ -693,7 +683,7 @@ canvas { display: block; cursor: default; }
}
// SetDesktopSize (msg type 251): ask the server to change the desktop
// resolution. One screen at the origin, sized to the request. (#133)
// resolution. One screen at the origin, sized to the request.
function sendSetDesktopSize(w, h) {
const b = new Uint8Array(24);
b[0] = 251; b[1] = 0; // message-type + padding

View file

@ -1,13 +1,7 @@
// Shared dashboard helpers — extracted from the original monolithic
// dashboard JS as step 1 of the #406 split. These bits are used by
// both the tab dashboard (index.html) and the flow page (flow.html):
// pure DOM helpers, the side-panel singleton, the OS-notification
// module, and the path-link / file-preview infrastructure for the
// side panel.
//
// Each page now has its own entry point — `./tabs.js` for index.html,
// `./flow.js` for flow.html — and both import from here directly
// (#406 steps 2 + 3 complete; #406 closed).
// Shared dashboard helpers used by both index.html (./tabs.js) and
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
// the OS-notification module, and the path-link / file-preview
// infrastructure for the side panel.
import { linkify as termLinkify } from '@hive/shared/terminal.js';
@ -57,31 +51,23 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
// tied to its caller, so they don't generalise cleanly. We can lift
// them when a second consumer needs the same shape.
// ─── shared-worker SSE pipe (#448) ──────────────────────────────────────
// Returns an EventSource-shaped object backed by a SharedWorker that
// holds ONE upstream `new EventSource(url)` and fans events out to
// every connected tab. Replaces direct `new EventSource(url)` at the
// dashboard's two consumer sites (tabs.js inline + flow.js via
// terminal.js's `streamFactory` option) so N hyperhive tabs share
// ONE backend connection — way under the browser's per-host
// connection cap, immune to per-tab throttling that drops the SSE
// when Firefox suspends background tabs.
// ─── shared-worker SSE pipe ─────────────────────────────────────────────
// Returns an EventSource-shaped facade backed by a SharedWorker that
// holds one upstream `new EventSource(url)` and fans events out to
// every connected tab. See docs/web-ui.md (SSE multiplexing paragraph)
// for the design + Firefox throttling motivation; graceful fallback to
// direct EventSource on environments without SharedWorker.
//
// Graceful fallback to direct EventSource on environments without
// SharedWorker (some embedded browsers, some Safari versions). The
// per-tab connection cost is the same as today — no regression.
//
// The page consumer uses the returned object like a regular
// EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()`
// tells the worker to drop the subscription; the worker closes the
// upstream EventSource when the last subscriber leaves.
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
// drops the subscription (the worker closes the upstream when the last
// subscriber leaves).
const SHARED_WORKER_PATH = '/static/stream-worker.js';
const SHARED_WORKER_NAME = 'hyperhive-stream';
// One SharedWorker port per page, reused by all openStream calls on
// that page. Invalidated on `pagehide` so a bfcache restore picks up
// a fresh port (the cached one may have been collected if all other
// tabs closed while this page was frozen — argus nit on #453).
// a fresh port — the cached port may be dead if all other tabs
// closed while this page was frozen.
let _sharedPort = null;
function makeSharedPort() {
if (typeof SharedWorker === 'undefined') return null;
@ -99,30 +85,11 @@ function getSharedPort() {
return _sharedPort;
}
// #515: detect when the SharedWorker has been killed. Firefox aggressively
// reclaims "idle" SharedWorkers under memory pressure (or just on tab
// lifecycle quirks we don't fully understand), and there's no native
// signal to the client when that happens — `postMessage` on a dead
// port silently no-ops, and the page just stops receiving events. The
// observable symptom is "dashboard never refreshes; F5 fixes it" (a
// fresh page creates a fresh worker), which is mara's report on #515.
//
// Pipeline:
// - The worker pings every connected port every WORKER_PING_INTERVAL_MS.
// - `route` (every message arrival, including pings) bumps
// `_lastWorkerActivityAt`.
// - `startWorkerWatchdog` polls every WORKER_WATCHDOG_INTERVAL_MS.
// If the page is visible AND we have active subs AND we haven't
// heard from the worker in > WORKER_DEAD_THRESHOLD_MS, we presume
// the worker is dead, log a warning, and re-subscribe on a fresh
// port.
//
// Numbers picked so a real Firefox tab-suspend / unsuspend cycle (which
// can pause the watchdog itself) doesn't false-positive: 90s without a
// 30s ping means at least three pings missed. Visibility-gated so a
// backgrounded tab — where Firefox throttles setInterval to 1Hz min and
// our healthcheck wouldn't trigger reliably anyway — doesn't try to
// reconnect uselessly.
// SharedWorker death detection: pings from the worker bump the
// activity clock; a visibility-gated watchdog polls and re-subscribes
// on a fresh port if the page has been silent past the threshold.
// See docs/web-ui.md (Worker-death self-heal paragraph) for the
// timing rationale + Firefox reclaim symptom.
const WORKER_DEAD_THRESHOLD_MS = 90_000;
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
let _lastWorkerActivityAt = 0;
@ -164,25 +131,19 @@ function rebindOnFreshPort() {
noteWorkerActivity();
}
// Registry of live subscriptions on this page. Keyed by url so a
// second openStream call for the same URL (would only happen on a
// hypothetical multi-consumer page) attaches to the existing route
// rather than overlapping. Each entry caches the route function so
// bfcache-restore re-bind can re-attach it to the fresh port.
//
// Today's pages only call openStream once with one URL; the registry
// shape just keeps the bfcache-restore path correct if that changes
// (e.g. /index.html later subscribing to two streams).
// Registry of live subscriptions on this page. Keyed by url; entries
// cache the route function so bfcache-restore re-bind can re-attach
// it to the fresh port. Today's pages only call openStream once with
// one URL; the registry shape just keeps the bfcache path correct
// if that changes.
const _activeSubs = new Map();
// One-shot wiring of the page-wide lifecycle hooks: on bfcache
// freeze (`pagehide { persisted: true }`) we unsubscribe so the
// worker can close the upstream when the last live subscriber
// leaves; on bfcache restore (`pageshow { persisted: true }`) we
// invalidate the cached port (it may be dead if all other tabs
// closed during the freeze) and re-attach every active subscription
// to a fresh port. argus nit on #453: without this, the consumer's
// onmessage stays bound but no events flow after a bfcache restore.
// One-shot wiring of page-wide lifecycle hooks. On bfcache freeze
// we unsubscribe so the worker can close the upstream when the last
// live subscriber leaves; on bfcache restore we invalidate the cached
// port (may be dead after the freeze) and re-attach every active
// subscription to a fresh port. Without this, the consumer's
// onmessage stays bound but no events flow after restore.
let _lifecycleBound = false;
function bindLifecycleOnce() {
if (_lifecycleBound) return;
@ -245,7 +206,7 @@ export function openStream(url) {
},
};
const route = (e) => {
// #515: any message from the worker is proof of life — note it
// Any message from the worker is proof of life — note it
// before the URL filter, since heartbeat pings carry no URL.
noteWorkerActivity();
const m = e.data;
@ -272,8 +233,8 @@ export function openStream(url) {
_activeSubs.set(url, { target, route });
port.addEventListener('message', route);
port.postMessage({ kind: 'subscribe', url });
// #515: seed the activity clock so the watchdog has a baseline; it
// would otherwise compare against 0 (epoch) and trigger immediately.
// Seed the activity clock so the watchdog has a baseline (would
// otherwise compare against 0 and trigger immediately).
noteWorkerActivity();
return target;
}
@ -330,13 +291,10 @@ export const Panel = (() => {
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
}
// #451: drag-to-resize the drawer's width. Listens on a thin
// hit-strip glued to the drawer's left edge; mousedown captures
// pointermove + pointerup on the document so the drag continues
// even if the cursor strays outside the 6px handle band. Width
// persists to localStorage so it survives page reload. The CSS
// clamps the value (min-width: 320px, max-width: 96vw) — drop
// unparseable / out-of-range stored values silently.
// Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel
// for the hit-strip + pointer-capture + localStorage persistence
// model; CSS clamps the stored value to min 320px / max 96vw and
// out-of-range stored values are dropped silently.
const WIDTH_KEY = 'hyperhive:side-panel-width';
const WIDTH_MIN = 320;
function clampWidth(w) {
@ -476,7 +434,7 @@ function mdNode(text) {
window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = window.marked.parse(text);
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard. (issue #233)
// so a click never navigates away from the dashboard.
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';

View file

@ -3,19 +3,19 @@
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
/* tabbed dashboard chrome (#369)
Top-of-page sticky header with banner + tab strip. Tab routing is
hash-based; tab panes are show/hide via the `.tab-pane-active`
class. SSE stays alive across tab switches so count pills update
live on inactive tabs without losing pulse on what's happening
elsewhere. */
/* tabbed dashboard chrome
Top-of-page sticky header with banner + tab strip. SSE stays
alive across tab switches so count pills update live on inactive
tabs. See docs/web-ui.md::Chrome header + Tab strip for the
routing model. */
body.dashboard-shell {
/* Full-width layout (#416 mara: drop the 90em cap so wide screens
don't waste real estate on empty side margins). `padding: 0 1.5em
1.5em` keeps a small gutter on the left/right so cards don't kiss
the viewport edge; `.dashboard-chrome { margin: 0 -1.5em ... }`
still pulls the chrome bar edge-to-edge through that gutter. */
/* Full-width layout no max-width cap so wide screens don't
waste real estate on empty side margins. `padding: 0 1.5em
1.5em` keeps a small gutter on the left/right so cards don't
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em
... }` still pulls the chrome bar edge-to-edge through that
gutter. */
margin: 0;
padding: 0 1.5em 1.5em;
}
@ -189,16 +189,9 @@ a:hover {
background: rgba(24, 24, 37, 0.55);
transition: opacity 200ms ease, border-color 200ms ease;
}
/* Topology indent (#363). Each depth level shifts the row right by one
step; the .tree-prefix span (drawn by tabs.js::treePrefix) carries
the / glyph and any continuation lines that thread through
ancestor columns. When every container has parent=null (pre-#361
state) `[data-depth]` is absent on every row and these rules are
no-ops the layout reads exactly like the legacy flat list.
Per-depth indent: hardcoded steps for 6 levels (sufficient for any
plausible hive topology) the typed `attr()` function from CSS
Values 5 would collapse this to one rule, but browser support is
still partial (Chromium-only as of 2026). */
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
+ lane geometry paragraph) for the 1.8em-per-depth-level
rationale + CSS-attr()-not-yet-portable caveat. */
.container-row[data-depth] { position: relative; }
.container-row[data-depth="1"] { margin-left: 1.8em; }
.container-row[data-depth="2"] { margin-left: 3.6em; }
@ -206,16 +199,9 @@ a:hover {
.container-row[data-depth="4"] { margin-left: 7.2em; }
.container-row[data-depth="5"] { margin-left: 9em; }
.container-row[data-depth="6"] { margin-left: 10.8em; }
/* Tree prefix sits in the left margin and paints the connecting
/ / lanes as CSS rules rather than text glyphs (#388). Each
ancestor depth gets its own `.tree-lane` so we can paint a
full-row-height vertical bar that extends through the
.containers row gap into the next sibling text box-drawing
glyphs only fill one text line, which left visible breaks
between rows once cards grew taller than one line of text (5em
square icons + multi-line body). The horizontal stub at the row's
own joint lands at the icon midline so the L/T meets the icon
edge cleanly. */
/* Tree prefix lanes DOM-painted, not text-glyph-painted. See
docs/web-ui.md::Topology tree for the full-row-height vertical
bar rationale + icon-midline joint alignment. */
.container-row .tree-prefix {
position: absolute;
/* Extend into the `.containers { gap: 0.4em }` below so vertical
@ -228,12 +214,10 @@ a:hover {
user-select: none;
color: var(--purple-dim);
}
/* Each depth step is one 1.8em lane wide same step as the row's
own margin-left ladder above, so the rightmost lane (the joint)
sits flush against the row content (the icon). The prefix's left
edge is depth*1.8em LEFT of the row's left edge, so its right
edge meets the icon, and the leftmost lane lines up with the
top-level rows' icons at x=0. */
/* Prefix-left-edge ladder. Each depth step is 1.8em (matches the
row indent ladder above) so the prefix's right edge meets the
icon and its leftmost lane lines up with top-level rows' icons
at x = 0. */
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
@ -277,14 +261,12 @@ a:hover {
width: 2em;
border-top: 1px solid currentColor;
}
/* Live cards get the icon-left / body-right split; tombstone rows keep
the plain stacked block layout. The icon is a background-image div
with no intrinsic size, so its load state can never reflow the row
(issue #177). It used to `align-self: stretch` to fill the body
height, but with state badges / rate-limit pills / etc. wrapping the
head row, the body grew taller and the square icon grew with it
so two cards with different content showed different-sized icons
(issue #344). Fixed at 5em now; height follows from aspect-ratio. */
/* Live cards get the icon-left / body-right split; tombstone rows
keep the plain stacked block layout. The icon's fixed 5em width
+ aspect-ratio-derived height avoid the align-self: stretch
feedback loop that used to make different-content cards show
different-sized icons. See docs/web-ui.md::Container row (Icon
layout + load strategy) for the load-state reflow rationale. */
.container-row:not(.tombstone) {
display: flex;
align-items: flex-start;
@ -298,9 +280,10 @@ a:hover {
aspect-ratio: 1;
border-radius: 6px;
background-color: rgba(17, 17, 27, 0.6);
/* #443 icon is the selection toggle. Cursor + hover ring make
that affordable without a chrome change. The :focus-visible ring
covers keyboard activation (Enter / Space). */
/* Icon doubles as the selection toggle cursor + hover ring
make that affordable without a chrome change. The
:focus-visible ring covers keyboard activation (Enter / Space).
See docs/web-ui.md::Selection bar for the toggle semantics. */
cursor: pointer;
transition: box-shadow 120ms ease, transform 120ms ease;
}
@ -321,9 +304,9 @@ a:hover {
.container-row.selected > .container-icon {
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
}
/* The icon image fills the square wrapper and is taken out of flow
(absolute) so its load state pending, loaded, broken can never
contribute intrinsic size or reflow the row. (issue #177) */
/* Icon image absolutely positioned, fills the square wrapper.
See docs/web-ui.md::Container row (Icon layout + load strategy)
for the load-state-can't-reflow-row rationale. */
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
position: absolute;
inset: 0;
@ -331,8 +314,9 @@ a:hover {
height: 100%;
object-fit: contain;
}
/* When the <img> fails to load it falls back to the dimmed hyperhive
mark, standing in for the unreachable agent icon (issues #195, #202). */
/* When the <img> fails to load it falls back to the dimmed
hyperhive mark see docs/web-ui.md::Container row for the
fire-and-forget load + favicon fallback chain. */
.container-row:not(.tombstone) > .container-icon.icon-unreachable {
filter: grayscale(1);
opacity: 0.4;
@ -341,23 +325,29 @@ a:hover {
flex: 1;
min-width: 0;
}
/* Pending state splits queued vs running (#769): queued ops show
only the pending-state badge the row sits unhighlighted so a
long queue doesn't paint half the SW4RM tab amber. Running ops
keep the amber row tint AND get a rotating amber ring on the
agent icon so it's obvious which container is actually moving. */
/* Pending state splits queued vs running queued ops show only
the pending-state badge (no row tint), running ops keep the
amber row tint AND get a rotating amber ring on the icon. See
docs/web-ui.md::Container row (Pending-state derivation) for
the priority order. */
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
.container-row.pending-running {
border-color: var(--amber);
background: rgba(250, 179, 135, 0.05);
}
/* The spinner is a transparent border ring with two adjacent sides
coloured amber, rotated by a CSS animation looks like an
orbiting arc around the icon. Override the icon's overflow:hidden
so the ring can sit just outside the square; it composes naturally
with the mauve selected-ring (selected + running shows both). */
/* The spinner is a faint amber ring with one brighter arc on top
that rotates around the icon classic CSS spinner shape. The
previous version coloured two adjacent border sides amber, which
rendered as a rotating L-corner ("_|") rather than a smooth
orbiting arc (mara on #804). Faint-ring + bright-arc reads
unambiguously as a loading spinner.
Override the icon's overflow:hidden so the ring can sit just
outside the square; it composes naturally with the mauve
selected-ring (selected + running shows both). */
@keyframes container-icon-spin {
to { transform: rotate(360deg); }
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.container-row.pending-running > .container-icon {
overflow: visible;
@ -367,9 +357,8 @@ a:hover {
position: absolute;
inset: -4px;
border-radius: 10px;
border: 2px solid transparent;
border: 2px solid rgba(250, 179, 135, 0.2);
border-top-color: var(--amber);
border-right-color: var(--amber);
animation: container-icon-spin 1s linear infinite;
pointer-events: none;
}
@ -385,11 +374,13 @@ a:hover {
font-weight: bold;
}
.container-row .head .meta { margin-left: auto; }
/* Icon-only nav strip in the head row the per-container backend-
supplied link list (issue #262). Inline-flex + gap so a longer list
(e.g. with `dashboardLinks` extras) doesn't cram (issue #333). Each
link gets a comfortable hit target with a subtle hover so the
icons read as interactive rather than decorative. */
/* Icon-only nav strip in the head row backend-supplied per-
container link list. Inline-flex + gap so a longer list (e.g.
with `dashboardLinks` extras) doesn't cram. Each link gets a
comfortable hit target with a subtle hover so the icons read
as interactive rather than decorative. See
docs/web-ui.md::Container row Line 1 for the link-list source
of truth. */
.container-row .head .nav-strip {
display: inline-flex;
align-items: center;
@ -441,11 +432,10 @@ a:hover {
color: var(--cyan); border-color: var(--cyan);
text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
}
/* Context-window usage badges on dashboard container rows. Thresholds
are derived per-container: yellow 50% and red 75% of the model's
context window (`ContainerView.context_window_tokens`), mirroring the
harness compaction watermarks. Falls back to fixed 100k / 150k when
the window is unknown. (issue #66) */
/* Context-window usage badges on dashboard container rows. See
docs/web-ui.md::Container row Line 1 for the per-container
threshold derivation (yellow 50% / red 75% of the model's
context window) and fallback values. */
.badge-ctx-ok {
color: var(--green); border-color: var(--green);
opacity: 0.85;
@ -478,10 +468,10 @@ a:hover {
/* Per-container journald viewer + applied-config viewer. Both open
in the side panel and lazy-fetch on open; output is monospace
inside a bordered <pre>, controls (unit select + refresh) above.
#541: the panel-body wrapper is a column flex container that fills
the side-panel-body so the <pre> can flex-grow into a single tall
scrollable surface instead of a short box at the top with the rest
of the panel empty. */
The panel-body wrapper is a column flex container that fills the
side-panel-body so the <pre> can flex-grow into a single tall
scrollable surface instead of a short box at the top with the
rest of the panel empty. */
.journal-body {
display: flex;
flex-direction: column;
@ -509,9 +499,9 @@ a:hover {
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.5em 0.7em;
/* #541: take all leftover panel height + scroll inside the pre so
long log fetches don't push the controls off-screen. `min-height:
0` is the canonical "let me actually flex-shrink for overflow"
/* Take all leftover panel height + scroll inside the pre so long
log fetches don't push the controls off-screen. `min-height: 0`
is the canonical "let me actually flex-shrink for overflow"
escape hatch on flex children. */
flex: 1 1 0;
min-height: 0;
@ -605,9 +595,8 @@ code {
flex-wrap: wrap;
gap: 0.3em;
}
/* When the approval was requested right-aligned in the head row;
goes amber once it has been pending 1h so a stale request stands
out at a glance (issue #272). */
/* Approval requested-at chip right-aligned, goes amber after
1h. See docs/web-ui.md::Approval card. */
.approval-ts {
margin-left: auto;
color: var(--muted);
@ -660,7 +649,8 @@ code {
border-color: var(--purple);
background: rgba(203, 166, 247, 0.08);
}
/* Image / tabbed file preview (issues #188, #192) */
/* Image / tabbed file preview see docs/web-ui.md::Side panel
for the type-aware preview shapes. */
.preview-host { margin-top: 0.5em; }
.img-preview {
display: block;
@ -734,7 +724,7 @@ code {
text-overflow: ellipsis;
white-space: nowrap;
}
/* Bulk select-all / -none control above the meta-inputs tree (#275). */
/* Bulk select-all / -none control above the meta-inputs tree. */
.meta-inputs-bulk {
margin: 0 0 0.5em;
font-size: 0.8em;
@ -754,7 +744,7 @@ code {
border-color: var(--cyan);
text-shadow: 0 0 6px currentColor;
}
/* Tree twig glyph prefixing a nested (sub-)input row (#275). */
/* Tree twig glyph prefixing a nested (sub-)input row. */
.meta-input-twig {
color: var(--purple-dim);
margin-right: 0.1em;
@ -780,7 +770,7 @@ code {
cursor: not-allowed;
}
/* In-progress banner for the META INPUTS panel: shown while a
dashboard-triggered meta-update runs in the background (issue #259). */
dashboard-triggered meta-update runs in the background. */
.meta-update-running {
margin: 0 0 0.7em;
padding: 0.4em 0.7em;
@ -836,11 +826,10 @@ code {
.rqe-source-approval { color: var(--green); border-color: var(--green); }
.rqe-when { color: var(--muted); font-size: 0.85em; }
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
/* #437: in-flight step indicator on running queue entries sub-line
below the main row, indented under the state glyph + kind. Cyan
keeps it visually grouped with the running spinner instead of
blending into the muted reason/timing chips. flex-basis: 100% so
it always wraps to its own line. */
/* In-flight step indicator on running queue entries cyan sub-
line below the main row, wraps to its own line via flex-basis:
100%. See docs/web-ui.md::R3BU1LD QU3U3 for the step-annotation
semantics. */
.rqe-step {
flex-basis: 100%;
margin: 0.1em 0 0 1.8em;
@ -858,12 +847,11 @@ code {
white-space: pre-wrap;
}
/* #575: cancel-X for queued rebuild_queue entries. Quiet by default
(sits at the far right via margin-left: auto), lights red on hover.
Renders only when `state === 'queued'` per renderQueueEntry's
guard Running / terminal rows don't get the affordance. Mirrors
the side-panel-close glyph shape () without the full B6N-style
`btn-deny` width so it stays unobtrusive in a list row. */
/* Cancel-X for queued rebuild_queue entries sits at far right
via margin-left: auto, lights red on hover. Mirrors the side-
panel-close glyph shape () so it stays unobtrusive in a list
row. See docs/web-ui.md::R3BU1LD QU3U3 for the queued-only
gating. */
.rqe-cancel {
margin-left: auto;
}
@ -942,10 +930,9 @@ ul form.inline { display: inline-block; }
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
/* #486 M0V3 affordance (selection bar). Mauve picks up the same
accent the question-override / mid-status surfaces use; reads as
"structural change" rather than the destructive red / amber chrome
of destroy / rebuild. */
/* M0V3 affordance (selection bar) mauve reads as "structural
change" rather than the destructive red / amber chrome of
destroy / rebuild. See docs/web-ui.md::Selection bar. */
.btn-move { color: var(--mauve); border-color: var(--mauve); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
@ -959,7 +946,7 @@ ul form.inline { display: inline-block; }
text-shadow: 0 0 6px currentColor;
box-shadow: 0 0 8px -2px currentColor;
}
/* #474: inline edit button on each schedule row. Yellow reads as a
/* Inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */
.btn-edit-schedule { color: var(--yellow, #f9e2af); border-color: var(--yellow, #f9e2af); }
@ -1224,10 +1211,8 @@ summary:hover { color: var(--purple); }
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.5em 0.8em;
/* #450: no max-height cap let the inbox grow to fill the
side-panel-body which already scrolls (`overflow: auto`). The
pre-#450 24em cap clamped the list well short of the available
panel height even on tall viewports. */
/* No max-height cap let the inbox grow to fill the
side-panel-body which already scrolls. */
}
.inbox li {
padding: 0.25em 0;
@ -1263,17 +1248,9 @@ summary:hover { color: var(--purple); }
text-indent: 0;
}
.live .msgrow .msg-body {
/* #485: body takes a full flex line of its own beneath the
metadata chips (ts / arrow / from / sep / to). Previously the
body sat inline with `flex: 1 1 0`, eating whatever the chips
left which on a long timestamp + agent names + arrows meant
the body started ~30ch in and wrapped awkwardly. Pushing
`flex-basis: 100%` forces the body to wrap to its own line in
the existing `flex-wrap: wrap` row, where it can use the full
width down to the row's content edge.
`min-width: 0` still applies so `word-break: break-word`
actually kicks in instead of forcing the row wider than its
container. */
/* Body takes a full flex line of its own beneath the metadata
chips. See docs/web-ui.md::FL0W page (MESS4GE FL0W) for the
flex-basis: 100% + min-width: 0 rationale. */
flex: 1 1 100%;
min-width: 0;
}
@ -1372,9 +1349,9 @@ footer {
font-size: 0.9em;
}
footer a { color: var(--purple); }
/* Slug banner now lives at the page footer (#389 follow-up) give
it a slim top margin so it doesn't crash into the prior content,
and bottom margin separating from the divider/link line. */
/* Slug banner lives at the page footer slim top margin so it
doesn't crash into the prior content, bottom margin separating
from the divider/link line. */
footer .banner-thin {
margin-bottom: 0.8em;
}
@ -1420,13 +1397,11 @@ footer .banner-thin {
top: 0;
right: 0;
bottom: 0;
/* #451: width is a CSS variable so the drag handle (added by
Panel.bind) can update it live, and so localStorage-persisted
widths apply on first paint. Default min(760px, 94vw) preserves
the pre-#451 behaviour for operators who never drag. JS sets
`--side-panel-w` via inline style on the drawer; persistence
lives in localStorage (key `hyperhive:side-panel-width`),
replayed onto the var by `Panel.applyStoredWidth` at bind. */
/* Width is a CSS variable so the drag handle can update it live
(JS sets `--side-panel-w` on the drawer) and localStorage-
persisted widths apply on first paint. Default min(760px, 94vw)
covers operators who never drag. See docs/web-ui.md::Side panel
for the drag-to-resize mechanism + localStorage key. */
width: var(--side-panel-w, min(760px, 94vw));
/* Clamp so a stored width can never push the drawer off-screen
or shrink it past readability. min content width matches the
@ -1444,12 +1419,11 @@ footer .banner-thin {
.side-panel.open { pointer-events: auto; }
.side-panel.open .side-panel-backdrop { opacity: 1; }
.side-panel.open .side-panel-drawer { transform: translateX(0); }
/* #451: drag-to-resize handle on the drawer's left edge. The handle
itself is invisible until hover/drag so it doesn't compete with the
2px mauve `border-left` for the visual boundary. Pointer-cursor
tells the operator the edge is grabbable; the brighter glow during
drag (`body.side-panel-resizing`) is the affordance the eye
tracks. */
/* Drag-to-resize handle on the drawer's left edge. Invisible until
hover/drag so it doesn't compete with the 2px mauve border-left
for the visual boundary. Pointer-cursor tells the operator the
edge is grabbable; the brighter glow during drag
(body.side-panel-resizing) is the affordance the eye tracks. */
.side-panel-resize {
position: absolute;
top: 0;
@ -1547,22 +1521,17 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
padding: 0.2em 0.5em;
}
/* /flow.html full-page chat (#369)
The all-agents chat surface lives on its own page so it can claim
full-viewport vibec0re styling (operator @ #369#issuecomment-3437).
Same shape as the per-agent live page (#362): frosted-glass header
at top, frosted composer docked at bottom, terminal scrolls
behind both. Operator inbox lives behind a header pill that opens
the side-panel flyout preserves the "inbox + chat in one view"
ergonomics without stealing terminal real estate. */
/* /flow.html full-page chat
See docs/web-ui.md::FL0W page for the page-vs-pane rationale
and the inbox-flyout-as-pill ergonomics. Shape mirrors the
per-agent live page (frosted-glass header + composer, full-
viewport terminal). */
:root {
/* Approximate height of the flow chrome (tabbar + dashboard-chrome
padding). The banner-thin slug used to live in the chrome and
padded this up to 4.7em; with the slug moved out (#389 follow-up:
slug lives at the page footer on /, simply omitted on /flow.html
since there's no normal-flow footer position in the full-viewport
terminal), the chrome is just tabs now and shrinks accordingly. */
padding). The slug banner lives at the page footer on /, omitted
on /flow.html since the full-viewport terminal has no normal-flow
footer position. */
--flow-header-h: 3.6em;
--flow-composer-h: 3.6em;
--flow-frost-bg: rgba(30, 30, 46, 0.74);
@ -1584,8 +1553,8 @@ body.flow-shell {
var(--bg);
}
/* Flow chrome (#383): reuses the dashboard's `.dashboard-chrome` +
tabbar so the operator can switch tabs from the flow page without
/* Flow chrome reuses the dashboard's `.dashboard-chrome` + tabbar
so the operator can switch tabs from the flow page without
navigating back first. The chrome must be fixed-position (vs
sticky on the dashboard) since flow-shell has `overflow: hidden`
on body and the main area absolute-positions the terminal. */
@ -1613,15 +1582,13 @@ body.flow-shell .tabbar .tab.active.tab-link {
border-color: var(--purple-dim);
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
}
/* Legacy `.flow-title` / `.flow-hint` / `.flow-back` rules were
removed in #383 the flow page now uses the shared chrome with
the dashboard tab strip, no need for FL0W-specific title/hint
elements. The `.notif-row` styling lives under the shared
`.tabbar #notif-row` selector earlier in the file. */
/* `.notif-row` styling lives under the shared `.tabbar #notif-row`
selector earlier in the file the flow page reuses the dashboard
tab strip rather than carrying its own title/hint/back chrome. */
/* Inbox pill operator inbox flyout trigger. Sits right under the
header so it stays in the operator's gaze without crowding the
chat. Same shape as the agent page's pills (#362). */
chat. Same shape as the agent page's header pills. */
.flow-pill {
position: fixed;
top: calc(var(--flow-header-h) + 0.8em);
@ -1690,11 +1657,10 @@ body.flow-shell .tabbar .tab.active.tab-link {
overflow: auto;
}
/* Tail pill ( N new): bottom offset clears the floating composer.
z-index escapes the stacking context the .terminal-wrap's
backdrop-filter creates (issue #375) tabs.js anchors the pill
on .flow-main now (not .terminal-wrap), so this z-index reaches
the root stacking context and properly floats above the
composer at z-index 30. */
Pill is anchored on .flow-main (not .terminal-wrap) so the
backdrop-filter stacking context doesn't trap its z-index see
docs/web-ui.md::Per-agent page (Terminal-wrap) for the same
gotcha on the agent page. */
.flow-main .tail-pill {
bottom: calc(var(--flow-composer-h) + 0.6em);
z-index: 35;
@ -1720,10 +1686,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
surface the messages via the pill/flyout instead. */
.flow-inbox-headless { display: none !important; }
/* scheduled prompts tab (#459)
/* scheduled prompts tab
Creation form at the top, list of queued schedule cards below.
Cards show: id + source + due-in + cancel-all in the header,
the prompt body, then a targets table with per-row cancel. */
the prompt body, then a targets table with per-row cancel. See
docs/web-ui.md::SCH3DUL3S tab. */
.schedule-edit-form {
display: flex;
@ -1735,9 +1702,9 @@ body.flow-shell .tabbar .tab.active.tab-link {
padding: 0.8em 1em;
margin-bottom: 0.5em;
}
/* #474 inline edit form opens directly under the schedule row's
actions strip, indented slightly so it visually nests under the
row it edits. */
/* Inline edit form opens directly under the schedule row's
actions strip, indented slightly so it visually nests under
the row it edits. */
.schedule-edit-form-wrapper {
margin-top: 0.5em;
padding-left: 0.5em;
@ -1800,7 +1767,7 @@ body.flow-shell .tabbar .tab.active.tab-link {
justify-content: flex-end;
}
/* Interval composer (#466). Preset chip row + d/h/m/s inputs + live
/* Interval composer preset chip row + d/h/m/s inputs + live
preview, so the operator never has to multiply seconds by hand. */
.schedule-interval-presets {
display: flex;
@ -1844,10 +1811,9 @@ body.flow-shell .tabbar .tab.active.tab-link {
}
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
/* #535 schedules-as-table. One row per schedule, attribute columns
/* Schedules-as-table one row per schedule, attribute columns
on the left, one / column per agent in the middle, actions
column on the right. Agent column headers tilt -45° so a stack of
short agent names fits in ~28px each. */
column on the right. See docs/web-ui.md::SCH3DUL3S tab. */
.schedules-table {
width: 100%;
border-collapse: collapse;
@ -1867,11 +1833,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
.schedules-table-id { width: 3em; }
.schedules-table-body-th { min-width: 12em; }
.schedules-table-actions-th { width: 7em; }
/* Tilted agent column headers (#535). Header cell is narrow (~28px)
and tall (~90px); the inner <div> rotates -45° about its bottom-left
corner, with a translate to slide the text up alongside the cell
border. The inner <span> carries the actual baseline so the
underline (border-bottom on the cell) aligns with the rotated text. */
/* Tilted agent column headers narrow (~28px) and tall (~90px);
the inner <div> rotates -45° about its bottom-left corner, with
a translate to slide the text up alongside the cell border. The
inner <span> carries the actual baseline so the underline
(border-bottom on the cell) aligns with the rotated text. */
.schedules-table-agent-th {
width: 28px;
min-width: 28px;
@ -1955,8 +1921,8 @@ body.flow-shell .tabbar .tab.active.tab-link {
padding: 0.1em 0.45em;
}
/* #564 inline create row at the bottom of the schedules table.
Cells host inputs directly so the operator can fill + click to
/* Inline create row at the bottom of the schedules table. Cells
host inputs directly so the operator can fill + click to
queue a new schedule without leaving the table view. Slightly
different background tone so it reads as "this isn't a schedule
yet, it's the create form". */
@ -2058,11 +2024,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
border-color: var(--border);
}
/* Selection bar (#443). Sticky-bottom strip that surfaces bulk
actions when 1 agent is selected (click the icon). Visually
echoes the flow composer's frosted-mauve treatment so the chrome
reads as part of the same vibecore family. Hidden when empty
leaves the page footer's normal-flow position untouched. */
/* Selection bar sticky-bottom strip that surfaces bulk actions
when 1 agent is selected (click the icon). Visually echoes the
flow composer's frosted-mauve treatment so the chrome reads as
part of the same vibecore family. Hidden when empty. See
docs/web-ui.md::Selection bar for the bulk-action semantics. */
.selection-bar {
position: fixed;
bottom: 0;
@ -2118,11 +2084,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
selection set is non-empty). */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
/* #486 M0V3 <pick> picker. Inline `<select>` + button pair
sitting alongside the bulk action buttons in the selection bar.
The select inherits the terminal-y monospace look so it doesn't
read as system-chrome popping out of the swarm aesthetic. Only
surfaces when exactly one agent is selected. */
/* M0V3 <pick> picker inline `<select>` + button pair sitting
alongside the bulk action buttons in the selection bar. The
select inherits the terminal-y monospace look so it doesn't read
as system-chrome popping out of the swarm aesthetic. See
docs/web-ui.md::Selection bar for the picker semantics. */
.move-picker {
display: inline-flex;
align-items: center;

View file

@ -8,12 +8,11 @@
</head>
<body class="flow-shell">
<!-- Fixed-overlay chrome — just the tab strip (#389 follow-up:
slug moved to the dashboard's page footer; the flow page is a
full-viewport terminal with no normal-flow footer position, so
the slug simply doesn't appear here). The operator can still
switch tabs from the flow page without navigating back; FL0W is
the current page, SW4RM / Y3R C4LL / SYST3M cross-link to the
<!-- Fixed-overlay chrome — just the tab strip. The full-viewport
terminal has no normal-flow footer position so the dashboard's
slug doesn't appear here. The operator can still switch tabs
from the flow page without navigating back; FL0W is the current
page, SW4RM / Y3R C4LL / SYST3M / SCH3DUL3S cross-link to the
dashboard with the matching hash. -->
<header class="dashboard-chrome flow-chrome" id="flow-header">
<nav class="tabbar" id="tabbar" role="tablist">
@ -55,7 +54,7 @@
<!-- Operator inbox flyout trigger — count + click → side panel
(singleton, declared below). Hidden until the inbox is non-
empty. Mirrors the agent page's pill pattern (#362). -->
empty. Mirrors the agent page's header pill pattern. -->
<button type="button" id="inbox-pill" class="flow-pill" hidden
title="open operator inbox">
<span class="flow-pill-icon" aria-hidden="true">📬</span>
@ -111,10 +110,10 @@
</aside>
</div>
<!-- Flow-specific bundle (#406 step 2). Contains the broker
terminal init, the operator-inbox derived store, the inbox
pill flyout, and the @-mention composer. Tab renderers etc.
live in `/static/tabs.js` which /flow.html doesn't load. -->
<!-- Flow-specific bundle. Contains the broker terminal init, the
operator-inbox derived store, the inbox pill flyout, and the
@-mention composer. Tab renderers etc. live in
`/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -1,11 +1,8 @@
// /flow.html entry point (#406 step 2 — flow-specific split from the
// previous combined entry; #406 step 3 renamed that combined entry
// from `app.js` to `tabs.js`).
//
// Owns the full-page broker terminal, the operator-inbox derived store
// (populated from the broker stream), the inbox pill flyout, and the
// @-mention compose box. Pulls shared infrastructure (DOM helpers, side
// panel, OS notifications, path linkification) from `./common.js`.
// /flow.html entry point. Owns the full-page broker terminal, the
// operator-inbox derived store (populated from the broker stream),
// the inbox pill flyout, and the @-mention compose box. Pulls shared
// infrastructure (DOM helpers, side panel, OS notifications, path
// linkification) from `./common.js`.
//
// Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
@ -106,11 +103,11 @@ import {
if (!flow) return;
flow.innerHTML = '';
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
// Pulse the page banner whenever a broker event lands. (Note:
// post-#389 the `.banner` lives in the dashboard's <footer>, not
// in the flow page chrome — `pulseBanner` no-ops on /flow.html
// since there's no element to find. Kept for parity if a future
// chrome change reintroduces a banner.)
// Pulse the page banner whenever a broker event lands. The
// `.banner` element lives in the dashboard's <footer> rather than
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner.
const banner = document.querySelector('.banner');
let bannerOffTimer = null;
function pulseBanner() {
@ -174,33 +171,28 @@ import {
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
}
// Anchor the `↓ N new` pill in `.flow-main` (NOT the default
// `log.parentElement` = `.terminal-wrap`). `.terminal-wrap`
// applies `backdrop-filter`, which creates a CSS stacking
// context — the pill's z-index would otherwise be trapped
// inside and clipped under the fixed composer (issue #375).
// `.flow-main` has no backdrop-filter / stacking-context
// creators, so the pill's z-index reaches the root and floats
// above the composer.
// Anchor the `↓ N new` pill in `.flow-main` rather than the
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page).
const flowMain = document.querySelector('.flow-main');
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: '/dashboard/history',
// #408: server-side filter — only the kinds this page actually
// renders or routes (sent/delivered → broker terminal,
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
// Backend (#499) pre-parses the allow-list at subscribe time so
// the per-frame hot path is one HashSet::contains and the
// Backend pre-parses the allow-list at subscribe time so the
// per-frame hot path is one HashSet::contains and the
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
// #448: route through the SharedWorker so this page's SSE shares
// a single backend connection with /index.html (and any other
// open hyperhive tab). Worker keys on the full URL (incl.
// query string), so the filtered subscribe is its own upstream
// — won't accidentally share with tabs.js's wider subscribe.
// Route through the SharedWorker — see docs/web-ui.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => renderMsg(ev, api, '→'),
@ -232,10 +224,9 @@ import {
// Re-sync the local containers cache on every SSE (re)connect.
// Live mutation events that fired during a disconnect window
// are never replayed, so without this the compose autocomplete
// could drift stale (issue #163). We don't try to recover
// missed broker rows here — operator inbox briefly stales on
// reconnect; HiveTerminal's history-replay covers the next
// page load.
// could drift stale. We don't try to recover missed broker rows
// here — operator inbox briefly stales on reconnect; the
// history-replay covers the next page load.
onStreamOpen: () => {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;

View file

@ -8,12 +8,11 @@
</head>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip now (#389 follow-up: the
"WE ARE THE WIRED" slug moved out of chrome entirely and lives
at the page footer below `<main>`; chrome is navigation only).
Tabs route via the URL hash so F5 / back-button / shared links
keep you on the same view. JS owns the actual show/hide; this
is just the menu. -->
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
slug lives at the page footer below `<main>`; chrome is
navigation only. Tabs route via the URL hash so F5 / back-
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<nav class="tabbar" id="tabbar" role="tablist">
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
@ -34,10 +33,10 @@
<span class="tab-label">◆ SYST3M ◆</span>
<span class="tab-count" id="tab-count-system" hidden></span>
</a>
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
queued schedules + an operator-direct creation form.
Count pill mirrors the active (non-cancelled) schedule
count; hidden when zero. -->
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
schedules + an operator-direct creation form. Count pill
mirrors the active (non-cancelled) schedule count; hidden
when zero. -->
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules">
@ -45,24 +44,25 @@
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- M4TR1X (#607): optional matrix web client (fluffychat-web by
default) mounted at /matrix/ by hive-c0re's dashboard router
when `hyperhive.matrix.gui.enable = true`. Same-origin same-
tab navigation (the static dist is its own SPA). Hidden in JS
when `state.matrix_gui_enabled === false` so operators without
matrix-gui on don't see a dead link (#609 covers the
post-#15 nginx-front re-root + .well-known auto-discovery). -->
<!-- M4TR1X: optional matrix web client (fluffychat-web by
default) mounted at /matrix/ by the gateway when
`hyperhive.matrix.gui.enable = true`. Same-origin
navigation (the static dist is its own SPA). Hidden in
JS when `state.matrix_gui_enabled === false` so operators
without matrix-gui on don't see a dead link. See
docs/web-ui.md::Tab strip for the gating model and
docs/gateway.md for the matrix vhost + .well-known
auto-discovery. -->
<a class="tab tab-link" id="tab-matrix" href="/matrix/" hidden
title="open the matrix chat client (fluffychat-web)">
<span class="tab-label">◆ M4TR1X ◆ →</span>
</a>
<!-- FL0W is its own page (`/flow.html`), not a tab — per
operator @ #369#issuecomment-3437 ("yes terminal can be a
separate page"). The link lives in the tab strip so it
reads as a peer surface; clicking navigates rather than
swapping panes in place. Count pill mirrors the dashboard's
operator-inbox length and is hidden when zero. -->
<!-- FL0W is its own page (`/flow.html`), not a tab. The link
lives in the tab strip so it reads as a peer surface;
clicking navigates rather than swapping panes in place.
Count pill mirrors the dashboard's operator-inbox length
and is hidden when zero. See docs/web-ui.md::FL0W page. -->
<a class="tab tab-link" id="tab-flow" href="/flow.html"
title="open the all-agents chat in a dedicated full-page terminal">
<span class="tab-label">◆ FL0W ◆ →</span>
@ -86,9 +86,8 @@
<!-- SW4RM: the swarm itself. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. The tab label itself reads SW4RM, so the
inline C0NTAINERS h2 heading + divider would be redundant —
dropped per #385. -->
that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. -->
<section class="tab-pane" id="tab-pane-swarm"
role="tabpanel" aria-labelledby="tab-swarm">
<div id="containers-section">
@ -117,11 +116,8 @@
<!-- SYST3M: passive / rare-interaction state. Meta inputs (lock
bumps), rebuild queue (watch only), kept state from previous
tombstoned agents. Queued reminders moved to the SCH3DUL3S
tab in #460 — they're conceptually "fire X at time Y" too,
just self-scheduled by agents instead of operator-set.
Headings stay; the per-section content auto-compresses to a
one-line summary when empty (separate JS toggle). -->
tombstoned agents. Per-section content auto-compresses to a
one-line summary when empty (JS toggle). -->
<section class="tab-pane" id="tab-pane-system"
role="tabpanel" aria-labelledby="tab-system">
<h2>◆ M3T4 1NPUTS ◆</h2>
@ -145,16 +141,15 @@
</div>
</section>
<!-- SCH3DUL3S (#459, #564): scheduled prompts. Creation + edit
are folded into the same table now (#564) — empty bottom row
is the create form (fill cells, click ), inline-edit-row
expands on the `✎` toggle for existing schedules. Live
schedules list driven by GET /api/schedules; POST /api/schedules
to create, PATCH /api/schedules/{id} to edit, POST
/api/schedules/{id}/cancel for per-target / whole-row cancel.
#444 backend doesn't emit SchedulesChanged dashboard event yet,
so the list re-fetches on tab activation + after each submit /
cancel. Live SSE wiring is the future PR C. -->
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
into the same table — empty bottom row is the create form
(fill cells, click ), inline-edit-row expands on the `✎`
toggle for existing schedules. Schedules list driven by
GET /api/schedules; POST /api/schedules to create, PATCH
/api/schedules/{id} to edit, POST /api/schedules/{id}/cancel
for per-target / whole-row cancel. No SchedulesChanged SSE
event yet, so the list re-fetches on tab activation + after
each submit / cancel. See docs/web-ui.md::SCH3DUL3S tab. -->
<section class="tab-pane" id="tab-pane-schedules"
role="tabpanel" aria-labelledby="tab-schedules">
<h2>◆ SCH3DUL3S ◆</h2>
@ -164,12 +159,12 @@
<p class="meta">loading…</p>
</div>
<!-- QU3U3D R3M1ND3RS (#460): self-scheduled agent reminders.
Moved here from the SYST3M tab so the operator has one
place for everything that fires at a future time —
operator-set schedules above, agent-self reminders here.
Backed by GET /api/reminders; refresh handled by
refreshReminders() (called from refreshState). -->
<!-- QU3U3D R3M1ND3RS: self-scheduled agent reminders. Lives
on this tab so the operator has one place for everything
that fires at a future time — operator-set schedules
above, agent-self reminders here. Backed by GET
/api/reminders; refresh handled by refreshReminders()
(called from refreshState). -->
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
@ -209,11 +204,10 @@
</aside>
</div>
<!-- Selection action bar (#443). Sticky-bottom strip that slides
into view when one or more agent cards is selected (click the
icon to toggle). Shows the selection count + actions that
apply to ALL selected; disabled-with-tooltip for actions that
don't (mara picked option B). Hidden when selection is empty. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui.md::Selection bar for the bulk
action gating + clear semantics. -->
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
aria-label="bulk agent actions">
<span class="selection-count" id="selection-count"></span>
@ -223,11 +217,10 @@
title="clear selection (esc)">✕ clear</button>
</div>
<!-- Single bundled entry (#406 step 3 — renamed from app.js to
tabs.js since this bundle is the dashboard *tabs* surface only;
flow.html has its own flow.js bundle). esbuild folds
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js; load order is preserved by the module bundler. -->
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -178,7 +178,7 @@ window.marked = marked;
// agent rebuild ripple) runs in the background. Cold-loaded from
// `s.meta_update_running`, then flipped live by the
// `meta_update_running` event. Drives the META INPUTS panel's
// disabled "updating…" state (issue #259).
// disabled "updating…" state.
let metaUpdateRunning = false;
function syncTombstonesFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
@ -802,7 +802,7 @@ window.marked = marked;
// and own descendants on the client side; the
// backend rechecks via `topology::set_parent`).
//
// Backend lives at POST /api/topology/set-parent (dashboard.rs#2170),
// Backend lives at POST /api/topology/set-parent (dashboard.rs),
// form-encoded `child=<name>&new_parent=<target-or-empty>`. The
// backend re-emits container snapshots on success, so the tree
// repaints without a separate refresh.
@ -830,11 +830,10 @@ window.marked = marked;
});
// M0V3 → <pick>: inline `<select>` of candidate parents + submit
// button. Available for any selection size (mara on #695 — was
// single-agent only in v1). Picker omits each selected agent itself
// plus the union of every selected agent's descendants (cycle-safe;
// backend `topology::set_parent` re-checks). Empty candidate list ⇒
// disable the picker.
// button. Available for any selection size. Picker omits each
// selected agent itself plus the union of every selected agent's
// descendants (cycle-safe; backend `topology::set_parent`
// re-checks). Empty candidate list ⇒ disable the picker.
const candidates = validReparentCandidates(selected, containers);
const wrap = el('span', { class: 'move-picker' });
const selectTitle = selected.length === 1
@ -1033,11 +1032,9 @@ window.marked = marked;
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll to the newest lines on fresh fetch. #541
// moved the scroll surface from side-panel-body onto the
// <pre> itself (the panel-body now fills the viewport and
// the <pre> is the inner overflow container), so scroll
// the <pre> instead of the side-panel-body.
// Auto-scroll to the newest lines on fresh fetch. The
// scroll surface is the <pre> itself (panel-body fills
// the viewport, <pre> is the inner overflow container).
pre.scrollTop = pre.scrollHeight;
}
} catch (err) {
@ -2946,9 +2943,9 @@ window.marked = marked;
if (!confirm(prompt)) return;
// Capture child nodes so we can restore on error, then replace
// with DOM-built content (textContent + element children rather
// than innerHTML — per argus's review note on #471, the format
// string only carries server-side ints/bool today but textContent
// is the safer pattern if a stringy field ever lands).
// than innerHTML — the format string only carries server-side
// ints/bool today but textContent is the safer pattern if a
// stringy field ever lands).
const originalChildren = btn ? Array.from(btn.childNodes) : [];
const restoreBtn = () => {
if (!btn) return;
@ -3206,7 +3203,7 @@ window.marked = marked;
};
})();
// ─── tab routing (#369) ────────────────────────────────────────────────
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
// a tab — it's a separate page (`/flow.html`) reached via the

View file

@ -6,13 +6,15 @@
colour; pages that don't emit a given kind simply never produce that
class the unused rule sits in the bundle harmlessly.
`.terminal-wrap` provides the crust-on-black phosphor chrome that makes
the agent page feel like a terminal. Pages can opt in by wrapping a
block in this class; or skip it and the rows still render with their
class colours, just without the frame.
`.terminal-wrap` provides the crust-on-black phosphor chrome that
makes the agent page feel like a terminal. Pages can opt in by
wrapping a block in this class; or skip it and the rows still render
with their class colours, just without the frame.
No `.term-input` here composers are a separate concern (see
hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */
No `.term-input` here composers are a separate concern, owned by
each page's own CSS. Row taxonomy + layout contract documented in
`docs/web-ui.md::Shared terminal pane` and
`docs/terminal-rendering.md`. */
.terminal-wrap {
position: relative;
@ -213,7 +215,7 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
border-radius: 0;
}
.live .row .md a { color: var(--cyan); text-decoration: underline; }
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
.live .row a { color: var(--cyan); text-decoration: underline; }
.live .row a:hover { color: var(--fg); }
.live .row .md strong { color: inherit; font-weight: bold; }

View file

@ -50,12 +50,9 @@
// count=0); pages use it to set state flags from the replayed history.
const NEAR_BOTTOM_PX = 48;
// Snap-to-bottom animation duration (#400 + mara feedback). Browser
// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read
// as "still smooth, but visibly slow." 140ms with ease-out is fast
// enough to feel snap-y, slow enough that the row's destination
// reads as motion (not a jump). Distances under SCROLL_SNAP_PX
// short-circuit to instant — animating a 12px nudge is just jitter.
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
// 500ms-browser-default + 24px short-circuit rationale.
const SCROLL_ANIM_MS = 140;
const SCROLL_SNAP_PX = 24;
@ -77,16 +74,9 @@ export function create(opts) {
// handler so both programmatic scrollTop assignments and
// operator-driven wheel/drag stay in sync.
let stickToBottom = true;
// Guards scroll-event-handler from misreading the position while
// our own animation is mid-flight (#400). The animation drives
// scrollTop with rAF, which fires a stream of scroll events as
// the position eases toward the target — the position passes
// through "not near bottom" before settling. Without this gate,
// the scroll handler flips `stickToBottom` to false mid-animation,
// which then causes the MutationObserver to skip the next snap
// and leaves the operator stranded mid-scroll. Set to the
// animation's nominal end + small headroom; each fresh snap
// re-arms it so back-to-back snaps stay gated.
// Scroll-handler gate during in-flight snap animations — see
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
// guard) for the eased-through-not-near-bottom rationale.
let smoothScrollingUntil = 0;
// rAF id for the current snap animation. Cancelled when a new
// snap starts so we never have two animations fighting over
@ -96,16 +86,10 @@ export function create(opts) {
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
// Snap the log to the bottom with a brief eased animation
// (#400 + mara: snappier than the browser's default 500ms smooth
// scroll). Each call cancels the previous frame loop and starts a
// fresh one, so a burst of mutations coalesces into one ride to
// the latest bottom. Re-evaluates the target each frame so a
// renderer mutation landing mid-animation extends the destination
// without a visible jump. Falls back to instant scroll when
// `currentNoAnim` is true (backfill replay — operator never sees
// intermediate positions, animation is wasted frames) or when the
// remaining distance is under SCROLL_SNAP_PX.
// Snap the log to the bottom with a brief eased animation. Cancels
// any in-flight frame loop so back-to-back snaps coalesce; falls
// back to instant scroll during `currentNoAnim` backfill replay
// or under SCROLL_SNAP_PX. See docs/web-ui.md::Shared terminal pane.
function snapToBottom(immediate) {
stickToBottom = true;
if (scrollAnimRaf) {
@ -161,48 +145,26 @@ export function create(opts) {
pill.classList.add('visible');
}
log.addEventListener('scroll', () => {
// Mid-smooth-scroll: ignore the intermediate scroll events. The
// gate releases when the animation has had time to settle (or
// when the next snap re-arms it). Without this, easing toward
// bottom would flip `stickToBottom` false partway and the next
// MO callback would skip the snap.
// Swallow scroll events during smooth-snap animations — see
// docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event
// guard).
if (Date.now() < smoothScrollingUntil) return;
stickToBottom = isNearBottom();
if (stickToBottom) { unseen = 0; updatePill(); }
});
// Post-append mutations (issue #393). Renderers commonly call
// `api.row(cls, text)` to create the row shell, then mutate it
// by appending more children (badges, multi-line bodies, tool
// result panes) AFTER api.row returned. The afterAppend scroll
// below only sees the row's INITIAL height — once the renderer
// adds the body, the row's grown past the visible bottom and
// the operator is left scrolled to the row's TOP, breaking
// stick-to-bottom for every subsequent event.
//
// Fix: MutationObserver on the log subtree. Fires once per
// microtask after each batch of synchronous mutations, so it
// runs once per renderer call regardless of how many children
// the renderer appends. When `stickToBottom` is true, snap to
// bottom again — catches whatever the renderer added after the
// afterAppend hop. Programmatic `scrollTop = scrollHeight`
// assignments don't re-trigger MO (the scroll itself isn't a
// DOM mutation), so no feedback loop.
// Post-append mutation snap — catches renderer mutations that land
// after `api.row` returns (badges, multi-line bodies, tool
// panes). See docs/web-ui.md::Shared terminal pane (Post-append
// MutationObserver) for why the pre-append `afterAppend` hop
// alone isn't enough.
const mo = new MutationObserver(() => {
if (stickToBottom) snapToBottom();
});
mo.observe(log, { childList: true, subtree: true, characterData: true });
// Auto-scroll decision uses the PRE-append scroll position
// (issue #375). Checking after the append underestimates
// "nearness" because the new row's own height has already pushed
// `scrollHeight - scrollTop - clientHeight` past the threshold,
// even when the user was visually at the bottom an instant ago.
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
// and hands it to afterAppend so the auto-scroll triggers
// whenever the operator was at the bottom when the row landed.
// (The MutationObserver above catches the AFTER-row mutations
// too, but this initial scroll keeps the visual lag to one
// frame instead of one microtask + frame.)
// Pre-append nearBottom snapshot drives the initial snap decision —
// see docs/web-ui.md::Shared terminal pane (Post-append
// MutationObserver) for why we need both this and the MO.
function afterAppend(wasNearBottom) {
if (currentNoAnim || wasNearBottom) {
snapToBottom();
@ -318,12 +280,11 @@ export function create(opts) {
let live = false;
let buffered = [];
// #448: callers can supply a `streamFactory(url)` that returns an
// EventSource-shaped object (must expose onmessage/onopen/onerror
// + .close()). The dashboard pages pass a SharedWorker-backed
// factory so all open hyperhive tabs share ONE upstream SSE
// connection. Default keeps the direct `new EventSource(url)`
// behaviour so non-dashboard consumers (per-agent UI) are unchanged.
// Optional streamFactory(url) → EventSource-shaped facade. Lets
// the dashboard hand the factory a SharedWorker-backed source so
// open hyperhive tabs share one upstream — see
// docs/web-ui.md::Shared terminal pane (Backfill + SSE). Default
// falls back to `new EventSource(url)`.
const es = opts.streamFactory
? opts.streamFactory(opts.streamUrl)
: new EventSource(opts.streamUrl);
@ -351,7 +312,7 @@ export function create(opts) {
// during a disconnect window, so a consumer with
// snapshot-derived state (the dashboard's /api/state stores)
// must re-sync here or it shows stale state until a manual
// reload (issue #163).
// reload.
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
@ -370,7 +331,7 @@ export function create(opts) {
// carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
// fetch. ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
@ -427,9 +388,9 @@ export function create(opts) {
}
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. Non-URL text stays as plain
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
// punctuation is kept out of the link. (issue #233)
// clickable links that open in a new tab. See docs/web-ui.md::Shared
// terminal pane (linkify) for the text-node-only / no-innerHTML
// XSS-safety + trailing-punctuation strip.
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkify(text) {
const str = text == null ? '' : String(text);

File diff suppressed because it is too large Load diff

View file

@ -15,8 +15,6 @@ use std::path::Path;
use tokio::process::Command;
use crate::client;
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
@ -99,25 +97,29 @@ async fn update_marketplaces() {
}
}
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`. When
/// `notify_recipient` is `Some(name)`, install failures also get sent
/// as a hyperhive message to that recipient (typically `"manager"` for
/// sub-agents) so it surfaces in the inbox rather than being buried in
/// journald. The manager itself passes `None` — there's nobody above
/// it to notify.
pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
/// Returns a list of human-readable failure messages so the caller can
/// route them through their own per-role surface (turn-failure-style
/// notification, see `Surface::send_to_parent`). Pre-#692 this function
/// hardcoded `"manager"` as the failure-notification recipient via a
/// `notify_recipient: Option<&str>` arg; mara on #778 wanted the
/// manager-name special case gone. Now plugins.rs is wire-agnostic and
/// the caller picks the recipient via the same `<parent>` sentinel
/// failure-notify uses everywhere else (#703).
pub async fn install_configured(socket: &Path) -> Vec<String> {
let _ = socket; // Reserved for future telemetry; currently unused.
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
return;
return Vec::new();
};
let specs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
return;
return Vec::new();
}
};
if specs.is_empty() {
return;
return Vec::new();
}
add_marketplaces().await;
if auto_update_enabled().await {
@ -125,6 +127,7 @@ pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
} else {
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
}
let mut failures = Vec::new();
for spec in specs {
match Command::new("claude")
.args(["plugin", "install", &spec])
@ -142,43 +145,18 @@ pub async fn install_configured(socket: &Path, notify_recipient: Option<&str>) {
stderr = %stderr,
"claude plugin install failed",
);
if let Some(to) = notify_recipient {
notify(
socket,
to,
format!(
"claude plugin install failed for `{spec}`:\n{}",
stderr.trim()
),
)
.await;
}
failures.push(format!(
"claude plugin install failed for `{spec}`:\n{}",
stderr.trim()
));
}
Err(e) => {
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
if let Some(to) = notify_recipient {
notify(
socket,
to,
format!("claude plugin install spawn failed for `{spec}`: {e}"),
)
.await;
}
failures.push(format!(
"claude plugin install spawn failed for `{spec}`: {e}"
));
}
}
}
}
/// Best-effort hyperhive send. Swallows transport errors — the warn log
/// is already in journald and the harness boot must not stall waiting
/// for the broker to be reachable.
async fn notify(socket: &Path, to: &str, body: String) {
let req = hive_sh4re::AgentRequest::Send {
to: to.to_owned(),
body,
in_reply_to: None,
};
if let Err(e) = client::request::<_, hive_sh4re::AgentResponse>(socket, &req).await {
tracing::warn!(error = ?e, "failed to notify {to} of plugin install failure");
}
failures
}

View file

@ -7,7 +7,7 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
@ -78,7 +78,19 @@ pub type Flavor = mcp::Flavor;
/// # Errors
///
/// Returns an error if the TCP listener cannot bind to the given port.
/// Returns an error if neither the TCP listener (default) nor the
/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or
/// if `HIVE_STATIC_DIR` is missing.
///
/// # Binding modes (#784 phase 1)
///
/// When `HIVE_WEB_SOCKET` is set + non-empty, bind a `UnixListener`
/// at that path so the gateway can `proxy_pass unix:…` instead of
/// reaching us over a TCP loopback that won't work post-#14 (private
/// netns). When the env var is unset, fall back to TCP bind on `port`
/// — the legacy path the gateway's `agent-ports.json` map drives. The
/// gateway can transition to socket upstreams independently of any
/// agent re-binding because the env var is opt-in per agent.
pub async fn serve(
label: String,
port: u16,
@ -138,13 +150,54 @@ pub async fn serve(
// hyperhive.frontend.mergedDist in nix).
.fallback_service(ServeDir::new(&static_dir))
.with_state(state);
// `HIVE_WEB_SOCKET` opt-in (#784 phase 1): when set, bind a
// `UnixListener` at the given path. Empty string treated as
// unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an
// un-bindable empty path. Falls through to the TCP path below
// otherwise.
if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET")
&& !socket_path.is_empty()
{
let path = PathBuf::from(socket_path);
let listener = bind_unix(&path)?;
tracing::info!(socket = %path.display(), "web UI listening on unix socket");
axum::serve(listener, app).await?;
return Ok(());
}
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?;
tracing::info!(%port, "web UI listening");
tracing::info!(%port, "web UI listening on tcp");
axum::serve(listener, app).await?;
Ok(())
}
/// Bind a `UnixListener` at `path`. Best-effort unlinks any stale
/// socket left over from a previous (crashed) harness — clean exit
/// removes it, but `bind(2)` refuses to overwrite an existing file.
/// Also `mkdir -p` the parent so a freshly-created `/run/hive-agent/`
/// bind-mount target works on first boot.
///
/// Permissions: mode `0o660` so peers in the same unix group (the
/// gateway container, when bind-mounting the socket dir with a
/// shared group) can `connect(2)`. The bind-mount source dir's
/// ownership + ACL is the real access gate; this is defence-in-depth.
fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create socket parent dir {}", parent.display()))?;
}
// Best-effort: ENOENT is fine (no stale file); any other error
// surfaces via the bind below with a clearer "AddrInUse" / perms
// message than a partial cleanup would.
let _ = std::fs::remove_file(path);
let listener = tokio::net::UnixListener::bind(path)
.with_context(|| format!("bind unix socket at {}", path.display()))?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
.with_context(|| format!("set perms on {}", path.display()))?;
Ok(listener)
}
// ---------------------------------------------------------------------------
// Static assets + state snapshot
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,269 @@
//! `/var/lib/hyperhive/agent-sockets.json` writer (#784 phase 2,
//! prerequisite to #14 container netns isolation).
//!
//! Sibling to `agent_ports.rs`. The gateway needs to know which unix
//! socket to `proxy_pass` to per agent once the per-agent web UI
//! flips off TCP and on to `UnixListener::bind` (#784 phase 1
//! landed via PR #800). This file is the source of truth for
//! "which agents exist + where to reach their web UI over a domain
//! socket" from the gateway's POV — read at request-handling time,
//! not at gateway build time, so a `nixos-container update` of the
//! gateway isn't needed every time an agent spawns / moves /
//! destroys.
//!
//! Shape (flat object keyed by logical agent name → socket path):
//!
//! ```json
//! {
//! "iris": "/run/hive-agent/iris.sock",
//! "atlas": "/run/hive-agent/atlas.sock",
//! "argus": "/run/hive-agent/argus.sock",
//! "damocles": "/run/hive-agent/damocles.sock"
//! }
//! ```
//!
//! Socket paths are deterministic from the agent name —
//! [`socket_path_for`] computes them, so a name alone resolves to a
//! reproducible path. Manager is intentionally excluded from the map
//! (same reasoning as `agent_ports.rs`: the gateway routes the
//! manager's UI at `/` straight to the dashboard upstream, not via
//! per-agent `/agent/<name>/`).
//!
//! Coexists with `agent-ports.json` during the #784 phase-3
//! transition: agents that haven't opted in to `HIVE_WEB_SOCKET` yet
//! still appear in both files; the gateway picks the socket upstream
//! when one exists, falls back to the TCP port otherwise. Step 4
//! drops the TCP path entirely once every agent's web UI has flipped.
//!
//! Atomicity: same `<path>.tmp` + `rename()` shape as `agent_ports.rs`
//! so the gateway's nginx worker never reads a partial file.
//!
//! ## Per-agent subdir layout
//!
//! `<sockets-root>/<name>/web.sock`, NOT `<sockets-root>/<name>.sock`.
//! Each agent's container bind-mounts the per-agent SUBDIR
//! (`/run/hive-agent/<name>/`), and the harness binds the socket
//! inside it. File-level bind-mounts don't survive the harness's
//! "unlink stale socket then `bind(2)` a new one" cycle — the unlink
//! drops the bind, the rebind happens in private container
//! namespace, host never sees the new inode. Bind-mounting the
//! parent dir keeps both sides looking at the same dir inode so the
//! socket appears on the host the moment the harness binds it.
//!
//! Per-agent dir isolation (one dir per agent rather than a shared
//! `/run/hive-agent/` bind) satisfies mara on #800: an agent's
//! container only sees its own dir + socket, never siblings'.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::lifecycle::MANAGER_NAME;
const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json";
/// Host-side parent directory holding per-agent socket subdirs. The
/// gateway container bind-mounts this whole tree (read-only) so it
/// can `proxy_pass` to any agent. Each agent's container bind-mounts
/// only its own `<name>/` subdir, scoping access per mara's #800
/// directive ("agents can only access their own sockets").
pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent";
/// Socket filename inside each per-agent subdir. Fixed so the path
/// derives entirely from `(AGENT_SOCKET_DIR, name)` — no second
/// degree of freedom for callers to get wrong.
pub const SOCKET_FILENAME: &str = "web.sock";
#[must_use]
pub fn host_sockets_path() -> PathBuf {
PathBuf::from(HOST_SOCKETS_PATH)
}
/// Per-agent socket subdir on the host. Lifecycle pre-creates this
/// before container start so the bind-mount source exists; the
/// harness binds the socket inside it as `web.sock`.
#[must_use]
pub fn agent_dir_for(name: &str) -> PathBuf {
Path::new(AGENT_SOCKET_DIR).join(name)
}
/// Compute the deterministic socket path for an agent. Pure function
/// of the agent name so the value matches whatever
/// [`agent_sockets::write`] writes for that agent, and whatever the
/// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1.
#[must_use]
pub fn socket_path_for(name: &str) -> PathBuf {
agent_dir_for(name).join(SOCKET_FILENAME)
}
/// Compute the agent-socket map for the given logical agent names.
/// Sub-agents only — manager is filtered out at the call boundary
/// for the same reason it's filtered from `agent_ports::build_map`
/// (manager UI is routed via the c0re dashboard upstream, not via
/// `/agent/<name>/`).
///
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
/// without churn produces byte-identical output — same idempotency
/// shape `agent_ports::write` relies on.
#[must_use]
pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> {
names
.iter()
.filter(|n| n.as_str() != MANAGER_NAME)
.map(|n| (n.clone(), socket_path_for(n)))
.collect()
}
/// Render the map as pretty-printed JSON. Pretty so a human peek at
/// `cat /var/lib/hyperhive/agent-sockets.json` shows one row per agent
/// — keeps the file readable without a separate jq step (mirrors
/// `agent_ports::render`).
fn render(map: &BTreeMap<String, PathBuf>) -> String {
// Serialize as strings (PathBuf → JSON string via the Display
// impl). BTreeMap → serde_json::to_string_pretty preserves key
// order, so the output is deterministic across calls with the
// same agent set.
let stringly: BTreeMap<&String, String> = map
.iter()
.map(|(k, v)| (k, v.display().to_string()))
.collect();
serde_json::to_string_pretty(&stringly)
.expect("BTreeMap<&String, String> is always serialisable")
}
/// Atomically write the JSON for `names` to
/// `/var/lib/hyperhive/agent-sockets.json`. Writes via a sibling
/// `<path>.tmp` + rename so a crashing process never leaves a
/// partial file behind that the gateway worker would fail to parse.
///
/// Idempotent — if the rendered content matches what's already on
/// disk, the write + rename are skipped so the file's mtime stays
/// stable and inotify watchers in the gateway (or any future
/// watchers) don't fire spurious reload events. Mirrors the
/// `agent_ports::write` shape — keep them in lockstep.
pub fn write(names: &[String]) -> Result<()> {
let map = build_map(names);
let body = render(&map);
let path = host_sockets_path();
if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body)
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"rename {} -> {} (atomic publish)",
tmp.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_path_for_uses_subdir_layout() {
// Per-agent subdir + fixed socket filename — see module-level
// "Per-agent subdir layout" for why this isn't a flat
// `<name>.sock`. Pin both ends so a future move (e.g. to
// `/run/hyperhive/sockets/`) requires updating both the
// constant and the consumers.
let p = socket_path_for("iris");
assert_eq!(p, Path::new("/run/hive-agent/iris/web.sock"));
}
#[test]
fn agent_dir_for_is_socket_parent() {
// `agent_dir_for` is what lifecycle bind-mounts per agent;
// `socket_path_for` lives inside it. Keep them in lockstep so
// a divergence (e.g. typo in one constant) surfaces here
// rather than as a confusing nspawn bind-source-not-found at
// container start.
let dir = agent_dir_for("iris");
let sock = socket_path_for("iris");
assert_eq!(sock.parent(), Some(dir.as_path()));
}
#[test]
fn build_map_filters_manager() {
// Use `MANAGER_NAME` in the input so the assert actually
// exercises the filter path — a literal `"hm1nd"` would pass
// trivially if the constant ever changed and the filter
// silently became a no-op (same pattern as #748 fix on
// agent_ports::build_map).
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
.iter()
.map(|s| (*s).to_owned())
.collect();
let map = build_map(&names);
assert!(!map.contains_key(MANAGER_NAME));
assert!(map.contains_key("iris"));
assert!(map.contains_key("argus"));
}
#[test]
fn build_map_uses_socket_path_for() {
// Map values agree with the helper so callers can use either
// (build_map for the bulk write, socket_path_for for one-off
// lookups) without divergence.
let names = vec!["iris".to_owned()];
let map = build_map(&names);
assert_eq!(map.get("iris"), Some(&socket_path_for("iris")));
}
#[test]
fn build_map_handles_empty_input() {
let map = build_map(&[]);
assert!(map.is_empty());
}
#[test]
fn build_map_dedupes_via_btreemap_key_collision() {
// Duplicate inputs collapse via the map; no callsite passes
// dups today, but guarding the invariant here means a future
// bug doesn't surface as a corrupt JSON doc (two `"iris":`
// keys). Mirrors agent_ports test.
let names = vec!["iris".to_owned(), "iris".to_owned()];
let map = build_map(&names);
assert_eq!(map.len(), 1);
}
#[test]
fn render_is_pretty_and_sorted() {
let mut map = BTreeMap::new();
map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta/web.sock"));
map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock"));
let body = render(&map);
// Pretty-print = newlines between keys + indentation.
assert!(body.contains('\n'));
// BTreeMap sorts → alpha before zeta in output.
let alpha_pos = body.find("alpha").expect("alpha in output");
let zeta_pos = body.find("zeta").expect("zeta in output");
assert!(
alpha_pos < zeta_pos,
"sorted order broken:\n{body}"
);
}
#[test]
fn render_emits_paths_as_strings() {
// PathBuf-valued map serialises as plain JSON strings (not
// some {"inner": "..."} wrapper). Pin the shape so the
// gateway-side reader can deserialise into String values
// without nested struct logic.
let mut map = BTreeMap::new();
map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris/web.sock"));
let body = render(&map);
assert!(body.contains("\"iris\""));
assert!(body.contains("\"/run/hive-agent/iris/web.sock\""));
}
}

View file

@ -15,6 +15,7 @@
pub mod actions;
pub mod agent_ports;
pub mod agent_server;
pub mod agent_sockets;
pub mod approvals;
pub mod auto_update;
pub mod broker;

View file

@ -1141,6 +1141,40 @@ fn set_nspawn_flags(
// empty RO dir instead of a container that won't boot.
std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
// Per-agent socket subdir (#784 phase 2 step 2b). Bind-mounts
// `/run/hive-agent/<name>/` into the container at the same
// path so the harness's `HIVE_WEB_SOCKET` bind has a stable
// location both sides can see. Sub-agents only — the
// manager's UI is served at `/` via the c0re dashboard
// upstream, not via `/agent/<name>/`, so it never needs the
// per-agent socket dir.
//
// Bind-mounting the SUBDIR (not the socket file) is mandatory:
// the harness's `bind_unix` helper unlinks any stale socket
// before calling `bind(2)`, and a file bind-mount drops its
// host-side anchor on unlink — the rebind would land in the
// container's private namespace, invisible to the gateway.
// Dir bind keeps the same dir inode visible on both sides, so
// the new `web.sock` shows up on the host the moment the
// harness binds it.
//
// Per-agent dir (rather than a shared `/run/hive-agent/`
// mount) means the agent's container only sees its own
// subdir — never siblings' (mara on #800: "agents can only
// access their own sockets").
//
// mkdir source defensively: nspawn refuses to start when the
// bind source is missing, and on a fresh host `/run/hive-agent/`
// doesn't exist yet.
let socket_dir = crate::agent_sockets::agent_dir_for(agent_name);
std::fs::create_dir_all(&socket_dir)
.with_context(|| format!("create {}", socket_dir.display()))?;
let _ = write!(
binds,
" --bind={socket_dir}:{socket_dir}",
socket_dir = socket_dir.display(),
);
}
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original

View file

@ -115,6 +115,16 @@ pub async fn sync_agents(
tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)");
}
// Refresh /var/lib/hyperhive/agent-sockets.json — sibling to the
// ports map, drives the gateway's unix-socket upstreams once
// agents opt in to `HIVE_WEB_SOCKET` (PR #800 / #784 phase 1).
// Coexists with the TCP-port map during the transition: the
// gateway picks the socket upstream when one exists, falls back
// to the TCP port otherwise. Same best-effort + non-fatal shape.
if let Err(e) = crate::agent_sockets::write(&agent_names) {
tracing::warn!(error = ?e, "agent_sockets::write failed (non-fatal)");
}
if initial {
git(&dir, &["init", "--initial-branch=main"]).await?;
}

View file

@ -91,6 +91,10 @@ enum Verb {
Diff(verbs::diff::Args),
/// Get or set this user's watch subscription on a repo.
Subscription(verbs::subscription::Args),
/// List timeline events on an issue or PR (closes, label adds,
/// assignments, commit refs, pushes, etc.) — the audit trail
/// `view` + `comments` don't surface (closes #783).
Timeline(verbs::timeline::Args),
/// Upload a file as an attachment to an issue.
AttachIssue(verbs::attach::IssueArgs),
/// Upload a file as an attachment to a comment.
@ -123,6 +127,7 @@ fn main() -> Result<()> {
Verb::TreeSha(a) => verbs::tree_sha::run(&client, a),
Verb::Diff(a) => verbs::diff::run(&client, a),
Verb::Subscription(a) => verbs::subscription::run(&client, a),
Verb::Timeline(a) => verbs::timeline::run(&client, a),
Verb::AttachIssue(a) => verbs::attach::run_issue(&client, a),
Verb::AttachComment(a) => verbs::attach::run_comment(&client, a),
}

View file

@ -1,6 +1,20 @@
//! `comments <number> [--limit N]` — list all comments on an issue
//! or PR. Closes the curl-fallback gap (#418). Use the global
//! `--json` flag for JSON output (#421).
//! `comments <number> [--limit N | --tail N]` — list comments on an
//! issue or PR. Closes the curl-fallback gap (#418); `--tail`
//! closes the third of the four #694 gaps (paging-for-long-threads
//! awkwardness).
//!
//! - `--limit N` (default 50, Forgejo's cap) returns the first N
//! comments — same shape this verb has always had.
//! - `--tail N` returns the *last* N comments in chronological
//! order. Reads the issue's `comments` count first to compute
//! which page contains the tail, then fetches only `ceil(N/50) +
//! 1` pages. No upstream cap — the work is bounded by `N`, not
//! by the thread's length, so it stays cheap even on threads with
//! thousands of comments. Use this for "what was the conclusion
//! on this long thread?" without scrolling through the whole
//! history.
//!
//! Use the global `--json` flag for JSON output (#421).
use anyhow::Result;
use clap::Args as ClapArgs;
@ -9,42 +23,51 @@ use serde_json::{Value, json};
use crate::client::Client;
use crate::verbs::print_json;
/// Forgejo's per-page comment cap. The API caps `limit` at 50 even
/// if a higher value is requested; pin it explicitly so the math
/// downstream doesn't depend on a hidden default.
const PAGE_SIZE: u64 = 50;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
number: u64,
/// Page size (Forgejo caps at 50 by default).
#[arg(long, default_value_t = 50)]
/// Page size for the head-of-thread shape (Forgejo caps at 50).
/// Mutually exclusive with `--tail`.
#[arg(long, default_value_t = 50, conflicts_with = "tail")]
limit: u64,
/// Return the last `N` comments in chronological order. Reads
/// the issue's `comments` count first, then fetches only the
/// `ceil(N/50) + 1` pages that contain the tail — work is
/// bounded by N, not by thread length. Mutually exclusive with
/// `--limit`.
#[arg(long)]
tail: Option<usize>,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let v = client.get_json(&format!(
"/repos/{repo}/issues/{}/comments?limit={}",
args.number, args.limit
))?;
let comments = match args.tail {
Some(n) => fetch_tail(client, &repo, args.number, n)?,
None => fetch_head(client, &repo, args.number, args.limit)?,
};
if client.json_mode() {
let trimmed: Vec<Value> = v
.as_array()
.map(|a| {
a.iter()
.map(|c| {
json!({
"id": c.get("id"),
"user": c.get("user").and_then(|u| u.get("login")),
"created_at": c.get("created_at"),
"updated_at": c.get("updated_at"),
"body": c.get("body"),
"url": c.get("html_url"),
})
})
.collect()
let trimmed: Vec<Value> = comments
.iter()
.map(|c| {
json!({
"id": c.get("id"),
"user": c.get("user").and_then(|u| u.get("login")),
"created_at": c.get("created_at"),
"updated_at": c.get("updated_at"),
"body": c.get("body"),
"url": c.get("html_url"),
})
})
.unwrap_or_default();
.collect();
print_json(&Value::Array(trimmed))
} else {
for c in v.as_array().cloned().unwrap_or_default() {
for c in &comments {
let user = c
.get("user")
.and_then(|u| u.get("login"))
@ -58,3 +81,124 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
Ok(())
}
}
/// Fetch the first page's worth of comments (existing behaviour).
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
let v = client.get_json(&format!(
"/repos/{repo}/issues/{number}/comments?limit={limit}"
))?;
Ok(v.as_array().cloned().unwrap_or_default())
}
/// Fetch the last `n` comments on an issue/PR in chronological order.
///
/// Forgejo orders `/issues/<n>/comments` oldest-first and has no
/// `direction=desc` knob, so naive "page everything and slice" pages
/// from the WRONG end on long threads — the first 1000 comments
/// instead of the last `n`. Fix: read the issue's `comments` count
/// first to know how many exist, then start paginating from the
/// page that contains item `total - n`. Work is bounded by
/// `ceil(n/50) + 1` page fetches, regardless of thread length.
fn fetch_tail(client: &Client, repo: &str, number: u64, n: usize) -> Result<Vec<Value>> {
if n == 0 {
return Ok(Vec::new());
}
let issue = client.get_json(&format!("/repos/{repo}/issues/{number}"))?;
let total = issue
.get("comments")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
if total == 0 {
return Ok(Vec::new());
}
// Cap `n` at the actual total so the math below stays in range
// when the caller asks for more comments than exist.
let n = n.min(total);
let page_size = PAGE_SIZE as usize;
// 0-based index of the first comment we want; integer-divide to
// get the 1-based page that contains it.
let start_idx = total - n;
let start_page = (start_idx / page_size) + 1;
let last_page = (total - 1) / page_size + 1;
let mut merged: Vec<Value> = Vec::with_capacity(n + page_size);
for page in start_page..=last_page {
let v = client.get_json(&format!(
"/repos/{repo}/issues/{number}/comments?limit={PAGE_SIZE}&page={page}"
))?;
let arr = v.as_array().cloned().unwrap_or_default();
if arr.is_empty() {
// Page came back empty — either we miscounted (comments
// deleted between the issue GET and now) or upstream's
// playing tricks. Stop rather than spin.
break;
}
merged.extend(arr);
}
// The first fetched page contains items from `start_page` × 50
// back; we overshoot by `start_idx % 50` items. Slice the tail
// to exactly `n` (or fewer if the count shrank under us).
let overshoot = merged.len().saturating_sub(n);
Ok(merged.into_iter().skip(overshoot).collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// Pure helper mirroring the page-arithmetic in `fetch_tail`:
/// given a total comment count + requested tail size, return
/// the (start_page, last_page) pair the network loop would
/// walk. Lets us pin the pagination plan — the part that's
/// easy to off-by-one — without touching the network.
fn tail_plan(total: usize, n: usize) -> (usize, usize) {
let n = n.min(total);
let page_size = PAGE_SIZE as usize;
let start_idx = total - n;
let start_page = (start_idx / page_size) + 1;
let last_page = (total - 1) / page_size + 1;
(start_page, last_page)
}
#[test]
fn tail_plan_small_thread() {
// 5 total, tail 2 → page 1 covers everything; trim happens
// via the merged.len() - n overshoot calculation, not pages.
assert_eq!(tail_plan(5, 2), (1, 1));
}
#[test]
fn tail_plan_exact_page_boundary() {
// 50 total, tail 3 → all on page 1 (items 1..50).
assert_eq!(tail_plan(50, 3), (1, 1));
}
#[test]
fn tail_plan_crosses_page_boundary() {
// 51 total, tail 3 → start_idx=48 lives on page 1, item 51
// lives on page 2; fetch both.
assert_eq!(tail_plan(51, 3), (1, 2));
}
#[test]
fn tail_plan_large_thread_bounded_pages() {
// 5000 total, tail 3 → start_idx=4997 lives on page 100,
// last_page=100. ONE page fetch on a 5000-comment thread —
// the whole point of swapping to count-then-page (vs the
// old "page everything, then slice the wrong end").
assert_eq!(tail_plan(5000, 3), (100, 100));
}
#[test]
fn tail_plan_large_thread_spans_two_pages() {
// 5000 total, tail 60 → start_idx=4940 on page 99, item 5000
// on page 100. Two fetches even for n > PAGE_SIZE.
assert_eq!(tail_plan(5000, 60), (99, 100));
}
#[test]
fn tail_plan_n_exceeds_total() {
// 5 total, tail 100 → cap n at total; same plan as the
// small-thread case above.
assert_eq!(tail_plan(5, 100), (1, 1));
}
}

View file

@ -23,6 +23,7 @@ pub mod pr;
pub mod pr_create;
pub mod pr_reviews;
pub mod subscription;
pub mod timeline;
pub mod tree_sha;
pub mod view;

View file

@ -0,0 +1,350 @@
//! `timeline <number> [--limit N]` — list timeline events on an
//! issue or PR. Closes #783 (last piece of the #694 epic: agents kept
//! falling back to curl for "who closed this?" / "when was this
//! labelled?" archaeology). Composes naturally with `view <n>` /
//! `comments <n>` — separate verb keeps the existing shapes stable.
//!
//! Forgejo's `/issues/{n}/timeline` endpoint returns BOTH the actual
//! comments AND the event entries (label, assignee, close, reopen,
//! pull_push, etc.) in chronological order. We render each row in
//! a human-readable form by default; pass the global `--json` flag
//! for the raw API shape.
//!
//! `--tail N` is a follow-up (the timeline endpoint doesn't expose a
//! total-count field so we can't use the count-then-page trick that
//! `comments --tail` lands in #770; future shape probably mirrors
//! `comments --tail` once Forgejo grows a `count` query or we accept
//! the trailing-slice cost).
use anyhow::Result;
use clap::Args as ClapArgs;
use serde_json::Value;
use crate::client::Client;
use crate::verbs::print_json;
#[derive(ClapArgs)]
pub struct Args {
/// Issue or PR number.
number: u64,
/// Page size (Forgejo caps at 50). Returns the first `N` events.
#[arg(long, default_value_t = 50)]
limit: u64,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let v = client.get_json(&format!(
"/repos/{repo}/issues/{}/timeline?limit={}",
args.number, args.limit
))?;
if client.json_mode() {
return print_json(&v);
}
let Some(events) = v.as_array() else {
return print_json(&v);
};
for ev in events {
print_event(ev);
}
Ok(())
}
/// Render one timeline event as a single `**actor @ ts**: summary`
/// line. Comment rows inline their full body; structured event types
/// (label, assignees, close, etc.) get a one-line human summary
/// derived from the per-type fields the API populates. Unknown /
/// future types fall through to a `[<type>]` placeholder so a forge
/// schema bump doesn't panic the verb — operator still sees that the
/// event existed, with timestamp + actor.
///
/// Pure function (no I/O) so the tests below can pin the formatted
/// output for every supported event type without re-implementing the
/// per-arm dispatch. `print_event` is the only caller that adds the
/// terminating newline.
fn format_event(ev: &Value) -> String {
let event_type = ev.get("type").and_then(Value::as_str).unwrap_or("?");
let user = ev
.get("user")
.and_then(|u| u.get("login"))
.and_then(Value::as_str)
.unwrap_or("?");
let ts = ev.get("created_at").and_then(Value::as_str).unwrap_or("?");
let summary = match event_type {
"comment" => {
// Comments get the full body inlined — matches `comments`
// verb shape so the operator sees the same line they'd
// get from the head-of-thread listing.
ev.get("body")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned()
}
"label" => {
// Forgejo encodes label add/remove via `body = "1"` (added)
// or `body = "0"` (removed). Quirky but stable.
let action = match ev.get("body").and_then(Value::as_str).unwrap_or("") {
"1" => "added",
"0" => "removed",
_ => "changed",
};
let label = ev
.get("label")
.and_then(|l| l.get("name"))
.and_then(Value::as_str)
.unwrap_or("?");
format!("{action} label `{label}`")
}
"assignees" => {
let assignee = ev
.get("assignee")
.and_then(|a| a.get("login"))
.and_then(Value::as_str)
.unwrap_or("?");
let removed = ev
.get("removed_assignee")
.and_then(Value::as_bool)
.unwrap_or(false);
if removed {
format!("unassigned @{assignee}")
} else {
format!("assigned @{assignee}")
}
}
"review_request" => {
let reviewer = ev
.get("assignee")
.and_then(|a| a.get("login"))
.and_then(Value::as_str)
.unwrap_or("?");
let removed = ev
.get("removed_assignee")
.and_then(Value::as_bool)
.unwrap_or(false);
if removed {
format!("removed review request from @{reviewer}")
} else {
format!("requested review from @{reviewer}")
}
}
"close" => "closed".to_owned(),
"reopen" => "reopened".to_owned(),
"merge" => "merged".to_owned(),
"milestone" => {
let title = ev
.get("milestone")
.and_then(|x| x.get("title"))
.and_then(Value::as_str)
.unwrap_or("?");
format!("added to milestone `{title}`")
}
"demilestone" => {
let title = ev
.get("old_milestone")
.and_then(|x| x.get("title"))
.and_then(Value::as_str)
.unwrap_or("?");
format!("removed from milestone `{title}`")
}
"pull_push" => {
// Body is JSON: `{"is_force_push":bool,"commit_ids":[...]}`.
// Defensive parse — fall through to a no-detail summary if
// the shape ever drifts.
let body_str = ev.get("body").and_then(Value::as_str).unwrap_or("");
let parsed: Option<Value> = serde_json::from_str(body_str).ok();
let n = parsed
.as_ref()
.and_then(|v| v.get("commit_ids"))
.and_then(Value::as_array)
.map_or(0, Vec::len);
let force = parsed
.as_ref()
.and_then(|v| v.get("is_force_push"))
.and_then(Value::as_bool)
.unwrap_or(false);
if force {
format!("force-pushed {n} commit(s)")
} else {
format!("pushed {n} commit(s)")
}
}
"commit_ref" => {
let sha = ev.get("ref_commit_sha").and_then(Value::as_str).unwrap_or("");
let short: String = sha.chars().take(7).collect();
if short.is_empty() {
"referenced from a commit".to_owned()
} else {
format!("referenced from commit {short}")
}
}
"comment_ref" | "issue_ref" => "referenced from another issue/PR".to_owned(),
"changed_target_branch" => "changed target branch".to_owned(),
"review" => "submitted a review".to_owned(),
"lock" => "locked the conversation".to_owned(),
"unlock" => "unlocked the conversation".to_owned(),
// Future / unknown types: surface the raw label so we don't
// pretend nothing happened. Operator sees `[deploy_status]` or
// whatever new event a forge bump invents.
other => format!("[{other}]"),
};
format!("**{user} @ {ts}**: {summary}")
}
/// Print one event followed by a blank line spacer. Thin wrapper
/// around `format_event` so the tests can pin per-arm output without
/// duplicating the dispatch.
fn print_event(ev: &Value) {
println!("{}", format_event(ev));
println!();
}
#[cfg(test)]
mod tests {
//! Tests call `format_event` directly so any new event-type arm
//! added in `print_event`'s dispatch is automatically covered by
//! the rendering path (no parallel test-side dispatch to keep in
//! sync). Argus on PR #798 🟡: "extract a `format_event(ev) ->
//! String` helper and test that function directly instead of
//! duplicating the logic" — addressed.
use super::*;
#[test]
fn comment_renders_body_inline() {
let ev = serde_json::json!({
"type": "comment",
"user": { "login": "iris" },
"created_at": "2026-05-31T12:00:00Z",
"body": "looks good to me",
});
assert_eq!(format_event(&ev), "**iris @ 2026-05-31T12:00:00Z**: looks good to me");
}
#[test]
fn label_added_renders_action_and_name() {
let ev = serde_json::json!({
"type": "label",
"user": { "login": "triage" },
"created_at": "2026-05-31T12:00:00Z",
"body": "1",
"label": { "name": "area:harness" },
});
assert_eq!(
format_event(&ev),
"**triage @ 2026-05-31T12:00:00Z**: added label `area:harness`"
);
}
#[test]
fn label_removed_renders_removed_action() {
let ev = serde_json::json!({
"type": "label",
"user": { "login": "mara" },
"created_at": "2026-05-31T12:00:00Z",
"body": "0",
"label": { "name": "needs-review" },
});
assert_eq!(
format_event(&ev),
"**mara @ 2026-05-31T12:00:00Z**: removed label `needs-review`"
);
}
#[test]
fn close_event_renders_one_word_summary() {
let ev = serde_json::json!({
"type": "close",
"user": { "login": "mara" },
"created_at": "2026-05-31T12:00:00Z",
});
assert_eq!(format_event(&ev), "**mara @ 2026-05-31T12:00:00Z**: closed");
}
#[test]
fn assignees_added_and_removed() {
let added = serde_json::json!({
"type": "assignees",
"user": { "login": "triage" },
"created_at": "2026-05-31T12:00:00Z",
"assignee": { "login": "damocles" },
"removed_assignee": false,
});
assert_eq!(
format_event(&added),
"**triage @ 2026-05-31T12:00:00Z**: assigned @damocles"
);
let removed = serde_json::json!({
"type": "assignees",
"user": { "login": "triage" },
"created_at": "2026-05-31T12:00:00Z",
"assignee": { "login": "damocles" },
"removed_assignee": true,
});
assert_eq!(
format_event(&removed),
"**triage @ 2026-05-31T12:00:00Z**: unassigned @damocles"
);
}
#[test]
fn pull_push_counts_commits_and_marks_force() {
let normal = serde_json::json!({
"type": "pull_push",
"user": { "login": "damocles" },
"created_at": "2026-05-31T12:00:00Z",
"body": r#"{"is_force_push":false,"commit_ids":["a","b","c"]}"#,
});
assert_eq!(
format_event(&normal),
"**damocles @ 2026-05-31T12:00:00Z**: pushed 3 commit(s)"
);
let forced = serde_json::json!({
"type": "pull_push",
"user": { "login": "damocles" },
"created_at": "2026-05-31T12:00:00Z",
"body": r#"{"is_force_push":true,"commit_ids":["a"]}"#,
});
assert_eq!(
format_event(&forced),
"**damocles @ 2026-05-31T12:00:00Z**: force-pushed 1 commit(s)"
);
}
#[test]
fn commit_ref_truncates_sha_to_seven() {
let ev = serde_json::json!({
"type": "commit_ref",
"user": { "login": "damocles" },
"created_at": "2026-05-31T12:00:00Z",
"ref_commit_sha": "abcdef0123456789abcdef0123456789abcdef01",
});
assert_eq!(
format_event(&ev),
"**damocles @ 2026-05-31T12:00:00Z**: referenced from commit abcdef0"
);
}
#[test]
fn unknown_event_type_renders_bracketed_placeholder() {
// Future-proofing: a forge schema bump that adds a new event
// type shouldn't silently swallow the row.
let ev = serde_json::json!({
"type": "deploy_status",
"user": { "login": "ci-bot" },
"created_at": "2026-05-31T12:00:00Z",
});
assert_eq!(
format_event(&ev),
"**ci-bot @ 2026-05-31T12:00:00Z**: [deploy_status]"
);
}
#[test]
fn missing_user_falls_back_to_placeholder() {
// Defensive: forge has been known to omit `user` on bot events.
let ev = serde_json::json!({
"type": "close",
"created_at": "2026-05-31T12:00:00Z",
});
assert_eq!(format_event(&ev), "**? @ 2026-05-31T12:00:00Z**: closed");
}
}

View file

@ -241,26 +241,20 @@ pub struct InboxRow {
pub in_reply_to: Option<i64>,
}
/// One delivered message in a `Recv` response. The unified
/// `Recv { max }` always returns a `Vec<DeliveredMessage>` — single
/// pop = a one-element vec, batch = up to `max` elements, idle =
/// empty. Each row carries the broker's id + redelivered flag so the
/// harness can drive `AckTurn` and surface the "may already be
/// handled" hint per-row.
/// One delivered message in a `Recv` response.
/// See `docs/conventions.md::Broker delivery + ack cycle` for the
/// full delivery/ack/requeue story.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveredMessage {
pub from: String,
pub body: String,
/// Broker row id, mirrored from the `Delivery` struct. Opaque to
/// claude but tracked by the harness so the broker's in-memory
/// unacked list can be drained on `AckTurn`. Marked `default` for
/// wire backwards-compat — pre-feature peers parse to 0.
/// Broker row id, tracked by the harness for `AckTurn`. Opaque to
/// claude. `default` for wire backwards-compat.
#[serde(default)]
pub id: i64,
/// `true` when this row was previously popped, never acked, and
/// resurfaced by `RequeueInflight`. The format helper prepends the
/// "may already be handled" hint to the rendered body so claude
/// sees the warning per-message in the batch.
/// `true` if this row was resurfaced by `RequeueInflight` (previously
/// popped, never acked). Formatter prepends a "may already be handled"
/// hint when set.
#[serde(default)]
pub redelivered: bool,
/// Row-id of the message this is a reply to, if any.
@ -371,22 +365,12 @@ pub enum AgentRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
in_reply_to: Option<i64>,
},
/// Pop pending messages from this agent's inbox. Always returns
/// a list (`Messages { messages }`) — empty when nothing's
/// pending. `max` caps the batch size (default 1 = single-message
/// behaviour, server-side cap 32). `wait_seconds` long-polls for
/// the first message; once one arrives (or one is already
/// pending), the call drains up to `max` in total before
/// returning. Same delivery + ack bookkeeping per row as before:
/// `delivered_at = NOW`, tracked on the per-recipient
/// `unacked_ids` list (the next `AckTurn` closes them out), and
/// each row carries `redelivered = true` if `RequeueInflight`
/// resurfaced it.
/// Pop pending messages from this agent's inbox.
/// Delivery + ack cycle: see
/// `docs/conventions.md::Broker delivery + ack cycle`.
Recv {
#[serde(default)]
wait_seconds: Option<u64>,
/// Maximum number of messages to pop. None = 1 (single).
/// Server-side cap is 32; values above clamp silently.
#[serde(default)]
max: Option<u32>,
},
@ -498,27 +482,14 @@ pub enum AgentRequest {
/// row. The manager surface uses the same wire variant but
/// accepts any id.
CancelLooseEnd { kind: CancelLooseEndKind, id: i64 },
/// Mark every message popped by this agent since the last `AckTurn`
/// as fully handled. Fired by the harness after `TurnOutcome::Ok`
/// — claude doesn't see this surface, it's harness↔broker only.
/// On `TurnOutcome::Failed` the harness intentionally skips this
/// call, so the unacked rows stay in-flight in the DB and get
/// requeued by the next `RequeueInflight` on harness boot. Tracks
/// the popped-id list in-memory on the broker side; no payload
/// needed (the broker knows which ids it handed to this
/// recipient).
/// Mark every message popped since the last `AckTurn` as handled.
/// Harness↔broker pairing fired after `TurnOutcome::Ok`. See
/// `docs/conventions.md::Broker delivery + ack cycle`.
AckTurn,
/// Requeue every message the broker handed to this agent that
/// never got acked. Fired by the harness exactly once at boot,
/// before entering the serve loop — catches the
/// crashed-mid-turn / OOM-killed / container-restarted cases
/// where a previous harness session popped messages but never
/// drove them to a clean turn-end. Resets `delivered_at` on each
/// row back to NULL (so the next `Recv` pops it) and remembers
/// the id in a per-recipient in-memory set so the next `Recv`
/// can tag the message with `redelivered: true` (the harness
/// then prepends a "may already be handled" hint to the wake
/// prompt). Idempotent + cheap when there's nothing in flight.
/// Requeue every popped-but-unacked message back into the inbox.
/// Harness fires this once at boot to recover from
/// crashed-mid-turn sessions. See
/// `docs/conventions.md::Broker delivery + ack cycle`.
RequeueInflight,
}

View file

@ -4,38 +4,24 @@
librsvg,
}:
# Static assets the rust workspace reads at runtime: the project's
# branding SVG/PNG family + the claude system-prompt template +
# claude-settings JSON. Lives as its own derivation so a tweak to
# branding/agent-configs.svg or hive-ag3nt/prompts/system.md doesn't
# invalidate the rust derivation's cargo cache (closes #555 follow-up
# to #538 — naersk previously paired with `src = ./.;` invalidating
# every rust build on any branding/prompt edit; crane inherited that
# coupling and this split breaks it cleanly).
# Branding SVG/PNG family + claude prompts, split out from the rust
# workspace so a tweak here doesn't invalidate the rust cargo cache.
# Rationale + agent-configs PNG rendering: docs/gotchas.md::Split asset
# derivations away from the rust workspace.
#
# Output layout:
#
# $out/share/hyperhive/branding/{hyperhive.svg, hyperhive.png,
# agent-configs.svg, agent-configs.png}
# $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png}
# $out/share/hyperhive/prompts/{system.md, claude-settings.json}
#
# The agent-configs PNG is rendered at build time from the SVG via
# rsvg-convert — same shape as the old `hive-c0re/build.rs` rasteriser,
# just hoisted into nix so the librsvg dependency stays *here* instead
# of in the rust derivation's nativeBuildInputs.
stdenv.mkDerivation {
pname = "hyperhive-assets";
version = "0.1.0";
# `src` is intentionally narrow — only branding/ + the hive-ag3nt/prompts/
# subdir, NOT the whole tree. Keeps the input hash decoupled from
# rust source / docs / nix module edits.
# Narrow `srcs` (branding/ + hive-ag3nt/prompts/) is what decouples
# this derivation's input hash from the rest of the tree.
srcs = [
../branding
../hive-ag3nt/prompts
];
# `unpackPhase` would normally extract each src to its own dir; we
# just want them side-by-side, so hand-roll a flat copy.
unpackPhase = ''
runHook preUnpack
cp -r ${../branding} branding
@ -46,10 +32,8 @@ stdenv.mkDerivation {
nativeBuildInputs = [ librsvg ];
# No real build step — just render the agent-configs PNG alongside
# its SVG. 300×300 matches branding/hyperhive.png, which is the size
# Forgejo's avatar endpoint accepts without resampling on upload (the
# same constraint hive-c0re/build.rs encoded).
# 300×300 matches branding/hyperhive.png — the size Forgejo's avatar
# endpoint accepts without resampling on upload.
buildPhase = ''
runHook preBuild
rsvg-convert --width 300 --height 300 \

View file

@ -4,34 +4,15 @@
self,
nixosSystem,
}:
# Options documentation for hyperhive's NixOS module surfaces.
# Closes #616. HTML output added per mara on internal-requests #8.
#
# Three rendering layers:
# CommonMark — `pkgs.nixosOptionsDoc.optionsCommonMark`. Source of
# truth; kept as `.md` files in the bundle.
# HTML — `pkgs.cmark-gfm` over the CommonMark output, wrapped
# in a minimal inline-CSS template. Primary surface; the
# bundle's `index.html` / `host.html` / `agent.html` are
# what the operator's nginx serves from
# `hyperhive.darkest.space/options/`.
#
# Three output trees consumed by `flake.nix`:
# docs-host — operator-facing host-module options
# (`services.hive-c0re.*`, `hyperhive.{domain,forge,matrix}.*`)
# docs-agent — per-agent harness options
# (`hyperhive.{model,allowedRecipients,extraMcpServers,…}`)
# docs — bundled static site (index + host + agent, .html + .md)
#
# All asset paths inside the rendered HTML are relative (e.g.
# `./host.html`) so the bundle can be mounted at any URL prefix
# without rewriting; styles are inline so there's no second-fetch
# request for the operator's browser.
# Nix options reference: `pkgs.nixosOptionsDoc` over two evaluated
# module trees, rendered as CommonMark + HTML + bundled static site
# the operator's nginx serves from `/options/`. Full pipeline +
# subtree-pick / output-tree rationale: docs/gotchas.md::Nix options
# reference.
let
# Evaluate the host module under a stub NixOS system. Stubs satisfy
# the few hard-required options (filesystems, stateVersion) without
# actually enabling the hive — we only want the option *declarations*
# to evaluate, not the config.
# Stub host system: every hyperhive subsystem `mkForce false` so
# heavy build inputs (matrix container, forge, etc.) stay out of
# the eval — only option *declarations* matter for the doc walk.
hostEval = nixosSystem {
system = pkgs.stdenv.hostPlatform.system;
modules = [
@ -46,10 +27,6 @@ let
};
boot.loader.grub.enable = false;
system.stateVersion = "25.11";
# Force-disable every hyperhive subsystem so config evaluation
# doesn't pull in heavy build inputs (matrix container, forge,
# etc.). Options are still fully declared either way — that's
# what nixosOptionsDoc traverses.
services.hyperhive.enable = lib.mkForce false;
services.hyperhive.forge.enable = lib.mkForce false;
services.hyperhive.matrix.enable = lib.mkForce false;
@ -59,14 +36,12 @@ let
];
};
# Agent options live in the already-evaluated `agent-base` container
# config. Reusing it avoids re-evaluating the harness module against
# a fresh stub — the options tree is identical to what a real agent
# container sees.
# Reuse the already-evaluated agent-base config — its options tree is
# identical to what a real agent container sees, no second eval needed.
agentEval = self.nixosConfigurations.agent-base;
# Strip the nix-store prefix from option declaration paths and rewrite
# them as forge URLs so the rendered docs link back to the source.
# Rewrite option declaration paths from nix-store absolute paths to
# forge URLs so rendered docs link back to source.
forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main";
storePrefix = toString self + "/";
transformOptions =
@ -90,10 +65,10 @@ let
) opt.declarations;
};
# Filter an evaluated `options` tree down to a set of top-level
# subtrees we care about. Anything outside the listed roots is
# dropped — keeps the rendered docs focused on hyperhive's surface
# instead of NixOS's 10k+ default options.
# Filter to a set of top-level subtree roots — keeps the rendered docs
# focused on hyperhive's surface instead of NixOS's 10k+ default
# options. Root choice matters: see docs/gotchas.md::Nix options
# reference for the post-#615 services.hyperhive consolidation history.
pickSubtrees =
options: roots:
let
@ -106,12 +81,6 @@ let
in
lib.foldl' lib.recursiveUpdate { } (map pick roots);
# Post-#615, host options live entirely under `services.hyperhive.*`
# (closes #630). Pre-#615 had a mix of `hyperhive.*` (forge, matrix,
# domain) and `services.hive-c0re.*` — picking against those roots
# silently produced an empty options tree on current main, so the
# rendered host page was just the template chrome with no `<h2>`
# option headers underneath.
hostOptions = pickSubtrees hostEval.options [
[
"services"
@ -133,8 +102,7 @@ let
inherit transformOptions;
};
# Plain-markdown page (with a short header). Source of truth; the
# HTML version is rendered from this.
# CommonMark .md = source of truth; HTML is rendered from this.
mkMarkdownPage =
name: title: doc:
pkgs.runCommand "hyperhive-${name}.md" { } ''
@ -149,16 +117,11 @@ let
} > $out
'';
# Inline-stylesheet, loaded as plain text from `./style.css` so it's
# editable with normal CSS tooling (#625). Inlined into every page
# so the bundle doesn't depend on a second HTTP fetch — keeps the
# `/options/` mount trivial for nginx (no MIME guessing for separate
# .css files, no cache-busting needed when this updates).
# Loaded as text so it's editable with normal CSS tooling and
# inlined into every page (no second-fetch dependency).
styleCSS = builtins.readFile ./style.css;
# HTML page: CommonMark → cmark-gfm → minimal template with inline
# CSS + relative-only links. cmark-gfm rather than plain cmark so
# any future tables / autolinks Just Work without revisiting.
# CommonMark → cmark-gfm → minimal template, inline CSS, relative links.
mkHtmlPage =
name: title: doc:
pkgs.runCommand "hyperhive-${name}.html" { nativeBuildInputs = [ pkgs.cmark-gfm ]; } ''
@ -192,9 +155,7 @@ let
} > $out
'';
# Landing page — same template shape as the option pages but
# hand-authored content (short intro + cross-links). Kept tight; the
# detail lives on the two option pages.
# Landing page — same template shape, hand-authored intro + cross-links.
indexHTML = pkgs.runCommand "hyperhive-docs-index.html" { } ''
{
echo '<!doctype html>'
@ -240,14 +201,11 @@ let
agentMD = mkMarkdownPage "docs-agent" "hyperhive per-agent options" agentDoc;
in
{
# Individual page outputs (HTML is the primary surface; the .md
# source is one `nix build` step away if needed).
host = hostHTML;
agent = agentHTML;
# Bundled static site for nginx to serve at `/options/`. Asset paths
# are all relative, no root-absolute references, so the prefix can
# change without rebuild.
# Bundled static site nginx serves at `/options/`. Asset paths are
# all relative so the prefix can change without rebuild.
bundle = pkgs.runCommand "hyperhive-options-docs" { } ''
mkdir -p $out
cp ${indexHTML} $out/index.html

View file

@ -238,28 +238,9 @@ in
managerToplevel
];
# Per-container web UIs share the host's network namespace and need
# their ports reachable when there's no gateway in front. Every
# container — including the manager (#753 dropped the pre-#753
# "manager pinned at 8000" special case) — hashes into
# 8100..8999 via `lifecycle::agent_web_port`'s FNV-1a, so a single
# range opening covers all of them.
#
# The dashboard port (`cfg.dashboardPort`, default 7000) is *not*
# listed here — since #652 the dashboard binds `127.0.0.1` only,
# so opening the firewall hole would be a no-op. Remote dashboard
# access flows through hive-gateway (default-on); operators who
# opt out of the gateway lose external dashboard reach by design —
# the surface is privileged (approve / deny / destroy) and must
# not be exposed without a real reverse proxy in front.
#
# When `services.hyperhive.gateway.enable = true` (the default), the
# gateway nginx is the sole external entry point and proxies to
# `127.0.0.1:7000` etc. internally — leaving the per-agent ports
# open in the host firewall would defeat the gateway's "single
# front door" story (closes #621). Operators who opt out of the
# gateway still get those direct ports opened so the legacy
# `http://<host>:<port>/` flow works.
# Open the per-agent web-port range when the gateway is *off* —
# otherwise the gateway nginx is the sole external entry point.
# See `docs/gateway.md::Firewall posture (host-level)`.
networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) {
allowedTCPPortRanges = [
{
@ -307,20 +288,11 @@ in
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
}
// lib.optionalAttrs config.services.hyperhive.forge.enable {
# Agents poll this URL for Forgejo notifications + run all
# `hive-forge` calls against it. Pinned to `127.0.0.1` for
# the in-cluster path: every agent container shares the
# host's network namespace so loopback reaches the forge
# container directly, no DNS lookup needed (closes #761).
#
# Post-#754 `cfg.domain` defaults to `forge.<hive-domain>`
# for the external gateway vhost. Using that value here
# would route every in-cluster call through DNS for an
# external hostname agents can't resolve from inside their
# nspawn — every `hive-forge` invocation would fail with
# "Name or service not known". The external gateway URL
# is for operator browsers + cross-host clients; internal
# callers stay on loopback.
# Loopback for in-cluster calls (agents share host netns;
# external `forge.<hive>` sub-domain isn't DNS-resolvable
# from inside nspawn). See
# `docs/gateway.md::HIVE_FORGE_URL: loopback for in-cluster,
# sub-domain for the operator`.
HIVE_FORGE_URL = "http://127.0.0.1:${toString config.services.hyperhive.forge.httpPort}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {

View file

@ -30,21 +30,10 @@ let
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
in
{
# Private Forgejo for hyperhive agents, wrapped in a nixos-container
# so it doesn't fight any `services.forgejo` the operator already
# runs on the host. The container shares the host network namespace
# (`privateNetwork = false`) so agents reach the forge at
# `http://localhost:<httpPort>` without any extra plumbing —
# nixos-container is just here for state + systemd-unit isolation,
# not network isolation.
#
# Container name is `hive-forge` (not `h-*`), so hive-c0re's
# lifecycle scanner ignores it; the operator manages it via the
# standard `nixos-container` CLI.
#
# State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/`
# and survives container restart / host reboot. To wipe, destroy the
# container.
# Private Forgejo in a `hive-forge` nixos-container, shared host
# netns so agents reach it on loopback. State at
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
# restart. See `docs/gateway.md::hive-forge container shape`.
options.services.hyperhive.forge = {
enable = lib.mkOption {

View file

@ -19,6 +19,56 @@ let
{ }
else
builtins.fromJSON (builtins.readFile cfg.agentPortsFile);
# Static error pages for `/agent/<name>/` mishaps (#755). Mara's
# call: useful pages instead of nginx's default 404/502 for routes
# we've already special-cased. See `docs/gateway.md::Per-agent
# error pages` for the design rationale + page-vs-status semantics.
agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } ''
mkdir -p $out
cat > $out/not-found.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>agent not found hyperhive</title>
<style>
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
h1 { color: #cba6f7; font-size: 1.5rem; margin: 0 0 0.5rem; }
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
a { color: #89b4fa; }
</style>
</head>
<body>
<h1> agent not found</h1>
<p>No agent matches the requested <code>/agent/&lt;name&gt;/</code> path on this hive.</p>
<p>Operator: check the agent name in <a href="/">the dashboard</a> the gateway picks up new agents on the next <code>nixos-rebuild switch</code>.</p>
</body>
</html>
EOF
cat > $out/unreachable.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>agent unreachable hyperhive</title>
<style>
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
h1 { color: #f9e2af; font-size: 1.5rem; margin: 0 0 0.5rem; }
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
a { color: #89b4fa; }
</style>
</head>
<body>
<h1> agent unreachable</h1>
<p>The agent's harness web server isn't responding. Container restarting, or the agent crashed.</p>
<p>Operator: <a href="/">dashboard</a> check the container status / journal; the page will recover on retry once the harness is back up.</p>
</body>
</html>
EOF
'';
in
{
# Single nginx in front of every hyperhive web surface — dashboard,
@ -268,8 +318,11 @@ in
# per entry in `agentPortsTable`. Trailing-slash pair
# strips the prefix; `X-Forwarded-Prefix` lets the
# harness build absolute URLs when relative isn't
# enough. See `docs/gateway.md` for the vhost map
# + tuning rationale.
# enough. `proxy_intercept_errors` + `error_page` rewrite
# upstream 502/503/504 (container down / restarting) to
# the static `unreachable.html` instead of nginx's
# default Bad Gateway page (#755). See
# `docs/gateway.md` for the vhost map + tuning.
lib.mapAttrs' (name: port: {
name = "/agent/${name}/";
value = {
@ -279,9 +332,44 @@ in
proxy_set_header X-Forwarded-Prefix /agent/${name};
proxy_buffering off;
proxy_read_timeout 1d;
proxy_intercept_errors on;
error_page 502 503 504 = /__hive_agent_unreachable;
'';
};
}) agentPortsTable
//
# `/agent/` catch-all (#755): hits when an operator
# requests `/agent/<unknown>/...` — a name not in
# `agentPortsTable`. Without this it falls through to
# `/` (c0re dashboard upstream) which returns 404
# with no useful context. Custom 404 page instead.
{
"/agent/" = {
extraConfig = ''
error_page 404 = /__hive_agent_not_found;
return 404;
'';
};
# Internal static-file locations the error_page
# directives above point at. `internal` keeps
# operators from hitting the file directly (only
# nginx's error-handling can reach it); `alias`
# serves the exact file regardless of request URI.
"= /__hive_agent_not_found" = {
extraConfig = ''
internal;
alias ${agentErrorPagesDir}/not-found.html;
default_type text/html;
'';
};
"= /__hive_agent_unreachable" = {
extraConfig = ''
internal;
alias ${agentErrorPagesDir}/unreachable.html;
default_type text/html;
'';
};
}
// {
# Everything else proxies to hive-c0re. Upgrade
# headers stay set so SSE (`/dashboard/stream`,

View file

@ -9,26 +9,16 @@ let
hyperhiveDomain = config.services.hyperhive.domain;
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
# Three files are missing from `pkgs.fluffychat-web` because
# `flutter341.buildFlutterApplication` doesn't run the dart
# web-worker compile pass + doesn't run the native_imaging emscripten
# build (#685). `fluffychat-web-imaging` below builds the latter from
# source via `pkgs.emscripten`; the worker compile is inline in
# `fluffychat-web-fixed.postInstall`. Drop both when nixpkgs's
# flutter builder grows worker + emcc support upstream.
# fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication`
# skips the dart web-worker compile + the emscripten native_imaging
# build. Two derivations below cover both. Full rationale (why
# passthru.pubspecLock.dependencySources, why `dontConfigure`, why
# `make -C js`, why build-CWD-relative dart path): docs/matrix.md::
# fluffychat-web build fixes.
# `Imaging.{js,wasm}` built from `native_imaging`'s C source via
# emscripten. Source comes from
# `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources` so
# there's no parallel hash pin — version auto-syncs with nixpkgs
# bumps. Build closure +~3.6 GiB (emscripten LLVM); runtime closure
# is just the two output files.
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
pname = "fluffychat-web-imaging";
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging;
# The pub-cache derivation that fluffychat-web's flutter build uses.
# Already in the build closure; no `fetchurl` or own hash pin.
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
nativeBuildInputs = with pkgs; [
@ -38,21 +28,18 @@ let
jq
];
# cmake config runs inside `js/Makefile` (via `emcmake cmake`) —
# skip the default `configurePhase` which would try to invoke
# cmake against the package root and fail (no CMakeLists at top).
# cmake runs inside js/Makefile via `emcmake cmake`; the default
# configurePhase would invoke cmake at the package root (no
# CMakeLists) and fail.
dontConfigure = true;
buildPhase = ''
runHook preBuild
# emscripten needs HOME + a writable cache dir for its sysroot
# build (libc, libc++, etc. compiled to wasm on demand).
# emscripten on-demand sysroot build needs writable HOME + cache.
export HOME=$TMPDIR
export EM_CACHE=$TMPDIR/.emscriptencache
mkdir -p $EM_CACHE
# `make -C js` keeps the build phase pwd at the source root so
# installPhase doesn't have to know about the cd (argus 🟡 on
# PR #697 v2 — robust against future reorders / `dontBuild`).
# `make -C js` keeps pwd at source root for the installPhase.
make -C js Imaging.js Imaging.wasm
runHook postBuild
'';
@ -66,71 +53,37 @@ let
'';
meta = with pkgs.lib; {
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web (#685)";
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web";
homepage = "https://pub.dev/packages/native_imaging";
license = licenses.agpl3Plus;
};
};
# `pkgs.fluffychat-web` with #685's three missing files patched in
# via postInstall. Mount point is `matrix.<hive>/` (#772); upstream
# `--base-href "/"` is correct at sub-domain root, no override.
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
# `dart` from the flutter341 closure (already pulled, no
# incremental closure cost) so we can compile the web-worker
# entry point that buildFlutterApplication skips.
# dart from the flutter341 closure (already pulled, no incremental
# cost) to compile the web-worker entry point.
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
postInstall =
(old.postInstall or "")
+ ''
# `web/...` is relative to build CWD so dart's package_config
# walk-up hits buildFlutterApplication's pub-get output (#685
# / #733 fixup — `$src/web/...` would walk up to a read-only
# store path with no `.dart_tool/`).
${pkgs.flutter341.dart}/bin/dart compile js \
-o $out/native_executor.js \
web/native_executor.dart
postInstall = (old.postInstall or "") + ''
# `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's
# package_config walk-up hits buildFlutterApplication's
# pub-get output `.dart_tool/`.
${pkgs.flutter341.dart}/bin/dart compile js \
-o $out/native_executor.js \
web/native_executor.dart
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
'';
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
'';
});
in
{
# Private Matrix homeserver (matrix-tuwunel — the official conduwuit
# successor) for hyperhive agents, wrapped in a nixos-container so it
# doesn't fight any existing `services.matrix-*` the operator may
# already run on the host. Same shape as `nix/modules/hive-forge.nix`:
# shared host netns (`privateNetwork = false`) so agents reach it at
# `http://localhost:<httpPort>` (or via the configured server_name
# for federation), nixos-container only here for state + systemd-unit
# isolation.
#
# Container name `hive-matrix` (not `h-*`) so the lifecycle scanner
# ignores it; operator manages via the standard `nixos-container` CLI.
#
# Persistent state at `/var/lib/nixos-containers/hive-matrix/var/lib/
# matrix-tuwunel/` (survives container restart / host reboot). To
# wipe, destroy the container.
#
# Initial rollout (#548): federation enabled (needed for multi-hive
# swarms; trusted_servers starts empty so no actual federation traffic
# leaves until peers are explicitly listed), registration enabled via
# a `registration_token_file` known only to hive-c0re (so agents can't
# self-register without going through the coordinator), e2ee disabled
# per operator call (tracked for follow-up at #551).
#
# Provisioning model (matches `nix/modules/hive-forge.nix` shape):
# hive-c0re generates a 32-byte random `registration_token` on first
# boot, writes it to `/var/lib/hyperhive/matrix-register-token` (mode
# 0600, root-only), and bind-mounts that file read-only into the
# tuwunel container at the same path so tuwunel can read it via
# `registration_token_file`. hive-c0re then uses the token to register
# each agent account via the matrix-spec UIAA registration flow, and
# persists the returned `access_token` to `<agent-state>/matrix-token`
# so the agent's matrix MCP client can authenticate without ever
# seeing the shared registration token.
# Private matrix-tuwunel homeserver wrapped in a nixos-container,
# optional fluffychat-web client at matrix.<hive>/. Container shape,
# serverName vs gatewayHost split, provisioning flow (registration
# token + LoadCredential), assertion rationale, initial rollout
# settings: docs/matrix.md. Vhost map + discovery flow + tuning
# knobs: docs/gateway.md.
options.services.hyperhive.matrix = {
enable = lib.mkOption {
@ -322,12 +275,10 @@ in
};
config = lib.mkIf cfg.enable {
# mara on #548: "there is no default, but it is required. add
# assertion." — fail eval with a helpful message rather than
# spawning a homeserver with a bogus server_name we can never
# change later. `services.hyperhive.domain` is host-wide; matrix derives
# the server_name from it (or from `cfg.serverName` if the
# operator wants to override).
# serverName must exist (mara on #548 — irrevocably embedded in
# user/room IDs); gatewayHost may not be "" (argus 🟡 on #764 —
# same footgun as forge.domain). docs/matrix.md::Assertion
# rationale.
assertions = [
{
assertion = hyperhiveDomain != null || cfg.serverName != null;
@ -344,11 +295,6 @@ in
'';
}
{
# Same footgun as forge.domain (#754): empty string renders
# `.<hive>` shaped garbage in both nginx server_name (treated
# as wildcard catch-all, surprising) and /etc/hosts (invalid
# entry). Argus 🟡 on #764 — fail loud here rather than ship
# the surprising behaviour.
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
message = ''
services.hyperhive.matrix.gatewayHost = "" is rejected. The
@ -361,22 +307,10 @@ in
}
];
# Generate the registration token at system activation time, BEFORE
# the hive-matrix container would otherwise start with an empty
# bind-mount target (argus nit on #565: nspawn creates an empty
# file when the host path is missing, tuwunel reads it as
# `registration_token_file=""` and rejects every registration
# until the next restart). Idempotent: only writes when the file
# doesn't exist. 32-byte hex = 64 chars, same shape hive-c0re's
# `matrix::ensure_register_token` would produce.
#
# Ownership: plain `root:root 0600` — tuwunel inside the container
# runs as a hardened dynamic user (#644) and reads the token via
# systemd's `LoadCredential=` mechanism (see container config
# below), so it never needs direct read access on the host-side
# file. No `chown :tuwunel` / `chmod 0640` / GID-pin gymnastics
# required (per iris on #644 8043, dropping the shape #649
# shipped with).
# Activation-time token generation (argus #565: the bind-mount
# would otherwise hand tuwunel an empty file on first boot and
# break every registration until restart). Idempotent;
# docs/matrix.md::Provisioning flow.
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
if [ ! -s "$tokenFile" ]; then
@ -385,25 +319,17 @@ in
echo >> "$tokenFile"
echo "hive-matrix: generated registration token at $tokenFile"
fi
# Always re-apply 0600 (idempotent on already-correct files;
# also normalises any 0640 / world-readable carry-over from
# pre-LoadCredential deployments).
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
chmod 0600 "$tokenFile"
'';
containers.hive-matrix = {
autoStart = true;
ephemeral = false;
# Share host netns — tuwunel's listeners look exactly like
# host-side services, no port-forward plumbing, and agent
# containers (also host netns) reach it via plain `localhost`.
# Shared host netns — agents reach tuwunel at localhost:<port>.
privateNetwork = false;
# Read-only bind of the host-managed registration token so
# tuwunel can resolve `registration_token_file` to a real
# file inside the container. The activation script above
# ensures the host path exists with a valid 64-char hex token
# before any container starts, so the bind always finds real
# content (no first-boot empty-file race; argus #565 nit).
# Read-only bind of the host-managed registration token; tuwunel
# reads it via systemd LoadCredential below (not directly).
bindMounts.${cfg.registrationTokenFile} = {
hostPath = cfg.registrationTokenFile;
isReadOnly = true;
@ -417,44 +343,28 @@ in
package = cfg.package;
settings.global = {
server_name = effectiveServerName;
# `address` is `listOf nonEmptyStr` upstream (multi-bind
# support). Single-host bind goes through as a one-element list.
# `address` + `port` are upstream `listOf` — wrap singles.
address = [ "0.0.0.0" ];
# `port` is `listOf port` upstream. Same shape.
port = [ cfg.httpPort ];
max_request_size = cfg.maxRequestSize;
# Federation enabled at the protocol level so swarms
# can be wired up later by extending `trustedServers`
# without a homeserver restart. Empty trusted_servers
# keeps it effectively closed until peers are listed.
# Federation enabled at the protocol level; empty
# trustedServers keeps it effectively closed.
allow_federation = true;
trusted_servers = cfg.trustedServers;
# Token-gated registration: hive-c0re holds the token,
# agents never see it. allow_registration must be true
# for the token flow to engage; the absent
# Token-gated registration. The absent
# `yes_i_am_very_very_sure_…_open_registration_…` flag
# keeps the server closed to anyone without the token.
allow_registration = true;
# Read the registration token via systemd's
# `LoadCredential=` mechanism (wired below) instead of
# the bind-mount path directly. systemd copies the host-
# owned 0600 root:root file into a per-service
# credentials dir owned by tuwunel's dynamic user with
# mode 0400 — keeps `DynamicUser=true` + `PrivateUsers=true`
# intact, no host-side `chown :tuwunel` / GID-pin
# gymnastics required (#644 / iris on 8043).
# LoadCredential below copies the host file into a
# 0400 dynamic-user-owned path; tuwunel reads from there.
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
# E2EE disabled in initial rollout per operator call
# (#548) — re-enabling tracked at #551.
# E2EE disabled in initial rollout (#548); re-enable at #551.
allow_encryption = false;
};
};
# `LoadCredential=<id>:<host-path>` makes systemd copy the
# bind-mounted host file into `/run/credentials/tuwunel.service/<id>`
# owned by the service's (dynamic) user with mode 0400 at
# service start. The hardcoded path in `registration_token_file`
# above is the systemd-stable credentials dir; see
# `man systemd.exec` → LoadCredential.
# Keeps DynamicUser=true + PrivateUsers=true intact — no
# host-side chown :tuwunel / GID-pin gymnastics needed (#644 /
# iris on 8043). See `man systemd.exec` → LoadCredential.
systemd.services.tuwunel.serviceConfig.LoadCredential = [
"registration_token:${toString cfg.registrationTokenFile}"
];

View file

@ -75,6 +75,40 @@ in
'';
};
options.hyperhive.web.useUnixSocket = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
When `true`, set `HIVE_WEB_SOCKET=/run/hive-agent/${userName}/web.sock`
on the harness service env, which makes `web_ui::serve` bind a
`UnixListener` at that path instead of the legacy TCP listener
on `HIVE_PORT`. Closes the third hop of the #784 rollout: PR
#800 added the harness-side opt-in, #809 / #813 added the c0re
bind-mount + JSON-map plumbing, this is the per-agent flip
that activates the unix-domain path.
Default `false` so an agent's web UI keeps binding TCP until
the per-agent flip is explicit. Rollout shape:
1. flip one canary agent (atlas volunteered) to `true` via its
`agent.nix` once #813 lands;
2. validate the gateway's `proxy_pass http://unix:.../web.sock`
end-to-end against that canary (atlas's step 3);
3. flip remaining agents per-agent as the gateway side soaks;
4. eventually drop this option once every agent's on unix +
atlas's gateway is the only path step 4 of #784 drops the
harness's TCP fallback at the same time.
Sub-agent-only by design: the manager's UI serves at `/` via
the c0re dashboard upstream, not via `/agent/<name>/`, so this
option has no effect when `hyperhive.role = "manager"` (the
env var is set unconditionally for clarity, but the manager's
web UI doesn't route through the gateway's per-agent unix
upstream its bind socket would just sit unused).
'';
};
options.hyperhive.role = lib.mkOption {
type = lib.types.enum [
"agent"
@ -537,6 +571,64 @@ in
'';
};
# Internal accumulator for shell snippets that should land in
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
# `lib.mkIf` gated on their own option; the lines type merges
# all contributions across modules into one file. Loaded via
# `$BASH_ENV` for non-interactive shells (claude's `Bash` tool
# runs `bash -c`) and via `programs.bash.interactiveShellInit`
# for interactive shells. Generic by design (mara on #779) so
# future hooks don't need to either rename this file or invent
# a parallel dispatcher.
options.hyperhive._bashEnvFragments = lib.mkOption {
type = lib.types.lines;
default = "";
internal = true;
description = ''
Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`.
Feature hooks contribute via `lib.mkIf` gated on their own
option. When empty, the file isn't created, `BASH_ENV` stays
unset, and the interactive bashrc hook is omitted zero cost
when no feature is on. Internal set indirectly via the
per-feature options that own the gate (e.g.
`hyperhive.cargo.shortMessages`).
'';
};
options.hyperhive.cargo.shortMessages = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Auto-inject `--message-format short` on cargo compile
subcommands (`build`, `check`, `clippy`, `test`, `run`,
`doc`, `bench`, `install`, `rustc`, `fix`) when claude (or
anything else) invokes `cargo` inside this container.
Saves tokens + context the verbose default output floods
the response window with per-crate progress lines that
carry no signal beyond the warning/error summary (#777).
Implementation: contributes a `cargo` shell function to
`/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`).
Loaded via `BASH_ENV` for non-interactive shells (`bash -c`
what the claude `Bash` tool runs) and sourced from
`programs.bash.interactiveShellInit` for interactive shells.
The function:
- handles the `+toolchain` selector prefix (`cargo +nightly
build` works);
- passes through cleanly when the caller already specified
`--message-format` (any form);
- leaves non-compile subcommands (`new`, `add`, `search`,
third-party `cargo-*` subcommands) untouched so they
don't error on the unknown flag.
Set to `false` for agents that need full cargo output (e.g.
tooling that parses `--message-format json` programmatically
and doesn't pass the flag explicitly).
'';
};
options.hyperhive.autoCompact = lib.mkOption {
type = lib.types.bool;
default = true;
@ -656,46 +748,20 @@ in
}
];
# First-boot migration from the legacy root-run shape (#658).
# Runs on every activation; marker-guarded so the move only
# happens once. The bind mount that hive-c0re sets up has
# already moved from `/root/.claude` to `${homeDir}/.claude`
# by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT`
# — the host-side path stays the same, the container-side
# mount target shifts), so the bulk of the data is already at
# the new location. This script just:
#
# - ensures `${homeDir}` exists with correct ownership (covers
# the very first boot before useradd's `createHome` has
# anything to chown);
# - migrates any leftover `/root/.claude` content that an
# operator might have populated before #658 deployed (the
# bind mount didn't exist in that lifecycle, so claude
# would have written into the root user's empty home —
# nothing important typically, but safer to move than to
# strand);
# - chowns the bind-mounted state dir (`/agents/*/state`) so
# the agent user can read/write it.
# Post-#658 first-boot migration to the per-agent unix user —
# creates the home dir, chowns the bind-mounted state +
# `~/.claude/`, and (marker-guarded) moves any leftover
# `/root/.claude` content from the pre-#658 root-run shape. See
# `docs/persistence.md::First-boot agent-user migration` for the
# step-by-step rationale; this script implements it.
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
homeDir=${lib.escapeShellArg homeDir}
userName=${lib.escapeShellArg userName}
# Always ensure the home dir exists with the right ownership —
# useradd's createHome handles the very first creation but
# doesn't re-chown if a rebuild changes the user name (rare
# but possible if the meta-flake's per-agent name evolves).
mkdir -p "$homeDir"
chown "$userName:$userName" "$homeDir"
# One-time migration of pre-#658 /root/.claude content into the
# new home. Marker-guarded so the move only runs once per
# container lifetime — subsequent activations skip the legacy
# path even if claude were to repopulate /root/.claude for any
# reason.
marker=/var/lib/hive-agent-user-migrated
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
mkdir -p "$homeDir/.claude"
# `mv -n` (no-clobber) so any pre-existing files at the
# destination (e.g. from the bind mount) win — we never
# blow over data already at the new location.
if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then
rm -rf /root/.claude
echo "hive-agent-user-migrate: moved /root/.claude $homeDir/.claude"
@ -703,25 +769,10 @@ in
fi
mkdir -p "$(dirname "$marker")"
: > "$marker"
# Chown the bind-mounted state dir so the agent user can
# read/write it. `/agents/*/state` is the canonical mount
# point set by hive-c0re's `set_nspawn_flags`. Wildcard
# because each container only sees its own
# `/agents/<name>/state` (one match); -h to avoid following
# any symlinks the agent might have planted in there.
for stateDir in /agents/*/state; do
[ -d "$stateDir" ] || continue
chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true
done
# Same treatment for the bind-mounted `~/.claude/` dir. Pre-#658
# the harness ran as root and `claude` wrote `.credentials.json`
# there 0600 root:root; post-#658 the harness reads
# `~/.claude/` as the agent user to decide Online vs
# NeedsLogin (`login::has_session`), and the host-side bind
# source is still root-owned 0700 from those legacy writes.
# Chown recursively so the existing credentials are readable
# under the new identity instead of getting silently treated
# as "no session" and re-prompting login every boot.
if [ -d "$homeDir/.claude" ]; then
chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true
fi
@ -753,6 +804,55 @@ in
source = config.hyperhive.icon;
};
# Cargo `--message-format short` injector (#777). Contributes a
# `cargo` shell function to `hyperhive._bashEnvFragments`; the
# bash-env infrastructure below packages that into a single file
# sourced by both non-interactive and interactive shells.
# `command cargo …` falls back to the un-wrapped binary in PATH
# (the rust toolchain's cargo — either from `environment.systemPackages`
# or from whatever `nix develop` shell the agent's working in).
hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages ''
# Auto-injects --message-format short on cargo compile
# subcommands so per-crate progress lines don't flood
# claude's context (#777). Bypassed when the caller
# already passes --message-format (any form).
cargo() {
# Strip leading +toolchain selectors (cargo +nightly …).
local pre=()
while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do
pre+=("$1")
shift
done
case "''${1:-}" in
build|check|clippy|test|run|doc|bench|install|rustc|fix)
local sub="$1"
shift
local arg
for arg in "$@"; do
case "$arg" in
--message-format|--message-format=*)
command cargo "''${pre[@]}" "$sub" "$@"
return $?
;;
esac
done
command cargo "''${pre[@]}" "$sub" --message-format short "$@"
;;
*)
command cargo "''${pre[@]}" "$@"
;;
esac
}
'';
# Single bash-env file with all configured shell fragments.
# Wiring is gated on at least one fragment being active so a
# fully feature-disabled agent has neither the file nor the
# `BASH_ENV` / interactive sourcing — zero cost in that case.
environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
text = config.hyperhive._bashEnvFragments;
};
environment.etc."hyperhive/bash-allow.json".text =
builtins.toJSON config.hyperhive.allowedBashPatterns;
@ -814,8 +914,28 @@ in
}
// lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) {
HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons;
}
// lib.optionalAttrs (config.hyperhive._bashEnvFragments != "") {
# Non-interactive bash invocations (claude's `Bash` tool runs
# `bash -c`) source $BASH_ENV at startup — drops every active
# feature hook's snippet into scope without touching
# `/etc/profile` (login-only). Interactive shells source the
# same file via the `interactiveShellInit` hook below so
# behaviour matches across both modes (#777).
BASH_ENV = "/etc/hyperhive/bash-env.sh";
};
# Interactive shells don't honour BASH_ENV — wire the same file
# in via the bashrc hook so operator SSH sessions get the same
# hook surface as claude's non-interactive calls. Gated on at
# least one fragment being active so we don't write a no-op
# source line into `/etc/bashrc` on fully-feature-disabled agents.
programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
if [ -r /etc/hyperhive/bash-env.sh ]; then
. /etc/hyperhive/bash-env.sh
fi
'';
boot.isNspawnContainer = true;
# Every agent gets flakes + the modern `nix` CLI out of the box.
@ -829,15 +949,11 @@ in
"flakes"
];
# Containers bind-mount the host's nix-daemon socket. The host daemon
# may be configured with remote builders or strict sandbox settings
# (sandbox-fallback = false) that make local `nix build` invocations
# fail inside the container. Enable sandbox-fallback so builds that
# can't set up the sandbox (no user-namespaces in nspawn) fall back
# to unsandboxed local builds rather than failing outright.
# mkForce overrides the nixpkgs nix module which sets this to false
# at normal priority -- without it agents get a conflicting definition
# error on rebuild. Security implications: see docs/security.md.
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
# in-container `nix build` invocations fall back to unsandboxed
# local builds rather than failing on the missing user-namespace.
# See `docs/gotchas.md::Containerized nix-daemon needs
# sandbox-fallback = true` + `docs/security.md` for the rationale.
nix.settings.sandbox-fallback = lib.mkForce true;
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
@ -1005,14 +1121,12 @@ in
'';
};
# Long-running matrix-sdk Client + sync per agent (#548 phase 3).
# Holds the unix socket the stdio `hive-matrix-mcp` bridge talks
# to, and emits hyperhive wake signals on incoming room events
# via `/run/hive/mcp.sock`. Conditional on `hyperhive.matrix.enable`
# AND token-file presence (the daemon binary itself exits 0 on
# missing token, but the path watcher below restarts it the
# moment the token lands — same first-boot-ordering pattern as
# matrix-avatar-sync.path / #571).
# Long-running matrix-sdk client + sync per agent. Holds the unix
# socket the stdio `hive-matrix-mcp` bridge connects to + emits
# hyperhive wake signals on incoming room events via
# `/run/hive/mcp.sock`. See
# `docs/persistence.md::Matrix per-agent daemon + token-arrival
# trigger` for the socket-path / first-boot-ordering rationale.
systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
description = "long-running matrix-sdk Client + MCP daemon socket";
wantedBy = [ "multi-user.target" ];
@ -1020,12 +1134,6 @@ in
wants = [ "network-online.target" ];
environment = {
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
# Socket path lives inside the systemd-managed runtime dir
# (`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`,
# owned by the agent user) so the daemon can bind it without
# needing root over `/run/` itself (#658). The stdio bridge
# picks up the same path via its own `HIVE_MATRIX_SOCKET` env
# in `extraMcpServers.matrix` below.
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
RUST_LOG = "info";
};
@ -1033,26 +1141,17 @@ in
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
Restart = "on-failure";
RestartSec = 5;
# Run as the per-agent unix user (#658). The runtime dir
# (`/run/hive-matrix/`) is owned by that user via
# `RuntimeDirectory`; claude (also as that user) can
# connect to the socket inside it when the stdio bridge
# spawns per turn.
User = userName;
Group = userName;
RuntimeDirectory = "hive-matrix";
};
};
# Path-trigger sibling so hive-matrix-daemon fires the moment
# `<state>/matrix-token` appears (#548 phase 3, mirrors the
# matrix-avatar-sync.path pattern from #571). On clean boot
# hive-c0re provisions the token AFTER agent containers come up;
# without the trigger the daemon would exit 0 quietly and the
# MCP would have no backend until next restart. With the watcher
# the daemon comes alive in the same boot cycle as provisioning.
# The glob matches every agent (manager sees its own state at
# `/agents/hm1nd/state/` via the `/agents` bind).
# Re-fire the daemon when the matrix token appears (hive-c0re
# provisions it after agent containers come up). Without this
# the daemon would exit 0 silently on first boot and the MCP
# would have no backend until next restart. See
# `docs/persistence.md` (same section as above).
systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
description = "trigger hive-matrix-daemon when matrix-token appears";
wantedBy = [ "multi-user.target" ];
@ -1217,12 +1316,10 @@ in
};
};
# Manager-only forge defaults (#671): skip the
# subscription/participation firehose so the manager's inbox
# only carries direct mentions, reviews, and assignments. Sub-
# agents keep the noisier defaults (`keepSubscriptions = true`,
# `skipNotifyReasons = [ ]`). `mkDefault` so any agent that
# wants to invert it can.
# Manager-only forge defaults: subscription/participation
# firehose stays off so the manager's inbox isn't drowned in
# noise. Full rationale + sub-agent contrast:
# docs/agent-hierarchy.md::Manager-only defaults.
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
keepSubscriptions = lib.mkDefault false;
skipNotifyReasons = lib.mkDefault [
@ -1231,91 +1328,49 @@ in
];
};
# Harness systemd unit. Role-driven so the same `harness-base.nix`
# covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`)
# and `nixosConfigurations.manager` (`hive-m1nd serve`) without a
# second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL
# come from the meta-flake's generated `applied/<name>/flake.nix`;
# the manager has hardcoded fallbacks here so `nixosConfigurations.manager`
# still builds standalone.
# Role-driven harness systemd unit: one binary, two unit names
# for log/ExecStartPre stability. Unit shape (PATH wrapper-dir
# trick, env vars, RuntimeDirectory, User=, standalone-eval
# fallbacks): docs/agent-hierarchy.md::Harness systemd unit
# shape (per-role). PATH /bin auto-append behaviour:
# docs/gotchas.md::systemd.services.*.path appends /bin to
# every entry.
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
let
isManager = config.hyperhive.role == "manager";
# Post-#598 there is exactly one harness binary (`hive`), and
# it picks its surface from `HIVE_ROLE` at startup. We still
# name the systemd unit `hive-ag3nt` / `hive-m1nd` so dashboard
# log queries + ExecStartPre paths + ancestor PR diffs keep
# working without a unit rename cascade.
binary = "hive";
in
{
description = "${binary}${lib.optionalString isManager " manager"} harness";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# systemd units get a minimal PATH by default and don't inherit
# `environment.systemPackages`. Pointing at `/run/current-system/sw`
# gives the harness (and any tools claude shells out to via Bash)
# access to everything declared in `systemPackages` — including
# anything an agent adds to its own `agent.nix` — without having
# to touch the service definition.
#
# `/run/wrappers/bin` prepended so the `security.wrappers`
# setuid shims (notably `sudo`) resolve before the bare
# nix-store binaries in `/run/current-system/sw/bin`.
# Post-#658 the harness runs as the per-agent user — without
# the wrapper dir on PATH, `sudo` resolves to the un-setuid
# nix-store binary and refuses with "must be owned by uid 0
# and have the setuid bit set" even when
# `hyperhive.user.passwordlessSudo = true` is configured
# (#672 fixup pulled forward into this PR to avoid the
# regression argus flagged on #676).
#
# `systemd.services.<name>.path` appends `/bin` to each entry,
# so the bare prefixes here resolve to `/run/wrappers/bin` +
# `/run/current-system/sw/bin` inside the unit's PATH. Passing
# the trailing `/bin` ourselves (the natural-looking spelling)
# would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`,
# neither of which exists — that's how #672 originally landed
# broken: every agent had a PATH pointing at non-existent dirs
# and `which sudo` kept falling back to the un-setuid binary.
# `/run/wrappers` before `/run/current-system/sw` so setuid
# `sudo` resolves first. Passing the bare prefixes (no trailing
# `/bin`) is intentional — see docs pointer above.
path = [
"/run/wrappers"
"/run/current-system/sw"
];
environment = {
SHELL = "${pkgs.bashInteractive}/bin/bash";
# `HOME` defaults to `/` for systemd services without a User=
# set. With #658 the harness runs as the agent user — set HOME
# explicitly so claude (which the harness spawns) finds its
# `~/.claude/` session dir at the bind-mounted location.
HOME = homeDir;
# Path to the merged agent static dist. The harness serves this
# via `tower_http::ServeDir` for any request it doesn't route to
# an API endpoint. `mergedDist` is the agent-default dist with
# `hyperhive.frontend.extraFiles` layered on top.
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
# Static runtime assets (branding + claude prompts). Set on the
# unit directly — `environment.variables` only populates
# /etc/profile, which systemd services don't inherit.
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
# Post-#598: the unified `hive` binary picks its surface from
# this env var at startup. Default (`"agent"`) matches the
# binary's standalone fallback when this is unset.
HIVE_ROLE = config.hyperhive.role;
}
// lib.optionalAttrs config.hyperhive.web.useUnixSocket {
# Per-agent unix-socket flip for the web UI (#784 phase 2
# step 2c). When set, the harness's `web_ui::serve` binds
# a `UnixListener` at this path instead of TCP. Path
# matches `hive_c0re::agent_sockets::socket_path_for(name)`
# so the lifecycle bind-mount (#813) and the gateway's
# upstream config all derive from the same canonical
# `/run/hive-agent/<name>/web.sock` shape — no triangulation.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
}
// lib.optionalAttrs isManager {
# Standalone-eval fallbacks for `nixosConfigurations.manager`.
# meta.rs overrides both via the per-agent generated
# `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`);
# the values here keep the container sensible if anyone
# evaluates the standalone config.
#
# `HIVE_PORT` = FNV-1a("hm1nd") % 900 + 8100 = 8875 per
# `lifecycle::agent_web_port` (#753 dropped the
# pre-#753 "manager pinned at 8000" special case). Hardcoded
# here because the standalone-eval path doesn't go through
# `meta::render_flake`; real deploys pick up the rust-computed
# value via meta and never touch this fallback.
# Standalone-eval fallbacks; meta.rs overrides at deploy time.
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
HIVE_PORT = "8875";
HIVE_LABEL = "hm1nd";
};
@ -1323,20 +1378,11 @@ in
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
Restart = "on-failure";
RestartSec = 2;
# `/run/hive-config/` is a per-service runtime dir owned by
# the agent user (`User=` below), auto-cleared by systemd on
# stop. The harness writes its regenerated
# claude-{mcp-config,settings,system-prompt} files there
# (see `paths::config_dir`). Kept separate from `/run/hive`
# — that bind comes in root-owned from the host and holds
# hive-c0re's `mcp.sock` we only connect to (#658 fixup).
# Per-service runtime dir owned by `User=` below; the harness
# writes its regenerated claude-{mcp-config,settings,system-prompt}
# files here (`paths::config_dir`). Separate from /run/hive,
# which holds hive-c0re's mcp.sock.
RuntimeDirectory = "hive-config";
# Run the harness as the per-agent user (#658). claude itself
# spawned by the harness then runs as that user too — drops
# root inside the container while sudo (`NOPASSWD: ALL` by
# default, see `hyperhive.user.passwordlessSudo`) keeps the
# previous root-by-default surface available explicitly for
# tools that need it.
User = userName;
Group = userName;
};

View file

@ -5,66 +5,41 @@
...
}:
{
# Optional Weston (the reference Wayland compositor) with the VNC
# backend, surfaced as a per-agent hyperhive option. An agent turns
# it on from its own `agent.nix`:
# Optional Weston (Wayland compositor) with the VNC backend,
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
# from harness-base.nix so every sub-agent + the manager sees the
# option; only those that flip it on get the service.
#
# hyperhive.gui.enable = true;
#
# Imported by `harness-base.nix`, so every sub-agent + the manager
# has the option available; only those that flip it on get the
# service. This is a flat per-agent option (evaluated inside that
# agent's own container build) — NOT a `hyperhive.agents.<name>.*`
# registry, which can't work: each agent is its own
# nixosConfiguration and has no cross-agent view.
#
# VNC port selection: a deterministic FNV-1a hash of the agent name
# (derived from the container hostname at runtime) maps into the
# range [15900, 16799], mirroring lifecycle::agent_web_port. The
# computed port is written to `/etc/hyperhive/gui.json` at service
# start; the harness (issue #51) reads that file to know where to
# relay WebSocket connections.
#
# Note: weston's VNC backend does not expose a CLI bind-address flag
# (unlike the RDP backend's `--address`), so VNC listens on all
# interfaces. The harness WebSocket relay (issue #51) connects only
# via 127.0.0.1, and the host firewall should block external access
# to the VNC port range. A future weston.ini `[vnc] address=` can
# restrict this once upstream supports it.
# Port allocation, weston bind-address quirk, PAM service name, the
# Type=simple choice, idle-time=0: all in
# docs/gotchas.md::Weston VNC compositor.
# Harness-side WebSocket relay shape: docs/web-ui.md::Per-agent
# endpoints (`/screen` + `/screen/ws`).
options.hyperhive.gui.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run Weston with the VNC backend as a systemd service, for
in-browser GUI access via the harness WebSocket relay (see
issue #51). Renders in software (pixman) — no GPU, DRM,
or VT access, so no extra container capabilities are needed.
in-browser GUI access via the harness `/screen/ws` WebSocket
relay. Renders in software (pixman) no GPU, DRM, or VT
access, so no extra container capabilities are needed.
The VNC port is deterministic: FNV-1a hash of the agent name
(taken from the container hostname) mapped into [15900, 16799].
The port and auth mode are written to `/etc/hyperhive/gui.json`
at service start so the harness can relay connections.
The unit is deliberately built so enabling it can NEVER abort
the agent's `nixos-container update`: `Type = "simple"` (so
`switch-to-configuration` doesn't block on weston readiness)
and the ExecStart script always tries to exec weston after
setup a misconfigured weston degrades to a restart loop
visible in `journalctl`, it does not block the rebuild. (Same
reasoning as the `tea-login` unit in `harness-base.nix`.)
The VNC port is a deterministic FNV-1a hash of the agent name
mapped into `[15900, 16799]`, written to
`/etc/hyperhive/gui.json` at service start so the harness can
relay connections without a separate config flag. The unit is
`Type = "simple"` so a misconfigured weston degrades to a
restart loop instead of blocking `nixos-container update`.
'';
};
config = lib.mkIf config.hyperhive.gui.enable {
# neatvnc 0.9 always calls the PAM auth callback (weston_authenticate_user)
# for Apple-DH (type 30), regardless of weston.ini auth-method=none.
# pam_permit.so makes the PAM service accept any credentials so the
# browser's empty Apple-DH credentials always pass.
#
# The service name is "weston-remote-access" — that is the literal string
# passed to pam_start() inside libweston (libweston/auth.c). Using "weston"
# instead silently falls back to the system default and rejects auth.
# neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
# (type 30), regardless of weston.ini auth-method=none.
# pam_permit.so accepts the browser's empty Apple-DH credentials.
# Service name MUST be the literal `weston-remote-access` — that's
# the string libweston passes to pam_start() in libweston/auth.c.
security.pam.services."weston-remote-access".text = ''
auth sufficient pam_permit.so
account sufficient pam_permit.so
@ -76,29 +51,21 @@
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
# `simple`, not `notify`: switch-to-configuration must not
# wait on weston signalling readiness (same reasoning as the
# `tea-login` unit in harness-base.nix).
Type = "simple";
# Creates /var/lib/weston (0700 root) at start.
StateDirectory = "weston";
Environment = "XDG_RUNTIME_DIR=/run/user/0";
# Wrapper script: computes the deterministic VNC port, writes
# /etc/hyperhive/gui.json for the harness (issue #51), then
# execs weston. Using `exec` keeps the PID stable so systemd
# tracks the weston process correctly under Type=simple.
# Any failure before the exec triggers Restart=on-failure
# (graceful degradation) rather than blocking the rebuild.
# /etc/hyperhive/gui.json for the harness, then execs weston.
# `exec` keeps the PID stable so systemd tracks the weston
# process correctly under Type=simple.
ExecStart = pkgs.writeShellScript "weston-vnc" ''
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true
# --- Compute deterministic VNC port via FNV-1a ---
# Agent name = container hostname with leading "h-" stripped,
# mirroring lifecycle::agent_web_port in hive-c0re/src/lifecycle.rs.
# Agent name = container hostname with leading `h-` stripped.
# Read from /etc/hostname (always present in NixOS containers)
# to avoid a dependency on the `hostname` binary (which lives in
# pkgs.inetutils, not pkgs.coreutils).
# VNC_PORT_BASE=15900, VNC_PORT_RANGE=900 → [15900, 16799].
# to avoid depending on `hostname` (lives in pkgs.inetutils,
# not pkgs.coreutils).
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname)
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \
| ${pkgs.gnused}/bin/sed 's/^h-//')
@ -111,30 +78,15 @@
done
VNC_PORT=$((15900 + hash % 900))
# --- Write gui.json marker ---
# The harness reads this at startup (issue #51) to know the
# VNC port and auth mode for the WebSocket relay.
# Marker file the harness reads at startup.
${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive
${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none"}\n' \
"$VNC_PORT" > /etc/hyperhive/gui.json || true
# neatvnc ≥ 0.9 advertises RSA-AES and Apple-DH security types
# when auth is compiled in. The browser client handles Apple-DH
# (type 30) with empty credentials.
#
# weston.ini [vnc] auth-method=none: weston uses an always-accept
# auth callback instead of PAM. Without this, weston defaults to
# PAM authentication which rejects empty credentials (SecurityResult=1).
#
# --disable-transport-layer-security prevents the VeNCrypt TLS
# wrapper; plain auth types (incl. type 30) are advertised directly.
# [core] idle-time=0 disables weston's idle timeout (default
# 300s). Without it the VNC desktop fades to black after 5 min
# idle and desktop-shell shows its click-to-unlock lock screen
# — useless for an agent desktop viewed over /screen (issue
# #180). idle-time=0 → the idle timer is updated with a 0ms
# delay, which wl_event_source_timer_update treats as "disarm",
# so the compositor never goes idle and never locks.
# --disable-transport-layer-security: skips the VeNCrypt TLS
# wrapper so plain auth types (incl. Apple-DH type 30) are
# advertised directly. [core] idle-time=0 disables the
# compositor's 300s idle/lock screen.
WESTON_INI=$(${pkgs.coreutils}/bin/mktemp /tmp/weston-XXXXXX.ini)
${pkgs.coreutils}/bin/printf '[core]\nidle-time=0\n\n[vnc]\nauth-method=none\n' > "$WESTON_INI"
@ -150,8 +102,8 @@
};
};
# weston on the agent's interactive PATH too, so claude can run
# Wayland clients / `weston-info` against the compositor.
# weston on the agent's interactive PATH so claude can run Wayland
# clients / weston-info against the compositor.
environment.systemPackages = [ pkgs.weston ];
};
}