// 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; })();