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`):
- 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`.
- Stopped: `▶ ST4RT`.
- 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
agent or auto-vacuum on age, same trade-off as the bounded broker
entry below.
- **Granular agent state badge** above the terminal: `idle 💤 / thinking 🧠 /
compacting 📦` with an age timer (`thinking · 12s`). Drives a state
channel from the harness: idle when waiting on the inbox, thinking
while claude's stream is open, compacting when `/compact` is in
flight. Replaces the binary "harness alive — turn loop running" line.
- **Terminal: slash commands + tab-completion.** Operator-facing
in-terminal commands: `/help`, `/model`, `/compact`, `/clear`. Tab
completes command names + model names (cf. bitburner-agent's pattern).
- **Terminal: multi-line input.** Replace the single-line `<input>` with
an auto-growing textarea; Enter sends, Shift+Enter newlines.
- **State badge: compacting + napping states.** Idle/thinking already
ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
`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
@ -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
toolbar button while the state badge is `idle`. Sets state to
`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
the harness. Pairs well with the unprivileged-container work — would let
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
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can
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);
color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
max-width: 70em;
max-width: 110em;
margin: 1.5em auto;
padding: 0 1.5em;
line-height: 1.6;
@ -124,12 +124,49 @@ pre.diff {
word-break: break-all;
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
unit. Crust as bg (almost-black), slightly inset, mauve phosphor glow.
Frosted-glass backdrop blur: the page bg behind the wrap gets softened,
so anything that bleeds through (page banner glow, scroll position)
reads as out-of-focus depth instead of sharp competing detail. */
.terminal-wrap {
position: relative;
background: rgba(17, 17, 27, 0.78);
-webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%);
@ -148,7 +185,10 @@ pre.diff {
border-radius: 0;
padding: 0.8em 1em 0.4em;
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-size: inherit;
color: inherit;
@ -224,6 +264,36 @@ pre.diff {
from { opacity: 0; transform: translateY(4px); }
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 {
white-space: normal;
padding-left: 0.5em;

View file

@ -254,6 +254,54 @@
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
// 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.
@ -277,6 +325,11 @@
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
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
// common case is `online` polling itself — without this guard, the
// operator's <input value> gets clobbered every cycle.
@ -331,13 +384,58 @@
row: (cls, text) => row(cls, text),
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) {
clearPlaceholder();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.textContent = text;
log.appendChild(e);
log.scrollTop = log.scrollHeight;
afterAppend();
return e;
}
function details(cls, summary, body) {
@ -352,7 +450,7 @@
pre.textContent = body;
d.appendChild(pre);
log.appendChild(d);
log.scrollTop = log.scrollHeight;
afterAppend();
return d;
}
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
@ -429,7 +527,7 @@
function handle(ev, opts) {
const fromHistory = !!(opts && opts.fromHistory);
if (ev.kind === 'turn_start') {
if (!fromHistory) setBannerActive(true);
if (!fromHistory) { setBannerActive(true); setState('thinking'); }
const block = row('turn-start', '◆ TURN ← ' + ev.from);
if (ev.unread > 0) {
const badge = document.createElement('span');
@ -444,7 +542,7 @@
return;
}
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';
row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
// Login may have just landed (or session re-enters Online). Pull
@ -483,6 +581,7 @@
}
currentNoAnim = false;
for (let i = 0; i < openTurns; i++) setBannerActive(true);
if (openTurns > 0) setState('thinking');
if (events.length) row('note', '─── live (older above) ───');
else setPlaceholder('(connected — waiting for events)');
} catch (err) {

View file

@ -13,6 +13,10 @@
<p class="meta">loading…</p>
</div>
<div id="state-row">
<span id="state-badge" class="state-badge state-loading">… booting</span>
</div>
<div class="terminal-wrap">
<div id="live" class="live terminal"><div class="meta">connecting…</div></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::time::Duration;
use anyhow::{Result, bail};
use anyhow::Result;
use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent};
use hive_ag3nt::login::{self, LoginState};
@ -29,10 +29,6 @@ enum Cmd {
#[arg(long, default_value_t = 1000)]
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
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
/// 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,
}
}
@ -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
//! at `/run/hive/mcp.sock` inside the `hm1nd` container) using the privileged
//! tool surface. Phase 4 minimum: a CLI to exercise the verbs from a shell,
//! plus a `serve` loop that logs the manager's inbox.
//! at `/run/hive/mcp.sock` inside the `hm1nd` container). Two surfaces:
//! `serve` (long-lived turn loop) and `mcp` (stdio MCP server claude spawns).
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Result, bail};
use anyhow::Result;
use clap::{Parser, Subcommand};
use hive_ag3nt::events::{Bus, LiveEvent};
use hive_ag3nt::login::{self, LoginState};
@ -32,25 +31,10 @@ enum Cmd {
#[arg(long, default_value_t = 1000)]
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
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
/// manager tool surface (`request_spawn`, `kill`,
/// `request_apply_commit`).
/// manager tool surface (`request_spawn`, `kill`, `start`, `restart`,
/// `request_apply_commit`, `ask_operator`).
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,
}
}
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<()> {
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
let mcp_config = turn::write_mcp_config(socket).await?;

View file

@ -167,6 +167,10 @@
' ',
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'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);

View file

@ -132,22 +132,40 @@ fn finish_approval(
/// kept, so recreating an agent of the same name reuses prior config + creds
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
/// 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).
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 {
bail!("refusing to destroy the manager ({name})");
}
tracing::info!(%name, "destroy");
tracing::info!(%name, purge, "destroy");
lifecycle::destroy(name).await?;
coord.unregister_agent(name);
let runtime = Coordinator::agent_dir(name);
if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime);
}
let _ = coord
.approvals
.fail_pending_for_agent(name, "agent destroyed");
if purge {
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)
{
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 {
agent: name.to_owned(),
});

View file

@ -424,8 +424,20 @@ fn strip_container_prefix(name: &str) -> String {
.to_owned()
}
async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
match actions::destroy(&state.coord, &name).await {
#[derive(Deserialize, Default)]
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(),
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
}

View file

@ -56,8 +56,14 @@ enum Cmd {
/// Stop a managed container (graceful).
Kill { name: String },
/// Tear down a sub-agent container. Container is removed; persistent
/// state (config repos + Claude credentials) is kept by default.
Destroy { name: String },
/// state (config repos + Claude credentials) is kept by default. Pass
/// `--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.
Rebuild { name: String },
/// List managed containers.
@ -121,8 +127,8 @@ async fn main() -> Result<()> {
Cmd::Kill { name } => {
render(client::request(&cli.socket, HostRequest::Kill { name }).await?)
}
Cmd::Destroy { name } => {
render(client::request(&cli.socket, HostRequest::Destroy { name }).await?)
Cmd::Destroy { name, purge } => {
render(client::request(&cli.socket, HostRequest::Destroy { name, purge }).await?)
}
Cmd::Rebuild { name } => {
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()
}
HostRequest::Destroy { name } => {
actions::destroy(&coord, name).await?;
HostRequest::Destroy { name, purge } => {
actions::destroy(&coord, name, *purge).await?;
HostResponse::success()
}
HostRequest::Rebuild { name } => {

View file

@ -27,8 +27,15 @@ pub enum HostRequest {
/// Tear down a sub-agent container: stop + remove + drop the systemd
/// drop-in, purge pending approvals. Persistent state (proposed/applied
/// repos, Claude credentials) is KEPT by default — recreating the agent
/// with the same name reuses prior config + login. Manager not destroyable.
Destroy { name: String },
/// with the same name reuses prior config + login. With `purge=true`
/// 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.
Rebuild { name: String },
/// List managed containers.