Compare commits

...
Author SHA1 Message Date
müde
070b237d03 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.
2026-05-15 17:17:48 +02:00
müde
124fd97288 agent ui: SPA shell — static index.html + app.js, /api/state JSON 2026-05-15 17:15:28 +02:00
7 changed files with 429 additions and 229 deletions

View file

@ -21,8 +21,8 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
src/actions.rs approve/deny/destroy src/actions.rs approve/deny/destroy
src/auto_update.rs startup rebuild scan + ensure_manager src/auto_update.rs startup rebuild scan + ensure_manager
src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator
src/dashboard.rs axum HTTP UI: containers, approvals, async-form actions src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
assets/ CSS + JS shipped via include_str! assets/ index.html, dashboard.css, app.js (include_str!)
hive-ag3nt/ in-container harness crate; produces TWO binaries hive-ag3nt/ in-container harness crate; produces TWO binaries
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT 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 marks them `failed` with note `"agent state dir missing"` so they fall out
of `pending`. They stay in sqlite for audit. 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 /<action>` (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 ## Agent MCP surface + turn loop
The harness ships an embedded MCP server (rmcp 1.7) that claude launches as The harness ships an embedded MCP server (rmcp 1.7) that claude launches as

View file

@ -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. 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, - **Per-agent UI substance.** Show last N inbox messages, last turn timing,
link back to dashboard. 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 - **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
the harness. Pairs well with the unprivileged-container work — would let the harness. Pairs well with the unprivileged-container work — would let
the operator drop into the container without `nixos-container root-login`. the operator drop into the container without `nixos-container root-login`.

290
hive-ag3nt/assets/app.js Normal file
View file

@ -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) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[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 = '<span class="spinner">◐</span>'; }
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 <code>from: operator</code> 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 <code>~/.claude/</code>. 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 <code>claude auth login</code> 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;
})();

View file

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive agent</title>
<link rel="stylesheet" href="/static/agent.css">
</head>
<body>
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
<h2 id="title">◆ … ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="status">
<p class="meta">loading…</p>
</div>
<h3>live</h3>
<div id="live" class="live"><div class="meta">connecting…</div></div>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -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]');
};
})();

View file

@ -1,7 +1,9 @@
//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port. //! Per-container HTTP UI. SPA shape: `GET /` returns a static shell;
//! Containers share the host's network namespace (privateNetwork = false), so //! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page
//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by //! state as JSON; the JS app renders. Live events stream on
//! `hive-c0re`'s generated per-agent flake (deterministic from agent name). //! `/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::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
@ -12,13 +14,14 @@ use anyhow::{Context, Result};
use axum::{ use axum::{
Form, Router, Form, Router,
extract::State, extract::State,
http::StatusCode,
response::{ response::{
Html, IntoResponse, Redirect, Response, IntoResponse, Redirect, Response,
sse::{Event, KeepAlive, Sse}, sse::{Event, KeepAlive, Sse},
}, },
routing::{get, post}, routing::{get, post},
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use crate::client; use crate::client;
@ -66,7 +69,10 @@ pub async fn serve(
flavor, flavor,
}; };
let app = Router::new() 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("/events/stream", get(events_stream))
.route("/send", post(post_send)) .route("/send", post(post_send))
.route("/login/start", post(post_login_start)) .route("/login/start", post(post_login_start))
@ -82,100 +88,83 @@ pub async fn serve(
Ok(()) Ok(())
} }
async fn index(State(state): State<AppState>) -> Html<String> { // ---------------------------------------------------------------------------
// 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<SessionView>,
}
#[derive(Serialize)]
struct SessionView {
/// First `https://…` claude emitted on stdout, if any.
url: Option<String>,
/// Accumulated stdout + stderr.
output: String,
finished: bool,
exit_note: Option<String>,
}
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
drop_if_finished(&state.session); drop_if_finished(&state.session);
let login = *state.login.lock().unwrap(); let login = *state.login.lock().unwrap();
let session_snapshot = state.session.lock().unwrap().clone(); let session_snapshot = state.session.lock().unwrap().clone();
let body = match (login, session_snapshot) { let (status, session_view) = match (login, session_snapshot) {
(LoginState::Online, _) => render_online(&state.label), (LoginState::Online, _) => ("online", None),
(LoginState::NeedsLogin, None) => render_needs_login_idle(), (LoginState::NeedsLogin, None) => ("needs_login_idle", None),
(LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), (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") let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
.ok() .ok()
.and_then(|s| s.parse::<u16>().ok()) .and_then(|s| s.parse::<u16>().ok())
.unwrap_or(7000); .unwrap_or(7000);
Html(format!( axum::Json(StateSnapshot {
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆ <a href=\"#\" id=\"rebuild-btn\" class=\"btn-rebuild\" data-port=\"{dashboard_port}\" data-label=\"{label}\">↻ R3BU1LD</a></h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n<script>\n(function() {{\n const b = document.getElementById('rebuild-btn');\n b.addEventListener('click', function(e) {{\n e.preventDefault();\n if (!confirm('rebuild ' + b.dataset.label + '? container will hot-reload.')) return;\n const url = window.location.protocol + '//' + window.location.hostname + ':' + b.dataset.port + '/rebuild/' + b.dataset.label;\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = url;\n document.body.appendChild(form);\n form.submit();\n }});\n}})();\n</script>\n</body>\n</html>\n", label: state.label.clone(),
label = state.label, dashboard_port,
)) status,
session: session_view,
})
} }
fn render_online(label: &str) -> String { // ---------------------------------------------------------------------------
format!( // Action handlers
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n\ // ---------------------------------------------------------------------------
<form id=\"sendform\" class=\"sendform\">\n \
<input id=\"sendbody\" name=\"body\" placeholder=\"message {label} as operator…\" required autocomplete=\"off\">\n \
<button type=\"submit\" class=\"btn btn-send\">◆ S3ND</button>\n\
</form>\n\
<p class=\"meta\">enqueued with <code>from: operator</code> on this agent's inbox; the next turn picks it up.</p>\n\
<script>\n\
document.getElementById('sendform').addEventListener('submit', async function(e) {{\n \
e.preventDefault();\n \
const input = document.getElementById('sendbody');\n \
const body = input.value.trim();\n \
if (!body) return;\n \
const resp = await fetch('/send', {{\n \
method: 'POST',\n \
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},\n \
body: new URLSearchParams({{ body }}),\n \
redirect: 'manual',\n \
}});\n \
if (resp.type === 'opaqueredirect' || (resp.ok && resp.status < 400)) {{\n \
input.value = '';\n \
}} else {{\n \
alert('send failed: ' + resp.status);\n \
}}\n\
}});\n\
</script>\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!(
"<h3>live</h3>\n",
"<div id=\"live\" class=\"live\"><div class=\"meta\">connecting…</div></div>\n",
"<script>\n",
include_str!("../assets/live.js"),
"</script>",
);
fn render_needs_login_idle() -> String {
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
}
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
let url_block = match session.url() {
Some(url) => format!(
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
url = html_escape(&url),
),
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
};
let exit_badge = if session.finished() {
let note = session.exit_note().unwrap_or_else(|| "exited".into());
format!(
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
note = html_escape(&note),
)
} else {
String::new()
};
let output = session.output();
let code_form = if session.finished() {
String::new()
} else {
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
};
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
format!(
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
output = html_escape(&output),
)
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct SendForm { struct SendForm {
@ -278,21 +267,7 @@ async fn post_login_cancel(State(state): State<AppState>) -> Response {
} }
fn error_response(message: &str) -> Response { fn error_response(message: &str) -> Response {
( // Plain text — JS app surfaces in `alert()`, HTML wrapping would just
axum::http::StatusCode::INTERNAL_SERVER_ERROR, // be noise.
Html(format!( (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
msg = html_escape(message),
)),
)
.into_response()
} }
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
const STYLE: &str = concat!("<style>\n", include_str!("../assets/agent.css"), "</style>",);

View file

@ -1,4 +1,4 @@
{ pkgs, ... }: { pkgs, lib, ... }:
{ {
# Shared scaffolding for any hyperhive harness container — both # Shared scaffolding for any hyperhive harness container — both
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend # 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 # 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. # request flow) and by hive-c0re's own setup_applied / setup_proposed.
# `programs.git.enable` installs the binary + manages `/etc/gitconfig` # The per-agent `applied/<name>/flake.nix` overrides `user.name` and
# declaratively so the inline module in `applied/<name>/flake.nix` can # `user.email` with the agent's identity — values here are `mkDefault`
# override `user.name` / `user.email` per agent without fighting a raw # so the per-agent override wins without needing `mkForce`.
# `environment.etc."gitconfig"` block.
programs.git = { programs.git = {
enable = true; enable = true;
config = { config = {
user = { user = {
name = "hyperhive"; name = lib.mkDefault "hyperhive";
email = "hyperhive@local"; email = lib.mkDefault "hyperhive@local";
}; };
init.defaultBranch = "main"; init.defaultBranch = lib.mkDefault "main";
}; };
}; };