web-ui: expose per-agent paused status, add pause/resume to the agent page's own overflow menu

hive-agent's own web_ui module never exposed the agent's own paused
status to its own /api/state — the dashboard's cross-container view
knew it, but a per-agent page had no way to know it's paused. Added
StateSnapshot.paused (a direct stat of the same harness-local pause
marker hive-c0re's Coordinator::is_paused checks).

The per-agent page's ⋯ overflow menu now has a pause/resume item that
POSTs to hive-c0re's existing /api/pause/<name> / /api/resume/<name> —
the same endpoints the dashboard's <hive-agent-menu> already uses,
same cross-origin form-submit pattern the existing rebuild-container
item uses. The item's label tracks state.paused on every /api/state
refresh so a pause/resume triggered from the dashboard while this page
is open doesn't leave a stale action showing.
This commit is contained in:
iris 2026-08-11 17:25:07 +02:00 committed by mara
commit 5bd085fbac
3 changed files with 85 additions and 2 deletions

View file

@ -67,14 +67,23 @@ through. Three flex columns:
(`GET /api/todos`, refreshed on cold load + every `turn_end`).
- **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
stacking context) with five management rows followed by a model
quick-picker section: `↑ dashboard` (link), `↻ rebuild container`
(POST confirm, same action as the dashboard R3BU1LD button),
`↻ new claude session` (POST confirm → `POST /api/new-session`;
next turn drops `--continue`), `🔓 logout` (POST confirm → `POST
/api/logout`; SIGINTs any in-flight turn, wipes OAuth credential
files, flips the agent to `needs_login` — session history
preserved). All destructive actions require one extra click to
preserved), and `⏸ pause agent` / `▶ resume agent` (POST confirm →
hive-c0re's `/api/pause/<name>` / `/api/resume/<name>` — the same
endpoints the dashboard's own `<hive-agent-menu>` uses, since
pausing is a hive-c0re-owned write this unprivileged process can't
make directly). The label + target endpoint track `state.paused`
(this agent's own `/api/state`, a direct stat of the harness's
local pause marker — see `docs/persistence.md`), refreshed on every
snapshot so a pause/resume triggered from the *dashboard* while
this page is open doesn't leave the menu item showing the wrong
action. All destructive actions require one extra click to
acknowledge — rare ops shouldn't live in the primary state strip.
Below a separator, a **model quick-picker** section labelled
`model` renders one button per model in the operator-configured

View file

@ -231,6 +231,41 @@ window.marked = marked;
});
menu.append(logoutBtn);
// ⏸ pause / ▶ resume — this page has no pause state of its own to
// hold or mutate; it POSTs to the same hive-c0re endpoints
// (`/api/pause/<name>` / `/api/resume/<name>`) the dashboard's own
// `<hive-agent-menu>` already uses, and only needs `state.paused`
// (this agent's own `/api/state`, added alongside this menu item)
// to know which of the two to show. See `renderPausedChip`, called
// from `refreshState` on every snapshot so the label tracks reality
// even when the pause/resume actually happened from the dashboard.
const pauseBtn = el('button', {
type: 'button',
class: 'overflow-item overflow-item-pause',
role: 'menuitem',
id: 'pause-btn',
},
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⏸'),
el('span', { id: 'pause-btn-label' }, 'pause agent'),
);
pauseBtn.addEventListener('click', async () => {
const paused = pauseBtn.dataset.paused === 'true';
const verb = paused ? 'resume' : 'pause';
const message = paused
? `resume ${label}? the turn loop restarts and drains queued messages.`
: `pause ${label}? parks the turn loop — inbox messages queue unacked.`;
if (!(await themedConfirm({
message, danger: true, confirmLabel: paused ? '▶ resume' : '⏸ pause',
}))) return;
closeOverflowMenu();
const f = document.createElement('form');
f.method = 'POST';
f.action = `${dashUrl}api/${verb}/${label}`;
document.body.appendChild(f);
f.submit();
});
menu.append(pauseBtn);
// ─── model quick-picker ────────────────────────────────────────
// One-click shortcuts for each model in `availableModels` (seeded
// from `state.available_models` / `HIVE_AVAILABLE_MODELS` nix option).
@ -493,6 +528,12 @@ window.marked = marked;
let effortPickerBtns = [];
let availableEfforts = [];
// Pause/resume toggle in the overflow menu — see `renderPausedChip`.
// Tracked so subsequent /api/state refreshes can flip the existing
// button's label without rebuilding the whole overflow menu (same
// reason `currentModel`/`currentEffort` are tracked above).
let currentPaused = false;
const SLASH_COMMANDS = [
{ name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
@ -1056,6 +1097,25 @@ window.marked = marked;
btn.setAttribute('aria-pressed', String(isActive));
}
}
// Flips the overflow menu's pause/resume item to match the backend's
// reported `state.paused` (harness-local marker stat, cheap to refresh
// on every /api/state poll) — without this, an operator who pauses from
// the *dashboard* while this page is open would still see a stale
// "pause agent" item here, offering the wrong action.
function renderPausedChip(paused) {
currentPaused = !!paused;
const btn = $('pause-btn');
if (!btn) return;
btn.dataset.paused = String(currentPaused);
const icon = btn.querySelector('.overflow-item-icon');
const label_ = $('pause-btn-label');
if (icon) icon.textContent = currentPaused ? '▶' : '⏸';
if (label_) label_.textContent = currentPaused ? 'resume agent' : 'pause agent';
btn.title = currentPaused
? 'resume this agent — the turn loop restarts and drains queued messages'
: 'pause this agent — parks the turn loop, inbox messages queue unacked';
}
// Token badges — two separate chips:
// ctx · N last inference's prompt size = current context window
// utilisation (what to watch for compaction decisions)
@ -1196,6 +1256,7 @@ window.marked = marked;
renderAliveBadge(s.status);
renderModelChip(s.model);
renderEffortChip(s.effort);
renderPausedChip(s.paused);
renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage });
// Todos pill: cold-load populate; turn_end refreshes via renderTodos.
refreshTodos();

View file

@ -66,6 +66,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
.iter()
.map(ToString::to_string)
.collect(),
paused: crate::paths::paused_marker().exists(),
})
}
@ -185,6 +186,18 @@ pub(super) struct StateSnapshot {
/// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like
/// `available_models`. The frontend renders one button per entry.
available_efforts: Vec<String>,
/// Whether this agent's turn loop is currently parked (the harness
/// keeps serving this page + its MCP daemons but drives no turns).
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
/// (`crate::paths::paused_marker`) — read directly here rather than
/// asking hive-c0re over the socket, since the harness already has
/// the file locally. hive-c0re owns the actual pause/resume *writes*
/// (via hive-priv, this process runs unprivileged) — the per-agent
/// page's own `⋯` menu POSTs to hive-c0re's existing
/// `/api/pause/<name>` / `/api/resume/<name>` (same endpoints
/// `<hive-agent-menu>` on the dashboard already uses), this field
/// only tells the frontend which of the two to show.
paused: bool,
}
#[derive(Serialize)]