From 124fd972887868ecd73203da0aa47766ef15e3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 17:15:28 +0200 Subject: [PATCH 1/2] =?UTF-8?q?agent=20ui:=20SPA=20shell=20=E2=80=94=20sta?= =?UTF-8?q?tic=20index.html=20+=20app.js,=20/api/state=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hive-ag3nt/assets/app.js | 290 +++++++++++++++++++++++++++++++++++ hive-ag3nt/assets/index.html | 22 +++ hive-ag3nt/assets/live.js | 103 ------------- hive-ag3nt/src/web_ui.rs | 191 ++++++++++------------- 4 files changed, 395 insertions(+), 211 deletions(-) create mode 100644 hive-ag3nt/assets/app.js create mode 100644 hive-ag3nt/assets/index.html delete mode 100644 hive-ag3nt/assets/live.js diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js new file mode 100644 index 00000000..ea07e6cd --- /dev/null +++ b/hive-ag3nt/assets/app.js @@ -0,0 +1,290 @@ +// Per-agent web UI. Renders title + login/online view from `/api/state`, +// tails `/events/stream` for live claude events, drives async-form +// actions (send / login/* / dashboard rebuild). + +(() => { + // ─── helpers ──────────────────────────────────────────────────────────── + const $ = (id) => document.getElementById(id); + const escText = (s) => String(s).replace(/[&<>"]/g, (c) => + ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) + ); + const el = (tag, attrs = {}, ...children) => { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k === 'html') e.innerHTML = v; + else e.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + e.append(c.nodeType ? c : document.createTextNode(c)); + } + return e; + }; + + // ─── async-form submit (shared with dashboard) ────────────────────────── + document.addEventListener('submit', async (e) => { + const f = e.target; + if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; + e.preventDefault(); + if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; + const btn = f.querySelector('button[type="submit"], button:not([type])'); + const original = btn ? btn.innerHTML : ''; + if (btn) { btn.disabled = true; btn.innerHTML = ''; } + try { + const resp = await fetch(f.action, { + method: f.method || 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(new FormData(f)), + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); + if (btn) { btn.disabled = false; btn.innerHTML = original; } + return; + } + // Clear text inputs the operator typed into (the form value was sent). + f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; }); + refreshState(); + } catch (err) { + alert('action failed: ' + err); + if (btn) { btn.disabled = false; btn.innerHTML = original; } + } + }); + + // ─── state rendering ──────────────────────────────────────────────────── + function setHeader(label, dashboardPort) { + $('banner').textContent = + `░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`; + const title = $('title'); + title.textContent = `◆ ${label} ◆ `; + const btn = el('a', { + href: '#', class: 'btn-rebuild', id: 'rebuild-btn', + }, '↻ R3BU1LD'); + btn.addEventListener('click', (e) => { + e.preventDefault(); + if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; + const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`; + const f = document.createElement('form'); + f.method = 'POST'; + f.action = url; + document.body.appendChild(f); + f.submit(); + }); + title.append(btn); + document.title = `${label} // hyperhive`; + } + + function renderOnline(label, root) { + root.append( + el('p', { class: 'status-online' }, '▓█▓▒░ harness alive — turn loop running ▓█▓▒░'), + ); + const form = el('form', { + action: '/send', method: 'POST', class: 'sendform', 'data-async': '', + }); + form.append( + el('input', { + name: 'body', placeholder: `message ${label} as operator…`, + required: '', autocomplete: 'off', + }), + el('button', { type: 'submit', class: 'btn btn-send' }, '◆ S3ND'), + ); + root.append(form); + root.append(el('p', { class: 'meta', html: + 'enqueued with from: operator on this agent\'s inbox; the next turn picks it up.', + })); + } + + function renderNeedsLoginIdle(root) { + root.append( + el('p', { class: 'status-needs-login' }, '▓█▓▒░ NEEDS L0G1N ▓█▓▒░'), + el('p', { html: + 'No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.', + }), + ); + const start = el('form', { + action: '/login/start', method: 'POST', 'data-async': '', + }); + start.append( + el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'), + ); + root.append(start); + root.append(el('p', { class: 'meta', html: + 'Spawns claude auth login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.', + })); + } + + function renderLoginInProgress(s, root) { + root.append(el('p', { class: 'status-needs-login' }, '▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░')); + if (s.url) { + const link = el('a', { + href: s.url, target: '_blank', rel: 'noreferrer', + }, s.url); + root.append(el('p', {}, '▶ ', link)); + root.append(el('p', { class: 'meta' }, + 'open this URL in a browser, complete the OAuth flow, paste the resulting code below.', + )); + } else { + root.append(el('p', { class: 'meta' }, + 'waiting for claude to emit an OAuth URL on stdout… (output below)', + )); + } + if (!s.finished) { + const code = el('form', { + action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '', + }); + code.append( + el('input', { + name: 'code', placeholder: 'paste OAuth code here', + required: '', autocomplete: 'off', + }), + el('button', { type: 'submit', class: 'btn btn-login' }, '◆ S3ND C0DE'), + ); + root.append(code); + } + const cancel = el('form', { + action: '/login/cancel', method: 'POST', 'data-async': '', + style: 'margin-top: 0.4em;', + }); + cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill')); + root.append(cancel); + if (s.finished) { + root.append(el('p', { class: 'status-needs-login' }, + `claude process exited: ${s.exit_note || 'exited'}. Start over if needed.`, + )); + } + root.append(el('h3', {}, 'output')); + root.append(el('pre', { class: 'diff' }, s.output || '')); + } + + let headerSet = false; + async function refreshState() { + try { + const resp = await fetch('/api/state'); + if (!resp.ok) throw new Error('http ' + resp.status); + const s = await resp.json(); + if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } + const root = $('status'); + root.innerHTML = ''; + if (s.status === 'online') renderOnline(s.label, root); + else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root); + else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root); + } catch (err) { + console.error('refreshState failed', err); + } + } + refreshState(); + // Mid-login refresh on a short interval so the output buffer updates. + setInterval(() => { + // Cheap; api/state is small. Could subscribe to SSE state events later. + refreshState(); + }, 3000); + + // ─── live event stream ────────────────────────────────────────────────── + (function() { + const log = $('live'); + if (!log) return; + let placeholder = log.firstChild; + function setPlaceholder(text) { + log.innerHTML = ''; + const span = document.createElement('div'); + span.className = 'meta'; + span.textContent = text; + log.appendChild(span); + placeholder = span; + } + function clearPlaceholder() { + if (placeholder) { log.innerHTML = ''; placeholder = null; } + } + function row(cls, text) { + clearPlaceholder(); + const e = document.createElement('div'); + e.className = 'row ' + (cls || ''); + e.textContent = text; + log.appendChild(e); + log.scrollTop = log.scrollHeight; + return e; + } + function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } + function renderStream(v) { + if (v.type === 'system' && v.subtype === 'init') { + row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?')); + return; + } + if (v.type === 'rate_limit_event') { + const u = Math.round((v.rate_limit_info?.utilization || 0) * 100); + const s = v.rate_limit_info?.status || ''; + row('sys', '· rate-limit util=' + u + '% (' + s + ')'); + return; + } + if (v.type === 'assistant' && v.message && v.message.content) { + for (const c of v.message.content) { + if (c.type === 'text' && c.text && c.text.trim()) row('text', c.text); + else if (c.type === 'thinking') row('thinking', '· thinking …'); + else if (c.type === 'tool_use') row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240)); + } + return; + } + if (v.type === 'user' && v.message && v.message.content) { + for (const c of v.message.content) { + if (c.type === 'tool_result') { + const txt = Array.isArray(c.content) + ? c.content.map(p => p.text || '').join(' ') + : (c.content || ''); + row('tool-result', '← ' + trim(txt, 300)); + } + } + return; + } + if (v.type === 'result') { + row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : '')); + return; + } + row('sys', '· ' + trim(JSON.stringify(v), 200)); + } + function handle(ev) { + if (ev.kind === 'turn_start') { + const block = row('turn-start', '◆ TURN ← ' + ev.from); + const body = document.createElement('div'); + body.className = 'turn-body'; + body.textContent = ev.body; + block.appendChild(body); + return; + } + if (ev.kind === 'turn_end') { + 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 + // fresh state so the form view reflects it. + refreshState(); + return; + } + if (ev.kind === 'note') { + row('note', '· ' + ev.text); + return; + } + if (ev.kind === 'stream') { + const v = Object.assign({}, ev); delete v.kind; + renderStream(v); + return; + } + row('note', JSON.stringify(ev)); + } + const es = new EventSource('/events/stream'); + es.onopen = () => setPlaceholder('(connected — waiting for events)'); + es.onmessage = (e) => { + try { handle(JSON.parse(e.data)); } + catch (err) { row('note', '[parse err] ' + e.data); } + }; + es.onerror = () => { + if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)'); + else row('note', '[disconnected]'); + }; + })(); + + // Avoid unused-var lint while keeping `escText` available for future use. + void escText; +})(); diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html new file mode 100644 index 00000000..b65ccd47 --- /dev/null +++ b/hive-ag3nt/assets/index.html @@ -0,0 +1,22 @@ + + + + + hyperhive agent + + + + +

◆ … ◆

+
══════════════════════════════════════════════════════════════
+ +
+

loading…

+
+ +

live

+
connecting…
+ + + + diff --git a/hive-ag3nt/assets/live.js b/hive-ag3nt/assets/live.js deleted file mode 100644 index 28200e40..00000000 --- a/hive-ag3nt/assets/live.js +++ /dev/null @@ -1,103 +0,0 @@ -(function() { - const log = document.getElementById('live'); - let placeholder = log.firstChild; - function setPlaceholder(text) { - log.innerHTML = ''; - const span = document.createElement('div'); - span.className = 'meta'; - span.textContent = text; - log.appendChild(span); - placeholder = span; - } - function clearPlaceholder() { - if (placeholder) { log.innerHTML = ''; placeholder = null; } - } - function row(cls, text) { - clearPlaceholder(); - const el = document.createElement('div'); - el.className = 'row ' + (cls || ''); - el.textContent = text; - log.appendChild(el); - log.scrollTop = log.scrollHeight; - return el; - } - function trim(s, n) { - return s.length > n ? s.slice(0, n) + '…' : s; - } - function renderStream(v) { - if (v.type === 'system' && v.subtype === 'init') { - row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?')); - return; - } - if (v.type === 'rate_limit_event') { - const u = Math.round((v.rate_limit_info?.utilization || 0) * 100); - const s = v.rate_limit_info?.status || ''; - row('sys', '· rate-limit util=' + u + '% (' + s + ')'); - return; - } - if (v.type === 'assistant' && v.message && v.message.content) { - for (const c of v.message.content) { - if (c.type === 'text' && c.text && c.text.trim()) - row('text', c.text); - else if (c.type === 'thinking') - row('thinking', '· thinking …'); - else if (c.type === 'tool_use') - row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240)); - } - return; - } - if (v.type === 'user' && v.message && v.message.content) { - for (const c of v.message.content) { - if (c.type === 'tool_result') { - const txt = Array.isArray(c.content) - ? c.content.map(p => p.text || '').join(' ') - : (c.content || ''); - row('tool-result', '← ' + trim(txt, 300)); - } - } - return; - } - if (v.type === 'result') { - row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : '')); - return; - } - // Fallback: small one-liner for unknown events; don't spam. - row('sys', '· ' + trim(JSON.stringify(v), 200)); - } - function handle(ev) { - if (ev.kind === 'turn_start') { - const block = row('turn-start', '◆ TURN ← ' + ev.from); - const body = document.createElement('div'); - body.className = 'turn-body'; - body.textContent = ev.body; - block.appendChild(body); - return; - } - if (ev.kind === 'turn_end') { - const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; - const sym = ev.ok ? '✓' : '✗'; - row(cls, sym + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); - return; - } - if (ev.kind === 'note') { - row('note', '· ' + ev.text); - return; - } - if (ev.kind === 'stream') { - const v = Object.assign({}, ev); delete v.kind; - renderStream(v); - return; - } - row('note', JSON.stringify(ev)); - } - const es = new EventSource('/events/stream'); - es.onopen = function() { setPlaceholder('(connected — waiting for events)'); }; - es.onmessage = function(e) { - try { handle(JSON.parse(e.data)); } - catch (err) { row('note', '[parse err] ' + e.data); } - }; - es.onerror = function() { - if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)'); - else row('note', '[disconnected]'); - }; -})(); diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 1b5be97b..edd4851f 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -1,7 +1,9 @@ -//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port. -//! Containers share the host's network namespace (privateNetwork = false), so -//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by -//! `hive-c0re`'s generated per-agent flake (deterministic from agent name). +//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell; +//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page +//! state as JSON; the JS app renders. Live events stream on +//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a +//! 303 Redirect (for browsers that submit the form normally) or just +//! 200 OK — the JS app re-fetches `/api/state` afterwards. use std::convert::Infallible; use std::net::SocketAddr; @@ -12,13 +14,14 @@ use anyhow::{Context, Result}; use axum::{ Form, Router, extract::State, + http::StatusCode, response::{ - Html, IntoResponse, Redirect, Response, + IntoResponse, Redirect, Response, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use crate::client; @@ -66,7 +69,10 @@ pub async fn serve( flavor, }; let app = Router::new() - .route("/", get(index)) + .route("/", get(serve_index)) + .route("/static/agent.css", get(serve_css)) + .route("/static/app.js", get(serve_app_js)) + .route("/api/state", get(api_state)) .route("/events/stream", get(events_stream)) .route("/send", post(post_send)) .route("/login/start", post(post_login_start)) @@ -82,100 +88,83 @@ pub async fn serve( Ok(()) } -async fn index(State(state): State) -> Html { +// --------------------------------------------------------------------------- +// Static assets + state snapshot +// --------------------------------------------------------------------------- + +async fn serve_index() -> impl IntoResponse { + ( + [("content-type", "text/html; charset=utf-8")], + include_str!("../assets/index.html"), + ) +} + +async fn serve_css() -> impl IntoResponse { + ( + [("content-type", "text/css")], + include_str!("../assets/agent.css"), + ) +} + +async fn serve_app_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript")], + include_str!("../assets/app.js"), + ) +} + +#[derive(Serialize)] +struct StateSnapshot { + label: String, + dashboard_port: u16, + /// `"online"` | `"needs_login_idle"` | `"needs_login_in_progress"`. + status: &'static str, + /// Present when `status == "needs_login_in_progress"`. + session: Option, +} + +#[derive(Serialize)] +struct SessionView { + /// First `https://…` claude emitted on stdout, if any. + url: Option, + /// Accumulated stdout + stderr. + output: String, + finished: bool, + exit_note: Option, +} + +async fn api_state(State(state): State) -> axum::Json { drop_if_finished(&state.session); let login = *state.login.lock().unwrap(); let session_snapshot = state.session.lock().unwrap().clone(); - let body = match (login, session_snapshot) { - (LoginState::Online, _) => render_online(&state.label), - (LoginState::NeedsLogin, None) => render_needs_login_idle(), - (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), + let (status, session_view) = match (login, session_snapshot) { + (LoginState::Online, _) => ("online", None), + (LoginState::NeedsLogin, None) => ("needs_login_idle", None), + (LoginState::NeedsLogin, Some(s)) => ( + "needs_login_in_progress", + Some(SessionView { + url: s.url(), + output: s.output(), + finished: s.finished(), + exit_note: s.exit_note(), + }), + ), }; let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); - Html(format!( - "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆ ↻ R3BU1LD

\n
══════════════════════════════════════════════════════════════
\n{body}\n\n\n\n", - label = state.label, - )) + axum::Json(StateSnapshot { + label: state.label.clone(), + dashboard_port, + status, + session: session_view, + }) } -fn render_online(label: &str) -> String { - format!( - "

▓█▓▒░ harness alive — turn loop running ▓█▓▒░

\n\ -
\n \ - \n \ - \n\ -
\n\ -

enqueued with from: operator on this agent's inbox; the next turn picks it up.

\n\ - \n\ - {LIVE_PANEL}", - ) -} - -/// Live event tail rendered into every `/` response when the agent is online. -/// JS opens an `EventSource` on `/events/stream` and appends rows; no full-page -/// reload, so the login flow and other forms aren't clobbered. -const LIVE_PANEL: &str = concat!( - "

live

\n", - "
connecting…
\n", - "", -); - -fn render_needs_login_idle() -> String { - "

▓█▓▒░ NEEDS L0G1N ▓█▓▒░

\n

No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.

\n
\n \n
\n

Spawns claude auth login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.

".into() -} - -fn render_login_in_progress(session: &Arc) -> String { - let url_block = match session.url() { - Some(url) => format!( - "

{url}

\n

open this URL in a browser, complete the OAuth flow, paste the resulting code below.

", - url = html_escape(&url), - ), - None => "

waiting for claude to emit an OAuth URL on stdout… (output below)

".into(), - }; - let exit_badge = if session.finished() { - let note = session.exit_note().unwrap_or_else(|| "exited".into()); - format!( - "

claude process exited: {note}. Start over if needed.

", - note = html_escape(¬e), - ) - } else { - String::new() - }; - let output = session.output(); - let code_form = if session.finished() { - String::new() - } else { - "
\n \n \n
".into() - }; - let cancel_form = "
\n \n
".to_owned(); - format!( - "

▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░

\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n

output

\n
{output}
", - output = html_escape(&output), - ) -} +// --------------------------------------------------------------------------- +// Action handlers +// --------------------------------------------------------------------------- #[derive(Deserialize)] struct SendForm { @@ -278,21 +267,7 @@ async fn post_login_cancel(State(state): State) -> Response { } fn error_response(message: &str) -> Response { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - Html(format!( - "\n{STYLE}

error

{msg}

← back

", - msg = html_escape(message), - )), - ) - .into_response() + // Plain text — JS app surfaces in `alert()`, HTML wrapping would just + // be noise. + (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() } - -fn html_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - -const STYLE: &str = concat!("",); From 070b237d03fe0c2c3fb726bbfe102d2726fe6867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 17:17:48 +0200 Subject: [PATCH 2/2] docs: SPA pattern noted, todo cleared; harness-base git config mkDefault programs.git.config.user.{name,email} in harness-base.nix now mkDefault so the per-agent applied flake's override merges without mkForce. --- CLAUDE.md | 30 ++++++++++++++++++++++++++++-- TODO.md | 9 --------- nix/templates/harness-base.nix | 15 +++++++-------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f3c1c60..0933e938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,8 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) src/actions.rs approve/deny/destroy src/auto_update.rs startup rebuild scan + ensure_manager src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator - src/dashboard.rs axum HTTP UI: containers, approvals, async-form actions - assets/ CSS + JS shipped via include_str! + src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions + assets/ index.html, dashboard.css, app.js (include_str!) hive-ag3nt/ in-container harness crate; produces TWO binaries src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT @@ -120,6 +120,32 @@ nix/ marks them `failed` with note `"agent state dir missing"` so they fall out of `pending`. They stay in sqlite for audit. +## Web UI shape + +Both the dashboard (port 7000) and the per-agent web UIs (8000 / +8100-8999) are SPAs with the same skeleton: + +- `GET /` → static `assets/index.html` (placeholders for state-driven + sections). +- `GET /static/*.css` + `GET /static/*.js` → static assets shipped via + `include_str!` so there's no runtime file dependency. +- `GET /api/state` → JSON snapshot the JS app renders into the DOM. +- `POST /` (approve, deny, kill, restart, rebuild, destroy, + request-spawn, update-all, send, login/*) → idempotent action endpoints. +- `GET /events/stream` (per-agent) and `GET /messages/stream` (dashboard) + are `text/event-stream` SSE for live updates. + +The JS app handles all `form[data-async]` submissions via a delegated +listener: read `data-confirm`, swap the button to a spinner, POST +`application/x-www-form-urlencoded` (axum's `Form` extractor rejects +multipart), then on success call `refreshState()` (re-fetch `/api/state` +and re-render). No full-page reloads. + +Per-agent + dashboard state shapes live in `dashboard.rs::StateSnapshot` +and `web_ui.rs::StateSnapshot`. When adding new state fields, plumb +through the snapshot struct and the relevant `assets/app.js` render +function — never reach for server-side HTML rendering again. + ## Agent MCP surface + turn loop The harness ships an embedded MCP server (rmcp 1.7) that claude launches as diff --git a/TODO.md b/TODO.md index 8091c35d..a7f72c7d 100644 --- a/TODO.md +++ b/TODO.md @@ -30,15 +30,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in only see them by watching the live panel of the agent that sent them. - **Per-agent UI substance.** Show last N inbox messages, last turn timing, link back to dashboard. -- **Static-asset SPA-style web UI.** Move toward: `index.html` is static, - CSS/JS is static, all dynamic state is fetched over SSE / JSON endpoints. - Currently the index HTML is server-rendered with state-dependent - fragments inlined; the live event stream + async forms are already SSE / - fetch. Goal is a cleaner split so the UI is one HTML file + JS app + - small JSON API. -- **Background JS refresh on the live panel.** Already there for sends; - any remaining places that reload the whole page should switch to fetch + - partial updates. - **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`. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 81402444..814e6051 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1,4 +1,4 @@ -{ pkgs, ... }: +{ pkgs, lib, ... }: { # Shared scaffolding for any hyperhive harness container — both # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend @@ -18,18 +18,17 @@ # Git is needed by claude's Bash tool (for the agent <-> manager config # request flow) and by hive-c0re's own setup_applied / setup_proposed. - # `programs.git.enable` installs the binary + manages `/etc/gitconfig` - # declaratively so the inline module in `applied//flake.nix` can - # override `user.name` / `user.email` per agent without fighting a raw - # `environment.etc."gitconfig"` block. + # The per-agent `applied//flake.nix` overrides `user.name` and + # `user.email` with the agent's identity — values here are `mkDefault` + # so the per-agent override wins without needing `mkForce`. programs.git = { enable = true; config = { user = { - name = "hyperhive"; - email = "hyperhive@local"; + name = lib.mkDefault "hyperhive"; + email = lib.mkDefault "hyperhive@local"; }; - init.defaultBranch = "main"; + init.defaultBranch = lib.mkDefault "main"; }; };