agent ui: consolidate status into state-row badges

drop the "● harness alive — turn loop running" paragraph; the
new #alive-badge chip in the state row carries the same signal
across all statuses (loading / online / needs-login / offline)
with colour coding. token-usage chip renamed + restyled as
#ctx-badge — primary number is total context-window tokens
used, mirroring claude code's "N tokens" indicator.

every state-row badge now has hover detail: state-badge gets
per-state tooltips + age suffix, model-chip explains the
/model command, last-turn shows the raw ms duration, ctx-badge
breaks out input / cache_read / cache_write / output.

new todo entry for the per-turn stats sink (start/end/model/
tokens/tool-call-count) the harness should be writing.
This commit is contained in:
müde 2026-05-17 22:36:02 +02:00
parent 85c0df2e64
commit b444dac6e8
4 changed files with 79 additions and 12 deletions

View file

@ -76,6 +76,10 @@ how often the friction bites in normal use.
Field is optional, ignored if the referenced id is unknown / cross- Field is optional, ignored if the referenced id is unknown / cross-
agent / out of retention. agent / out of retention.
## Telemetry
- **Per-turn stats log**: persist one row per claude turn in a new sqlite table on the per-agent state dir (or the host broker DB, indexed by agent). Columns: `started_at`, `ended_at`, `duration_ms`, `model`, `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `tool_call_count`, `tool_call_breakdown` (JSON: `{Read: 12, Bash: 3, ...}`), `bytes_streamed`, `wake_reason` (recv'd message / reminder / operator-kick / manual), `result_kind` (ok / cancelled / failed-mid-turn / compacted), `note` (e.g. failure reason). Powers: per-agent dashboards (avg turn time over time, tool-usage histogram, cost projections from token counts × model rate), debugging stuck loops (look for repeated identical wake_reason + zero tool calls), and operator-visible "this is what your spend looked like this week" rollups. Source data is already mostly in the harness's `TurnState` + the per-event bus; just needs a sink. Keep a retention sweep (host-side) so the table doesn't grow forever.
## Bugs ## Bugs
- **Post-rebuild system-message missed wake**: at 09:13:14 the dashboard showed `system → damocles container rebuilt` as ✓ delivered, but the agent harness never ran a turn for it (no claude invocation, no operator-visible activity). A subsequent `recv()` from inside the agent returned `(empty)`, confirming the message was popped + marked delivered server-side — yet drove no turn. Most likely cause: the agent_server `serve_agent_stdio` task is up and answering MCP/socket calls, but the `hive-ag3nt::serve` long-poll loop that drives `drive_turn` either died silently during rebuild or never restarted. Investigate: (a) does hive-ag3nt's serve loop survive `nixos-container update` cleanly, or does its tokio runtime get torn down mid-loop? (b) is there an early-exit path on a transient socket error during rebuild that drops the serve task without notifying the manager? (c) compare timeline with manager's own post-rebuild wake to see if this is rebuilt-agents-only or universal. Could be related to the `recv_blocking` fix in `e423d57` if the rebuild restarts the broker mid-subscribe. - **Post-rebuild system-message missed wake**: at 09:13:14 the dashboard showed `system → damocles container rebuilt` as ✓ delivered, but the agent harness never ran a turn for it (no claude invocation, no operator-visible activity). A subsequent `recv()` from inside the agent returned `(empty)`, confirming the message was popped + marked delivered server-side — yet drove no turn. Most likely cause: the agent_server `serve_agent_stdio` task is up and answering MCP/socket calls, but the `hive-ag3nt::serve` long-poll loop that drives `drive_turn` either died silently during rebuild or never restarted. Investigate: (a) does hive-ag3nt's serve loop survive `nixos-container update` cleanly, or does its tokio runtime get torn down mid-loop? (b) is there an early-exit path on a transient socket error during rebuild that drops the serve task without notifying the manager? (c) compare timeline with manager's own post-rebuild wake to see if this is rebuilt-agents-only or universal. Could be related to the `recv_blocking` fix in `e423d57` if the rebuild restarts the broker mid-subscribe.

View file

@ -165,12 +165,38 @@ pre.diff {
font-size: 0.78em; font-size: 0.78em;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.token-usage { /* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
color: var(--muted); chip single primary number (total prompt tokens in use), full
font-size: 0.8em; breakdown on hover. Sized/coloured like a peer of model-chip so
the state row reads as one row of chrome. */
.ctx-badge {
display: inline-block;
padding: 0.1em 0.6em;
border: 1px solid var(--purple-dim);
border-radius: 999px;
color: var(--green);
font-size: 0.78em;
letter-spacing: 0.04em; letter-spacing: 0.04em;
cursor: default; cursor: default;
white-space: pre-line;
} }
/* Harness reachability badge. Same chip shape + sizing as
`.state-badge` / `.model-chip` so the state row stays visually
uniform; colour communicates the actual reachability state. */
.status-badge {
display: inline-block;
padding: 0.25em 0.8em;
border: 1px solid;
border-radius: 999px;
font-size: 0.85em;
letter-spacing: 0.05em;
}
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); }
.status-badge.status-online { color: var(--green); border-color: var(--green);
text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
.btn-dashlink { .btn-dashlink {
color: var(--cyan); color: var(--cyan);
border: 1px solid var(--cyan); border: 1px solid var(--cyan);

View file

@ -91,10 +91,10 @@
document.title = `${label} // hyperhive`; document.title = `${label} // hyperhive`;
} }
function renderOnline(_label, root) { function renderOnline(_label, _root) {
root.append( // Online state is conveyed by the `#alive-badge` chip in the
el('p', { class: 'status-online' }, '● harness alive — turn loop running'), // state row — no longer a separate paragraph in the status
); // block (keeps the terminal the star, status row stays compact).
} }
function renderNeedsLoginIdle(root) { function renderNeedsLoginIdle(root) {
@ -348,6 +348,13 @@
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm'; 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() { function renderStateBadge() {
const badge = $('state-badge'); const badge = $('state-badge');
if (!badge) return; if (!badge) return;
@ -355,6 +362,7 @@
const age = fmtAge(Date.now() - stateSince); const age = fmtAge(Date.now() - stateSince);
badge.textContent = def.glyph + ' ' + def.text + ' · ' + age; badge.textContent = def.glyph + ' ' + def.text + ' · ' + age;
badge.className = 'state-badge state-' + stateName; badge.className = 'state-badge state-' + stateName;
badge.title = (STATE_TOOLTIPS[stateName] || '') + '\nin this state for ' + age;
const cancelBtn = $('cancel-btn'); const cancelBtn = $('cancel-btn');
if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking';
} }
@ -405,27 +413,53 @@
list.append(li); 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' },
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) { function renderModelChip(model) {
const el_ = $('model-chip'); const el_ = $('model-chip');
if (!el_) return; if (!el_) return;
if (!model) { el_.hidden = true; return; } if (!model) { el_.hidden = true; return; }
el_.hidden = false; el_.hidden = false;
el_.textContent = 'model · ' + model; el_.textContent = 'model · ' + model;
el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`;
} }
// Context badge — mirrors Claude Code's bottom-right "N tokens"
// indicator. Primary number is total prompt tokens used in the
// current context window (input + both cache axes); hover for the
// breakdown including output. Kept as chrome on the state row so
// the terminal stays the star.
function renderTokenUsage(u) { function renderTokenUsage(u) {
const el_ = $('token-usage'); const el_ = $('ctx-badge');
if (!el_) return; if (!el_) return;
if (!u) { el_.hidden = true; return; } if (!u) { el_.hidden = true; return; }
const ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens; const ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens;
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
el_.hidden = false; el_.hidden = false;
el_.title = [ el_.title = [
'context window in use',
'input: ' + u.input_tokens, 'input: ' + u.input_tokens,
'output: ' + u.output_tokens,
'cache_read: ' + u.cache_read_input_tokens, 'cache_read: ' + u.cache_read_input_tokens,
'cache_write: ' + u.cache_creation_input_tokens, 'cache_write: ' + u.cache_creation_input_tokens,
].join(' · '); 'output (last turn): ' + u.output_tokens,
el_.textContent = '· ctx ' + fmt(ctx) + ' in · ' + fmt(u.output_tokens) + ' out'; ].join('\n');
el_.textContent = 'ctx · ' + fmt(ctx);
} }
function renderLastTurn(ms) { function renderLastTurn(ms) {
const el_ = $('last-turn'); const el_ = $('last-turn');
@ -435,6 +469,7 @@
else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's'; 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'; else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's';
el_.textContent = '· last turn ' + s; el_.textContent = '· last turn ' + s;
el_.title = `wall-clock duration of the last completed claude turn (${ms} ms)`;
el_.hidden = false; el_.hidden = false;
} }
function startStateTicker() { function startStateTicker() {
@ -499,6 +534,7 @@
} else if (s.turn_state) { } else if (s.turn_state) {
setStateAbs(s.turn_state, s.turn_state_since); setStateAbs(s.turn_state, s.turn_state_since);
} }
renderAliveBadge(s.status);
renderModelChip(s.model); renderModelChip(s.model);
renderTokenUsage(s.token_usage); renderTokenUsage(s.token_usage);
// Skip the re-render if nothing structurally changed. The most // Skip the re-render if nothing structurally changed. The most

View file

@ -14,10 +14,11 @@
</div> </div>
<div id="state-row"> <div id="state-row">
<span id="alive-badge" class="status-badge status-loading" title="harness reachability"></span>
<span id="state-badge" class="state-badge state-loading">… booting</span> <span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="model-chip" class="model-chip" hidden></span> <span id="model-chip" class="model-chip" hidden></span>
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
<span id="last-turn" class="last-turn" hidden></span> <span id="last-turn" class="last-turn" hidden></span>
<span id="token-usage" class="token-usage" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button> <button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
<button type="button" id="new-session-btn" class="btn-new-session" <button type="button" id="new-session-btn" class="btn-new-session"
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button> title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>