diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index ea353cbe..ec677ed0 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -85,6 +85,11 @@ code { color: var(--purple); border-color: var(--purple); text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent); } +/* Active Claude model badge on dashboard container rows. */ +.badge-model { + color: var(--blue); border-color: var(--blue); + opacity: 0.8; +} /* Context-window usage badges on dashboard container rows. */ .badge-ctx-ok { color: var(--green); border-color: var(--green); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 8b9f4c5a..6220ac44 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -521,6 +521,7 @@ window.marked = marked; needs_login: c.needs_login, needs_update: c.needs_update, pending_reminders: c.pending_reminders, + active_model: c.active_model, port: c.port, pending, opRunning, @@ -726,6 +727,12 @@ window.marked = marked; )); } + if (c.active_model) { + head.append(el('span', + { class: 'badge badge-model', title: `active claude model: ${c.active_model}` }, + c.active_model)); + } + if (c.pending_reminders && c.pending_reminders > 0) { head.append(el('span', { diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index e4144324..b255d3f3 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -106,7 +106,7 @@ fn harness_json_path() -> PathBuf { crate::paths::state_dir().join(HARNESS_JSON) } -fn read_harness_state() -> (bool, bool) { +fn read_harness_state() -> (bool, bool, Option) { // Try the new consolidated file first. if let Ok(raw) = std::fs::read_to_string(harness_json_path()) && let Ok(v) = serde_json::from_str::(&raw) @@ -119,24 +119,34 @@ fn read_harness_state() -> (bool, bool) { .get("needs_login") .and_then(serde_json::Value::as_bool) .unwrap_or(false); - return (rate_limited, needs_login); + let active_model = v + .get("active_model") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + return (rate_limited, needs_login, active_model); } // Fall back to legacy sentinel files written by older harness builds. let state_dir = crate::paths::state_dir(); let rate_limited = state_dir.join("hyperhive-rate-limited").exists(); let needs_login = state_dir.join("hyperhive-needs-login").exists(); - (rate_limited, needs_login) + (rate_limited, needs_login, None) } /// Write harness state atomically via a `.tmp` + `rename` pair so -/// hive-c0re never reads a partial file. -fn write_harness_state(rate_limited: bool, needs_login: bool) { +/// hive-c0re never reads a partial file. Pass `active_model: Some(s)` +/// to include the resolved model (as surfaced in the dashboard badge); +/// `None` omits the field, which hive-c0re treats as "not yet known". +fn write_harness_state(rate_limited: bool, needs_login: bool, active_model: Option<&str>) { let path = harness_json_path(); - let body = serde_json::json!({ + let mut json = serde_json::json!({ "rate_limited": rate_limited, "needs_login": needs_login, - }) - .to_string(); + }); + if let Some(model) = active_model { + json["active_model"] = serde_json::Value::String(model.to_string()); + } + let body = json.to_string(); let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, &body).is_ok() { let _ = std::fs::rename(&tmp, &path); @@ -688,7 +698,12 @@ impl Bus { // Restore rate_limited (and needs_login) from the consolidated // harness state file so the dashboard shows the correct status // on cold load if the harness crashed while parked. - let (was_rate_limited, _was_needs_login) = read_harness_state(); + let (was_rate_limited, was_needs_login, _) = read_harness_state(); + // Write the resolved active model to hyperhive-harness.json on + // startup so hive-c0re can surface the model badge without reading + // hyperhive-model directly. Written once here; updated on every + // set_model() call (runtime override) and emit_status() call. + write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model)); Self { tx: Arc::new(tx), event_seq: Arc::new(AtomicU64::new(0)), @@ -797,6 +812,11 @@ impl Bus { if let Err(e) = persist_model(&value) { tracing::warn!(error = ?e, "model: persist failed"); } + // Mirror the resolved model into hyperhive-harness.json so + // hive-c0re can surface it on the dashboard badge without reading + // the hyperhive-model override file directly. + let (rate_limited, needs_login, _) = read_harness_state(); + write_harness_state(rate_limited, needs_login, Some(&value)); self.emit(LiveEvent::ModelChanged { model: value }); } @@ -1037,7 +1057,7 @@ impl Bus { // turns, so the two callers never overlap. Documented rather than // locked because adding a Mutex here would be overkill for the // actual call pattern. - let (_, current_needs_login) = read_harness_state(); + let (_, current_needs_login, _) = read_harness_state(); let new_needs_login = if status == "needs_login_idle" { true } else if status == "online" { @@ -1045,7 +1065,8 @@ impl Bus { } else { current_needs_login }; - write_harness_state(new_rate_limited, new_needs_login); + let current_model = self.model.lock().unwrap().clone(); + write_harness_state(new_rate_limited, new_needs_login, Some(¤t_model)); self.emit(LiveEvent::StatusChanged { status }); } diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 28821cda..74c41dd1 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -44,6 +44,13 @@ pub struct ContainerView { /// in the tree. See `docs/agent-hierarchy.md::Current state`. #[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option, + /// The Claude model the agent's harness is currently using, read from + /// `state/hyperhive-harness.json["active_model"]`. `None` when the + /// agent has never started a turn or the field is absent. Only + /// meaningful when `running` is true; the dashboard skips the badge + /// for stopped containers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_model: Option, } /// Build the full container list. Wraps `lifecycle::list()` and @@ -78,6 +85,14 @@ pub async fn build_all(coord: &Coordinator) -> Vec { let needs_login = running && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) || auth_failed_sentinel(&logical)); + // Read the active model from the harness state file. Only surfaced + // when the container is running — stale model info from a stopped + // agent is misleading (the model may change on next boot). + let active_model = if running { + read_active_model(&logical) + } else { + None + }; out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running, @@ -88,6 +103,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { deployed_sha, pending_reminders, parent, + active_model, }); } out @@ -190,6 +206,22 @@ pub async fn read_agent_status_live(name: &str) -> (Option, Option, (text, set_at, true) } +/// Read the active Claude model from `hyperhive-harness.json` (the +/// consolidated harness state file in the agent's state dir). Written by +/// the harness on startup and on every `set_model` / `emit_status` call, +/// so it always reflects the resolved priority (nix config > runtime +/// override > default). Returns `None` when the field is absent or the +/// harness has not yet started a turn. +fn read_active_model(name: &str) -> Option { + let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json"); + let raw = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + v.get("active_model") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + /// Host-side hive + swarm display names, read from the c0re service's /// own process env. The `hive-c0re.nix` module sets these from /// `services.hyperhive.{hiveName, swarmName}`. The agent-side diff --git a/hive-c0re/src/host_stats.rs b/hive-c0re/src/host_stats.rs index 819c90d0..4e16da55 100644 --- a/hive-c0re/src/host_stats.rs +++ b/hive-c0re/src/host_stats.rs @@ -205,6 +205,7 @@ mod tests { deployed_sha: None, pending_reminders: 0, parent: None, + active_model: None, } }