From 1278f880da8dfc48769668e9dc6b63bb412246b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 02:49:48 +0200 Subject: [PATCH 1/3] docs: sync to current state of the world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 72 +++++++++++++++++++++++++++++++---------------- README.md | 29 ++++++++++++++----- docs/turn-loop.md | 38 +++++++++++++++++++++++-- docs/web-ui.md | 35 +++++++++++++++++++++-- 4 files changed, 137 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 631ef259..8a523180 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,31 +123,53 @@ In-flight or recent context that hasn't earned a section yet. Prune freely. - **Just landed:** meta-flake overhaul. Each agent's applied - repo is a tiny module-only flake (`nixosModules.default = - import ./agent.nix`); `agent.nix` is a plain NixOS module - function — no extendModules, no hyperhive input visible to - the manager. A single hive-c0re-owned repo at - `/var/lib/hyperhive/meta/` declares one input per agent - (pointing at that agent's applied repo via `git+file://`) - and one `nixosConfigurations.` output per agent, - wrapping `inputs.agent-.nixosModules.default` with the - identity + `HIVE_PORT` / `HIVE_LABEL` / - `HIVE_DASHBOARD_PORT` injection. Containers run against - `meta#`. Every approve runs `nix flake lock - --update-input agent-` (two-phase: prepare on the - build path, finalize/abort on the result) — meta's git - log is the system-wide deploy audit trail; failures and - denials live as annotated tags in applied. The manager - has `/applied` and `/meta` RO-bound and the `applied` - remote pre-wired in every proposed repo so `git fetch - applied`, `git show applied/refs/tags/deployed/`, - `git -C /meta log --oneline`, `cat /meta/flake.lock` - all just work. Migration runs idempotently on - hive-c0re startup (`HIVE_SKIP_META_MIGRATION=1` skips it): - rewrites pre-meta applied flakes to module-only, wires - the proposed remote, seeds meta, and repoints every - container at `meta#` (guarded by a marker so the - expensive phase only runs once). + repo is a module-only flake (forwards every `inputs.*` + through to `agent.nix` as the `flakeInputs` module arg — + manager edits `inputs` to pull in external flakes like an + MCP server's own flake; the new sha lands in the agent's + own `flake.lock` and rolls up to meta's). A single + hive-c0re-owned repo at `/var/lib/hyperhive/meta/` + declares one input per agent and one + `nixosConfigurations.` output, wrapping the agent's + `nixosModules.default` with identity + `HIVE_PORT` / + `HIVE_LABEL` / `HIVE_DASHBOARD_PORT` / + `HIVE_OPERATOR_PRONOUNS`. Containers run against + `meta#`. Every approve uses two-phase staging + (prepare → build → finalize/abort) so meta's git log only + records successful deploys; failures + denials live as + annotated tags in applied. All meta operations + serialize behind a tokio mutex; stale `.git/index.lock` + is cleared on hive-c0re startup. Manager has `/applied` + + `/meta` RO-bound + the `applied` remote pre-wired in + every proposed repo. Migration runs idempotently on + startup (`HIVE_SKIP_META_MIGRATION=1` skips). Operator + pronouns are a NixOS module option + (`services.hive-c0re.operatorPronouns`, default + `"she/her"`); the harness substitutes them into the + system prompt at boot. +- **Just landed:** per-agent extra MCP servers via the + `hyperhive.extraMcpServers.` 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____`). + 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..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- driven config-apply. Two-repo split (proposed = manager RW, applied = core-only); `request_apply_commit` fetches diff --git a/README.md b/README.md index 5fbcf390..57be5ac5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ host (NixOS, runs hive-c0re.service) │ ask_operator) + web UI on :8000 │ └── h- 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 @@ -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 prompt; slash commands `/help` `/clear` `/cancel` `/compact` -`/model `; granular state badge (idle / thinking / -compacting / offline) with age timer + last-turn duration chip + -model chip; cancel-turn button while thinking; sticky-bottom -auto-scroll with "↓ N new" pill; event history backfilled on page -load; collapsible inbox + collapsible journald viewer + collapsible -`agent.nix` viewer per agent on the dashboard. +`/model ` `/new-session`; granular state badge (idle / thinking +/ compacting / offline) with age timer + last-turn duration chip + +model chip; cancel-turn + new-session buttons in the state row; +sticky-bottom auto-scroll with "↓ N new" pill; event history +backfilled on page load; collapsible inbox + collapsible journald +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:, body}`. Same shape any sub-agent +sees as a regular inbox message. Config changes flow the other way: manager edits files under `/agents//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 ({ ... }: { 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, …) system.stateVersion = "25.11"; }) diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 3c8fb757..6a6bab3f 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -45,6 +45,18 @@ sessions in `~/.claude/projects/`, which is bind-mounted persistently). Auto-compact and auto-memory are disabled via `--settings` because hyperhive owns compaction (`/compact` on 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 `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 auto-memory off, effortLevel medium). - `claude-system-prompt.md` — rendered from - `hive-ag3nt/prompts/{agent,manager}.md` with `{label}` - substituted. Passed via `--system-prompt-file`. + `hive-ag3nt/prompts/{agent,manager}.md` with `{label}` and + `{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, 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 work instead of busy-looping with short polls — they wake 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. = { 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.`) +and `--allowedTools` (as `mcp____`). 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..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) diff --git a/docs/web-ui.md b/docs/web-ui.md index c333a845..2fec461a 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -74,7 +74,21 @@ the previous process's socket release resolves itself. 6. **P3NDING APPR0VALS** — the queue. The R3QU3ST SP4WN form lives at the top of this section since submitting it 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 `@ 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 @@ -140,6 +154,9 @@ not ours. the sentinel `[cancelled]`. Same code path as a real answer. - `POST /request-spawn` — queue a Spawn approval. - `POST /update-all` — rebuild every stale container. +- `POST /op-send` (`to=`, `body=`) — drop an + operator-authored message into ``'s inbox. Used by the + compose textbox under MESS4GE FL0W. - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for a managed container. - `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`. - Status section (online / needs login / login-in-progress). - **State row**: state badge + model chip + last-turn timing + - cancel-turn button. + cancel-turn button + new-session button. - State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` / `○ offline` / `… booting`, with an age suffix (`12s`, `2m 14s`). Driven from `/api/state.turn_state` + @@ -170,6 +187,11 @@ Layout, top to bottom: turn ends, computed from the state-since deltas. - `■ cancel turn` button: visible only while state=thinking, 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 `
` block (collapsed): `inbox · N` — last 30 messages addressed to this agent, fetched via `AgentRequest::Recent { limit: 30 }`. (Separate from @@ -238,6 +260,9 @@ Slash commands today: Takes effect on the next turn; persisted to `/state/hyperhive-model` so the override survives harness 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. @@ -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 /login/{start,code,cancel}` — claude OAuth login flow. - `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=`) — switch the model for 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. From 6f1b664c859d007d34690b9c706c9c7d541f456c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 02:57:16 +0200 Subject: [PATCH 2/3] lifecycle: stream nixos-container stdout/stderr line-by-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hive-c0re/src/lifecycle.rs | 83 +++++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index fe3432dd..b3395db0 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -781,27 +781,74 @@ fn set_nspawn_flags( Ok(()) } +/// Spawn `nixos-container ` 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<()> { - let out = Command::new("nixos-container") + use tokio::io::{AsyncBufReadExt, BufReader}; + let cmdline = args.join(" "); + let mut child = Command::new("nixos-container") .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::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 - .with_context(|| format!("invoke nixos-container {}", args.join(" ")))?; - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - if !stdout.trim().is_empty() { - tracing::info!(target: "nixos-container", "{}", stdout.trim()); - } - if !stderr.trim().is_empty() { - tracing::warn!(target: "nixos-container", "{}", stderr.trim()); - } - if !out.status.success() { - bail!( - "nixos-container {} failed ({}): {}", - args.join(" "), - out.status, - stderr.trim() - ); + .with_context(|| format!("wait nixos-container {cmdline}"))?; + let _ = pump_stdout.await; + let _ = pump_stderr.await; + + if !status.success() { + let tail = stderr_tail + .lock() + .unwrap() + .iter() + .cloned() + .collect::>() + .join("\n"); + bail!("nixos-container {cmdline} failed ({status}): {tail}"); } Ok(()) } From c92108a11c107aaca8694bbb4b8e6bc9d5e3fd2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 02:58:34 +0200 Subject: [PATCH 3/3] lifecycle: fetch into checked-out main with --update-head-ok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup_applied does `git init --initial-branch=main` then `git fetch 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' --- hive-c0re/src/lifecycle.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index b3395db0..59580810 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -428,7 +428,18 @@ pub async fn setup_applied( let proposed_str = proposed.display().to_string(); git( 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?; git_read_tree_reset(applied_dir, "refs/heads/main").await?;