diff --git a/CLAUDE.md b/CLAUDE.md index 86f4913f..1f446a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,3 +129,6 @@ The docs below own the details — this section just points at them. window:** → [`docs/turn-loop.md`](docs/turn-loop.md). - **Two-step spawn, approval flow, flake.lock validation:** → [`docs/approvals.md`](docs/approvals.md). +- **Pre-push lint hook** (catches tracker-tag and comment-block failures + before CI does — install once per clone): + `ln -sf ../../scripts/pre-push .git/hooks/pre-push` diff --git a/docs/boundary.md b/docs/boundary.md index c1608f7c..1fc5ed3b 100644 --- a/docs/boundary.md +++ b/docs/boundary.md @@ -5,11 +5,12 @@ _implementation_ work — container network isolation, the unifying gateway, core-daemon privsep — is tracked as `area:ops` issues on the forge. -Today "the operator surface" and "the agent surface" are a -_convention_, not a boundary — nothing stops a container from -curling the core daemon on `localhost:`, or another agent's -web UI. Network isolation, the gateway, and privsep together turn -that convention into an enforced boundary. +The operator/agent boundary is now technically enforced, not just a +convention. Containers run in private netns (network isolation is +always on), the gateway proxies all operator-facing traffic, and +`hive-c0re` runs as the unprivileged `hive-core` user. All three +`area:ops` pillars — network isolation, the gateway, and privsep — +are complete and active. ## Two principals, two paths @@ -39,14 +40,17 @@ agent page). ## Why network isolation is the load-bearing step -Containers currently share the host network namespace, so a -container can reach `localhost:`, the dashboard, and -every other agent's web port. Until that changes, the -operator/agent split is on the honour system — every boundary -claim above is aspirational. Network isolation is what makes the -boundary _real_; the gateway and privsep are ergonomics and +Without network isolation, containers share the host network namespace +and can reach `localhost:`, the dashboard, and every other +agent's web port — the operator/agent split is on the honour system and +every boundary claim above is aspirational. Network isolation is what +makes the boundary _real_; the gateway and privsep are ergonomics and defence-in-depth layered on top. +Network isolation is now complete and always on: every agent container +runs in a private netns behind the hive bridge. The shared-netns mode +was removed. See `docs/network.md`. + The `area:ops` issues followed this sequencing: 1. **Gateway** — pure ergonomics win, unblocks same-origin (lets the @@ -54,7 +58,8 @@ The `area:ops` issues followed this sequencing: behavioural risk. An nginx nixos-container now sits in front of all surfaces; per-agent UIs are proxied under `/agent//`. 2. **Network isolation** — the load-bearing step that turns the - honour-system split into an enforced boundary. In progress. + honour-system split into an enforced boundary. **Complete** — + always-on, unconditional; the shared-netns mode was removed. 3. **Privsep** — defence in depth on the core process; `hive-c0re` runs as the unprivileged `hive-core` user and delegates root operations to `hive-priv`, a narrow socket-activated helper. See diff --git a/docs/ci.md b/docs/ci.md index b3da8e13..73da7583 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,6 +1,46 @@ # hive-ci: Forgejo Actions Runner -The `hive-ci` module runs a Forgejo Actions runner in a `hive-ci` nixos-container, executing CI jobs from `.forgejo/workflows/ci.yml` (e.g., `nix flake check` on every PR). +The `hive-ci` module runs a Forgejo Actions runner in a `hive-ci` nixos-container, +executing CI jobs from `.forgejo/workflows/ci.yml` on every PR. + +## CI checks + +Three jobs run on every PR (and on `workflow_dispatch` for manual re-triggers): + +| Job | What it runs | Currently required | +| --- | --- | --- | +| **nix flake check** | treefmt + rustfmt formatting, `cargo clippy -D warnings`, `cargo test`, module evaluation | yes | +| **tracker-tag lint** | flags `#NNN` issue tags in source and comments (`scripts/check-issue-refs.sh`) | no (red, non-blocking) | +| **comment-block lint** | flags contiguous comment blocks over 30 lines (`scripts/check-comment-blocks.sh`) | no (red, non-blocking) | + +The tracker-tag and comment-block checks are non-blocking today (a hit fails the +check but does not prevent merge) while the legacy backlog is cleaned up. They are +expected to become required checks once the tree is clean. + +### Running checks locally + +Don't run `nix flake check` directly — it dispatches to the shared build farm and +wastes a remote-builder slot. Use the devshell equivalents instead: + +```sh +nix develop -c cargo clippy --all-targets -- -D warnings +nix develop -c cargo test +nix develop -c treefmt # same as nix fmt; treefmt covers rustfmt + nixfmt + taplo +sh scripts/check-issue-refs.sh # tracker-tag lint +sh scripts/check-comment-blocks.sh # comment-block lint +``` + +A git pre-push hook that automates the two lint checks is provided at +`scripts/pre-push`. Install it once per clone: + +```sh +ln -sf ../../scripts/pre-push .git/hooks/pre-push +``` + +After that, any `git push` automatically runs both lints and aborts with a +diagnostic if either fails — catching the issue locally before CI sees it. +Note that the hook does **not** run `cargo clippy` or `cargo test` (those are +slow); run those manually before pushing Rust changes. ## Operator bootstrap diff --git a/docs/coordinator.md b/docs/coordinator.md index b8ac35d6..539aa159 100644 --- a/docs/coordinator.md +++ b/docs/coordinator.md @@ -117,19 +117,28 @@ render. ## Auto-update sweep -On startup, `auto_update.rs` rebuilds every known container unconditionally. -`nixos-container update` is a no-op at the nix level when nothing changed (same -store path), so the cost is low and avoids rev-marker staleness — all agents always -need an update pass when any meta commit lands. +On startup, `auto_update.rs` rebuilds containers that actually need it. Two skip +rules keep boot-time work minimal: + +1. **Stopped containers** are deferred: the startup sweep enqueues nothing for them. + When the operator later starts a stopped container (via the dashboard or the + `start` MCP tool), both `run_start` (queue path) and `handle_start` (socket path) + check the rev marker first — if it's stale, the start is silently upgraded to a + full rebuild+start so the container runs current nix derivations. + +2. **Running containers with a matching rev marker** are skipped: if the per-agent + `.{name}.hyperhive-rev` file under `/var/lib/hyperhive/applied/` already holds + the current flake rev, no nix work is needed and the entry is omitted entirely. `auto_update::run` enqueues a single `StartupSweep` parent entry (`kind = startup_sweep`, `agent = "hyperhive"`) followed by per-agent `Rebuild` children -(`source = startup_sweep`, `parent_id = sweep_id`). The worker processes the parent -by bumping the meta `hyperhive` input lock, then transitions it to Done. The child +for the agents that do need rebuilding (`source = startup_sweep`, `parent_id = +sweep_id`). The sweep description records the rebuild / deferred / skipped counts +so the operator can see at a glance how much work the boot triggered. The child rebuilds drain sequentially through the queue; the dashboard renders them nested -under the parent so the operator can see the whole boot-time sweep in one group. +under the parent. -Before this change, each boot enqueued flat `Rebuild` entries with +Before the sweep-grouping change, each boot enqueued flat `Rebuild` entries with `source = AutoUpdate` and no parent — visible but ungrouped. ## Meta flake diff --git a/docs/gotchas.md b/docs/gotchas.md index 23181fa3..153df0fa 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -323,11 +323,10 @@ connects to the compositor at `127.0.0.1:`. ## 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). +`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every +hyperhive subsystem `mkForce false` so heavy build inputs stay out of +the eval) and `agentEval` (evaluates `agent-base.nix` fresh for the +per-agent options tree). Three output trees consumed by `flake.nix`, all **markdown**: @@ -356,3 +355,63 @@ Host options live entirely under `services.hyperhive.*`. 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 `

` headers. + +### Docs drv stability: `nixSrc` + stub overlay + +Naively, the docs evaluation depends on `self` (the flake's store path), +so every commit — even Rust-only or frontend-only changes — produces new +docs drv hashes. The remote builder must rebuild docs from scratch for +every PR branch, and if its store is full the build fails with a cached +failure that blocks CI for the whole branch. + +The fix (`nix/docs/default.nix`): + +1. **`nixSrc`** — `builtins.path` on the `nix/` directory, wrapped in + `builtins.unsafeDiscardStringContext` to strip `self`'s store-path + context. The resulting store path is content-addressed from the nix/ + file contents only. Docs drvs only change when a `.nix` file changes. + +2. **`docsStubOverlay`** — replaces `self.overlays.default` with stub + packages (`pkgs.emptyFile` / `pkgs.emptyDirectory`) for the docs eval. + `nixosOptionsDoc` renders `defaultText` for all package options anyway; + the stubs prevent attribute-missing eval errors without pulling in the + Rust or frontend build closure. + +3. Both `hostEval` and `agentEval` are evaluated from `nixSrc` paths + (not `self`), so the docs drv dependency chain ends at `nixSrc`. + +Why `builtins.unsafeDiscardStringContext`? The path string +`toString self + "/nix"` carries `self`'s string context, which would +make `builtins.path` include `self` as a build dependency even after +content-addressing the directory. Discarding the context makes the +resulting `nixSrc` truly independent of `self`'s store path. + +### `nix fmt` fails in a git worktree with "object not found" + +`nix fmt` (and any `nix` command that fetches a `git+file://` flake +URL) uses libgit2 internally to compute `revCount` — the number of +commits reachable from HEAD. This walk fails with: + +``` +error: getting Git object '': object not found (libgit2 error code = 9) +``` + +when a commit that was reachable at some earlier evaluation is now gone +(GC'd, rebased away, or pruned). The failure is persistent: clearing +`~/.cache/nix/{eval-cache-v6,gitv3,fetcher-cache-v4.sqlite}` does not +help because the missing object is a structural gap in the git object +graph itself, not in nix's caches. + +**Workaround: use a plain clone, not a git worktree.** + +```bash +git clone http:///hyperhive/hyperhive.git ~/hh-work +cd ~/hh-work && nix fmt +``` + +The root cause is specific to worktrees: a worktree shares the object +store with its parent repo. If the parent repo's history was rewritten +(rebase, force-push, `git gc --prune`) while the worktree was checked +out at a branch tip that references the pruned commits via its reflog or +history, libgit2's rev-walk encounters the gap. A plain clone has its +own self-consistent object store and is immune to the issue. diff --git a/docs/network.md b/docs/network.md index 3fad6e3e..e87e5802 100644 --- a/docs/network.md +++ b/docs/network.md @@ -1,31 +1,14 @@ # hive-network -Host-side bridge + per-agent DNS resolver — the foundation that -makes container netns isolation safe to land. Configured via -`services.hyperhive.network.*`; off by default during rollout. +Host-side bridge + per-agent private-netns isolation — always on +whenever hyperhive is enabled. Configured via +`services.hyperhive.network.*`. -## Why ship before netns isolation - -If netns isolation lands first, agent containers lose -`/etc/resolv.conf` propagation from the host and DNS breaks until a -separate resolver is up. Inverting the sequence — bridge + dnsmasq -first, netns flip second — makes the flag day boring: the resolver -endpoint is already live, agents just discover it via veth instead -of shared netns. - -## v1 vs v2 - -| feature | v1 (this PR) | v2 (after netns isolation) | -| ------------------------ | ------------------------------------------------------- | ----------------------------------------------- | -| bridge interface | created on host, no slave NICs | per-agent veth pairs attach | -| dnsmasq binding | bridge IP (reachable via host loopback in shared netns) | bridge IP (reachable via veth in private netns) | -| agent container netns | shared host | private | -| agent `/etc/resolv.conf` | unchanged (host DNS) | `nameserver ` | -| `address` rules target | `` (works in both modes) | unchanged from v1 | - -The `address` rules ship pointing at the bridge IP from v1 so the -DNS contract is fixed before any container actually depends on it -— minimises the things that flip on netns day. +> **Historical note:** the bridge and private-netns isolation landed in +> two separate phases. `services.hyperhive.network.enable` and +> `services.hyperhive.network.isolateContainers` are retained as +> deprecated no-op options so existing configs eval without change; both +> are ignored — isolation is the only mode. ## Container shape (where dnsmasq lives) @@ -33,9 +16,7 @@ Co-located in the existing `hive-gateway` container — single front-door for both DNS and HTTP, saves a sibling container, single systemd-unit / state surface to monitor. The gateway shares host netns (`privateNetwork = false`) so dnsmasq's `bind-interfaces` -listener on `bridgeIp` works without any veth gymnastics today; when -agent containers flip to private netns the binding doesn't change -(it's still on the host's bridge interface). +listener on `bridgeIp` is on the host's bridge interface. ## Configuration @@ -44,19 +25,14 @@ agent containers flip to private netns the binding doesn't change services.hyperhive = { enable = true; domain = "darkest.space"; - network.enable = true; # opt in to bridge + DNS - network.bridgeIp = "10.42.0.1"; # default - network.upstreamDns = [ # default Cloudflare + Quad9 - "1.1.1.1" - "9.9.9.9" - ]; + # network.bridgeIp = "10.42.0.1"; # default + # network.upstreamDns = [ "1.1.1.1" "9.9.9.9" ]; # default }; } ``` -Asserts `services.hyperhive.domain != null` (resolver needs a domain -to be authoritative for) + `services.hyperhive.gateway.enable = -true` (resolver lives in the gateway container). +Requires `services.hyperhive.domain` to be set — the dnsmasq resolver +is authoritative for `` and its sub-domains. ## Bridge addressing @@ -91,15 +67,14 @@ agent containers. ## Firewall posture `networking.firewall.interfaces..allowedUDPPorts = [ 53 ]` +`networking.firewall.interfaces..allowedTCPPorts = [ 53 80 443 ]` -- `allowedTCPPorts = [ 53 ]` opens the resolver on the bridge - interface only. Other interfaces stay closed. The hive resolver - isn't an external-facing service. - -When `isolateContainers = true`, `allowedTCPPorts` is extended with -`[ 80 443 ]` so isolated agents can reach nginx (gateway container, -shared host netns) for the forge sub-domain, per-agent UI proxies, -and any other HTTP services. +- Port 53 opens the resolver on the bridge interface only. Other + interfaces stay closed. The hive resolver isn't an external-facing + service. +- Ports 80 and 443 let isolated agents reach nginx (gateway + container, shared host netns) for the forge sub-domain, per-agent + UI proxies, and any other HTTP services. ### Reaching host services (`exposeHostPorts`) @@ -126,19 +101,16 @@ DNS/gateway), so only expose services safe for any agent to reach. ## Container isolation -`services.hyperhive.network.isolateContainers` (default `false`) flips -agent containers from shared host netns to private netns. Set only after -`enable = true` is stable in production — an assertion blocks the reverse. - -### What the nix side does when `isolateContainers = true` +Each agent container runs in a private network namespace with a dedicated +veth pair attached to the bridge. The following table summarises what +the nix side sets up unconditionally: | effect | mechanism | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` | | Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC | | Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks | -| Gateway access | `networking.firewall.interfaces..allowedTCPPorts = [ 80 443 ]` — lets isolated agents reach nginx on the host (shared netns) | -| Forge URL | `HIVE_FORGE_URL` flips from `http://127.0.0.1:3000` to `http://forge.` — agents resolve via dnsmasq, nginx proxies to forgejo | +| Gateway access | `networking.firewall.interfaces..allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host | | c0re signal | `HIVE_NETWORK_ISOLATION=1`, `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` | `HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g. @@ -148,7 +120,7 @@ address arithmetic. ### What the Rust side does -`hive-c0re` reads `HIVE_NETWORK_ISOLATION` and, when set, passes +`hive-c0re` reads `HIVE_NETWORK_ISOLATION` and passes `PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=`, `HOST_ADDRESS=`, and `HOST_BRIDGE=` via `lifecycle::set_nspawn_flags` when creating or updating containers. Each @@ -171,21 +143,17 @@ but no path off the bridge subnet (no internet, no `api.anthropic.com`). nixos-container copies the **host's** `/etc/resolv.conf` into the container at every start. The host resolver (e.g. `127.0.0.53` from systemd-resolved, or a LAN router) is unreachable from a private netns and isn't -authoritative for the hive's own zones, so it must be replaced with the -bridge dnsmasq (the gateway IP). Because the copy happens on every start, a +authoritative for the hive's own zones, so it is replaced with the +bridge dnsmasq at boot. Because the copy happens on every start, a declarative `environment.etc."resolv.conf"` would be clobbered — so the wiring is runtime: - `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the - gateway IP) into the container's `/etc` **only when isolated**, removing - it otherwise — so one shared container toplevel behaves correctly in both - netns modes. + gateway IP) into each container's `/etc`. - the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that marker, rewrites `/etc/resolv.conf` to `nameserver ` at boot. It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and - `tea-login` so the resolver is correct before the first DNS lookup; it's - an instant no-op in shared-netns mode (the marker is absent, so - `ConditionPathExists` skips it). + `tea-login` so the resolver is correct before the first DNS lookup. **Why isolation is safe**: all hive-c0re communication goes through unix domain sockets (`/run/hive/mcp.sock` for agent requests, @@ -200,19 +168,6 @@ against a compromised agent reaching the c0re dashboard HTTP at `127.0.0.1`). Agents have no legitimate reason to reach the dashboard over loopback — the hive-c0re admin socket is a UDS, not TCP. -### Prerequisites before flipping on - -- All agents must have `hyperhive.web.useUnixSocket = true`. Agents that - still bind TCP on `0.0.0.0:` will be reachable at their bridge IP - from other agents on the same subnet — defeating the isolation goal. The - gateway routes via unix sockets so gateway reach is unaffected. - -### Migration behaviour - -Containers are destroyed and re-created when the flag flips. Agent state -under `/agents//state/` is bind-mounted and survives; the container -rootfs is recreated cleanly from the nix store. - ## Cross-references - `docs/gateway.md` — vhost map + the gateway container's other duties diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index 32da78cb..d535cc3c 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -121,12 +121,9 @@ suffix, so the terminal degrades cleanly against older event shapes. `renderRichToolUse` (Write/Edit/send/ask/answer get custom renderings); on miss fall through to a flat `.tool-use` row with `fmtToolUse → fmtArgsGeneric`. - `fmtToolUse` surfaces the salient arg per built-in tool: - `recv` shows `wait s` / `max ` when set; `Bash` - flags `[bg]`; `remind` shows `+Xm "preview"`; matrix - tools show `→ /: "body"` or ` [limit]`; - scheduling tools show `#id · fields`; `fmtArgsGeneric` - handles anything else. + `fmtToolUse` surfaces the salient arg per built-in tool + (see [`fmtToolUse` patterns](#fmttooluse-patterns) below); + `fmtArgsGeneric` handles everything else. 4. `type == "user"` → walk `message.content[]` for `tool_result`; `renderToolResult` correlates via `tool_use_id → toolNameById` to default-open `recv` @@ -134,6 +131,62 @@ suffix, so the terminal degrades cleanly against older event shapes. long = collapsed details. 5. Unrecognised shape → `.sys` row (amber, `!` glyph). +### `fmtToolUse` patterns + +The `short` name strips the `mcp__hyperhive__` / `mcp__bash__` / +`mcp__matrix__` prefix and appends `*` (e.g. `recv*`, `run*`, +`send_message*`). Unprefixed tools (Read, Write, etc.) keep their +name as-is. + +| Tool | Rendered as | +|------|-------------| +| **Claude built-ins** | | +| `Read` | `Read ` | +| `Write` | rich diff row `Write · +N` | +| `Edit` | rich diff row `Edit · -N +N` | +| `Glob` | `Glob ` | +| `Grep` | `Grep ` | +| `Bash` | `Bash [bg] $ ` (also rich renderer for full body) | +| `TodoWrite` | `TodoWrite (N items)` | +| **Core hyperhive** | | +| `send*` | rich renderer: `send* → to · NL` (default-open body) | +| `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` | +| `ask*` | rich renderer: `ask* → to` (inline answer form for operator) | +| `answer*` | rich renderer: `answer* #id` | +| `remind*` | `remind* +Xm "preview"` or `remind* at HH:MMZ "preview"` | +| `set_status*` | `set_status* "text"` | +| `get_loose_ends*` | `get_loose_ends*()` or `get_loose_ends* [agent]` | +| `get_agent_meta*` | `get_agent_meta*()` or `get_agent_meta* name` | +| `cancel_loose_end*` | `cancel_loose_end* kind #id` | +| `ack_until*` | `ack_until* ≤N` | +| **Lifecycle** | | +| `kill*/restart*/start*/update*` | `kill* name` (etc.) | +| `get_logs*` | `get_logs* name` or `get_logs* name NL` | +| `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` | +| **Approvals / config** | | +| `request_apply_commit*` | `request_apply_commit* agent @ sha12` | +| `request_init_config*` | `request_init_config* name` | +| `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` | +| **Scheduling** | | +| `list_schedules*` | `list_schedules*()` | +| `cancel_schedule*` | `cancel_schedule* #id all` or `#id [t1, t2]` | +| `fire_schedule_now*` | `fire_schedule_now* #id` | +| `edit_schedule*` | `edit_schedule* #id · body · interval · next · +N tgt · -N tgt` (only changed fields shown) | +| `request_schedule_prompt*` | `request_schedule_prompt* → t1, t2 at HH:MMZ` (+ `+Ns` if recurring) | +| **Bash MCP** | | +| `run*` | `run* [bg] $ cmd` (also rich renderer for full cmd body) | +| `status*` (bash) | `status* id:xyz` or `status* id:xyz · wait Ns` | +| `kill*` (bash) | `kill* id` or `kill* id [force]` | +| **Matrix MCP** | | +| `send_message*/send_dm*/send_reply*` | `send_message* → room: "body"` / `send_dm* → @user: "body"` | +| `send_reaction*` | `send_reaction* room emoji` | +| `read_room*` | `read_room* room` or `read_room* room [N]` | +| `mark_read*` | `mark_read* room` | +| `join_room*/open_dm*` | `join_room* room` / `open_dm* @user` | +| `invite_user*` | `invite_user* @user → room` | +| `download_file*` | `download_file* room` | +| **Everything else** | `fmtArgsGeneric` — see [Extra-MCP tools](#extra-mcp-tools) | + ## Markdown `mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 6f2134f3..72d03ff0 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -14,9 +14,9 @@ invocation regardless of tool groups. Submit a shell command for background execution (runs via `bash`). Stdout and stderr stream to `harness/bash-tasks/.{out,err}`. When the task completes (or times out, or the process errors), the -harness fires a wake with `from: "bash-task-"` and the exit code - -- last stdout lines in the body; handle it on a future turn. +harness fires a wake with `from: "bash-task-"`; the body contains +the exit code and last stdout lines. Handle the completion on a future +turn. * `timeout_secs` — kill the task after N seconds and mark it `timed_out`. Omit for no timeout (runs until natural exit). diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 605182da..1245239a 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -178,9 +178,20 @@ plain comment show under `last comment`, not `reviews`. ### Repo management -Three verbs create repos and manage their membership — the supported -path for workflows that need a fresh repo (agents cannot push to -non-existent repos; the Forgejo instance disables push-to-create). +Agents **cannot create repos directly via forge token** — Forgejo +disables push-to-create and the agent token doesn't have the Create +scope. Two paths exist depending on where the repo should live: + +**Agent repos (`agents/`)** — Use the `mcp__hyperhive__create_repo` +MCP tool (requires the `forge` tool group). hive-c0re creates the repo in +the c0re-owned `agents/` org, adds you as a write collaborator (not +owner), and enables branch protection (operator-team merge approval +required — you cannot self-merge). Clone URL is returned immediately. +This is the standard path for agents that need a working repo. + +**Other repos** — Use the CLI verbs below (`repo-create` / `repo-add-collaborator`). +These use the agent's own forge token so the repo lands under the agent's +user account or an org the agent belongs to. **`repo-create `** — create a repo under the authenticated user and print its URL. Key flags: @@ -195,8 +206,7 @@ and print its URL. Key flags: active repo (`-r`/`HIVE_FORGE_REPO`). Companion to `repo-create`. The `--permission` flag accepts `read` / `write` (default) / `admin`. `hive-c0re` uses this internally when an agent's config repo is -initialised (`create_repo` → `repo-add-collaborator` → agent gets -write access). +initialised. **`repo-labels [PATTERN]`** — list every label defined on the repo, optionally filtered by a name substring (case-sensitive). Distinct from diff --git a/docs/tools/matrix.md b/docs/tools/matrix.md index bdf3f7fe..1c43bc02 100644 --- a/docs/tools/matrix.md +++ b/docs/tools/matrix.md @@ -17,6 +17,11 @@ room you haven't read yet. accepts `!id:server` or `#alias:server`, daemon resolves either - `send_dm(user_id, body)` — open (or reuse) the DM room with `user_id` and post `body` to it +- `open_dm(user_id)` — resolve (find-or-create) the DM room with + `user_id` and return its room id **without sending anything**. + Use the returned id with room-based tools (`send_message`, + `send_file`, …) to deliver into a DM when `send_dm`'s body + parameter is inconvenient (e.g. for file attachments) - `send_reply(room, event_id, body)` — threaded reply to a specific event - `send_reaction(room, event_id, key)` — react to a message with an diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 04f4a15e..d6e987b7 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -616,6 +616,17 @@ at_unix_timestamp?)`, `request_next_turn()`. - **Scheduling + diagnostics** (`scheduling`, `diagnostics`) — scheduled prompts, `get_logs`. See [`docs/tools/scheduling.md`](tools/scheduling.md). +- **Forge repos** (`forge`) — `create_repo` — the only agent path to + create a repo under the `agents/` org (direct forge token creation is + disabled for agents). The repo is created in the c0re-owned `agents` + org; the calling agent gets write collaborator access; the default + branch is branch-protected (operator-team must approve merges, so the + agent cannot self-merge). Opt-in; not in any default preset. + See [`docs/tools/forge.md — Repo management`](tools/forge.md). +- **Web egress** (`web_tools`) — enables Claude's built-in `WebFetch` + and `WebSearch` tools (not MCP tools; added directly to the + `--allowedTools` list). Off by default; add the group in the + P3RM1SS10NS tab and rebuild to enable. - **Capability-gated** — `get_host_journal` (requires `read_host_journal` capability set via the P3RM1SS10NS tab; orthogonal to tool groups). Full list of capabilities and their diff --git a/docs/web-ui.md b/docs/web-ui.md index e1f40c5a..07ac9e7b 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -37,7 +37,9 @@ This doc has been split for readability. Pick the section you need: [`web-ui/dashboard.md`](web-ui/dashboard.md) (Dashboard endpoints, Dashboard event channel). - **"How does the per-agent terminal render tool calls?"** → - [`web-ui/agent.md`](web-ui/agent.md) (Live view). + [`terminal-rendering.md`](terminal-rendering.md) (full row + taxonomy + dispatch walkthrough); for a high-level summary see + [`web-ui/agent.md`](web-ui/agent.md) (Per-stream rendering). - **"What slash commands does the agent accept?"** → [`web-ui/agent.md`](web-ui/agent.md) (Terminal-embedded prompt). - **"What are the per-agent HTTP endpoints?"** → diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 24d7f420..84c5b6fa 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -96,8 +96,8 @@ through. Three flex columns: closes the menu without an extra POST. A second separator + **effort quick-picker** section labelled `effort` appears when the backend declares effort levels in - `state.available_efforts`. One button per level (e.g. `medium`, - `high`, `xhigh`); clicking POSTs `/api/effort` (same endpoint as + `state.available_efforts`. One button per level (e.g. `low`, + `medium`, `high`, `xhigh`, `max`); clicking POSTs `/api/effort` (same endpoint as the `/effort ` slash command). The active level's button is highlighted; `renderEffortChip` keeps the picker in sync with `StateSnapshot.effort` from the cold-load snapshot. @@ -219,7 +219,8 @@ SSE frame and the replayed history rows. The web UI: - terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter blur, row fade-in slide-up. -Per-stream rendering: +Per-stream rendering (see [`docs/terminal-rendering.md`](../terminal-rendering.md) for +the full row taxonomy and dispatch logic): - `Stream` `tool_use` → - `Write` / `Edit`: collapsed `
` with a +/- diff body @@ -227,13 +228,18 @@ Per-stream rendering: `input.new_string` or every line of `input.content`). Summary carries the path + line counts. - others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send → - operator: "..."`, etc.): flat one-line per-tool format. + operator: "..."`, etc.): flat one-line per-tool format with + per-tool salient-arg extraction (`fmtToolUse`). - `Stream` `tool_result` short → flat `← ...`; long → collapsed `
` `▸ ← Nl · headline` (click to expand full body). -- `Stream` `thinking` → text content if claude provided one, - otherwise the bare `· thinking …` indicator. -- `Stream` `system init`, `result`, `rate_limit_event` are - dropped — too noisy. +- `Stream` `thinking` → `.thinking` row with a `💭 thinking …` + indicator. +- `Stream` `system` → handled by subtype: `plugin_install` and + `compact_boundary` emit muted notes; `commands_changed` emits an + expandable details row listing slash commands; `thinking_tokens` + updates a single in-place `🧠` counter; `init`, `result`, and + `rate_limit_event` are dropped (noise / used elsewhere); other + subtypes → muted `⚙ ` note. - `Note` → `· text`. - `TurnStart` → `◆ TURN ← ` with the wake-prompt body; a muted `· HH:MM:SS` time suffix from the event `ts`. @@ -263,7 +269,7 @@ Slash commands today: `/state/hyperhive-model` so the override survives harness restart / rebuild. - `/effort ` — `POST /api/effort` setting the claude effort - level (`medium` / `high` / `xhigh`). Takes effect on the next + level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next turn. The overflow menu surfaces an effort picker that calls the same endpoint; both stay in sync via `StateSnapshot.effort`. - `/new-session` — `POST /api/new-session` (confirms first). @@ -304,7 +310,7 @@ shaped). future turns. `Bus::set_model` emits `ModelChanged`. - `POST /api/effort` (`effort=`) — switch the claude effort level for future sessions. Validated server-side against - `EFFORT_LEVELS` (`medium`/`high`/`xhigh`) — unknown values are + `EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are rejected rather than forwarded to `claude --effort`. Persists via `Bus::set_effort`, which emits `EffortChanged`. Applies on the next session start (no mid-session swap). diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 63916293..190e3cc1 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1626,6 +1626,40 @@ window.marked = marked; } case 'mcp__hyperhive__request_apply_commit': return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); + case 'mcp__hyperhive__request_init_config': + return short + ' ' + (input.name || '?'); + case 'mcp__hyperhive__request_update_meta_inputs': { + const ins = Array.isArray(input.inputs) && input.inputs.length + ? '[' + input.inputs.slice(0, 4).join(', ') + + (input.inputs.length > 4 ? ', …' : '') + ']' + : 'all'; + return short + ' ' + ins; + } + case 'mcp__hyperhive__list_schedules': + return short + '()'; + case 'mcp__hyperhive__cancel_schedule': + return short + ' #' + (input.id != null ? input.id : '?') + + (Array.isArray(input.targets) && input.targets.length + ? ' [' + input.targets.join(', ') + ']' : ' all'); + case 'mcp__hyperhive__fire_schedule_now': + return short + ' #' + (input.id != null ? input.id : '?'); + case 'mcp__hyperhive__edit_schedule': { + const parts = ['#' + (input.id != null ? input.id : '?')]; + if (input.body != null) parts.push('body'); + if (input.interval_seconds != null) parts.push('interval'); + if (input.next_fire_at_unix != null) parts.push('next'); + if (input.targets_add && input.targets_add.length) parts.push('+' + input.targets_add.length + ' tgt'); + if (input.targets_remove && input.targets_remove.length) parts.push('-' + input.targets_remove.length + ' tgt'); + return short + ' ' + parts.join(' · '); + } + case 'mcp__hyperhive__request_schedule_prompt': { + const tgts = Array.isArray(input.targets) ? input.targets : []; + const when = input.first_fire_at_unix != null + ? new Date(input.first_fire_at_unix * 1000).toISOString().slice(11, 16) + 'Z' + : '?'; + return short + ' → ' + (tgts.length ? tgts.join(', ') : '?') + ' at ' + when + + (input.interval_seconds != null ? ' +' + input.interval_seconds + 's' : ''); + } case 'mcp__bash__run': { // Rich renderer handles the full body; this summary covers any // fallback path and the details summary line. diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index e69a9cff..c6fb2d46 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -583,7 +583,7 @@ pub const DEFAULT_EFFORT: &str = "medium"; /// Valid claude `--effort` levels, ascending. The operator picker is /// constrained to these; [`is_valid_effort`] guards the persist path. -pub const EFFORT_LEVELS: [&str; 3] = ["medium", "high", "xhigh"]; +pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; /// True iff `level` is one of [`EFFORT_LEVELS`]. #[must_use] @@ -1271,7 +1271,7 @@ mod tests { } // Out-of-set, empty, and wrong-case inputs are rejected so a bad // picker POST never reaches `claude --effort`. - for bad in ["low", "", "MEDIUM", "ultra", "high "] { + for bad in ["lowest", "", "MEDIUM", "ultra", "high "] { assert!(!is_valid_effort(bad), "{bad:?} should be rejected"); } } diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index fc78cf90..95af42f2 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -2182,21 +2182,14 @@ const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; /// Enforce the per-agent send allow-list. Returns `Ok` when the -/// recipient is permitted (no list configured, manager always -/// allowed, or `to` is in the list); returns `Err(refusal)` with a -/// claude-readable string when blocked — the harness surfaces the -/// refusal as the tool result so claude knows the message didn't -/// land and can react (e.g. route via the manager instead). +/// recipient is permitted (no list configured, `` sentinel +/// always allowed, or `to` is in the list); returns `Err(refusal)` +/// with a claude-readable string when blocked ��� the harness surfaces +/// the refusal as the tool result so claude knows the message didn't +/// land and can react (e.g. route via `` instead). fn check_send_allowed(to: &str) -> Result<(), String> { - if to == hive_sh4re::MANAGER_AGENT { - // Always allow agents to talk to the manager — otherwise a - // misconfigured allow-list could leave a sub-agent unable - // to ask for help. - return Ok(()); - } if to == hive_sh4re::PARENT_RECIPIENT { - // Always allow `` — same escape-hatch rationale as - // the manager exception. The allow-list constrains peer + // Always allow `` — the allow-list constrains peer // chatter, not the structural reporting line; the operator // can rewire who the parent IS via `set_parent` without // having to remember to update the per-agent allow-list. diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 694d6dba..1925ea90 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -461,7 +461,7 @@ struct StateSnapshot { /// runtime via `POST /api/effort`; applies on the next session. effort: String, /// Selectable effort levels for the picker, ascending. Fixed set - /// (`medium`, `high`, `xhigh`) — sourced from + /// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from /// [`crate::events::EFFORT_LEVELS`], not operator-configurable like /// `available_models`. The frontend renders one button per entry. available_efforts: Vec, diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 40d81b6e..d17a1887 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -1,8 +1,13 @@ -//! Startup auto-update: on `hive-c0re serve` boot, rebuild every known -//! container unconditionally. `nixos-container update` is a no-op at the -//! nix level when nothing changed (same store path), so the cost is low -//! and avoids rev-marker staleness (all agents always need an update pass -//! when any meta commit lands). See `docs/coordinator.md::Auto-update sweep`. +//! Startup auto-update: on `hive-c0re serve` boot, rebuild containers that +//! actually need it. Two skip rules keep boot-time work minimal: +//! +//! 1. **Stopped containers** are deferred — they will be rebuilt the first +//! time the operator starts them (see `rebuild_queue::run_start` and +//! `socket_server::handle_start`). +//! 2. **Running containers whose rev marker matches** the current hyperhive +//! flake path are skipped — nothing changed, no nix work to do. +//! +//! See `docs/coordinator.md::Auto-update sweep`. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -322,11 +327,13 @@ pub fn topology_sort( }); } -/// Rebuild every container on startup. Enqueues a `StartupSweep` parent -/// entry (agent = `"hyperhive"`) followed by per-agent `Rebuild` children -/// linked via `parent_id`. The dashboard renders them nested so the operator -/// can see at a glance "boot N agents, here is each rebuild's status". -/// Returns Ok even if some rebuilds failed. +/// Rebuild containers that need it on startup. Skips: +/// - **Stopped containers**: deferred to on-start (`run_start` / `handle_start` +/// upgrades a plain start to rebuild+start when the rev marker is stale). +/// - **Running containers with a matching rev marker**: no nix work needed. +/// +/// Enqueues a `StartupSweep` parent entry followed by per-agent `Rebuild` +/// children linked via `parent_id`. Returns Ok even if some rebuilds failed. pub async fn run(coord: Arc) -> Result<()> { let containers = match lifecycle::list().await { Ok(c) => c, @@ -336,22 +343,7 @@ pub async fn run(coord: Arc) -> Result<()> { } }; - // Enqueue the parent sweep entry. The worker processes it trivially - // (no-op dispatch) so it completes quickly; its purpose is to give the - // dashboard a "why" header for the per-agent child rebuilds below. - let sweep_id = coord.rebuild_queue.enqueue( - crate::rebuild_queue::QueueKind::StartupSweep, - "hyperhive".to_owned(), - crate::rebuild_queue::QueueSource::AutoUpdate, - format!("startup sweep ({} containers)", containers.len()), - None, - ); - - tracing::info!( - agents = containers.len(), - sweep_id, - "auto-update: queueing all on startup" - ); + let current_rev = current_flake_rev(&coord.hyperhive_flake); // Resolve container names to logical agent names, then sort by // topology depth so parents are always rebuilt before their @@ -363,7 +355,56 @@ pub async fn run(coord: Arc) -> Result<()> { .collect(); let topo = crate::topology::read(); topology_sort(&mut logical_names, &topo); - for name in logical_names { + + // Pre-classify: decide which agents need a rebuild now vs can be skipped. + let mut to_rebuild: Vec = Vec::new(); + let mut n_deferred = 0usize; + let mut n_skipped = 0usize; + for name in &logical_names { + // Idea 2: stopped containers are deferred — rebuild happens the first + // time the operator starts them. + if !lifecycle::is_running(name).await { + n_deferred += 1; + tracing::debug!(%name, "startup sweep: stopped — deferring rebuild to on-start"); + continue; + } + // Idea 1: running containers with a matching rev marker need no rebuild. + if let Some(ref rev) = current_rev { + let stored = std::fs::read_to_string(rev_marker_path(name)).ok(); + if stored.as_deref() == Some(rev.as_str()) { + n_skipped += 1; + tracing::debug!(%name, "startup sweep: rev unchanged — skipping rebuild"); + continue; + } + } + to_rebuild.push(name.clone()); + } + + // Enqueue the parent sweep entry. The worker processes it trivially + // (no-op dispatch); its purpose is to give the dashboard a "why" header. + let sweep_id = coord.rebuild_queue.enqueue( + crate::rebuild_queue::QueueKind::StartupSweep, + "hyperhive".to_owned(), + crate::rebuild_queue::QueueSource::AutoUpdate, + format!( + "startup sweep: {} rebuild(s), {} deferred (stopped), {} skipped (up-to-date)", + to_rebuild.len(), + n_deferred, + n_skipped, + ), + None, + ); + + tracing::info!( + total = containers.len(), + rebuilds = to_rebuild.len(), + deferred = n_deferred, + skipped = n_skipped, + sweep_id, + "auto-update: startup sweep" + ); + + for name in to_rebuild { coord.rebuild_queue.enqueue( crate::rebuild_queue::QueueKind::Rebuild, name, diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 74353bc8..d4789852 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1681,7 +1681,7 @@ async fn post_request_spawn( hive_sh4re::ApprovalKind::Spawn, "", None, - hive_sh4re::MANAGER_AGENT, + "operator", ) { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index 46f7e597..bdecc232 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -16,7 +16,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use hive_sh4re::{LooseEnd, MANAGER_AGENT}; +use hive_sh4re::LooseEnd; use crate::coordinator::Coordinator; @@ -56,12 +56,12 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { } // Show each pending approval to the agent that submitted it. The // submitter column is NULL for rows predating it; those count as - // the root agent's. + // operator-initiated (no agent tracking predates the column). for a in coord.approvals.pending()? { let submitter = coord .approvals .submitter_of(a.id)? - .unwrap_or_else(|| MANAGER_AGENT.to_owned()); + .unwrap_or_else(|| "operator".to_owned()); if submitter != agent { continue; } diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index 2d99dfd8..ca388839 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -203,13 +203,14 @@ pub fn handle_cancel_loose_end( // may withdraw it. Without the ownership check, any // approvals-group agent could cancel any other's approval by // id. A NULL submitter (legacy row predating the column) is - // owned by the root agent. + // treated as operator-initiated (no agent tracking predates + // the column). check_can_cancel_approval(canceller)?; let submitter = coord .approvals .submitter_of(id) .map_err(|e| format!("{e:#}"))? - .unwrap_or_else(|| hive_sh4re::MANAGER_AGENT.to_owned()); + .unwrap_or_else(|| "operator".to_owned()); if submitter != canceller { return Err(format!( "cancel_loose_end: approval {id} was submitted by {submitter}, \ diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 8482a838..b6853698 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -1034,11 +1034,33 @@ async fn rebuild_for_entry( /// Uses the cold-start fallback (stop + kill + start retry) so the /// deferred start-after-rebuild keeps the same activation-error recovery /// it had when it ran inline on the build lane. +/// +/// If the hyperhive flake rev has changed since the container was last built +/// (i.e. the rev marker is stale or missing), the start is upgraded to a full +/// rebuild so the container runs current nix derivations. This is the +/// "deferred stopped container" path from `auto_update::run`. async fn run_start( coord: &std::sync::Arc, entry: &QueueEntry, ) -> anyhow::Result<()> { let name = &entry.agent; + // Upgrade to rebuild+start if the rev marker is stale. + let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake); + if let Some(ref rev) = current_rev { + let stored = std::fs::read_to_string(crate::auto_update::rev_marker_path(name)).ok(); + if stored.as_deref() != Some(rev.as_str()) { + tracing::info!(%name, "start: rev stale — upgrading to rebuild+start"); + return crate::auto_update::rebuild_agent( + coord, + name, + rev, + Some(entry.id), + true, + Some(entry.source), + ) + .await; + } + } let _guard = coord.transient_guard(name, crate::coordinator::TransientKind::Starting); coord.set_queue_step(Some(entry.id), "nixos-container start"); crate::lifecycle::start_with_fallback(name).await?; diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index a2c63a82..a711bf6b 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -85,7 +85,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { hive_sh4re::ApprovalKind::Spawn, "", None, - hive_sh4re::MANAGER_AGENT, + "operator", )?; tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() diff --git a/hive-c0re/src/socket_server.rs b/hive-c0re/src/socket_server.rs index d44b69d5..a839f86c 100644 --- a/hive-c0re/src/socket_server.rs +++ b/hive-c0re/src/socket_server.rs @@ -803,6 +803,25 @@ async fn handle_start(coord: &Arc, agent: &str, name: &str) -> Agen return err; } tracing::info!(%agent, %name, "start container"); + // If the hyperhive rev is stale, route through the rebuild queue so the + // container runs current nix derivations before it starts. Same logic as + // `run_start`; this covers the MCP `start` tool path. + let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake); + if let Some(ref rev) = current_rev { + let stored = std::fs::read_to_string(crate::auto_update::rev_marker_path(name)).ok(); + if stored.as_deref() != Some(rev.as_str()) { + tracing::info!(%agent, %name, "start: rev stale — enqueuing rebuild"); + coord.rebuild_queue.enqueue( + crate::rebuild_queue::QueueKind::Rebuild, + name.to_owned(), + crate::rebuild_queue::QueueSource::Manual, + format!("start {name}: rev stale — rebuilding first"), + None, + ); + coord.emit_rebuild_queue_snapshot(); + return AgentResponse::Ok; + } + } match crate::lifecycle::start(name).await { Ok(()) => { coord.kick_agent(name, "container started"); @@ -1828,8 +1847,8 @@ pub(crate) fn submit_init_config( description.as_deref(), // `parent` is the requesting agent (becomes the new child's // parent); it's also the submitter the approval events route - // back to. No declared parent = operator/root path. - parent.unwrap_or(hive_sh4re::MANAGER_AGENT), + // back to. No declared parent = operator-initiated path. + parent.unwrap_or("operator"), ) .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; tracing::info!(%id, %name, "init_config approval queued"); diff --git a/nix/docs/default.nix b/nix/docs/default.nix index 62e618d8..e03df075 100644 --- a/nix/docs/default.nix +++ b/nix/docs/default.nix @@ -11,17 +11,50 @@ # prose `/docs/` tree. Full pipeline + subtree-pick / output-tree # rationale: docs/gotchas.md::Nix options reference. let + # Content-addressed narrow source covering only the nix/ directory. + # `builtins.unsafeDiscardStringContext` strips `self`'s store-path + # context so `builtins.path` hashes only the nix/ file content, not + # the full flake source (Rust, frontend, markdown, …). Result: the + # docs drvs only change when a .nix file changes, not on every + # commit — the muede-pc2 remote builder can reuse its cached result + # for any commit that doesn't touch nix/. + nixSrc = builtins.path { + path = builtins.unsafeDiscardStringContext (toString self + "/nix"); + name = "hyperhive-nix-src"; + }; + + # Stub overlay that satisfies pkgs.hyperhive-* references in module + # option defaults without depending on self's Rust / frontend builds. + # nixosOptionsDoc renders `defaultText` for these options anyway; the + # stubs just prevent attribute-missing eval errors. + docsStubOverlay = _final: _prev: { + hyperhive = pkgs.emptyFile; + hyperhive-frontend = pkgs.emptyDirectory; + hyperhive-assets = pkgs.emptyDirectory; + hyperhive-docs = pkgs.emptyDirectory; + }; + # 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. + # Import hive-c0re.nix from the content-addressed nixSrc with stub + # package args so the eval doesn't depend on self's Rust builds. hostEval = nixosSystem { system = pkgs.stdenv.hostPlatform.system; modules = [ - self.nixosModules.default + (import "${nixSrc}/modules/hive-c0re.nix" { + hyperhivePackage = _system: pkgs.emptyFile; + hyperhiveFrontend = _system: pkgs.emptyFile; + hyperhiveAssets = _system: pkgs.emptyDirectory; + hyperhiveFlake = ""; + hyperhiveDocs = ""; + agentBaseToplevel = pkgs.emptyFile; + managerToplevel = pkgs.emptyFile; + }) ( { lib, ... }: { - nixpkgs.overlays = [ self.overlays.default ]; + nixpkgs.overlays = [ docsStubOverlay ]; fileSystems."/" = { device = "/dev/null"; fsType = "tmpfs"; @@ -35,14 +68,23 @@ let ]; }; - # 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; + # Agent module eval from the content-addressed nixSrc. Relative + # imports inside agent-base.nix (e.g. ./harness-base.nix) resolve + # correctly against the nixSrc directory tree. + agentEval = nixosSystem { + system = pkgs.stdenv.hostPlatform.system; + modules = [ + "${nixSrc}/templates/agent-base.nix" + { nixpkgs.overlays = [ docsStubOverlay ]; } + ]; + }; # Rewrite option declaration paths from nix-store absolute paths to # forge URLs so rendered docs link back to source. + # nixSrc is a content-addressed copy of nix/; strip its store prefix + # and prepend nix/ to recover the repo-relative path. forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main"; - storePrefix = toString self + "/"; + nixSrcPrefix = builtins.unsafeDiscardStringContext (toString nixSrc + "/"); transformOptions = opt: opt @@ -52,8 +94,8 @@ let let declStr = toString decl; relPath = - if lib.hasPrefix storePrefix declStr then - lib.removePrefix storePrefix declStr + if lib.hasPrefix nixSrcPrefix declStr then + "nix/" + lib.removePrefix nixSrcPrefix declStr else baseNameOf declStr; in @@ -136,7 +178,7 @@ let `hyperhive.forge.*`, `hyperhive.matrix.*`, `hyperhive.gui.*`). Regenerate with `nix build .#docs` (bundle), `.#docs-host`, or - `.#docs-agent`. + `.#docs-agent`. Consumed by the website repo to render `/options/`. ''; hostMD = mkMarkdownPage "docs-host" "hyperhive — host options" hostDoc; diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index ea719720..451a5a46 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -795,10 +795,9 @@ in managerToplevel ]; - # Unprivileged coordinator user. hive-c0re runs as this user - # (privsep phase 2); privileged operations are delegated to - # hive-priv which runs as root, socket-activated at - # /run/hive/priv.sock. + # Unprivileged coordinator user. hive-c0re runs as this user; + # privileged operations are delegated to hive-priv which runs as + # root, socket-activated at /run/hive/priv.sock. users.users.hive-core = { isSystemUser = true; group = "hive-core"; diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index dd0310af..c494b1ae 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -423,7 +423,7 @@ in # # /run/hive-agent — per-agent UDS socket dir, written by c0re's # set_nspawn_flags when agents start. Owned by `hive-core` (the - # unprivileged coordinator user, privsep phase 2): c0re does the + # unprivileged coordinator user): c0re does the # `create_dir_all(/run/hive-agent/)` itself, so a root-owned # parent would EACCES on the very first agent create on a fresh host # (hive-priv only chowns the subdir afterwards, it doesn't make it). @@ -949,7 +949,6 @@ in # container. Listens on the bridge interface from # `services.hyperhive.network`; authoritative for the hive # domain + sub-domains, forwards everything else upstream. - # No-op when `network.enable = false`. services.dnsmasq = lib.mkIf networkCfg.enable { enable = true; # Don't substitute the container's /etc/resolv.conf — diff --git a/nix/modules/hive-network.nix b/nix/modules/hive-network.nix index 8c66f8e8..0dcca3d8 100644 --- a/nix/modules/hive-network.nix +++ b/nix/modules/hive-network.nix @@ -8,9 +8,8 @@ let in { # Hive-internal network — host-side bridge + per-agent DNS resolver. - # Containers stay on shared host netns at v1; this module stands the - # bridge + resolver up so the endpoint is in place before network - # isolation flips containers to private netns. Full design: docs/network.md. + # Always active when hyperhive is enabled: agent containers run in + # private netns behind the bridge. Full design: docs/network.md. options.services.hyperhive.network = { enable = lib.mkOption { diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 0629116b..1610fa6f 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -219,9 +219,11 @@ in options.hyperhive.effortLevel = lib.mkOption { type = lib.types.enum [ + "low" "medium" "high" "xhigh" + "max" ]; default = "medium"; example = "high"; @@ -231,12 +233,12 @@ in effort as operator-override-file → this env → built-in `"medium"`, and passes the result to `claude --effort` at turn launch. - `"medium"` (the default) keeps token spend low and works well with - the harness across families. `"high"` matches the current platform - default; `"xhigh"` is recommended for coding / high-autonomy work on - capable models (Opus 4.8+) at higher token cost. The operator can - override at runtime per-agent via the web UI (applied on the next - session); any rebuild that changes this option resets that override. + Ascending scale: `"low"` (minimal thinking budget), `"medium"` + (default — balanced), `"high"` (platform default), `"xhigh"` + (recommended for autonomous coding on capable models), `"max"` + (maximum thinking budget, highest cost). The operator can override + at runtime per-agent via the web UI (applied on the next session); + any rebuild that changes this option resets that override. ''; }; diff --git a/scripts/pre-push b/scripts/pre-push new file mode 100755 index 00000000..3b6ea478 --- /dev/null +++ b/scripts/pre-push @@ -0,0 +1,32 @@ +#!/bin/sh +# Git pre-push hook: runs the tracker-tag lint and the comment-block +# lint against the working tree before any push lands on the remote. +# Catches issues that would fail CI and require a follow-up commit +# (common failure mode: a nix comment containing a hash-issue-number +# tag added mid-session). +# +# Install (once per clone): +# ln -sf ../../scripts/pre-push .git/hooks/pre-push +# +# The hook runs against the full working tree (not just staged or +# pushed files) to match what CI sees: `nix flake check` builds from +# the committed tree, so an untracked hit on a staged file would still +# trip CI. +set -eu + +repo_root="$(git rev-parse --show-toplevel)" + +echo "pre-push: running tracker-tag lint..." >&2 +if ! sh "$repo_root/scripts/check-issue-refs.sh"; then + echo "pre-push: tracker-tag lint FAILED — fix before pushing" >&2 + exit 1 +fi + +echo "pre-push: running comment-block lint..." >&2 +if ! sh "$repo_root/scripts/check-comment-blocks.sh"; then + echo "pre-push: comment-block lint FAILED — fix before pushing" >&2 + exit 1 +fi + +echo "pre-push: lints passed ✓" >&2 +exit 0