diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md
index 1b521206..3365a486 100644
--- a/docs/web-ui/agent.md
+++ b/docs/web-ui/agent.md
@@ -61,6 +61,13 @@ 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). 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
@@ -149,7 +156,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
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..26f3c074 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,12 @@ window.marked = marked;
// db, fetched via the per-agent socket). Cold-load fetches
// it here; turn_end refreshes it via the renderer below.
refreshLooseEnds();
+ // 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
// operator's gets clobbered every cycle.
@@ -1311,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/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 @@
+
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index f52c2dae..113da430 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,61 @@ 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");
+ // 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") {
+ 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))
+ });
+ tasks
+ })
+ .await
+ .unwrap_or_default();
+ 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."
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.