diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js
index 33ed880..0596b6b 100644
--- a/hive-c0re/assets/app.js
+++ b/hive-c0re/assets/app.js
@@ -126,7 +126,7 @@
// ── line 1: identity ─────────────────────────────────────────
const head = el('div', { class: 'head' });
head.append(
- el('a', { class: 'name', href: url }, c.name),
+ el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
c.is_manager ? 'm1nd' : 'ag3nt'),
);
@@ -135,7 +135,8 @@
el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…'));
} else if (c.needs_login) {
head.append(el('a',
- { class: 'badge badge-warn', href: url }, 'needs login →'));
+ { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
+ 'needs login →'));
}
if (c.needs_update) {
head.append(form(
@@ -182,6 +183,66 @@
root.append(ul);
}
+ function renderTombstones(s) {
+ const root = $('tombstones-section');
+ root.innerHTML = '';
+ if (!s.tombstones || !s.tombstones.length) {
+ root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
+ return;
+ }
+ const fmtBytes = (n) => {
+ if (n < 1024) return n + ' B';
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
+ return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
+ };
+ const fmtAge = (ts) => {
+ if (!ts) return '?';
+ const d = Math.floor((Date.now() / 1000 - ts) / 86400);
+ if (d <= 0) return 'today';
+ if (d === 1) return '1 day ago';
+ return d + ' days ago';
+ };
+ const ul = el('ul', { class: 'containers' });
+ for (const t of s.tombstones) {
+ const li = el('li', { class: 'container-row tombstone' });
+ const head = el('div', { class: 'head' });
+ head.append(
+ el('span', { class: 'name' }, t.name),
+ el('span', { class: 'badge badge-muted' }, 'destroyed'),
+ );
+ if (t.has_creds) {
+ head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
+ }
+ head.append(el('span', { class: 'meta' },
+ `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
+ li.append(head);
+
+ const actions = el('div', { class: 'actions' });
+ // Reuse the existing spawn form pattern via /request-spawn — operator
+ // can queue an approval that recreates the agent with the same name
+ // and reuses the kept state.
+ const respawn = el('form', {
+ method: 'POST', action: '/request-spawn',
+ class: 'inline', 'data-async': '',
+ 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
+ });
+ respawn.append(
+ el('input', { type: 'hidden', name: 'name', value: t.name }),
+ el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
+ );
+ actions.append(respawn);
+ actions.append(form(
+ '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
+ 'PURGE ' + t.name + '? config history, claude creds, /state/ notes '
+ + 'are all WIPED. no undo.',
+ ));
+ li.append(actions);
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+
function renderQuestions(s) {
const root = $('questions-section');
root.innerHTML = '';
@@ -331,6 +392,7 @@
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderContainers(s);
+ renderTombstones(s);
renderQuestions(s);
renderInbox(s);
renderApprovals(s);
diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css
index 08479d8..1e0792a 100644
--- a/hive-c0re/assets/dashboard.css
+++ b/hive-c0re/assets/dashboard.css
@@ -133,6 +133,16 @@ a:hover {
color: var(--amber); border-color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
}
+.badge-muted {
+ color: var(--muted); border-color: var(--purple-dim);
+ background: rgba(127, 132, 156, 0.08);
+}
+.container-row.tombstone {
+ border-style: dashed;
+ background: rgba(24, 24, 37, 0.35);
+ opacity: 0.85;
+}
+.container-row.tombstone .name { color: var(--muted); }
.pending-state {
color: var(--amber);
font-size: 0.85em;
diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html
index 838591a..26e7513 100644
--- a/hive-c0re/assets/index.html
+++ b/hive-c0re/assets/index.html
@@ -16,6 +16,12 @@
loading…
+ ◆ K3PT ST4T3 ◆
+ ══════════════════════════════════════════════════════════════
+
+
◆ M1ND H4S QU3STI0NS ◆
══════════════════════════════════════════════════════════════
diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs
index ed44bea..b6df8ac 100644
--- a/hive-c0re/src/coordinator.rs
+++ b/hive-c0re/src/coordinator.rs
@@ -204,4 +204,23 @@ impl Coordinator {
pub fn agent_applied_dir(name: &str) -> PathBuf {
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
}
+
+ /// Enumerate names that have a persistent state dir under
+ /// `/var/lib/hyperhive/agents/` (i.e. config / claude creds /
+ /// notes survive). Includes both currently-existing containers and
+ /// destroyed-but-kept tombstones; callers filter the latter by
+ /// subtracting `lifecycle::list()`.
+ #[must_use]
+ pub fn kept_state_names() -> Vec
{
+ let Ok(rd) = std::fs::read_dir(AGENT_STATE_ROOT) else {
+ return Vec::new();
+ };
+ let mut out: Vec = rd
+ .flatten()
+ .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
+ .filter_map(|e| e.file_name().into_string().ok())
+ .collect();
+ out.sort();
+ out
+ }
}
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index c5f9334..2a540e3 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> {
.route("/rebuild/{name}", post(post_rebuild))
.route("/update-all", post(post_update_all))
.route("/answer-question/{id}", post(post_answer_question))
+ .route("/purge-tombstone/{name}", post(post_purge_tombstone))
.route("/request-spawn", post(post_request_spawn))
.route("/messages/stream", get(messages_stream))
.with_state(AppState { coord });
@@ -106,6 +107,21 @@ struct StateSnapshot {
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
/// into the manager's inbox.
questions: Vec,
+ /// State dirs (config history + claude creds + /state/ notes) that
+ /// survive after a destroy-without-purge. The operator can re-spawn
+ /// with the same name to resume, or PURG3 to wipe them.
+ tombstones: Vec,
+}
+
+#[derive(Serialize)]
+struct TombstoneView {
+ name: String,
+ /// Bytes used by the state dir tree. Cheap-ish to compute; let the
+ /// operator know how much they're holding onto.
+ state_bytes: u64,
+ /// Mtime (unix seconds) of the state dir; rough "last seen".
+ last_seen: i64,
+ has_creds: bool,
}
#[derive(Serialize)]
@@ -145,6 +161,7 @@ struct ApprovalView {
diff_html: Option,
}
+#[allow(clippy::too_many_lines)]
async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json {
let host = headers
.get("host")
@@ -242,6 +259,35 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J
.unwrap_or_default();
let questions = state.coord.questions.pending().unwrap_or_default();
+ // Tombstones: state-dir names that don't appear in the live container
+ // list (and aren't the manager). Operator can re-spawn or PURG3.
+ let live: std::collections::HashSet = containers
+ .iter()
+ .map(|c| c.name.clone())
+ .chain(state.coord.transient_snapshot().into_keys())
+ .collect();
+ let tombstones: Vec = Coordinator::kept_state_names()
+ .into_iter()
+ .filter(|name| name != MANAGER_NAME && !live.contains(name))
+ .map(|name| {
+ let root = Coordinator::agent_state_root(&name);
+ let state_bytes = dir_size_bytes(&root);
+ let last_seen = std::fs::metadata(&root)
+ .and_then(|m| m.modified())
+ .ok()
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+ .and_then(|d| i64::try_from(d.as_secs()).ok())
+ .unwrap_or(0);
+ let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name));
+ TombstoneView {
+ name,
+ state_bytes,
+ last_seen,
+ has_creds,
+ }
+ })
+ .collect();
+
axum::Json(StateSnapshot {
hostname,
manager_port: MANAGER_PORT,
@@ -251,9 +297,33 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J
approvals: approval_views,
operator_inbox,
questions,
+ tombstones,
})
}
+/// Sum the byte size of every regular file under `root`. Cheap to compute
+/// for typical agent state (config repo + claude creds + notes file —
+/// usually a few MB); fine to do inline on each /api/state. Returns 0 on
+/// any error.
+fn dir_size_bytes(root: &Path) -> u64 {
+ fn walk(p: &Path, acc: &mut u64) {
+ let Ok(rd) = std::fs::read_dir(p) else { return };
+ for entry in rd.flatten() {
+ let Ok(ft) = entry.file_type() else { continue };
+ if ft.is_dir() {
+ walk(&entry.path(), acc);
+ } else if ft.is_file()
+ && let Ok(meta) = entry.metadata()
+ {
+ *acc += meta.len();
+ }
+ }
+ }
+ let mut total = 0u64;
+ walk(root, &mut total);
+ total
+}
+
async fn messages_stream(
State(state): State,
) -> Sse>> {
@@ -316,6 +386,48 @@ async fn post_answer_question(
}
}
+async fn post_purge_tombstone(
+ State(state): State,
+ AxumPath(name): AxumPath,
+) -> Response {
+ if name == lifecycle::MANAGER_NAME {
+ return error_response("refusing to purge the manager's state");
+ }
+ // Sanity: refuse to purge if a live container still exists with this
+ // name. The dashboard already filters tombstones to non-live names,
+ // but the operator could send a stale POST.
+ let live = lifecycle::list().await.unwrap_or_default();
+ if live
+ .iter()
+ .any(|c| c == &format!("{}{name}", lifecycle::AGENT_PREFIX) || c == &name)
+ {
+ return error_response(&format!(
+ "refusing to purge {name}: container still exists — use DESTR0Y first"
+ ));
+ }
+ let mut errors = Vec::new();
+ for dir in [
+ Coordinator::agent_state_root(&name),
+ Coordinator::agent_applied_dir(&name),
+ ] {
+ if dir.exists()
+ && let Err(e) = std::fs::remove_dir_all(&dir)
+ {
+ errors.push(format!("{}: {e}", dir.display()));
+ }
+ }
+ let _ = state
+ .coord
+ .approvals
+ .fail_pending_for_agent(&name, "agent state purged");
+ if errors.is_empty() {
+ tracing::info!(%name, "tombstone purged");
+ Redirect::to("/").into_response()
+ } else {
+ error_response(&format!("purge {name} partial: {}", errors.join(", ")))
+ }
+}
+
async fn post_request_spawn(
State(state): State,
Form(form): Form,
diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs
index cfb8b4f..7ece4fc 100644
--- a/hive-c0re/src/operator_questions.rs
+++ b/hive-c0re/src/operator_questions.rs
@@ -26,7 +26,7 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
";
/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN`
-/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first.
+/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first.
fn ensure_multi_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?