Compare commits

..
5 changed files with 56 additions and 214 deletions

View file

@ -123,53 +123,31 @@ 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 module-only flake (forwards every `inputs.*` repo is a tiny module-only flake (`nixosModules.default =
through to `agent.nix` as the `flakeInputs` module arg — import ./agent.nix`); `agent.nix` is a plain NixOS module
manager edits `inputs` to pull in external flakes like an function — no extendModules, no hyperhive input visible to
MCP server's own flake; the new sha lands in the agent's the manager. A single hive-c0re-owned repo at
own `flake.lock` and rolls up to meta's). A single `/var/lib/hyperhive/meta/` declares one input per agent
hive-c0re-owned repo at `/var/lib/hyperhive/meta/` (pointing at that agent's applied repo via `git+file://`)
declares one input per agent and one and one `nixosConfigurations.<n>` output per agent,
`nixosConfigurations.<n>` output, wrapping the agent's wrapping `inputs.agent-<n>.nixosModules.default` with the
`nixosModules.default` with identity + `HIVE_PORT` / identity + `HIVE_PORT` / `HIVE_LABEL` /
`HIVE_LABEL` / `HIVE_DASHBOARD_PORT` / `HIVE_DASHBOARD_PORT` injection. Containers run against
`HIVE_OPERATOR_PRONOUNS`. Containers run against `meta#<n>`. Every approve runs `nix flake lock
`meta#<n>`. Every approve uses two-phase staging --update-input agent-<n>` (two-phase: prepare on the
(prepare → build → finalize/abort) so meta's git log only build path, finalize/abort on the result) — meta's git
records successful deploys; failures + denials live as log is the system-wide deploy audit trail; failures and
annotated tags in applied. All meta operations denials live as annotated tags in applied. The manager
serialize behind a tokio mutex; stale `.git/index.lock` has `/applied` and `/meta` RO-bound and the `applied`
is cleared on hive-c0re startup. Manager has `/applied` remote pre-wired in every proposed repo so `git fetch
+ `/meta` RO-bound + the `applied` remote pre-wired in applied`, `git show applied/refs/tags/deployed/<id>`,
every proposed repo. Migration runs idempotently on `git -C /meta log --oneline`, `cat /meta/flake.lock`
startup (`HIVE_SKIP_META_MIGRATION=1` skips). Operator all just work. Migration runs idempotently on
pronouns are a NixOS module option hive-c0re startup (`HIVE_SKIP_META_MIGRATION=1` skips it):
(`services.hive-c0re.operatorPronouns`, default rewrites pre-meta applied flakes to module-only, wires
`"she/her"`); the harness substitutes them into the the proposed remote, seeds meta, and repoints every
system prompt at boot. container at `meta#<n>` (guarded by a marker so the
- **Just landed:** per-agent extra MCP servers via the expensive phase only runs once).
`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,9 +36,7 @@ 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 / ask_operator + agent-declared extras MCP (send / recv) + web UI on a hashed :8100-8999
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
@ -49,20 +47,12 @@ 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>` `/new-session`; granular state badge (idle / thinking `/model <name>`; 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 + new-session buttons in the state row; model chip; cancel-turn button while thinking; sticky-bottom
sticky-bottom auto-scroll with "↓ N new" pill; event history auto-scroll with "↓ N new" pill; event history backfilled on page
backfilled on page load; collapsible inbox + collapsible journald load; collapsible inbox + collapsible journald viewer + collapsible
viewer + collapsible `agent.nix` viewer per agent on the dashboard; `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
@ -112,11 +102,6 @@ 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,18 +45,6 @@ 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
@ -80,11 +68,8 @@ 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}` and `hive-ag3nt/prompts/{agent,manager}.md` with `{label}`
`{operator_pronouns}` substituted. Pronouns come from substituted. Passed via `--system-prompt-file`.
`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,
@ -107,25 +92,6 @@ 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,21 +74,7 @@ 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 (newest-first). 7. **MESS4GE FL0W** — live broker SSE tail.
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
@ -154,9 +140,6 @@ 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
@ -175,7 +158,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 + new-session button. cancel-turn 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` +
@ -187,11 +170,6 @@ 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
@ -260,9 +238,6 @@ 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.
@ -271,11 +246,7 @@ 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,18 +428,7 @@ 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,
// --update-head-ok lets us fetch into refs/heads/main while &["fetch", "--no-tags", &proposed_str, "main:refs/heads/main"],
// 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?;
@ -792,74 +781,27 @@ 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<()> {
use tokio::io::{AsyncBufReadExt, BufReader}; let out = Command::new("nixos-container")
let cmdline = args.join(" ");
let mut child = Command::new("nixos-container")
.args(args) .args(args)
.stdout(std::process::Stdio::piped()) .output()
.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!("wait nixos-container {cmdline}"))?; .with_context(|| format!("invoke nixos-container {}", args.join(" ")))?;
let _ = pump_stdout.await; let stdout = String::from_utf8_lossy(&out.stdout);
let _ = pump_stderr.await; let stderr = String::from_utf8_lossy(&out.stderr);
if !stdout.trim().is_empty() {
if !status.success() { tracing::info!(target: "nixos-container", "{}", stdout.trim());
let tail = stderr_tail }
.lock() if !stderr.trim().is_empty() {
.unwrap() tracing::warn!(target: "nixos-container", "{}", stderr.trim());
.iter() }
.cloned() if !out.status.success() {
.collect::<Vec<_>>() bail!(
.join("\n"); "nixos-container {} failed ({}): {}",
bail!("nixos-container {cmdline} failed ({status}): {tail}"); args.join(" "),
out.status,
stderr.trim()
);
} }
Ok(()) Ok(())
} }