diff --git a/CLAUDE.md b/CLAUDE.md index 06400965..fea64a49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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}//`; + no undo). - Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`. - Stopped: `▶ ST4RT`. - Stale marker: clickable `needs update ↻` badge (same target as rebuild diff --git a/TODO.md b/TODO.md index 5492d727..59d85386 100644 --- a/TODO.md +++ b/TODO.md @@ -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 `` 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//` - and `/var/lib/hyperhive/applied//`. -## 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. diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 15ea4777..249daa59 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -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; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 45c1bdc1..6183e939 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -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 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) { diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 7e3613ed..219f8d9f 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -13,6 +13,10 @@

loading…

+
+ … booting +
+
connecting…
diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 16b521f0..bc5af540 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -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(()) -} diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 6798922d..d3cbd8d7 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -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?; diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index d16f3b86..4fdc6923 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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); diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 8e7dee90..069b640c 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -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(), }); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index bcfed096..02b5bc9c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -424,8 +424,20 @@ fn strip_container_prefix(name: &str) -> String { .to_owned() } -async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { - match actions::destroy(&state.coord, &name).await { +#[derive(Deserialize, Default)] +struct DestroyForm { + #[serde(default)] + purge: Option, +} + +async fn post_destroy( + State(state): State, + AxumPath(name): AxumPath, + Form(form): Form, +) -> 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:#}")), } diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index c78735bf..6561703a 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -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?) diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index afe1985b..9ce4df1e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -115,8 +115,8 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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 } => { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 3415d20f..067d79e7 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -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}//` 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.