From cb71a073005cd51e3ee758cd99517900b9b53600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 22:08:15 +0200 Subject: [PATCH] dashboard: clickable file-path previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents constantly emit pointer strings to /agents//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
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//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. --- TODO.md | 8 ++- hive-c0re/assets/app.js | 126 ++++++++++++++++++++++++++++----- hive-c0re/assets/dashboard.css | 35 +++++++++ hive-c0re/src/dashboard.rs | 98 +++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 18 deletions(-) diff --git a/TODO.md b/TODO.md index a0e6b9f..b0554c5 100644 --- a/TODO.md +++ b/TODO.md @@ -25,7 +25,13 @@ 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. --> -- **Clickable file paths in message bodies**: agents drop pointer strings like `/agents//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 `
` collapse pattern so inline preview doesn't blow up the message-flow stream. + - **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 diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 2a82007..b2f370c 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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
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//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
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 `
` 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 = - '' + tsFmt(ev.at) + '' + - '' + glyph + '' + - '' + esc(ev.from) + '' + - '' + - '' + esc(ev.to) + '' + - '' + esc(ev.body) + ''; + 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, diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 51e90b1..d6648cc 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -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
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 { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 8cb87d5..c513e51 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -54,6 +54,7 @@ pub async fn serve(port: u16, coord: Arc) -> 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)) @@ -885,6 +886,103 @@ async fn get_agent_config(AxumPath(name): AxumPath) -> Response { } } +#[derive(Deserialize)] +struct StateFileQuery { + path: String, +} + +/// Bounded-size read of a file under one of two allow-listed +/// roots: `/var/lib/hyperhive/agents//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, +) -> 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//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, AxumPath(name): AxumPath,