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.
1970 lines
85 KiB
JavaScript
1970 lines
85 KiB
JavaScript
// Per-agent web UI. Renders title + login/online view from `/api/state`,
|
||
// tails `/events/stream` for live claude events, drives async-form
|
||
// actions (send / login/* / dashboard rebuild).
|
||
|
||
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
|
||
import { marked } from 'marked';
|
||
|
||
// Expose the previously-script-tag-provided globals so the IIFE below
|
||
// keeps working unchanged. Pre-split these were attached by
|
||
// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js`
|
||
// (marked) loading before app.js. The bundle now pulls them in via ES
|
||
// imports; once the IIFE is opened up these aliases can be dropped in
|
||
// favour of direct named imports.
|
||
window.HiveTerminal = { create: termCreate, linkify: termLinkify };
|
||
window.marked = marked;
|
||
|
||
(() => {
|
||
// ─── helpers ────────────────────────────────────────────────────────────
|
||
const $ = (id) => document.getElementById(id);
|
||
const escText = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||
);
|
||
const el = (tag, attrs = {}, ...children) => {
|
||
const e = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (k === 'class') e.className = v;
|
||
else if (k === 'html') e.innerHTML = v;
|
||
else e.setAttribute(k, v);
|
||
}
|
||
for (const c of children) {
|
||
if (c == null) continue;
|
||
e.append(c.nodeType ? c : document.createTextNode(c));
|
||
}
|
||
return e;
|
||
};
|
||
|
||
// Base URL of the host dashboard (core backend). Set once the first
|
||
// /api/state lands. Operator-authority actions (answering a question
|
||
// as the operator) POST here rather than to this agent's own socket —
|
||
// see docs/boundary.md for why the boundary lives on the core side.
|
||
let dashboardBase = '';
|
||
|
||
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
||
document.addEventListener('submit', async (e) => {
|
||
const f = e.target;
|
||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||
e.preventDefault();
|
||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||
const btn = f.querySelector('button[type="submit"], button:not([type])');
|
||
const original = btn ? btn.innerHTML : '';
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<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;
|
||
})();
|