`; External → already
// absolute. DOM-built via el() — agent-declared icon / label /
// url strings must NEVER reach innerHTML.
const metaLinks = $('meta-links');
if (metaLinks && Array.isArray(s.links)) {
metaLinks.replaceChildren();
const forgeBase = `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 || '',
});
if (i > 0) a.style.marginLeft = '1em';
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);
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.innerHTML = '';
if (s.status === 'online') renderOnline(s.label, root);
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
lastStatus = s.status;
lastOutputLen = outLen;
}
// Only poll while a login is in flight — otherwise SSE turn_end
// events trigger a refresh, and the operator can type into the
// send form without it getting cleared every few seconds.
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (s.status === 'needs_login_in_progress') {
pollTimer = setTimeout(refreshState, 1500);
}
} catch (err) {
console.error('refreshState failed', err);
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
// ─── live event stream ──────────────────────────────────────────────────
// 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.innerHTML = '';
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. (issue #233)
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;
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__request_spawn': return short + ' ' + (input.name || '');
case 'mcp__hyperhive__kill': return short + ' ' + (input.name || '');
case 'mcp__hyperhive__request_apply_commit':
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12);
default: return 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;
return detailsOpenMd(api, 'tool-use',
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
q);
}
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);
}
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';
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 session init, claude's result line, rate-limit — noise.
// TurnEnd communicates pass/fail; session init isn't actionable.
if (v.type === 'system' && v.subtype === 'init') return;
if (v.type === 'rate_limit_event') return;
if (v.type === 'result') 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,
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);
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';
api.row(cls,
(ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail')
+ (ev.note ? ' — ' + ev.note : ''));
},
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.innerHTML = '';
lastStatus = 'online';
}
},
model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); },
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.innerHTML = ''; },
};
})();
// Avoid unused-var lint while keeping `escText` available for future use.
void escText;
})();