From b70209836ece0ae21d700e4805eb51480efdfd73 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 12:37:14 +0200 Subject: [PATCH 1/5] hive-sh4re: lift TaskFile + TaskStatus from hive-bash-mcp Move the bash-task on-disk schema (TaskFile + TaskStatus) into hive-sh4re, the shared wire-types crate, and re-export them from hive-bash-mcp::protocol so existing in-crate imports keep compiling. This gives hive-ag3nt's agent web UI a canonical type to deserialize when reading the bash-tasks dir for a running-tasks panel, instead of a parallel struct that would silently drift from the daemon's persisted format. Both crates already depend on hive-sh4re, so no new dependency edges. --- hive-bash-mcp/src/protocol.rs | 48 +++++--------------------------- hive-sh4re/src/lib.rs | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/hive-bash-mcp/src/protocol.rs b/hive-bash-mcp/src/protocol.rs index 4638a409..cb106047 100644 --- a/hive-bash-mcp/src/protocol.rs +++ b/hive-bash-mcp/src/protocol.rs @@ -8,47 +8,13 @@ use serde::{Deserialize, Serialize}; // Task state (shared between runner and protocol) // --------------------------------------------------------------------------- -/// Lifecycle state of a bash task. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TaskStatus { - Pending, - Running, - Done, - TimedOut, - /// Daemon was restarted while the task was running; process is gone. - Interrupted, - /// Killed on request via `BashKill` (SIGINT or SIGKILL to the task's - /// process group). Distinct from `Interrupted` (daemon-restart) and - /// `TimedOut` (exceeded `timeout_secs`). - Killed, -} - -/// Task metadata + result written to `.json` under the tasks dir. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskFile { - pub id: String, - pub cmd: String, - /// Kill timeout in seconds. `None` means no timeout — task runs until - /// natural exit. Old task files with a numeric value are still readable - /// (serde coerces `u64` → `Some(u64)` is handled by the caller). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_secs: Option, - pub status: TaskStatus, - pub created_at: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub started_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub completed_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - /// Last [`crate::runner::SUMMARY_BYTES`] of stdout. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stdout_tail: Option, - /// Last [`crate::runner::SUMMARY_BYTES`] of stderr. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stderr_tail: Option, -} +// `TaskFile` + `TaskStatus` are the bash-task on-disk schema. They live in +// `hive-sh4re` (the shared wire-types crate) so the agent web UI in +// `hive-ag3nt` can deserialize the same canonical type when reading the +// tasks dir for its running-tasks panel — no parallel copy to drift. Both +// are re-exported here so existing `crate::protocol::{TaskFile, TaskStatus}` +// imports across this crate keep compiling unchanged. +pub use hive_sh4re::{TaskFile, TaskStatus}; // --------------------------------------------------------------------------- // Request / response diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 73f7234c..8cdb9f47 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -380,6 +380,58 @@ pub enum CancelLooseEndKind { Approval, } +// --------------------------------------------------------------------------- +// Bash-task on-disk schema (shared with `hive-bash-mcp`) +// --------------------------------------------------------------------------- + +/// Lifecycle state of a bash task. +/// +/// Canonical home for the bash-task persisted schema: `hive-bash-mcp` +/// (the daemon that writes the files) re-exports these from its +/// `protocol` module, and `hive-ag3nt` (the agent web UI that reads +/// them back for the running-tasks panel) deserializes the same type, so +/// the on-disk shape can't drift between writer and reader. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + Pending, + Running, + Done, + TimedOut, + /// Daemon was restarted while the task was running; process is gone. + Interrupted, + /// Killed on request via `BashKill` (SIGINT or SIGKILL to the task's + /// process group). Distinct from `Interrupted` (daemon-restart) and + /// `TimedOut` (exceeded `timeout_secs`). + Killed, +} + +/// Task metadata + result written to `.json` under the bash-tasks dir. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskFile { + pub id: String, + pub cmd: String, + /// Kill timeout in seconds. `None` means no timeout — task runs until + /// natural exit. Old task files with a numeric value are still readable + /// (serde coerces `u64` → `Some(u64)` is handled by the caller). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, + pub status: TaskStatus, + pub created_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Last `SUMMARY_BYTES` of stdout (see `hive-bash-mcp` runner). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stdout_tail: Option, + /// Last `SUMMARY_BYTES` of stderr (see `hive-bash-mcp` runner). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stderr_tail: Option, +} + /// Unified request enum for both agent and manager sockets. The agent's /// identity is the socket it arrived on. Privileged variants are marked /// `*(privileged)*` — an agent socket returns `Err` for them server-side. From ceb852e5b4afda3d753ad5cf70e162512efbf147 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 12:38:59 +0200 Subject: [PATCH 2/5] hive-ag3nt: add GET /api/bash-tasks endpoint for the agent page Snapshot of the agent's in-flight bash tasks: reads the in-container bash-tasks/ dir (the co-located hive-bash-mcp daemon writes one TaskFile JSON per task), deserializes the canonical hive_sh4re::TaskFile, filters to Pending/Running, and returns them running-first then oldest-first. Skips unreadable/malformed files (and the daemon's .json.tmp scratch writes) so a stray file can't fail the list. Snapshot-only for v1; the page polls it like /api/loose-ends, SSE live-push is a possible follow-up. --- hive-ag3nt/src/web_ui.rs | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index f52c2dae..fe68ea69 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -123,6 +123,7 @@ pub async fn serve( .route("/api/new-session", post(post_new_session)) .route("/api/logout", post(post_logout)) .route("/api/loose-ends", get(api_loose_ends)) + .route("/api/bash-tasks", get(api_bash_tasks)) .route("/api/stats", get(api_stats)) .route("/screen/ws", get(screen_ws)) .route("/icon", get(serve_icon)) @@ -525,6 +526,53 @@ async fn api_loose_ends(State(state): State) -> Response { axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() } +/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks. +/// +/// The `hive-bash-mcp` daemon runs in this same container and writes one +/// `.json` ([`hive_sh4re::TaskFile`]) per task under the harness +/// `bash-tasks/` dir. This reads that dir and returns the tasks still +/// `Pending` or `Running`, so the agent page can show what's running without +/// going through the broker. Snapshot only — the page polls/refreshes it like +/// `/api/loose-ends`; there's no live SSE push for task state yet. Unreadable +/// or malformed files (incl. the daemon's `.json.tmp` scratch writes, which +/// don't match the `.json` extension) are skipped so one stray file can't +/// fail the whole list. +async fn api_bash_tasks() -> Response { + let dir = crate::paths::harness_dir().join("bash-tasks"); + let mut tasks: Vec = Vec::new(); + if let Ok(rd) = std::fs::read_dir(&dir) { + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(task) = serde_json::from_str::(&text) else { + continue; + }; + if matches!( + task.status, + hive_sh4re::TaskStatus::Pending | hive_sh4re::TaskStatus::Running + ) { + tasks.push(task); + } + } + } + // Running before Pending, then oldest-first so a long-runner sits on top. + tasks.sort_by(|a, b| { + let rank = |s: &hive_sh4re::TaskStatus| match s { + hive_sh4re::TaskStatus::Running => 0, + _ => 1, + }; + rank(&a.status) + .cmp(&rank(&b.status)) + .then(a.created_at.cmp(&b.created_at)) + }); + axum::Json(serde_json::json!({ "tasks": tasks })).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 4ce919a19fa13151e1874aae0a53512e729b4d70 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 12:42:46 +0200 Subject: [PATCH 3/5] agent-ui: running bash-tasks pill + flyout on the agent page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'tasks' header pill (hidden at zero, like inbox/loose-ends) that opens a side-panel flyout listing the agent's in-flight bash tasks from GET /api/bash-tasks. Each row shows status (running/queued), task id, elapsed time, and a truncated single-line command preview. Polled on the same /api/state cycle as loose-ends (tasks complete async between turns, so the count stays live); clicking the pill opens the flyout. Snapshot-only v1 — SSE live-push is a possible follow-up. --- frontend/packages/agent/src/agent.css | 12 ++++ frontend/packages/agent/src/app.js | 76 ++++++++++++++++++++++++++ frontend/packages/agent/src/index.html | 6 ++ 3 files changed, 94 insertions(+) diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index b491c8e5..8f980e57 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -334,6 +334,10 @@ h2, h3 { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } +.header-pill-tasks .header-pill-count { + background: color-mix(in srgb, var(--green) 18%, transparent); + color: var(--green); +} .agent-main { position: absolute; @@ -528,6 +532,14 @@ pre.diff { padding-left: 0.8em; border-left: 2px solid var(--purple-dim); } +/* Running bash-tasks flyout rows (buildBashTasksList). */ +.agent-inbox .bash-task-status { font-weight: bold; font-size: 0.9em; } +.agent-inbox .bash-task-running { color: var(--green); } +.agent-inbox .bash-task-pending { color: var(--blue); } +.agent-inbox .bash-task-cmd { + font-family: monospace; + font-size: 0.92em; +} .agent-inbox li.inbox-reply { padding-left: 1em; border-left: 2px solid var(--border); diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 3798351a..cbc5ab4c 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -135,6 +135,13 @@ window.marked = marked; buildLooseEndsList(lastLooseEnds)); }); } + const bashPill = $('bash-tasks-pill'); + if (bashPill) { + bashPill.addEventListener('click', () => { + Panel.open('bash-tasks', 'tasks · ' + lastBashTasks.length, + buildBashTasksList(lastBashTasks)); + }); + } })(); // ─── state rendering ──────────────────────────────────────────────────── @@ -909,6 +916,71 @@ window.marked = marked; reconcileAskBinds(); } + // Running bash tasks: `GET /api/bash-tasks` returns the in-flight + // (Pending/Running) TaskFiles from the in-container bash-tasks dir. Same + // best-effort, silent-failure contract as loose-ends — a fetch miss keeps + // the pill at zero rather than surfacing stale chrome. + let lastBashTasks = []; + async function refreshBashTasks() { + try { + const resp = await fetch('api/bash-tasks'); + if (!resp.ok) { + renderBashTasks([]); + return; + } + const data = await resp.json(); + renderBashTasks(data.tasks || []); + } catch (err) { + console.warn('bash-tasks fetch failed', err); + renderBashTasks([]); + } + } + + function buildBashTasksList(tasks) { + const wrap = el('div', { class: 'agent-inbox' }); + if (!tasks.length) { + wrap.append(el('p', { class: 'side-panel-empty' }, + 'no running bash tasks.')); + return wrap; + } + const list = el('ul'); + 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'; + }; + const now = Math.floor(Date.now() / 1000); + for (const t of tasks) { + const li = el('li'); + const running = t.status === 'running'; + // Elapsed since the task started (running) or was queued (pending). + const since = running ? (t.started_at || t.created_at || now) : (t.created_at || now); + const elapsed = Math.max(0, now - since); + // Single-line, truncated command preview (the cmd can be multi-line). + const cmdPreview = (t.cmd || '').replace(/\s+/g, ' ').trim().slice(0, 100); + li.append( + el('span', { class: 'bash-task-status bash-task-' + t.status }, running ? '▶ running' : '◷ queued'), ' ', + el('span', { class: 'inbox-from' }, t.id), ' ', + el('span', { class: 'inbox-ts' }, (running ? '' : 'queued ') + fmtAge(elapsed) + (running ? ' elapsed' : '')), + el('div', { class: 'inbox-body bash-task-cmd' }, cmdPreview), + ); + list.append(li); + } + wrap.append(list); + return wrap; + } + + function renderBashTasks(tasks) { + lastBashTasks = tasks; + const pill = $('bash-tasks-pill'); + const count = $('bash-tasks-count'); + if (count) count.textContent = tasks.length; + if (pill) pill.hidden = tasks.length === 0; + Panel.refresh('bash-tasks', 'tasks · ' + tasks.length, + buildBashTasksList(tasks)); + } + /** Walk `pendingAskBinds` against the latest `lastLooseEnds` * snapshot, pair unbound slots with the first unclaimed pending * operator-bound question whose text matches, and flip already- @@ -1282,6 +1354,10 @@ window.marked = marked; // db, fetched via the per-agent socket). Cold-load fetches // it here; turn_end refreshes it via the renderer below. refreshLooseEnds(); + // Running bash tasks live in the in-container bash-tasks dir (not + // /api/state); same poll cadence as loose-ends. They complete async + // between turns, so polling on each state cycle keeps the count live. + refreshBashTasks(); // 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. diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index aad9bf90..3e0ce46a 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -64,6 +64,12 @@ loose ends 0 + From bf98aa69aa319460765793d67d4f60b676c1b107 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 12:43:59 +0200 Subject: [PATCH 4/5] docs(agent-page): document the running bash-tasks pill + flyout --- docs/web-ui/agent.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 1b521206..a9d8549e 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -61,6 +61,11 @@ through. Three flex columns: the inbox flyout in the side panel. - **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty; click opens the loose-ends flyout. + - **Tasks pill** (`⚙ tasks · N`): hidden when empty; click opens the + running-bash-tasks flyout (`GET /api/bash-tasks` — the in-flight + Pending/Running tasks from the in-container `bash-tasks/` dir). Polled + on the same `/api/state` cycle as loose-ends, since tasks complete + asynchronously between turns. Snapshot only (no SSE push yet). - **Overflow button** (`⋯`): always visible. Opens a frosted popover (`#overflow-menu`, positioned outside the header to escape any stacking context) with four management rows followed by a model @@ -149,7 +154,11 @@ will reflect zero. Loose-ends flyout: questions, approvals, and reminders pending against this agent (`GET /api/loose-ends`); question rows carry an inline answer form that POSTs cross-origin to the core dashboard's `/answer-question/{id}` so the operator answers -*as operator* (see `docs/boundary.md`). +*as operator* (see `docs/boundary.md`). Tasks flyout: in-flight bash +tasks (`GET /api/bash-tasks`); each row shows status (`▶ running` / +`◷ queued`), the task id, elapsed time, and a truncated one-line +command preview. Read-only — kill/inspect lives in the harness, not +the page. **Ask → operator inline-answer binding.** When the agent emits `mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer From cd7846b3488826882674db50864dfab58cfc515a Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 12:50:04 +0200 Subject: [PATCH 5/5] bash-tasks page: live interval poll + spawn_blocking the dir scan (review) Two fixes from review: - Liveness: /api/state isn't polled while online (only during login), so hooking refreshBashTasks to it only populated on cold load. Bash tasks start + finish asynchronously between turns, so add a light ~4s interval to keep the tasks pill live; the /api/state-time call now just does the first-paint populate. Doc note corrected to match. - Move the blocking dir scan + per-file reads in /api/bash-tasks off the async executor via tokio::task::spawn_blocking (damocles nit). --- docs/web-ui/agent.md | 8 ++++--- frontend/packages/agent/src/app.js | 14 +++++++++--- hive-ag3nt/src/web_ui.rs | 34 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index a9d8549e..3365a486 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -63,9 +63,11 @@ through. Three flex columns: click opens the loose-ends flyout. - **Tasks pill** (`⚙ tasks · N`): hidden when empty; click opens the running-bash-tasks flyout (`GET /api/bash-tasks` — the in-flight - Pending/Running tasks from the in-container `bash-tasks/` dir). Polled - on the same `/api/state` cycle as loose-ends, since tasks complete - asynchronously between turns. Snapshot only (no SSE push yet). + Pending/Running tasks from the in-container `bash-tasks/` dir). Unlike + loose-ends (refreshed on turn_end), tasks start + finish asynchronously + between turns and `/api/state` isn't polled while online, so the pill + polls the endpoint on a light interval (≈4s). Snapshot only (no SSE push + yet). - **Overflow button** (`⋯`): always visible. Opens a frosted popover (`#overflow-menu`, positioned outside the header to escape any stacking context) with four management rows followed by a model diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index cbc5ab4c..26f3c074 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1354,9 +1354,11 @@ window.marked = marked; // db, fetched via the per-agent socket). Cold-load fetches // it here; turn_end refreshes it via the renderer below. refreshLooseEnds(); - // Running bash tasks live in the in-container bash-tasks dir (not - // /api/state); same poll cadence as loose-ends. They complete async - // between turns, so polling on each state cycle keeps the count live. + // Cold-load populate of the running-bash-tasks pill. Tasks complete + // asynchronously between turns (independent of turn_end SSE) and + // /api/state isn't polled while online, so a dedicated interval (set + // up next to the initial refreshState() below) keeps the count live; + // this call just fills it immediately on first paint. refreshBashTasks(); // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself — without this guard, the @@ -1387,6 +1389,12 @@ window.marked = marked; } } refreshState(); + // Keep the running-bash-tasks pill live. Unlike loose-ends (refreshed on + // turn_end SSE), bash tasks start + finish asynchronously between turns and + // /api/state isn't polled while online — so poll the cheap snapshot endpoint + // on a light interval. Fails silently (renders zero) when offline. v1 is + // polling; an SSE push for task state could replace this later. + setInterval(refreshBashTasks, 4000); // ─── live event stream ────────────────────────────────────────────────── // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index fe68ea69..113da430 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -539,8 +539,13 @@ async fn api_loose_ends(State(state): State) -> Response { /// fail the whole list. async fn api_bash_tasks() -> Response { let dir = crate::paths::harness_dir().join("bash-tasks"); - let mut tasks: Vec = Vec::new(); - if let Ok(rd) = std::fs::read_dir(&dir) { + // The dir scan + per-file reads are blocking fs I/O; run them off the + // async executor so a slow or large tasks dir can't stall other requests. + let tasks = tokio::task::spawn_blocking(move || { + let mut tasks: Vec = Vec::new(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return tasks; + }; for entry in rd.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { @@ -559,17 +564,20 @@ async fn api_bash_tasks() -> Response { tasks.push(task); } } - } - // Running before Pending, then oldest-first so a long-runner sits on top. - tasks.sort_by(|a, b| { - let rank = |s: &hive_sh4re::TaskStatus| match s { - hive_sh4re::TaskStatus::Running => 0, - _ => 1, - }; - rank(&a.status) - .cmp(&rank(&b.status)) - .then(a.created_at.cmp(&b.created_at)) - }); + // Running before Pending, then oldest-first so a long-runner sits on top. + tasks.sort_by(|a, b| { + let rank = |s: &hive_sh4re::TaskStatus| match s { + hive_sh4re::TaskStatus::Running => 0, + _ => 1, + }; + rank(&a.status) + .cmp(&rank(&b.status)) + .then(a.created_at.cmp(&b.created_at)) + }); + tasks + }) + .await + .unwrap_or_default(); axum::Json(serde_json::json!({ "tasks": tasks })).into_response() }