Compare commits

...
Author SHA1 Message Date
müde
897e7c07ae dashboard: spawn form moves under approvals; docs synced
submitting R3QU3ST SP4WN immediately queues an approval that lands
in the very next list. the form belonged with that list, not at the
top of containers — the agent doesn't exist yet at form time anyway.

docs: claude.md grows operator_questions.rs / events.rs sqlite /
broker vacuum to the file map; web-ui shape lists the actual current
endpoint set (per-agent cancel/compact/history, dashboard tombstone
purge/answer/spawn); live-view section now describes the state
badge, sticky-bottom scroll, history backfill, and the terminal-
embedded prompt with its slash commands; dashboard-action-surface
rewritten around the new six-section page (containers / kept-state /
questions / inbox / approvals / message-flow) and the two-line
container row. new 'persistence + retention' section documenting both
sqlite databases and their vacuum cadences. readme picks up the new
mgr mcp surface (start/restart/ask_operator) + operator-side
features list + ask_operator answer flow.

todo trimmed of shipped items (bigger terminal / sticky scroll /
cancel button / /compact trigger / /cancel command). new entry for
the two-step spawn-with-preconfig flow.
2026-05-15 20:02:54 +02:00
müde
c9647f4106 operator control: /compact slash command + endpoint
new POST /api/compact on the per-agent web UI: spawns
turn::compact_session in the background so the http handler returns
immediately. claude runs '/compact' over the persistent --continue
session; output streams into the live panel like any other turn.

slash command /compact wired to the new endpoint. SLASH_COMMANDS list
now lists all four (/help /clear /cancel /compact). postCancelTurn +
postCompact share a postSimple() helper.

deliberately not gated against an in-flight turn — claude's own
session lock will reject a concurrent compact and the failure
surfaces as a Note in the live panel.
2026-05-15 19:56:53 +02:00
müde
5ee65d2f15 dashboard: K3PT ST4T3 section + agent links open in new tab
new section between containers and questions: lists every name with a
state dir under /var/lib/hyperhive/agents/ that doesn't correspond to
a live container. shows state size + last-modified age + whether
claude creds are kept. two actions per row:

- R3V1V3 — queues a spawn approval with the same name (operator
  approves to recreate; spawn flow reuses prior config + claude
  creds, no re-login needed)
- PURG3 — wipes the agent's state + applied dirs (post /purge-tombstone/
  endpoint; refuses if a live container with that name still exists)

dashboard also opens agent links in new tabs now (target=_blank +
rel=noopener) so the operator's overview tab stays put when they
dive into an agent.
2026-05-15 19:55:27 +02:00
11 changed files with 428 additions and 86 deletions

144
CLAUDE.md
View file

@ -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 /<action>` (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<LiveEvent>` 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 · <age>`) 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 ← <from> · 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}/<name>/`;
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 `<applied_dir>#default`. The flake extends
an inline module setting `programs.git.config.user` (committer identity =
the agent's name) and `systemd.services.<harness>.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/<name>/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/<name>/`):
`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.

View file

@ -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-<name> 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/<name>/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

45
TODO.md
View file

@ -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 <name>`
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/<name>/{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

View file

@ -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;

View file

@ -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<AppState>) -> 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<AppState>) -> 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

View file

@ -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);

View file

@ -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;

View file

@ -16,6 +16,12 @@
<p class="meta">loading…</p>
</div>
<h2>◆ K3PT ST4T3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">

View file

@ -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<String> {
let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else {
return Vec::new();
};
let mut out: Vec<String> = 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
}
}

View file

@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<crate::operator_questions::OpQuestion>,
/// 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<TombstoneView>,
}
#[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<String>,
}
#[allow(clippy::too_many_lines)]
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
let host = headers
.get("host")
@ -242,6 +259,35 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> 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<String> = containers
.iter()
.map(|c| c.name.clone())
.chain(state.coord.transient_snapshot().into_keys())
.collect();
let tombstones: Vec<TombstoneView> = 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<AppState>) -> 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<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
@ -316,6 +386,48 @@ async fn post_answer_question(
}
}
async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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<AppState>,
Form(form): Form<RequestSpawnForm>,

View file

@ -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'")?