Compare commits

...
Author SHA1 Message Date
müde
754db7830e ask_operator: ttl_seconds auto-cancel + remaining-time chip
manager can pass ttl_seconds to ask_operator. on submit, host
stores deadline_at = now + ttl in operator_questions (new column,
migrated via existing pragma_table_info pattern), spawns a tokio
task that sleeps until the deadline then resolves the question with
answer '[expired]' and fires the same OperatorAnswered helper event.
already-resolved races no-op silently.

dashboard renders a ' MM:SS' chip on the question row when
deadline_at is set. format collapses seconds → s, < 1h → m s, ≥ 1h
→ h m. heartbeat refresh (5s) keeps the chip current; the operator
sees it tick down.

manager prompt + mcp tool description updated. journald viewer per
container queued in todo (separate task).
2026-05-15 20:38:02 +02:00
müde
2146e47770 web ui: retry binding on AddrInUse during restart races
operator hit 'Address already in use (os error 98)' on a harness
restart — the new harness raced the old socket's release. add a
bind_with_retry helper that backs off (250ms doubling, capped at
2s, 12 tries ≈ 22s total) on AddrInUse before giving up. applied
to both the per-agent web UI and the hive-c0re dashboard.

proper fix would be SO_REUSEADDR via socket2 but retry covers the
TIME_WAIT case fine and keeps the dep count down. Other bind errors
still fail immediately (port permission, fd exhaustion).
2026-05-15 20:33:51 +02:00
müde
538e0446d7 agent page: inbox view of last 30 messages addressed to this agent
new wire request AgentRequest::Recent { limit } / ManagerRequest::Recent
(plus matching responses with Vec<InboxRow>). InboxRow moved to
hive-sh4re so it lives on both surfaces without an internal-to-wire
conversion. host-side dispatch in agent_server / manager_server
calls broker.recent_for(name, limit).

per-agent web_ui /api/state grew an inbox: Vec<InboxRow> populated
via the same per-agent socket (best-effort; transport failure
returns empty). frontend renders as a collapsible <details> section
between the state row and the terminal — fmt timestamp / from /
body in a tight grid, capped at 16em scrollable. only visible when
there are rows.
2026-05-15 20:32:19 +02:00
müde
bd7d2d4860 agent page: dashboard back-link + last-turn timing chip
title bar grows a '↑ DASHB04RD' link next to the rebuild button —
opens the host dashboard in a new tab so the operator can pivot
between agents without losing the live tail. uses the dashboardPort
already plumbed via /api/state.

state row picks up a 'last turn 12.3s' chip that fills in when
state transitions away from thinking. format: ms / s.s / m s.
hidden until the first turn completes.
2026-05-15 20:27:09 +02:00
müde
ee5b85716d ask_operator: operator-side ✗ CANC3L on pending questions
new POST /cancel-question/{id} resolves a pending operator question
with the sentinel answer '[cancelled]' and fires the usual
HelperEvent::OperatorAnswered so the manager sees a terminal state
and can fall back. uses the same OperatorQuestions::answer path —
no special handling, the manager already has to deal with arbitrary
answer strings.

dashboard renders the cancel as a separate <form> below the main
qform so the answer-merge submit handler on the main form doesn't
inadvertently fire when the operator clicks cancel. confirm dialog
spells out what the manager will see.

ttl-based auto-cancel is still on the todo (would spawn a tokio task
per submitted question).
2026-05-15 20:25:11 +02:00
müde
bc87ff80d2 agent terminal: inline +/- diffs on Write and Edit tool calls
Write and Edit tool_use rows used to render as the bare file path. now
they're collapsed <details> blocks with the actual change inside —
Write shows every content line prefixed '+', Edit shows old_string as
'-' lines then new_string as '+' lines. summary carries the file path
+ counts ('→  Edit /foo · -3 +5'). lines colored via diff-add /
diff-del / diff-ctx; click to expand the full body.

renderFileWriteEdit returns null for any other tool so the existing
flat-row path (fmtToolUse) is untouched.
2026-05-15 20:23:22 +02:00
17 changed files with 513 additions and 71 deletions

28
TODO.md
View file

@ -27,8 +27,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## UI / UX
- **Per-agent UI substance.** Show last N inbox messages, last turn timing,
link back to dashboard.
- **State badge: compacting + napping states.** Idle/thinking already
ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
`napping 😴` once the `/compact` trigger and `nap` tool exist —
@ -42,15 +40,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
`GET /api/state` (`status: "thinking" | "idle" | "compacting" |
"napping"`). JS just renders. Drops the
derive-from-events-and-pray code path.
- **Terminal: inline diffs for Write/Edit.** Today a `Write` /
`Edit` tool-use row just shows the file path. Render the actual
change inline in the terminal: for `Edit`, a small `+`/`-`
per-line diff between `input.old_string` and `input.new_string`;
for `Write`, the first few lines of `input.content` (it's all
"+"). Keep collapsed by default (`<details>` like the existing
tool_result rollups), expand to full diff on click. Color via
the same `.diff-add` / `.diff-del` classes the dashboard
approval diff already uses.
- **Terminal: `/model` slash command.** Operator-typeable model
override from the terminal. Depends on the model-override work
above; once an override mechanism exists, wire a `/model <name>`
@ -79,12 +68,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## Manager → operator question channel
- **TTL / cancel on `ask_operator`.** Questions today block forever; the
manager turn stays alive until the operator answers. Add a per-question
`ttl_seconds` (or a dashboard "cancel" button that resolves the question
with a sentinel answer) so a long-idle question can time out and let the
manager fall back. Wire the timeout into `OperatorQuestions::wait_answered`
and surface remaining-time on the dashboard.
## Spawn flow
@ -124,6 +107,17 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## Lifecycle / reliability
- **journald viewer per container in the dashboard.** Surface the
equivalent of `journalctl -M h-coder -b` in the dashboard so the
operator can see container logs without ssh-ing in. Optional
filter by hive-specific systemd unit (`hive-ag3nt.service`,
`hive-m1nd.service`). Implementation: backend shells out to
`journalctl -M <container> -b --output=short-iso --no-pager`
(optionally `-u <unit>`), streams or paginates the result over a
new dashboard endpoint. Could be a `<details>` per container row
or a dedicated page. Honest journalctl, not the in-container
events stream — those are different surfaces (events = claude turn
loop; journalctl = systemd-wide logs incl. boot, network, etc.).
- **Container crash events.** Watch `container@*.service` via D-Bus, push
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can
react (restart, escalate, etc.).

View file

@ -130,6 +130,62 @@ pre.diff {
align-items: center;
gap: 0.6em;
}
/* Per-agent inbox section collapsible, dim, lives between the
state row and the terminal so the operator can peek at what
landed without scrolling through the live tail. */
.agent-inbox {
margin: 0.4em 0;
font-size: 0.85em;
color: var(--muted);
}
.agent-inbox > summary {
cursor: pointer;
letter-spacing: 0.05em;
list-style: none;
}
.agent-inbox > summary::marker { content: ''; }
.agent-inbox[open] > summary > span::before { content: ''; }
.agent-inbox ul {
list-style: none;
padding: 0.4em 0.8em;
margin: 0.3em 0 0;
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid var(--purple-dim);
max-height: 16em;
overflow-y: auto;
}
.agent-inbox li {
padding: 0.15em 0;
display: grid;
grid-template-columns: auto auto auto 1fr;
gap: 0.5em;
align-items: baseline;
}
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; }
.agent-inbox .inbox-from { color: var(--amber); }
.agent-inbox .inbox-sep { color: var(--muted); }
.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
.last-turn {
color: var(--muted);
font-size: 0.8em;
letter-spacing: 0.05em;
}
.btn-dashlink {
color: var(--cyan);
border: 1px solid var(--cyan);
padding: 0.15em 0.6em;
font-size: 0.55em;
font-family: inherit;
text-decoration: none;
letter-spacing: 0.1em;
margin-left: 0.6em;
vertical-align: middle;
}
.btn-dashlink:hover {
background: rgba(137, 220, 235, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
.btn-cancel-turn {
font-family: inherit;
font-size: 0.8em;
@ -333,6 +389,22 @@ details.row > summary::before {
}
details.row[open] > summary::before { content: '▾ '; }
details.row.tool-result-block > summary { color: var(--muted); }
/* Inline diff body for Write / Edit tool_use rows: same shape as
tool-body but each line is wrapped in a span with diff-add /
diff-del / diff-ctx so + / - lines are colored. */
details.row > pre.diff-body {
margin: 0.3em 0 0.4em 1.2em;
padding: 0.4em 0.6em;
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid var(--purple-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 22em;
overflow-y: auto;
}
details.row > pre.diff-body .diff-add { color: var(--green); }
details.row > pre.diff-body .diff-del { color: var(--red); }
details.row > pre.diff-body .diff-ctx { color: var(--fg); }
details.row > pre.tool-body {
margin: 0.3em 0 0.4em 1.2em;
padding: 0.4em 0.6em;

View file

@ -65,16 +65,25 @@
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
const title = $('title');
title.textContent = `${label}`;
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
// tab to keep the agent page anchored where the operator is.
const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`;
title.append(
el('a', {
href: dashUrl, target: '_blank', rel: 'noopener',
class: 'btn-dashlink', title: 'host dashboard',
}, '↑ DASHB04RD'),
' ',
);
const btn = el('a', {
href: '#', class: 'btn-rebuild', id: 'rebuild-btn',
}, '↻ R3BU1LD');
btn.addEventListener('click', (e) => {
e.preventDefault();
if (!confirm(`rebuild ${label}? container will hot-reload.`)) return;
const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`;
const f = document.createElement('form');
f.method = 'POST';
f.action = url;
f.action = `${dashUrl}rebuild/${label}`;
document.body.appendChild(f);
f.submit();
});
@ -310,6 +319,13 @@
}
function setState(next) {
if (next === stateName) return;
// Capture the just-ending state's duration when leaving 'thinking'
// so the operator can eyeball turn length without scrolling the
// terminal back.
if (stateName === 'thinking' && next !== 'thinking') {
const elapsedMs = Date.now() - stateSince;
renderLastTurn(elapsedMs);
}
stateName = next;
stateSince = Date.now();
const badge = $('state-badge');
@ -321,6 +337,40 @@
}
renderStateBadge();
}
function renderInbox(rows) {
const root = $('inbox-section');
const list = $('inbox-list');
const summary = $('inbox-summary');
if (!root || !list || !summary) return;
if (!rows.length) {
root.hidden = true;
return;
}
root.hidden = false;
summary.textContent = 'inbox · ' + rows.length;
list.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
for (const m of rows) {
const li = el('li');
li.append(
el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ',
el('span', { class: 'inbox-from' }, m.from), ' ',
el('span', { class: 'inbox-sep' }, '→'), ' ',
el('span', { class: 'inbox-body' }, m.body),
);
list.append(li);
}
}
function renderLastTurn(ms) {
const el_ = $('last-turn');
if (!el_) return;
let s = '';
if (ms < 1000) s = ms + 'ms';
else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's';
else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's';
el_.textContent = '· last turn ' + s;
el_.hidden = false;
}
function startStateTicker() {
if (stateTickTimer) return;
stateTickTimer = setInterval(renderStateBadge, 1000);
@ -360,6 +410,7 @@
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
renderTermInput(s.label, s.status === 'online');
renderInbox(s.inbox || []);
// Drive the state badge from the harness status. Live SSE events
// override to 'thinking' / 'idle' as turns start/end; this only
// kicks in for the not-online (offline) case and the initial seed.
@ -514,6 +565,63 @@
default: return name + ' ' + trim(JSON.stringify(input), 200);
}
}
// Build a tool_use row for Write/Edit as a collapsed <details>
// showing the actual change. Returns null for any other tool so
// the caller falls back to the flat-row path.
// Write: every input.content line is "+".
// Edit: old_string lines as "-", new_string lines as "+".
// Not a true diff algorithm — claude's Edit blocks are already a
// contiguous old/new pair, so a literal -/+ rendering is honest.
function renderFileWriteEdit(c) {
const name = c.name || '';
const input = c.input || {};
if (name !== 'Write' && name !== 'Edit') return null;
const path = input.file_path || '?';
let body;
let plus = 0;
let minus = 0;
if (name === 'Write') {
const content = String(input.content || '');
const lines = content.split('\n');
plus = lines.length;
body = lines.map(l => '+ ' + l).join('\n');
} else {
const oldLines = String(input.old_string || '').split('\n');
const newLines = String(input.new_string || '').split('\n');
minus = oldLines.length;
plus = newLines.length;
body = oldLines.map(l => '- ' + l).join('\n')
+ '\n'
+ newLines.map(l => '+ ' + l).join('\n');
}
const summary = '→ ' + name + ' ' + path + ' · '
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
return detailsDiff('tool-use', summary, body);
}
function detailsDiff(cls, summary, body) {
clearPlaceholder();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
const pre = document.createElement('pre');
pre.className = 'tool-body diff-body';
// Color each line by its leading +/-.
for (const line of body.split('\n')) {
const span = document.createElement('span');
if (line.startsWith('+ ')) span.className = 'diff-add';
else if (line.startsWith('- ')) span.className = 'diff-del';
else span.className = 'diff-ctx';
span.textContent = line + '\n';
pre.appendChild(span);
}
d.appendChild(pre);
log.appendChild(d);
afterAppend();
return d;
}
function renderToolResult(c) {
const txt = Array.isArray(c.content)
? c.content.map(p => p.text || '').join('')
@ -547,7 +655,13 @@
const txt = (c.thinking || c.text || '').trim();
row('thinking', txt ? '· ' + txt : '· thinking …');
}
else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c));
else if (c.type === 'tool_use') {
// Write/Edit get a collapsed +/- diff body; everything
// else stays as the flat row produced by fmtToolUse.
if (!renderFileWriteEdit(c)) {
row('tool-use', '→ ' + fmtToolUse(c));
}
}
}
return;
}

View file

@ -15,9 +15,15 @@
<div id="state-row">
<span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
</div>
<details id="inbox-section" class="agent-inbox" hidden>
<summary><span id="inbox-summary">inbox</span></summary>
<ul id="inbox-list"></ul>
</details>
<div class="terminal-wrap">
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
<div id="term-input" class="term-input"></div>

View file

@ -9,7 +9,7 @@ Tools (hyperhive surface):
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval.
- `mcp__hyperhive__ask_operator(question, options?, multi?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Do not poll inside the same turn — finish the current work and react when the event lands.
- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands.
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day.

View file

@ -139,7 +139,7 @@ async fn serve(
turn::emit_turn_end(&bus, &outcome);
}
Ok(AgentResponse::Empty) => {}
Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => {
tracing::warn!("recv produced unexpected response kind");
}
Ok(AgentResponse::Err { message }) => {

View file

@ -139,7 +139,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
Ok(
ManagerResponse::Ok
| ManagerResponse::Status { .. }
| ManagerResponse::QuestionQueued { .. },
| ManagerResponse::QuestionQueued { .. }
| ManagerResponse::Recent { .. },
) => {
tracing::warn!("recv produced unexpected response kind");
}

View file

@ -38,6 +38,7 @@ pub enum SocketReply {
Empty,
Status(u64),
QuestionQueued(i64),
Recent(Vec<hive_sh4re::InboxRow>),
}
impl From<hive_sh4re::AgentResponse> for SocketReply {
@ -48,6 +49,7 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
hive_sh4re::AgentResponse::Message { from, body } => Self::Message { from, body },
hive_sh4re::AgentResponse::Empty => Self::Empty,
hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread),
hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows),
}
}
}
@ -61,6 +63,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
hive_sh4re::ManagerResponse::Empty => Self::Empty,
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
}
}
}
@ -237,6 +240,12 @@ pub struct AskOperatorArgs {
/// selections joined by ", ". Ignored when `options` is empty.
#[serde(default)]
pub multi: bool,
/// Optional auto-cancel after `ttl_seconds`. On expiry the question
/// resolves with answer `[expired]` and the manager receives the
/// usual `operator_answered` system event. `None` (default) =
/// wait indefinitely.
#[serde(default)]
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
@ -377,7 +386,9 @@ impl ManagerServer {
request, policy call, scope clarification). `options` is advisory: pass a short \
fixed-choice list when applicable, otherwise leave empty for free text. Set \
`multi: true` to let the operator pick multiple options (checkboxes); the answer \
comes back as a comma-separated string."
comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \
no-longer-relevant question instead of blocking forever on expiry the answer \
is `[expired]` and the same `operator_answered` event fires."
)]
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
let log = format!("{args:?}");
@ -387,6 +398,7 @@ impl ManagerServer {
question: args.question,
options: args.options,
multi: args.multi,
ttl_seconds: args.ttl_seconds,
})
.await;
match resp {

View file

@ -83,9 +83,7 @@ pub async fn serve(
.route("/api/compact", post(post_compact))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("bind web UI on port {port}"))?;
let listener = bind_with_retry(addr, "web UI").await?;
tracing::info!(%port, "web UI listening");
axum::serve(listener, app).await?;
Ok(())
@ -95,6 +93,33 @@ pub async fn serve(
// Static assets + state snapshot
// ---------------------------------------------------------------------------
/// Bind a TCP listener, retrying on `AddrInUse` for up to ~20s.
/// nspawn restarts can race the previous harness's socket release;
/// without retry the new harness fails to bind and systemd just
/// keeps restarting it. `SO_REUSEADDR` would be the proper fix but
/// would require socket2; retry is good enough here.
async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> {
let mut delay_ms = 250u64;
let mut attempts = 0u32;
loop {
match tokio::net::TcpListener::bind(addr).await {
Ok(l) => return Ok(l),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < 12 => {
tracing::warn!(
%addr, attempt = attempts + 1,
"{label}: AddrInUse, retrying in {delay_ms}ms"
);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
attempts += 1;
delay_ms = (delay_ms * 2).min(2000);
}
Err(e) => {
return Err(e).with_context(|| format!("bind {label} on {addr}"));
}
}
}
}
async fn serve_index() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
@ -124,6 +149,10 @@ struct StateSnapshot {
status: &'static str,
/// Present when `status == "needs_login_in_progress"`.
session: Option<SessionView>,
/// Last N messages addressed to this agent, newest-first. Pulled
/// from the broker via the per-agent socket on each render.
/// Empty on transport failure.
inbox: Vec<hive_sh4re::InboxRow>,
}
#[derive(Serialize)]
@ -157,14 +186,47 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(7000);
let inbox = recent_inbox(&state.socket, state.flavor).await;
axum::Json(StateSnapshot {
label: state.label.clone(),
dashboard_port,
status,
session: session_view,
inbox,
})
}
/// Best-effort: pull the last 30 messages addressed to us via the
/// per-agent / manager socket. Empty list on any transport / decode
/// failure — the inbox section is decorative, not authoritative.
async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec<hive_sh4re::InboxRow> {
const LIMIT: u64 = 30;
match flavor {
Flavor::Agent => {
match client::request::<_, hive_sh4re::AgentResponse>(
socket,
&hive_sh4re::AgentRequest::Recent { limit: LIMIT },
)
.await
{
Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows,
_ => Vec::new(),
}
}
Flavor::Manager => {
match client::request::<_, hive_sh4re::ManagerResponse>(
socket,
&hive_sh4re::ManagerRequest::Recent { limit: LIMIT },
)
.await
{
Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows,
_ => Vec::new(),
}
}
}
}
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------

View file

@ -237,14 +237,23 @@
const ul = el('ul', { class: 'questions' });
for (const q of s.questions) {
const li = el('li', { class: 'question' });
li.append(
el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, 'asks:'),
),
el('div', { class: 'q-body' }, q.question),
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, 'asks:'),
);
if (q.deadline_at) {
const remaining = q.deadline_at - Math.floor(Date.now() / 1000);
let txt;
if (remaining <= 0) txt = 'expiring…';
else if (remaining < 60) txt = '⏳ ' + remaining + 's';
else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm '
+ (remaining % 60) + 's';
else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h '
+ Math.floor((remaining % 3600) / 60) + 'm';
head.append(' ', el('span', { class: 'q-ttl' }, txt));
}
li.append(head, el('div', { class: 'q-body' }, q.question));
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '',
@ -284,12 +293,28 @@
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
}, true);
if (hasOptions) f.append(optionGroup);
f.append(
el('div', { class: 'q-free' }, freeText),
const buttons = el('div', { class: 'q-buttons' });
buttons.append(
el('button', { type: 'submit', class: 'btn btn-approve' },
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
);
f.append(
el('div', { class: 'q-free' }, freeText),
buttons,
);
li.append(f);
// Separate form so the cancel button doesn't get the answer
// merge-on-submit handler attached to the main form.
const cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '',
'data-confirm': 'cancel this question? manager will see '
+ '"[cancelled]" as the answer.',
});
cancelForm.append(
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
);
li.append(cancelForm);
ul.append(li);
}
root.append(ul);

View file

@ -296,6 +296,12 @@ summary:hover { color: var(--purple); }
}
.questions li.question:last-child { border-bottom: 0; }
.questions .q-head { font-size: 0.9em; }
.questions .q-ttl {
color: var(--amber);
margin-left: 0.4em;
font-size: 0.95em;
letter-spacing: 0.05em;
}
.questions .q-body {
color: var(--fg);
margin: 0.3em 0;
@ -332,6 +338,7 @@ summary:hover { color: var(--purple); }
.qform .q-free input::placeholder { color: var(--muted); }
.qform .q-free input:focus { outline: 1px solid var(--amber); }
.qform button { align-self: flex-start; }
.qform-cancel { margin-top: 0.3em; }
.inbox {
background: var(--bg-elev);
border: 1px solid var(--border);

View file

@ -121,5 +121,11 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp
message: format!("{e:#}"),
},
},
AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) {
Ok(rows) => AgentResponse::Recent { rows },
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
},
}
}

View file

@ -6,7 +6,7 @@ use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use hive_sh4re::Message;
use hive_sh4re::{InboxRow, Message};
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use tokio::sync::broadcast;
@ -28,16 +28,6 @@ CREATE INDEX IF NOT EXISTS idx_messages_undelivered
/// may drop events past this; we send a `lagged` notice in their stream.
const EVENT_CHANNEL: usize = 256;
/// One row in a `recent_for()` query — the broker's flat view of a
/// message addressed to a given recipient.
#[derive(Debug, Clone, Serialize)]
pub struct InboxRow {
pub id: i64,
pub from: String,
pub body: String,
pub at: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum MessageEvent {

View file

@ -51,14 +51,13 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/rebuild/{name}", post(post_rebuild))
.route("/update-all", post(post_update_all))
.route("/answer-question/{id}", post(post_answer_question))
.route("/cancel-question/{id}", post(post_cancel_question))
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
.route("/request-spawn", post(post_request_spawn))
.route("/messages/stream", get(messages_stream))
.with_state(AppState { coord });
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("bind dashboard on port {port}"))?;
let listener = bind_with_retry(addr).await?;
tracing::info!(%port, "dashboard listening");
axum::serve(listener, app).await?;
Ok(())
@ -72,6 +71,30 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// `/messages/stream` for broker traffic.
// ---------------------------------------------------------------------------
/// Retry-on-AddrInUse bind. Same shape as the per-agent variant —
/// hive-c0re restarts also race the previous process's socket release.
async fn bind_with_retry(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
let mut delay_ms = 250u64;
let mut attempts = 0u32;
loop {
match tokio::net::TcpListener::bind(addr).await {
Ok(l) => return Ok(l),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < 12 => {
tracing::warn!(
%addr, attempt = attempts + 1,
"dashboard: AddrInUse, retrying in {delay_ms}ms"
);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
attempts += 1;
delay_ms = (delay_ms * 2).min(2000);
}
Err(e) => {
return Err(e).with_context(|| format!("bind dashboard on {addr}"));
}
}
}
}
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../assets/index.html"))
}
@ -101,7 +124,7 @@ struct StateSnapshot {
/// Latest messages addressed to `operator` — surfaces agent replies
/// asynchronously so the operator can see them without watching the
/// live panel during a turn.
operator_inbox: Vec<crate::broker::InboxRow>,
operator_inbox: Vec<hive_sh4re::InboxRow>,
/// Pending operator questions (currently only from the manager).
/// `ask_operator` returns immediately with the id; on `/answer-question`
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
@ -417,6 +440,33 @@ async fn post_answer_question(
}
}
/// Resolve a pending operator question with a sentinel answer when
/// the operator decides not to / can't answer. The manager harness
/// receives an `OperatorAnswered` event with `answer = "[cancelled]"`
/// so it can fall back on whatever default it had. Same code path as
/// a real answer — just lets the operator close the loop instead of
/// letting the question dangle forever.
async fn post_cancel_question(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
const SENTINEL: &str = "[cancelled]";
match state.coord.questions.answer(id, SENTINEL) {
Ok(question) => {
tracing::info!(%id, "operator cancelled question");
state
.coord
.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered {
id,
question,
answer: SENTINEL.to_owned(),
});
Redirect::to("/").into_response()
}
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
}
}
async fn post_purge_tombstone(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,

View file

@ -72,7 +72,7 @@ async fn serve(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
const MANAGER_RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30);
#[allow(clippy::too_many_lines)]
async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse {
async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResponse {
match req {
ManagerRequest::Send { to, body } => match coord.broker.send(&Message {
from: MANAGER_AGENT.to_owned(),
@ -100,6 +100,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
message: format!("{e:#}"),
},
},
ManagerRequest::Recent { limit } => match coord.broker.recent_for(MANAGER_AGENT, *limit) {
Ok(rows) => ManagerResponse::Recent { rows },
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
},
ManagerRequest::Recv => match coord
.broker
.recv_blocking(MANAGER_AGENT, MANAGER_RECV_LONG_POLL)
@ -192,14 +198,26 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
question,
options,
multi,
ttl_seconds,
} => {
tracing::info!(%question, ?options, multi, "manager: ask_operator");
tracing::info!(%question, ?options, multi, ?ttl_seconds, "manager: ask_operator");
let deadline_at = ttl_seconds.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
match coord
.questions
.submit(MANAGER_AGENT, question, options, *multi)
.submit(MANAGER_AGENT, question, options, *multi, deadline_at)
{
Ok(id) => {
tracing::info!(%id, "operator question queued");
tracing::info!(%id, ?deadline_at, "operator question queued");
if let Some(ttl) = *ttl_seconds {
spawn_question_watchdog(coord, id, ttl);
}
ManagerResponse::QuestionQueued { id }
}
Err(e) => ManagerResponse::Err {
@ -221,3 +239,28 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
}
}
}
/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then
/// try to resolve the question with `[expired]`. If the operator (or
/// any other path) already answered it, `answer()` returns Err and
/// we no-op silently. Otherwise fire the usual `OperatorAnswered`
/// helper event so the manager sees a terminal state.
const TTL_SENTINEL: &str = "[expired]";
fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64) {
let coord = coord.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await;
// `answer` returns Err if already resolved — that's the
// normal path when the operator responded before the ttl
// fired, so no-op silently.
if let Ok(question) = coord.questions.answer(id, TTL_SENTINEL) {
tracing::info!(%id, "operator question expired (ttl)");
coord.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered {
id,
question,
answer: TTL_SENTINEL.to_owned(),
});
}
});
}

View file

@ -25,17 +25,29 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
ON operator_questions (id) WHERE answered_at IS NULL;
";
/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN`
/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first.
fn ensure_multi_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?
.exists([])?;
if !has {
conn.execute_batch(
/// Add late-added columns to pre-existing databases. `ALTER TABLE
/// ADD COLUMN` has no `IF NOT EXISTS` form in sqlite, so we check
/// `pragma_table_info` first per column.
fn ensure_columns(conn: &Connection) -> Result<()> {
for (name, sql) in [
(
"multi",
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;",
)
.context("add operator_questions.multi column")?;
),
(
"deadline_at",
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;",
),
] {
let has: bool = conn
.prepare(&format!(
"SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = '{name}'"
))?
.exists([])?;
if !has {
conn.execute_batch(sql)
.with_context(|| format!("add operator_questions.{name} column"))?;
}
}
Ok(())
}
@ -49,6 +61,10 @@ pub struct OpQuestion {
pub options: Vec<String>,
pub multi: bool,
pub asked_at: i64,
/// Absolute unix-seconds deadline after which a watchdog auto-
/// resolves the question with answer `[expired]`. `None` = no
/// expiry. Surfaced on the dashboard as a remaining-time chip.
pub deadline_at: Option<i64>,
pub answered_at: Option<i64>,
pub answer: Option<String>,
}
@ -68,7 +84,7 @@ impl OperatorQuestions {
.with_context(|| format!("open operator_questions db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply operator_questions schema")?;
ensure_multi_column(&conn).context("migrate operator_questions.multi")?;
ensure_columns(&conn).context("migrate operator_questions columns")?;
Ok(Self {
conn: Mutex::new(conn),
})
@ -80,13 +96,22 @@ impl OperatorQuestions {
question: &str,
options: &[String],
multi: bool,
deadline_at: Option<i64>,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
conn.execute(
"INSERT INTO operator_questions (asker, question, options_json, multi, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![asker, question, options_json, i64::from(multi), now_unix()],
"INSERT INTO operator_questions
(asker, question, options_json, multi, deadline_at, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
asker,
question,
options_json,
i64::from(multi),
deadline_at,
now_unix(),
],
)?;
Ok(conn.last_insert_rowid())
}
@ -119,7 +144,7 @@ impl OperatorQuestions {
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
FROM operator_questions WHERE id = ?1",
params![id],
row_to_question,
@ -131,7 +156,7 @@ impl OperatorQuestions {
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
FROM operator_questions
WHERE answered_at IS NULL
ORDER BY id ASC",
@ -155,6 +180,7 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
asked_at: row.get(5)?,
answered_at: row.get(6)?,
answer: row.get(7)?,
deadline_at: row.get(8)?,
})
}

View file

@ -146,6 +146,19 @@ pub struct Message {
pub body: String,
}
/// One row of a broker inbox query — what the dashboard renders in
/// its operator-inbox section and what a per-agent web UI returns
/// from a `Recent` request. Lives in `hive_sh4re` so it can travel
/// over both the dashboard's `/api/state` and the agent socket
/// without an internal-to-wire conversion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxRow {
pub id: i64,
pub from: String,
pub body: String,
pub at: i64,
}
/// Requests on a per-agent socket. The agent's identity is the socket
/// it came in on; `Send.from` is filled in by the server, not the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -163,6 +176,10 @@ pub enum AgentRequest {
/// per-agent equivalent of the old dashboard T4LK form, but scoped to
/// the agent whose page the operator is on.
OperatorMsg { body: String },
/// Last `limit` messages addressed to this agent, newest-first.
/// Non-mutating — pulls from the broker without delivering. The
/// per-agent web UI uses this to render its own inbox section.
Recent { limit: u64 },
}
/// Responses on a per-agent socket.
@ -179,6 +196,8 @@ pub enum AgentResponse {
Empty,
/// `Status` result: how many pending messages are in this agent's inbox.
Status { unread: u64 },
/// `Recent` result: newest-first inbox rows.
Recent { rows: Vec<InboxRow> },
}
// -----------------------------------------------------------------------------
@ -264,6 +283,11 @@ pub enum ManagerRequest {
OperatorMsg {
body: String,
},
/// Last `limit` messages addressed to the manager, newest-first.
/// Non-mutating; mirror of `AgentRequest::Recent`.
Recent {
limit: u64,
},
/// Submit a spawn request for the user to approve. On approval the host
/// creates and starts the container. Brand-new agent names only — if an
/// agent of the same name already exists, the approval will fail.
@ -299,12 +323,18 @@ pub enum ManagerRequest {
/// - `multi=true` lets the operator pick multiple options (rendered
/// as checkboxes). The answer is returned as a single string with
/// selections joined by ", ".
/// - `ttl_seconds`: optional auto-cancel after that many seconds. On
/// expiry the question is resolved with answer `[expired]` and the
/// manager gets the usual `OperatorAnswered` event. None = wait
/// forever for an operator answer (or manual cancel).
AskOperator {
question: String,
#[serde(default)]
options: Vec<String>,
#[serde(default)]
multi: bool,
#[serde(default)]
ttl_seconds: Option<u64>,
},
}
@ -329,4 +359,8 @@ pub enum ManagerResponse {
QuestionQueued {
id: i64,
},
/// `Recent` result: mirror of `AgentResponse::Recent`.
Recent {
rows: Vec<InboxRow>,
},
}