Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ac48299a | ||
|
|
88f17320b6 | ||
|
|
3780f674f4 | ||
|
|
4905b688be |
5 changed files with 86 additions and 209 deletions
|
|
@ -338,7 +338,7 @@ h2, h3 {
|
|||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
color: var(--red);
|
||||
}
|
||||
.header-pill-tasks .header-pill-count {
|
||||
.header-pill-todos .header-pill-count {
|
||||
background: color-mix(in srgb, var(--green) 18%, transparent);
|
||||
color: var(--green);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,18 +130,11 @@ window.marked = marked;
|
|||
buildInboxList(lastInbox));
|
||||
});
|
||||
}
|
||||
const loosePill = $('loose-ends-pill');
|
||||
if (loosePill) {
|
||||
loosePill.addEventListener('click', () => {
|
||||
Panel.open('loose-ends', 'loose ends · ' + lastLooseEnds.length,
|
||||
buildLooseEndsList(lastLooseEnds));
|
||||
});
|
||||
}
|
||||
const bashPill = $('bash-tasks-pill');
|
||||
if (bashPill) {
|
||||
bashPill.addEventListener('click', () => {
|
||||
Panel.open('bash-tasks', 'tasks · ' + lastBashTasks.length,
|
||||
buildBashTasksList(lastBashTasks));
|
||||
const todosPill = $('todos-pill');
|
||||
if (todosPill) {
|
||||
todosPill.addEventListener('click', () => {
|
||||
Panel.open('todos', 'todos · ' + lastTodos.length,
|
||||
buildTodosList(lastTodos));
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -829,26 +822,26 @@ window.marked = marked;
|
|||
}
|
||||
renderStateBadge();
|
||||
}
|
||||
// Loose-ends section: same data the get_loose_ends MCP tool
|
||||
// returns. Best-effort fetch on cold load + after every turn_end
|
||||
// (a turn likely answered or asked something). Silent failure
|
||||
// keeps the pill count at zero rather than surfacing a stale chrome.
|
||||
async function refreshLooseEnds() {
|
||||
// Todos section: in-agent todos (loose-ends v2) pushed by subsystems
|
||||
// (matrix, forge, bash). Best-effort fetch on cold load + after every
|
||||
// turn_end. Silent failure keeps the pill at zero.
|
||||
async function refreshTodos() {
|
||||
try {
|
||||
const resp = await fetch('api/loose-ends');
|
||||
const resp = await fetch('api/todos');
|
||||
if (!resp.ok) {
|
||||
renderLooseEnds([]);
|
||||
renderTodos([]);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
renderLooseEnds(data.loose_ends || []);
|
||||
renderTodos(data.todos || []);
|
||||
} catch (err) {
|
||||
console.warn('loose-ends fetch failed', err);
|
||||
renderLooseEnds([]);
|
||||
console.warn('todos fetch failed', err);
|
||||
renderTodos([]);
|
||||
}
|
||||
}
|
||||
/** Latest snapshot kept in module state so the pill click handler
|
||||
* has fresh data to render into the panel without re-fetching. */
|
||||
let lastTodos = [];
|
||||
let lastLooseEnds = [];
|
||||
let lastInbox = [];
|
||||
|
||||
|
|
@ -923,47 +916,13 @@ window.marked = marked;
|
|||
return wrap;
|
||||
}
|
||||
|
||||
/** Pill-count + open-panel-refresh wiring for loose-ends. The legacy
|
||||
* in-page `<details>` block is gone — operator clicks the header
|
||||
* pill to surface the list in the side panel. */
|
||||
function renderLooseEnds(threads) {
|
||||
lastLooseEnds = threads;
|
||||
const pill = $('loose-ends-pill');
|
||||
const count = $('loose-ends-count');
|
||||
if (count) count.textContent = threads.length;
|
||||
if (pill) pill.hidden = threads.length === 0;
|
||||
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
||||
buildLooseEndsList(threads));
|
||||
// Wire inline answer forms into any `ask → operator` rows
|
||||
// waiting on a broker-assigned question id.
|
||||
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) {
|
||||
/** Build the todos side-panel list. Each entry is a LooseEnd::Todo
|
||||
* (subsystem, summary, source, age_seconds). */
|
||||
function buildTodosList(todos) {
|
||||
const wrap = el('div', { class: 'agent-inbox' });
|
||||
if (!tasks.length) {
|
||||
if (!todos.length) {
|
||||
wrap.append(el('p', { class: 'side-panel-empty' },
|
||||
'no running bash tasks.'));
|
||||
'no todos — all subsystem queues are clear.'));
|
||||
return wrap;
|
||||
}
|
||||
const list = el('ul');
|
||||
|
|
@ -973,20 +932,13 @@ window.marked = marked;
|
|||
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) {
|
||||
for (const t of todos) {
|
||||
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);
|
||||
const label = t.source ? t.subsystem + ' · ' + t.source : t.subsystem;
|
||||
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),
|
||||
el('span', { class: 'inbox-from' }, label), ' ',
|
||||
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
|
||||
el('div', { class: 'inbox-body' }, t.summary || ''),
|
||||
);
|
||||
list.append(li);
|
||||
}
|
||||
|
|
@ -994,14 +946,15 @@ window.marked = marked;
|
|||
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));
|
||||
/** Pill-count + open-panel-refresh wiring for todos. */
|
||||
function renderTodos(todos) {
|
||||
lastTodos = todos;
|
||||
const pill = $('todos-pill');
|
||||
const count = $('todos-count');
|
||||
if (count) count.textContent = todos.length;
|
||||
if (pill) pill.hidden = todos.length === 0;
|
||||
Panel.refresh('todos', 'todos · ' + todos.length,
|
||||
buildTodosList(todos));
|
||||
}
|
||||
|
||||
/** Walk `pendingAskBinds` against the latest `lastLooseEnds`
|
||||
|
|
@ -1066,7 +1019,6 @@ window.marked = marked;
|
|||
});
|
||||
if (resp.ok) {
|
||||
status.textContent = 'answered ✓';
|
||||
refreshLooseEnds();
|
||||
} else {
|
||||
status.textContent = 'failed: ' + (await resp.text());
|
||||
}
|
||||
|
|
@ -1363,16 +1315,8 @@ window.marked = marked;
|
|||
renderModelChip(s.model);
|
||||
renderEffortChip(s.effort);
|
||||
renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage });
|
||||
// Open-threads aren't part of /api/state (kept on the broker
|
||||
// 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();
|
||||
// Todos pill: cold-load populate; turn_end refreshes via renderTodos.
|
||||
refreshTodos();
|
||||
// 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.
|
||||
|
|
@ -1402,12 +1346,10 @@ 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);
|
||||
// Keep the todos pill live. Todos change asynchronously (matrix syncs,
|
||||
// bash task starts/completions) independent of turn_end SSE, so poll
|
||||
// the snapshot endpoint on a light interval. Fails silently when offline.
|
||||
setInterval(refreshTodos, 4000);
|
||||
|
||||
// ─── live event stream ──────────────────────────────────────────────────
|
||||
// Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
|
||||
|
|
@ -1505,11 +1447,7 @@ window.marked = marked;
|
|||
slot._askQuestion = c._body;
|
||||
d.appendChild(slot);
|
||||
pendingAskBinds.push(slot);
|
||||
// Mid-turn refresh — the standard `turn_end` refresh
|
||||
// won't fire until the agent's turn finishes; we want
|
||||
// the form to show up as soon as the ask lands.
|
||||
if (!api.fromHistory) refreshLooseEnds();
|
||||
else reconcileAskBinds();
|
||||
if (api.fromHistory) reconcileAskBinds();
|
||||
}
|
||||
}
|
||||
return d;
|
||||
|
|
@ -1538,15 +1476,6 @@ window.marked = marked;
|
|||
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
||||
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
||||
// When an ask's tool_result lands the broker has just
|
||||
// persisted the question with its assigned id. Refresh
|
||||
// loose-ends so reconcileAskBinds finds the new entry and
|
||||
// mounts the inline answer form under the rendered ask row.
|
||||
// Skipped during history replay (the question's likely
|
||||
// long-resolved; turn_end refresh on cold-load covers
|
||||
// reconciliation).
|
||||
if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) {
|
||||
refreshLooseEnds();
|
||||
}
|
||||
const trimmed = txt.replace(/\s+/g, ' ').trim();
|
||||
const summaryBody = (() => {
|
||||
if (!trimmed) return '(empty)';
|
||||
|
|
@ -1766,8 +1695,7 @@ window.marked = marked;
|
|||
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
||||
} else {
|
||||
setBannerActive(false); setState('idle');
|
||||
// Likely answered/asked/scheduled something — refresh.
|
||||
refreshLooseEnds();
|
||||
refreshTodos();
|
||||
}
|
||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||
const row = api.row(cls,
|
||||
|
|
|
|||
|
|
@ -58,17 +58,11 @@
|
|||
<span class="header-pill-label">inbox</span>
|
||||
<span class="header-pill-count" id="inbox-count">0</span>
|
||||
</button>
|
||||
<button type="button" id="loose-ends-pill" class="header-pill header-pill-loose" hidden
|
||||
title="open loose-ends flyout">
|
||||
<span class="header-pill-icon" aria-hidden="true">🪢</span>
|
||||
<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 type="button" id="todos-pill" class="header-pill header-pill-todos" hidden
|
||||
title="open todos flyout">
|
||||
<span class="header-pill-icon" aria-hidden="true">📋</span>
|
||||
<span class="header-pill-label">todos</span>
|
||||
<span class="header-pill-count" id="todos-count">0</span>
|
||||
</button>
|
||||
<button type="button" id="overflow-btn" class="overflow-btn"
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
|
|
|
|||
|
|
@ -115,8 +115,7 @@ pub async fn serve(
|
|||
.route("/api/effort", post(actions::post_set_effort))
|
||||
.route("/api/new-session", post(actions::post_new_session))
|
||||
.route("/api/logout", post(auth::post_logout))
|
||||
.route("/api/loose-ends", get(stats::api_loose_ends))
|
||||
.route("/api/bash-tasks", get(stats::api_bash_tasks))
|
||||
.route("/api/todos", get(stats::api_todos))
|
||||
.route("/api/stats", get(stats::api_stats))
|
||||
.route("/screen/ws", get(screen::screen_ws))
|
||||
.route("/icon", get(screen::serve_icon));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
//! Stats + loose-ends + bash-tasks read endpoints.
|
||||
//! Stats + loose-ends + todos read endpoints.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{AppState, error_response};
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct StatsQuery {
|
||||
|
|
@ -47,86 +46,43 @@ async fn fetch_reminder_stats(
|
|||
}
|
||||
}
|
||||
|
||||
/// Proxy this agent's loose-ends list via the per-agent socket. The
|
||||
/// web UI surfaces the result as a collapsible section in the page
|
||||
/// so the operator can see at a glance what's pending against the
|
||||
/// agent (questions asked by it, peer questions targeting it,
|
||||
/// reminders it scheduled, approvals for the manager). Same data
|
||||
/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the
|
||||
/// container.
|
||||
pub(super) async fn api_loose_ends(State(state): State<AppState>) -> Response {
|
||||
match super::broker_request(
|
||||
&state.socket,
|
||||
&hive_core_agent_sock::Request::GetLooseEnds { agent: None },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hive_core_agent_sock::Response::LooseEnds { loose_ends }) => {
|
||||
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
|
||||
}
|
||||
Ok(hive_core_agent_sock::Response::Err { message }) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("get_loose_ends: {message}"),
|
||||
),
|
||||
Ok(other) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("get_loose_ends: unexpected response: {other:?}"),
|
||||
),
|
||||
Err(e) => super::broker_error_response(&e, "get_loose_ends"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks.
|
||||
/// `GET /api/todos` — snapshot of this agent's local todos (loose-ends v2).
|
||||
///
|
||||
/// 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.
|
||||
pub(super) 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
|
||||
/// Connects to the in-agent harness socket (`HIVE_AGENT_SOCKET`) and calls
|
||||
/// `ListTodos`. Returns `{ "todos": [...] }` where each entry is a
|
||||
/// `LooseEnd::Todo` (subsystem, key, summary, source, `age_seconds`). Returns
|
||||
/// an empty array when the socket is unavailable — best-effort, silent failure.
|
||||
pub(super) async fn api_todos() -> Response {
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
let socket_path = match std::env::var_os("HIVE_AGENT_SOCKET") {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => return axum::Json(serde_json::json!({ "todos": [] })).into_response(),
|
||||
};
|
||||
if !socket_path.exists() {
|
||||
return axum::Json(serde_json::json!({ "todos": [] })).into_response();
|
||||
}
|
||||
let todos = tokio::time::timeout(std::time::Duration::from_secs(3), async move {
|
||||
let mut stream = UnixStream::connect(&socket_path).await?;
|
||||
let req = hive_agent_sock::Request::ListTodos { subsystem: None };
|
||||
let mut line = serde_json::to_string(&req)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
let resp_line = lines
|
||||
.next_line()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("agent socket closed without response"))?;
|
||||
let resp: hive_agent_sock::Response = serde_json::from_str(&resp_line)?;
|
||||
anyhow::Ok(match resp {
|
||||
hive_agent_sock::Response::LooseEnds { loose_ends } => loose_ends,
|
||||
_ => Vec::new(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(anyhow::anyhow!("timeout")))
|
||||
.unwrap_or_default();
|
||||
axum::Json(serde_json::json!({ "tasks": tasks })).into_response()
|
||||
axum::Json(serde_json::json!({ "todos": todos })).into_response()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue