Compare commits

...
Author SHA1 Message Date
müde
a9ed33d94f todo: trim state-badge entry to what's left (compacting/napping) 2026-05-15 19:36:42 +02:00
müde
211599c589 agent state badge: idle / thinking / offline + age timer
new badge between the status line and the terminal. shows current
state with a glyph + label + age suffix (e.g. '🧠 thinking · 12s').
state transitions are driven from existing SSE turn_start/turn_end —
no harness changes needed. on page load, history backfill detects an
in-flight turn (turn_start without matching turn_end) and starts in
thinking. state-just-changed flash kicks in on each transition. age
timer ticks client-side every 1s.

compacting/napping states will be added when /compact and nap land —
their slots are reserved in the state enum, just unused for now.
2026-05-15 19:36:29 +02:00
müde
0cc25d33d8 drop debug-only cli subcommands from hive-ag3nt + hive-m1nd
drop the one-shot send/recv/kill/start/restart/request-spawn/request-
apply-commit subcommands from both in-container binaries. they were
debug-only — the host admin socket (`hive-c0re ...`) exposes the
same verbs and the manager mcp surface covers the rest from inside
claude. now each binary's --help shows just `serve` and `mcp`,
which are the only commands either is meant to be started with.

removes the `one_shot` helper and the `render` / `check` glue.
2026-05-15 19:34:58 +02:00
müde
08f2ec5232 agent terminal: sticky-bottom auto-scroll with new-row pill
new rows no longer yank the view if the operator is scrolled up.
threshold for 'near bottom' is 48px. when not near bottom, an amber
'↓ N new' pill appears in the bottom-right of the terminal-wrap;
clicking it jumps to bottom. scrolling back near bottom clears the
counter. backfilled (history-replay) rows always scroll to bottom
since the operator hasn't started reading yet.
2026-05-15 19:30:34 +02:00
müde
875a8f5be4 agent terminal: take up real screen space
terminal height is now min(72vh, 60em) instead of a 32em strip — on a
1080p screen that's ~3x more visible lines. body max-width raised
to 110em so a wide window doesn't waste the available width on the
margin.
2026-05-15 19:29:36 +02:00
müde
48ebfefd1a destroy --purge: also wipe agent state dirs
new --purge flag on the destroy verb (cli + admin socket + dashboard).
default destroy still keeps /var/lib/hyperhive/{agents,applied}/<name>/
so recreating with the same name reuses prior config + creds.
with --purge, both dirs go too (config history, claude creds, /state/
notes). no undo. dashboard adds a separate PURG3 button with an
explicit confirmation copy; the existing DESTR0Y button keeps the
soft semantics.

claude.md dashboard-action-surface section updated; todo entry
dropped.
2026-05-15 19:29:14 +02:00
13 changed files with 268 additions and 116 deletions

View file

@ -337,7 +337,9 @@ loops over every stale container.
Container row buttons (rendered per-state by `assets/app.js`): Container row buttons (rendered per-state by `assets/app.js`):
- Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents - Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents
`DESTR0Y` (container removed, state + creds kept). `DESTR0Y` (container removed, state + creds kept) + `PURG3`
(DESTR0Y plus wipes `/var/lib/hyperhive/{agents,applied}/<name>/`;
no undo).
- Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`. - Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`.
- Stopped: `▶ ST4RT`. - Stopped: `▶ ST4RT`.
- Stale marker: clickable `needs update ↻` badge (same target as rebuild - Stale marker: clickable `needs update ↻` badge (same target as rebuild

38
TODO.md
View file

@ -37,16 +37,23 @@ Pick anything from here when relevant. Cross-cutting design notes live in
turns, and so `/events/history` survives restart. Cap rows per turns, and so `/events/history` survives restart. Cap rows per
agent or auto-vacuum on age, same trade-off as the bounded broker agent or auto-vacuum on age, same trade-off as the bounded broker
entry below. entry below.
- **Granular agent state badge** above the terminal: `idle 💤 / thinking 🧠 / - **State badge: compacting + napping states.** Idle/thinking already
compacting 📦` with an age timer (`thinking · 12s`). Drives a state ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
channel from the harness: idle when waiting on the inbox, thinking `napping 😴` once the `/compact` trigger and `nap` tool exist —
while claude's stream is open, compacting when `/compact` is in both need a harness signal (an explicit `LiveEvent::StateChange`
flight. Replaces the binary "harness alive — turn loop running" line. variant or piggyback on Note).
- **Terminal: slash commands + tab-completion.** Operator-facing - **Terminal: slash commands beyond /help and /clear.** Operator-facing
in-terminal commands: `/help`, `/model`, `/compact`, `/clear`. Tab in-terminal commands still to add: `/model`, `/compact`, `/cancel`.
completes command names + model names (cf. bitburner-agent's pattern). Each needs harness-side support (model override, force compaction,
- **Terminal: multi-line input.** Replace the single-line `<input>` with cancel current claude turn).
an auto-growing textarea; Enter sends, Shift+Enter newlines. - **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 - **Terminal: cancel-current-turn button.** Explicit "kill claude
process for this turn" control. Harness needs to track the process for this turn" control. Harness needs to track the
in-flight claude child PID and offer a `/cancel` endpoint that sends in-flight claude child PID and offer a `/cancel` endpoint that sends
@ -57,9 +64,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
same session id. Surfaces as a slash command in the terminal + a same session id. Surfaces as a slash command in the terminal + a
toolbar button while the state badge is `idle`. Sets state to toolbar button while the state badge is `idle`. Sets state to
`compacting` during the run. `compacting` during the run.
- **Visuals.** Frosted-glass backdrop blur on the terminal wrap,
per-event fade-in slide-up animation on new rows, badge pulse
animation on state-badge transitions.
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by - **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
the harness. Pairs well with the unprivileged-container work — would let the harness. Pairs well with the unprivileged-container work — would let
the operator drop into the container without `nixos-container root-login`. the operator drop into the container without `nixos-container root-login`.
@ -115,12 +119,4 @@ Pick anything from here when relevant. Cross-cutting design notes live in
- **Container crash events.** Watch `container@*.service` via D-Bus, push - **Container crash events.** Watch `container@*.service` via D-Bus, push
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can `HelperEvent::ContainerCrash` to the manager's inbox so the manager can
react (restart, escalate, etc.). react (restart, escalate, etc.).
- **`destroy --purge`.** Today `destroy` keeps state by design; add an
opt-in flag (CLI + dashboard) to also wipe `/var/lib/hyperhive/agents/<name>/`
and `/var/lib/hyperhive/applied/<name>/`.
## Cleanup / docs
- **Debug-only sub-commands.** `hive-ag3nt send/recv` and the analogous
`hive-m1nd send/recv/...` exist only for ops debugging. Move them into a
hidden `debug` sub-command to declutter `--help`, or drop entirely.

View file

@ -15,7 +15,7 @@ body {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace; font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
max-width: 70em; max-width: 110em;
margin: 1.5em auto; margin: 1.5em auto;
padding: 0 1.5em; padding: 0 1.5em;
line-height: 1.6; line-height: 1.6;
@ -124,12 +124,49 @@ pre.diff {
word-break: break-all; word-break: break-all;
max-height: 30em; max-height: 30em;
} }
#state-row {
margin: 0.4em 0 0.2em;
}
.state-badge {
display: inline-block;
padding: 0.25em 0.8em;
border: 1px solid;
border-radius: 999px;
font-size: 0.85em;
letter-spacing: 0.05em;
transition: color 280ms ease, border-color 280ms ease,
box-shadow 280ms ease, background 280ms ease;
}
.state-badge.state-loading {
color: var(--muted); border-color: var(--purple-dim);
}
.state-badge.state-offline {
color: var(--muted); border-color: var(--muted);
}
.state-badge.state-idle {
color: var(--cyan); border-color: var(--cyan);
text-shadow: 0 0 6px rgba(137, 220, 235, 0.55);
}
.state-badge.state-thinking {
color: var(--amber); border-color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
animation: badge-pulse 1.8s ease-in-out infinite;
}
.state-badge.state-just-changed {
animation: state-flash 600ms ease-out;
}
@keyframes state-flash {
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
}
/* Terminal-ish wrapper holding the live output + prompt input as one /* Terminal-ish wrapper holding the live output + prompt input as one
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow. unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
Frosted-glass backdrop blur: the page bg behind the wrap gets softened, Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
so anything that bleeds through (page banner glow, scroll position) so anything that bleeds through (page banner glow, scroll position)
reads as out-of-focus depth instead of sharp competing detail. */ reads as out-of-focus depth instead of sharp competing detail. */
.terminal-wrap { .terminal-wrap {
position: relative;
background: rgba(17, 17, 27, 0.78); background: rgba(17, 17, 27, 0.78);
-webkit-backdrop-filter: blur(8px) saturate(120%); -webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%); backdrop-filter: blur(8px) saturate(120%);
@ -148,7 +185,10 @@ pre.diff {
border-radius: 0; border-radius: 0;
padding: 0.8em 1em 0.4em; padding: 0.8em 1em 0.4em;
overflow-y: auto; overflow-y: auto;
max-height: 32em; /* Make the terminal the page's main visual element on tall screens
while staying inside the page chrome on short ones. */
height: min(72vh, 60em);
max-height: none;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
color: inherit; color: inherit;
@ -224,6 +264,36 @@ pre.diff {
from { opacity: 0; transform: translateY(4px); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* "↓ N new" pill: shown when new rows arrive while the operator is
scrolled up; click to jump to bottom. */
.tail-pill {
position: absolute;
right: 1em;
bottom: 4.2em;
background: var(--amber);
color: #11111b;
font-family: inherit;
font-size: 0.8em;
font-weight: bold;
letter-spacing: 0.08em;
border: 0;
border-radius: 999px;
padding: 0.35em 0.9em;
cursor: pointer;
box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.tail-pill.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tail-pill:hover {
filter: brightness(1.1);
}
details.row { details.row {
white-space: normal; white-space: normal;
padding-left: 0.5em; padding-left: 0.5em;

View file

@ -254,6 +254,54 @@
if (ta) ta.disabled = !online; if (ta) ta.disabled = !online;
} }
// Granular state badge: idle / thinking / offline. Driven from SSE
// turn_start/turn_end. Age timer ticks client-side; badge re-renders
// each second so the "· 12s" suffix stays current. State changes
// trigger a short flash animation via .state-just-changed.
const STATE_LABELS = {
loading: { glyph: '…', text: 'booting' },
offline: { glyph: '○', text: 'offline' },
idle: { glyph: '💤', text: 'idle' },
thinking: { glyph: '🧠', text: 'thinking' },
};
let stateName = 'loading';
let stateSince = Date.now();
let stateTickTimer = null;
function fmtAge(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
}
function renderStateBadge() {
const badge = $('state-badge');
if (!badge) return;
const def = STATE_LABELS[stateName] || STATE_LABELS.loading;
const age = fmtAge(Date.now() - stateSince);
badge.textContent = def.glyph + ' ' + def.text + ' · ' + age;
badge.className = 'state-badge state-' + stateName;
}
function setState(next) {
if (next === stateName) return;
stateName = next;
stateSince = Date.now();
const badge = $('state-badge');
if (badge) {
// Re-add the flash class so the animation replays.
badge.classList.remove('state-just-changed');
void badge.offsetWidth;
badge.classList.add('state-just-changed');
}
renderStateBadge();
}
function startStateTicker() {
if (stateTickTimer) return;
stateTickTimer = setInterval(renderStateBadge, 1000);
}
startStateTicker();
// Track banner activity by reference-counting in-flight turns. A turn // Track banner activity by reference-counting in-flight turns. A turn
// can begin while the previous turn_end is still in the pipeline (rare // can begin while the previous turn_end is still in the pipeline (rare
// but happens on tight wake cycles), so we count rather than toggle. // but happens on tight wake cycles), so we count rather than toggle.
@ -277,6 +325,11 @@
const s = await resp.json(); const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
renderTermInput(s.label, s.status === 'online'); renderTermInput(s.label, s.status === 'online');
// Drive the state badge from the harness status. Live SSE events
// override to 'thinking' / 'idle' as turns start/end; this only
// kicks in for the not-online (offline) case and the initial seed.
if (s.status !== 'online') setState('offline');
else if (stateName === 'loading' || stateName === 'offline') setState('idle');
// Skip the re-render if nothing structurally changed. The most // Skip the re-render if nothing structurally changed. The most
// common case is `online` polling itself — without this guard, the // common case is `online` polling itself — without this guard, the
// operator's <input value> gets clobbered every cycle. // operator's <input value> gets clobbered every cycle.
@ -331,13 +384,58 @@
row: (cls, text) => row(cls, text), row: (cls, text) => row(cls, text),
clear: () => { log.innerHTML = ''; placeholder = null; }, clear: () => { log.innerHTML = ''; placeholder = null; },
}; };
// Sticky-bottom auto-scroll. If the user is reading scrolled-up, new
// rows do NOT yank the view. A floating "↓ N new" pill appears in
// the bottom-right corner; clicking it jumps to bottom and clears
// the counter. Scrolling back near the bottom also clears it.
const NEAR_BOTTOM_PX = 48;
let unseen = 0;
let pill = null;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
function ensurePill() {
if (pill) return pill;
pill = document.createElement('button');
pill.type = 'button';
pill.className = 'tail-pill';
pill.addEventListener('click', () => {
log.scrollTop = log.scrollHeight;
});
log.parentElement.appendChild(pill);
return pill;
}
function updatePill() {
if (unseen <= 0) {
if (pill) pill.classList.remove('visible');
return;
}
ensurePill();
pill.textContent = '↓ ' + unseen + ' new';
pill.classList.add('visible');
}
log.addEventListener('scroll', () => {
if (isNearBottom()) {
unseen = 0;
updatePill();
}
});
function afterAppend() {
if (currentNoAnim || isNearBottom()) {
log.scrollTop = log.scrollHeight;
} else {
unseen += 1;
updatePill();
}
}
function row(cls, text) { function row(cls, text) {
clearPlaceholder(); clearPlaceholder();
const e = document.createElement('div'); const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.textContent = text; e.textContent = text;
log.appendChild(e); log.appendChild(e);
log.scrollTop = log.scrollHeight; afterAppend();
return e; return e;
} }
function details(cls, summary, body) { function details(cls, summary, body) {
@ -352,7 +450,7 @@
pre.textContent = body; pre.textContent = body;
d.appendChild(pre); d.appendChild(pre);
log.appendChild(d); log.appendChild(d);
log.scrollTop = log.scrollHeight; afterAppend();
return d; return d;
} }
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
@ -429,7 +527,7 @@
function handle(ev, opts) { function handle(ev, opts) {
const fromHistory = !!(opts && opts.fromHistory); const fromHistory = !!(opts && opts.fromHistory);
if (ev.kind === 'turn_start') { if (ev.kind === 'turn_start') {
if (!fromHistory) setBannerActive(true); if (!fromHistory) { setBannerActive(true); setState('thinking'); }
const block = row('turn-start', '◆ TURN ← ' + ev.from); const block = row('turn-start', '◆ TURN ← ' + ev.from);
if (ev.unread > 0) { if (ev.unread > 0) {
const badge = document.createElement('span'); const badge = document.createElement('span');
@ -444,7 +542,7 @@
return; return;
} }
if (ev.kind === 'turn_end') { if (ev.kind === 'turn_end') {
if (!fromHistory) setBannerActive(false); if (!fromHistory) { setBannerActive(false); setState('idle'); }
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
// Login may have just landed (or session re-enters Online). Pull // Login may have just landed (or session re-enters Online). Pull
@ -483,6 +581,7 @@
} }
currentNoAnim = false; currentNoAnim = false;
for (let i = 0; i < openTurns; i++) setBannerActive(true); for (let i = 0; i < openTurns; i++) setBannerActive(true);
if (openTurns > 0) setState('thinking');
if (events.length) row('note', '─── live (older above) ───'); if (events.length) row('note', '─── live (older above) ───');
else setPlaceholder('(connected — waiting for events)'); else setPlaceholder('(connected — waiting for events)');
} catch (err) { } catch (err) {

View file

@ -13,6 +13,10 @@
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>
<div id="state-row">
<span id="state-badge" class="state-badge state-loading">… booting</span>
</div>
<div class="terminal-wrap"> <div class="terminal-wrap">
<div id="live" class="live terminal"><div class="meta">connecting…</div></div> <div id="live" class="live terminal"><div class="meta">connecting…</div></div>
<div id="term-input" class="term-input"></div> <div id="term-input" class="term-input"></div>

View file

@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail}; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent}; use hive_ag3nt::events::{Bus, LiveEvent};
use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::login::{self, LoginState};
@ -29,10 +29,6 @@ enum Cmd {
#[arg(long, default_value_t = 1000)] #[arg(long, default_value_t = 1000)]
poll_ms: u64, poll_ms: u64,
}, },
/// Send a message to another agent.
Send { to: String, body: String },
/// Pop one message from the inbox.
Recv,
/// Run the agent's MCP server on stdio. Spawned by `claude` via /// Run the agent's MCP server on stdio. Spawned by `claude` via
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into /// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
/// the hyperhive broker. /// the hyperhive broker.
@ -103,17 +99,6 @@ async fn main() -> Result<()> {
} }
} }
} }
Cmd::Send { to, body } => {
let resp: AgentResponse =
client::request(&cli.socket, &AgentRequest::Send { to, body }).await?;
render(&resp)?;
check(&resp)
}
Cmd::Recv => {
let resp: AgentResponse = client::request(&cli.socket, &AgentRequest::Recv).await?;
render(&resp)?;
check(&resp)
}
Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await, Cmd::Mcp => mcp::serve_agent_stdio(cli.socket).await,
} }
} }
@ -192,14 +177,3 @@ async fn inbox_unread(socket: &Path) -> u64 {
} }
} }
fn render(resp: &AgentResponse) -> Result<()> {
println!("{}", serde_json::to_string_pretty(resp)?);
Ok(())
}
fn check(resp: &AgentResponse) -> Result<()> {
if let AgentResponse::Err { message } = resp {
bail!("{message}");
}
Ok(())
}

View file

@ -1,13 +1,12 @@
//! Manager harness. Talks to the manager socket (bind-mounted from the host //! Manager harness. Talks to the manager socket (bind-mounted from the host
//! at `/run/hive/mcp.sock` inside the `hm1nd` container) using the privileged //! at `/run/hive/mcp.sock` inside the `hm1nd` container). Two surfaces:
//! tool surface. Phase 4 minimum: a CLI to exercise the verbs from a shell, //! `serve` (long-lived turn loop) and `mcp` (stdio MCP server claude spawns).
//! plus a `serve` loop that logs the manager's inbox.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail}; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent}; use hive_ag3nt::events::{Bus, LiveEvent};
use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::login::{self, LoginState};
@ -32,25 +31,10 @@ enum Cmd {
#[arg(long, default_value_t = 1000)] #[arg(long, default_value_t = 1000)]
poll_ms: u64, poll_ms: u64,
}, },
/// Send a message to a sub-agent (or anywhere — the broker doesn't validate).
Send { to: String, body: String },
/// Pop one message from the manager's inbox.
Recv,
/// Submit a spawn request for the user to approve (creates a pending
/// approval; on approval the host creates + starts the container).
RequestSpawn { name: String },
/// Kill a sub-agent.
Kill { name: String },
/// Start a stopped sub-agent.
Start { name: String },
/// Restart a sub-agent (stop + start).
Restart { name: String },
/// Submit a config commit on the agent's config repo for user approval.
RequestApplyCommit { agent: String, commit_ref: String },
/// Run the manager MCP server on stdio. Spawned by claude via /// Run the manager MCP server on stdio. Spawned by claude via
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the /// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
/// manager tool surface (`request_spawn`, `kill`, /// manager tool surface (`request_spawn`, `kill`, `start`, `restart`,
/// `request_apply_commit`). /// `request_apply_commit`, `ask_operator`).
Mcp, Mcp,
} }
@ -101,34 +85,10 @@ async fn main() -> Result<()> {
} }
} }
} }
Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await,
Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await,
Cmd::RequestSpawn { name } => {
one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await
}
Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await,
Cmd::Start { name } => one_shot(&cli.socket, ManagerRequest::Start { name }).await,
Cmd::Restart { name } => one_shot(&cli.socket, ManagerRequest::Restart { name }).await,
Cmd::RequestApplyCommit { agent, commit_ref } => {
one_shot(
&cli.socket,
ManagerRequest::RequestApplyCommit { agent, commit_ref },
)
.await
}
Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await, Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await,
} }
} }
async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> {
let resp: ManagerResponse = client::request(socket, &req).await?;
println!("{}", serde_json::to_string_pretty(&resp)?);
if let ManagerResponse::Err { message } = resp {
bail!("{message}");
}
Ok(())
}
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-m1nd serve"); tracing::info!(socket = %socket.display(), "hive-m1nd serve");
let mcp_config = turn::write_mcp_config(socket).await?; let mcp_config = turn::write_mcp_config(socket).await?;

View file

@ -167,6 +167,10 @@
' ', ' ',
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.'), 'destroy ' + c.name + '? container is removed; state + creds kept.'),
' ',
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }),
); );
} }
ul.append(li); ul.append(li);

View file

@ -132,22 +132,40 @@ fn finish_approval(
/// kept, so recreating an agent of the same name reuses prior config + creds /// kept, so recreating an agent of the same name reuses prior config + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/` /// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
/// is cleared because its contents (the mcp socket) don't survive restarts /// is cleared because its contents (the mcp socket) don't survive restarts
/// anyway. A future `--purge` path can wipe state when the operator opts in. /// anyway. With `purge=true` the persistent trees are also wiped — config
/// history, claude creds, notes — there is no undo.
/// Refuses the manager (declarative; would fight with the host's nixos config). /// Refuses the manager (declarative; would fight with the host's nixos config).
pub async fn destroy(coord: &Coordinator, name: &str) -> Result<()> { pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> {
if name == MANAGER_NAME || name == MANAGER_AGENT { if name == MANAGER_NAME || name == MANAGER_AGENT {
bail!("refusing to destroy the manager ({name})"); bail!("refusing to destroy the manager ({name})");
} }
tracing::info!(%name, "destroy"); tracing::info!(%name, purge, "destroy");
lifecycle::destroy(name).await?; lifecycle::destroy(name).await?;
coord.unregister_agent(name); coord.unregister_agent(name);
let runtime = Coordinator::agent_dir(name); let runtime = Coordinator::agent_dir(name);
if runtime.exists() { if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime); let _ = std::fs::remove_dir_all(&runtime);
} }
let _ = coord if purge {
.approvals for dir in [
.fail_pending_for_agent(name, "agent destroyed"); Coordinator::agent_state_root(name),
Coordinator::agent_applied_dir(name),
] {
if dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&dir)
{
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
}
}
}
let _ = coord.approvals.fail_pending_for_agent(
name,
if purge {
"agent purged"
} else {
"agent destroyed"
},
);
coord.notify_manager(&HelperEvent::Destroyed { coord.notify_manager(&HelperEvent::Destroyed {
agent: name.to_owned(), agent: name.to_owned(),
}); });

View file

@ -424,8 +424,20 @@ fn strip_container_prefix(name: &str) -> String {
.to_owned() .to_owned()
} }
async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response { #[derive(Deserialize, Default)]
match actions::destroy(&state.coord, &name).await { struct DestroyForm {
#[serde(default)]
purge: Option<String>,
}
async fn post_destroy(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
Form(form): Form<DestroyForm>,
) -> Response {
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
match actions::destroy(&state.coord, &name, purge).await {
Ok(()) => Redirect::to("/").into_response(), Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
} }

View file

@ -56,8 +56,14 @@ enum Cmd {
/// Stop a managed container (graceful). /// Stop a managed container (graceful).
Kill { name: String }, Kill { name: String },
/// Tear down a sub-agent container. Container is removed; persistent /// Tear down a sub-agent container. Container is removed; persistent
/// state (config repos + Claude credentials) is kept by default. /// state (config repos + Claude credentials) is kept by default. Pass
Destroy { name: String }, /// `--purge` to also wipe the agent's state dirs (config + creds +
/// notes). No undo.
Destroy {
name: String,
#[arg(long)]
purge: bool,
},
/// Apply pending config to a managed container. /// Apply pending config to a managed container.
Rebuild { name: String }, Rebuild { name: String },
/// List managed containers. /// List managed containers.
@ -121,8 +127,8 @@ async fn main() -> Result<()> {
Cmd::Kill { name } => { Cmd::Kill { name } => {
render(client::request(&cli.socket, HostRequest::Kill { name }).await?) render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
} }
Cmd::Destroy { name } => { Cmd::Destroy { name, purge } => {
render(client::request(&cli.socket, HostRequest::Destroy { name }).await?) render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?)
} }
Cmd::Rebuild { name } => { Cmd::Rebuild { name } => {
render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?) render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?)

View file

@ -115,8 +115,8 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
}); });
HostResponse::success() HostResponse::success()
} }
HostRequest::Destroy { name } => { HostRequest::Destroy { name, purge } => {
actions::destroy(&coord, name).await?; actions::destroy(&coord, name, *purge).await?;
HostResponse::success() HostResponse::success()
} }
HostRequest::Rebuild { name } => { HostRequest::Rebuild { name } => {

View file

@ -27,8 +27,15 @@ pub enum HostRequest {
/// Tear down a sub-agent container: stop + remove + drop the systemd /// Tear down a sub-agent container: stop + remove + drop the systemd
/// drop-in, purge pending approvals. Persistent state (proposed/applied /// drop-in, purge pending approvals. Persistent state (proposed/applied
/// repos, Claude credentials) is KEPT by default — recreating the agent /// repos, Claude credentials) is KEPT by default — recreating the agent
/// with the same name reuses prior config + login. Manager not destroyable. /// with the same name reuses prior config + login. With `purge=true`
Destroy { name: String }, /// the agent's `/var/lib/hyperhive/{agents,applied}/<name>/` trees are
/// also wiped (config history + creds + notes gone forever). Manager
/// not destroyable.
Destroy {
name: String,
#[serde(default)]
purge: bool,
},
/// Apply pending config to a managed container. /// Apply pending config to a managed container.
Rebuild { name: String }, Rebuild { name: String },
/// List managed containers. /// List managed containers.