From 4375ab6246083b5105440dd5e90b2dab7c685aaa Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 27 Jun 2026 19:40:37 +0200 Subject: [PATCH 1/3] feat(dash): show active model badge on agent cards (closes #2069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the persisted model name from each agent's harness state file (harness/hyperhive-model) and surface it as a small blue badge on the container row in the SW4RM tab. - container_view.rs: add `active_model: Option` to ContainerView; populated by new `read_active_model` helper that reads harness/hyperhive-model; only set when container is running (stale model info from a stopped agent is misleading) - container_view.rs: add active_model to ContainerView literal in host_stats test helper - tabs.js: render badge-model chip after needs-update, before reminders; add active_model to the row fingerprint so re-renders fire on model change - common.css: add .badge-model (blue, 80% opacity — informational) --- frontend/packages/dashboard/src/common.css | 5 ++++ frontend/packages/dashboard/src/tabs.js | 7 ++++++ hive-c0re/src/container_view.rs | 29 ++++++++++++++++++++++ hive-c0re/src/host_stats.rs | 1 + 4 files changed, 42 insertions(+) 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-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 28821cda..7d1d6ea5 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -44,6 +44,12 @@ 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 + /// `harness/hyperhive-model`. `None` when the agent has never started + /// a turn or the file 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 +84,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 +102,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { deployed_sha, pending_reminders, parent, + active_model, }); } out @@ -190,6 +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". +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()) + } +} + /// 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, } } From c580d721fb95d0d5e77b9ca3e06351e60f1a83ed Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 27 Jun 2026 21:06:54 +0200 Subject: [PATCH 2/3] refactor(dash): fold active_model into hyperhive-harness.json, not a separate file hive-c0re was reading harness/hyperhive-model directly to surface the model badge on the dashboard. hyperhive-model is a runtime-override file (not the resolved priority) and adds to the marker-file count. Instead: mirror the fully-resolved model into hyperhive-harness.json (the consolidated state file that already replaced hyperhive-rate-limited / hyperhive-needs-login). Written by hive-ag3nt on: - Bus::new() startup (captures nix config > override > default) - set_model() runtime change (MCP set-model call) - emit_status() (keeps model current across rate-limit / auth flips) hive-c0re reads active_model from hyperhive-harness.json, same dir + same read path as rate_limited / needs_login. No new files. --- hive-ag3nt/src/events.rs | 43 ++++++++++++++++++++++++--------- hive-c0re/src/container_view.rs | 24 +++++++++--------- 2 files changed, 45 insertions(+), 22 deletions(-) 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 From c6639fe093a2100105f33b8770fd713d6cd96f77 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 27 Jun 2026 21:14:07 +0200 Subject: [PATCH 3/3] docs: fix stale field doc for ContainerView::active_model The field was originally backed by harness/hyperhive-model; after the rework (fab6259d) it reads from state/hyperhive-harness.json. Update the struct-level doc comment to match. --- hive-c0re/src/container_view.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 2b11bea1..74c41dd1 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -45,9 +45,10 @@ pub struct ContainerView { #[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option, /// The Claude model the agent's harness is currently using, read from - /// `harness/hyperhive-model`. `None` when the agent has never started - /// a turn or the file is absent. Only meaningful when `running` is - /// true; the dashboard skips the badge for stopped containers. + /// `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, }