Compare commits

...
Author SHA1 Message Date
müde
c92108a11c lifecycle: fetch into checked-out main with --update-head-ok
setup_applied does `git init --initial-branch=main` then
`git fetch <proposed> main:refs/heads/main` to seed the
applied repo with proposed's initial commit. git's default
safeguard refuses to fetch into the currently-checked-out
branch, even though the working tree is empty (we just init'd).
add --update-head-ok to bypass — the read-tree-reset
immediately after fetches the right state, so the safeguard
the flag bypasses isn't relevant here anyway.

repro from the user: spawn of 'dmatrix' failed with
  fatal: refusing to fetch into branch 'refs/heads/main'
  checked out at '/var/lib/hyperhive/applied/dmatrix'
2026-05-16 02:58:34 +02:00
müde
6f1b664c85 lifecycle: stream nixos-container stdout/stderr line-by-line
run() previously buffered the child's output via .output() and
only logged at exit — a multi-minute 'nixos-container update'
(typical on a fresh hyperhive bump) showed nothing in journald
until the very end. operator watching 'journalctl -u hive-c0re
-f' couldn't tell 'slow nix build' from 'wedged daemon'.

new shape: spawn with piped stdio, pump each line into tracing
as it arrives (stdout → INFO, stderr → WARN), keep a tail of
the last 32 stderr lines for the bail message so the eventual
'failed (status 2)' still carries the actual nix eval error.
target field 'nixos-container', argv-equivalent attached via
the 'cmdline' field so filtering by subcommand works.
2026-05-16 02:57:16 +02:00
müde
1278f880da docs: sync to current state of the world
claude.md scratchpad rewritten — folds in pronouns option,
extra MCP servers + flakeInputs forwarding, ask_operator
on sub-agents, dashboard compose box with @-mentions, new-
session button, cwd=/state for claude turns, meta-mutex +
stale-lock cleanup.

readme picks up the operator pronouns option example,
the dashboard compose box description, the new slash
commands list, the deployed-sha chip, the per-agent UI
gains new-session.

docs/web-ui.md gains:
- a fuller MESS4GE FL0W description that calls out the
  compose box, sticky @-mention recipient, /op-send, and
  the manager-name swap
- /op-send in the dashboard endpoint table
- new-session button + /new-session slash command in the
  per-agent surface
- compact endpoint now notes 'same session shape as a normal
  turn'

docs/turn-loop.md:
- new-session one-shot, cwd=/state with CLAUDE.md auto-load
  walking upward, operator-pronouns substitution
- sub-agent tool list grows ask_operator
- new 'Extra MCP servers (per-agent)' section documenting
  hyperhive.extraMcpServers + the flakeInputs forwarding
  pattern
2026-05-16 02:49:48 +02:00
5 changed files with 214 additions and 56 deletions

View file

@ -123,31 +123,53 @@ In-flight or recent context that hasn't earned a section yet.
Prune freely. Prune freely.
- **Just landed:** meta-flake overhaul. Each agent's applied - **Just landed:** meta-flake overhaul. Each agent's applied
repo is a tiny module-only flake (`nixosModules.default = repo is a module-only flake (forwards every `inputs.*`
import ./agent.nix`); `agent.nix` is a plain NixOS module through to `agent.nix` as the `flakeInputs` module arg —
function — no extendModules, no hyperhive input visible to manager edits `inputs` to pull in external flakes like an
the manager. A single hive-c0re-owned repo at MCP server's own flake; the new sha lands in the agent's
`/var/lib/hyperhive/meta/` declares one input per agent own `flake.lock` and rolls up to meta's). A single
(pointing at that agent's applied repo via `git+file://`) hive-c0re-owned repo at `/var/lib/hyperhive/meta/`
and one `nixosConfigurations.<n>` output per agent, declares one input per agent and one
wrapping `inputs.agent-<n>.nixosModules.default` with the `nixosConfigurations.<n>` output, wrapping the agent's
identity + `HIVE_PORT` / `HIVE_LABEL` / `nixosModules.default` with identity + `HIVE_PORT` /
`HIVE_DASHBOARD_PORT` injection. Containers run against `HIVE_LABEL` / `HIVE_DASHBOARD_PORT` /
`meta#<n>`. Every approve runs `nix flake lock `HIVE_OPERATOR_PRONOUNS`. Containers run against
--update-input agent-<n>` (two-phase: prepare on the `meta#<n>`. Every approve uses two-phase staging
build path, finalize/abort on the result) — meta's git (prepare → build → finalize/abort) so meta's git log only
log is the system-wide deploy audit trail; failures and records successful deploys; failures + denials live as
denials live as annotated tags in applied. The manager annotated tags in applied. All meta operations
has `/applied` and `/meta` RO-bound and the `applied` serialize behind a tokio mutex; stale `.git/index.lock`
remote pre-wired in every proposed repo so `git fetch is cleared on hive-c0re startup. Manager has `/applied`
applied`, `git show applied/refs/tags/deployed/<id>`, + `/meta` RO-bound + the `applied` remote pre-wired in
`git -C /meta log --oneline`, `cat /meta/flake.lock` every proposed repo. Migration runs idempotently on
all just work. Migration runs idempotently on startup (`HIVE_SKIP_META_MIGRATION=1` skips). Operator
hive-c0re startup (`HIVE_SKIP_META_MIGRATION=1` skips it): pronouns are a NixOS module option
rewrites pre-meta applied flakes to module-only, wires (`services.hive-c0re.operatorPronouns`, default
the proposed remote, seeds meta, and repoints every `"she/her"`); the harness substitutes them into the
container at `meta#<n>` (guarded by a marker so the system prompt at boot.
expensive phase only runs once). - **Just landed:** per-agent extra MCP servers via the
`hyperhive.extraMcpServers.<key>` NixOS option in
`agent.nix`. Declares `{ command, args, env,
allowedTools }`; the module writes the whole map to
`/etc/hyperhive/extra-mcp.json`; the harness reads that
file and merges each entry into both `--mcp-config`
and `--allowedTools` (mapped to `mcp__<key>__<pattern>`).
Unblocks matrix / bitburner / any agent with rich
domain tooling — the agent flake's `inputs` block pulls
the external flake, `agent.nix` references it via
`flakeInputs.<name>.packages.${pkgs.system}.default`.
- **Just landed:** `mcp__hyperhive__ask_operator` is now on
the sub-agent surface too (not just the manager). Answer
routes back to whichever agent asked via
`coord.notify_agent`; the dashboard already shows the
asker on each question row.
- **Just landed:** dashboard now has a terminal-style
compose textbox under the message-flow stream — `@name`
picks the recipient (sticky in localStorage, auto-
completed from `containers[]`), POSTs `/op-send`. New
per-agent `↻ new session` button drops `--continue` for
one turn. Claude spawns with `cwd = /state` so relative
paths in tool calls land in the durable dir.
- **Just landed (prior overhaul still underneath):** tag- - **Just landed (prior overhaul still underneath):** tag-
driven config-apply. Two-repo split (proposed = manager driven config-apply. Two-repo split (proposed = manager
RW, applied = core-only); `request_apply_commit` fetches RW, applied = core-only); `request_apply_commit` fetches

View file

@ -36,7 +36,9 @@ host (NixOS, runs hive-c0re.service)
│ ask_operator) + web UI on :8000 │ ask_operator) + web UI on :8000
└── h-<name> hive-ag3nt serve : claude turn loop + └── h-<name> hive-ag3nt serve : claude turn loop +
MCP (send / recv) + web UI on a hashed :8100-8999 MCP (send / recv / ask_operator + agent-declared extras
via hyperhive.extraMcpServers) + web UI on a
hashed :8100-8999
``` ```
Each turn: harness pops one inbox message (Recv long-polls server-side and Each turn: harness pops one inbox message (Recv long-polls server-side and
@ -47,12 +49,20 @@ claude drives any further `recv`/`send` itself via the embedded MCP server.
Operator surface per agent: terminal-themed live tail with a textarea Operator surface per agent: terminal-themed live tail with a textarea
prompt; slash commands `/help` `/clear` `/cancel` `/compact` prompt; slash commands `/help` `/clear` `/cancel` `/compact`
`/model <name>`; granular state badge (idle / thinking / `/model <name>` `/new-session`; granular state badge (idle / thinking
compacting / offline) with age timer + last-turn duration chip + / compacting / offline) with age timer + last-turn duration chip +
model chip; cancel-turn button while thinking; sticky-bottom model chip; cancel-turn + new-session buttons in the state row;
auto-scroll with "↓ N new" pill; event history backfilled on page sticky-bottom auto-scroll with "↓ N new" pill; event history
load; collapsible inbox + collapsible journald viewer + collapsible backfilled on page load; collapsible inbox + collapsible journald
`agent.nix` viewer per agent on the dashboard. viewer + collapsible `agent.nix` viewer per agent on the dashboard;
deployed-sha chip per container (read from meta's `flake.lock`).
Operator surface on the dashboard itself: a terminal compose box
under the message-flow stream — `@name` picks the recipient with
auto-complete from the live container list, sticky across sends,
POSTs `/op-send` which drops the message into the broker as
`{from:"operator", to:<name>, body}`. Same shape any sub-agent
sees as a regular inbox message.
Config changes flow the other way: manager edits files under Config changes flow the other way: manager edits files under
`/agents/<name>/config/``agent.nix` is a plain NixOS module function `/agents/<name>/config/``agent.nix` is a plain NixOS module function
@ -102,6 +112,11 @@ Minimal `flake.nix` for a host that runs hive-c0re:
hyperhive.nixosModules.hive-c0re hyperhive.nixosModules.hive-c0re
({ ... }: { ({ ... }: {
services.hive-c0re.enable = true; services.hive-c0re.enable = true;
# Free-text operator pronouns — defaults to "she/her", threaded
# through to every agent's system prompt as HIVE_OPERATOR_PRONOUNS
# so claude refers to you naturally in third person.
# services.hive-c0re.operatorPronouns = "they/them";
# ... rest of your host config (hardware, networking, users, …) # ... rest of your host config (hardware, networking, users, …)
system.stateVersion = "25.11"; system.stateVersion = "25.11";
}) })

View file

@ -45,6 +45,18 @@ 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
`--settings` because hyperhive owns compaction (`/compact` on `--settings` because hyperhive owns compaction (`/compact` on
overflow, retry once; operator can also force one via `/api/compact`). overflow, retry once; operator can also force one via `/api/compact`).
A one-shot `--continue` suppression is available via
`POST /api/new-session` (or `/new-session` slash command in the
per-agent terminal) — `Bus::take_skip_continue()` flips an
`AtomicBool` once per turn, the next claude invocation drops
`--continue`, every subsequent turn resumes normal behaviour.
The child runs with `cwd = /state` (when the bind exists; falls
back to the parent's cwd in dev), so any relative path in a tool
call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the
agent's durable bind-mounted dir. CLAUDE.md auto-load walks
upward from `/state` — drop a per-agent CLAUDE.md there if you
want long-term hints that survive destroy/recreate.
The wake prompt is intentionally minimal: just the popped message's The wake prompt is intentionally minimal: just the popped message's
`from`/`body`, plus an inline `({unread} more pending — drain via `from`/`body`, plus an inline `({unread} more pending — drain via
@ -68,8 +80,11 @@ socket at `/run/hive/` once at startup:
- `claude-settings.json` — the `--settings` blob (auto-compact and - `claude-settings.json` — the `--settings` blob (auto-compact and
auto-memory off, effortLevel medium). auto-memory off, effortLevel medium).
- `claude-system-prompt.md` — rendered from - `claude-system-prompt.md` — rendered from
`hive-ag3nt/prompts/{agent,manager}.md` with `{label}` `hive-ag3nt/prompts/{agent,manager}.md` with `{label}` and
substituted. Passed via `--system-prompt-file`. `{operator_pronouns}` substituted. Pronouns come from
`HIVE_OPERATOR_PRONOUNS` env (set by the meta flake from
`services.hive-c0re.operatorPronouns`, default `she/her`).
Passed via `--system-prompt-file`.
The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config, The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config,
write_settings, write_system_prompt, run_turn, drive_turn, write_settings, write_system_prompt, run_turn, drive_turn,
@ -92,6 +107,25 @@ it as a stdio child via `--mcp-config`. The hyperhive socket name is
omitted). Agents use a long wait to park their turn waiting for omitted). Agents use a long wait to park their turn waiting for
work instead of busy-looping with short polls — they wake work instead of busy-looping with short polls — they wake
instantly when a message arrives. instantly when a message arrives.
- `ask_operator(question, options?, multi?, ttl_seconds?)`
surface a question on the dashboard. Same shape as the manager's;
answer routes back to the asker's own inbox as
`HelperEvent::OperatorAnswered` via `coord.notify_agent`.
### Extra MCP servers (per-agent)
Each agent's NixOS config can declare additional MCP servers via
`hyperhive.extraMcpServers.<key> = { command, args, env,
allowedTools }`. The module writes the map to
`/etc/hyperhive/extra-mcp.json`; the harness reads it at boot and
merges every entry into `--mcp-config` (under `mcpServers.<key>`)
and `--allowedTools` (as `mcp__<key>__<pattern>`). The agent's
flake.nix forwards every flake input to `agent.nix` as the
`flakeInputs` module arg, so external MCP-server flakes are pulled
in by adding them to `inputs.*` and referenced as
`flakeInputs.<name>.packages.${pkgs.system}.default` — the
resolved sha lands in the agent's own `flake.lock` and rolls up to
meta's.
### Manager tools (in addition to send/recv) ### Manager tools (in addition to send/recv)

View file

@ -74,7 +74,21 @@ the previous process's socket release resolves itself.
6. **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 lives at the top of this section since submitting it
immediately queues an approval that lands directly below. immediately queues an approval that lands directly below.
7. **MESS4GE FL0W** — live broker SSE tail. 7. **MESS4GE FL0W** — live broker SSE tail (newest-first).
Each row is one broker event — `sent` or `delivered` — with
`from → to: body`; per-agent thinking / tool calls / claude
chatter stay out of this view, only what passes through
hive-c0re's broker. Below the stream sits a terminal-style
compose box: `@name` picks the recipient (sticky across
sends via localStorage; auto-complete from the live
container list, Tab/Enter to confirm), starting a message
with `@<name> body` retargets in one stroke, plain text
sends to the sticky recipient. `POST /op-send` drops
`{from:"operator", to, body}` into the broker — same shape
any sub-agent sees as a regular inbox message. Manager is
addressed as `@manager` (the broker recipient string), not
`@hm1nd` (the container name); the auto-complete swaps
automatically.
### Container row ### Container row
@ -140,6 +154,9 @@ not ours.
the sentinel `[cancelled]`. Same code path as a real answer. 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.
- `POST /op-send` (`to=<name>`, `body=<text>`) — drop an
operator-authored message into `<name>`'s inbox. Used by the
compose textbox under MESS4GE FL0W.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container. a managed container.
- `GET /api/agent-config/{name}` — read-only view of the applied - `GET /api/agent-config/{name}` — read-only view of the applied
@ -158,7 +175,7 @@ Layout, top to bottom:
- Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. - 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 row**: state badge + model chip + last-turn timing + - **State row**: state badge + model chip + last-turn timing +
cancel-turn button. cancel-turn button + new-session button.
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` / - State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting`, with an age suffix (`12s`, `○ offline` / `… booting`, with an age suffix (`12s`,
`2m 14s`). Driven from `/api/state.turn_state` + `2m 14s`). Driven from `/api/state.turn_state` +
@ -170,6 +187,11 @@ Layout, top to bottom:
turn ends, computed from the state-since deltas. turn ends, computed from the state-since deltas.
- `■ cancel turn` button: visible only while state=thinking, - `■ cancel turn` button: visible only while state=thinking,
POSTs `/api/cancel`. POSTs `/api/cancel`.
- `↻ new session` button: always visible, amber. Confirms
via `window.confirm()` then POSTs `/api/new-session` to
arm a one-shot Bus flag — the next turn drops
`--continue`, starting a fresh claude session. Subsequent
turns resume normal `--continue`.
- Inbox `<details>` block (collapsed): `inbox · N` — last 30 - Inbox `<details>` block (collapsed): `inbox · N` — last 30
messages addressed to this agent, fetched via messages addressed to this agent, fetched via
`AgentRequest::Recent { limit: 30 }`. (Separate from `AgentRequest::Recent { limit: 30 }`. (Separate from
@ -238,6 +260,9 @@ Slash commands today:
Takes effect on the next turn; persisted to Takes effect on the next turn; persisted to
`/state/hyperhive-model` so the override survives harness `/state/hyperhive-model` so the override survives harness
restart / rebuild. restart / rebuild.
- `/new-session``POST /api/new-session` (confirms first).
Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely.
Unknown `/foo` shows an error row instead of being silently sent. Unknown `/foo` shows an error row instead of being silently sent.
@ -246,7 +271,11 @@ Unknown `/foo` shows an error row instead of being silently sent.
- `POST /send` — operator-injected message into this agent's inbox. - `POST /send` — operator-injected message into this agent's inbox.
- `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
(same MCP config + system prompt + allowed tools as a normal
turn — only the stdin payload differs).
- `POST /api/model` (`model=<name>`) — switch the model for - `POST /api/model` (`model=<name>`) — switch the model for
future turns. future turns.
- `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`.
- `GET /events/history` — replay buffer for the terminal. - `GET /events/history` — replay buffer for the terminal.

View file

@ -428,7 +428,18 @@ pub async fn setup_applied(
let proposed_str = proposed.display().to_string(); let proposed_str = proposed.display().to_string();
git( git(
applied_dir, applied_dir,
&["fetch", "--no-tags", &proposed_str, "main:refs/heads/main"], // --update-head-ok lets us fetch into refs/heads/main while
// HEAD still points there. git's default safeguard refuses
// to avoid index/working-tree desync, but the working tree
// is empty (we just `init`'d) and we read-tree-reset right
// after, so the safeguard is moot here.
&[
"fetch",
"--no-tags",
"--update-head-ok",
&proposed_str,
"main:refs/heads/main",
],
) )
.await?; .await?;
git_read_tree_reset(applied_dir, "refs/heads/main").await?; git_read_tree_reset(applied_dir, "refs/heads/main").await?;
@ -781,27 +792,74 @@ fn set_nspawn_flags(
Ok(()) Ok(())
} }
/// Spawn `nixos-container <args>` and pipe its stdout + stderr into
/// `tracing` one line at a time so a long-running command (most
/// notably `update`, which kicks off a full nix build that can run
/// for minutes on a stale flake) shows progress in journald as it
/// happens. The buffered `.output()` we used before only flushed the
/// summary at exit, which made "slow" and "stuck" look identical to
/// the operator watching `journalctl -u hive-c0re -f`.
///
/// stdout lines log at INFO, stderr at WARN. Stderr lines are also
/// collected into a single string so the bailout message at the end
/// can include the actual failure reason (nix dumps eval errors to
/// stderr).
async fn run(args: &[&str]) -> Result<()> { async fn run(args: &[&str]) -> Result<()> {
let out = Command::new("nixos-container") use tokio::io::{AsyncBufReadExt, BufReader};
let cmdline = args.join(" ");
let mut child = Command::new("nixos-container")
.args(args) .args(args)
.output() .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("invoke nixos-container {cmdline}"))?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stdout_cmdline = cmdline.clone();
let pump_stdout = tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::info!(target: "nixos-container", cmdline = %stdout_cmdline, "{line}");
}
});
// Tail of stderr lines (last 32) for the bailout message. Newer
// lines push older ones out; nix's actual error usually lands
// in the last few lines.
let stderr_cmdline = cmdline.clone();
let stderr_tail: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>> =
std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::with_capacity(32)));
let stderr_tail_pump = stderr_tail.clone();
let pump_stderr = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!(target: "nixos-container", cmdline = %stderr_cmdline, "{line}");
let mut tail = stderr_tail_pump.lock().unwrap();
if tail.len() == 32 {
tail.pop_front();
}
tail.push_back(line);
}
});
let status = child
.wait()
.await .await
.with_context(|| format!("invoke nixos-container {}", args.join(" ")))?; .with_context(|| format!("wait nixos-container {cmdline}"))?;
let stdout = String::from_utf8_lossy(&out.stdout); let _ = pump_stdout.await;
let stderr = String::from_utf8_lossy(&out.stderr); let _ = pump_stderr.await;
if !stdout.trim().is_empty() {
tracing::info!(target: "nixos-container", "{}", stdout.trim()); if !status.success() {
} let tail = stderr_tail
if !stderr.trim().is_empty() { .lock()
tracing::warn!(target: "nixos-container", "{}", stderr.trim()); .unwrap()
} .iter()
if !out.status.success() { .cloned()
bail!( .collect::<Vec<_>>()
"nixos-container {} failed ({}): {}", .join("\n");
args.join(" "), bail!("nixos-container {cmdline} failed ({status}): {tail}");
out.status,
stderr.trim()
);
} }
Ok(()) Ok(())
} }