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] =?UTF-8?q?agent=20ui:=20SPA=20shell=20=E2=80=94=20static?= =?UTF-8?q?=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 0000000..ea07e6c --- /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 0000000..b65ccd4 --- /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 28200e4..0000000 --- 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 1b5be97..edd4851 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!("",);