Compare commits

..
30 changed files with 200 additions and 541 deletions

View file

@ -129,6 +129,3 @@ The docs below own the details — this section just points at them.
window:** → [`docs/turn-loop.md`](docs/turn-loop.md). window:** → [`docs/turn-loop.md`](docs/turn-loop.md).
- **Two-step spawn, approval flow, flake.lock validation:** - **Two-step spawn, approval flow, flake.lock validation:**
[`docs/approvals.md`](docs/approvals.md). [`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`

View file

@ -5,12 +5,11 @@ _implementation_ work — container network isolation, the unifying
gateway, core-daemon privsep — is tracked as `area:ops` issues on gateway, core-daemon privsep — is tracked as `area:ops` issues on
the forge. the forge.
The operator/agent boundary is now technically enforced, not just a Today "the operator surface" and "the agent surface" are a
convention. Containers run in private netns (network isolation is _convention_, not a boundary — nothing stops a container from
always on), the gateway proxies all operator-facing traffic, and curling the core daemon on `localhost:<port>`, or another agent's
`hive-c0re` runs as the unprivileged `hive-core` user. All three web UI. Network isolation, the gateway, and privsep together turn
`area:ops` pillars — network isolation, the gateway, and privsep — that convention into an enforced boundary.
are complete and active.
## Two principals, two paths ## Two principals, two paths
@ -40,17 +39,14 @@ agent page).
## Why network isolation is the load-bearing step ## Why network isolation is the load-bearing step
Without network isolation, containers share the host network namespace Containers currently share the host network namespace, so a
and can reach `localhost:<core-port>`, the dashboard, and every other container can reach `localhost:<core-port>`, the dashboard, and
agent's web port — the operator/agent split is on the honour system and every other agent's web port. Until that changes, the
every boundary claim above is aspirational. Network isolation is what operator/agent split is on the honour system — every boundary
makes the boundary _real_; the gateway and privsep are ergonomics and 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. 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: The `area:ops` issues followed this sequencing:
1. **Gateway** — pure ergonomics win, unblocks same-origin (lets the 1. **Gateway** — pure ergonomics win, unblocks same-origin (lets the
@ -58,8 +54,7 @@ The `area:ops` issues followed this sequencing:
behavioural risk. An nginx nixos-container now sits in front of all behavioural risk. An nginx nixos-container now sits in front of all
surfaces; per-agent UIs are proxied under `/agent/<name>/`. surfaces; per-agent UIs are proxied under `/agent/<name>/`.
2. **Network isolation** — the load-bearing step that turns the 2. **Network isolation** — the load-bearing step that turns the
honour-system split into an enforced boundary. **Complete** honour-system split into an enforced boundary. In progress.
always-on, unconditional; the shared-netns mode was removed.
3. **Privsep** — defence in depth on the core process; `hive-c0re` 3. **Privsep** — defence in depth on the core process; `hive-c0re`
runs as the unprivileged `hive-core` user and delegates root runs as the unprivileged `hive-core` user and delegates root
operations to `hive-priv`, a narrow socket-activated helper. See operations to `hive-priv`, a narrow socket-activated helper. See

View file

@ -1,46 +1,6 @@
# hive-ci: Forgejo Actions Runner # hive-ci: Forgejo Actions Runner
The `hive-ci` module runs a Forgejo Actions runner in a `hive-ci` nixos-container, 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).
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 ## Operator bootstrap

View file

@ -117,28 +117,19 @@ render.
## Auto-update sweep ## Auto-update sweep
On startup, `auto_update.rs` rebuilds containers that actually need it. Two skip On startup, `auto_update.rs` rebuilds every known container unconditionally.
rules keep boot-time work minimal: `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
1. **Stopped containers** are deferred: the startup sweep enqueues nothing for them. need an update pass when any meta commit lands.
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 = `auto_update::run` enqueues a single `StartupSweep` parent entry (`kind =
startup_sweep`, `agent = "hyperhive"`) followed by per-agent `Rebuild` children startup_sweep`, `agent = "hyperhive"`) followed by per-agent `Rebuild` children
for the agents that do need rebuilding (`source = startup_sweep`, `parent_id = (`source = startup_sweep`, `parent_id = sweep_id`). The worker processes the parent
sweep_id`). The sweep description records the rebuild / deferred / skipped counts by bumping the meta `hyperhive` input lock, then transitions it to Done. The child
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 rebuilds drain sequentially through the queue; the dashboard renders them nested
under the parent. under the parent so the operator can see the whole boot-time sweep in one group.
Before the sweep-grouping change, each boot enqueued flat `Rebuild` entries with Before this change, each boot enqueued flat `Rebuild` entries with
`source = AutoUpdate` and no parent — visible but ungrouped. `source = AutoUpdate` and no parent — visible but ungrouped.
## Meta flake ## Meta flake

View file

@ -323,10 +323,11 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
## Nix options reference (`nix/docs/default.nix`) ## Nix options reference (`nix/docs/default.nix`)
`pkgs.nixosOptionsDoc` over two evaluated module trees: `pkgs.nixosOptionsDoc` over two evaluated module trees:
`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every `hostEval` (a stub NixOS system loading `self.nixosModules.default`
hyperhive subsystem `mkForce false` so heavy build inputs stay out of with every hyperhive subsystem `mkForce false` so heavy build
the eval) and `agentEval` (evaluates `agent-base.nix` fresh for the inputs stay out of the eval) and `agentEval` (reuses the already-evaluated
per-agent options tree). `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`, all **markdown**: Three output trees consumed by `flake.nix`, all **markdown**:
@ -355,63 +356,3 @@ Host options live entirely under `services.hyperhive.*`. The
options tree picks up everything under that root — picking against options tree picks up everything under that root — picking against
stray roots produces an empty tree and renders the host page as stray roots produces an empty tree and renders the host page as
template chrome with no `<h2>` headers. template chrome with no `<h2>` 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 '<hash>': 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://<forge>/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.

View file

@ -1,14 +1,31 @@
# hive-network # hive-network
Host-side bridge + per-agent private-netns isolation — always on Host-side bridge + per-agent DNS resolver — the foundation that
whenever hyperhive is enabled. Configured via makes container netns isolation safe to land. Configured via
`services.hyperhive.network.*`. `services.hyperhive.network.*`; off by default during rollout.
> **Historical note:** the bridge and private-netns isolation landed in ## Why ship before netns isolation
> two separate phases. `services.hyperhive.network.enable` and
> `services.hyperhive.network.isolateContainers` are retained as If netns isolation lands first, agent containers lose
> deprecated no-op options so existing configs eval without change; both `/etc/resolv.conf` propagation from the host and DNS breaks until a
> are ignored — isolation is the only mode. 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 <bridge-ip>` |
| `address` rules target | `<bridge-ip>` (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.
## Container shape (where dnsmasq lives) ## Container shape (where dnsmasq lives)
@ -16,7 +33,9 @@ Co-located in the existing `hive-gateway` container — single
front-door for both DNS and HTTP, saves a sibling container, single front-door for both DNS and HTTP, saves a sibling container, single
systemd-unit / state surface to monitor. The gateway shares host systemd-unit / state surface to monitor. The gateway shares host
netns (`privateNetwork = false`) so dnsmasq's `bind-interfaces` netns (`privateNetwork = false`) so dnsmasq's `bind-interfaces`
listener on `bridgeIp` is on the host's bridge interface. 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).
## Configuration ## Configuration
@ -25,14 +44,19 @@ listener on `bridgeIp` is on the host's bridge interface.
services.hyperhive = { services.hyperhive = {
enable = true; enable = true;
domain = "darkest.space"; domain = "darkest.space";
# network.bridgeIp = "10.42.0.1"; # default network.enable = true; # opt in to bridge + DNS
# network.upstreamDns = [ "1.1.1.1" "9.9.9.9" ]; # default network.bridgeIp = "10.42.0.1"; # default
network.upstreamDns = [ # default Cloudflare + Quad9
"1.1.1.1"
"9.9.9.9"
];
}; };
} }
``` ```
Requires `services.hyperhive.domain` to be set — the dnsmasq resolver Asserts `services.hyperhive.domain != null` (resolver needs a domain
is authoritative for `<hive-domain>` and its sub-domains. to be authoritative for) + `services.hyperhive.gateway.enable =
true` (resolver lives in the gateway container).
## Bridge addressing ## Bridge addressing
@ -67,14 +91,15 @@ agent containers.
## Firewall posture ## Firewall posture
`networking.firewall.interfaces.<bridge>.allowedUDPPorts = [ 53 ]` `networking.firewall.interfaces.<bridge>.allowedUDPPorts = [ 53 ]`
`networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 53 80 443 ]`
- Port 53 opens the resolver on the bridge interface only. Other - `allowedTCPPorts = [ 53 ]` opens the resolver on the bridge
interfaces stay closed. The hive resolver isn't an external-facing interface only. Other interfaces stay closed. The hive resolver
service. 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 When `isolateContainers = true`, `allowedTCPPorts` is extended with
UI proxies, and any other HTTP services. `[ 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.
### Reaching host services (`exposeHostPorts`) ### Reaching host services (`exposeHostPorts`)
@ -101,16 +126,19 @@ DNS/gateway), so only expose services safe for any agent to reach.
## Container isolation ## Container isolation
Each agent container runs in a private network namespace with a dedicated `services.hyperhive.network.isolateContainers` (default `false`) flips
veth pair attached to the bridge. The following table summarises what agent containers from shared host netns to private netns. Set only after
the nix side sets up unconditionally: `enable = true` is stable in production — an assertion blocks the reverse.
### What the nix side does when `isolateContainers = true`
| effect | mechanism | | effect | mechanism |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` | | 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 | | 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 | | 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.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host | | Gateway access | `networking.firewall.interfaces.<bridge>.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.<domain>` — agents resolve via dnsmasq, nginx proxies to forgejo |
| c0re signal | `HIVE_NETWORK_ISOLATION=1`, `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` | | 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. `HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g.
@ -120,7 +148,7 @@ address arithmetic.
### What the Rust side does ### What the Rust side does
`hive-c0re` reads `HIVE_NETWORK_ISOLATION` and passes `hive-c0re` reads `HIVE_NETWORK_ISOLATION` and, when set, passes
`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=<deterministic-ip>`, `PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=<deterministic-ip>`,
`HOST_ADDRESS=<bridge-ip>`, and `HOST_BRIDGE=<bridgeName>` via `HOST_ADDRESS=<bridge-ip>`, and `HOST_BRIDGE=<bridgeName>` via
`lifecycle::set_nspawn_flags` when creating or updating containers. Each `lifecycle::set_nspawn_flags` when creating or updating containers. Each
@ -143,17 +171,21 @@ 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 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, 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 or a LAN router) is unreachable from a private netns and isn't
authoritative for the hive's own zones, so it is replaced with the authoritative for the hive's own zones, so it must be replaced with the
bridge dnsmasq at boot. Because the copy happens on every start, a bridge dnsmasq (the gateway IP). Because the copy happens on every start, a
declarative `environment.etc."resolv.conf"` would be clobbered — so the declarative `environment.etc."resolv.conf"` would be clobbered — so the
wiring is runtime: wiring is runtime:
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the - `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the
gateway IP) into each container's `/etc`. gateway IP) into the container's `/etc` **only when isolated**, removing
it otherwise — so one shared container toplevel behaves correctly in both
netns modes.
- the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that - the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot. marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
`tea-login` so the resolver is correct before the first DNS lookup. `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).
**Why isolation is safe**: all hive-c0re communication goes **Why isolation is safe**: all hive-c0re communication goes
through unix domain sockets (`/run/hive/mcp.sock` for agent requests, through unix domain sockets (`/run/hive/mcp.sock` for agent requests,
@ -168,6 +200,19 @@ against a compromised agent reaching the c0re dashboard HTTP at
`127.0.0.1`). Agents have no legitimate reason to reach the dashboard over `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. 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:<port>` 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/<name>/state/` is bind-mounted and survives; the container
rootfs is recreated cleanly from the nix store.
## Cross-references ## Cross-references
- `docs/gateway.md` — vhost map + the gateway container's other duties - `docs/gateway.md` — vhost map + the gateway container's other duties

View file

@ -121,9 +121,12 @@ suffix, so the terminal degrades cleanly against older event shapes.
`renderRichToolUse` (Write/Edit/send/ask/answer get `renderRichToolUse` (Write/Edit/send/ask/answer get
custom renderings); on miss fall through to a flat custom renderings); on miss fall through to a flat
`.tool-use` row with `fmtToolUse → fmtArgsGeneric`. `.tool-use` row with `fmtToolUse → fmtArgsGeneric`.
`fmtToolUse` surfaces the salient arg per built-in tool `fmtToolUse` surfaces the salient arg per built-in tool:
(see [`fmtToolUse` patterns](#fmttooluse-patterns) below); `recv` shows `wait <N>s` / `max <N>` when set; `Bash`
`fmtArgsGeneric` handles everything else. flags `[bg]`; `remind` shows `+Xm "preview"`; matrix
tools show `→ <room>/<user>: "body"` or `<room> [limit]`;
scheduling tools show `#id · fields`; `fmtArgsGeneric`
handles anything else.
4. `type == "user"` → walk `message.content[]` for 4. `type == "user"` → walk `message.content[]` for
`tool_result`; `renderToolResult` correlates via `tool_result`; `renderToolResult` correlates via
`tool_use_id → toolNameById` to default-open `recv` `tool_use_id → toolNameById` to default-open `recv`
@ -131,62 +134,6 @@ suffix, so the terminal degrades cleanly against older event shapes.
long = collapsed details. long = collapsed details.
5. Unrecognised shape → `.sys` row (amber, `!` glyph). 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 <path>` |
| `Write` | rich diff row `Write <path> · +N` |
| `Edit` | rich diff row `Edit <path> · -N +N` |
| `Glob` | `Glob <pattern>` |
| `Grep` | `Grep <pattern>` |
| `Bash` | `Bash [bg] $ <cmd>` (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 ## Markdown
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm `mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm

View file

@ -14,9 +14,9 @@ invocation regardless of tool groups.
Submit a shell command for background execution (runs via `bash`). Submit a shell command for background execution (runs via `bash`).
Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`. Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`.
When the task completes (or times out, or the process errors), the When the task completes (or times out, or the process errors), the
harness fires a wake with `from: "bash-task-<id>"`; the body contains harness fires a wake with `from: "bash-task-<id>"` and the exit code
the exit code and last stdout lines. Handle the completion on a future
turn. - last stdout lines in the body; handle it on a future turn.
* `timeout_secs` — kill the task after N seconds and mark it * `timeout_secs` — kill the task after N seconds and mark it
`timed_out`. Omit for no timeout (runs until natural exit). `timed_out`. Omit for no timeout (runs until natural exit).

View file

@ -178,20 +178,9 @@ plain comment show under `last comment`, not `reviews`.
### Repo management ### Repo management
Agents **cannot create repos directly via forge token** — Forgejo Three verbs create repos and manage their membership — the supported
disables push-to-create and the agent token doesn't have the Create path for workflows that need a fresh repo (agents cannot push to
scope. Two paths exist depending on where the repo should live: non-existent repos; the Forgejo instance disables push-to-create).
**Agent repos (`agents/<name>`)** — 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 <name>`** — create a repo under the authenticated user **`repo-create <name>`** — create a repo under the authenticated user
and print its URL. Key flags: and print its URL. Key flags:
@ -206,7 +195,8 @@ and print its URL. Key flags:
active repo (`-r`/`HIVE_FORGE_REPO`). Companion to `repo-create`. The active repo (`-r`/`HIVE_FORGE_REPO`). Companion to `repo-create`. The
`--permission` flag accepts `read` / `write` (default) / `admin`. `--permission` flag accepts `read` / `write` (default) / `admin`.
`hive-c0re` uses this internally when an agent's config repo is `hive-c0re` uses this internally when an agent's config repo is
initialised. initialised (`create_repo``repo-add-collaborator` → agent gets
write access).
**`repo-labels [PATTERN]`** — list every label defined on the repo, **`repo-labels [PATTERN]`** — list every label defined on the repo,
optionally filtered by a name substring (case-sensitive). Distinct from optionally filtered by a name substring (case-sensitive). Distinct from

View file

@ -17,11 +17,6 @@ room you haven't read yet.
accepts `!id:server` or `#alias:server`, daemon resolves either accepts `!id:server` or `#alias:server`, daemon resolves either
- `send_dm(user_id, body)` — open (or reuse) the DM room with - `send_dm(user_id, body)` — open (or reuse) the DM room with
`user_id` and post `body` to it `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 - `send_reply(room, event_id, body)` — threaded reply to a specific
event event
- `send_reaction(room, event_id, key)` — react to a message with an - `send_reaction(room, event_id, key)` — react to a message with an

View file

@ -616,17 +616,6 @@ at_unix_timestamp?)`, `request_next_turn()`.
- **Scheduling + diagnostics** (`scheduling`, `diagnostics`) — - **Scheduling + diagnostics** (`scheduling`, `diagnostics`) —
scheduled prompts, `get_logs`. See scheduled prompts, `get_logs`. See
[`docs/tools/scheduling.md`](tools/scheduling.md). [`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 - **Capability-gated**`get_host_journal` (requires
`read_host_journal` capability set via the P3RM1SS10NS tab; `read_host_journal` capability set via the P3RM1SS10NS tab;
orthogonal to tool groups). Full list of capabilities and their orthogonal to tool groups). Full list of capabilities and their

View file

@ -37,9 +37,7 @@ This doc has been split for readability. Pick the section you need:
[`web-ui/dashboard.md`](web-ui/dashboard.md) (Dashboard [`web-ui/dashboard.md`](web-ui/dashboard.md) (Dashboard
endpoints, Dashboard event channel). endpoints, Dashboard event channel).
- **"How does the per-agent terminal render tool calls?"** → - **"How does the per-agent terminal render tool calls?"** →
[`terminal-rendering.md`](terminal-rendering.md) (full row [`web-ui/agent.md`](web-ui/agent.md) (Live view).
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?"** → - **"What slash commands does the agent accept?"** →
[`web-ui/agent.md`](web-ui/agent.md) (Terminal-embedded prompt). [`web-ui/agent.md`](web-ui/agent.md) (Terminal-embedded prompt).
- **"What are the per-agent HTTP endpoints?"** → - **"What are the per-agent HTTP endpoints?"** →

View file

@ -96,8 +96,8 @@ through. Three flex columns:
closes the menu without an extra POST. closes the menu without an extra POST.
A second separator + **effort quick-picker** section labelled A second separator + **effort quick-picker** section labelled
`effort` appears when the backend declares effort levels in `effort` appears when the backend declares effort levels in
`state.available_efforts`. One button per level (e.g. `low`, `state.available_efforts`. One button per level (e.g. `medium`,
`medium`, `high`, `xhigh`, `max`); clicking POSTs `/api/effort` (same endpoint as `high`, `xhigh`); clicking POSTs `/api/effort` (same endpoint as
the `/effort <level>` slash command). The active level's button is the `/effort <level>` slash command). The active level's button is
highlighted; `renderEffortChip` keeps the picker in sync with highlighted; `renderEffortChip` keeps the picker in sync with
`StateSnapshot.effort` from the cold-load snapshot. `StateSnapshot.effort` from the cold-load snapshot.
@ -219,8 +219,7 @@ SSE frame and the replayed history rows. The web UI:
- terminal-themed: phosphor mauve glow, Crust bg, - terminal-themed: phosphor mauve glow, Crust bg,
backdrop-filter blur, row fade-in slide-up. backdrop-filter blur, row fade-in slide-up.
Per-stream rendering (see [`docs/terminal-rendering.md`](../terminal-rendering.md) for Per-stream rendering:
the full row taxonomy and dispatch logic):
- `Stream` `tool_use` - `Stream` `tool_use`
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body - `Write` / `Edit`: collapsed `<details>` with a +/- diff body
@ -228,18 +227,13 @@ the full row taxonomy and dispatch logic):
`input.new_string` or every line of `input.content`). `input.new_string` or every line of `input.content`).
Summary carries the path + line counts. Summary carries the path + line counts.
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send → - others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
operator: "..."`, etc.): flat one-line per-tool format with operator: "..."`, etc.): flat one-line per-tool format.
per-tool salient-arg extraction (`fmtToolUse`).
- `Stream` `tool_result` short → flat `← ...`; long → collapsed - `Stream` `tool_result` short → flat `← ...`; long → collapsed
`<details>` `▸ ← Nl · headline` (click to expand full body). `<details>` `▸ ← Nl · headline` (click to expand full body).
- `Stream` `thinking``.thinking` row with a `💭 thinking …` - `Stream` `thinking` → text content if claude provided one,
indicator. otherwise the bare `· thinking …` indicator.
- `Stream` `system` → handled by subtype: `plugin_install` and - `Stream` `system init`, `result`, `rate_limit_event` are
`compact_boundary` emit muted notes; `commands_changed` emits an dropped — too noisy.
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 `⚙ <subtype>` note.
- `Note``· text`. - `Note``· text`.
- `TurnStart``◆ TURN ← <from>` with the wake-prompt body; a - `TurnStart``◆ TURN ← <from>` with the wake-prompt body; a
muted `· HH:MM:SS` time suffix from the event `ts`. muted `· HH:MM:SS` time suffix from the event `ts`.
@ -269,7 +263,7 @@ Slash commands today:
`/state/hyperhive-model` so the override survives harness `/state/hyperhive-model` so the override survives harness
restart / rebuild. restart / rebuild.
- `/effort <level>``POST /api/effort` setting the claude effort - `/effort <level>``POST /api/effort` setting the claude effort
level (`low` / `medium` / `high` / `xhigh` / `max`). Takes effect on the next level (`medium` / `high` / `xhigh`). Takes effect on the next
turn. The overflow menu surfaces an effort picker that calls the turn. The overflow menu surfaces an effort picker that calls the
same endpoint; both stay in sync via `StateSnapshot.effort`. same endpoint; both stay in sync via `StateSnapshot.effort`.
- `/new-session``POST /api/new-session` (confirms first). - `/new-session``POST /api/new-session` (confirms first).
@ -310,7 +304,7 @@ shaped).
future turns. `Bus::set_model` emits `ModelChanged`. future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/effort` (`effort=<level>`) — switch the claude - `POST /api/effort` (`effort=<level>`) — switch the claude
effort level for future sessions. Validated server-side against effort level for future sessions. Validated server-side against
`EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are `EFFORT_LEVELS` (`medium`/`high`/`xhigh`) — unknown values are
rejected rather than forwarded to `claude --effort`. Persists via rejected rather than forwarded to `claude --effort`. Persists via
`Bus::set_effort`, which emits `EffortChanged`. Applies on the next `Bus::set_effort`, which emits `EffortChanged`. Applies on the next
session start (no mid-session swap). session start (no mid-session swap).

View file

@ -1626,40 +1626,6 @@ window.marked = marked;
} }
case 'mcp__hyperhive__request_apply_commit': case 'mcp__hyperhive__request_apply_commit':
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); 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': { case 'mcp__bash__run': {
// Rich renderer handles the full body; this summary covers any // Rich renderer handles the full body; this summary covers any
// fallback path and the details summary line. // fallback path and the details summary line.

View file

@ -583,7 +583,7 @@ pub const DEFAULT_EFFORT: &str = "medium";
/// Valid claude `--effort` levels, ascending. The operator picker is /// Valid claude `--effort` levels, ascending. The operator picker is
/// constrained to these; [`is_valid_effort`] guards the persist path. /// constrained to these; [`is_valid_effort`] guards the persist path.
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; pub const EFFORT_LEVELS: [&str; 3] = ["medium", "high", "xhigh"];
/// True iff `level` is one of [`EFFORT_LEVELS`]. /// True iff `level` is one of [`EFFORT_LEVELS`].
#[must_use] #[must_use]
@ -1271,7 +1271,7 @@ mod tests {
} }
// Out-of-set, empty, and wrong-case inputs are rejected so a bad // Out-of-set, empty, and wrong-case inputs are rejected so a bad
// picker POST never reaches `claude --effort`. // picker POST never reaches `claude --effort`.
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] { for bad in ["low", "", "MEDIUM", "ultra", "high "] {
assert!(!is_valid_effort(bad), "{bad:?} should be rejected"); assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
} }
} }

View file

@ -2182,14 +2182,21 @@ const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
/// Enforce the per-agent send allow-list. Returns `Ok` when the /// Enforce the per-agent send allow-list. Returns `Ok` when the
/// recipient is permitted (no list configured, `<parent>` sentinel /// recipient is permitted (no list configured, manager always
/// always allowed, or `to` is in the list); returns `Err(refusal)` /// allowed, or `to` is in the list); returns `Err(refusal)` with a
/// with a claude-readable string when blocked <20><><EFBFBD> the harness surfaces /// claude-readable string when blocked — the harness surfaces the
/// the refusal as the tool result so claude knows the message didn't /// refusal as the tool result so claude knows the message didn't
/// land and can react (e.g. route via `<parent>` instead). /// land and can react (e.g. route via the manager instead).
fn check_send_allowed(to: &str) -> Result<(), String> { 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 { if to == hive_sh4re::PARENT_RECIPIENT {
// Always allow `<parent>` — the allow-list constrains peer // Always allow `<parent>` — same escape-hatch rationale as
// the manager exception. The allow-list constrains peer
// chatter, not the structural reporting line; the operator // chatter, not the structural reporting line; the operator
// can rewire who the parent IS via `set_parent` without // can rewire who the parent IS via `set_parent` without
// having to remember to update the per-agent allow-list. // having to remember to update the per-agent allow-list.

View file

@ -461,7 +461,7 @@ struct StateSnapshot {
/// runtime via `POST /api/effort`; applies on the next session. /// runtime via `POST /api/effort`; applies on the next session.
effort: String, effort: String,
/// Selectable effort levels for the picker, ascending. Fixed set /// Selectable effort levels for the picker, ascending. Fixed set
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from /// (`medium`, `high`, `xhigh`) — sourced from
/// [`crate::events::EFFORT_LEVELS`], not operator-configurable like /// [`crate::events::EFFORT_LEVELS`], not operator-configurable like
/// `available_models`. The frontend renders one button per entry. /// `available_models`. The frontend renders one button per entry.
available_efforts: Vec<String>, available_efforts: Vec<String>,

View file

@ -1,13 +1,8 @@
//! Startup auto-update: on `hive-c0re serve` boot, rebuild containers that //! Startup auto-update: on `hive-c0re serve` boot, rebuild every known
//! actually need it. Two skip rules keep boot-time work minimal: //! container unconditionally. `nixos-container update` is a no-op at the
//! //! nix level when nothing changed (same store path), so the cost is low
//! 1. **Stopped containers** are deferred — they will be rebuilt the first //! and avoids rev-marker staleness (all agents always need an update pass
//! time the operator starts them (see `rebuild_queue::run_start` and //! when any meta commit lands). See `docs/coordinator.md::Auto-update sweep`.
//! `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::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@ -327,13 +322,11 @@ pub fn topology_sort(
}); });
} }
/// Rebuild containers that need it on startup. Skips: /// Rebuild every container on startup. Enqueues a `StartupSweep` parent
/// - **Stopped containers**: deferred to on-start (`run_start` / `handle_start` /// entry (agent = `"hyperhive"`) followed by per-agent `Rebuild` children
/// upgrades a plain start to rebuild+start when the rev marker is stale). /// linked via `parent_id`. The dashboard renders them nested so the operator
/// - **Running containers with a matching rev marker**: no nix work needed. /// can see at a glance "boot N agents, here is each rebuild's status".
/// /// Returns Ok even if some rebuilds failed.
/// 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<Coordinator>) -> Result<()> { pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
let containers = match lifecycle::list().await { let containers = match lifecycle::list().await {
Ok(c) => c, Ok(c) => c,
@ -343,7 +336,22 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
} }
}; };
let current_rev = current_flake_rev(&coord.hyperhive_flake); // 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"
);
// Resolve container names to logical agent names, then sort by // Resolve container names to logical agent names, then sort by
// topology depth so parents are always rebuilt before their // topology depth so parents are always rebuilt before their
@ -355,56 +363,7 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
.collect(); .collect();
let topo = crate::topology::read(); let topo = crate::topology::read();
topology_sort(&mut logical_names, &topo); 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<String> = 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( coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::Rebuild, crate::rebuild_queue::QueueKind::Rebuild,
name, name,

View file

@ -1681,7 +1681,7 @@ async fn post_request_spawn(
hive_sh4re::ApprovalKind::Spawn, hive_sh4re::ApprovalKind::Spawn,
"", "",
None, None,
"operator", hive_sh4re::MANAGER_AGENT,
) { ) {
Ok(id) => { Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");

View file

@ -16,7 +16,7 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result; use anyhow::Result;
use hive_sh4re::LooseEnd; use hive_sh4re::{LooseEnd, MANAGER_AGENT};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
@ -56,12 +56,12 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
} }
// Show each pending approval to the agent that submitted it. The // Show each pending approval to the agent that submitted it. The
// submitter column is NULL for rows predating it; those count as // submitter column is NULL for rows predating it; those count as
// operator-initiated (no agent tracking predates the column). // the root agent's.
for a in coord.approvals.pending()? { for a in coord.approvals.pending()? {
let submitter = coord let submitter = coord
.approvals .approvals
.submitter_of(a.id)? .submitter_of(a.id)?
.unwrap_or_else(|| "operator".to_owned()); .unwrap_or_else(|| MANAGER_AGENT.to_owned());
if submitter != agent { if submitter != agent {
continue; continue;
} }

View file

@ -203,14 +203,13 @@ pub fn handle_cancel_loose_end(
// may withdraw it. Without the ownership check, any // may withdraw it. Without the ownership check, any
// approvals-group agent could cancel any other's approval by // approvals-group agent could cancel any other's approval by
// id. A NULL submitter (legacy row predating the column) is // id. A NULL submitter (legacy row predating the column) is
// treated as operator-initiated (no agent tracking predates // owned by the root agent.
// the column).
check_can_cancel_approval(canceller)?; check_can_cancel_approval(canceller)?;
let submitter = coord let submitter = coord
.approvals .approvals
.submitter_of(id) .submitter_of(id)
.map_err(|e| format!("{e:#}"))? .map_err(|e| format!("{e:#}"))?
.unwrap_or_else(|| "operator".to_owned()); .unwrap_or_else(|| hive_sh4re::MANAGER_AGENT.to_owned());
if submitter != canceller { if submitter != canceller {
return Err(format!( return Err(format!(
"cancel_loose_end: approval {id} was submitted by {submitter}, \ "cancel_loose_end: approval {id} was submitted by {submitter}, \

View file

@ -1034,33 +1034,11 @@ async fn rebuild_for_entry(
/// Uses the cold-start fallback (stop + kill + start retry) so the /// Uses the cold-start fallback (stop + kill + start retry) so the
/// deferred start-after-rebuild keeps the same activation-error recovery /// deferred start-after-rebuild keeps the same activation-error recovery
/// it had when it ran inline on the build lane. /// 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( async fn run_start(
coord: &std::sync::Arc<crate::coordinator::Coordinator>, coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry, entry: &QueueEntry,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let name = &entry.agent; 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); let _guard = coord.transient_guard(name, crate::coordinator::TransientKind::Starting);
coord.set_queue_step(Some(entry.id), "nixos-container start"); coord.set_queue_step(Some(entry.id), "nixos-container start");
crate::lifecycle::start_with_fallback(name).await?; crate::lifecycle::start_with_fallback(name).await?;

View file

@ -85,7 +85,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
hive_sh4re::ApprovalKind::Spawn, hive_sh4re::ApprovalKind::Spawn,
"", "",
None, None,
"operator", hive_sh4re::MANAGER_AGENT,
)?; )?;
tracing::info!(%id, %name, "spawn approval queued"); tracing::info!(%id, %name, "spawn approval queued");
HostResponse::success() HostResponse::success()

View file

@ -803,25 +803,6 @@ async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Agen
return err; return err;
} }
tracing::info!(%agent, %name, "start container"); 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 { match crate::lifecycle::start(name).await {
Ok(()) => { Ok(()) => {
coord.kick_agent(name, "container started"); coord.kick_agent(name, "container started");
@ -1847,8 +1828,8 @@ pub(crate) fn submit_init_config(
description.as_deref(), description.as_deref(),
// `parent` is the requesting agent (becomes the new child's // `parent` is the requesting agent (becomes the new child's
// parent); it's also the submitter the approval events route // parent); it's also the submitter the approval events route
// back to. No declared parent = operator-initiated path. // back to. No declared parent = operator/root path.
parent.unwrap_or("operator"), parent.unwrap_or(hive_sh4re::MANAGER_AGENT),
) )
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued"); tracing::info!(%id, %name, "init_config approval queued");

View file

@ -11,50 +11,17 @@
# prose `/docs/` tree. Full pipeline + subtree-pick / output-tree # prose `/docs/` tree. Full pipeline + subtree-pick / output-tree
# rationale: docs/gotchas.md::Nix options reference. # rationale: docs/gotchas.md::Nix options reference.
let 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 # Stub host system: every hyperhive subsystem `mkForce false` so
# heavy build inputs (matrix container, forge, etc.) stay out of # heavy build inputs (matrix container, forge, etc.) stay out of
# the eval — only option *declarations* matter for the doc walk. # 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 { hostEval = nixosSystem {
system = pkgs.stdenv.hostPlatform.system; system = pkgs.stdenv.hostPlatform.system;
modules = [ modules = [
(import "${nixSrc}/modules/hive-c0re.nix" { self.nixosModules.default
hyperhivePackage = _system: pkgs.emptyFile;
hyperhiveFrontend = _system: pkgs.emptyFile;
hyperhiveAssets = _system: pkgs.emptyDirectory;
hyperhiveFlake = "";
hyperhiveDocs = "";
agentBaseToplevel = pkgs.emptyFile;
managerToplevel = pkgs.emptyFile;
})
( (
{ lib, ... }: { lib, ... }:
{ {
nixpkgs.overlays = [ docsStubOverlay ]; nixpkgs.overlays = [ self.overlays.default ];
fileSystems."/" = { fileSystems."/" = {
device = "/dev/null"; device = "/dev/null";
fsType = "tmpfs"; fsType = "tmpfs";
@ -68,23 +35,14 @@ let
]; ];
}; };
# Agent module eval from the content-addressed nixSrc. Relative # Reuse the already-evaluated agent-base config — its options tree is
# imports inside agent-base.nix (e.g. ./harness-base.nix) resolve # identical to what a real agent container sees, no second eval needed.
# correctly against the nixSrc directory tree. agentEval = self.nixosConfigurations.agent-base;
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 # Rewrite option declaration paths from nix-store absolute paths to
# forge URLs so rendered docs link back to source. # 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"; forgeRoot = "https://forge.darkest.space/hyperhive/hyperhive/src/branch/main";
nixSrcPrefix = builtins.unsafeDiscardStringContext (toString nixSrc + "/"); storePrefix = toString self + "/";
transformOptions = transformOptions =
opt: opt:
opt opt
@ -94,8 +52,8 @@ let
let let
declStr = toString decl; declStr = toString decl;
relPath = relPath =
if lib.hasPrefix nixSrcPrefix declStr then if lib.hasPrefix storePrefix declStr then
"nix/" + lib.removePrefix nixSrcPrefix declStr lib.removePrefix storePrefix declStr
else else
baseNameOf declStr; baseNameOf declStr;
in in
@ -178,7 +136,7 @@ let
`hyperhive.forge.*`, `hyperhive.matrix.*`, `hyperhive.gui.*`). `hyperhive.forge.*`, `hyperhive.matrix.*`, `hyperhive.gui.*`).
Regenerate with `nix build .#docs` (bundle), `.#docs-host`, or Regenerate with `nix build .#docs` (bundle), `.#docs-host`, or
`.#docs-agent`. Consumed by the website repo to render `/options/`. `.#docs-agent`.
''; '';
hostMD = mkMarkdownPage "docs-host" "hyperhive host options" hostDoc; hostMD = mkMarkdownPage "docs-host" "hyperhive host options" hostDoc;

View file

@ -795,9 +795,10 @@ in
managerToplevel managerToplevel
]; ];
# Unprivileged coordinator user. hive-c0re runs as this user; # Unprivileged coordinator user. hive-c0re runs as this user
# privileged operations are delegated to hive-priv which runs as # (privsep phase 2); privileged operations are delegated to
# root, socket-activated at /run/hive/priv.sock. # hive-priv which runs as root, socket-activated at
# /run/hive/priv.sock.
users.users.hive-core = { users.users.hive-core = {
isSystemUser = true; isSystemUser = true;
group = "hive-core"; group = "hive-core";

View file

@ -423,7 +423,7 @@ in
# #
# /run/hive-agent — per-agent UDS socket dir, written by c0re's # /run/hive-agent — per-agent UDS socket dir, written by c0re's
# set_nspawn_flags when agents start. Owned by `hive-core` (the # set_nspawn_flags when agents start. Owned by `hive-core` (the
# unprivileged coordinator user): c0re does the # unprivileged coordinator user, privsep phase 2): c0re does the
# `create_dir_all(/run/hive-agent/<name>)` itself, so a root-owned # `create_dir_all(/run/hive-agent/<name>)` itself, so a root-owned
# parent would EACCES on the very first agent create on a fresh host # 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). # (hive-priv only chowns the subdir afterwards, it doesn't make it).
@ -949,6 +949,7 @@ in
# container. Listens on the bridge interface from # container. Listens on the bridge interface from
# `services.hyperhive.network`; authoritative for the hive # `services.hyperhive.network`; authoritative for the hive
# domain + sub-domains, forwards everything else upstream. # domain + sub-domains, forwards everything else upstream.
# No-op when `network.enable = false`.
services.dnsmasq = lib.mkIf networkCfg.enable { services.dnsmasq = lib.mkIf networkCfg.enable {
enable = true; enable = true;
# Don't substitute the container's /etc/resolv.conf — # Don't substitute the container's /etc/resolv.conf —

View file

@ -8,8 +8,9 @@ let
in in
{ {
# Hive-internal network — host-side bridge + per-agent DNS resolver. # Hive-internal network — host-side bridge + per-agent DNS resolver.
# Always active when hyperhive is enabled: agent containers run in # Containers stay on shared host netns at v1; this module stands the
# private netns behind the bridge. Full design: docs/network.md. # bridge + resolver up so the endpoint is in place before network
# isolation flips containers to private netns. Full design: docs/network.md.
options.services.hyperhive.network = { options.services.hyperhive.network = {
enable = lib.mkOption { enable = lib.mkOption {

View file

@ -219,11 +219,9 @@ in
options.hyperhive.effortLevel = lib.mkOption { options.hyperhive.effortLevel = lib.mkOption {
type = lib.types.enum [ type = lib.types.enum [
"low"
"medium" "medium"
"high" "high"
"xhigh" "xhigh"
"max"
]; ];
default = "medium"; default = "medium";
example = "high"; example = "high";
@ -233,12 +231,12 @@ in
effort as operator-override-file this env built-in `"medium"`, effort as operator-override-file this env built-in `"medium"`,
and passes the result to `claude --effort` at turn launch. and passes the result to `claude --effort` at turn launch.
Ascending scale: `"low"` (minimal thinking budget), `"medium"` `"medium"` (the default) keeps token spend low and works well with
(default balanced), `"high"` (platform default), `"xhigh"` the harness across families. `"high"` matches the current platform
(recommended for autonomous coding on capable models), `"max"` default; `"xhigh"` is recommended for coding / high-autonomy work on
(maximum thinking budget, highest cost). The operator can override capable models (Opus 4.8+) at higher token cost. The operator can
at runtime per-agent via the web UI (applied on the next session); override at runtime per-agent via the web UI (applied on the next
any rebuild that changes this option resets that override. session); any rebuild that changes this option resets that override.
''; '';
}; };

View file

@ -1,32 +0,0 @@
#!/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