Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ed33d94f | ||
|
|
211599c589 | ||
|
|
0cc25d33d8 | ||
|
|
08f2ec5232 | ||
|
|
875a8f5be4 | ||
|
|
48ebfefd1a |
13 changed files with 268 additions and 116 deletions
|
|
@ -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
38
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 `<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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:#}")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue