feat(#1292): add GET /api/dashboard-state to hive-ag3nt

hive-ag3nt now exposes a lean DashboardState endpoint that returns the
agent-owned fields the dashboard card needs:

  - status_text / status_set_at  (from hyperhive-status on disk)
  - ctx_tokens / context_window_tokens  (from Bus)
  - rate_limited  (from Bus)
  - links  (from agent_links — includes screen link for GUI agents,
            which c0re's disk-based fallback cannot determine)

The dashboard fetches `${containerBase}/api/dashboard-state` instead of
the previous c0re proxy `/api/agent/{name}/links`. For gateway deployments
this is a same-origin call to the agent via the gateway's unix-socket
upstream; for direct TCP it hits the agent port directly. Both paths fail
gracefully (empty strip) when the agent is starting up.

The main behaviour fix: GUI agents now show the screen link in the dashboard
nav strip. c0re's build_nav_links reads /etc/hyperhive/gui.json from outside
the container (not possible), so it always omitted the screen link.
This commit is contained in:
iris 2026-06-04 19:16:40 +02:00
commit 40845c21cd
2 changed files with 92 additions and 11 deletions

View file

@ -724,11 +724,12 @@ window.marked = marked;
head.append(
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
);
// Icon-only nav strip — populated async from `/api/agent/{name}/links`,
// a same-origin proxy that forwards the agent backend's own link list
// (stats / screen-if-gui / forge profile / agent-configs / extras).
// The agent backend is the single source of truth; no hardcoded link
// list here. DOM-built — link strings come from the agent's process
// Icon-only nav strip — populated async from the agent's own
// `GET /api/dashboard-state` (via gateway when enabled, direct
// TCP otherwise). The agent is the single source of truth for its
// link list: stats / screen (GUI agents only — c0re's disk-based
// fallback cannot detect this) / forge profile / agent-configs /
// extras. DOM-built — link strings come from the agent's process
// and must never reach the HTML parser.
const navStrip = el('span', { class: 'nav-strip' });
head.append(navStrip);
@ -742,11 +743,14 @@ window.marked = marked;
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
if (c.running) {
fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
.then((r) => (r.ok ? r.json() : []))
.then((links) => {
if (!Array.isArray(links)) return;
for (const lnk of links) {
// Fetch the lean dashboard-state snapshot from the agent directly.
// Fails gracefully (empty strip) when the agent is starting up
// or the gateway is not yet routing to it.
fetch(`${containerBase}/api/dashboard-state`)
.then((r) => (r.ok ? r.json() : null))
.then((ds) => {
if (!ds || !Array.isArray(ds.links)) return;
for (const lnk of ds.links) {
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
: lnk.kind === 'external' ? (lnk.url || '')
: /* container */ containerBase + '/' + (lnk.url || '');
@ -762,7 +766,7 @@ window.marked = marked;
navStrip.append(a);
}
})
.catch(() => { /* graceful: agent down → no strip */ });
.catch(() => { /* graceful: agent starting / gateway miss → no strip */ });
}
// Status / runtime badges. Pending transients always win
// (start / stop / restart / rebuild is in progress). Otherwise,

View file

@ -109,6 +109,7 @@ pub async fn serve(
};
let app = Router::new()
.route("/api/state", get(api_state))
.route("/api/dashboard-state", get(api_dashboard_state))
.route("/events/stream", get(events_stream))
.route("/events/history", get(events_history))
.route("/send", post(post_send))
@ -564,6 +565,82 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
})
}
/// Lean snapshot of the agent-owned fields that the dashboard card
/// needs. Served at `GET /api/dashboard-state` (accessible through the
/// gateway at `/agent/<name>/api/dashboard-state`). The dashboard
/// fetches this once per running agent to get fresh, agent-authoritative
/// values instead of relying on hive-c0re's periodic file-reads.
///
/// Structural fields (running, needs_update, deployed_sha, parent, …)
/// continue to come from hive-c0re's `/api/state`; this endpoint covers
/// only the fields the agent itself is the source of truth for.
#[derive(serde::Serialize)]
struct DashboardState {
/// Free-text status set by `set_status`, read directly from the
/// `hyperhive-status` file the harness writes. `None` when unset.
#[serde(skip_serializing_if = "Option::is_none")]
status_text: Option<String>,
/// Unix timestamp (seconds) when the status file was last written.
/// `None` when no status is set.
#[serde(skip_serializing_if = "Option::is_none")]
status_set_at: Option<i64>,
/// Input token count from the most recent completed turn (`ctx_usage.input_tokens`).
/// `None` until the first turn finishes.
#[serde(skip_serializing_if = "Option::is_none")]
ctx_tokens: Option<u64>,
/// Effective context-window budget for the current model. Same
/// derivation as `StateSnapshot::context_window_tokens`.
context_window_tokens: u64,
/// True while the harness is parked after a rate-limit response.
rate_limited: bool,
/// Navigation links for the dashboard card's icon strip. This is
/// the authoritative source — includes the screen link (GUI agents)
/// which hive-c0re's disk-based fallback cannot determine.
links: Vec<AgentLink>,
}
/// Read the agent's own free-text status and the timestamp when it was
/// set, directly from the `hyperhive-status` file in the state dir.
/// Mirrors `hive_c0re::container_view::read_agent_status` but runs
/// inside the agent container using its own state dir.
fn read_own_status() -> (Option<String>, Option<i64>) {
let path = crate::paths::state_dir().join("hyperhive-status");
let meta = std::fs::metadata(&path).ok();
let text = std::fs::read_to_string(&path)
.ok()
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_owned);
let mtime = meta.and_then(|m| {
m.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
})
});
if text.is_none() { (None, None) } else { (text, mtime) }
}
async fn api_dashboard_state(State(state): State<AppState>) -> axum::Json<DashboardState> {
let (status_text, status_set_at) = read_own_status();
let rate_limited = state.bus.is_rate_limited();
let model = state.bus.model();
let context_window_tokens = state
.bus
.api_context_window()
.unwrap_or_else(|| crate::events::context_window_tokens(&model));
let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.input_tokens);
axum::Json(DashboardState {
status_text,
status_set_at,
ctx_tokens,
context_window_tokens,
rate_limited,
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
})
}
/// Build the navigation link list for the agent page header. URLs
/// are paths (relative) for `Container`/`Forge` targets and absolute
/// for `External`; the frontend resolves each against its `kind`