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 @@
+