hyperhive/frontend/packages/agent/src/app.js
iris ecf9ff4d80 web-ui: remove dead ask->operator inline-answer binding from the per-agent terminal
hyperhive#2922: the inline-answer slot (pendingAskBinds/reconcileAskBinds/
buildAnswerForm) depended on a since-removed /api/loose-ends endpoint
and had been silently non-functional the whole time — nothing ever
reassigned lastLooseEnds, so the reconciler always no-op'd.

Per mara's call on the issue (ask/answer is deprecated per #2850, only
the main dashboard UI needs to work, remove the broken per-agent inline
view): ripped out the dead JS (binding + form + CSS) rather than
restoring the endpoint. An ask tool call now renders like any other
tool call, no inline answer affordance; the operator answers via the
dashboard's own question surfacing.

Also fixed adjacent doc staleness this same removal made newly
contradictory (docs/web-ui/agent.md, docs/terminal-rendering.md):
the doc already described the loose-ends/bash-tasks flyouts and the
ask-binding as recently dead, but the endpoint reference table and a
header-pills bullet list still described them as live.
2026-08-11 20:21:52 +02:00

1656 lines
72 KiB
JavaScript

// 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 { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js';
import { el } from '@hive/shared/dom.js';
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// 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) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
// 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) ──────────────────────────
// Themed confirm/prompt/toast dialogs + the asyncBtn spinner instead of
// native confirm()/alert() dialogs that broke out of the page theme.
bindAsyncForms(() => refreshState());
// ─── header icon fallback ───────────────────────────────────────────────
// `/icon` 404s when this agent has no `hyperhive.icon` override (see
// hive-agent::web_ui::screen::serve_icon — no bundled server-side
// default any more). Mirrors the dashboard's `swarm.js` `/favicon.svg`
// fallback: fire-and-forget load, swap on failure, guarded so a 404 on
// the fallback itself can't loop.
(function bindHeaderIconFallback() {
const iconImg = document.querySelector('.agent-icon');
if (!iconImg) return;
iconImg.addEventListener('error', () => {
if (iconImg.dataset.fallback) return;
iconImg.dataset.fallback = '1';
iconImg.src = '/favicon.svg';
});
})();
// ─── side panel (singleton drawer for inbox + todos flyouts) ───────────
// The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js
// for the chrome/behavior it owns), created once, eagerly, when this
// module's IIFE runs (ES modules execute after the document is
// parsed, so `document.body` already exists). Used directly — this
// UI's opens always take an owner name, so call sites use the
// element's own `openNamed(name, title, content)` rather than the
// untyped `open`. `agent.css` reaches the slotted content via a plain
// `hive-side-panel .agent-inbox …` tag-name selector (no compat class
// needed — the element's own tag name already identifies it).
const sidePanel = document.createElement('hive-side-panel');
document.body.append(sidePanel);
// Wire the header pills to open the side panel. Pre-built (vs
// re-building per-click) so the freshest snapshot already lives
// in `lastInbox` / `lastTodos` 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', () => {
sidePanel.openNamed('inbox', 'inbox · ' + lastInbox.length,
buildInboxList(lastInbox));
});
}
const todosPill = $('todos-pill');
if (todosPill) {
todosPill.addEventListener('click', () => {
sidePanel.openNamed('todos', 'todos · ' + lastTodos.length,
buildTodosList(lastTodos));
});
}
})();
// ─── 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/<name>/` 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 / 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', async () => {
if (!(await themedConfirm({ message: `rebuild ${label}? container will hot-reload.`, confirmLabel: '↻ rebuild' }))) return;
closeOverflowMenu();
const f = document.createElement('form');
f.method = 'POST';
f.action = `${dashUrl}api/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', async () => {
if (!(await themedConfirm({
message: 'arm a fresh claude session for the next turn? all prior --continue context will be dropped.',
danger: true, confirmLabel: '↻ arm fresh session',
}))) 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', async () => {
if (!(await themedConfirm({
message:
`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.`,
danger: true, confirmLabel: '🔓 log out',
}))) 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 = {
low: 'low',
medium: 'medium (default)',
high: 'high',
xhigh: 'xhigh',
max: '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 (sidePanel).
(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 <code>~/.claude/</code>. 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 <code>claude auth login</code> 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 <name> — switch claude model for future turns' },
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) 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();
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':
// Fire the (async) themed confirm without blocking this function's
// synchronous `true` return — the caller only needs to know the
// line was a recognized slash command, not that the action fired.
(async () => {
if (await themedConfirm({
message: 'arm a fresh claude session for the next turn? all prior --continue context will be dropped.',
danger: true, confirmLabel: '↻ arm fresh session',
})) postNewSession();
})();
return true;
case '/logout':
(async () => {
if (await themedConfirm({
message:
`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.`,
danger: true, confirmLabel: '🔓 log out',
})) 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;
}
case '/effort': {
const parts = trimmed.split(/\s+/);
if (parts.length < 2 || !parts[1]) {
termAPI.row('turn-end-fail',
'✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)');
} else {
postEffort(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();
}
// Todos section: in-agent todos (loose-ends v2) pushed by subsystems —
// matrix, forge, bash are the built-in ones, but any user-configured MCP
// server can push its own. Best-effort fetch on cold load + after every
// turn_end. Silent failure keeps the pill at zero.
async function refreshTodos() {
try {
const resp = await fetch('api/todos');
if (!resp.ok) {
renderTodos([]);
return;
}
const data = await resp.json();
renderTodos(data.todos || []);
} catch (err) {
console.warn('todos fetch failed', err);
renderTodos([]);
}
}
/** Latest snapshot kept in module state so the pill click handler
* has fresh data to render into the panel without re-fetching. */
let lastTodos = [];
let lastInbox = [];
/** Bulk "mark done" row for the todos flyout: select all / select none
* + a mark-done button, disabled until at least one row is checked.
* POSTs the checked ids (comma-joined into one field, same shape as
* `hive-c0re`'s meta-inputs bulk form — axum's `Form` extractor doesn't
* natively decode repeated same-name keys) to this agent's own
* `/api/todos/mark-done`, then calls `refreshTodos()` on success so the
* flyout reloads without the now-dismissed rows. `wrap` is the panel
* root — bulk buttons read/toggle the checkboxes it contains. */
function buildTodosMarkDoneRow(wrap) {
const status = el('span', { class: 'inbox-mark-status' });
const selAll = el('button', { type: 'button', class: 'inbox-mark-all-btn' }, 'select all');
const selNone = el('button', { type: 'button', class: 'inbox-mark-all-btn' }, 'select none');
const markBtn = el('button', {
type: 'button', class: 'inbox-mark-all-btn', disabled: '',
}, '✓ mark done');
const checkboxes = () => Array.from(wrap.querySelectorAll('input[data-todo-id]'));
const refreshDisabled = () => {
const any = checkboxes().some((cb) => cb.checked);
if (any) markBtn.removeAttribute('disabled');
else markBtn.setAttribute('disabled', '');
};
selAll.addEventListener('click', () => {
checkboxes().forEach((cb) => { cb.checked = true; });
refreshDisabled();
});
selNone.addEventListener('click', () => {
checkboxes().forEach((cb) => { cb.checked = false; });
refreshDisabled();
});
wrap.addEventListener('change', (e) => {
if (e.target.matches('input[data-todo-id]')) refreshDisabled();
});
markBtn.addEventListener('click', () => {
const ids = checkboxes().filter((cb) => cb.checked).map((cb) => cb.dataset.todoId);
if (!ids.length) return;
status.textContent = 'marking…';
asyncBtn(markBtn, async () => {
try {
const resp = await fetch('api/todos/mark-done', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'ids=' + encodeURIComponent(ids.join(',')),
});
if (resp.ok) {
status.textContent = '✓ marked done';
refreshTodos();
} else {
status.textContent = 'failed: ' + (await resp.text());
}
} catch (err) {
status.textContent = 'failed: ' + err;
}
});
});
return el('div', { class: 'inbox-mark-all-row' }, selAll, selNone, markBtn, status);
}
/** Build the todos side-panel list. Each entry is a LooseEnd::Todo
* (id, subsystem, summary, source, age_seconds). A checkbox per row
* plus the bulk row above lets the operator dismiss several at once
* instead of one `cancel_loose_end` call at a time. */
function buildTodosList(todos) {
const wrap = el('div', { class: 'agent-inbox' });
if (!todos.length) {
wrap.append(el('p', { class: 'side-panel-empty' },
'no todos — all subsystem queues are clear.'));
return wrap;
}
wrap.append(buildTodosMarkDoneRow(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 todos) {
const li = el('li');
const label = t.source ? t.subsystem + ' · ' + t.source : t.subsystem;
const cbId = 'todo-cb-' + t.id;
const cb = el('input', {
type: 'checkbox', id: cbId, class: 'todo-cb', 'data-todo-id': String(t.id),
});
li.append(
cb, ' ',
el('label', { for: cbId, class: 'inbox-from' }, label), ' ',
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
el('div', { class: 'inbox-body' }, t.summary || ''),
);
list.append(li);
}
wrap.append(list);
return wrap;
}
/** Pill-count + open-panel-refresh wiring for todos. */
function renderTodos(todos) {
lastTodos = todos;
const pill = $('todos-pill');
const count = $('todos-count');
if (count) count.textContent = todos.length;
if (pill) pill.hidden = todos.length === 0;
sidePanel.refresh('todos', 'todos · ' + todos.length,
buildTodosList(todos));
}
/** "mark all read" affordance for the agent's inbox flyout —
* see docs/web-ui.md::Per-agent page for the inbox flyout's
* 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 (!(await themedConfirm({
message: `mark every queued message for ${label} as read? `
+ `the message history shown stays; only the unread queue is drained.`,
confirmLabel: '✓ mark all read',
}))) return;
status.textContent = 'clearing…';
asyncBtn(btn, async () => {
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;
}
} catch (err) {
status.textContent = 'failed: ' + err;
}
});
});
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. The inbox shows only
// unread (acked_at IS NULL) rows, so after the drain + refreshState()
// the list empties (matching the operator's expectation).
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;
sidePanel.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();
// s.forge_public_url (set from services.hyperhive.forge.publicUrl)
// or falsy — never guessed from "<hostname>:3000". A forge-kind
// link is skipped entirely below when there's no public URL to
// point it at.
const forgeBase = s.forge_public_url || null;
s.links.forEach((lnk, i) => {
if (lnk.kind === 'forge' && !forgeBase) return;
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 });
// Todos pill: cold-load populate; turn_end refreshes via renderTodos.
refreshTodos();
// Skip the re-render if nothing structurally changed. The most
// common case is `online` polling itself — without this guard, the
// operator's <input value> 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();
// Keep the todos pill live. Todos change asynchronously (matrix syncs,
// bash task starts/completions) independent of turn_end SSE, so poll
// the snapshot endpoint on a light interval. Fails silently when offline.
setInterval(refreshTodos, 4000);
// ─── 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 <div class="md">.
// 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.
// `text` is untrusted (peer-agent / matrix-relayed message bodies,
// agent-authored files) — the parsed HTML is run through DOMPurify
// before it ever touches innerHTML, since markdown can carry raw
// HTML/script tags that `marked` itself no longer strips (v5+
// dropped the built-in sanitizer).
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 = DOMPurify.sanitize(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, icon) {
const d = api.details(cls, summary, '', icon);
d.open = true;
const pre = d.querySelector('pre.tool-body');
if (pre) {
pre.replaceWith(mdNode(body));
} else {
d.appendChild(mdNode(body));
}
return d;
}
// 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 <details>, full body text inside.
function renderRichToolUse(c, api) {
const name = c.name || '';
const icon = c._icon || '🔧';
// 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.
// Generic backend-computed body: the backend stamps `_body` + `_body_type`
// for tools whose expandable content is pre-computable. Dispatch on type —
// no tool-specific JS needed for most cases.
if (c._body != null) {
const summary = c._summary || name || '?';
if (c._body_type === 'diff') {
return api.detailsDiff('tool-use', summary, c._body, icon);
}
if (c._body_type === 'markdown') {
return detailsOpenMd(api, 'tool-use', summary, c._body, icon);
}
return api.details('tool-use', summary, c._body, icon);
}
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 rawTxt = Array.isArray(c.content)
? c.content.map(p => p.text || '').join('')
: (c.content || '');
// Strip the <tool_use_error>…</tool_use_error> wrapper that claude
// emits when a tool call fails — the tags are an implementation detail
// and add noise to the terminal display.
const isError = !!c.is_error;
const txt = isError
? rawTxt.replace(/^<tool_use_error>([\s\S]*)<\/tool_use_error>$/, '$1').trim()
: rawTxt;
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
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}`;
})();
if (isError) {
if (!txt.trim() || txt.length <= 120) {
api.row('tool-result error', '✗ ' + summaryBody);
} else {
api.details('tool-result-block error', summaryBody, txt);
}
return;
}
// 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;
}
// Coalescing helper: collapses a burst of same-kind ticks into ONE
// row that updates in place instead of appending a fresh note per
// tick. Holds the row + its text node in a closure; reuses them only
// while the row is still the last one rendered (nextElementSibling
// === null) — any other event in between starts a fresh row. `cls`
// and `icon` are fixed per coalescer instance (mirrors `api.mutableRow`'s
// signature); `api` is passed per-call since `renderStream` receives a
// fresh one each invocation.
function makeCoalescer(cls, icon) {
let row = null;
let text = null;
return function update(api, newText) {
if (row && row.isConnected && row.nextElementSibling === null) {
text.nodeValue = newText;
} else {
[row, text] = api.mutableRow(cls, newText, icon);
}
};
}
// Live `thinking_tokens` counter — claude streams many of these per turn.
const updateThinkingTokens = makeCoalescer('note', '🧠');
// Bare `status` ticks (claude's generic "still working" signal) — the
// only signal during a compaction pass (no dedicated "compacting…"
// event), so without coalescing a compaction looked like a wall of
// identical `⚙ status` rows.
const updateStatus = makeCoalescer('note');
// `plugin_install`: claude emits a `started` tick then a `completed`
// tick per plugin — without coalescing that's two rows ("loading…"
// then "✓ done") for what's really one event.
const updatePluginInstall = makeCoalescer('note');
function renderStream(v, api) {
// Backend pre-computes `_category` on all known event types.
// "drop" covers: type=result, type=rate_limit_event, and system/init.
if (v._category === 'drop') return;
if (v.type === 'system') {
const cat = v._category;
const summary = v._summary;
// thinking_tok: collapse many ticks into one in-place counter row.
if (cat === 'thinking_tok') {
updateThinkingTokens(api, summary || 'thinking…');
return;
}
// plugin_install: coalesced in-place row while the plugin loads.
if (v.subtype === 'plugin_install') {
updatePluginInstall(api, summary || '⚙ plugin install');
return;
}
// status: backend provides the base label; when the harness state is
// `compacting` we override with elapsed time from `stateSince` — a
// client-side wall-clock value the backend can't know at emit time.
if (v.subtype === 'status') {
let label = summary || '⚙ status';
if (stateName === 'compacting') {
const elapsed = Math.round((Date.now() - stateSince) / 1000);
label = '⚙ compact · ' + elapsed + 's…';
}
updateStatus(api, label);
return;
}
// details: expandable row with _summary as header, _body as content.
if (cat === 'details') {
api.details('note', summary || '⚙ ' + (v.subtype || ''), v._body || '');
return;
}
// note (and any unknown category): single summary line.
api.row('note', summary || '⚙ ' + (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 || 'thinking …', '💭');
}
else if (c.type === 'tool_use') {
if (c.id && c.name) toolNameById.set(c.id, c.name);
// `_category: "rich"` is stamped by the backend on tools that
// have full-body renderers (Write/Edit diffs, send/ask/answer
// message bodies). Flat-row tools use backend _icon/_summary.
if (c._category === 'rich') {
if (!renderRichToolUse(c, api)) {
api.row('tool-use', c._summary || c.name || '?', c._icon || '🔧');
}
} else {
api.row('tool-use', c._summary || c.name || '?', c._icon || '🔧');
}
}
}
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/<name>/) still hits the right SSE upstream.
historyUrl: 'events/history',
streamUrl: 'events/stream',
// Re-read live (not captured once) so flipping the overflow-menu
// toggle mid-session affects the next rendered row immediately.
expandDetails: () => getExpandDetailsPref(),
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');
refreshTodos();
}
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;
})();