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.
This commit is contained in:
müde 2026-05-17 22:08:15 +02:00
parent a15fafb5de
commit cb71a07300
4 changed files with 249 additions and 18 deletions

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
@ -721,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': '',
@ -814,14 +889,18 @@
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' }, `${q.answerer || '?'}: `),
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);
@ -854,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);
@ -1288,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,

View file

@ -450,6 +450,41 @@ 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 {