diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index f363009d..88e8a29d 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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, diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index fdaecc20..799682a9 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -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) -> axum::Json { }) } +/// Lean snapshot of the agent-owned fields that the dashboard card +/// needs. Served at `GET /api/dashboard-state` (accessible through the +/// gateway at `/agent//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, + /// 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, + /// 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, + /// 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, +} + +/// 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, Option) { + 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) -> axum::Json { + 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`