// 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 = ''; }); // Re-enable the button — refreshState() often skips re-rendering the // form (status unchanged), so without this the spinner sticks and // the operator can't submit again. if (btn) { btn.disabled = false; btn.innerHTML = original; } 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; let lastStatus = null; let lastOutputLen = -1; let pollTimer = null; 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; } // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself — without this guard, the // operator's gets clobbered every cycle. const outLen = s.session?.output?.length ?? -1; const dirty = s.status !== lastStatus || (s.status === 'needs_login_in_progress' && outLen !== lastOutputLen); if (dirty) { 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); lastStatus = s.status; lastOutputLen = outLen; } // Only poll while a login is in flight — otherwise SSE turn_end // events trigger a refresh, and the operator can type into the // send form without it getting cleared every few seconds. if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } if (s.status === 'needs_login_in_progress') { pollTimer = setTimeout(refreshState, 1500); } } catch (err) { console.error('refreshState failed', err); pollTimer = setTimeout(refreshState, 5000); } } refreshState(); // ─── 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 details(cls, summary, body) { clearPlaceholder(); const d = document.createElement('details'); d.className = 'row ' + (cls || ''); const s = document.createElement('summary'); s.textContent = summary; d.appendChild(s); const pre = document.createElement('pre'); pre.className = 'tool-body'; pre.textContent = body; d.appendChild(pre); log.appendChild(d); log.scrollTop = log.scrollHeight; return d; } function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } // Pretty-print a tool call: per-known-tool format, fallback to JSON // for unknown tools. function fmtToolUse(c) { const name = c.name || ''; const input = c.input || {}; const short = name.startsWith('mcp__hyperhive__') ? name.slice('mcp__hyperhive__'.length) + '*' : name; switch (name) { case 'Read': return short + ' ' + (input.file_path || ''); case 'Write': return short + ' ' + (input.file_path || ''); case 'Edit': return short + ' ' + (input.file_path || ''); case 'Glob': return short + ' ' + (input.pattern || ''); case 'Grep': return short + ' ' + (input.pattern || ''); case 'Bash': return short + ' $ ' + (input.command || ''); case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)'; case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': ' + JSON.stringify(input.body || '').slice(0, 80); case 'mcp__hyperhive__recv': return short + '()'; case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || ''); case 'mcp__hyperhive__kill': return short + ' ' + (input.name || ''); case 'mcp__hyperhive__request_apply_commit': return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); default: return name + ' ' + trim(JSON.stringify(input), 200); } } function renderToolResult(c) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') : (c.content || ''); const summary = '← ' + (() => { const trimmed = txt.replace(/\s+/g, ' ').trim(); if (!trimmed) return '(empty)'; if (trimmed.length <= 120) return trimmed; const lines = txt.split('\n').filter(l => l.length).length; const headline = trimmed.slice(0, 90) + '…'; return `${lines}L · ${headline}`; })(); // For empty / short results, render as a flat row (no expand). if (!txt.trim() || txt.length <= 120) { row('tool-result', summary); } else { details('tool-result-block', summary, txt); } } function renderStream(v) { // Drop session init, claude's result line, rate-limit — they're // noise. TurnEnd communicates pass/fail; session init data isn't // actionable. if (v.type === 'system' && v.subtype === 'init') return; if (v.type === 'rate_limit_event') return; if (v.type === 'result') 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') { const txt = (c.thinking || c.text || '').trim(); row('thinking', txt ? '· ' + txt : '· thinking …'); } else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c)); } return; } if (v.type === 'user' && v.message && v.message.content) { for (const c of v.message.content) { if (c.type === 'tool_result') renderToolResult(c); } return; } row('sys', '· ' + trim(JSON.stringify(v), 200)); } function handle(ev) { if (ev.kind === 'turn_start') { const block = row('turn-start', '◆ TURN ← ' + ev.from); if (ev.unread > 0) { const badge = document.createElement('span'); badge.className = 'unread-badge'; badge.textContent = '· ' + ev.unread + ' unread'; block.appendChild(badge); } 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; })();