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 ag3nt ░▒▓█▓▒░+
░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░\n▓█▓▒░ harness alive — turn loop running ▓█▓▒░
\n\ - \n\ - \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!( - "▓█▓▒░ NEEDS L0G1N ▓█▓▒░
\nNo Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.
▶ {url}
\n", - url = html_escape(&url), - ), - None => "".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 { - "".into() - }; - let cancel_form = "".to_owned(); - format!( - "▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░
\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n{output}",
- output = html_escape(&output),
- )
-}
+// ---------------------------------------------------------------------------
+// Action handlers
+// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SendForm {
@@ -278,21 +267,7 @@ async fn post_login_cancel(State(state): State{msg}",
- 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!("",);