Phase 1 of the backend/frontend code split (#273). Additive — no existing code is touched; the legacy hive-c0re/assets, hive-ag3nt/ assets and hive-fr0nt/assets trees stay in place until the Rust cutover later in this branch. Layout: frontend/package.json npm workspaces root frontend/packages/shared/ @hive/shared src/{base,terminal}.css + terminal.js (ES module) src/index.js re-exports terminal.js frontend/packages/dashboard/ @hive/dashboard src/{index.html, app.js, dashboard.css} ported from hive-c0re/assets build.mjs esbuild config → dist/ frontend/packages/agent/ @hive/agent src/{index,stats,screen}.html + agent.css + {app,stats}.js ported from hive-ag3nt/assets build.mjs esbuild config → dist/ Changes vs the existing assets: - terminal.js is an ES module exporting { create, linkify } instead of assigning to window.HiveTerminal. The dashboard / agent app.js files re-expose them on window so the IIFE bodies keep working unchanged through Phase 1; the global aliases can be dropped in a follow-up once the IIFEs are unwrapped. - marked is imported from the marked@4.3.0 npm package (replacing the vendored hive-fr0nt/assets/marked.umd.js bundle). - chart.js is imported from chart.js@4.4.4 (replacing the jsDelivr CDN script tag on the per-agent stats page — page now works offline / on operator machines without internet egress). - dashboard.css and agent.css both gain @import lines at the top that pull base.css + terminal.css from @hive/shared, replacing the runtime string concatenation in serve_css. - index.html / stats.html collapse from three / two script tags to one type="module" tag pointing at the bundled output. package-lock.json is intentionally omitted from this commit — npm isn't available in the iris container yet (approval pending) and the lockfile will land in the next commit on this branch once the toolchain is in place. The PR will not be opened until it's there. Phase 2 (nix derivations), Phase 3 (container plumbing + the hyperhive.frontend.extraFiles option for per-agent layering), and Phase 4 (Rust cutover to tower_http::ServeDir, delete hive-fr0nt + legacy assets dirs) land as follow-up commits on this same branch. Refs #273.
1168 lines
49 KiB
JavaScript
1168 lines
49 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; }
|
|
}
|
|
});
|
|
|
|
// ─── state rendering ────────────────────────────────────────────────────
|
|
function setHeader(label, dashboardPort) {
|
|
$('banner').textContent =
|
|
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
|
|
const title = $('title');
|
|
title.textContent = `◆ ${label} ◆ `;
|
|
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
|
|
// tab to keep the agent page anchored where the operator is.
|
|
const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`;
|
|
dashboardBase = dashUrl;
|
|
title.append(
|
|
el('a', {
|
|
href: dashUrl, target: '_blank', rel: 'noopener',
|
|
class: 'btn-dashlink', title: 'host dashboard',
|
|
}, '↑ DASHB04RD'),
|
|
' ',
|
|
);
|
|
const btn = el('a', {
|
|
href: '#', class: 'btn-rebuild', id: 'rebuild-btn',
|
|
}, '↻ R3BU1LD');
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (!confirm(`rebuild ${label}? container will hot-reload.`)) return;
|
|
const f = document.createElement('form');
|
|
f.method = 'POST';
|
|
f.action = `${dashUrl}rebuild/${label}`;
|
|
document.body.appendChild(f);
|
|
f.submit();
|
|
});
|
|
title.append(btn);
|
|
document.title = `${label} // hyperhive`;
|
|
}
|
|
|
|
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': '',
|
|
});
|
|
code.append(
|
|
el('input', {
|
|
name: 'code', placeholder: 'paste OAuth code here',
|
|
required: '', autocomplete: 'off',
|
|
}),
|
|
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 = '';
|
|
|
|
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)' },
|
|
];
|
|
|
|
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 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);
|
|
}
|
|
}
|
|
const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
|
|
const postCompact = () => postSimple('/api/compact', '/compact');
|
|
const postNewSession = () => postSimple('/api/new-session', '/new-session');
|
|
|
|
function handleSlashCommand(line) {
|
|
if (!termAPI) return false;
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('/')) return false;
|
|
const [cmd] = trimmed.split(/\s+/);
|
|
switch (cmd) {
|
|
case '/help':
|
|
termAPI.row('note', '· /help');
|
|
for (const c of SLASH_COMMANDS) {
|
|
termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc);
|
|
}
|
|
return true;
|
|
case '/clear':
|
|
termAPI.clear();
|
|
termAPI.row('note', '· terminal cleared (local view only — server history kept)');
|
|
return true;
|
|
case '/cancel':
|
|
postCancelTurn();
|
|
return true;
|
|
case '/compact':
|
|
postCompact();
|
|
return true;
|
|
case '/new-session':
|
|
if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) {
|
|
postNewSession();
|
|
}
|
|
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.innerHTML = '';
|
|
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';
|
|
}
|
|
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 section hidden rather than surfacing an empty banner.
|
|
let lastLooseEndsCount = 0;
|
|
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([]);
|
|
}
|
|
}
|
|
function renderLooseEnds(threads) {
|
|
const root = $('loose-ends-section');
|
|
const list = $('loose-ends-list');
|
|
const summary = $('loose-ends-summary');
|
|
if (!root || !list || !summary) return;
|
|
if (!threads.length) {
|
|
root.hidden = true;
|
|
lastLooseEndsCount = 0;
|
|
return;
|
|
}
|
|
root.hidden = false;
|
|
summary.textContent = 'loose ends · ' + threads.length;
|
|
list.innerHTML = '';
|
|
// Auto-expand on first appearance of any open thread so the
|
|
// operator notices new loose ends; collapse only on operator
|
|
// click (sticky after that).
|
|
if (lastLooseEndsCount === 0) root.open = true;
|
|
lastLooseEndsCount = threads.length;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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;
|
|
}
|
|
|
|
function renderInbox(rows) {
|
|
const root = $('inbox-section');
|
|
const list = $('inbox-list');
|
|
const summary = $('inbox-summary');
|
|
if (!root || !list || !summary) return;
|
|
if (!rows.length) {
|
|
root.hidden = true;
|
|
return;
|
|
}
|
|
root.hidden = false;
|
|
summary.textContent = 'inbox · ' + rows.length;
|
|
list.innerHTML = '';
|
|
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);
|
|
}
|
|
}
|
|
// 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) {
|
|
const el_ = $('model-chip');
|
|
if (!el_) return;
|
|
if (!model) { el_.hidden = true; return; }
|
|
el_.hidden = false;
|
|
el_.textContent = 'model · ' + model;
|
|
el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`;
|
|
}
|
|
// 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; });
|
|
});
|
|
})();
|
|
|
|
// Wire the new-session button (always visible; arms a one-shot for
|
|
// the next turn). Mildly destructive (drops --continue context) so
|
|
// we confirm before posting.
|
|
(() => {
|
|
const btn = $('new-session-btn');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return;
|
|
btn.disabled = true;
|
|
postNewSession().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();
|
|
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
|
currentLabel = s.label;
|
|
// Render server-supplied navigation links — stats, screen, the
|
|
// forge profile, the agent-configs mirror, plus any
|
|
// agent-declared `dashboardLinks` extras (issue #262). Each
|
|
// NavLink's `kind` says how to resolve `url`: Container →
|
|
// same-origin path (the agent page is itself container-local);
|
|
// Forge → `http://<host>:3000<url>`; External → already
|
|
// absolute. DOM-built via el() — agent-declared icon / label /
|
|
// url strings must NEVER reach innerHTML.
|
|
const metaLinks = $('meta-links');
|
|
if (metaLinks && Array.isArray(s.links)) {
|
|
metaLinks.replaceChildren();
|
|
const forgeBase = `http://${window.location.hostname}:3000`;
|
|
s.links.forEach((lnk, i) => {
|
|
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
|
: lnk.kind === 'external' ? (lnk.url || '')
|
|
: /* container */ (lnk.url || '');
|
|
const a = el('a', {
|
|
class: 'agent-nav-link',
|
|
href,
|
|
target: '_blank',
|
|
rel: 'noopener',
|
|
title: lnk.label || '',
|
|
});
|
|
if (i > 0) a.style.marginLeft = '1em';
|
|
a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →');
|
|
metaLinks.append(a);
|
|
});
|
|
}
|
|
renderTermInput(s.label, s.status === 'online');
|
|
renderInbox(s.inbox || []);
|
|
// Authoritative state comes from the harness via /api/state.
|
|
// Login-not-yet → 'offline'; otherwise use the server-reported
|
|
// turn_state (idle / thinking / compacting). stateSince in
|
|
// unix-seconds is converted to a client-side Date.now() anchor.
|
|
if (s.status !== 'online') {
|
|
setState('offline');
|
|
} else if (s.turn_state) {
|
|
setStateAbs(s.turn_state, s.turn_state_since);
|
|
}
|
|
renderAliveBadge(s.status);
|
|
renderModelChip(s.model);
|
|
renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage });
|
|
// Open-threads aren't part of /api/state (kept on the broker
|
|
// db, fetched via the per-agent socket). Cold-load fetches
|
|
// it here; turn_end refreshes it via the renderer below.
|
|
refreshLooseEnds();
|
|
// Skip the re-render if nothing structurally changed. The most
|
|
// common case is `online` polling itself — without this guard, the
|
|
// operator's <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.innerHTML = '';
|
|
if (s.status === 'online') renderOnline(s.label, root);
|
|
else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root);
|
|
else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root);
|
|
lastStatus = s.status;
|
|
lastOutputLen = outLen;
|
|
}
|
|
// Only poll while a login is in flight — otherwise SSE turn_end
|
|
// events trigger a refresh, and the operator can type into the
|
|
// send form without it getting cleared every few seconds.
|
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
if (s.status === 'needs_login_in_progress') {
|
|
pollTimer = setTimeout(refreshState, 1500);
|
|
}
|
|
} catch (err) {
|
|
console.error('refreshState failed', err);
|
|
pollTimer = setTimeout(refreshState, 5000);
|
|
}
|
|
}
|
|
refreshState();
|
|
|
|
// ─── live event stream ──────────────────────────────────────────────────
|
|
// Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
|
// (window.HiveTerminal). What stays here is the per-kind rendering:
|
|
// turn framing, claude stream-json interpretation, tool_use prettyprint,
|
|
// tool_result collapse, +/- diff bodies for Write/Edit.
|
|
(function() {
|
|
const log = $('live');
|
|
if (!log || !window.HiveTerminal) return;
|
|
log.innerHTML = '';
|
|
|
|
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
|
// Render a message body as markdown into a new <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. (issue #233)
|
|
div.querySelectorAll('a[href]').forEach((a) => {
|
|
a.target = '_blank';
|
|
a.rel = 'noopener noreferrer';
|
|
});
|
|
} catch (err) {
|
|
console.warn('marked failed', err);
|
|
div.textContent = src;
|
|
}
|
|
} else {
|
|
div.textContent = src;
|
|
}
|
|
return div;
|
|
}
|
|
// Build a default-open details row whose body is markdown-rendered.
|
|
// Used by send / ask / answer tool_use renderers and by `recv`
|
|
// tool_result so message-bearing rows show their content inline
|
|
// without an extra click.
|
|
function detailsOpenMd(api, cls, summary, body) {
|
|
const d = api.details(cls, summary, '');
|
|
d.open = true;
|
|
const pre = d.querySelector('pre.tool-body');
|
|
if (pre) {
|
|
pre.replaceWith(mdNode(body));
|
|
} else {
|
|
d.appendChild(mdNode(body));
|
|
}
|
|
return d;
|
|
}
|
|
// Generic args-pretty-printer for unknown / extra-MCP tools. The
|
|
// built-in switch handles the common claude/hyperhive tools; this
|
|
// is the fallback so an `mcp__matrix__send_message` or similar
|
|
// doesn't dump raw JSON. Heuristics: single string-valued field →
|
|
// `Name field: "value"`; single dict-valued field → `Name field
|
|
// {…}`; otherwise compact JSON. Always trimmed to fit a row.
|
|
function fmtArgsGeneric(name, input) {
|
|
const keys = Object.keys(input || {});
|
|
if (keys.length === 0) return name + '()';
|
|
if (keys.length === 1) {
|
|
const k = keys[0];
|
|
const v = input[k];
|
|
if (typeof v === 'string') {
|
|
const oneline = v.replace(/\s+/g, ' ').trim();
|
|
return name + ' ' + k + ': ' + JSON.stringify(trim(oneline, 100));
|
|
}
|
|
if (typeof v === 'number' || typeof v === 'boolean') {
|
|
return name + ' ' + k + ': ' + JSON.stringify(v);
|
|
}
|
|
}
|
|
// Multi-field: render `k: v` pairs with strings/numbers inlined and
|
|
// anything else summarised by type so the row stays readable.
|
|
const pretty = keys.slice(0, 4).map((k) => {
|
|
const v = input[k];
|
|
if (v == null) return k + ': null';
|
|
if (typeof v === 'string') {
|
|
const oneline = v.replace(/\s+/g, ' ').trim();
|
|
return k + ': ' + JSON.stringify(trim(oneline, 40));
|
|
}
|
|
if (typeof v === 'number' || typeof v === 'boolean') return k + ': ' + v;
|
|
if (Array.isArray(v)) return k + `: [${v.length}]`;
|
|
return k + ': {…}';
|
|
});
|
|
const tail = keys.length > 4 ? ' …+' + (keys.length - 4) : '';
|
|
return name + ' ' + pretty.join(' · ') + tail;
|
|
}
|
|
// Pretty-print a tool call: per-known-tool format, fallback to JSON
|
|
// for unknown tools.
|
|
function fmtToolUse(c) {
|
|
const name = c.name || '';
|
|
const input = c.input || {};
|
|
const short = name.startsWith('mcp__hyperhive__')
|
|
? name.slice('mcp__hyperhive__'.length) + '*' : name;
|
|
switch (name) {
|
|
case 'Read': return short + ' ' + (input.file_path || '');
|
|
case 'Write': return short + ' ' + (input.file_path || '');
|
|
case 'Edit': return short + ' ' + (input.file_path || '');
|
|
case 'Glob': return short + ' ' + (input.pattern || '');
|
|
case 'Grep': return short + ' ' + (input.pattern || '');
|
|
case 'Bash': return short + (input.run_in_background ? ' [bg]' : '')
|
|
+ ' $ ' + (input.command || '');
|
|
case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)';
|
|
case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': '
|
|
+ JSON.stringify(input.body || '').slice(0, 80);
|
|
case 'mcp__hyperhive__recv': {
|
|
// Surface the long-poll wait + batch size — a bare `recv()` row
|
|
// hides whether the agent is parking a turn (wait_seconds) or
|
|
// draining a burst (max).
|
|
const parts = [];
|
|
if (input.wait_seconds != null) parts.push('wait ' + input.wait_seconds + 's');
|
|
if (input.max != null) parts.push('max ' + input.max);
|
|
return short + (parts.length ? ' ' + parts.join(' · ') : '()');
|
|
}
|
|
case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || '');
|
|
case 'mcp__hyperhive__kill': return short + ' ' + (input.name || '');
|
|
case 'mcp__hyperhive__request_apply_commit':
|
|
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12);
|
|
default: return fmtArgsGeneric(short, input);
|
|
}
|
|
}
|
|
// Build a "rich" tool_use row for tools whose input has a body we
|
|
// want the operator to see in full. Returns null for any other tool
|
|
// so the caller falls back to the flat-row path.
|
|
// Write: every input.content line is "+".
|
|
// Edit: old_string lines as "-", new_string lines as "+".
|
|
// mcp__hyperhive__send: collapsed <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');
|
|
}
|
|
// Summaries on expandable rows omit the row's directional glyph
|
|
// (`→`) — the disclosure marker (`▸/▾`) from CSS sits in the
|
|
// prefix column for every row kind, and the row's cyan colour
|
|
// already signals "outbound tool".
|
|
const summary = name + ' ' + path + ' · '
|
|
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
|
|
return api.detailsDiff('tool-use', summary, body);
|
|
}
|
|
// Message-bearing tools render default-open with a markdown body so
|
|
// the operator sees the content without an extra click. send / ask
|
|
// address a target; answer attaches to an existing question id.
|
|
if (name === 'mcp__hyperhive__send') {
|
|
const to = input.to || '?';
|
|
const body = String(input.body || '');
|
|
const lines = body.split('\n').length;
|
|
return detailsOpenMd(api, 'tool-use',
|
|
'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
|
body);
|
|
}
|
|
if (name === 'mcp__hyperhive__ask') {
|
|
const to = input.to || 'operator';
|
|
const q = String(input.question || '');
|
|
const lines = q.split('\n').length;
|
|
return detailsOpenMd(api, 'tool-use',
|
|
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
|
q);
|
|
}
|
|
if (name === 'mcp__hyperhive__answer') {
|
|
const id = input.id != null ? String(input.id) : '?';
|
|
const a = String(input.answer || '');
|
|
const lines = a.split('\n').length;
|
|
return detailsOpenMd(api, 'tool-use',
|
|
'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
|
|
a);
|
|
}
|
|
return null;
|
|
}
|
|
// Track tool_use_id → tool name so we can decide on rendering when the
|
|
// matching tool_result lands later. Lets us default-open the body for
|
|
// message-bearing tools (`recv`) while keeping shell/file tool output
|
|
// collapsed unless the operator clicks. Cleared on /clear; otherwise
|
|
// grows with the session — entries are tiny strings.
|
|
const toolNameById = new Map();
|
|
function renderToolResult(c, api) {
|
|
const txt = Array.isArray(c.content)
|
|
? c.content.map(p => p.text || '').join('')
|
|
: (c.content || '');
|
|
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
|
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
|
const trimmed = txt.replace(/\s+/g, ' ').trim();
|
|
const summaryBody = (() => {
|
|
if (!trimmed) return '(empty)';
|
|
if (trimmed.length <= 120) return trimmed;
|
|
const lines = txt.split('\n').filter(l => l.length).length;
|
|
const headline = trimmed.slice(0, 90) + '…';
|
|
return `${lines}L · ${headline}`;
|
|
})();
|
|
// Flat row: keep the `←` glyph in the prefix column. Details rows
|
|
// drop it — the `▸/▾` disclosure marker sits in that column via CSS.
|
|
if (isMessageBearing && txt.trim()) {
|
|
return detailsOpenMd(api, 'tool-result-block',
|
|
'recv ← ' + summaryBody, txt);
|
|
}
|
|
if (!txt.trim() || txt.length <= 120) {
|
|
api.row('tool-result', '← ' + summaryBody);
|
|
} else {
|
|
api.details('tool-result-block', summaryBody, txt);
|
|
}
|
|
}
|
|
// Pretty-render claude's background-task subagent events
|
|
// (`task_started`, `task_notification`). They share the same
|
|
// task_id so the operator can correlate start ↔ result; render
|
|
// each as a peer of tool_use / tool_result with a `⌁` glyph to
|
|
// mark "this happened in a subagent" rather than the main
|
|
// session.
|
|
function renderTaskEvent(v, api) {
|
|
const id = (v.task_id || '').slice(0, 8);
|
|
const kind = v.task_type ? ` [${v.task_type}]` : '';
|
|
const desc = v.description || v.summary || '(no description)';
|
|
if (v.subtype === 'task_started') {
|
|
api.row('tool-use', `⌁ task ${id} started · ${desc}${kind}`);
|
|
return true;
|
|
}
|
|
if (v.subtype === 'task_notification') {
|
|
const status = v.status || 'unknown';
|
|
const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌';
|
|
const cls = status === 'completed' ? 'turn-end-ok'
|
|
: status === 'failed' ? 'turn-end-fail'
|
|
: 'tool-result';
|
|
const out = v.output_file ? ` · → ${v.output_file}` : '';
|
|
api.row(cls, `⌁ task ${id} ${glyph} ${status} · ${desc}${out}`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function renderStream(v, api) {
|
|
// Drop session init, claude's result line, rate-limit — noise.
|
|
// TurnEnd communicates pass/fail; session init isn't actionable.
|
|
if (v.type === 'system' && v.subtype === 'init') return;
|
|
if (v.type === 'rate_limit_event') return;
|
|
if (v.type === 'result') return;
|
|
// Background-task subagent events (claude's `Task` tool spawns
|
|
// a separate session whose progress lands here as `task_*`
|
|
// subtypes). Match by subtype so we don't have to track which
|
|
// top-level `type` claude wraps them under across versions.
|
|
if (v.subtype === 'task_started' || v.subtype === 'task_notification') {
|
|
if (renderTaskEvent(v, api)) return;
|
|
}
|
|
if (v.type === 'assistant' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'text' && c.text && c.text.trim()) {
|
|
// Assistant prose renders with markdown — claude often
|
|
// emits bullets / fenced code / inline code; raw text
|
|
// loses the structure.
|
|
const row = api.row('text', '');
|
|
row.appendChild(mdNode(c.text));
|
|
}
|
|
else if (c.type === 'thinking') {
|
|
const txt = (c.thinking || c.text || '').trim();
|
|
api.row('thinking', txt ? '· ' + txt : '· thinking …');
|
|
}
|
|
else if (c.type === 'tool_use') {
|
|
if (c.id && c.name) toolNameById.set(c.id, c.name);
|
|
if (!renderRichToolUse(c, api)) {
|
|
api.row('tool-use', '→ ' + fmtToolUse(c));
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (v.type === 'user' && v.message && v.message.content) {
|
|
for (const c of v.message.content) {
|
|
if (c.type === 'tool_result') renderToolResult(c, api);
|
|
}
|
|
return;
|
|
}
|
|
// Catch-all for unrecognised stream-json shapes. Loud (orange) so
|
|
// silently-dropped event types surface in the scrollback for
|
|
// follow-up classification.
|
|
api.row('sys', '! ' + trim(JSON.stringify(v), 200));
|
|
}
|
|
|
|
// Count open turns across the backfill replay so the live banner +
|
|
// state badge reflect whatever the history last left running. With
|
|
// shared HiveTerminal this is computed inside each renderer instead
|
|
// of in a second walk over the events list.
|
|
let openTurnsFromHistory = 0;
|
|
|
|
const term = HiveTerminal.create({
|
|
logEl: log,
|
|
historyUrl: '/events/history',
|
|
streamUrl: '/events/stream',
|
|
renderers: {
|
|
turn_start(ev, api) {
|
|
if (api.fromHistory) openTurnsFromHistory += 1;
|
|
else { setBannerActive(true); setState('thinking'); }
|
|
const block = api.row('turn-start', '◆ TURN ← ' + ev.from);
|
|
if (ev.unread > 0) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'unread-badge';
|
|
badge.textContent = '· ' + ev.unread + ' unread';
|
|
block.appendChild(badge);
|
|
}
|
|
const body = document.createElement('div');
|
|
body.className = 'turn-body';
|
|
body.textContent = ev.body;
|
|
block.appendChild(body);
|
|
},
|
|
turn_end(ev, api) {
|
|
if (api.fromHistory) {
|
|
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
|
} else {
|
|
setBannerActive(false); setState('idle');
|
|
// Likely answered/asked/scheduled something — refresh.
|
|
refreshLooseEnds();
|
|
}
|
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
|
api.row(cls,
|
|
(ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail')
|
|
+ (ev.note ? ' — ' + ev.note : ''));
|
|
},
|
|
note(ev, api) {
|
|
const t = String(ev.text || '');
|
|
// stderr lines coming off the claude pump get an orange `!`
|
|
// glyph so they're not visually fused with ambient harness
|
|
// chatter. Operator-initiated notes (/cancel, /compact,
|
|
// /model, new-session) get a mauve italic affordance so the
|
|
// scrollback distinguishes "the operator did this" from
|
|
// "the harness did this on its own."
|
|
if (t.startsWith('stderr:')) {
|
|
api.row('note stderr', '! ' + t);
|
|
} else if (t.startsWith('operator:')) {
|
|
api.row('note op', '· ' + t);
|
|
} else {
|
|
api.row('note', '· ' + t);
|
|
}
|
|
},
|
|
stream(ev, api) {
|
|
const v = Object.assign({}, ev); delete v.kind;
|
|
renderStream(v, api);
|
|
},
|
|
// Bus-driven state/badges. `status_changed` may also need a
|
|
// /api/state refresh to render the login `#status` block
|
|
// (which carries the OAuth URL + form), so we kick the
|
|
// existing refresh path on that transition. Online → only
|
|
// the badge updates; no /api/state fetch needed.
|
|
status_changed(ev, api) {
|
|
if (api.fromHistory) return;
|
|
renderAliveBadge(ev.status);
|
|
renderTermInput(currentLabel, ev.status === 'online');
|
|
// Login-flow transitions need the #status block rebuilt
|
|
// (it carries the OAuth URL + form). The existing
|
|
// refreshState path also re-arms the in-progress poll for
|
|
// session output streaming. Online → only the badge moves;
|
|
// no /api/state fetch is necessary.
|
|
if (ev.status !== 'online' && ev.status !== lastStatus) {
|
|
refreshState();
|
|
} else if (ev.status === 'online' && lastStatus !== 'online') {
|
|
// Status block stays as-is or shows the previous
|
|
// login UI; clear it so the operator sees a clean
|
|
// online state without a separate refetch.
|
|
const root = $('status');
|
|
if (root) root.innerHTML = '';
|
|
lastStatus = 'online';
|
|
}
|
|
},
|
|
model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); },
|
|
token_usage_changed(ev, api) {
|
|
if (!api.fromHistory) renderTokenUsage({ ctx: ev.ctx, cost: ev.cost });
|
|
},
|
|
turn_state_changed(ev, api) {
|
|
if (!api.fromHistory) setStateAbs(ev.state, ev.since_unix);
|
|
},
|
|
},
|
|
onBackfillDone() {
|
|
// If the last replayed turn never closed, the banner shimmer +
|
|
// thinking badge should be on. Apply in one pass after replay.
|
|
for (let i = 0; i < openTurnsFromHistory; i++) setBannerActive(true);
|
|
if (openTurnsFromHistory > 0) setState('thinking');
|
|
},
|
|
});
|
|
|
|
// Expose the panel API for slash commands (`/help`, `/clear`).
|
|
termAPI = {
|
|
row: (cls, text) => term.row(cls, text),
|
|
clear: () => { log.innerHTML = ''; },
|
|
};
|
|
})();
|
|
|
|
// Avoid unused-var lint while keeping `escText` available for future use.
|
|
void escText;
|
|
})();
|