From aed43ce4df3386ecbc39aff8fcce6b32a0bc7fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:52:12 +0200 Subject: [PATCH 1/4] =?UTF-8?q?dashboard:=20tombstones=20+=20meta=5Finputs?= =?UTF-8?q?=20events=20=E2=80=94=20last=20/api/state=20refetches=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new DashboardEvent::TombstonesChanged + MetaInputsChanged carry full snapshots (lists are tiny; snapshot beats diff for race avoidance). Coordinator-side helpers emit_tombstones_snapshot + emit_meta_inputs_snapshot fire from every mutation site: actions::destroy + post_purge_tombstone + actions::approve (spawn finalise consumes tombstone) + run_meta_update + auto_update::rebuild_agent (lock bumps). client adds derived stores + apply* handlers + drops the post-submit refetch on PURG3 (container row + tombstone row) and meta-update. after this commit /api/state is fetched exactly once per page session (cold load); every other change rides the SSE channel. --- hive-c0re/assets/app.js | 45 +++++++++++++++++++-- hive-c0re/src/actions.rs | 11 ++++- hive-c0re/src/auto_update.rs | 2 + hive-c0re/src/dashboard.rs | 67 ++++++++++++++++++++++--------- hive-c0re/src/dashboard_events.rs | 22 ++++++++++ 5 files changed, 123 insertions(+), 24 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index bd4a3e39..39facb82 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -335,6 +335,32 @@ if (containersState.delete(ev.name)) renderContainersFromState(); } + // Derived tombstones + meta_inputs. Both are emitted as full + // snapshots (not diffs) — the lists are tiny and recomputing + // avoids ordering races between a same-tick destroy + purge. + let tombstonesState = []; + let metaInputsState = []; + function syncTombstonesFromSnapshot(s) { + tombstonesState = (s.tombstones || []).slice(); + } + function syncMetaInputsFromSnapshot(s) { + metaInputsState = (s.meta_inputs || []).slice(); + } + function applyTombstonesChanged(ev) { + tombstonesState = (ev.tombstones || []).slice(); + renderTombstonesFromState(); + } + function applyMetaInputsChanged(ev) { + metaInputsState = (ev.inputs || []).slice(); + renderMetaInputsFromState(); + } + function renderTombstonesFromState() { + renderTombstones({ tombstones: tombstonesState }); + } + function renderMetaInputsFromState() { + renderMetaInputs({ meta_inputs: metaInputsState }); + } + // 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 @@ -512,14 +538,16 @@ 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. + // Both event-covered now (ContainerRemoved + + // TombstonesChanged); no /api/state refetch needed. actions.append( form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', '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 notes are all WIPED. no undo.', { purge: 'on' }), + + 'and notes are all WIPED. no undo.', + { purge: 'on' }, { noRefresh: true }), ); } li.append(actions); @@ -683,8 +711,9 @@ actions.append(respawn); actions.append(form( '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', - 'PURGE ' + t.name + '? config history, claude creds, /state/ notes ' - + 'are all WIPED. no undo.', + 'PURGE ' + t.name + '? config history, claude creds, ' + + 'and notes are all WIPED. no undo.', + {}, { noRefresh: true }, )); li.append(actions); ul.append(li); @@ -1214,6 +1243,10 @@ action: '/meta-update', class: 'meta-inputs-form', 'data-async': '', + // run_meta_update emits MetaInputsChanged once the lock + // bump finishes; per-agent rebuilds fire their own + // ContainerStateChanged. No /api/state refetch needed. + 'data-no-refresh': '', 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?', }); const ul = el('ul', { class: 'meta-inputs' }); @@ -1419,6 +1452,8 @@ // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); + syncTombstonesFromSnapshot(s); + syncMetaInputsFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the @@ -1513,6 +1548,8 @@ transient_cleared: (ev) => { applyTransientCleared(ev); }, container_state_changed: (ev) => { applyContainerStateChanged(ev); }, container_removed: (ev) => { applyContainerRemoved(ev); }, + tombstones_changed: (ev) => { applyTombstonesChanged(ev); }, + meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); }, }, // Both history backfill and live frames flow through here, so the // inbox section ends up populated correctly on first paint and diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 32ce5af1..edc837e8 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -87,8 +87,12 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { } // New container row appeared (or didn't, on failure // before nixos-container create completed) — rescan so - // dashboards reflect the post-spawn state. + // dashboards reflect the post-spawn state. Spawn can + // also consume a tombstone of the same name; emit the + // fresh list so the operator's dormant-state pane + // updates without a refetch. coord_bg.rescan_containers_and_emit().await; + crate::dashboard::emit_tombstones_snapshot(&coord_bg).await; }); Ok(()) } @@ -360,8 +364,11 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul agent: name.to_owned(), }); // Container row disappeared — rescan so the dashboard fires - // `ContainerRemoved` for the gone row. + // `ContainerRemoved` for the gone row, then emit the + // tombstones snapshot (gained one on destroy, lost one on + // purge — recompute either way). coord.rescan_containers_and_emit().await; + crate::dashboard::emit_tombstones_snapshot(coord).await; Ok(()) } diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 4c714d34..36dc72cd 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -99,6 +99,8 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s // shifted — rescan so dashboards drop the "needs update" // chip without waiting for the next /api/state poll. coord.rescan_containers_and_emit().await; + // Lock bump → meta-inputs panel needs to re-render. + crate::dashboard::emit_meta_inputs_snapshot(coord); } Err(e) => { coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c23acb74..6c1d2db5 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -193,15 +193,15 @@ struct PortConflict { agents: Vec, } -#[derive(Serialize)] -struct TombstoneView { - name: String, +#[derive(Serialize, Clone, Debug)] +pub(crate) struct TombstoneView { + pub name: String, /// Bytes used by the state dir tree. Cheap-ish to compute; let the /// operator know how much they're holding onto. - state_bytes: u64, + pub state_bytes: u64, /// Mtime (unix seconds) of the state dir; rough "last seen". - last_seen: i64, - has_creds: bool, + pub last_seen: i64, + pub has_creds: bool, } #[derive(Serialize)] @@ -356,19 +356,19 @@ fn build_port_conflicts(containers: &[ContainerView]) -> Vec { .collect() } -#[derive(Serialize, Clone)] -struct MetaInputView { +#[derive(Serialize, Clone, Debug)] +pub(crate) struct MetaInputView { /// Input key in meta's `flake.nix` — `hyperhive`, `agent-`, etc. - name: String, + pub name: String, /// Full locked sha. Not displayed verbatim; the dashboard /// truncates to the first 12 chars for the chip. - rev: String, + pub rev: String, /// Unix seconds — `locked.lastModified`. Drives the relative /// "2h ago" timestamp on each input row. - last_modified: i64, + pub last_modified: i64, /// `original.url` if available, for the tooltip / row meta text. #[serde(skip_serializing_if = "Option::is_none")] - url: Option, + pub url: Option, } /// Walk `flake.lock`'s `nodes` graph from `root` and emit one @@ -956,6 +956,36 @@ fn resolve_state_path(raw: &str) -> std::result::Result) { + let containers = coord.containers_snapshot().await; + let transient_snapshot = coord.transient_snapshot(); + let tombstones = build_tombstone_views(coord, &containers, &transient_snapshot); + coord.emit_dashboard_event( + crate::dashboard_events::DashboardEvent::TombstonesChanged { + seq: coord.next_seq(), + tombstones, + }, + ); +} + +/// Snapshot meta/flake.lock's root inputs + emit +/// `MetaInputsChanged`. Call after any mutation that bumps a lock +/// (`run_meta_update`, `auto_update::rebuild_agent`). +pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { + let inputs = read_meta_inputs(); + coord.emit_dashboard_event( + crate::dashboard_events::DashboardEvent::MetaInputsChanged { + seq: coord.next_seq(), + inputs, + }, + ); +} + /// Scan `body` for path-shaped tokens, validate each against the /// allow-list, return the unique set of tokens that resolve to a /// regular file. Called at broker-message ingest time so the @@ -1094,9 +1124,10 @@ async fn post_purge_tombstone( .fail_pending_for_agent(&name, "agent state purged"); if errors.is_empty() { tracing::info!(%name, "tombstone purged"); - // Tombstones aren't event-derived yet, so the client still - // refetches /api/state to see this one disappear (matching - // form omits `data-no-refresh`). + // Fire the post-purge tombstones snapshot so dashboards + // drop the row live; matching form carries + // `data-no-refresh`. + emit_tombstones_snapshot(&state.coord).await; (StatusCode::OK, "ok").into_response() } else { error_response(&format!("purge {name} partial: {}", errors.join(", "))) @@ -1148,10 +1179,10 @@ async fn post_meta_update( let inputs_clone = inputs.clone(); tokio::spawn(async move { run_meta_update(&coord, &inputs_clone).await; + // Lock file changed — emit so dashboards refresh the + // meta-inputs panel without a snapshot poll. + emit_meta_inputs_snapshot(&coord); }); - // 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() } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 4e531c26..86f7e941 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -26,6 +26,7 @@ use serde::Serialize; use crate::container_view::ContainerView; +use crate::dashboard::{MetaInputView, TombstoneView}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] @@ -156,4 +157,25 @@ pub enum DashboardEvent { /// `nixos-container destroy` (operator-driven or otherwise) on the /// next rescan. ContainerRemoved { seq: u64, name: String }, + /// Full snapshot of the tombstones list. Emitted on every + /// mutation that could add / remove a tombstone: destroy + /// (with or without purge), purge-tombstone, spawn approval + /// (which can consume a tombstone of the same name). Snapshot + /// shape (not diff) because the list is tiny (single-digit + /// typical) and recomputing avoids the add/remove races a + /// per-row event would have. + TombstonesChanged { + seq: u64, + tombstones: Vec, + }, + /// Full snapshot of `meta/flake.lock`'s root inputs. Emitted + /// after every operation that bumps a lock: `meta-update`, + /// `rebuild_agent` (lock bumps via two-phase staging), + /// `update-all`. Same snapshot-shape rationale as + /// `TombstonesChanged` — the list is small (one row per agent + /// plus their fetched inputs). + MetaInputsChanged { + seq: u64, + inputs: Vec, + }, } From 087a5366fba56321b2736e0e6b0a3a37ee1ab55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:52:56 +0200 Subject: [PATCH 2/4] =?UTF-8?q?container=20row:=20pending-reminder=20count?= =?UTF-8?q?=20chip=20(=E2=8F=B0=20N)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContainerView gains pending_reminders: u64; computed during build_all via Broker::count_pending_reminders_for, mapping manager → MANAGER_AGENT recipient + sub-agents → logical name. Updates on every rescan (mutation sites + crash_watch's 10s poll); accept 10s staleness on background remind / scheduler delivery — live updates on operator cancel via /api/state path. client renders a small cyan chip on the row when the count > 0; tooltip points the operator at the reminders section to view or cancel. --- hive-c0re/assets/app.js | 8 ++++++++ hive-c0re/assets/dashboard.css | 4 ++++ hive-c0re/src/container_view.rs | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 39facb82..ced15d2a 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -509,6 +509,14 @@ { class: 'meta', title: 'sha currently locked in /meta/flake.lock' }, `deployed:${c.deployed_sha}`)); } + if (c.pending_reminders && c.pending_reminders > 0) { + head.append(el('span', + { + class: 'badge badge-reminder', + title: 'pending reminders queued for this agent — see the reminders section to view / cancel', + }, + `⏰ ${c.pending_reminders}`)); + } li.append(head); // ── line 2: action buttons ─────────────────────────────────── diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index b1ffe7f7..46eeed67 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -119,6 +119,10 @@ a:hover { color: var(--muted); border-color: var(--purple-dim); background: rgba(127, 132, 156, 0.08); } +.badge-reminder { + color: var(--cyan); border-color: var(--cyan); + text-shadow: 0 0 6px rgba(137, 220, 235, 0.4); +} .container-row.tombstone { border-style: dashed; background: rgba(24, 24, 37, 0.35); diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 869eea87..a946a88a 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -29,6 +29,13 @@ pub struct ContainerView { /// for this agent's input. #[serde(skip_serializing_if = "Option::is_none")] pub deployed_sha: Option, + /// Count of this agent's pending reminders. Computed during + /// `build_all` via `Broker::count_pending_reminders_for`; the + /// dashboard renders a small chip when > 0. Updates with the + /// 10s `crash_watch` rescan + every container mutation site; + /// not real-time on remind/cancel-reminder but close enough. + #[serde(default)] + pub pending_reminders: u64, } /// Build the full container list. Wraps `lifecycle::list()` and @@ -53,6 +60,20 @@ pub async fn build_all(coord: &Coordinator) -> Vec { let deployed_sha = locked .get(&format!("agent-{logical}")) .map(|s| s[..s.len().min(12)].to_owned()); + // Recipient name the broker uses for this agent — sub-agents + // are addressed by logical name, the manager by the + // MANAGER_AGENT constant. Mirrors the rest of the broker + // surface so the count matches what `mcp__hyperhive__remind` + // queued. + let reminder_recipient = if is_manager { + hive_sh4re::MANAGER_AGENT + } else { + logical.as_str() + }; + let pending_reminders = coord + .broker + .count_pending_reminders_for(reminder_recipient) + .unwrap_or(0); out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -62,6 +83,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { needs_update, needs_login, deployed_sha, + pending_reminders, }); } out From 378e8bf9dfa17b0990f8b0827bfd2567bbfd4381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:53:40 +0200 Subject: [PATCH 3/4] agent ui: open-threads section (questions + approvals pending) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new /api/open-threads endpoint on hive-ag3nt proxies the agent's own GetOpenThreads RPC (manager flavour proxies the hive-wide ManagerRequest::GetOpenThreads). same data the mcp__hyperhive__get_open_threads tool sees from inside claude. frontend renders a collapsible
section above the terminal, listing each pending row (approval / question) with asker → target, age, and free-form body. auto-expands on the first appearance of any open thread; sticky after that. refreshed on cold load + after every turn_end (turns are when threads land or resolve). --- hive-ag3nt/assets/app.js | 75 ++++++++++++++++++++++++++++++++++++ hive-ag3nt/assets/index.html | 5 +++ hive-ag3nt/src/web_ui.rs | 44 +++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index d93f8cf3..34b4ca5f 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -393,6 +393,75 @@ } renderStateBadge(); } + // Open-threads section: same data the get_open_threads MCP tool + // returns. Best-effort fetch on cold load + after every turn_end + // (a turn likely answered or asked something). Silent failure + // keeps the section hidden rather than surfacing an empty banner. + let lastOpenThreadsCount = 0; + async function refreshOpenThreads() { + try { + const resp = await fetch('/api/open-threads'); + if (!resp.ok) { + renderOpenThreads([]); + return; + } + const data = await resp.json(); + renderOpenThreads(data.threads || []); + } catch (err) { + console.warn('open-threads fetch failed', err); + renderOpenThreads([]); + } + } + function renderOpenThreads(threads) { + const root = $('open-threads-section'); + const list = $('open-threads-list'); + const summary = $('open-threads-summary'); + if (!root || !list || !summary) return; + if (!threads.length) { + root.hidden = true; + lastOpenThreadsCount = 0; + return; + } + root.hidden = false; + summary.textContent = 'open threads · ' + threads.length; + list.innerHTML = ''; + // Auto-expand on first appearance of any open thread so the + // operator notices new loose ends; collapse only on operator + // click (sticky after that). + if (lastOpenThreadsCount === 0) root.open = true; + lastOpenThreadsCount = threads.length; + const fmtAge = (s) => { + if (s < 60) return s + 's'; + if (s < 3600) return Math.floor(s / 60) + 'm'; + if (s < 86400) return Math.floor(s / 3600) + 'h'; + return Math.floor(s / 86400) + 'd'; + }; + for (const t of threads) { + const li = el('li'); + if (t.kind === 'approval') { + li.append( + el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + ); + if (t.description) { + li.append(el('div', { class: 'inbox-body' }, t.description)); + } + } else if (t.kind === 'question') { + const target = t.target || 'operator'; + li.append( + el('span', { class: 'inbox-from' }, '? #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + el('div', { class: 'inbox-body' }, t.question || ''), + ); + } else { + li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t))); + } + list.append(li); + } + } + function renderInbox(rows) { const root = $('inbox-section'); const list = $('inbox-list'); @@ -542,6 +611,10 @@ renderAliveBadge(s.status); renderModelChip(s.model); renderTokenUsage(s.token_usage); + // Open-threads aren't part of /api/state (kept on the broker + // db, fetched via the per-agent socket). Cold-load fetches + // it here; turn_end refreshes it via the renderer below. + refreshOpenThreads(); // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself — without this guard, the // operator's gets clobbered every cycle. @@ -730,6 +803,8 @@ openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); } else { setBannerActive(false); setState('idle'); + // Likely answered/asked/scheduled something — refresh. + refreshOpenThreads(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; api.row(cls, diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 204ec135..a0c86e7a 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -29,6 +29,11 @@
    + +
    connecting…
    diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 22411de3..c3c093c0 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -105,6 +105,7 @@ pub async fn serve( .route("/api/compact", post(post_compact)) .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) + .route("/api/open-threads", get(api_open_threads)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; @@ -231,6 +232,49 @@ struct SessionView { exit_note: Option, } +/// Proxy this agent's open-threads via the per-agent socket. The +/// web UI surfaces the result as a collapsible section in the page +/// so the operator can see at a glance what's pending against the +/// agent (questions asked by it, peer questions targeting it, +/// approvals for the manager). Same data the +/// `mcp__hyperhive__get_open_threads` tool sees from inside the +/// container. +async fn api_open_threads(State(state): State) -> Response { + let threads: Vec = match state.flavor() { + Flavor::Agent => { + match client::request::<_, hive_sh4re::AgentResponse>( + &state.socket, + &hive_sh4re::AgentRequest::GetOpenThreads, + ) + .await + { + Ok(hive_sh4re::AgentResponse::OpenThreads { threads }) => threads, + Ok(hive_sh4re::AgentResponse::Err { message }) => { + return error_response(&format!("get_open_threads: {message}")); + } + Ok(other) => return error_response(&format!("unexpected response: {other:?}")), + Err(e) => return error_response(&format!("transport: {e:#}")), + } + } + Flavor::Manager => { + match client::request::<_, hive_sh4re::ManagerResponse>( + &state.socket, + &hive_sh4re::ManagerRequest::GetOpenThreads, + ) + .await + { + Ok(hive_sh4re::ManagerResponse::OpenThreads { threads }) => threads, + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + return error_response(&format!("get_open_threads: {message}")); + } + Ok(other) => return error_response(&format!("unexpected response: {other:?}")), + Err(e) => return error_response(&format!("transport: {e:#}")), + } + } + }; + axum::Json(serde_json::json!({ "threads": threads })).into_response() +} + async fn api_state(State(state): State) -> axum::Json { // Capture seq *before* any reads so the dedupe contract is // "events with seq > snapshot.seq are post-snapshot, never missed." From 4ec401a6c72bf142c7384cb8cd43e7137733165e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 23:54:35 +0200 Subject: [PATCH 4/4] question/answer text: server-side file_refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardEvent::QuestionAdded gains question_refs and QuestionResolved gains answer_refs — both populated via scan_validated_paths at emit time, same helper the broker forwarder uses for Sent/Delivered. cold-load snapshot wraps each OpQuestion in QuestionView with the same fields computed once per /api/state. client threads refs through questionsState rows (pending + history) and passes them to appendLinkified at every render site (live pane, history details). path tokens in question and answer bodies now linkify with the same server-vouched guarantee broker messages already enjoyed. --- hive-c0re/assets/app.js | 9 ++++-- hive-c0re/src/coordinator.rs | 4 +++ hive-c0re/src/dashboard.rs | 50 +++++++++++++++++++++++++++---- hive-c0re/src/dashboard_events.rs | 8 +++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index ced15d2a..012aeef7 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -748,6 +748,7 @@ asked_at: ev.asked_at, deadline_at: ev.deadline_at ?? null, target: ev.target || null, + question_refs: ev.question_refs || [], }); renderQuestions(); } @@ -766,6 +767,8 @@ answer: ev.answer, answerer: ev.answerer, target: existing?.target ?? ev.target ?? null, + question_refs: existing?.question_refs || [], + answer_refs: ev.answer_refs || [], }); if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { questionsState.history.length = QUESTION_HISTORY_LIMIT; @@ -857,7 +860,7 @@ head.append(' ', el('span', { class: 'q-ttl' }, txt)); } const qBody = el('div', { class: 'q-body' }); - const qPreviews = appendLinkified(qBody, q.question); + const qPreviews = appendLinkified(qBody, q.question, q.question_refs); li.append(head, qBody); for (const d of qPreviews) li.appendChild(d); const f = el('form', { @@ -953,9 +956,9 @@ el('span', { class: 'msg-sep' }, 'asked:'), ); const histBody = el('div', { class: 'q-body' }); - const histBodyPreviews = appendLinkified(histBody, q.question); + const histBodyPreviews = appendLinkified(histBody, q.question, q.question_refs); const ansText = el('span', { class: 'q-answer-text' }); - const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)'); + const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)', q.answer_refs); const ansLine = el('div', { class: 'q-answer' }, el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), ansText, diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 98e9c778..1128c6b6 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -262,6 +262,7 @@ impl Coordinator { .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0); + let question_refs = crate::dashboard::scan_validated_paths(question); self.emit_dashboard_event(DashboardEvent::QuestionAdded { seq: self.next_seq(), id, @@ -272,6 +273,7 @@ impl Coordinator { asked_at, deadline_at, target: target.map(str::to_owned), + question_refs, }); } @@ -293,6 +295,7 @@ impl Coordinator { .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0); + let answer_refs = crate::dashboard::scan_validated_paths(answer); self.emit_dashboard_event(DashboardEvent::QuestionResolved { seq: self.next_seq(), id, @@ -301,6 +304,7 @@ impl Coordinator { answered_at, cancelled, target: target.map(str::to_owned), + answer_refs, }); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 6c1d2db5..da25b15b 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -170,9 +170,9 @@ struct StateSnapshot { /// fire `HelperEvent::QuestionAnswered` back into the asker's /// inbox. Peer-to-peer questions live in the same table but never /// surface here (see `OperatorQuestions::pending`). - questions: Vec, + questions: Vec, /// Last 20 answered questions, newest-first. - question_history: Vec, + question_history: Vec, /// State dirs (config history + claude creds + /state/ notes) that /// survive after a destroy-without-purge. The operator can re-spawn /// with the same name to resume, or PURG3 to wipe them. @@ -186,6 +186,35 @@ struct StateSnapshot { meta_inputs: Vec, } +/// OpQuestion + computed `question_refs` / `answer_refs`. Built +/// from the snapshot read; the live channel attaches the same +/// fields directly on `QuestionAdded` / `QuestionResolved`. +#[derive(Serialize)] +struct QuestionView { + #[serde(flatten)] + inner: crate::operator_questions::OpQuestion, + #[serde(skip_serializing_if = "Vec::is_empty")] + question_refs: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + answer_refs: Vec, +} + +impl QuestionView { + fn from_question(q: crate::operator_questions::OpQuestion) -> Self { + let question_refs = scan_validated_paths(&q.question); + let answer_refs = q + .answer + .as_deref() + .map(scan_validated_paths) + .unwrap_or_default(); + Self { + inner: q, + question_refs, + answer_refs, + } + } +} + #[derive(Serialize)] struct PortConflict { port: u16, @@ -311,12 +340,21 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J // dashboard now derives it client-side from the message stream // (terminal backfill + live SSE), so the snapshot stops shipping it. // 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( + // dashboard. Client filters by target client-side. Each row is + // wrapped in QuestionView so the snapshot carries the same + // file_refs the live event variants attach. + let questions: Vec = + log_default("questions.pending_all", state.coord.questions.pending_all()) + .into_iter() + .map(QuestionView::from_question) + .collect(); + let question_history: Vec = log_default( "questions.recent_answered_all", state.coord.questions.recent_answered_all(20), - ); + ) + .into_iter() + .map(QuestionView::from_question) + .collect(); axum::Json(StateSnapshot { seq, diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 86f7e941..fe9f57c2 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -106,6 +106,11 @@ pub enum DashboardEvent { asked_at: i64, deadline_at: Option, target: Option, + /// Verified file-path tokens that appear in `question`. + /// Same shape as broker `Sent`/`Delivered` events; the + /// client linkifies only what hive-c0re vouched for. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + question_refs: Vec, }, /// A question was answered (operator answer, peer answer, /// operator override on a peer thread, or ttl watchdog @@ -120,6 +125,9 @@ pub enum DashboardEvent { answered_at: i64, cancelled: bool, target: Option, + /// Verified file-path tokens that appear in `answer`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + answer_refs: Vec, }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next