// 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). import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; import { marked } from 'marked'; // Expose the previously-script-tag-provided globals so the IIFE below // keeps working unchanged. Pre-split these were attached by // `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js` // (marked) loading before app.js. The bundle now pulls them in via ES // imports; once the IIFE is opened up these aliases can be dropped in // favour of direct named imports. window.HiveTerminal = { create: termCreate, linkify: termLinkify }; window.marked = marked; (() => { // ─── 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; }; // Base URL of the host dashboard (core backend). Set once the first // /api/state lands. Operator-authority actions (answering a question // as the operator) POST here rather than to this agent's own socket — // see docs/boundary.md for why the boundary lives on the core side. let dashboardBase = ''; // ─── 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; } } }); // ─── side panel (singleton drawer for inbox + loose-ends flyouts) ────── // Shared shape with the dashboard's panel. Candidate for extraction // into @hive/shared once both surfaces stabilise. const Panel = (() => { const root = $('side-panel'); const titleEl = $('side-panel-title'); const bodyEl = $('side-panel-body'); /** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check * against this so a live event only re-renders the panel when * the matching view is actually visible. null when closed. */ let owner = null; function open(name, title, content) { owner = name; titleEl.textContent = title; bodyEl.replaceChildren(...(content ? [content] : [])); root.classList.add('open'); root.setAttribute('aria-hidden', 'false'); } function close() { owner = null; root.classList.remove('open'); root.setAttribute('aria-hidden', 'true'); } function refresh(name, title, content) { if (owner !== name) return; titleEl.textContent = title; bodyEl.replaceChildren(...(content ? [content] : [])); } function bind() { $('side-panel-close').addEventListener('click', close); $('side-panel-backdrop').addEventListener('click', close); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && root.classList.contains('open')) close(); }); } return { open, close, refresh, bind, currentOwner: () => owner }; })(); Panel.bind(); // Wire the header pills to open the side panel. Pre-built (vs // re-building per-click) so the freshest snapshot already lives // in `lastInbox` / `lastLooseEnds` when the pill is clicked — even // if it fires during a turn the render is the same. (function bindHeaderPills() { const inboxPill = $('inbox-pill'); if (inboxPill) { inboxPill.addEventListener('click', () => { Panel.open('inbox', 'inbox · ' + lastInbox.length, buildInboxList(lastInbox)); }); } const loosePill = $('loose-ends-pill'); if (loosePill) { loosePill.addEventListener('click', () => { Panel.open('loose-ends', 'loose ends · ' + lastLooseEnds.length, buildLooseEndsList(lastLooseEnds)); }); } })(); // ─── state rendering ──────────────────────────────────────────────────── function setHeader(label, qualifiedLabel, dashboardPort, hiveName, swarmName) { const title = $('title'); // Title is just the glowing identity glyph — DASHB04RD, R3BU1LD, // NEW SESSION live in the overflow `⋯` menu. Glow + uppercase // styling from h2 / .agent-header-title-row. The glyphic title // stays short (no @hive suffix) — the hive qualifier lives on // the second row's `qualified` chip + the browser tab title so // the cinematic header reads cleanly at a glance. title.textContent = `◆ ${label} ◆`; // Document title: prefer the human display names when available // (e.g. "iris // pr1ma") so browser tabs read naturally. Fall back // to the qualified domain label (e.g. "iris@pr1ma.darkest.space") // for multi-hive disambiguation when display names are unset, then // to plain label for single-hive deploys. const hiveLabel = hiveName || null; const tab = qualifiedLabel && qualifiedLabel !== label ? qualifiedLabel : label; document.title = hiveLabel ? `${label} // ${hiveLabel}` : `${tab} // hyperhive`; // When accessed via hive-gateway the page lives at // `/agent//` on the same origin as the dashboard (`/`). // Detect this by the path prefix and use `location.origin` instead // of the direct TCP port — the direct port is unreachable or // wrong scheme behind HTTPS TLS termination. const dashUrl = location.pathname.startsWith('/agent/') ? location.origin + '/' : `${location.protocol}//${location.hostname}:${dashboardPort}/`; dashboardBase = dashUrl; populateOverflowMenu(label, dashUrl); // Swarm label in the chrome: show "swarm/hive" when both set, just // "hive" when only hive is known. Targets the `.agent-hive-label` // element in the header row if present. const hiveEl = document.querySelector('.agent-hive-label'); if (hiveEl && (hiveName || swarmName)) { hiveEl.textContent = swarmName && hiveName ? `${swarmName} / ${hiveName}` : (hiveName || swarmName); hiveEl.hidden = false; } } // Overflow popover: dashboard back-link + rebuild + new-session + // logout. Rare + destructive actions live here behind one extra // click (the operator rebuilds from the host dashboard normally). // See docs/web-ui.md::Per-agent page (Overflow button). let overflowMenuPopulated = false; function populateOverflowMenu(label, dashUrl) { const menu = $('overflow-menu'); if (!menu) return; menu.replaceChildren(); // ↑ dashboard — host dashboard back-link. The dashboard SPA lives at // `dashboard.html` (the `/` root now serves the H0M3 menu hub), so the // link targets that file; `dashUrl` itself stays the API origin used // for the rebuild / answer-question / mark-all-read POSTs below. menu.append(el('a', { class: 'overflow-item overflow-item-dashboard', href: dashUrl + 'dashboard.html', target: '_blank', rel: 'noopener', role: 'menuitem', }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↑'), 'dashboard', )); // ↻ rebuild — POST `{dash}/rebuild/{label}` after a confirm. // Same shape as the old `.btn-rebuild` handler. const rebuildBtn = el('button', { type: 'button', class: 'overflow-item overflow-item-rebuild', role: 'menuitem', id: 'rebuild-btn', }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'), 'rebuild container', ); rebuildBtn.addEventListener('click', () => { if (!window.confirm(`rebuild ${label}? container will hot-reload.`)) return; closeOverflowMenu(); const f = document.createElement('form'); f.method = 'POST'; f.action = `${dashUrl}rebuild/${label}`; document.body.appendChild(f); f.submit(); }); menu.append(rebuildBtn); // ↻ new session — arms a one-shot for the next turn. Mildly // destructive (drops --continue context) so we confirm. const newSessBtn = el('button', { type: 'button', class: 'overflow-item overflow-item-new-session', role: 'menuitem', id: 'new-session-btn', title: 'next turn runs without --continue, starting a fresh claude session', }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'), 'new claude session', ); newSessBtn.addEventListener('click', () => { if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return; newSessBtn.disabled = true; closeOverflowMenu(); postNewSession().finally(() => { newSessBtn.disabled = false; }); }); menu.append(newSessBtn); // 🔓 logout — SIGINTs claude, wipes the credentials dir, flips // the harness LoginState to NeedsLogin. The turn loop's next // iteration parks in wait_for_login; a fresh `claude auth login` // from the dashboard re-arms it. Operator has to re-paste OAuth // creds on the login screen after, but the --continue session // history is preserved (the backend wipe is narrowed to just // .credentials.json + mcp-needs-auth-cache.json) — so the agent // picks up where it left off on the next turn after re-login. const logoutBtn = el('button', { type: 'button', class: 'overflow-item overflow-item-logout', role: 'menuitem', id: 'logout-btn', title: 'rotate OAuth credentials in ~/.claude/ + park in needs-login until a fresh `claude auth login` runs (--continue session history preserved)', }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '🔓'), 'logout', ); logoutBtn.addEventListener('click', () => { if (!window.confirm( `log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` + `credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` + `and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` + `login screen. prior --continue session history is preserved — the agent picks up ` + `where it left off on the next turn after re-login.` )) return; logoutBtn.disabled = true; closeOverflowMenu(); postLogout().finally(() => { logoutBtn.disabled = false; }); }); menu.append(logoutBtn); // ─── model quick-picker ──────────────────────────────────────── // One-click shortcuts for each model in `availableModels` (seeded // from `state.available_models` / `HIVE_AVAILABLE_MODELS` nix option). // The active model is highlighted via the `active` class; see // `renderModelChip` which updates `modelPickerBtns` live. const modelSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' }); const modelLabel = el('div', { class: 'overflow-section-label' }, 'model'); menu.append(modelSep, modelLabel); // Well-known aliases get a parenthetical description; unknown aliases // (operator-declared custom models) show just the name. const MODEL_DESCRIPTIONS = { haiku: 'haiku (fast)', sonnet: 'sonnet (balanced)', opus: 'opus (powerful)', }; modelPickerBtns = []; for (const name of availableModels) { const label = MODEL_DESCRIPTIONS[name] ?? name; const btn = el('button', { type: 'button', class: 'overflow-item overflow-item-model', role: 'menuitem', title: `/model ${name}`, 'data-model': name, }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⊞'), label, ); btn.addEventListener('click', () => { if (currentModel === name) { closeOverflowMenu(); return; } closeOverflowMenu(); postModel(name); }); modelPickerBtns.push(btn); menu.append(btn); } // ─── effort quick-picker ─────────────────────────────────────── // One-click shortcuts for each reasoning-effort level the backend // declares in `availableEfforts` (from `state.available_efforts`). // The active level is highlighted via the `active` class; see // `renderEffortChip` which updates `effortPickerBtns` live. Applies // on the next claude session. Omitted entirely when the backend // hasn't supplied any levels (older harness / cold-load not yet in). effortPickerBtns = []; if (availableEfforts.length) { const effortSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' }); const effortLabel = el('div', { class: 'overflow-section-label' }, 'effort'); menu.append(effortSep, effortLabel); // Display-only labels for well-known levels; any level the backend // sends that isn't here just shows its bare name. const EFFORT_DESCRIPTIONS = { medium: 'medium (default)', high: 'high', xhigh: 'xhigh (max)', }; for (const level of availableEfforts) { const label = EFFORT_DESCRIPTIONS[level] ?? level; const btn = el('button', { type: 'button', class: 'overflow-item overflow-item-effort', role: 'menuitem', title: `set reasoning effort to ${level} (applies next session)`, 'data-effort': level, }, el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '◇'), label, ); btn.addEventListener('click', () => { if (currentEffort === level) { closeOverflowMenu(); return; } closeOverflowMenu(); postEffort(level); }); effortPickerBtns.push(btn); menu.append(btn); } } overflowMenuPopulated = true; } function openOverflowMenu() { const btn = $('overflow-btn'); const menu = $('overflow-menu'); if (!btn || !menu || !overflowMenuPopulated) return; // Position the menu so its top-right corner anchors just below // the trigger button's bottom-right edge. Using fixed positioning // + getBoundingClientRect so we don't get trapped in any of the // header's stacking contexts. const r = btn.getBoundingClientRect(); menu.style.top = `${Math.round(r.bottom + 6)}px`; // Render off-screen first to measure, then anchor the right edge. menu.hidden = false; const mr = menu.getBoundingClientRect(); menu.style.left = `${Math.round(r.right - mr.width)}px`; btn.setAttribute('aria-expanded', 'true'); } function closeOverflowMenu() { const btn = $('overflow-btn'); const menu = $('overflow-menu'); if (!btn || !menu) return; menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); } function toggleOverflowMenu() { const menu = $('overflow-menu'); if (!menu) return; if (menu.hidden) openOverflowMenu(); else closeOverflowMenu(); } // Wire once on boot. The trigger itself + click-outside + Escape // dismissal pattern matches the side-panel flyout (Panel). (function bindOverflowMenu() { const btn = $('overflow-btn'); if (!btn) return; btn.addEventListener('click', (e) => { e.stopPropagation(); toggleOverflowMenu(); }); document.addEventListener('click', (e) => { const menu = $('overflow-menu'); if (!menu || menu.hidden) return; if (menu.contains(e.target) || btn.contains(e.target)) return; closeOverflowMenu(); }); document.addEventListener('keydown', (e) => { const menu = $('overflow-menu'); if (e.key === 'Escape' && menu && !menu.hidden) { closeOverflowMenu(); btn.focus(); } }); })(); function renderOnline(_label, _root) { // Online state is conveyed by the `#alive-badge` chip in the // state row — no longer a separate paragraph in the status // block (keeps the terminal the star, status row stays compact). } 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': '', }); // OAuth code input — masked password + reveal toggle, semantic // autocomplete. See docs/web-ui.md::Per-agent page (#status // overlay) for the shoulder-surfer + WHATWG one-time-code // rationale. const codeInput = el('input', { name: 'code', type: 'password', placeholder: 'paste OAuth code here (hidden)', required: '', autocomplete: 'one-time-code', spellcheck: 'false', }); const reveal = el('button', { type: 'button', class: 'loginform-reveal', title: 'show / hide pasted code', 'aria-label': 'show / hide pasted OAuth code', 'aria-pressed': 'false', }, '👁'); reveal.addEventListener('click', () => { const showing = codeInput.getAttribute('type') === 'text'; codeInput.setAttribute('type', showing ? 'password' : 'text'); reveal.setAttribute('aria-pressed', showing ? 'false' : 'true'); }); code.append( codeInput, reveal, 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; // Label captured from the first /api/state cold load — used by the // bus-driven `status_changed` handler so it can re-enable the // composer without waiting for the next snapshot fetch. let currentLabel = ''; // Tracked so the overflow-menu model picker can highlight the active // model and avoid a redundant `/api/model` POST on same-model click. let currentModel = null; // References to the model picker buttons populated in the overflow // menu so renderModelChip can update their `active` state without // rebuilding the whole menu. let modelPickerBtns = []; // Ordered list of model short-names available on this hive. Seeded from // `state.available_models` (injected by the nix option); falls back to // the built-in default until the first /api/state cold-load completes. let availableModels = ['haiku', 'sonnet', 'opus']; // Effort quick-picker — sibling of the model picker. The operator's // selected reasoning-effort level applies on the next claude session. // `currentEffort` highlights the active button + chip; `effortPickerBtns` // are the menu buttons updated live by `renderEffortChip`. The level list // is the backend's to declare — seeded from `state.available_efforts` on // cold-load; empty until then (no hard-coded levels in the frontend), so // the picker section is omitted until the backend supplies the set. let currentEffort = null; let effortPickerBtns = []; let availableEfforts = []; 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' }, { name: '/model', desc: '/model — switch claude model for future turns' }, { name: '/new-session', desc: 'next turn runs without --continue (fresh claude session)' }, { name: '/logout', desc: 'rotate OAuth credentials + park in needs-login (--continue session preserved)' }, ]; async function postModel(name) { try { const resp = await fetch('api/model', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ model: name }), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok && termAPI) { const text = await resp.text().catch(() => ''); termAPI.row('turn-end-fail', '✗ /model failed: ' + resp.status + (text ? ' — ' + text : '')); } // No refreshState — the harness emits `model_changed` on the // SSE bus and the chip handler picks it up live. } catch (err) { if (termAPI) termAPI.row('turn-end-fail', '✗ /model failed: ' + err); } } async function postEffort(level) { try { const resp = await fetch('api/effort', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ effort: level }), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok && termAPI) { const text = await resp.text().catch(() => ''); termAPI.row('turn-end-fail', '✗ effort change failed: ' + resp.status + (text ? ' — ' + text : '')); } // No refreshState — the harness emits `effort_changed` on the // SSE bus and the chip handler picks it up live. } catch (err) { if (termAPI) termAPI.row('turn-end-fail', '✗ effort change failed: ' + err); } } 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); } } // First arg is the URL path (relative to document base — see // docs/web-ui.md::Per-agent relative paths); second is the // slash-command label rendered in the local note. const postCancelTurn = () => postSimple('api/cancel', '/cancel'); const postCompact = () => postSimple('api/compact', '/compact'); const postNewSession = () => postSimple('api/new-session', '/new-session'); const postLogout = () => postSimple('api/logout', '/logout'); 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(); // Detached `ask → operator` rows no longer have a live // mount-point in the DOM — drop their slots so subsequent // loose-ends reconciliation doesn't walk dead references. pendingAskBinds.length = 0; termAPI.row('note', '· terminal cleared (local view only — server history kept)'); return true; case '/cancel': postCancelTurn(); return true; case '/compact': postCompact(); return true; case '/new-session': if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) { postNewSession(); } return true; case '/logout': if (window.confirm( `log out? this SIGINTs any running claude turn, deletes only the OAuth ` + `credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` + `and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` + `login screen. prior --continue session history is preserved — the agent picks up ` + `where it left off on the next turn after re-login.` )) { postLogout(); } return true; case '/model': { const parts = trimmed.split(/\s+/); if (parts.length < 2 || !parts[1]) { termAPI.row('turn-end-fail', '✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)'); } else { postModel(parts[1]); } 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.replaceChildren(); 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' }, compacting: { glyph: '📦', text: 'compacting' }, }; 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'; } // Wall-clock HH:MM:SS (UTC, matching the inbox timestamps on this page) // from a unix-seconds value. Used to label turn-start / turn-end rows // when the event carries a `ts` (see the turn renderers below). function fmtClock(sec) { return new Date(sec * 1000).toISOString().slice(11, 19); } // Unix-seconds stamp of the most recent open turn-start, so the // matching turn-end can show a duration. Turns are sequential, so a // single slot is enough (history replays chronologically too). let pendingTurnStartTs = null; const STATE_TOOLTIPS = { loading: 'harness not yet contacted', offline: 'harness unreachable or claude not logged in', idle: 'turn loop running, no claude invocation in flight', thinking: 'claude is executing the current turn', compacting: 'operator-triggered /compact running on the persistent session', }; 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; badge.title = (STATE_TOOLTIPS[stateName] || '') + '\nin this state for ' + age; const cancelBtn = $('cancel-btn'); if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; } function setState(next) { setStateAbs(next, Math.floor(Date.now() / 1000)); } /// Set state with an authoritative since-unix from the server. Lets /// `last turn` track the actual server-side duration rather than /// whatever the client perceived between SSE events. function setStateAbs(next, sinceUnix) { if (next === stateName && sinceUnix * 1000 === stateSince) return; if (stateName === 'thinking' && next !== 'thinking') { const elapsedMs = Date.now() - stateSince; renderLastTurn(elapsedMs); } const flashing = next !== stateName; stateName = next; stateSince = sinceUnix * 1000; const badge = $('state-badge'); if (badge && flashing) { badge.classList.remove('state-just-changed'); void badge.offsetWidth; badge.classList.add('state-just-changed'); } renderStateBadge(); } // Loose-ends section: same data the get_loose_ends MCP tool // returns. Best-effort fetch on cold load + after every turn_end // (a turn likely answered or asked something). Silent failure // keeps the pill count at zero rather than surfacing a stale chrome. async function refreshLooseEnds() { try { const resp = await fetch('api/loose-ends'); if (!resp.ok) { renderLooseEnds([]); return; } const data = await resp.json(); renderLooseEnds(data.loose_ends || []); } catch (err) { console.warn('loose-ends fetch failed', err); renderLooseEnds([]); } } /** Latest snapshot kept in module state so the pill click handler * has fresh data to render into the panel without re-fetching. */ let lastLooseEnds = []; let lastInbox = []; /** ask → operator inline-answer slot registry. See * docs/web-ui.md::Per-agent page (Ask → operator inline-answer * binding) for the matching algorithm + resolution semantics. */ const pendingAskBinds = []; function buildLooseEndsList(threads) { // Returns the
the side panel renders. The structural shape // mirrors the legacy
-collapsible block — same CSS rules // apply via `.side-panel-body .agent-inbox`. const wrap = el('div', { class: 'agent-inbox' }); if (!threads.length) { wrap.append(el('p', { class: 'side-panel-empty' }, 'no loose ends — every question, approval and reminder is resolved.')); return wrap; } const list = el('ul'); const fmtAge = (s) => { if (s < 60) return s + 's'; if (s < 3600) return Math.floor(s / 60) + 'm'; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; }; for (const t of threads) { const li = el('li'); if (t.kind === 'approval') { li.append( el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ', el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ', el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), ); if (t.description) { li.append(el('div', { class: 'inbox-body' }, t.description)); } } else if (t.kind === 'question') { const target = t.target || 'operator'; li.append( el('span', { class: 'inbox-from' }, '? #' + t.id), ' ', el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ', el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), el('div', { class: 'inbox-body' }, t.question || ''), buildAnswerForm(t.id), ); } else if (t.kind === 'reminder') { // due_at is an absolute unix-seconds value; show time-until-fire // (negative when overdue, fmtAge handles 0/positive case here). const now = Math.floor(Date.now() / 1000); const dueIn = (t.due_at || 0) - now; const dueLabel = dueIn >= 0 ? 'in ' + fmtAge(dueIn) : fmtAge(-dueIn) + ' overdue'; li.append( el('span', { class: 'inbox-from' }, '⏰ reminder #' + t.id), ' ', el('span', { class: 'inbox-sep' }, t.owner + ' · due ' + dueLabel), ' ', el('span', { class: 'inbox-ts' }, 'scheduled ' + fmtAge(t.age_seconds || 0) + ' ago'), el('div', { class: 'inbox-body' }, t.message || ''), ); } else { li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t))); } list.append(li); } wrap.append(list); return wrap; } /** Pill-count + open-panel-refresh wiring for loose-ends. The legacy * in-page `
` block is gone — operator clicks the header * pill to surface the list in the side panel. */ function renderLooseEnds(threads) { lastLooseEnds = threads; const pill = $('loose-ends-pill'); const count = $('loose-ends-count'); if (count) count.textContent = threads.length; if (pill) pill.hidden = threads.length === 0; Panel.refresh('loose-ends', 'loose ends · ' + threads.length, buildLooseEndsList(threads)); // Wire inline answer forms into any `ask → operator` rows // waiting on a broker-assigned question id. reconcileAskBinds(); } /** Walk `pendingAskBinds` against the latest `lastLooseEnds` * snapshot, pair unbound slots with the first unclaimed pending * operator-bound question whose text matches, and flip already- * bound slots to `[resolved]` when their question disappears. * See docs/web-ui.md::Per-agent page (Ask → operator inline-answer * binding) for the full design + edge cases. */ function reconcileAskBinds() { if (!pendingAskBinds.length) return; const pending = lastLooseEnds.filter( (t) => t.kind === 'question' && (!t.target || t.target === 'operator'), ); // Defensive prune: drop slots whose DOM node has been removed. for (let i = pendingAskBinds.length - 1; i >= 0; i--) { if (!pendingAskBinds[i].isConnected) pendingAskBinds.splice(i, 1); } const claimed = new Set(); for (const slot of pendingAskBinds) { if (slot._boundId) claimed.add(slot._boundId); } for (const slot of pendingAskBinds) { if (slot._boundId) { const stillPending = pending.some((q) => q.id === slot._boundId); if (!stillPending && !slot._resolvedShown) { slot.replaceChildren(); slot.appendChild(el('span', { class: 'ask-resolved-tag' }, '[resolved]')); slot._resolvedShown = true; } continue; } const q = pending.find((p) => p.question === slot._askQuestion && !claimed.has(p.id)); if (!q) continue; claimed.add(q.id); slot._boundId = q.id; slot.replaceChildren(); slot.appendChild(buildAnswerForm(q.id)); } } // Inline "answer as operator" form for a question loose-end. POSTs to // the host dashboard (core backend), never this agent's socket — the // core is the only place that can stamp `operator` as the answerer. // Used by both the loose-ends side panel (`buildLooseEndsList`) and // the in-stream ask-row binder (`reconcileAskBinds`). function buildAnswerForm(id) { const wrap = el('div', { class: 'answer-form' }); const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' }); const btn = el('button', { type: 'button' }, 'send answer'); const status = el('span', { class: 'answer-status' }); btn.addEventListener('click', async () => { const answer = ta.value.trim(); if (!answer) { status.textContent = 'answer required'; return; } if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; } btn.disabled = true; status.textContent = 'sending…'; try { const resp = await fetch(dashboardBase + 'answer-question/' + id, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'answer=' + encodeURIComponent(answer), }); if (resp.ok) { status.textContent = 'answered ✓'; refreshLooseEnds(); } else { status.textContent = 'failed: ' + (await resp.text()); btn.disabled = false; } } catch (err) { status.textContent = 'failed: ' + err; btn.disabled = false; } }); wrap.append(ta, btn, status); return wrap; } /** "mark all read" affordance for the agent's inbox flyout — * see docs/web-ui.md::Per-agent page (Loose-ends flyout) for the * cross-origin POST + count rendering. Returns a DOM row * containing a button + an inline status pill; re-runs * `onCleared` on success so the caller can refresh its own state. */ function buildInboxMarkAllRow(label, onCleared) { const status = el('span', { class: 'inbox-mark-status' }); const btn = el('button', { type: 'button', class: 'inbox-mark-all-btn', title: 'mark every queued message for this agent as read — ' + 'drains the host broker\'s pending + delivered-unacked rows. ' + 'history shown here is the most-recent-N regardless of state, ' + 'so the list itself stays visible.', }, '✓ mark all read'); btn.addEventListener('click', async () => { if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; } if (!label) { status.textContent = 'agent label unknown'; return; } if (!window.confirm( `mark every queued message for ${label} as read? ` + `the message history shown stays; only the unread queue is drained.` )) return; btn.disabled = true; status.textContent = 'clearing…'; try { const resp = await fetch( dashboardBase + 'api/agent/' + encodeURIComponent(label) + '/mark-all-read', { method: 'POST' }); if (resp.ok) { const data = await resp.json().catch(() => ({})); const n = Number(data.marked) || 0; status.textContent = '✓ marked ' + n + ' as read'; if (typeof onCleared === 'function') onCleared(); } else { status.textContent = 'failed: http ' + resp.status; btn.disabled = false; } } catch (err) { status.textContent = 'failed: ' + err; btn.disabled = false; } }); return el('div', { class: 'inbox-mark-all-row' }, btn, status); } function buildInboxList(rows) { const wrap = el('div', { class: 'agent-inbox' }); if (!rows.length) { wrap.append(el('p', { class: 'side-panel-empty' }, 'inbox empty.')); return wrap; } // "mark all read" header row drains the host broker's pending + // delivered-unacked rows for this agent. Visible rows here are // the most-recent-N regardless of ack state, so the list itself // doesn't visually empty on click — the status pill confirms // the drain count, and the next turn_start's "unread" badge // will read zero. wrap.append(buildInboxMarkAllRow(currentLabel, () => { // Refresh state so any UI surface that DOES depend on // delivery state (eg future per-status filters) picks up // the new shape. refreshState(); })); const list = el('ul'); const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); for (const m of rows) { const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {}); if (m.in_reply_to != null) { li.append(el('span', { class: 'inbox-reply-tag' }, '↳ reply · ')); } li.append( el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', el('span', { class: 'inbox-from' }, m.from), ' ', el('span', { class: 'inbox-sep' }, '→'), ' ', el('span', { class: 'inbox-body' }, m.body), ); list.append(li); } wrap.append(list); return wrap; } /** Pill-count + open-panel-refresh wiring for inbox. */ function renderInbox(rows) { lastInbox = rows; const pill = $('inbox-pill'); const count = $('inbox-count'); if (count) count.textContent = rows.length; if (pill) pill.hidden = rows.length === 0; Panel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows)); } // Harness reachability badge: derived from the same `s.status` the // status block reads. Each status maps to a glyph + label + colour // class. Lives in the state row so the operator sees boot/login/ // online without losing terminal real-estate to a paragraph. const ALIVE_LABELS = { loading: { glyph: '…', text: 'connecting', cls: 'status-loading' }, online: { glyph: '●', text: 'alive', cls: 'status-online' }, rate_limited: { glyph: '⊘', text: 'rate limited', cls: 'status-rate-limited' }, needs_login_idle: { glyph: '◌', text: 'needs login', cls: 'status-needs-login' }, needs_login_in_progress: { glyph: '◌', text: 'logging in', cls: 'status-needs-login' }, offline: { glyph: '○', text: 'offline', cls: 'status-offline' }, }; function renderAliveBadge(status) { const el_ = $('alive-badge'); if (!el_) return; const def = ALIVE_LABELS[status] || ALIVE_LABELS.loading; el_.textContent = def.glyph + ' ' + def.text; el_.className = 'status-badge ' + def.cls; } function renderModelChip(model) { currentModel = model || null; const el_ = $('model-chip'); if (!el_) return; if (!model) { el_.hidden = true; } else { el_.hidden = false; el_.textContent = 'model · ' + model; el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`; } // Sync model picker buttons: highlight the active alias. A short alias // (haiku/sonnet/opus) matches if `model` ends with that string; full // API names like `claude-3-5-haiku-20241022` match on the suffix too. for (const btn of modelPickerBtns) { const alias = btn.dataset.model || ''; const isActive = !!model && (model === alias || model.endsWith(alias)); btn.classList.toggle('active', isActive); btn.setAttribute('aria-pressed', String(isActive)); } } function renderEffortChip(effort) { currentEffort = effort || null; const el_ = $('effort-chip'); if (el_) { if (!effort) { el_.hidden = true; } else { el_.hidden = false; el_.textContent = 'effort · ' + effort; el_.title = `reasoning effort: ${effort}\nset via the operator's effort picker; applies on the next claude session`; } } // Sync effort picker buttons: highlight the active level (exact match). for (const btn of effortPickerBtns) { const isActive = !!effort && btn.dataset.effort === effort; btn.classList.toggle('active', isActive); btn.setAttribute('aria-pressed', String(isActive)); } } // Token badges — two separate chips: // ctx · N last inference's prompt size = current context window // utilisation (what to watch for compaction decisions) // cost · M cumulative billed tokens across the whole last turn // (sum across every inference; tool-heavy turns rebill // the cached prompt per call and blow past the model's // context window — this is a cost signal, not a size // signal) // Both fed by the same `token_usage_changed` SSE event (`{ ctx, cost }`). const fmtTokens = (n) => { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; if (n >= 1_000) return Math.round(n / 1000) + 'k'; return String(n); }; function renderOneUsage(elId, label, u, blurb) { const el_ = $(elId); if (!el_) return; if (!u) { el_.hidden = true; return; } const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens; el_.hidden = false; el_.title = [ blurb, 'input: ' + u.input_tokens, 'cache_read: ' + u.cache_read_input_tokens, 'cache_write: ' + u.cache_creation_input_tokens, 'output: ' + u.output_tokens, ].join('\n'); el_.textContent = label + ' · ' + fmtTokens(total); } function renderTokenUsage(ev) { // `ev` is `{ ctx, cost }` either off /api/state cold-load (each may // be null) or off a `token_usage_changed` SSE event (both present // post-turn). renderOneUsage('ctx-badge', 'ctx', ev && ev.ctx, 'last-inference prompt size — the actual context window in use right now'); renderOneUsage('cost-badge', 'cost', ev && ev.cost, 'cumulative tokens billed across the last turn (sum across every inference)'); } function renderLastTurn(ms) { const el_ = $('last-turn'); if (!el_) return; let s = ''; if (ms < 1000) s = ms + 'ms'; else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's'; else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's'; el_.textContent = '· last turn ' + s; el_.title = `wall-clock duration of the last completed claude turn (${ms} ms)`; el_.hidden = false; } 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(); // Seed available_models before populateOverflowMenu (called from // setHeader on the first load) so the picker uses the operator-declared // list rather than the JS fallback default. if (Array.isArray(s.available_models) && s.available_models.length > 0) { availableModels = s.available_models; } if (Array.isArray(s.available_efforts) && s.available_efforts.length > 0) { availableEfforts = s.available_efforts; } if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port, s.hive_name, s.swarm_name); headerSet = true; } currentLabel = s.label; // Render server-supplied navigation links — see // docs/web-ui.md::Per-agent page (meta-nav) for the NavLink // kind → URL resolution table + XSS-safe DOM-build rationale. const metaLinks = $('meta-links'); if (metaLinks && Array.isArray(s.links)) { metaLinks.replaceChildren(); // Prefer forge_public_url from state (set when the gateway serves // forge at its sub-domain); fall back to :3000 for local-dev. const forgeBase = s.forge_public_url || `http://${window.location.hostname}:3000`; s.links.forEach((lnk, i) => { const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') : lnk.kind === 'external' ? (lnk.url || '') : /* container */ (lnk.url || ''); const a = el('a', { class: 'agent-nav-link', href, target: '_blank', rel: 'noopener', title: lnk.label || '', }); // Layout gap comes from `.agent-nav { gap }`. The trailing // `→` is the "leaves this page" affordance. a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →'); metaLinks.append(a); }); } renderTermInput(s.label, s.status === 'online'); renderInbox(s.inbox || []); // Authoritative state comes from the harness via /api/state. // Login-not-yet → 'offline'; otherwise use the server-reported // turn_state (idle / thinking / compacting). stateSince in // unix-seconds is converted to a client-side Date.now() anchor. if (s.status !== 'online') { setState('offline'); } else if (s.turn_state) { setStateAbs(s.turn_state, s.turn_state_since); } renderAliveBadge(s.status); renderModelChip(s.model); renderEffortChip(s.effort); renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage }); // Open-threads aren't part of /api/state (kept on the broker // db, fetched via the per-agent socket). Cold-load fetches // it here; turn_end refreshes it via the renderer below. refreshLooseEnds(); // 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.replaceChildren(); 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 ────────────────────────────────────────────────── // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS // (window.HiveTerminal). What stays here is the per-kind rendering: // turn framing, claude stream-json interpretation, tool_use prettyprint, // tool_result collapse, +/- diff bodies for Write/Edit. (function() { const log = $('live'); if (!log || !window.HiveTerminal) return; log.replaceChildren(); function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } // Render a message body as markdown into a new
. // Wraps `marked.parse` so the per-row body element carries the // `.md` class (CSS in TERMINAL_CSS scopes paragraph/code/list // styles to it). Falls back to a plain text node if marked isn't // loaded (network glitch, asset 404) so the body still renders. function mdNode(text) { const div = document.createElement('div'); div.className = 'md'; const src = String(text || ''); if (window.marked && typeof window.marked.parse === 'function') { try { marked.setOptions({ breaks: true, gfm: true }); div.innerHTML = marked.parse(src); // marked autolinks URLs but leaves them same-tab — open them // externally so a click never unloads the terminal. div.querySelectorAll('a[href]').forEach((a) => { a.target = '_blank'; a.rel = 'noopener noreferrer'; }); } catch (err) { console.warn('marked failed', err); div.textContent = src; } } else { div.textContent = src; } return div; } // Build a default-open details row whose body is markdown-rendered. // Used by send / ask / answer tool_use renderers and by `recv` // tool_result so message-bearing rows show their content inline // without an extra click. function detailsOpenMd(api, cls, summary, body) { const d = api.details(cls, summary, ''); d.open = true; const pre = d.querySelector('pre.tool-body'); if (pre) { pre.replaceWith(mdNode(body)); } else { d.appendChild(mdNode(body)); } return d; } // Generic args-pretty-printer for unknown / extra-MCP tools. The // built-in switch handles the common claude/hyperhive tools; this // is the fallback so an `mcp__matrix__send_message` or similar // doesn't dump raw JSON. Heuristics: single string-valued field → // `Name field: "value"`; single dict-valued field → `Name field // {…}`; otherwise compact JSON. Always trimmed to fit a row. function fmtArgsGeneric(name, input) { const keys = Object.keys(input || {}); if (keys.length === 0) return name + '()'; if (keys.length === 1) { const k = keys[0]; const v = input[k]; if (typeof v === 'string') { const oneline = v.replace(/\s+/g, ' ').trim(); return name + ' ' + k + ': ' + JSON.stringify(trim(oneline, 100)); } if (typeof v === 'number' || typeof v === 'boolean') { return name + ' ' + k + ': ' + JSON.stringify(v); } } // Multi-field: render `k: v` pairs with strings/numbers inlined and // anything else summarised by type so the row stays readable. const pretty = keys.slice(0, 4).map((k) => { const v = input[k]; if (v == null) return k + ': null'; if (typeof v === 'string') { const oneline = v.replace(/\s+/g, ' ').trim(); return k + ': ' + JSON.stringify(trim(oneline, 40)); } if (typeof v === 'number' || typeof v === 'boolean') return k + ': ' + v; if (Array.isArray(v)) return k + `: [${v.length}]`; return k + ': {…}'; }); const tail = keys.length > 4 ? ' …+' + (keys.length - 4) : ''; return name + ' ' + pretty.join(' · ') + tail; } // 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.startsWith('mcp__bash__') ? name.slice('mcp__bash__'.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.run_in_background ? ' [bg]' : '') + ' $ ' + (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': { // Surface the long-poll wait + batch size — a bare `recv()` row // hides whether the agent is parking a turn (wait_seconds) or // draining a burst (max). const parts = []; if (input.wait_seconds != null) parts.push('wait ' + input.wait_seconds + 's'); if (input.max != null) parts.push('max ' + input.max); return short + (parts.length ? ' ' + parts.join(' · ') : '()'); } case 'mcp__hyperhive__kill': return short + ' ' + (input.name || ''); case 'mcp__hyperhive__request_apply_commit': return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); case 'mcp__bash__run': { // Rich renderer handles the full body; this summary covers any // fallback path and the details summary line. const firstLine = String(input.cmd || '').split('\n')[0]; return short + ' $ ' + trim(firstLine.trim(), 72); } case 'mcp__bash__status': return short + ' id:' + (input.id || '?') + (input.wait_seconds != null ? ' · wait ' + input.wait_seconds + 's' : ''); default: return fmtArgsGeneric(short, input); } } // Build a "rich" tool_use row for tools whose input has a body we // want the operator to see in full. 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 "+". // mcp__hyperhive__send: collapsed
, full body text inside. function renderRichToolUse(c, api) { const name = c.name || ''; const input = c.input || {}; if (name === 'Write' || name === 'Edit') { 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'); } // Summaries on expandable rows omit the row's directional glyph // (`→`) — the disclosure marker (`▸/▾`) from CSS sits in the // prefix column for every row kind, and the row's cyan colour // already signals "outbound tool". const summary = name + ' ' + path + ' · ' + (minus ? '-' + minus + ' ' : '') + '+' + plus; return api.detailsDiff('tool-use', summary, body); } // Message-bearing tools render default-open with a markdown body so // the operator sees the content without an extra click. send / ask // address a target; answer attaches to an existing question id. if (name === 'mcp__hyperhive__send') { const to = input.to || '?'; const body = String(input.body || ''); const lines = body.split('\n').length; return detailsOpenMd(api, 'tool-use', 'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''), body); } if (name === 'mcp__hyperhive__ask') { const to = input.to || 'operator'; const q = String(input.question || ''); const lines = q.split('\n').length; const d = detailsOpenMd(api, 'tool-use', 'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''), q); // When the ask targets the operator, mount an inline answer // slot in the live terminal — see docs/web-ui.md::Per-agent // page (Ask → operator inline-answer binding) for the slot // registry + reconciler + [resolved] semantics. if (to === 'operator') { const slot = el('div', { class: 'ask-answer-inline-slot' }); // Stash the question text on the slot so the reconciler // can match against `lastLooseEnds` entries without // walking the row's text content. Options/multi can be // surfaced later when the harness emits them on the // tool_result; phase A is pure text-match. slot._askQuestion = q; d.appendChild(slot); pendingAskBinds.push(slot); // Mid-turn refresh — the standard `turn_end` refresh // won't fire until the agent's turn finishes; we want // the form to show up as soon as the ask lands. Cheap // best-effort (silent on failure). if (!api.fromHistory) refreshLooseEnds(); else reconcileAskBinds(); } return d; } if (name === 'mcp__hyperhive__answer') { const id = input.id != null ? String(input.id) : '?'; const a = String(input.answer || ''); const lines = a.split('\n').length; return detailsOpenMd(api, 'tool-use', 'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''), a); } // Bash task runner — show full command in an expandable pre block so // multi-line scripts are readable. Summary uses the first line so the // row is identifiable without expanding. if (name === 'mcp__bash__run') { const cmd = String(input.cmd || ''); const firstLine = cmd.split('\n')[0]; const summary = 'run* $ ' + trim(firstLine.trim(), 72); return api.details('tool-use', summary, '$ ' + cmd); } return null; } // Track tool_use_id → tool name so we can decide on rendering when the // matching tool_result lands later. Lets us default-open the body for // message-bearing tools (`recv`) while keeping shell/file tool output // collapsed unless the operator clicks. Cleared on /clear; otherwise // grows with the session — entries are tiny strings. const toolNameById = new Map(); function renderToolResult(c, api) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') : (c.content || ''); const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null; const isMessageBearing = sourceName === 'mcp__hyperhive__recv'; // When an ask's tool_result lands the broker has just // persisted the question with its assigned id. Refresh // loose-ends so reconcileAskBinds finds the new entry and // mounts the inline answer form under the rendered ask row. // Skipped during history replay (the question's likely // long-resolved; turn_end refresh on cold-load covers // reconciliation). if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) { refreshLooseEnds(); } const trimmed = txt.replace(/\s+/g, ' ').trim(); const summaryBody = (() => { 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}`; })(); // Flat row: keep the `←` glyph in the prefix column. Details rows // drop it — the `▸/▾` disclosure marker sits in that column via CSS. if (isMessageBearing && txt.trim()) { return detailsOpenMd(api, 'tool-result-block', 'recv ← ' + summaryBody, txt); } if (!txt.trim() || txt.length <= 120) { api.row('tool-result', '← ' + summaryBody); } else { api.details('tool-result-block', summaryBody, txt); } } // Pretty-render claude's background-task subagent events // (`task_started`, `task_notification`). They share the same // task_id so the operator can correlate start ↔ result; render // each as a peer of tool_use / tool_result with a `⌁` glyph to // mark "this happened in a subagent" rather than the main // session. function renderTaskEvent(v, api) { const id = (v.task_id || '').slice(0, 8); const kind = v.task_type ? ` [${v.task_type}]` : ''; const desc = v.description || v.summary || '(no description)'; if (v.subtype === 'task_started') { api.row('tool-use', `⌁ task ${id} started · ${desc}${kind}`); return true; } if (v.subtype === 'task_notification') { const status = v.status || 'unknown'; const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌'; const cls = status === 'completed' ? 'turn-end-ok' : status === 'failed' ? 'turn-end-fail' : 'tool-result'; const out = v.output_file ? ` · → ${v.output_file}` : ''; api.row(cls, `⌁ task ${id} ${glyph} ${status} · ${desc}${out}`); return true; } return false; } function renderStream(v, api) { // Drop claude's result line and rate-limit — noise. TurnEnd // communicates pass/fail; rate-limit events are noisy status chatter. if (v.type === 'rate_limit_event') return; if (v.type === 'result') return; // `system` events: `init` is silent startup noise; `api_retry` // and `api_error` get human-readable notes; unknown subtypes get a // muted line rather than a raw-JSON dump in the loud `sys` colour. if (v.type === 'system') { if (v.subtype === 'init') return; if (v.subtype === 'api_retry') { const parts = ['⚠ api retry']; if (v.attempt != null && v.max_retries != null) parts.push(v.attempt + '/' + v.max_retries); if (v.error) parts.push(String(v.error)); else if (v.error_status) parts.push('HTTP ' + v.error_status); if (v.retry_delay_ms != null) parts.push(Math.round(v.retry_delay_ms) + 'ms'); api.row('note', parts.join(' · ')); return; } if (v.subtype === 'api_error') { const msg = v.error || v.message || (v.error_status ? 'HTTP ' + v.error_status : 'unknown'); api.row('note stderr', '✗ api error · ' + msg); return; } // Other system subtypes (context_window_exceeded, etc.) — render a // muted note with the subtype label; reserve the loud orange `sys` // catch-all for truly unrecognised top-level types. api.row('note', '⚙ ' + (v.subtype || 'system')); return; } // Background-task subagent events (claude's `Task` tool spawns // a separate session whose progress lands here as `task_*` // subtypes). Match by subtype so we don't have to track which // top-level `type` claude wraps them under across versions. if (v.subtype === 'task_started' || v.subtype === 'task_notification') { if (renderTaskEvent(v, api)) 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()) { // Assistant prose renders with markdown — claude often // emits bullets / fenced code / inline code; raw text // loses the structure. const row = api.row('text', ''); row.appendChild(mdNode(c.text)); } else if (c.type === 'thinking') { const txt = (c.thinking || c.text || '').trim(); api.row('thinking', txt ? '· ' + txt : '· thinking …'); } else if (c.type === 'tool_use') { if (c.id && c.name) toolNameById.set(c.id, c.name); if (!renderRichToolUse(c, api)) { api.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, api); } return; } // Catch-all for unrecognised stream-json shapes. Loud (orange) so // silently-dropped event types surface in the scrollback for // follow-up classification. api.row('sys', '! ' + trim(JSON.stringify(v), 200)); } // Count open turns across the backfill replay so the live banner + // state badge reflect whatever the history last left running. With // shared HiveTerminal this is computed inside each renderer instead // of in a second walk over the events list. let openTurnsFromHistory = 0; const term = HiveTerminal.create({ logEl: log, // Anchor the `↓ N new` pill in `.agent-main` rather than the // default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent // page (Terminal-wrap) for the backdrop-filter stacking-context // gotcha. pillAnchor: $('agent-main'), // Path-relative URLs so the page mounted under a nginx prefix // (e.g. /agent//) still hits the right SSE upstream. historyUrl: 'events/history', streamUrl: 'events/stream', renderers: { turn_start(ev, api) { if (api.fromHistory) openTurnsFromHistory += 1; else { setBannerActive(true); setState('thinking'); } const block = api.row('turn-start', '◆ TURN ← ' + ev.from); // Turn start time. Guarded on a numeric `ts` (unix seconds) // so the row degrades to its old text-only form until the // backend surfaces per-event timestamps. if (typeof ev.ts === 'number') { pendingTurnStartTs = ev.ts; const t = document.createElement('span'); t.className = 'turn-time'; t.textContent = '· ' + fmtClock(ev.ts); block.appendChild(t); } 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); }, turn_end(ev, api) { if (api.fromHistory) { openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); } else { setBannerActive(false); setState('idle'); // Likely answered/asked/scheduled something — refresh. refreshLooseEnds(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; const row = api.row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); // Turn end time + duration since the paired turn-start. // Same `ts` guard as turn_start; duration only when we saw the // matching start's stamp. if (typeof ev.ts === 'number') { const t = document.createElement('span'); t.className = 'turn-time'; let label = '· ' + fmtClock(ev.ts); if (pendingTurnStartTs != null && ev.ts >= pendingTurnStartTs) { label += ' · ' + fmtAge((ev.ts - pendingTurnStartTs) * 1000); } t.textContent = label; row.appendChild(t); } pendingTurnStartTs = null; }, note(ev, api) { const t = String(ev.text || ''); // stderr lines coming off the claude pump get an orange `!` // glyph so they're not visually fused with ambient harness // chatter. Operator-initiated notes (/cancel, /compact, // /model, new-session) get a mauve italic affordance so the // scrollback distinguishes "the operator did this" from // "the harness did this on its own." if (t.startsWith('stderr:')) { api.row('note stderr', '! ' + t); } else if (t.startsWith('operator:')) { api.row('note op', '· ' + t); } else { api.row('note', '· ' + t); } }, stream(ev, api) { const v = Object.assign({}, ev); delete v.kind; renderStream(v, api); }, // Bus-driven state/badges. `status_changed` may also need a // /api/state refresh to render the login `#status` block // (which carries the OAuth URL + form), so we kick the // existing refresh path on that transition. Online → only // the badge updates; no /api/state fetch needed. status_changed(ev, api) { if (api.fromHistory) return; renderAliveBadge(ev.status); renderTermInput(currentLabel, ev.status === 'online'); // Login-flow transitions need the #status block rebuilt // (it carries the OAuth URL + form). The existing // refreshState path also re-arms the in-progress poll for // session output streaming. Online → only the badge moves; // no /api/state fetch is necessary. if (ev.status !== 'online' && ev.status !== lastStatus) { refreshState(); } else if (ev.status === 'online' && lastStatus !== 'online') { // Status block stays as-is or shows the previous // login UI; clear it so the operator sees a clean // online state without a separate refetch. const root = $('status'); if (root) root.replaceChildren(); lastStatus = 'online'; } }, model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); }, effort_changed(ev, api) { if (!api.fromHistory) renderEffortChip(ev.effort); }, token_usage_changed(ev, api) { if (!api.fromHistory) renderTokenUsage({ ctx: ev.ctx, cost: ev.cost }); }, turn_state_changed(ev, api) { if (!api.fromHistory) setStateAbs(ev.state, ev.since_unix); }, }, onBackfillDone() { // If the last replayed turn never closed, the banner shimmer + // thinking badge should be on. Apply in one pass after replay. for (let i = 0; i < openTurnsFromHistory; i++) setBannerActive(true); if (openTurnsFromHistory > 0) setState('thinking'); }, }); // Expose the panel API for slash commands (`/help`, `/clear`). termAPI = { row: (cls, text) => term.row(cls, text), clear: () => { log.replaceChildren(); }, }; })(); // Avoid unused-var lint while keeping `escText` available for future use. void escText; })();