// 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]), textarea').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'), ); } 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; let termInputRendered = false; // Filled in by the live-event IIFE below. Used by the slash-command // dispatcher to print local-only rows ('help', errors) and to clear // the terminal on `/clear`. let termAPI = null; const SLASH_COMMANDS = [ { name: '/help', desc: 'list slash commands' }, { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, { name: '/compact', desc: 'compact the persistent claude session' }, ]; async function postSimple(url, label) { try { const resp = await fetch(url, { method: 'POST', redirect: 'manual' }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok && termAPI) { termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status); } } catch (err) { if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err); } } const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); const postCompact = () => postSimple('/api/compact', '/compact'); function handleSlashCommand(line) { if (!termAPI) return false; const trimmed = line.trim(); if (!trimmed.startsWith('/')) return false; const [cmd] = trimmed.split(/\s+/); switch (cmd) { case '/help': termAPI.row('note', '· /help'); for (const c of SLASH_COMMANDS) { termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc); } return true; case '/clear': termAPI.clear(); termAPI.row('note', '· terminal cleared (local view only — server history kept)'); return true; case '/cancel': postCancelTurn(); return true; case '/compact': postCompact(); return true; default: termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help'); return true; } } // Cycle through commands when operator hits Tab on a `/…` prefix. function completeSlash(prefix) { const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)); if (!matches.length) return null; // Cycle: when the current prefix already equals a command name, // advance to the next match. const idx = matches.findIndex((c) => c.name === prefix); return matches[(idx + 1) % matches.length].name; } function renderTermInput(label, online) { const slot = $('term-input'); if (!slot) return; if (!termInputRendered) { slot.innerHTML = ''; const form = el('form', { action: '/send', method: 'POST', class: 'sendform-term', 'data-async': '', }); const ta = el('textarea', { name: 'body', placeholder: 'message ' + label + '…', required: '', autocomplete: 'off', rows: '1', }); // Enter submits, Shift+Enter inserts a newline. Auto-grow up to // ~8 rows of content, then scroll inside the textarea. const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base const grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px'; }; ta.addEventListener('input', grow); ta.addEventListener('keydown', (e) => { // Tab-complete slash commands when the buffer starts with `/`. if (e.key === 'Tab' && ta.value.startsWith('/') && !ta.value.includes(' ')) { const next = completeSlash(ta.value); if (next) { e.preventDefault(); ta.value = next; return; } } if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); const line = ta.value; if (!line.trim()) return; // Intercept slash commands locally; never send them to the agent. if (line.trim().startsWith('/')) { if (handleSlashCommand(line)) { ta.value = ''; grow(); return; } } form.requestSubmit(); } }); // Reset height after async submit clears the value. form.addEventListener('submit', () => setTimeout(grow, 0)); form.append( el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'), ta, el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline · /help'), ); slot.append(form); termInputRendered = true; } slot.classList.toggle('disabled', !online); const ta = slot.querySelector('textarea'); if (ta) ta.disabled = !online; } // Granular state badge: idle / thinking / offline. Driven from SSE // turn_start/turn_end. Age timer ticks client-side; badge re-renders // each second so the "· 12s" suffix stays current. State changes // trigger a short flash animation via .state-just-changed. const STATE_LABELS = { loading: { glyph: '…', text: 'booting' }, offline: { glyph: '○', text: 'offline' }, idle: { glyph: '💤', text: 'idle' }, thinking: { glyph: '🧠', text: 'thinking' }, }; let stateName = 'loading'; let stateSince = Date.now(); let stateTickTimer = null; function fmtAge(ms) { const s = Math.floor(ms / 1000); if (s < 60) return s + 's'; const m = Math.floor(s / 60); if (m < 60) return m + 'm ' + (s % 60) + 's'; const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } function renderStateBadge() { const badge = $('state-badge'); if (!badge) return; const def = STATE_LABELS[stateName] || STATE_LABELS.loading; const age = fmtAge(Date.now() - stateSince); badge.textContent = def.glyph + ' ' + def.text + ' · ' + age; badge.className = 'state-badge state-' + stateName; const cancelBtn = $('cancel-btn'); if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; } function setState(next) { if (next === stateName) return; stateName = next; stateSince = Date.now(); const badge = $('state-badge'); if (badge) { // Re-add the flash class so the animation replays. badge.classList.remove('state-just-changed'); void badge.offsetWidth; badge.classList.add('state-just-changed'); } renderStateBadge(); } function startStateTicker() { if (stateTickTimer) return; stateTickTimer = setInterval(renderStateBadge, 1000); } startStateTicker(); // Wire the cancel-turn button (visible only while state === thinking). (() => { const btn = $('cancel-btn'); if (!btn) return; btn.addEventListener('click', () => { btn.disabled = true; postCancelTurn().finally(() => { btn.disabled = false; }); }); })(); // Track banner activity by reference-counting in-flight turns. A turn // can begin while the previous turn_end is still in the pipeline (rare // but happens on tight wake cycles), so we count rather than toggle. let activeTurns = 0; function setBannerActive(on) { const banner = $('banner'); if (!banner) return; if (on) { activeTurns += 1; banner.classList.add('active'); } else { activeTurns = Math.max(0, activeTurns - 1); if (activeTurns === 0) banner.classList.remove('active'); } } 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; } renderTermInput(s.label, s.status === 'online'); // Drive the state badge from the harness status. Live SSE events // override to 'thinking' / 'idle' as turns start/end; this only // kicks in for the not-online (offline) case and the initial seed. if (s.status !== 'online') setState('offline'); else if (stateName === 'loading' || stateName === 'offline') setState('idle'); // 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; } } // Backfill replays mark rows .no-anim so we don't stagger 100 fade-ins // on page load. Set via `currentNoAnim` before the row helpers fire. let currentNoAnim = false; // Expose the panel API for slash commands (`/help`, `/clear`). termAPI = { row: (cls, text) => row(cls, text), clear: () => { log.innerHTML = ''; placeholder = null; }, }; // Sticky-bottom auto-scroll. If the user is reading scrolled-up, new // rows do NOT yank the view. A floating "↓ N new" pill appears in // the bottom-right corner; clicking it jumps to bottom and clears // the counter. Scrolling back near the bottom also clears it. const NEAR_BOTTOM_PX = 48; let unseen = 0; let pill = null; function isNearBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; } function ensurePill() { if (pill) return pill; pill = document.createElement('button'); pill.type = 'button'; pill.className = 'tail-pill'; pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; }); log.parentElement.appendChild(pill); return pill; } function updatePill() { if (unseen <= 0) { if (pill) pill.classList.remove('visible'); return; } ensurePill(); pill.textContent = '↓ ' + unseen + ' new'; pill.classList.add('visible'); } log.addEventListener('scroll', () => { if (isNearBottom()) { unseen = 0; updatePill(); } }); function afterAppend() { if (currentNoAnim || isNearBottom()) { log.scrollTop = log.scrollHeight; } else { unseen += 1; updatePill(); } } function row(cls, text) { clearPlaceholder(); const e = document.createElement('div'); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); e.textContent = text; log.appendChild(e); afterAppend(); return e; } function details(cls, summary, body) { clearPlaceholder(); const d = document.createElement('details'); d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); 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); afterAppend(); 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); } } // Build a tool_use row for Write/Edit as a collapsed
// showing the actual change. Returns null for any other tool so // the caller falls back to the flat-row path. // Write: every input.content line is "+". // Edit: old_string lines as "-", new_string lines as "+". // Not a true diff algorithm — claude's Edit blocks are already a // contiguous old/new pair, so a literal -/+ rendering is honest. function renderFileWriteEdit(c) { const name = c.name || ''; const input = c.input || {}; if (name !== 'Write' && name !== 'Edit') return null; const path = input.file_path || '?'; let body; let plus = 0; let minus = 0; if (name === 'Write') { const content = String(input.content || ''); const lines = content.split('\n'); plus = lines.length; body = lines.map(l => '+ ' + l).join('\n'); } else { const oldLines = String(input.old_string || '').split('\n'); const newLines = String(input.new_string || '').split('\n'); minus = oldLines.length; plus = newLines.length; body = oldLines.map(l => '- ' + l).join('\n') + '\n' + newLines.map(l => '+ ' + l).join('\n'); } const summary = '→ ' + name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; return detailsDiff('tool-use', summary, body); } function detailsDiff(cls, summary, body) { clearPlaceholder(); const d = document.createElement('details'); d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); const s = document.createElement('summary'); s.textContent = summary; d.appendChild(s); const pre = document.createElement('pre'); pre.className = 'tool-body diff-body'; // Color each line by its leading +/-. for (const line of body.split('\n')) { const span = document.createElement('span'); if (line.startsWith('+ ')) span.className = 'diff-add'; else if (line.startsWith('- ')) span.className = 'diff-del'; else span.className = 'diff-ctx'; span.textContent = line + '\n'; pre.appendChild(span); } d.appendChild(pre); log.appendChild(d); afterAppend(); return d; } 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') { // Write/Edit get a collapsed +/- diff body; everything // else stays as the flat row produced by fmtToolUse. if (!renderFileWriteEdit(c)) { 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, opts) { const fromHistory = !!(opts && opts.fromHistory); if (ev.kind === 'turn_start') { if (!fromHistory) { setBannerActive(true); setState('thinking'); } 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') { if (!fromHistory) { setBannerActive(false); setState('idle'); } 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. if (!fromHistory) 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)); } // Backfill the last N events before subscribing live. Walk through // turn_start/turn_end to leave the banner-active counter in the right // state: if the history's last turn never closed, we *do* want the // banner shimmer to be on. fromHistory=true on the replay; we apply // the final activity state in one pass at the end. async function backfill() { try { const resp = await fetch('/events/history'); if (!resp.ok) return; const events = await resp.json(); let openTurns = 0; currentNoAnim = true; for (const ev of events) { handle(ev, { fromHistory: true }); if (ev.kind === 'turn_start') openTurns += 1; else if (ev.kind === 'turn_end') openTurns = Math.max(0, openTurns - 1); } currentNoAnim = false; for (let i = 0; i < openTurns; i++) setBannerActive(true); if (openTurns > 0) setState('thinking'); if (events.length) row('note', '─── live (older above) ───'); else setPlaceholder('(connected — waiting for events)'); } catch (err) { // Best effort; SSE will catch up. console.warn('history backfill failed', err); } } backfill().then(() => { const es = new EventSource('/events/stream'); es.onopen = () => { /* no placeholder — backfill already painted */ }; es.onmessage = (e) => { try { handle(JSON.parse(e.data)); } catch (err) { row('note', '[parse err] ' + e.data); } }; es.onerror = () => { if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]'); else row('note', '[disconnected]'); }; }); })(); // Avoid unused-var lint while keeping `escText` available for future use. void escText; })();