From a15fafb5dea3dac8a54b6fbb79661625ca922065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 22:06:53 +0200 Subject: [PATCH] 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; emit sites drop their target.is_none() guard. answered-history rows show the answerer prefix so override answers are auditable at a glance. --- TODO.md | 5 +- hive-c0re/assets/app.js | 93 ++++++++++++++++++++++++++--- hive-c0re/assets/dashboard.css | 34 +++++++++++ hive-c0re/src/coordinator.rs | 23 +++---- hive-c0re/src/dashboard.rs | 40 +++++++------ hive-c0re/src/dashboard_events.rs | 22 +++---- hive-c0re/src/manager_server.rs | 4 +- hive-c0re/src/operator_questions.rs | 20 +++---- hive-c0re/src/questions.rs | 17 +++--- 9 files changed, 187 insertions(+), 71 deletions(-) diff --git a/TODO.md b/TODO.md index 86c57b1..a0e6b9f 100644 --- a/TODO.md +++ b/TODO.md @@ -21,7 +21,10 @@ ## Dashboard -- **UI for agent-to-agent questions** (follow-up to the `ask` rename): now that agents can `ask(to: )` each other, surface those threads in the per-agent dashboard view. Replace the existing read/unread tabs with THREE filters: `unread`, `from: `, `to: `. 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//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) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 338aed5..2a82007 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -149,7 +149,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); } } @@ -610,6 +612,7 @@ multi: !!ev.multi, asked_at: ev.asked_at, deadline_at: ev.deadline_at ?? null, + target: ev.target || null, }); renderQuestions(); } @@ -627,26 +630,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:` 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) { @@ -701,9 +762,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), @@ -712,10 +783,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( @@ -733,17 +805,20 @@ 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: 'msg-sep' }, `${q.answerer || '?'}: `), el('span', { class: 'q-answer-text' }, q.answer || '(none)'), ), ); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index beba286..51e90b1 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -450,6 +450,40 @@ 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); } } +/* 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); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 892e417..98e9c77 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -243,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, @@ -256,6 +255,7 @@ impl Coordinator { options: &[String], multi: bool, deadline_at: Option, + target: Option<&str>, ) { let asked_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -271,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) @@ -298,6 +300,7 @@ impl Coordinator { answerer: answerer.to_owned(), answered_at, cancelled, + target: target.map(str::to_owned), }); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0810e1e..8cb87d5 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -307,9 +307,13 @@ async fn api_state(headers: HeaderMap, State(state): State) -> 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, @@ -734,14 +738,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:#}")), @@ -766,14 +769,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 { diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index ac508bc..e17a23e 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -79,11 +79,11 @@ pub enum DashboardEvent { note: Option, description: Option, }, - /// An operator-targeted question landed in the queue - /// (`Ask { to: None | Some("operator") }`). Peer-to-peer - /// questions (target = Some()) 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()` 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, @@ -93,12 +93,13 @@ pub enum DashboardEvent { multi: bool, asked_at: i64, deadline_at: Option, + target: Option, }, - /// 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, @@ -106,6 +107,7 @@ pub enum DashboardEvent { answerer: String, answered_at: i64, cancelled: bool, + target: Option, }, /// A lifecycle action started for an agent (spawn / start / stop /// / restart / rebuild / destroy). Clients render a spinner next diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 5fafc39..203f2fc 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -454,9 +454,7 @@ pub fn spawn_question_watchdog(coord: &Arc, 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()); } }); } diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index 6c24dc3..5ef5b19 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -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> { + /// 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> { 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> { + /// Last `limit` answered questions across both target kinds, + /// newest-first. Companion to `pending_all`. + pub fn recent_answered_all(&self, limit: u64) -> Result> { 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::>>() .map_err(Into::into) } + } fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index d94ad8f..d622d1d 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -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(()) }