Compare commits

...
Author SHA1 Message Date
müde
cb71a07300 dashboard: clickable file-path previews
agents constantly emit pointer strings to /agents/<n>/state/foo.md
since broker bodies cap at 1 KiB. now those tokens linkify in the
message flow, question bodies, answer text, and operator inbox;
clicking expands an inline <details> that lazy-fetches via the
new /api/state-file?path=... endpoint.

endpoint allow-list: per-agent state dirs + shared docs, both
in their container-mount form (/agents/<n>/state, /shared) and
host form (/var/lib/hyperhive/...). 1 MiB read cap; canonicalises
before the prefix check so `..` / symlinks can't escape.

legacy bare `/state/...` is deliberately not matched — ambiguous
from the host's perspective (we'd need to know which agent the
message references to translate). agents should use the qualified
form going forward.
2026-05-17 22:08:15 +02:00
müde
a15fafb5de dashboard: surface peer questions + operator override
questions pane now shows both operator-targeted threads
(target IS NULL) and agent-to-agent threads (target = some
agent). filter chips above the list: all / @operator / @peer /
per-participant. peer rows get a mauve left rule + a 0V3RR1D3
button that POSTs the same /answer-question endpoint
(OperatorQuestions::answer already permits the operator as
answerer on any target).

wire changes: OperatorQuestions gains pending_all +
recent_answered_all; QuestionAdded + QuestionResolved events
carry target: Option<String>; emit sites drop their
target.is_none() guard. answered-history rows show the
answerer prefix so override answers are auditable at a glance.
2026-05-17 22:06:53 +02:00
müde
e7ce35c503 phase 6: container events + drop the 5s /api/state poll
new DashboardEvent::ContainerStateChanged + ContainerRemoved
close the last refetch loop on the dashboard. Coordinator's
rescan_containers_and_emit diffs a fresh container_view::build_all
against a cached last_containers map and fires per-row events.
called from actions::approve (post-spawn), actions::destroy,
the lifecycle_action wrapper, auto_update::rebuild_agent, and
the existing 10s crash_watch poll.

ContainerView extracted to its own module so coordinator and
dashboard can both build it. dashboard endpoints flip to 200;
container-lifecycle forms carry data-no-refresh. client drops
the periodic poll entirely — initial cold load + SSE for
everything afterwards. pending overlay reads from the existing
transientsState since the new event payload doesn't carry it.

PURG3 + meta-update keep the post-submit refetch since
tombstones + meta_inputs aren't event-derived yet; tracked in
TODO.md.
2026-05-17 22:01:15 +02:00
15 changed files with 831 additions and 283 deletions

View file

@ -193,6 +193,26 @@ Prune freely.
domain tooling — the agent flake's `inputs` block pulls
the external flake, `agent.nix` references it via
`flakeInputs.<name>.packages.${pkgs.system}.default`.
- **Just landed:** Phase 6 container events. New
`DashboardEvent::ContainerStateChanged { container }` +
`ContainerRemoved { name }` close the last refetch loop on the
dashboard side. `Coordinator::rescan_containers_and_emit` builds a
fresh `container_view::build_all` snapshot, diffs it against a
cached `last_containers` map, and fires per-row events for the
delta. Called from every mutation site: `actions::approve`
(post-spawn), `actions::destroy`, the `lifecycle_action` wrapper
in `dashboard.rs` (start/stop/restart/rebuild), `auto_update::
rebuild_agent`, and the existing 10s `crash_watch` poll loop.
`ContainerView` extracted to its own module so coordinator +
dashboard can both build it. Dashboard endpoints (`/restart`,
`/destroy`, `/kill`, `/rebuild`, `/start`, `/update-all`,
`/meta-update`, `/purge-tombstone`) now return 200; matching
forms carry `data-no-refresh` where the event coverage is
complete (purge + meta-update keep the refetch since tombstones
+ meta_inputs aren't event-derived yet). Client drops the 5s
periodic `/api/state` poll entirely — initial cold load + SSE
for everything afterwards; pending overlay reads from
`transientsState` since the new event payload doesn't carry it.
- **Just landed:** dashboard event refactor. New `hive-fr0nt`
workspace crate hosts shared frontend assets (palette + terminal
CSS + `window.HiveTerminal.create` JS) so both the dashboard and

16
TODO.md
View file

@ -21,13 +21,23 @@
## Dashboard
- **UI for agent-to-agent questions** (follow-up to the `ask` rename): now that agents can `ask(to: <agent>)` each other, surface those threads in the per-agent dashboard view. Replace the existing read/unread tabs with THREE filters: `unread`, `from: <agent>`, `to: <agent>`. The `to:` filter makes agent-targeted questions visible so the operator can see at a glance "alice has 3 questions outstanding from bob" and intervene if a thread is stuck. Same UI is useful for general inbox filtering too. Data lives in the existing `operator_questions` table (with the new `target` column) + the broker inbox; no new schema needed. Also expose a "respond" affordance so the operator can override-answer a peer question when an agent is offline / stuck (the answerer-auth check in `OperatorQuestions::answer` already permits the operator on any target).
- **Clickable file paths in message bodies**: agents drop pointer strings like `/agents/<name>/state/foo.md` constantly (it's the whole 1 KiB-cap escape hatch). Right now they're plain text — operator has to copy-paste into a terminal to peek. Detect path-shaped tokens (start with `/agents/`, `/shared/`, `/state/`, or absolute `/var/lib/hyperhive/...`) in rendered message bodies + question text + answer text + helper-event payloads, render as clickable links that hit a new `/api/state-file?path=…` dashboard endpoint. Endpoint serves the file as text (with a strict allow-list — only paths under `/var/lib/hyperhive/agents/*/state/`, `/var/lib/hyperhive/shared/`, never anything else), syntax-highlighting where it makes sense, falling back to download for binaries. Reuses the existing `<details>` collapse pattern so inline preview doesn't blow up the message-flow stream.
<!-- Landed: dashboard questions pane now shows both operator-targeted
and peer (agent-to-agent) threads, with filter chips (all /
@operator / @peer / per-participant) and an 0V3RR1D3 button on
peer rows so the operator can answer when an agent is stuck. -->
<!-- Landed: PATH_RE-detected pointer strings in message + question +
answer + inbox bodies linkify to inline <details> previews
fetched from /api/state-file. Allow-list: `/agents/<n>/state/...`
+ `/var/lib/hyperhive/agents/<n>/state/...` + `/shared/...` +
`/var/lib/hyperhive/shared/...`. Legacy bare `/state/...` is
intentionally NOT matched (ambiguous from host's perspective);
prefer `/agents/<n>/state/...` in agent outputs. -->
- **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel
- Per-agent reminder status (pending, delivered)
- Reminder query interface for debugging
- Display reminder delivery errors (failed sends, mark failures)
- **Phase 6 leftovers** — event-covered endpoints (`/approve`, `/deny`, `/answer-question`, `/cancel-question`, `/request-spawn`) now return 200 (f559441); the matching forms carry `data-no-refresh` so the post-submit `/api/state` refetch is skipped (the SSE event delivers the update). Container-lifecycle endpoints (`/restart`, `/destroy`, `/kill`, `/rebuild`, `/start`, `/api/{cancel,compact,model,new-session}`, `/meta-update`, `/purge-tombstone`) still need a `ContainerListChanged` event before their redirects can drop — `ContainerView` is currently sourced from external `nixos-container list`, so the 5s poll continues to drive that section.
- **Phase 6 follow-ups** — dashboard side is fully event-driven (Phase 6 leftovers landed); the per-agent web UI's lifecycle endpoints (`/api/{cancel,compact,model,new-session}`, `/login/*`) still 303-redirect-and-poll. Convert them to 200 + `data-no-refresh` so the per-agent page stops refetching `/api/state` on every operator click — `LiveEvent::Note` already covers cancel/compact/model/new-session, login state needs its own `NeedsLogin` / `LoggedIn` events on the per-agent bus.
- **Tombstones + meta_inputs events**: not yet event-derived. PURG3 + meta-update still trigger a post-submit `/api/state` refetch on the dashboard. Add `TombstoneAdded`/`TombstoneRemoved` + `MetaInputsChanged` so those forms can drop their refetch too and the cold-load is the only `/api/state` fetch in normal operation.
## Bugs

View file

@ -38,6 +38,78 @@
return f;
};
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is
// deliberately not matched — it's ambiguous from the host's
// perspective (we'd need to know which agent the message is about
// to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve.
const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g;
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
return text;
}
function makePathPreview(path) {
// Inline anchor + a sibling <details> that lazy-loads the file
// on first open. Caller appends both: the anchor inline with the
// surrounding text, the details as a block sibling after the
// line so the layout doesn't get awkward.
const anchor = el('a', {
href: '#', class: 'path-link', title: 'click to preview ' + path,
}, path);
const details = el('details', { class: 'path-preview' });
const summary = el('summary', {}, '↳ ' + path);
const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)');
details.append(summary, pre);
let fetched = false;
async function doFetch() {
if (fetched) return;
fetched = true;
try {
pre.textContent = await fetchStateFile(path);
} catch (e) {
pre.textContent = 'error: ' + (e.message || e);
fetched = false; // allow retry on next open
}
}
details.addEventListener('toggle', () => { if (details.open) doFetch(); });
anchor.addEventListener('click', (e) => {
e.preventDefault();
details.open = !details.open;
});
return { anchor, details };
}
// Append `text` to `parent` as a mix of text nodes + path anchors.
// Returns the array of generated `<details>` previews so the
// caller can append them as block siblings under the row.
function appendLinkified(parent, text) {
const previews = [];
if (text == null) return previews;
const str = String(text);
let lastIdx = 0;
PATH_RE.lastIndex = 0;
let m;
while ((m = PATH_RE.exec(str)) !== null) {
if (m.index > lastIdx) {
parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index)));
}
const { anchor, details } = makePathPreview(m[0]);
parent.appendChild(anchor);
previews.push(details);
lastIdx = m.index + m[0].length;
}
if (lastIdx < str.length) {
parent.appendChild(document.createTextNode(str.slice(lastIdx)));
}
return previews;
}
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue
@ -149,7 +221,9 @@
for (const q of questions) {
if (seenQuestions.has(q.id)) continue;
seenQuestions.add(q.id);
NOTIF.show('◆ manager asks', q.question.slice(0, 120),
const targetLabel = q.target || 'operator';
NOTIF.show(`${q.asker}${targetLabel} asks`,
q.question.slice(0, 120),
'hyperhive:question:' + q.id);
}
}
@ -214,6 +288,27 @@
}
});
// Derived container state — cold-loaded from /api/state.containers,
// then mutated live by `container_state_changed` (upsert by name)
// and `container_removed` (drop by name). The coordinator's rescan
// helper fires these after every mutation site + on a periodic poll
// in crash_watch. Keyed by ContainerView.name so the lifecycle
// forms' POST → 200 → matching event flips the row without a
// snapshot refetch.
const containersState = new Map();
function syncContainersFromSnapshot(s) {
containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c);
}
function applyContainerStateChanged(ev) {
if (!ev.container || !ev.container.name) return;
containersState.set(ev.container.name, ev.container);
renderContainersFromState();
}
function applyContainerRemoved(ev) {
if (containersState.delete(ev.name)) renderContainersFromState();
}
// Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
@ -251,27 +346,56 @@
if (s) renderContainers(s);
}
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
function derivePortConflicts(containers) {
const byPort = new Map();
for (const c of containers) {
if (!byPort.has(c.port)) byPort.set(c.port, []);
byPort.get(c.port).push(c.name);
}
const out = [];
for (const [port, agents] of byPort) {
if (agents.length > 1) {
agents.sort();
out.push({ port, agents });
}
}
out.sort((a, b) => a.port - b.port);
return out;
}
// ─── state rendering ────────────────────────────────────────────────────
function renderContainers(s) {
const root = $('containers-section');
root.innerHTML = '';
// Containers come from the derived map (event-driven) rather than
// `s.containers`; `s` still supplies hostname (for the web-ui
// link) and tombstones/meta_inputs (not event-derived yet).
const containers = Array.from(containersState.values())
.sort((a, b) => a.name.localeCompare(b.name));
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (s.port_conflicts && s.port_conflicts.length) {
if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = s.port_conflicts.map((c) =>
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (s.any_stale) {
if (anyStale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
}
@ -290,15 +414,20 @@
root.append(ul);
}
if (!s.containers.length && !transientsState.size) {
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
const hostname = (s && s.hostname) || window.location.hostname;
const ul = el('ul', { class: 'containers' });
for (const c of s.containers) {
const url = `http://${s.hostname}:${c.port}/`;
const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') });
for (const c of containers) {
const url = `http://${hostname}:${c.port}/`;
// Pending state is overlaid from the transient store, not from
// the container row — `ContainerStateChanged` doesn't carry it,
// `TransientSet` / `TransientCleared` do.
const pending = transientsState.get(c.name)?.kind || null;
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
// ── line 1: identity ─────────────────────────────────────────
const head = el('div', { class: 'head' });
@ -307,9 +436,9 @@
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
c.is_manager ? 'm1nd' : 'ag3nt'),
);
if (c.pending) {
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…'));
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} else if (c.needs_login) {
head.append(el('a',
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
@ -319,6 +448,7 @@
head.append(form(
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true },
));
}
head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
@ -333,29 +463,37 @@
const actions = el('div', { class: 'actions' });
if (c.running) {
actions.append(
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'),
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?', {}, { noRefresh: true }),
);
if (!c.is_manager) {
actions.append(
form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'),
form('/kill/' + c.name, 'btn-stop', '■ ST0P',
'stop ' + c.name + '?', {}, { noRefresh: true }),
);
}
} else {
actions.append(
form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'),
form('/start/' + c.name, 'btn-start', '▶ ST4RT',
'start ' + c.name + '?', {}, { noRefresh: true }),
);
}
actions.append(
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.'),
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true }),
);
if (!c.is_manager) {
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
// wipes tombstone state which isn't event-derived yet, so it
// keeps the post-submit refetch.
actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.'),
'destroy ' + c.name + '? container is removed; state + creds kept.',
{}, { noRefresh: true }),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }),
+ 'and notes are all WIPED. no undo.', { purge: 'on' }),
);
}
li.append(actions);
@ -546,6 +684,7 @@
multi: !!ev.multi,
asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null,
target: ev.target || null,
});
renderQuestions();
}
@ -563,26 +702,84 @@
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
renderQuestions();
}
// Filter selection for the questions section. Persisted so the
// operator's preferred view (all / operator-targeted / peer)
// survives a reload.
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
function getQuestionsFilter() {
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
}
function setQuestionsFilter(v) {
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
renderQuestions();
}
function questionMatchesFilter(q, filter) {
if (filter === 'all') return true;
if (filter === 'operator') return !q.target;
if (filter === 'peer') return !!q.target;
// `agent:<name>` matches when the agent appears as asker OR target.
if (filter.startsWith('agent:')) {
const name = filter.slice('agent:'.length);
return q.asker === name || q.target === name;
}
return true;
}
function renderQuestions() {
const root = $('questions-section');
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const pending = questionsState.pending;
const allPending = questionsState.pending;
const activeFilter = getQuestionsFilter();
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
// Filter chips. Always include `all` / `operator` / `peer`; add
// per-agent chips for any agent that appears as asker or target
// in the pending list so the operator can isolate a single
// thread without typing.
const participants = new Set();
for (const q of allPending) {
participants.add(q.asker);
if (q.target) participants.add(q.target);
}
const filterRow = el('div', { class: 'questions-filters' });
const mkChip = (value, label) => {
const b = el('button', {
type: 'button',
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
}, label);
b.addEventListener('click', () => setQuestionsFilter(value));
return b;
};
filterRow.append(
mkChip('all', `all · ${allPending.length}`),
mkChip('operator', '@operator'),
mkChip('peer', '@peer'),
);
for (const name of Array.from(participants).sort()) {
filterRow.append(mkChip('agent:' + name, '@' + name));
}
root.append(filterRow);
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'no pending questions'));
root.append(el('p', { class: 'empty' },
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
}
const ul = el('ul', { class: 'questions' });
for (const q of pending) {
const li = el('li', { class: 'question' });
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asks:'),
);
if (q.deadline_at) {
@ -596,7 +793,10 @@
+ Math.floor((remaining % 3600) / 60) + 'm';
head.append(' ', el('span', { class: 'q-ttl' }, txt));
}
li.append(head, el('div', { class: 'q-body' }, q.question));
const qBody = el('div', { class: 'q-body' });
const qPreviews = appendLinkified(qBody, q.question);
li.append(head, qBody);
for (const d of qPreviews) li.appendChild(d);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '',
@ -637,9 +837,19 @@
}, true);
if (hasOptions) f.append(optionGroup);
const buttons = el('div', { class: 'q-buttons' });
// On peer threads the operator's answer is an override —
// mark the button so it's clear what the click does (the
// backend permits it via OperatorQuestions::answer's
// answerer-auth rule).
const answerLabel = q.target
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
buttons.append(
el('button', { type: 'submit', class: 'btn btn-approve' },
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
el('button', {
type: 'submit',
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
}, answerLabel),
);
f.append(
el('div', { class: 'q-free' }, freeText),
@ -648,10 +858,11 @@
li.append(f);
// Separate form so the cancel button doesn't get the answer
// merge-on-submit handler attached to the main form.
const cancelTargetLabel = q.target ? q.target : 'asker';
const cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
'data-confirm': 'cancel this question? manager will see '
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ '"[cancelled]" as the answer.',
});
cancelForm.append(
@ -669,20 +880,27 @@
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
const hul = el('ul', { class: 'questions questions-answered' });
for (const q of hist) {
const li = el('li', { class: 'question question-answered' });
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asked:'),
);
li.append(
head,
el('div', { class: 'q-body' }, q.question),
el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, 'answer: '),
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
),
const histBody = el('div', { class: 'q-body' });
const histBodyPreviews = appendLinkified(histBody, q.question);
const ansText = el('span', { class: 'q-answer-text' });
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)');
const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
ansText,
);
li.append(head, histBody);
for (const d of histBodyPreviews) li.appendChild(d);
li.append(ansLine);
for (const d of histAnsPreviews) li.appendChild(d);
hul.append(li);
}
details.append(hul);
@ -715,12 +933,15 @@
const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) {
const li = el('li');
const body = el('span', { class: 'msg-body' });
const previews = appendLinkified(body, m.body);
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
el('span', { class: 'msg-body' }, m.body),
body,
);
for (const d of previews) li.appendChild(d);
ul.append(li);
}
root.append(ul);
@ -1088,10 +1309,11 @@
// names from here instead of refetching on every keystroke).
window.__hyperhive_state = s;
const openDetails = snapshotOpenDetails();
// Sync transients first so renderContainers below sees the
// current derived map (it reads from `transientsState`, not
// from `s.transients`).
// Sync transients + containers first so renderContainers below
// sees the current derived maps (it reads from
// `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
renderContainers(s);
renderTombstones(s);
// Sync the derived approvals + questions stores from the
@ -1106,18 +1328,20 @@
renderMetaInputs(s);
restoreOpenDetails(openDetails);
notifyDeltas(s);
// Auto-refresh: fast (2s) while a spawn or a per-container
// action is in flight, otherwise heartbeat (5s) so newly-queued
// approvals from the manager show up without the operator
// having to reload the page. Broker SSE already triggers a
// refresh on operator-bound messages; this catches the rest
// (approvals, tombstones, questions).
const anyPending = s.containers.some((c) => c.pending);
const next = (transientsState.size || anyPending) ? 2000 : 5000;
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
// approvals + questions + transients have their own events;
// broker traffic flows through the SSE channel. The only
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates).
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (next) pollTimer = setTimeout(refreshState, next);
} catch (err) {
console.error('refreshState failed', err);
// Schedule a single retry on transient errors so the page
// recovers from a brief network blip without making the
// operator reload.
pollTimer = setTimeout(refreshState, 5000);
}
}
@ -1146,14 +1370,24 @@
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
}
function renderMsg(ev, api, glyph) {
const el = api.row('msgrow ' + ev.kind, '');
el.innerHTML =
'<span class="msg-ts">' + tsFmt(ev.at) + '</span>' +
'<span class="msg-arrow">' + glyph + '</span>' +
'<span class="msg-from">' + esc(ev.from) + '</span>' +
'<span class="msg-sep">→</span>' +
'<span class="msg-to">' + esc(ev.to) + '</span>' +
'<span class="msg-body">' + esc(ev.body) + '</span>';
const row = api.row('msgrow ' + ev.kind, '');
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement('span');
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
const arrow = document.createElement('span');
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
const from = document.createElement('span');
from.className = 'msg-from'; from.textContent = ev.from;
const sep = document.createElement('span');
sep.className = 'msg-sep'; sep.textContent = '→';
const to = document.createElement('span');
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
const previews = appendLinkified(body, ev.body);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
for (const d of previews) row.appendChild(d);
}
HiveTerminal.create({
logEl: flow,
@ -1171,6 +1405,8 @@
question_resolved: (ev) => { applyQuestionResolved(ev); },
transient_set: (ev) => { applyTransientSet(ev); },
transient_cleared: (ev) => { applyTransientCleared(ev); },
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
container_removed: (ev) => { applyContainerRemoved(ev); },
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and
@ -1208,15 +1444,14 @@
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
}
function knownAgents() {
const s = window.__hyperhive_state;
if (!s || !Array.isArray(s.containers)) return [];
// The broker uses the literal recipient `manager` for the
// manager's inbox, not the container name `hm1nd`. Swap on
// suggestion so `@manager` Just Works.
const names = s.containers.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out the message to every registered agent (server-side
// broadcast_send). Surface it as a suggestion so operators can
// type `@*` from the dashboard the same way the manager does.
// Read live from the derived containers map so newly-spawned
// agents become addressable without an /api/state refetch.
// Broker uses the literal recipient `manager` for the manager's
// inbox, not the container name `hm1nd`.
const names = Array.from(containersState.values())
.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
return names;
}

View file

@ -450,6 +450,75 @@ summary:hover { color: var(--purple); }
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
}
/* Path linkification agents drop pointer strings into messages
constantly; clicking the anchor expands a sibling <details> that
lazy-loads from /api/state-file. */
.path-link {
color: var(--blue, #89b4fa);
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover { color: var(--amber); }
.path-preview {
margin: 0.2em 0 0.4em 1.5em;
border-left: 2px solid var(--border);
padding-left: 0.6em;
}
.path-preview > summary {
cursor: pointer;
color: var(--muted);
font-size: 0.85em;
list-style: none;
user-select: none;
}
.path-preview > summary::marker { content: ''; }
.path-preview-body {
background: var(--bg);
border: 1px solid var(--border);
padding: 0.5em 0.7em;
margin: 0.3em 0 0;
max-height: 30em;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
color: var(--fg);
}
/* Filter chip row above the questions list. The active chip lights
up amber to match the rest of the dashboard's selection accents. */
.questions-filters {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
margin-bottom: 0.5em;
}
.q-filter-chip {
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.15em 0.7em;
font: inherit;
font-size: 0.85em;
cursor: pointer;
}
.q-filter-chip:hover { color: var(--fg); }
.q-filter-chip.active {
color: var(--amber);
border-color: var(--amber);
}
/* Peer (agent-to-agent) question rows get a left rule + dim
target-name styling so they read distinctly from operator-bound
threads at a glance. */
.questions li.question-peer {
border-left: 2px solid var(--mauve, #cba6f7);
padding-left: 0.6em;
}
.questions .msg-to-peer { color: var(--mauve, #cba6f7); }
/* The override button on peer threads picks up a non-default colour
so the operator notices they're answering on someone's behalf. */
.btn-override { background: var(--mauve, #cba6f7) !important; color: var(--bg) !important; }
.questions li.question {
padding: 0.4em 0;
border-bottom: 1px solid var(--border);

View file

@ -85,6 +85,10 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) {
tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed");
}
// New container row appeared (or didn't, on failure
// before nixos-container create completed) — rescan so
// dashboards reflect the post-spawn state.
coord_bg.rescan_containers_and_emit().await;
});
Ok(())
}
@ -355,6 +359,9 @@ pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Resul
coord.notify_manager(&HelperEvent::Destroyed {
agent: name.to_owned(),
});
// Container row disappeared — rescan so the dashboard fires
// `ContainerRemoved` for the gone row.
coord.rescan_containers_and_emit().await;
Ok(())
}

View file

@ -95,6 +95,10 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
// dashboard's meta-input update path — all of which
// route through rebuild_agent.
coord.kick_agent(name, "container rebuilt");
// Container state (needs_update, deployed_sha) may have
// shifted — rescan so dashboards drop the "needs update"
// chip without waiting for the next /api/state poll.
coord.rescan_containers_and_emit().await;
}
Err(e) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
@ -104,6 +108,7 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
sha: None,
tag: None,
});
coord.rescan_containers_and_emit().await;
}
}
result

View file

@ -0,0 +1,121 @@
//! `ContainerView` + the snapshot builder that turns
//! `nixos-container list` (plus per-agent state on disk) into the row
//! shape the dashboard renders. Extracted from `dashboard.rs` so the
//! coordinator's rescan-and-emit helper can build the same view and
//! diff against the last snapshot to fire
//! `ContainerStateChanged` / `ContainerRemoved` events.
use std::collections::HashMap;
use std::path::Path;
use serde::Serialize;
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct ContainerView {
/// Logical agent name (no `h-` prefix). Used in action URLs.
pub name: String,
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
pub container: String,
pub is_manager: bool,
pub port: u16,
pub running: bool,
pub needs_update: bool,
pub needs_login: bool,
/// First 12 chars of the sha the meta flake currently has locked
/// for this agent's input.
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_sha: Option<String>,
}
/// Build the full container list. Wraps `lifecycle::list()` and
/// resolves every per-agent attribute the dashboard surfaces.
pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
let raw = lifecycle::list().await.unwrap_or_default();
let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake);
let locked = read_meta_locked_revs();
let mut out = Vec::new();
for c in &raw {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
(n.to_owned(), false)
} else {
continue;
};
let needs_update =
current_rev.as_deref().is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
let needs_login =
!is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
let deployed_sha = locked
.get(&format!("agent-{logical}"))
.map(|s| s[..s.len().min(12)].to_owned());
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
container: c.clone(),
name: logical,
is_manager,
needs_update,
needs_login,
deployed_sha,
});
}
out
}
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true
/// if the agent's bound `~/.claude/` dir on disk contains any regular
/// file. Reads each `build_all()` so a login driven from the agent's
/// own web UI reflects on the next snapshot.
pub fn claude_has_session(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
}
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
/// render the `deployed:<sha12>` chip per container row.
fn read_meta_locked_revs() -> HashMap<String, String> {
let mut out = HashMap::new();
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
return out;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return out;
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return out;
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return out;
};
let Some(root_inputs) = nodes
.get(root_name)
.and_then(|n| n.get("inputs"))
.and_then(|v| v.as_object())
else {
return out;
};
for alias in root_inputs.keys() {
let target_name = match root_inputs.get(alias) {
Some(serde_json::Value::String(s)) => s.clone(),
_ => continue,
};
if let Some(rev) = nodes
.get(&target_name)
.and_then(|n| n.get("locked"))
.and_then(|v| v.get("rev"))
.and_then(|v| v.as_str())
{
out.insert(alias.clone(), rev.to_owned());
}
}
out
}

View file

@ -13,6 +13,7 @@ use tokio::sync::broadcast;
use crate::agent_server::{self, AgentSocket};
use crate::approvals::Approvals;
use crate::broker::Broker;
use crate::container_view::{self, ContainerView};
use crate::dashboard_events::DashboardEvent;
use crate::operator_questions::OperatorQuestions;
@ -64,6 +65,14 @@ pub struct Coordinator {
/// snapshot.
dashboard_events: broadcast::Sender<DashboardEvent>,
event_seq: AtomicU64,
/// Last container snapshot seen by `rescan_containers_and_emit`,
/// keyed by `ContainerView.name`. The rescan diffs a fresh
/// `container_view::build_all` against this map and emits one
/// `ContainerStateChanged` per added/changed row and one
/// `ContainerRemoved` per disappeared row. Async — guarded by a
/// tokio mutex so the rescan can `await` `lifecycle::list` /
/// `is_running` without blocking other coordinator paths.
last_containers: tokio::sync::Mutex<HashMap<String, ContainerView>>,
}
/// Per-agent in-progress state that the dashboard surfaces between approve
@ -142,6 +151,7 @@ impl Coordinator {
transient: Mutex::new(HashMap::new()),
dashboard_events,
event_seq: AtomicU64::new(0),
last_containers: tokio::sync::Mutex::new(HashMap::new()),
})
}
@ -233,11 +243,10 @@ impl Coordinator {
});
}
/// Emit `QuestionAdded` after an operator-targeted question is
/// inserted. Peer-to-peer questions (those with a non-null
/// `target` agent) never fire this — they don't surface on the
/// dashboard at all. Caller is responsible for the
/// `target.is_none()` guard.
/// Emit `QuestionAdded` after a question is inserted. Fires for
/// both operator-targeted (`target = None`) and peer-to-peer
/// (`target = Some(agent)`) threads — the dashboard surfaces
/// both, distinguishing visually + offering operator override.
pub fn emit_question_added(
&self,
id: i64,
@ -246,6 +255,7 @@ impl Coordinator {
options: &[String],
multi: bool,
deadline_at: Option<i64>,
target: Option<&str>,
) {
let asked_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -261,20 +271,22 @@ impl Coordinator {
multi,
asked_at,
deadline_at,
target: target.map(str::to_owned),
});
}
/// Emit `QuestionResolved` when an operator-targeted question
/// transitions to answered (operator answer, peer override,
/// cancel, or ttl watchdog). Caller filters on the original
/// question's `target.is_none()` — peer questions are dashboard-
/// invisible.
/// Emit `QuestionResolved` when a question transitions to
/// answered (operator answer, peer answer, operator override on
/// a peer thread, operator cancel, or ttl watchdog). Both
/// operator-targeted and peer threads fire so the dashboard's
/// derived store can move the row from pending to history.
pub fn emit_question_resolved(
&self,
id: i64,
answer: &str,
answerer: &str,
cancelled: bool,
target: Option<&str>,
) {
let answered_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -288,9 +300,72 @@ impl Coordinator {
answerer: answerer.to_owned(),
answered_at,
cancelled,
target: target.map(str::to_owned),
});
}
/// Rebuild the per-container snapshot, diff it against the last
/// one cached on `self`, and emit one
/// `DashboardEvent::ContainerStateChanged` per added/changed row
/// and one `DashboardEvent::ContainerRemoved` per disappeared row.
/// Call after any mutation that could affect what
/// `nixos-container list` returns or what a row's
/// `running` / `needs_update` / `needs_login` / `deployed_sha`
/// resolves to — lifecycle ops, destroy, approve (post-spawn),
/// rebuild, meta-update, and the crash-watcher's periodic poll.
/// Cheap when nothing changed (one `nixos-container list` + a
/// HashMap diff + zero emits).
pub async fn rescan_containers_and_emit(self: &Arc<Self>) {
let fresh = container_view::build_all(self).await;
let mut last = self.last_containers.lock().await;
let mut changed_or_new = Vec::new();
let mut removed = Vec::new();
// Diff into change vs. add.
for view in &fresh {
match last.get(&view.name) {
Some(prev) if prev == view => {} // unchanged
_ => changed_or_new.push(view.clone()),
}
}
// Anything in `last` but not in `fresh` is gone.
let fresh_names: std::collections::HashSet<&str> =
fresh.iter().map(|c| c.name.as_str()).collect();
for name in last.keys() {
if !fresh_names.contains(name.as_str()) {
removed.push(name.clone());
}
}
// Rebuild the cache from the fresh snapshot.
last.clear();
for c in fresh {
last.insert(c.name.clone(), c);
}
drop(last);
for c in changed_or_new {
self.emit_dashboard_event(DashboardEvent::ContainerStateChanged {
seq: self.next_seq(),
container: c,
});
}
for name in removed {
self.emit_dashboard_event(DashboardEvent::ContainerRemoved {
seq: self.next_seq(),
name,
});
}
}
/// Read-only snapshot of the last cached container view. Used by
/// `/api/state` to cold-load page-open clients without re-running
/// `nixos-container list` themselves; the
/// `rescan_containers_and_emit` calls keep this fresh.
pub async fn containers_snapshot(&self) -> Vec<ContainerView> {
let last = self.last_containers.lock().await;
let mut out: Vec<ContainerView> = last.values().cloned().collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild,
// or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.

View file

@ -16,10 +16,10 @@
//! but polling is simpler and a 10s detection delay is fine.
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use crate::container_view::claude_has_session;
use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
@ -69,6 +69,12 @@ pub fn spawn(coord: Arc<Coordinator>) {
emit_login_transitions(&coord, &prev_logged_in, &current_logged_in, &sub_agents);
emit_update_transitions(&coord, &prev_updated, &current_updated, &sub_agents);
}
// Periodic container rescan — catches state flips that
// happen outside our mutation surface (operator runs
// `nixos-container stop` over ssh, agent logs in via its
// own web UI, etc.) so the dashboard converges within one
// POLL_INTERVAL. Idempotent + cheap when nothing changed.
coord.rescan_containers_and_emit().await;
prev_running = current_running;
prev_logged_in = current_logged_in;
prev_updated = current_updated;
@ -163,14 +169,3 @@ fn emit_update_transitions(
});
}
}
/// Mirrors `dashboard::claude_has_session`. Lives here too so the
/// watcher doesn't depend on dashboard internals.
fn claude_has_session(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
}

View file

@ -14,7 +14,7 @@ use axum::{
extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode},
response::{
Html, IntoResponse, Redirect, Response,
Html, IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
@ -25,8 +25,9 @@ use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
use crate::actions;
use crate::container_view::{ContainerView, claude_has_session};
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
use crate::lifecycle::{self, MANAGER_NAME};
const MANAGER_PORT: u16 = 8000;
@ -53,6 +54,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/cancel-question/{id}", post(post_cancel_question))
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
.route("/api/journal/{name}", get(get_journal))
.route("/api/state-file", get(get_state_file))
.route("/api/agent-config/{name}", get(get_agent_config))
.route("/request-spawn", post(post_request_spawn))
.route("/op-send", post(post_op_send))
@ -200,31 +202,6 @@ struct TombstoneView {
has_creds: bool,
}
#[derive(Serialize)]
#[allow(clippy::struct_excessive_bools)]
struct ContainerView {
/// Logical agent name (no `h-` prefix). Used in action URLs.
name: String,
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
container: String,
is_manager: bool,
port: u16,
running: bool,
needs_update: bool,
needs_login: bool,
/// When a lifecycle action is in flight on this container, the kind
/// (`starting`, `stopping`, etc.) so the JS can render a spinner +
/// disable other buttons.
#[serde(skip_serializing_if = "Option::is_none")]
pending: Option<&'static str>,
/// First 12 chars of the sha the meta flake currently has locked
/// for this agent's input. Reflects what's actually deployed; can
/// differ from `applied/<n>/main` only between
/// `meta::prepare_deploy` and `finalize_deploy` (≤ build duration).
#[serde(skip_serializing_if = "Option::is_none")]
deployed_sha: Option<String>,
}
#[derive(Serialize)]
struct TransientView {
name: String,
@ -303,17 +280,20 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
// to make idempotent, not ours to avoid here.
let seq = state.coord.current_seq();
let raw_containers = log_default("nixos-container list", lifecycle::list().await);
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
// Refresh the coordinator's cached container snapshot before
// reading. Cold-load clients then see whatever the latest rescan
// produced; live clients converge via the matching
// `ContainerStateChanged` / `ContainerRemoved` events the rescan
// emits.
state.coord.rescan_containers_and_emit().await;
let containers = state.coord.containers_snapshot().await;
let any_stale = containers.iter().any(|c| c.needs_update);
let transient_snapshot = state.coord.transient_snapshot();
let pending_approvals = gc_orphans(
&state.coord,
log_default("approvals.pending", state.coord.approvals.pending()),
);
let (containers, any_stale) =
build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await;
let transients = build_transient_views(&raw_containers, &transient_snapshot);
let transients = build_transient_views(&containers, &transient_snapshot);
let approvals = build_approval_views(pending_approvals).await;
let approval_history = log_default(
"approvals.recent_resolved",
@ -328,9 +308,13 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
// operator_inbox used to be served here as a 50-row array; the
// dashboard now derives it client-side from the message stream
// (terminal backfill + live SSE), so the snapshot stops shipping it.
let questions = log_default("questions.pending", state.coord.questions.pending());
let question_history =
log_default("questions.recent_answered", state.coord.questions.recent_answered(20));
// Both operator-targeted and peer threads now surface on the
// dashboard. Client filters by target client-side.
let questions = log_default("questions.pending_all", state.coord.questions.pending_all());
let question_history = log_default(
"questions.recent_answered_all",
state.coord.questions.recent_answered_all(20),
);
axum::Json(StateSnapshot {
seq,
@ -370,96 +354,6 @@ fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
.collect()
}
/// Build `ContainerView`s for every live nixos-container. Returns the
/// list and whether any container is stale (drives the "↻ UPD4TE 4LL"
/// banner).
async fn build_container_views(
raw_containers: &[String],
current_rev: Option<&str>,
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
) -> (Vec<ContainerView>, bool) {
let mut out = Vec::new();
let mut any_stale = false;
let locked = read_meta_locked_revs();
for c in raw_containers {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
(n.to_owned(), false)
} else {
continue;
};
let needs_update =
current_rev.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
if needs_update {
any_stale = true;
}
let needs_login =
!is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
let pending = transient_snapshot
.get(&logical)
.map(|st| transient_label(st.kind));
let deployed_sha = locked
.get(&format!("agent-{logical}"))
.map(|s| s[..s.len().min(12)].to_owned());
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
container: c.clone(),
name: logical,
is_manager,
needs_update,
needs_login,
pending,
deployed_sha,
});
}
(out, any_stale)
}
/// Map of node name → locked sha for nodes the **root** of meta
/// directly depends on (`hyperhive`, `agent-<n>`). Used by the
/// container row to render its `deployed:<sha12>` chip per agent.
/// Distinct from `read_meta_inputs()` which walks deeper for the
/// flake-input update form.
fn read_meta_locked_revs() -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
return out;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return out;
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return out;
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return out;
};
let Some(root_inputs) = nodes
.get(root_name)
.and_then(|n| n.get("inputs"))
.and_then(|v| v.as_object())
else {
return out;
};
for alias in root_inputs.keys() {
let target_name = match root_inputs.get(alias) {
Some(serde_json::Value::String(s)) => s.clone(),
_ => continue,
};
if let Some(rev) = nodes
.get(&target_name)
.and_then(|n| n.get("locked"))
.and_then(|v| v.get("rev"))
.and_then(|v| v.as_str())
{
out.insert(alias.clone(), rev.to_owned());
}
}
out
}
#[derive(Serialize, Clone)]
struct MetaInputView {
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, etc.
@ -577,16 +471,12 @@ fn walk_meta_inputs(
/// (`Spawning`). Lifecycle ops on existing containers surface as
/// `ContainerView.pending` inline; this list only catches pre-creation.
fn build_transient_views(
raw_containers: &[String],
containers: &[ContainerView],
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
) -> Vec<TransientView> {
transient_snapshot
.iter()
.filter(|(name, _)| {
!raw_containers
.iter()
.any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == *name)
})
.filter(|(name, _)| !containers.iter().any(|c| &c.name == *name))
.map(|(name, st)| TransientView {
name: name.clone(),
kind: transient_label(st.kind),
@ -849,14 +739,13 @@ async fn post_answer_question(
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
},
);
if target.is_none() {
state.coord.emit_question_resolved(
id,
answer,
hive_sh4re::OPERATOR_RECIPIENT,
false,
);
}
state.coord.emit_question_resolved(
id,
answer,
hive_sh4re::OPERATOR_RECIPIENT,
false,
target.as_deref(),
);
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
@ -881,14 +770,13 @@ async fn post_cancel_question(
{
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator cancelled question");
if target.is_none() {
state.coord.emit_question_resolved(
id,
SENTINEL,
hive_sh4re::OPERATOR_RECIPIENT,
true,
);
}
state.coord.emit_question_resolved(
id,
SENTINEL,
hive_sh4re::OPERATOR_RECIPIENT,
true,
target.as_deref(),
);
state.coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {
@ -998,6 +886,103 @@ async fn get_agent_config(AxumPath(name): AxumPath<String>) -> Response {
}
}
#[derive(Deserialize)]
struct StateFileQuery {
path: String,
}
/// Bounded-size read of a file under one of two allow-listed
/// roots: `/var/lib/hyperhive/agents/<n>/state/` (per-agent durable
/// notes — the only writable path agents have outside their
/// container) and `/var/lib/hyperhive/shared/` (shared docs). Both
/// path forms are accepted:
/// - canonical host: `/var/lib/hyperhive/agents/alice/state/foo.md`
/// - container view: `/agents/alice/state/foo.md`
/// - shared: `/shared/foo.md`
///
/// `/state/...` on its own is *not* accepted — the in-container
/// mount is ambiguous from the host's perspective (we don't know
/// which agent's `/state` it refers to) and using it would silently
/// resolve to the wrong file.
///
/// Path is canonicalised before the allow-list check so `..`
/// traversal and symlink games can't escape the roots. Files larger
/// than `MAX_BYTES` are truncated with a banner so a runaway log
/// can't OOM the browser.
async fn get_state_file(
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
) -> Response {
const MAX_BYTES: usize = 1 << 20; // 1 MiB
const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
let raw = q.path.trim();
// Translate the container-view forms to host paths so the
// allow-list check has a single canonical shape to match.
let mapped: std::path::PathBuf = if let Some(rest) = raw.strip_prefix("/agents/") {
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}"))
} else if let Some(rest) = raw.strip_prefix("/shared/") {
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}"))
} else if raw.starts_with(AGENTS_ROOT) || raw.starts_with(SHARED_ROOT) {
std::path::PathBuf::from(raw)
} else {
return error_response(&format!("state-file: path not in allow-list: {raw}"));
};
// Canonicalise so `..` / symlinks resolve before the prefix
// check. A failure here means the path doesn't exist on disk
// (or we can't reach it) — surface the underlying error.
let canonical = match std::fs::canonicalize(&mapped) {
Ok(p) => p,
Err(e) => return error_response(&format!("state-file: {}: {e}", mapped.display())),
};
let allowed = canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT);
if !allowed {
return error_response(&format!(
"state-file: resolved path escapes allow-list: {}",
canonical.display()
));
}
// For per-agent paths, also require the second-from-root
// component to be `state` (not `claude` or `config`). Claude
// creds shouldn't leak through this endpoint; config is the
// applied repo (already exposed via /api/agent-config). Reading
// `/var/lib/hyperhive/agents/<n>/state/...` is the intended use.
if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) {
let mut components = rel.components();
let _agent = components.next();
let dir = components.next().and_then(|c| c.as_os_str().to_str());
if dir != Some("state") {
return error_response(&format!(
"state-file: only per-agent state/ is readable here ({} dir not allowed)",
dir.unwrap_or("(root)")
));
}
}
let meta = match std::fs::metadata(&canonical) {
Ok(m) => m,
Err(e) => return error_response(&format!("state-file: stat {}: {e}", canonical.display())),
};
if !meta.is_file() {
return error_response(&format!(
"state-file: {} is not a regular file",
canonical.display()
));
}
let size = meta.len();
let bytes = match std::fs::read(&canonical) {
Ok(b) => b,
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
};
let truncated = bytes.len() > MAX_BYTES;
let body_bytes = if truncated { &bytes[..MAX_BYTES] } else { &bytes[..] };
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
if truncated {
body.push_str(&format!(
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
));
}
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
@ -1034,7 +1019,10 @@ async fn post_purge_tombstone(
.fail_pending_for_agent(&name, "agent state purged");
if errors.is_empty() {
tracing::info!(%name, "tombstone purged");
Redirect::to("/").into_response()
// Tombstones aren't event-derived yet, so the client still
// refetches /api/state to see this one disappear (matching
// form omits `data-no-refresh`).
(StatusCode::OK, "ok").into_response()
} else {
error_response(&format!("purge {name} partial: {}", errors.join(", ")))
}
@ -1086,7 +1074,10 @@ async fn post_meta_update(
tokio::spawn(async move {
run_meta_update(&coord, &inputs_clone).await;
});
Redirect::to("/").into_response()
// Background task — each per-agent rebuild emits its own
// `ContainerStateChanged`; the meta inputs panel still relies on
// /api/state freshness (matching form omits `data-no-refresh`).
(StatusCode::OK, "ok").into_response()
}
/// Background task: run `nix flake update <inputs>` in meta + commit,
@ -1260,7 +1251,13 @@ where
match result {
Ok(()) => {
extra(state, &logical);
Redirect::to("/").into_response()
// Rescan so the running/needs_login/needs_update flip on
// the affected row lands on every dashboard's SSE channel
// without waiting for a snapshot poll. 200 + matching
// `data-no-refresh` on the form skip the post-submit
// /api/state refetch.
state.coord.rescan_containers_and_emit().await;
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")),
}
@ -1336,7 +1333,8 @@ async fn post_update_all(State(state): State<AppState>) -> Response {
}
}
if errors.is_empty() {
Redirect::to("/").into_response()
// Each rebuild_agent rescanned; no extra refetch needed.
(StatusCode::OK, "ok").into_response()
} else {
error_response(&format!(
"update-all partial failure:\n{}",
@ -1380,8 +1378,11 @@ async fn post_destroy(
) -> Response {
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
// `actions::destroy` rescans the container list on success, so the
// `ContainerRemoved` event lands before we return 200. The matching
// form carries `data-no-refresh`.
match actions::destroy(&state.coord, &name, purge).await {
Ok(()) => Redirect::to("/").into_response(),
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
}
}
@ -1429,18 +1430,6 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
.collect()
}
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the
/// agent's bound `~/.claude/` dir on disk contains any regular file. The
/// dashboard reads this each render so logins driven from the agent web UI
/// (Phase 8 step 4) reflect within one auto-refresh cycle.
fn claude_has_session(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
}
/// Multi-file unified diff between the currently-deployed tree and
/// the proposal for this approval. Runs against the applied repo

View file

@ -25,6 +25,8 @@
use serde::Serialize;
use crate::container_view::ContainerView;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum DashboardEvent {
@ -77,11 +79,11 @@ pub enum DashboardEvent {
note: Option<String>,
description: Option<String>,
},
/// An operator-targeted question landed in the queue
/// (`Ask { to: None | Some("operator") }`). Peer-to-peer
/// questions (target = Some(<agent>)) never fire this event —
/// the dashboard only ever shows operator-bound questions, so
/// the emit site filters on `target.is_none()`.
/// A question landed in the queue. `target = None` means
/// operator-targeted (`Ask { to: None | Some("operator") }`);
/// `target = Some(<agent>)` means a peer-to-peer question. Both
/// are surfaced on the dashboard so the operator can monitor /
/// override-answer stuck threads.
QuestionAdded {
seq: u64,
id: i64,
@ -91,12 +93,13 @@ pub enum DashboardEvent {
multi: bool,
asked_at: i64,
deadline_at: Option<i64>,
target: Option<String>,
},
/// An operator-targeted question was answered (operator answer,
/// peer override, or ttl watchdog `[expired]`). Clients move the
/// row from pending to history. `cancelled = true` when the
/// operator dismissed via the cancel button — same code path on
/// the server but useful to surface differently in the UI.
/// A question was answered (operator answer, peer answer,
/// operator override on a peer thread, or ttl watchdog
/// `[expired]`). Clients move the row from pending to history.
/// `cancelled = true` when the operator dismissed via the cancel
/// button.
QuestionResolved {
seq: u64,
id: i64,
@ -104,6 +107,7 @@ pub enum DashboardEvent {
answerer: String,
answered_at: i64,
cancelled: bool,
target: Option<String>,
},
/// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next
@ -121,4 +125,24 @@ pub enum DashboardEvent {
/// The matching lifecycle action resolved (success or failure).
/// Clients drop the spinner row.
TransientCleared { seq: u64, name: String },
/// One container row changed — new container appeared (post-spawn
/// finalise), an existing one flipped running/needs_update/sha,
/// etc. Clients upsert by `container.name`. Payload carries the
/// full row so cold-loaded clients and event-driven clients
/// converge on the same render.
///
/// Fired by `Coordinator::rescan_containers_and_emit`, which diffs
/// a fresh `nixos-container list`derived snapshot against the
/// last one cached on the coordinator. Mutation sites (lifecycle
/// endpoints, actions::destroy / approve, crash_watch's poll loop)
/// call the rescan after their work lands.
ContainerStateChanged {
seq: u64,
container: ContainerView,
},
/// A container that was in the previous snapshot is gone. Clients
/// drop the row by name. Fired alongside any
/// `nixos-container destroy` (operator-driven or otherwise) on the
/// next rescan.
ContainerRemoved { seq: u64, name: String },
}

View file

@ -11,6 +11,7 @@ mod approvals;
mod auto_update;
mod broker;
mod client;
mod container_view;
mod coordinator;
mod crash_watch;
mod dashboard;

View file

@ -454,9 +454,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
answerer: TTL_ANSWERER.to_owned(),
},
);
if target.is_none() {
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false);
}
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref());
}
});
}

View file

@ -209,15 +209,15 @@ impl OperatorQuestions {
.map_err(Into::into)
}
/// Pending operator-targeted questions only (`target IS NULL`).
/// Drives the dashboard's pending-question pane — agent-to-agent
/// questions never appear here so the operator's queue stays clean.
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
/// Every pending question, operator-targeted or peer-to-peer.
/// Drives the dashboard's questions pane now that peer threads
/// are surfaced for visibility + operator override-answer.
pub fn pending_all(&self) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions
WHERE answered_at IS NULL AND target IS NULL
WHERE answered_at IS NULL
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_question)?;
@ -225,15 +225,14 @@ impl OperatorQuestions {
.map_err(Into::into)
}
/// Last `limit` answered operator-targeted questions, newest-first.
/// Same `target IS NULL` filter as `pending()` so the dashboard's
/// history view only shows operator-relevant rows.
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
/// Last `limit` answered questions across both target kinds,
/// newest-first. Companion to `pending_all`.
pub fn recent_answered_all(&self, limit: u64) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions
WHERE answered_at IS NOT NULL AND target IS NULL
WHERE answered_at IS NOT NULL
ORDER BY answered_at DESC
LIMIT ?1",
)?;
@ -241,6 +240,7 @@ impl OperatorQuestions {
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {

View file

@ -86,9 +86,10 @@ pub fn handle_ask(
multi,
},
);
} else {
coord.emit_question_added(id, asker, question, options, multi, deadline_at);
}
// Always fire on the dashboard channel — both operator-targeted
// and peer threads now surface in the dashboard's questions pane.
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
}
@ -120,13 +121,11 @@ pub fn handle_answer(
answerer: answerer.to_owned(),
},
);
// Only operator-targeted questions surface on the dashboard;
// peer-to-peer answers are invisible to it. `cancelled = false`
// because this path is a real answer (operator cancel goes
// through `post_cancel_question` directly).
if target.is_none() {
coord.emit_question_resolved(id, answer, answerer, false);
}
// Dashboard surfaces both operator-targeted and peer threads;
// emit unconditionally so the derived store moves the row.
// `cancelled = false` because this path is a real answer (the
// operator-cancel button goes through `post_cancel_question`).
coord.emit_question_resolved(id, answer, answerer, false, target.as_deref());
Ok(())
}