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 7d1d6ea5..2b11bea1 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -205,18 +205,20 @@ pub async fn read_agent_status_live(name: &str) -> (Option, Option, (text, set_at, true) } -/// Read the active Claude model from the agent's harness state file -/// (`harness/hyperhive-model`). Returns `None` when the file is absent -/// or empty — callers should treat `None` as "model not yet known". +/// 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_harness_dir(name).join("hyperhive-model"); - let s = std::fs::read_to_string(path).ok()?; - let trimmed = s.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } + 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