Compare commits
7 changed files with 42 additions and 229 deletions
|
|
@ -61,13 +61,6 @@ 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
|
||||
|
|
@ -156,11 +149,7 @@ 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`). 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.
|
||||
*as operator* (see `docs/boundary.md`).
|
||||
|
||||
**Ask → operator inline-answer binding.** When the agent emits
|
||||
`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer
|
||||
|
|
|
|||
|
|
@ -334,10 +334,6 @@ 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;
|
||||
|
|
@ -532,14 +528,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -135,13 +135,6 @@ 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 ────────────────────────────────────────────────────
|
||||
|
|
@ -916,71 +909,6 @@ 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-
|
||||
|
|
@ -1354,12 +1282,6 @@ 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 <input value> gets clobbered every cycle.
|
||||
|
|
@ -1389,12 +1311,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -64,12 +64,6 @@
|
|||
<span class="header-pill-label">loose ends</span>
|
||||
<span class="header-pill-count" id="loose-ends-count">0</span>
|
||||
</button>
|
||||
<button type="button" id="bash-tasks-pill" class="header-pill header-pill-tasks" hidden
|
||||
title="open running bash-tasks flyout">
|
||||
<span class="header-pill-icon" aria-hidden="true">⚙</span>
|
||||
<span class="header-pill-label">tasks</span>
|
||||
<span class="header-pill-count" id="bash-tasks-count">0</span>
|
||||
</button>
|
||||
<button type="button" id="overflow-btn" class="overflow-btn"
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
title="more actions">⋯</button>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ 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))
|
||||
|
|
@ -526,61 +525,6 @@ async fn api_loose_ends(State(state): State<AppState>) -> 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
|
||||
/// `<id>.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<hive_sh4re::TaskFile> = 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::<hive_sh4re::TaskFile>(&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<AppState>) -> axum::Json<StateSnapshot> {
|
||||
// Capture seq *before* any reads so the dedupe contract is
|
||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||
|
|
|
|||
|
|
@ -8,13 +8,47 @@ use serde::{Deserialize, Serialize};
|
|||
// Task state (shared between runner and protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `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};
|
||||
/// 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 `<id>.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<u64>,
|
||||
pub status: TaskStatus,
|
||||
pub created_at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exit_code: Option<i32>,
|
||||
/// Last [`crate::runner::SUMMARY_BYTES`] of stdout.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stdout_tail: Option<String>,
|
||||
/// Last [`crate::runner::SUMMARY_BYTES`] of stderr.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stderr_tail: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / response
|
||||
|
|
|
|||
|
|
@ -380,58 +380,6 @@ 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 `<id>.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<u64>,
|
||||
pub status: TaskStatus,
|
||||
pub created_at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exit_code: Option<i32>,
|
||||
/// Last `SUMMARY_BYTES` of stdout (see `hive-bash-mcp` runner).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stdout_tail: Option<String>,
|
||||
/// Last `SUMMARY_BYTES` of stderr (see `hive-bash-mcp` runner).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stderr_tail: Option<String>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue