diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 460264a..1a93359 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -54,6 +54,7 @@ pub enum SocketReply { role: String, hyperhive_rev: Option, status_text: Option, + status_set_at: Option, }, } @@ -76,11 +77,13 @@ impl From for SocketReply { role, hyperhive_rev, status_text, + status_set_at, } => Self::Whoami { name, role, hyperhive_rev, status_text, + status_set_at, }, } } @@ -106,11 +109,13 @@ impl From for SocketReply { role, hyperhive_rev, status_text, + status_set_at, } => Self::Whoami { name, role, hyperhive_rev, status_text, + status_set_at, }, } } @@ -275,11 +280,22 @@ pub fn format_whoami(resp: Result) -> String { role, hyperhive_rev, status_text, + status_set_at, }) => { let rev = hyperhive_rev.as_deref().unwrap_or(""); let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}"); if let Some(s) = status_text { - out.push_str(&format!("\nstatus: {s}")); + let age = status_set_at.and_then(|ts| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + let secs = now.saturating_sub(ts as u64); + Some(format_age_secs(secs)) + }); + if let Some(a) = age { + out.push_str(&format!("\nstatus: {s} (set {a} ago)")); + } else { + out.push_str(&format!("\nstatus: {s}")); + } } out } @@ -289,6 +305,19 @@ pub fn format_whoami(resp: Result) -> String { } } +/// Format a duration in seconds as a human-readable age string. +fn format_age_secs(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86400 { + format!("{}h", secs / 3600) + } else { + format!("{}d", secs / 86400) + } +} + /// Common envelope around every MCP tool handler: pre-log → run → /// post-log. The inbox-status hint used to be appended to every tool /// result; that lives in the wake prompt + UI header now, so tool diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index ccd24dc..7d14054 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -17,6 +17,8 @@ // ─── helpers ──────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); + const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m` + : s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`; const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) ); @@ -729,9 +731,16 @@ // ── agent status text ───────────────────────────────────────── if (c.status_text) { - body.append(el('div', { class: 'agent-status', title: 'agent self-reported status' }, + const nowUnix = Math.floor(Date.now() / 1000); + const ageStr = c.status_set_at != null + ? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : ''; + body.append(el('div', { + class: 'agent-status', + title: `agent self-reported status${ageStr}`, + }, el('span', { class: 'status-icon' }, '◈ '), c.status_text, + el('span', { class: 'status-age' }, ageStr), )); } diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 32a223b..248f523 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -216,6 +216,7 @@ a:hover { text-overflow: ellipsis; } .agent-status .status-icon { opacity: 0.65; } +.agent-status .status-age { opacity: 0.5; font-size: 0.9em; margin-left: 0.2em; } .container-row.tombstone { border-style: dashed; diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index c0ac215..f009c45 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -221,12 +221,13 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } } AgentRequest::Whoami => { - let status_text = crate::container_view::read_agent_status_text(agent); + let (status_text, status_set_at) = crate::container_view::read_agent_status(agent); AgentResponse::Whoami { name: agent.to_owned(), role: "agent".to_owned(), hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), status_text, + status_set_at, } } AgentRequest::SetStatus { text } => { diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 4b780ee..13befa3 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -82,6 +82,11 @@ pub struct ContainerView { /// is absent or empty — the agent hasn't set one yet. #[serde(default, skip_serializing_if = "Option::is_none")] pub status_text: Option, + /// Unix timestamp (seconds since epoch) when the status was last written. + /// Derived from the `hyperhive-status` file's mtime. `None` when no + /// status is set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_set_at: Option, } /// Build the full container list. Wraps `lifecycle::list()` and @@ -124,7 +129,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); let rate_limited = is_rate_limited(&logical); let extra_links = read_dashboard_links(&logical); - let status_text = read_status_text(&logical); + let (status_text, status_set_at) = read_status(&logical); out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -140,6 +145,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { rate_limited, extra_links, status_text, + status_set_at, }); } out @@ -179,18 +185,25 @@ fn is_rate_limited(name: &str) -> bool { .exists() } -/// Read the agent's free-text status set via `mcp__hyperhive__set_status`. -/// Returns `None` when the file is absent or empty — best-effort, never panics. -/// `pub` so `agent_server` and `manager_server` can include it in `Whoami` responses. -pub fn read_agent_status_text(name: &str) -> Option { +/// Read the agent's free-text status and the Unix timestamp when it was last set +/// (derived from the file's mtime). Returns `(None, None)` when the file is absent +/// or empty. `pub` so `agent_server` and `manager_server` can populate `Whoami`. +pub fn read_agent_status(name: &str) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); - let s = std::fs::read_to_string(path).ok()?; - let trimmed = s.trim(); - if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } + let meta = std::fs::metadata(&path).ok(); + let s = std::fs::read_to_string(&path).ok(); + let text = s.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) } } -fn read_status_text(name: &str) -> Option { - read_agent_status_text(name) +fn read_status(name: &str) -> (Option, Option) { + read_agent_status(name) } /// Read the agent's most recent completed turn from its turn-stats diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index c13246d..ab98d03 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -481,12 +481,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } } ManagerRequest::Whoami => { - let status_text = crate::container_view::read_agent_status_text(MANAGER_AGENT); + let (status_text, status_set_at) = + crate::container_view::read_agent_status(MANAGER_AGENT); ManagerResponse::Whoami { name: MANAGER_AGENT.to_owned(), role: "manager".to_owned(), hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), status_text, + status_set_at, } } ManagerRequest::SetStatus { text } => { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 42aa6fd..3b2ab59 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -513,6 +513,8 @@ pub enum AgentResponse { /// response). `hyperhive_rev` is `None` only when the configured /// flake URL has no canonical path. `status_text` is the last /// value written via `SetStatus`, or `None` if none has been set. + /// `status_set_at` is a Unix timestamp (seconds since epoch) of + /// when the status was last written; `None` when no status is set. Whoami { name: String, role: String, @@ -520,6 +522,8 @@ pub enum AgentResponse { hyperhive_rev: Option, #[serde(default, skip_serializing_if = "Option::is_none")] status_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_set_at: Option, }, } @@ -936,5 +940,7 @@ pub enum ManagerResponse { hyperhive_rev: Option, #[serde(default, skip_serializing_if = "Option::is_none")] status_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_set_at: Option, }, }