hyperhive/frontend/packages/agent/src/app.js
iris 1c47bd4333 agent-ui: real fixed-width icon column for terminal rows
Replaces the first-character-glyph + negative-text-indent trick (which let a
wide emoji or a leading disclosure caret knock the icon out of column) with a
genuine icon cell.

terminal.js: row() / details() / detailsDiff() take an optional `icon` that
goes in a fixed-width `.row-glyph` element (inline-block, 1.4em). Details
summaries wrap their text in a `.summary-text` span; the disclosure caret
moves to `.summary-text::before` so it leads the text, not the icon — keeping
the icon in the shared column. terminal.css carries the cell + caret rules.

app.js passes the per-tool emoji as `icon` for the flat tool-use row and every
expandable tool summary (Write/Edit/send/ask/answer/bash) plus the 💭 thinking
row, instead of string-prefixing it. A details `🖥️` now lines up under a flat
row's `🧠` regardless of emoji width. Doc: terminal-rendering.md layout
contract updated. Closes #1844.
2026-06-22 00:41:24 +02:00

1970 lines
85 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[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 = '<span class="spinner">◐</span>'; }
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));
});
}
const bashPill = $('bash-tasks-pill');
if (bashPill) {
bashPill.addEventListener('click', () => {
Panel.open('bash-tasks', 'tasks · ' + lastBashTasks.length,
buildBashTasksList(lastBashTasks));
});
}
})();
// ─── 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 / 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 <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: '/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 <div> the side panel renders. The structural shape
// mirrors the legacy <details>-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 `<details>` 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();
}
// Running bash tasks: `GET /api/bash-tasks` returns the in-flight
// (Pending/Running) TaskFiles from the in-container bash-tasks dir. Same
// best-effort, silent-failure contract as loose-ends — a fetch miss keeps
// the pill at zero rather than surfacing stale chrome.
let lastBashTasks = [];
async function refreshBashTasks() {
try {
const resp = await fetch('api/bash-tasks');
if (!resp.ok) {
renderBashTasks([]);
return;
}
const data = await resp.json();
renderBashTasks(data.tasks || []);
} catch (err) {
console.warn('bash-tasks fetch failed', err);
renderBashTasks([]);
}
}
function buildBashTasksList(tasks) {
const wrap = el('div', { class: 'agent-inbox' });
if (!tasks.length) {
wrap.append(el('p', { class: 'side-panel-empty' },
'no running bash tasks.'));
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';
};
const now = Math.floor(Date.now() / 1000);
for (const t of tasks) {
const li = el('li');
const running = t.status === 'running';
// Elapsed since the task started (running) or was queued (pending).
const since = running ? (t.started_at || t.created_at || now) : (t.created_at || now);
const elapsed = Math.max(0, now - since);
// Single-line, truncated command preview (the cmd can be multi-line).
const cmdPreview = (t.cmd || '').replace(/\s+/g, ' ').trim().slice(0, 100);
li.append(
el('span', { class: 'bash-task-status bash-task-' + t.status }, running ? '▶ running' : '◷ queued'), ' ',
el('span', { class: 'inbox-from' }, t.id), ' ',
el('span', { class: 'inbox-ts' }, (running ? '' : 'queued ') + fmtAge(elapsed) + (running ? ' elapsed' : '')),
el('div', { class: 'inbox-body bash-task-cmd' }, cmdPreview),
);
list.append(li);
}
wrap.append(list);
return wrap;
}
function renderBashTasks(tasks) {
lastBashTasks = tasks;
const pill = $('bash-tasks-pill');
const count = $('bash-tasks-count');
if (count) count.textContent = tasks.length;
if (pill) pill.hidden = tasks.length === 0;
Panel.refresh('bash-tasks', 'tasks · ' + tasks.length,
buildBashTasksList(tasks));
}
/** 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();
// Cold-load populate of the running-bash-tasks pill. Tasks complete
// asynchronously between turns (independent of turn_end SSE) and
// /api/state isn't polled while online, so a dedicated interval (set
// up next to the initial refreshState() below) keeps the count live;
// this call just fills it immediately on first paint.
refreshBashTasks();
// 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 running-bash-tasks pill live. Unlike loose-ends (refreshed on
// turn_end SSE), bash tasks start + finish asynchronously between turns and
// /api/state isn't polled while online — so poll the cheap snapshot endpoint
// on a light interval. Fails silently (renders zero) when offline. v1 is
// polling; an SSE push for task state could replace this later.
setInterval(refreshBashTasks, 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.
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, 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;
}
// 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;
}
// Per-tool glyph for the tool-use row prefix — gives each hive / MCP /
// built-in tool a distinctive icon instead of a generic wrench, so the
// scrollback is scannable at a glance. Exact tool names first, then
// MCP-server family fallbacks, then a generic wrench default.
const TOOL_ICONS = {
'mcp__hyperhive__send': '📤',
'mcp__hyperhive__recv': '📥',
'mcp__hyperhive__ask': '❓',
'mcp__hyperhive__answer': '✍️',
'mcp__hyperhive__remind': '⏰',
'mcp__hyperhive__set_status': '🏷️',
'mcp__hyperhive__get_loose_ends': '🪢',
'mcp__hyperhive__cancel_loose_end': '✂️',
'mcp__hyperhive__get_agent_meta': '',
'mcp__hyperhive__request_next_turn': '⏩',
'mcp__hyperhive__restart': '↻',
'mcp__hyperhive__kill': '⏹️',
'mcp__hyperhive__start': '▶️',
'mcp__hyperhive__update': '🔄',
'mcp__hyperhive__list_containers': '📋',
Read: '📖', Write: '💾', Edit: '✏️', Glob: '🔍', Grep: '🔍',
};
function toolIcon(name) {
if (TOOL_ICONS[name]) return TOOL_ICONS[name];
if (typeof name === 'string') {
if (name.startsWith('mcp__matrix__')) return '💬';
if (name.startsWith('mcp__bash__')) return '🖥️';
if (name.includes('schedule')) return '⏱️';
// request_apply_commit / request_init_config / request_update_meta_inputs
if (name.startsWith('mcp__hyperhive__request_')) return '📦';
}
return '🔧';
}
// 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 <details>, 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');
}
// The tool icon goes in the shared `.row-glyph` cell (4th arg) so it
// lines up with flat-row icons; the summary text carries no
// directional `→` (the row's cyan colour signals "outbound tool" and
// the CSS disclosure caret leads the text).
const summary = name + ' ' + path + ' · '
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
return api.detailsDiff('tool-use', summary, body, toolIcon(name));
}
// 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, toolIcon(name));
}
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, toolIcon(name));
// 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, toolIcon(name));
}
// 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, toolIcon(name));
}
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;
}
// Coalescing target for the live `thinking_tokens` counter.
// Holds the row currently showing the running estimate so consecutive
// ticks update in place instead of appending a note each. Reset
// implicitly: we only reuse it while it's still the last row rendered
// (see the nextElementSibling check), so any other event starts fresh.
let thinkingTokensRow = null;
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;
}
// Live thinking-token counter — claude streams many of these per
// turn (a running `estimated_tokens` total while it thinks). Collapse
// consecutive ticks into ONE in-place-updating row instead of a note
// per tick. Reuse our row only while it's still the last one
// in the scrollback; once any other event renders after it, the next
// tick starts a fresh row.
if (v.subtype === 'thinking_tokens') {
const n = v.estimated_tokens;
const label = '🧠 thinking … '
+ (n != null ? '~' + Number(n).toLocaleString() + ' tokens' : '');
if (thinkingTokensRow && thinkingTokensRow.isConnected
&& thinkingTokensRow.nextElementSibling === null) {
thinkingTokensRow.textContent = label;
} else {
thinkingTokensRow = api.row('note', label);
}
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 || '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), toolIcon(c.name));
}
}
}
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',
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;
})();