docs sync + revert auto-unfree removal

revert the earlier 'operator must set allowUnfree' move:
per-agent containers evaluate their own nixpkgs and the operator's
host-level allowUnfree doesn't propagate in. restoring the scoped
allowUnfreePredicate inside both the claude-unstable overlay and
harness-base.nix; documented in README + gotchas as 'nothing to
set on the operator side'.

docs:
- claude.md file map adds crash_watch.rs, kick_agent on coordinator,
  /api/model + journald viewer + bind-with-retry references.
- scratchpad rewritten to reflect the recent run.
- web-ui.md: notification row + browser notifications section,
  state row (badge + model chip + last-turn chip + cancel button),
  per-agent inbox, /model slash, /cancel-question + journald
  endpoints, focus-preservation on refresh.
- turn-loop.md: --model is read from Bus::model() per turn (runtime
  override via /model); recv(wait_seconds) up to 180s with the
  rationale; ask_operator gains ttl_seconds; new TurnState section;
  kick_agent inbox-on-startup hint.
- approvals.md: ttl/cancel resolution paths for operator questions.
- persistence.md: /state/hyperhive-model file.
- gotchas.md: web UI port collision policy (rename, don't probe);
  bind retry + SO_REUSEADDR shape; auto-unfree restored.
- todo.md: cleaned up empty sections and stale entries; /model
  shipped, dropped from the list.
This commit is contained in:
müde 2026-05-15 21:26:13 +02:00
parent d275b50177
commit 62d1a74929
10 changed files with 239 additions and 95 deletions

View file

@ -25,21 +25,26 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
src/operator_questions.rs sqlite question queue backing `ask_operator` src/operator_questions.rs sqlite question queue backing `ask_operator`
src/events_vacuum.rs host-side hourly sweep of every agent's src/events_vacuum.rs host-side hourly sweep of every agent's
/state/hyperhive-events.sqlite /state/hyperhive-events.sqlite
src/crash_watch.rs poll every 10s; fire HelperEvent::ContainerCrash
when a previously-running container disappears
without an operator-initiated transient
src/coordinator.rs shared state (broker/approvals/questions/transient/ src/coordinator.rs shared state (broker/approvals/questions/transient/
sockets) + tombstone enumeration sockets) + tombstone enumeration + kick_agent
src/actions.rs approve/deny/destroy (transient-aware) src/actions.rs approve/deny/destroy (transient-aware)
src/auto_update.rs startup rebuild scan + ensure_manager src/auto_update.rs startup rebuild scan + ensure_manager
src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
+ journald viewer + bind-with-retry (SO_REUSEADDR)
assets/ index.html, dashboard.css, app.js (include_str!) assets/ index.html, dashboard.css, app.js (include_str!)
hive-ag3nt/ in-container harness crate; produces TWO binaries hive-ag3nt/ in-container harness crate; produces TWO binaries
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
src/client.rs generic JSON-line request/response over unix socket src/client.rs generic JSON-line request/response over unix socket
src/web_ui.rs per-container axum HTTP page (incl /api/cancel, src/web_ui.rs per-container axum HTTP page (incl /api/cancel,
/api/compact, /events/history) /api/compact, /api/model, /events/history)
src/events.rs LiveEvent + broadcast Bus + sqlite-backed history src/events.rs LiveEvent + broadcast Bus + sqlite-backed history
(/state/hyperhive-events.sqlite) (/state/hyperhive-events.sqlite) + TurnState +
model selection (persisted at /state/hyperhive-model)
src/turn.rs claude --print + stream-json pump; --compact retry src/turn.rs claude --print + stream-json pump; --compact retry
src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer
src/login.rs probe /root/.claude/ for a valid session src/login.rs probe /root/.claude/ for a valid session
@ -109,10 +114,17 @@ read them à la carte.
In-flight or recent context that hasn't earned a section yet. In-flight or recent context that hasn't earned a section yet.
Prune freely. Prune freely.
- Loop session 2026-05-15: shipped state badge, /cancel + /compact, - 2026-05-15 ish: tombstones, multi-select ask_operator, broker +
tombstones, multi-select ask_operator, broker + events vacuum. events vacuum, docs split into `docs/`, lifecycle_action helper,
- After loop session 2026-05-15: docs split into `docs/` (this api_state split.
page slimmed to index + scratchpad). Cleanups landed: vacuum - Then: inline +/- diffs on Write/Edit, operator cancel + ttl on
host-side, `lifecycle_action` helper, `api_state` split. questions, dashboard back-link, per-agent inbox view, bind-retry
- Next likely focus: telemetry/charts (still queued from earlier + SO_REUSEADDR, journald viewer, server-side TurnState,
triage) + server-side state badge. recv(wait_seconds) max 180s, runtime /model switch, crash
watcher, model persistence, stopped auto-allowing claude-code
unfree (operator must opt in), pure-hash agent_web_port (port
files reverted), browser notifications, focus-preserving
refresh.
- Open threads: telemetry/charts, custom per-agent MCP tools (the
groundwork for moving bitburner-agent into hyperhive),
two-step spawn, unprivileged containers, Bash allow-list.

View file

@ -91,16 +91,12 @@ hive-c0re will then:
- auto-create the manager container (`hm1nd`) if missing, - auto-create the manager container (`hm1nd`) if missing,
- auto-rebuild any managed container whose hyperhive rev is stale. - auto-rebuild any managed container whose hyperhive rev is stale.
`claude-code` is unfree; hyperhive does not auto-allow it for you. `claude-code` is unfree; hyperhive whitelists it for itself
Add to your host config: (scoped: only `claude-code`, nothing else) inside the
`claude-unstable` overlay and `harness-base.nix`. Per-agent
```nix containers evaluate their own nixpkgs instance so the operator's
nixpkgs.config.allowUnfreePredicate = host-level `allowUnfree` doesn't propagate in — the predicate has
pkg: builtins.elem (nixpkgs.lib.getName pkg) [ "claude-code" ]; to live inline. Nothing to set on the operator side.
```
(or `nixpkgs.config.allowUnfree = true`, your call). Each per-agent
container inherits this through the same nixpkgs evaluation.
## Build / deploy ## Build / deploy

View file

@ -44,11 +44,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## UI / UX ## UI / UX
- **Terminal: `/model` slash command.** Operator-typeable model
override from the terminal. Depends on the model-override work
above; once an override mechanism exists, wire a `/model <name>`
command that POSTs to a new endpoint.
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by - **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
the harness. Pairs well with the unprivileged-container work — would let the harness. Pairs well with the unprivileged-container work — would let
the operator drop into the container without `nixos-container root-login`. the operator drop into the container without `nixos-container root-login`.
@ -71,9 +66,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
the harness on `TurnEnd`; `GET /api/stats?since=…` returns the the harness on `TurnEnd`; `GET /api/stats?since=…` returns the
series; agent page renders with a small chart lib (uPlot is light). series; agent page renders with a small chart lib (uPlot is light).
## Manager → operator question channel
## Spawn flow ## Spawn flow
- **Two-step spawn.** Today `request_spawn(name)` is one shot: manager - **Two-step spawn.** Today `request_spawn(name)` is one shot: manager
@ -101,4 +93,3 @@ Pick anything from here when relevant. Cross-cutting design notes live in
grow. Bitburner-agent's pattern: a short-lived secondary claude session grow. Bitburner-agent's pattern: a short-lived secondary claude session
that takes the existing notes + a "compact this" prompt and rewrites that takes the existing notes + a "compact this" prompt and rewrites
them in place. Add when the notes start bloating. them in place. Add when the notes start bloating.

View file

@ -84,9 +84,9 @@ package legitimacy, cheaper alternative, blast radius) before
committing and calling `request_apply_commit`. committing and calling `request_apply_commit`.
For ambiguous cases or anything that needs human signal, the For ambiguous cases or anything that needs human signal, the
manager calls `ask_operator(question, options?, multi?)` — queues manager calls `ask_operator(question, options?, multi?,
the question on the dashboard and returns the id immediately. The ttl_seconds?)` — queues the question on the dashboard and returns
operator's answer arrives later as the id immediately. The operator's answer arrives later as
`HelperEvent::OperatorAnswered` in the manager inbox. Storage is `HelperEvent::OperatorAnswered` in the manager inbox. Storage is
`hive-c0re::operator_questions` (sqlite); the answer flow is: `hive-c0re::operator_questions` (sqlite); the answer flow is:
@ -96,6 +96,16 @@ POST /answer-question/{id}
→ notify_manager(OperatorAnswered { id, question, answer }) → notify_manager(OperatorAnswered { id, question, answer })
``` ```
Two more paths resolve a pending question with a sentinel answer:
- `POST /cancel-question/{id}` (✗ CANC3L button on the dashboard)
resolves with `[cancelled]`. The manager sees a terminal state
and can fall back.
- `ttl_seconds` deadline: a tokio watchdog spawned at submit time
fires `answer(id, "[expired]")` once the ttl runs out. Already-
resolved races no-op. The dashboard surfaces a `⏳ MM:SS` chip
on each pending question with a deadline.
## Helper events to the manager ## Helper events to the manager
`Coordinator::notify_manager(&HelperEvent)` enqueues an inbox `Coordinator::notify_manager(&HelperEvent)` enqueues an inbox

View file

@ -54,14 +54,13 @@ socket without needing a clean reinstall.
## `claude-code` is unfree ## `claude-code` is unfree
The flake pins it to **nixpkgs-unstable** via The flake pins it to **nixpkgs-unstable** via
`overlays.claude-unstable` (stable lags too far). The overlay `overlays.claude-unstable` (stable lags too far). The overlay sets
imports unstable inheriting the user's `nixpkgs.config`, so the `config.allowUnfreePredicate` on its unstable import to whitelist
operator must opt in by setting `allowUnfree = true` (or an `claude-code` specifically — scoped, only this one package.
`allowUnfreePredicate` that whitelists `claude-code`) on their host `harness-base.nix` does the same at the container level because
config. hyperhive deliberately does NOT auto-allow — silent unfree each per-agent `nixosConfiguration` evaluates its own nixpkgs
bypass would be sketchy, and the error message is clear enough that instance and the operator's host-level `allowUnfree` does **not**
the operator can fix it once and forget about it. Same on the propagate in. Operators don't need to set anything on their side.
per-agent containers (they inherit through the same nixpkgs).
## Claude credentials are per-agent ## Claude credentials are per-agent
@ -79,6 +78,28 @@ across `destroy`/recreate (`--purge` wipes them).
writes its events log here (`/state/hyperhive-events.sqlite`). writes its events log here (`/state/hyperhive-events.sqlite`).
Survives `destroy`/recreate alongside the claude dir. Survives `destroy`/recreate alongside the claude dir.
## Web UI ports collide on hash
Sub-agent web UI ports are deterministic FNV-1a of the agent name
modulo 900 (range 8100..8999). With ~30 agents the birthday-paradox
collision rate gets meaningful; at 23 agents you can still get
unlucky. Operator resolves a collision by renaming the offending
agent (different hash → different port) and rebuilding. No state
file, no probing, no port-allocation drift — the value is
reproducible from just the name. Manager is fixed at 8000;
dashboard at `cfg.dashboardPort` (default 7000).
## Restart races on TCP bind
Both the dashboard and per-agent web UI use `tokio::net::TcpSocket`
with `SO_REUSEADDR` plus a retry-on-`AddrInUse` loop (12 tries,
exponential backoff capped at 2s, ~22s total). REUSEADDR handles
the `TIME_WAIT` case from a clean previous exit; retry covers the
genuine "previous process is still alive during a systemd restart
overlap" case. REUSEADDR does **not** allow two simultaneous
`LISTEN` sockets on the same port (that would be `SO_REUSEPORT`,
which we don't use) — exclusivity is preserved.
## Orphan approvals ## Orphan approvals
If state dirs are wiped out from under a pending approval (test If state dirs are wiped out from under a pending approval (test

View file

@ -46,6 +46,15 @@ setups). On open failure the `Bus` falls back to no-store mode
rather than crashing the harness — events still broadcast over SSE, rather than crashing the harness — events still broadcast over SSE,
just nothing persisted. just nothing persisted.
### `/state/hyperhive-model` (per agent)
Single-line text file holding the claude model name currently
selected for this agent (default `haiku` when absent). Written by
`Bus::set_model` whenever the operator flips it via `/model
<name>` in the web terminal. Read once at harness boot in
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`.
Survives destroy/recreate, gone on `--purge`.
## State dirs (per agent) ## State dirs (per agent)
Under `/var/lib/hyperhive/agents/<name>/`: Under `/var/lib/hyperhive/agents/<name>/`:

View file

@ -26,7 +26,7 @@ Each agent harness (`hive-ag3nt serve` or `hive-m1nd serve`) runs:
## The claude invocation ## The claude invocation
``` ```
claude --print --verbose --output-format stream-json --model haiku \ claude --print --verbose --output-format stream-json --model <name> \
--continue --settings /run/hive/claude-settings.json \ --continue --settings /run/hive/claude-settings.json \
--system-prompt-file /run/hive/claude-system-prompt.md \ --system-prompt-file /run/hive/claude-system-prompt.md \
--mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
@ -34,6 +34,12 @@ claude --print --verbose --output-format stream-json --model haiku \
# wake prompt piped over stdin # wake prompt piped over stdin
``` ```
`<name>` is read from `Bus::model()` on each turn, default
`haiku`. Operator can flip it at runtime with `/model <name>` in
the web terminal — the next turn picks it up. The choice is
persisted to `/state/hyperhive-model` so it survives restart;
override path: `HYPERHIVE_MODEL_FILE` env var for tests.
`--continue` keeps a persistent session per agent (claude stores `--continue` keeps a persistent session per agent (claude stores
sessions in `~/.claude/projects/`, which is bind-mounted sessions in `~/.claude/projects/`, which is bind-mounted
persistently). Auto-compact and auto-memory are disabled via persistently). Auto-compact and auto-memory are disabled via
@ -45,6 +51,12 @@ The wake prompt is intentionally minimal: just the popped message's
…)` hint when `unread > 0`. Claude drives any further `recv`/`send` …)` hint when `unread > 0`. Claude drives any further `recv`/`send`
itself via the embedded MCP server. itself via the embedded MCP server.
Whenever hive-c0re starts / restarts / rebuilds a container, it
also drops a `system` message into the agent's inbox via
`Coordinator::kick_agent` — a one-line "you were just (re)started,
check /state/ for your notes, --continue session is intact". The
next turn picks it up like any other inbox message.
### On-boot files ### On-boot files
`hive_ag3nt::turn::write_*` writes three files next to the per-agent `hive_ag3nt::turn::write_*` writes three files next to the per-agent
@ -75,7 +87,11 @@ it as a stdio child via `--mcp-config`. The hyperhive socket name is
- `send(to, body)` — message a peer (logical agent name), another - `send(to, body)` — message a peer (logical agent name), another
agent, or the operator (recipient `operator`, surfaces in the agent, or the operator (recipient `operator`, surfaces in the
dashboard inbox). dashboard inbox).
- `recv()` — drain one inbox message. - `recv(wait_seconds?)` — drain one inbox message. Long-polls
server-side; `wait_seconds` is capped at 180 (default 30 when
omitted). Agents use a long wait to park their turn waiting for
work instead of busy-looping with short polls — they wake
instantly when a message arrives.
### Manager tools (in addition to send/recv) ### Manager tools (in addition to send/recv)
@ -87,12 +103,16 @@ it as a stdio child via `--mcp-config`. The hyperhive socket name is
- `request_apply_commit(agent, commit_ref)` — submit a config - `request_apply_commit(agent, commit_ref)` — submit a config
change for any agent (`hm1nd` for the manager's own config) for change for any agent (`hm1nd` for the manager's own config) for
operator approval. operator approval.
- `ask_operator(question, options?, multi?)` — surface a question - `ask_operator(question, options?, multi?, ttl_seconds?)` —
on the dashboard. Non-blocking — returns the queued question id; surface a question on the dashboard. Non-blocking — returns the
the operator's answer arrives later as queued question id; the operator's answer arrives later as
`HelperEvent::OperatorAnswered` in the manager inbox. Options `HelperEvent::OperatorAnswered` in the manager inbox. Options
always render alongside a free-text fallback; `multi=true` always render alongside a free-text fallback; `multi=true`
renders options as checkboxes. renders options as checkboxes. `ttl_seconds` auto-cancels with
answer `[expired]` after the deadline (useful for time-sensitive
decisions that become moot if the operator hasn't responded).
The operator can also manually cancel with `[cancelled]` via the
dashboard.
The boundary: lifecycle ops on *existing* sub-agents The boundary: lifecycle ops on *existing* sub-agents
(`kill`/`start`/`restart`) are at the manager's discretion — no (`kill`/`start`/`restart`) are at the manager's discretion — no
@ -100,6 +120,21 @@ operator approval. Creating a new agent (`request_spawn`) and
changing any agent's config (`request_apply_commit`) still go changing any agent's config (`request_apply_commit`) still go
through the approval queue. through the approval queue.
### Authoritative state
`hive_ag3nt::events::Bus` carries the current turn-loop state in
addition to the broadcast channel and the events history. Variants:
- `Idle` — sitting on `Recv` waiting for mail.
- `Thinking``claude --print` is running for a turn.
- `Compacting` — operator-triggered `/compact` is in flight.
The harness flips state at the relevant transitions
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
after; `set_state(Compacting)` around `compact_session`). Exposed
via `/api/state.turn_state` + `turn_state_since` (unix seconds);
the agent page renders this rather than deriving from SSE events.
### Tool envelope ### Tool envelope
`mcp::run_tool_envelope`: every MCP tool handler logs the request, `mcp::run_tool_envelope`: every MCP tool handler logs the request,

View file

@ -20,46 +20,84 @@ listener: read `data-confirm`, swap the button to a spinner, POST
`application/x-www-form-urlencoded`, re-enable the button on success `application/x-www-form-urlencoded`, re-enable the button on success
(refreshState may keep the form mounted, so we don't rely on a (refreshState may keep the form mounted, so we don't rely on a
re-render), call `refreshState()`. State shapes live in re-render), call `refreshState()`. State shapes live in
`dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot` `dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot` when
when adding state fields, plumb through the snapshot struct and the adding state fields, plumb through the snapshot struct and the
relevant `assets/app.js` render function. relevant `assets/app.js` render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections
and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as
soon as they blur.
Both bind their listeners with `SO_REUSEADDR` via
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.
## Dashboard sections (top to bottom) ## Dashboard sections (top to bottom)
1. **C0NTAINERS** — live containers with their action surface. 1. **Notification row**`🔔 enable notifications` button when
2. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size + permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted;
inline "unsupported / blocked" message when applicable. Sits
under the banner.
2. **C0NTAINERS** — live containers with their action surface.
3. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes Spawn approval; existing state is reused), `PURG3` (wipes
state + applied dirs; `POST /purge-tombstone/{name}`). state + applied dirs; `POST /purge-tombstone/{name}`).
3. **M1ND H4S QU3STI0NS** — pending `ask_operator` questions 4. **M1ND H4S QU3STI0NS** — pending `ask_operator` questions
(amber pulsing border). Always renders a free-text fallback (amber pulsing border). Free-text fallback always rendered
alongside any option list; `multi=true` renders options as alongside any option list; `multi=true` renders options as
checkboxes; submit merges selections + free text comma-joined. checkboxes; submit merges selections + free text comma-joined.
4. **0PER4T0R 1NB0X** — recent messages addressed to `operator` Each row has a `✗ CANC3L` button that resolves the question
with `[cancelled]`. Questions with a `ttl_seconds` show a
`⏳ MM:SS` chip; the host-side watchdog auto-cancels with
`[expired]` when the deadline fires.
5. **0PER4T0R 1NB0X** — recent messages addressed to `operator`
(last 50, from the broker). (last 50, from the broker).
5. **P3NDING APPR0VALS** — the queue. The R3QU3ST SP4WN form 6. **P3NDING APPR0VALS** — the queue. The R3QU3ST SP4WN form
lives at the top of this section since submitting it immediately lives at the top of this section since submitting it
queues an approval that lands directly below. immediately queues an approval that lands directly below.
6. **MESS4GE FL0W** — live broker SSE tail. 7. **MESS4GE FL0W** — live broker SSE tail.
### Container row ### Container row
Two-line layout (`assets/app.js::renderContainers`): Two-line layout (`assets/app.js::renderContainers`):
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, `needs - Line 1: agent name (link → new tab), m1nd/ag3nt chip, `needs
login` / `needs update` warning badges, in-flight ` pending-state…` login` / `needs update` warning badges, in-flight `
pill (replaces buttons during start/stop/restart/rebuild/destroy), pending-state…` pill (replaces buttons during start / stop /
container name + port. restart / rebuild / destroy), container name + port.
- Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3` - Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3`
on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running, on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running,
`▶ ST4RT` when stopped. Buttons dim + disable while a transient `▶ ST4RT` when stopped. Buttons dim + disable while a transient
lifecycle action is in flight. lifecycle action is in flight.
- Plus a collapsible `↳ logs · <container>` `<details>` block.
Expanding lazy-fetches journald output via `GET
/api/journal/{name}?unit=...&lines=...` (`journalctl -M
<container> -b --no-pager --output=short-iso`). A unit dropdown
switches between the harness service (default) and the full
machine journal; refresh button re-fetches.
`↻ UPD4TE 4LL` button appears above the containers list when any `↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale. agent is stale. Banner pulses on each broker SSE event
(`pulseBanner` with a 4s grace timer).
Banner pulses on each broker SSE event (`pulseBanner` with a 4 s ### Browser notifications
grace timer).
Pure frontend (`Notification` API). Three signals trigger them:
- new pending approval (per id, delta on `/api/state`)
- new pending operator question (per id)
- new broker message sent `to: "operator"` (live via SSE)
First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count.
`tag: "hyperhive"` collapses bursts; click focuses the dashboard
tab. localStorage-backed mute toggle silences without revoking
the OS permission. Requires a secure context (HTTPS or
localhost); on other origins the controls hide themselves.
### Dashboard endpoints ### Dashboard endpoints
@ -67,20 +105,38 @@ grace timer).
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. - `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
- `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs. - `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /answer-question/{id}` — answer a pending operator question. - `POST /answer-question/{id}` — answer a pending operator question.
- `POST /cancel-question/{id}` — cancel a pending question with
the sentinel `[cancelled]`. Same code path as a real answer.
- `POST /request-spawn` — queue a Spawn approval. - `POST /request-spawn` — queue a Spawn approval.
- `POST /update-all` — rebuild every stale container. - `POST /update-all` — rebuild every stale container.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container.
## Per-agent page ## Per-agent page
Layout, top to bottom: Layout, top to bottom:
- Banner (gradient shimmer while state=thinking). - Banner (gradient shimmer while state=thinking).
- Title with `↻ R3BU1LD` button. - Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`.
- Status section (online / needs login / login-in-progress). - Status section (online / needs login / login-in-progress).
- State badge row (`💤 idle / 🧠 thinking / ○ offline · <age>`) + - **State row**: state badge + model chip + last-turn timing +
`■ cancel turn` button visible while state=thinking. cancel-turn button.
- Terminal-wrap: live event tail (with sticky-bottom auto-scroll - State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
and a `↓ N new` pill when not at bottom) followed by an `○ offline` / `… booting`, with an age suffix (`12s`,
`2m 14s`). Driven from `/api/state.turn_state` +
`turn_state_since`; SSE turn_start/turn_end still flip it
instantly between polls. Authoritative source is the
harness's `Bus::state_snapshot()`.
- Model chip: `model · <name>` (e.g. `model · haiku`).
- Last-turn chip: `last turn 12.3s` appears after the first
turn ends, computed from the state-since deltas.
- `■ cancel turn` button: visible only while state=thinking,
POSTs `/api/cancel`.
- Inbox `<details>` block (collapsed): `inbox · N` — last 30
messages addressed to this agent, fetched via
`AgentRequest::Recent { limit: 30 }`.
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
`↓ N new` pill when not at bottom) followed by an
operator-input textarea acting as a prompt. operator-input textarea acting as a prompt.
### Live view ### Live view
@ -92,25 +148,32 @@ The harness emits `TurnStart { from, body, unread }`,
`TurnEnd { ok, note }`. The web UI: `TurnEnd { ok, note }`. The web UI:
- fetches `GET /events/history` on page load and replays the last - fetches `GET /events/history` on page load and replays the last
2000 events (oldest first, with `.no-anim` so they don't stagger); 2000 events (oldest first, with `.no-anim` so they don't
stagger);
- then subscribes to `GET /events/stream` (SSE) for live tail; - then subscribes to `GET /events/stream` (SSE) for live tail;
- shows a granular state badge above the terminal, driven from - shows a granular state badge above the terminal, driven
`turn_start`/`turn_end`, with a flash animation on transition; authoritatively from `/api/state.turn_state`. SSE turn_start /
turn_end still flip the badge instantly between renders;
- sticky-bottom auto-scroll: scrolling up parks the view; new rows - sticky-bottom auto-scroll: scrolling up parks the view; new rows
surface a "↓ N new" pill instead of yanking; surface a "↓ N new" pill instead of yanking;
- terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter - terminal-themed: phosphor mauve glow, Crust bg,
blur, row fade-in slide-up. backdrop-filter blur, row fade-in slide-up.
Per-stream rendering: Per-stream rendering:
- `Stream` `tool_use``→ Read /path` / `→ Bash $ cmd` / `→ send → - `Stream` `tool_use`
operator: "..."` etc., per-tool pretty rather than raw JSON. - `Write` / `Edit`: collapsed `<details>` with a +/- diff body
(`-` lines from `input.old_string`, `+` lines from
`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.
- `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` → text content if claude provided one, - `Stream` `thinking` → text content if claude provided one,
otherwise the bare `· thinking …` indicator. otherwise the bare `· thinking …` indicator.
- `Stream` `system init`, `result`, `rate_limit_event` are dropped - `Stream` `system init`, `result`, `rate_limit_event` are
— too noisy. dropped — too noisy.
- `Note``· text`. - `Note``· text`.
- `TurnEnd``✓ turn ok` / `✗ turn fail — note`, triggers a - `TurnEnd``✓ turn ok` / `✗ turn fail — note`, triggers a
`refreshState()`. `refreshState()`.
@ -118,19 +181,23 @@ Per-stream rendering:
### Terminal-embedded prompt ### Terminal-embedded prompt
The operator input lives *inside* the terminal-wrap as a The operator input lives *inside* the terminal-wrap as a
prompt-style textarea below the live tail: multi-line (Enter sends, prompt-style textarea below the live tail: multi-line (Enter
Shift+Enter newlines), tab-completes slash commands. sends, Shift+Enter newlines), tab-completes slash commands.
Slash commands today: Slash commands today:
- `/help` — list commands locally - `/help` — list commands locally.
- `/clear` — wipe the local terminal view (server history kept) - `/clear` — wipe the local terminal view (server history kept).
- `/cancel``POST /api/cancel` → host shellouts `pkill -INT - `/cancel``POST /api/cancel` → host shellouts `pkill -INT
claude`, emits a Note. Also surfaces as a `■ cancel turn` button claude`, emits a Note. Also surfaces as a `■ cancel turn`
in the state row while state=thinking. button in the state row while state=thinking.
- `/compact``POST /api/compact` → host spawns - `/compact``POST /api/compact` → host spawns
`turn::compact_session` in the background; output streams into `turn::compact_session` in the background; output streams into
the live panel. the live panel.
- `/model <name>``POST /api/model` flipping `Bus::set_model`.
Takes effect on the next turn; persisted to
`/state/hyperhive-model` so the override survives harness
restart / rebuild.
Unknown `/foo` shows an error row instead of being silently sent. Unknown `/foo` shows an error row instead of being silently sent.
@ -140,4 +207,6 @@ Unknown `/foo` shows an error row instead of being silently sent.
- `POST /login/{start,code,cancel}` — claude OAuth login flow. - `POST /login/{start,code,cancel}` — claude OAuth login flow.
- `POST /api/cancel` — SIGINT the in-flight claude turn. - `POST /api/cancel` — SIGINT the in-flight claude turn.
- `POST /api/compact` — run `/compact` on the persistent session. - `POST /api/compact` — run `/compact` on the persistent session.
- `POST /api/model` (`model=<name>`) — switch the model for
future turns.
- `GET /events/history` — replay buffer for the terminal. - `GET /events/history` — replay buffer for the terminal.

View file

@ -67,14 +67,16 @@
claude-unstable = claude-unstable =
final: prev: final: prev:
let let
# Inherit the *user's* nixpkgs config so allowUnfree (or an # The overlay imports its own nixpkgs-unstable instance to
# `allowUnfreePredicate` they set on their flake) propagates # pin claude-code there. That instance has its own config
# into the unstable import. hyperhive does not silently # (independent from the user's prev.config), so we have to
# bypass the unfree gate — if the operator hasn't opted in, # set allowUnfreePredicate inline to whitelist claude-code
# this overlay's `claude-code` access fails honestly. # specifically — otherwise the unstable import itself
# refuses to evaluate. This is scoped: only claude-code
# bypasses unfree, nothing else.
unstable = import nixpkgs-unstable { unstable = import nixpkgs-unstable {
inherit (prev.stdenv.hostPlatform) system; inherit (prev.stdenv.hostPlatform) system;
config = prev.config; config.allowUnfreePredicate = pkg: builtins.elem (prev.lib.getName pkg) [ "claude-code" ];
}; };
in in
{ {

View file

@ -7,12 +7,11 @@
boot.isNspawnContainer = true; boot.isNspawnContainer = true;
# `claude-code` is unfree. hyperhive intentionally does NOT auto-allow # `claude-code` is unfree. Each per-agent container's nixosConfiguration
# it — the operator opts in by setting # evaluates its own `nixpkgs` instance, so the operator's host-level
# `nixpkgs.config.allowUnfreePredicate` (or `allowUnfree = true`) in # `nixpkgs.config.allowUnfreePredicate` does not propagate into here —
# their own host config / agent.nix. Without that, the per-agent # we have to allow it inside the container's config as well.
# build fails on this package and the operator sees an honest "this nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
# is unfree, are you sure?" error.
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
hyperhive hyperhive