Defensive against the fresh-boot-before-any-agent-spawn window where
the bind-mount source wouldn't exist from c0re's per-agent
`set_nspawn_flags` mkdir chain yet. nspawn auto-creates missing
sources, so this is intent-explicit rather than functional fix.
Switch per-agent gateway upstreams from TCP loopback to unix-domain
socket when the agent has opted in via `hyperhive.web.useUnixSocket`
(#822). Coexists with the TCP path during rollout.
Changes:
- New `agentSocketsFile` option (default
`/var/lib/hyperhive/agent-sockets.json`) — c0re writes the map
there via `hive_c0re::agent_sockets::write` (#809).
- `agentSocketsTable = lib.importJSON ...` (graceful empty when
file missing).
- `agentUpstreamFor name port` picks `http://unix:<path>:/` when the
socket has a JSON entry AND the file exists at eval time; else
`http://127.0.0.1:<port>/`. Path-exists gate guards against
c0re's blanket-emit shape during the canary window (agents in
`agent-sockets.json` who haven't actually flipped have no
bound socket on disk → fall back to TCP). Damocles will ship a
`.bound` marker filter on the c0re side (#784 step 2d
follow-up); once that's in, the path-exists check is redundant
but harmless. Step 4 drops it entirely along with the TCP
fallback.
- `containers.hive-gateway.bindMounts."/run/hive-agent"` —
read-only, unconditional. Inert when no agents have opted in.
Required so nginx inside the gateway container can `connect(2)`
to the per-agent sockets damocles's #813 bind-mounts into agent
containers at the same paths.
Docs:
- `docs/gateway.md::Per-agent UDS upstream (#784)` — full rollout
flow, subdir-bind rationale (damocles #813), eval-time gate
explainer, step 4 drop plan.
`nix flake check` clean; `nix fmt` clean.
Canary plan: once #822 (`useUnixSocket` option) lands + this PR
merges, manager flips atlas's agent.nix to `useUnixSocket = true`
via the config-update flow. End-to-end validation against atlas
before broader rollout.
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).
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).
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
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).
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.
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.
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).
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
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.