diff --git a/CLAUDE.md b/CLAUDE.md index fea64a49..be0efecd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,15 +10,19 @@ Operator + dev notes: conventions, gotchas, per-subsystem design. ``` hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) src/main.rs clap setup; serve / spawn / kill / rebuild / list / - pending / approve / deny / destroy / request-spawn + pending / approve / deny / destroy [--purge] / + request-spawn; periodic broker vacuum task src/server.rs host admin socket (HostRequest → dispatch) src/client.rs admin-socket client src/manager_server.rs manager-privileged socket (ManagerRequest) src/agent_server.rs per-sub-agent socket listener (long-poll Recv) - src/broker.rs sqlite Message store + broadcast channel for SSE + src/broker.rs sqlite Message store + broadcast channel for SSE + + hourly vacuum of delivered>30d src/approvals.rs sqlite Approval queue + kinds - src/coordinator.rs shared state (broker/approvals/transient/sockets) - src/actions.rs approve/deny/destroy + src/operator_questions.rs sqlite question queue backing `ask_operator` + src/coordinator.rs shared state (broker/approvals/questions/transient/ + sockets) + tombstone enumeration + src/actions.rs approve/deny/destroy (transient-aware) src/auto_update.rs startup rebuild scan + ensure_manager src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions @@ -27,14 +31,16 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) hive-ag3nt/ in-container harness crate; produces TWO binaries src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT src/client.rs generic JSON-line request/response over unix socket - src/web_ui.rs per-container axum HTTP page - src/events.rs LiveEvent + broadcast Bus for the SSE stream + src/web_ui.rs per-container axum HTTP page (incl /api/cancel, + /api/compact, /events/history) + src/events.rs LiveEvent + broadcast Bus + sqlite-backed history + (/state/hyperhive-events.sqlite) + hourly vacuum src/turn.rs claude --print + stream-json pump; --compact retry src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer src/login.rs probe /root/.claude/ for a valid session src/login_session.rs drives `claude auth login` over stdio pipes - src/bin/hive-ag3nt.rs sub-agent main - src/bin/hive-m1nd.rs manager main + src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands) + src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands) assets/ index.html, agent.css, app.js (include_str!) prompts/ static role/tools/settings for claude (include_str!): agent.md — sub-agent system prompt @@ -138,16 +144,23 @@ Both the dashboard (port 7000) and the per-agent web UIs (8000 / - `GET /static/*.css` + `GET /static/*.js` → static assets shipped via `include_str!` so there's no runtime file dependency. - `GET /api/state` → JSON snapshot the JS app renders into the DOM. -- `POST /` (approve, deny, kill, restart, rebuild, destroy, - request-spawn, update-all, send, login/*) → idempotent action endpoints. - `GET /events/stream` (per-agent) and `GET /messages/stream` (dashboard) are `text/event-stream` SSE for live updates. +Per-agent endpoints: `POST /send`, `POST /login/{start,code,cancel}`, +`POST /api/cancel`, `POST /api/compact`, `GET /events/history`. + +Dashboard endpoints: `POST /{approve,deny}/{id}`, `POST +/{rebuild,kill,restart,start,destroy}/{name}`, `POST +/purge-tombstone/{name}`, `POST /answer-question/{id}`, `POST +/request-spawn`, `POST /update-all`. + The JS app handles all `form[data-async]` submissions via a delegated listener: read `data-confirm`, swap the button to a spinner, POST `application/x-www-form-urlencoded` (axum's `Form` extractor rejects -multipart), then on success call `refreshState()` (re-fetch `/api/state` -and re-render). No full-page reloads. +multipart), then on success re-enable the button (refreshState often +keeps the form mounted) and call `refreshState()` (re-fetch +`/api/state` and re-render). No full-page reloads. Per-agent + dashboard state shapes live in `dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot`. When adding new state fields, plumb @@ -226,12 +239,25 @@ into the wake prompt + UI header. New tools call this helper. `Bash` is on the allow-list pending a finer-grained pattern allow-list (`Bash(git *)`-style) — see [TODO.md](TODO.md). -**Live view.** Each agent runs an `events::Bus` (a -`tokio::sync::broadcast` wrapper). The harness emits -`TurnStart { from, body, unread }`, `Stream(value)` (one per parsed -stream-json line), `Note`, `TurnEnd { ok, note }`. The web UI subscribes -via `/events/stream` (SSE) and a JS panel (terminal-themed: Crust bg, inset -shadow, monospace) renders rows: +**Live view.** Each agent runs an `events::Bus` (broadcast channel + +sqlite-backed history at `/state/hyperhive-events.sqlite`). The harness +emits `TurnStart { from, body, unread }`, `Stream(value)` (one per +parsed stream-json line), `Note`, `TurnEnd { ok, note }`. The web UI: + +- fetches `GET /events/history` on page load and replays the last + 2000 events (oldest first, `.no-anim` so they don't stagger), +- then subscribes to `GET /events/stream` (SSE) for live tail, +- shows a granular state badge above the terminal (`💤 idle / 🧠 + thinking / ○ offline · `) driven from `turn_start`/`turn_end`, + with a flash animation on transition, +- sticky-bottom auto-scroll: scrolling up parks the view; new rows + surface a "↓ N new" pill instead of yanking. Scrolling back to + bottom clears the counter, +- terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter + blur, row fade-in slide-up, banner gradient shimmer while + state=thinking. + +Per-tool rendering: - `TurnStart` → `◆ TURN ← · N unread` header + indented body. - `Stream` `tool_use` → `→ Read /path` / `→ Bash $ cmd` / @@ -247,8 +273,20 @@ shadow, monospace) renders rows: `refreshState()` so the page form view reflects state transitions (e.g. login just landed). -The operator send form sits below the live panel, so the tail is what -you read first. +The operator input lives *inside* the terminal-wrap as a prompt-style +textarea below the live tail: multi-line (Enter sends, Shift+Enter +newlines), tab-completes slash commands. Available slash commands: + +- `/help` — list commands locally +- `/clear` — wipe the visible terminal (server history kept) +- `/cancel` — POST `/api/cancel` (host shellouts `pkill -INT + claude`, emits a Note); also surfaces as a `■ cancel turn` button + in the state row while state=thinking +- `/compact` — POST `/api/compact` (host spawns + `turn::compact_session` in the background; output streams into the + live panel) + +Unknown `/foo` shows an error row instead of being silently sent. ## Manager (hm1nd) is hive-c0re-managed @@ -334,19 +372,40 @@ loops over every stale container. ## Dashboard action surface -Container row buttons (rendered per-state by `assets/app.js`): +Page sections (top to bottom): -- Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents - `DESTR0Y` (container removed, state + creds kept) + `PURG3` - (DESTR0Y plus wipes `/var/lib/hyperhive/{agents,applied}//`; - no undo). -- Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`. -- Stopped: `▶ ST4RT`. -- Stale marker: clickable `needs update ↻` badge (same target as rebuild - but only shown when out of date). +1. **C0NTAINERS** — live containers with their action surface (below). +2. **K3PT ST4T3** — destroyed-but-state-kept tombstones (size + + age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a + Spawn approval; existing state is reused), `PURG3` (wipes + state + applied dirs; `POST /purge-tombstone/{name}`). +3. **M1ND H4S QU3STI0NS** — pending `ask_operator` questions + (amber pulsing border). Always renders a free-text fallback + alongside any option list; `multi=true` renders options as + checkboxes; submit merges selections + free text comma-joined. +4. **0PER4T0R 1NB0X** — recent messages addressed to `operator` + (last 50, from the broker). +5. **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. +6. **MESS4GE FL0W** — live broker SSE tail. -Top of the containers list: `↻ UPD4TE 4LL` (when any stale) + the -"R3QU3ST SP4WN" form for queuing a new agent through the approval flow. +Container row (two-line layout, `assets/app.js::renderContainers`): + +- Line 1: agent name (link → new tab), m1nd/ag3nt chip, `needs + login` / `needs update` warning badges, in-flight `◐ pending-state…` + pill (replaces buttons during start/stop/restart/rebuild/destroy), + container name + port. +- Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3` + on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running, + `▶ ST4RT` when stopped. Buttons dim + disable while a transient + lifecycle action is in flight. + +`↻ UPD4TE 4LL` button appears above the containers list when any +agent is stale. + +Banner pulses on each broker SSE event (`pulseBanner` with a 4s +grace timer). ## Approval flow @@ -374,3 +433,26 @@ The container's `--flake` ref is `#default`. The flake extends an inline module setting `programs.git.config.user` (committer identity = the agent's name) and `systemd.services..environment` (HIVE_PORT, HIVE_LABEL, HIVE_DASHBOARD_PORT). + +## Persistence + retention + +Two sqlite files; both autovacuum on a 1h tokio task: + +- **`/var/lib/hyperhive/broker.sqlite`** (host) — `messages` + + `approvals` + `operator_questions` tables. `Broker::vacuum_delivered` + drops delivered messages older than 30 days; undelivered rows are + always kept. Approvals + questions are kept indefinitely (auditable). +- **`/state/hyperhive-events.sqlite`** (per-container, bind-mounted + from `/var/lib/hyperhive/agents//state/`) — every `LiveEvent` + emitted on the per-agent `Bus`. Hourly vacuum drops rows older than + 7 days, then trims to the most recent 2000. Path overridable via + `HYPERHIVE_EVENTS_DB` (for dev / no-`/state` setups; on open failure + the Bus falls back to no-store mode rather than crashing the + harness). Survives destroy/recreate; gone on `--purge`. + +State dirs (per agent, under `/var/lib/hyperhive/agents//`): +`config/` (proposed nix repo), `claude/` (creds, bind-mounted RW to +`/root/.claude`), `state/` (durable notes + events db, bind-mounted to +`/state`). Wiped only on explicit `--purge`. Tombstones (state dir +without a live container) surface in the dashboard's K3PT ST4T3 +section so the operator can either revive or purge. diff --git a/README.md b/README.md index 4878ebee..de64ccea 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ host (NixOS, runs hive-c0re.service) │ manager additionally gets /agents RW) │ ├── hm1nd hive-m1nd serve : claude turn loop + - │ MCP (send / recv / request_spawn / kill / - │ request_apply_commit) + web UI on :8000 + │ MCP (send / recv / request_spawn / kill / start / + │ restart / request_apply_commit / ask_operator) + │ + web UI on :8000 │ └── h- hive-ag3nt serve : claude turn loop + MCP (send / recv) + web UI on a hashed :8100-8999 @@ -39,13 +40,23 @@ host (NixOS, runs hive-c0re.service) Each turn: harness pops one inbox message (Recv long-polls server-side and wakes on a broker Sent event) → builds a wake prompt → spawns `claude --print --continue --output-format stream-json --mcp-config …` → -streams JSON events into the per-agent SSE bus → claude drives any further -`recv`/`send` itself via the embedded MCP server. +streams JSON events into the per-agent SSE bus + a sqlite history db → +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`; granular +state badge (idle / thinking / offline) with age timer; cancel-turn +button while thinking; sticky-bottom auto-scroll with "↓ N new" pill; +event history backfilled on page load. Config changes flow the other way: manager edits `/agents//config/agent.nix` (bind-mounted from the host's proposed repo) → commits → submits the sha as an approval → operator clicks ◆ APPR0VE on the dashboard → hive-c0re copies the file into the applied repo and `nixos-container update`s the agent. +For decisions the manager needs human signal on, `ask_operator(question, +options?, multi?)` queues a free-text/checkbox/radio form on the +dashboard; the answer arrives later as a `HelperEvent::OperatorAnswered` +in the manager's inbox. ## Host config diff --git a/TODO.md b/TODO.md index c4bb48d1..d5ccad3d 100644 --- a/TODO.md +++ b/TODO.md @@ -34,28 +34,10 @@ Pick anything from here when relevant. Cross-cutting design notes live in `napping 😴` once the `/compact` trigger and `nap` tool exist — both need a harness signal (an explicit `LiveEvent::StateChange` variant or piggyback on Note). -- **Terminal: slash commands beyond /help and /clear.** Operator-facing - in-terminal commands still to add: `/model`, `/compact`, `/cancel`. - Each needs harness-side support (model override, force compaction, - cancel current claude turn). -- **Terminal: bigger.** The 32em max-height is cramped on a 1080p+ - screen. Grow it (e.g. `min(70vh, 60em)`) so the live tail is the - main visual element of the page rather than a strip. -- **Terminal: sticky-bottom auto-scroll.** Today every appended row - scrolls to bottom, so the view shifts while the operator is reading - scrolled-up. Track whether the user is *already* at the bottom - (within a small threshold), and only auto-scroll when that's true. - Show a small "↓ N new" indicator when not at bottom; click to jump. -- **Terminal: cancel-current-turn button.** Explicit "kill claude - process for this turn" control. Harness needs to track the - in-flight claude child PID and offer a `/cancel` endpoint that sends - SIGTERM; UI surfaces a button while the state badge is `thinking`. - Slash-command equivalent: `/cancel`. -- **`/compact` trigger.** Operator-initiated compaction of the current - claude session — `claude --print --continue` with `/compact` over the - same session id. Surfaces as a slash command in the terminal + a - toolbar button while the state badge is `idle`. Sets state to - `compacting` during the run. +- **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 ` + command that POSTs to a new endpoint. - **xterm.js terminal** embedded per-agent, attached to a PTY exposed by the harness. Pairs well with the unprivileged-container work — would let the operator drop into the container without `nixos-container root-login`. @@ -87,6 +69,25 @@ Pick anything from here when relevant. Cross-cutting design notes live in manager fall back. Wire the timeout into `OperatorQuestions::wait_answered` and surface remaining-time on the dashboard. +## Spawn flow + +- **Two-step spawn.** Today `request_spawn(name)` is one shot: manager + asks → operator approves → container is created with a default + `agent.nix` and empty `/state/`. Manager has no way to pre-stage + per-agent prompt material, package additions, or initial notes before + the agent first wakes. Split into: + 1. `request_spawn_draft(name)` — host creates the per-agent + `proposed/` repo (initial commit) and `state/` dir with no + container; manager now has `/agents//{config,state}/` to + edit + commit just like an existing agent. + 2. `request_spawn_commit(name, commit_ref)` — submits the queued + approval; operator sees the diff in the dashboard like a normal + `apply_commit`; on approve the container is created from that + commit. + Backwards-compat: keep the existing one-shot `request_spawn` for + trivial agents (operator can still type a name in the dashboard). + Surface "drafts" as a new section between K3PT ST4T3 and approvals. + ## Loop substance - **`nap` tool.** Agent-side MCP tool `mcp__hyperhive__nap(seconds)` that diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index a2f620f4..0a40d59e 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -161,23 +161,26 @@ let termAPI = null; const SLASH_COMMANDS = [ - { name: '/help', desc: 'list slash commands' }, - { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, - { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, + { name: '/help', desc: 'list slash commands' }, + { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, + { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, + { name: '/compact', desc: 'compact the persistent claude session' }, ]; - async function postCancelTurn() { + async function postSimple(url, label) { try { - const resp = await fetch('/api/cancel', { method: 'POST', redirect: 'manual' }); + const resp = await fetch(url, { method: 'POST', redirect: 'manual' }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok && termAPI) { - termAPI.row('turn-end-fail', '✗ /cancel failed: http ' + resp.status); + termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status); } } catch (err) { - if (termAPI) termAPI.row('turn-end-fail', '✗ /cancel failed: ' + err); + if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err); } } + const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); + const postCompact = () => postSimple('/api/compact', '/compact'); function handleSlashCommand(line) { if (!termAPI) return false; @@ -198,6 +201,9 @@ case '/cancel': postCancelTurn(); return true; + case '/compact': + postCompact(); + return true; default: termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help'); return true; diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 3f7bb781..e0ea25f7 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -80,6 +80,7 @@ pub async fn serve( .route("/login/code", post(post_login_code)) .route("/login/cancel", post(post_login_cancel)) .route("/api/cancel", post(post_cancel_turn)) + .route("/api/compact", post(post_compact)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) @@ -274,6 +275,37 @@ async fn post_login_cancel(State(state): State) -> Response { Redirect::to("/").into_response() } +/// Operator-initiated session compaction. Spawns `turn::compact_session` +/// in the background — the HTTP handler returns immediately so the +/// async-form spinner can clear. Output (claude's compaction stream, +/// the "/compact done" note) lands in the live event panel like any +/// other turn. If a regular turn is in flight, claude's own session +/// lock will reject this one and we surface the error as a Note. +async fn post_compact(State(state): State) -> Response { + let bus = state.bus.clone(); + let socket = state.socket.clone(); + tokio::spawn(async move { + bus.emit(crate::events::LiveEvent::Note( + "operator: /compact — running on persistent session".into(), + )); + let settings = match crate::turn::write_settings(&socket).await { + Ok(p) => p, + Err(e) => { + bus.emit(crate::events::LiveEvent::Note(format!( + "/compact failed: settings write — {e:#}" + ))); + return; + } + }; + if let Err(e) = crate::turn::compact_session(&settings, &bus).await { + bus.emit(crate::events::LiveEvent::Note(format!( + "/compact failed: {e:#}" + ))); + } + }); + Redirect::to("/").into_response() +} + /// Cancel the in-flight claude turn. Coarse-grained: shells out /// `pkill -INT claude` since there's at most one claude per container. /// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 33ed8804..40e15906 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -83,23 +83,6 @@ )); } - const spawn = el('form', { - method: 'POST', action: '/request-spawn', - class: 'spawnform', 'data-async': '', - }); - spawn.append( - el('input', { - name: 'name', - placeholder: 'new agent name (≤9 chars)', - maxlength: '9', required: '', autocomplete: 'off', - }), - el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), - ); - root.append(spawn); - root.append(el('p', { class: 'meta' }, - 'spawn requests queue as approvals. operator approves below to actually create the container.', - )); - if (s.transients.length) { const ul = el('ul'); for (const t of s.transients) { @@ -126,7 +109,7 @@ // ── line 1: identity ───────────────────────────────────────── const head = el('div', { class: 'head' }); head.append( - el('a', { class: 'name', href: url }, c.name), + el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); @@ -135,7 +118,8 @@ el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); } else if (c.needs_login) { head.append(el('a', - { class: 'badge badge-warn', href: url }, 'needs login →')); + { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, + 'needs login →')); } if (c.needs_update) { head.append(form( @@ -182,6 +166,66 @@ root.append(ul); } + function renderTombstones(s) { + const root = $('tombstones-section'); + root.innerHTML = ''; + if (!s.tombstones || !s.tombstones.length) { + root.append(el('p', { class: 'empty' }, 'no kept state — clean')); + return; + } + const fmtBytes = (n) => { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; + return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + }; + const fmtAge = (ts) => { + if (!ts) return '?'; + const d = Math.floor((Date.now() / 1000 - ts) / 86400); + if (d <= 0) return 'today'; + if (d === 1) return '1 day ago'; + return d + ' days ago'; + }; + const ul = el('ul', { class: 'containers' }); + for (const t of s.tombstones) { + const li = el('li', { class: 'container-row tombstone' }); + const head = el('div', { class: 'head' }); + head.append( + el('span', { class: 'name' }, t.name), + el('span', { class: 'badge badge-muted' }, 'destroyed'), + ); + if (t.has_creds) { + head.append(el('span', { class: 'badge badge-muted' }, 'creds kept')); + } + head.append(el('span', { class: 'meta' }, + `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`)); + li.append(head); + + const actions = el('div', { class: 'actions' }); + // Reuse the existing spawn form pattern via /request-spawn — operator + // can queue an approval that recreates the agent with the same name + // and reuses the kept state. + const respawn = el('form', { + method: 'POST', action: '/request-spawn', + class: 'inline', 'data-async': '', + 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.', + }); + respawn.append( + el('input', { type: 'hidden', name: 'name', value: t.name }), + el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'), + ); + actions.append(respawn); + actions.append(form( + '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', + 'PURGE ' + t.name + '? config history, claude creds, /state/ notes ' + + 'are all WIPED. no undo.', + )); + li.append(actions); + ul.append(li); + } + root.append(ul); + } + function renderQuestions(s) { const root = $('questions-section'); root.innerHTML = ''; @@ -276,6 +320,24 @@ function renderApprovals(s) { const root = $('approvals-section'); root.innerHTML = ''; + + // Spawn request form: submitting it queues a Spawn approval that + // lands in this same list, so the form belongs here rather than on + // the containers list (the agent doesn't exist yet). + const spawn = el('form', { + method: 'POST', action: '/request-spawn', + class: 'spawnform', 'data-async': '', + }); + spawn.append( + el('input', { + name: 'name', + placeholder: 'new agent name (≤9 chars)', + maxlength: '9', required: '', autocomplete: 'off', + }), + el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), + ); + root.append(spawn); + if (!s.approvals.length) { root.append(el('p', { class: 'empty' }, 'queue empty')); return; @@ -331,6 +393,7 @@ if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); renderContainers(s); + renderTombstones(s); renderQuestions(s); renderInbox(s); renderApprovals(s); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 08479d83..1e0792ae 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -133,6 +133,16 @@ a:hover { color: var(--amber); border-color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.5); } +.badge-muted { + color: var(--muted); border-color: var(--purple-dim); + background: rgba(127, 132, 156, 0.08); +} +.container-row.tombstone { + border-style: dashed; + background: rgba(24, 24, 37, 0.35); + opacity: 0.85; +} +.container-row.tombstone .name { color: var(--muted); } .pending-state { color: var(--amber); font-size: 0.85em; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 838591ae..26e75134 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -16,6 +16,12 @@

loading…

+

◆ K3PT ST4T3 ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+

◆ M1ND H4S QU3STI0NS ◆

══════════════════════════════════════════════════════════════
diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ed44beaf..b6df8ac0 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -204,4 +204,23 @@ impl Coordinator { pub fn agent_applied_dir(name: &str) -> PathBuf { PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}")) } + + /// Enumerate names that have a persistent state dir under + /// `/var/lib/hyperhive/agents/` (i.e. config / claude creds / + /// notes survive). Includes both currently-existing containers and + /// destroyed-but-kept tombstones; callers filter the latter by + /// subtracting `lifecycle::list()`. + #[must_use] + pub fn kept_state_names() -> Vec { + let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else { + return Vec::new(); + }; + let mut out: Vec = rd + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + out.sort(); + out + } } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c5f9334a..2a540e30 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) .route("/answer-question/{id}", post(post_answer_question)) + .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/request-spawn", post(post_request_spawn)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -106,6 +107,21 @@ struct StateSnapshot { /// we mark the row answered and fire `HelperEvent::OperatorAnswered` /// into the manager's inbox. questions: Vec, + /// State dirs (config history + claude creds + /state/ notes) that + /// survive after a destroy-without-purge. The operator can re-spawn + /// with the same name to resume, or PURG3 to wipe them. + tombstones: Vec, +} + +#[derive(Serialize)] +struct TombstoneView { + name: String, + /// Bytes used by the state dir tree. Cheap-ish to compute; let the + /// operator know how much they're holding onto. + state_bytes: u64, + /// Mtime (unix seconds) of the state dir; rough "last seen". + last_seen: i64, + has_creds: bool, } #[derive(Serialize)] @@ -145,6 +161,7 @@ struct ApprovalView { diff_html: Option, } +#[allow(clippy::too_many_lines)] async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json { let host = headers .get("host") @@ -242,6 +259,35 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .unwrap_or_default(); let questions = state.coord.questions.pending().unwrap_or_default(); + // Tombstones: state-dir names that don't appear in the live container + // list (and aren't the manager). Operator can re-spawn or PURG3. + let live: std::collections::HashSet = containers + .iter() + .map(|c| c.name.clone()) + .chain(state.coord.transient_snapshot().into_keys()) + .collect(); + let tombstones: Vec = Coordinator::kept_state_names() + .into_iter() + .filter(|name| name != MANAGER_NAME && !live.contains(name)) + .map(|name| { + let root = Coordinator::agent_state_root(&name); + let state_bytes = dir_size_bytes(&root); + let last_seen = std::fs::metadata(&root) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .unwrap_or(0); + let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name)); + TombstoneView { + name, + state_bytes, + last_seen, + has_creds, + } + }) + .collect(); + axum::Json(StateSnapshot { hostname, manager_port: MANAGER_PORT, @@ -251,9 +297,33 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J approvals: approval_views, operator_inbox, questions, + tombstones, }) } +/// Sum the byte size of every regular file under `root`. Cheap to compute +/// for typical agent state (config repo + claude creds + notes file — +/// usually a few MB); fine to do inline on each /api/state. Returns 0 on +/// any error. +fn dir_size_bytes(root: &Path) -> u64 { + fn walk(p: &Path, acc: &mut u64) { + let Ok(rd) = std::fs::read_dir(p) else { return }; + for entry in rd.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + walk(&entry.path(), acc); + } else if ft.is_file() + && let Ok(meta) = entry.metadata() + { + *acc += meta.len(); + } + } + } + let mut total = 0u64; + walk(root, &mut total); + total +} + async fn messages_stream( State(state): State, ) -> Sse>> { @@ -316,6 +386,48 @@ async fn post_answer_question( } } +async fn post_purge_tombstone( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + if name == lifecycle::MANAGER_NAME { + return error_response("refusing to purge the manager's state"); + } + // Sanity: refuse to purge if a live container still exists with this + // name. The dashboard already filters tombstones to non-live names, + // but the operator could send a stale POST. + let live = lifecycle::list().await.unwrap_or_default(); + if live + .iter() + .any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name) + { + return error_response(&format!( + "refusing to purge {name}: container still exists — use DESTR0Y first" + )); + } + let mut errors = Vec::new(); + for dir in [ + Coordinator::agent_state_root(&name), + Coordinator::agent_applied_dir(&name), + ] { + if dir.exists() + && let Err(e) = std::fs::remove_dir_all(&dir) + { + errors.push(format!("{}: {e}", dir.display())); + } + } + let _ = state + .coord + .approvals + .fail_pending_for_agent(&name, "agent state purged"); + if errors.is_empty() { + tracing::info!(%name, "tombstone purged"); + Redirect::to("/").into_response() + } else { + error_response(&format!("purge {name} partial: {}", errors.join(", "))) + } +} + async fn post_request_spawn( State(state): State, Form(form): Form, diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index cfb8b4fd..7ece4fcf 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -26,7 +26,7 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending "; /// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN` -/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first. +/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first. fn ensure_multi_column(conn: &Connection) -> Result<()> { let has: bool = conn .prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?