From fe2933b21361cf22872404afa06b535591559437 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 23 May 2026 01:04:49 +0200 Subject: [PATCH 1/4] feat: add set_status MCP tool and status field to whoami/dashboard (closes #325) --- hive-ag3nt/prompts/agent.md | 3 +- hive-ag3nt/src/mcp.rs | 58 ++++++++++++++++++++++++++++++--- hive-c0re/assets/app.js | 8 +++++ hive-c0re/assets/dashboard.css | 10 ++++++ hive-c0re/src/agent_server.rs | 38 ++++++++++++++++++--- hive-c0re/src/container_view.rs | 21 ++++++++++++ hive-c0re/src/manager_server.rs | 35 +++++++++++++++++--- hive-sh4re/src/lib.rs | 13 +++++++- 8 files changed, 170 insertions(+), 16 deletions(-) diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 8d204f4d..46cacc28 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -10,7 +10,8 @@ Tools (hyperhive surface): - `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology. - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by ]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. -- `mcp__hyperhive__whoami()` — self-introspection: returns your canonical agent name (from socket identity, not the prompt-substituted label), role, and current hyperhive rev. No args. Use it when you want a trustworthy identity stamp for state files, commit messages, or cross-agent attribution that won't drift across renames. +- `mcp__hyperhive__whoami()` — self-introspection: returns your canonical agent name (from socket identity, not the prompt-substituted label), role, and current hyperhive rev. No args. Use it when you want a trustworthy identity stamp for state files, commit messages, or cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. +- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"reviewing PR #42"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts. - `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 3ae9b5e2..460264ae 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -53,6 +53,7 @@ pub enum SocketReply { name: String, role: String, hyperhive_rev: Option, + status_text: Option, }, } @@ -74,10 +75,12 @@ impl From for SocketReply { name, role, hyperhive_rev, + status_text, } => Self::Whoami { name, role, hyperhive_rev, + status_text, }, } } @@ -102,10 +105,12 @@ impl From for SocketReply { name, role, hyperhive_rev, + status_text, } => Self::Whoami { name, role, hyperhive_rev, + status_text, }, } } @@ -269,9 +274,14 @@ pub fn format_whoami(resp: Result) -> String { name, role, hyperhive_rev, + status_text, }) => { let rev = hyperhive_rev.as_deref().unwrap_or(""); - format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}") + let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}"); + if let Some(s) = status_text { + out.push_str(&format!("\nstatus: {s}")); + } + out } Ok(SocketReply::Err(m)) => format!("whoami failed: {m}"), Ok(other) => format!("whoami unexpected response: {other:?}"), @@ -551,8 +561,9 @@ impl AgentServer { #[tool( description = "Self-introspection: returns your own canonical agent name (the \ socket-identity name, NOT the prompt-substituted label), role (`agent`), and \ - the current hyperhive rev hive-c0re is running against. No args. Useful when \ - you want a trustworthy identity stamp for state files / commit messages / \ + the current hyperhive rev hive-c0re is running against. Also returns the \ + current `status` text if one has been set via `set_status`. No args. Useful \ + when you want a trustworthy identity stamp for state files / commit messages / \ cross-agent attribution that won't drift across renames or session-continue \ boundaries where the system-prompt label could be stale." )] @@ -564,6 +575,22 @@ impl AgentServer { .await } + #[tool( + description = "Set a free-text status string visible on the operator dashboard. \ + Call this at the START of every task to describe what you're working on (e.g. \ + `\"reviewing PR #42\"`, `\"fixing bitburner crash\"`, `\"idle\"`). Pass an empty \ + string to clear. The status is shown on your dashboard card and persists across \ + harness restarts." + )] + async fn set_status(&self, Parameters(args): Parameters) -> String { + run_tool_envelope("set_status", args.text.clone(), async move { + let (resp, retries) = + self.dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text }).await; + annotate_retries(format_ack(resp, "set_status", "status updated".to_owned()), retries) + }) + .await + } + #[tool( description = "Cancel an open thread you own — a `question` you asked (the \ asker gets `[cancelled by ]` as the answer and unblocks) or a `reminder` \ @@ -724,6 +751,12 @@ pub struct KillArgs { pub name: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SetStatusArgs { + /// Status text to display on the dashboard card. Pass an empty string to clear. + pub text: String, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct StartArgs { /// Sub-agent name (without the `h-` container prefix). @@ -1246,7 +1279,8 @@ impl ManagerServer { #[tool( description = "Self-introspection for the manager: returns canonical name \ - (`manager`), role (`manager`), and the current hyperhive rev. Same shape as \ + (`manager`), role (`manager`), and the current hyperhive rev. Also returns \ + the current `status` text if one has been set via `set_status`. Same shape as \ the agent flavour; useful for cross-agent attribution / boot announcements / \ state-file headers without trusting prompt substitution." )] @@ -1258,6 +1292,20 @@ impl ManagerServer { .await } + #[tool( + description = "Set a free-text status string visible on the operator dashboard. \ + Call this at the START of every task to describe what you're working on. \ + Pass an empty string to clear. Persists across harness restarts." + )] + async fn set_status(&self, Parameters(args): Parameters) -> String { + run_tool_envelope("set_status", args.text.clone(), async move { + let (resp, retries) = + self.dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text }).await; + annotate_retries(format_ack(resp, "set_status", "status updated".to_owned()), retries) + }) + .await + } + #[tool( description = "Cancel any open thread in the swarm — a `question` (cancels \ with the operator-override sentinel so the asker unblocks) or a `reminder` \ @@ -1377,6 +1425,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "remind", "get_loose_ends", "whoami", + "set_status", "cancel_loose_end", ], Flavor::Manager => &[ @@ -1395,6 +1444,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "get_loose_ends", "remind", "whoami", + "set_status", "cancel_loose_end", ], }; diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 807484a1..ccd24dc3 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -727,6 +727,14 @@ } body.append(head); + // ── agent status text ───────────────────────────────────────── + if (c.status_text) { + body.append(el('div', { class: 'agent-status', title: 'agent self-reported status' }, + el('span', { class: 'status-icon' }, '◈ '), + c.status_text, + )); + } + // ── action buttons ─────────────────────────────────────────── const actions = el('div', { class: 'actions' }); if (c.running) { diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 6a96df73..32a223b6 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -207,6 +207,16 @@ a:hover { color: var(--red); border-color: var(--red); text-shadow: 0 0 6px rgba(243, 139, 168, 0.5); } +.agent-status { + font-size: 0.82em; + color: var(--subtext0, #a6adc8); + padding: 0.1em 0.3em 0.25em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.agent-status .status-icon { opacity: 0.65; } + .container-row.tombstone { border-style: dashed; background: rgba(24, 24, 37, 0.35); diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 2df2a099..c0ac2157 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -220,11 +220,39 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> }, } } - AgentRequest::Whoami => AgentResponse::Whoami { - name: agent.to_owned(), - role: "agent".to_owned(), - hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), - }, + AgentRequest::Whoami => { + let status_text = crate::container_view::read_agent_status_text(agent); + AgentResponse::Whoami { + name: agent.to_owned(), + role: "agent".to_owned(), + hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), + status_text, + } + } + AgentRequest::SetStatus { text } => { + let path = crate::coordinator::Coordinator::agent_notes_dir(agent) + .join("hyperhive-status"); + let result = if text.trim().is_empty() { + // Empty = clear: remove the file (ignore missing). + std::fs::remove_file(&path) + .or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + }) + } else { + std::fs::write(&path, format!("{}\n", text.trim())) + }; + match result { + Ok(()) => { + // Kick a container rescan so the dashboard updates live. + let coord2 = Arc::clone(coord); + tokio::spawn(async move { coord2.rescan_containers_and_emit().await }); + AgentResponse::Ok + } + Err(e) => AgentResponse::Err { message: format!("set_status write failed: {e}") }, + } + } AgentRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end( coord, agent, *kind, *id, ) diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index b748998b..4b780eea 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -77,6 +77,11 @@ pub struct ContainerView { /// the file is absent or the agent declares no links. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extra_links: Vec, + /// Free-text status set by the agent via `mcp__hyperhive__set_status`. + /// Persisted to `{state_dir}/hyperhive-status`. `None` when the file + /// is absent or empty — the agent hasn't set one yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_text: Option, } /// Build the full container list. Wraps `lifecycle::list()` and @@ -119,6 +124,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); out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -133,6 +139,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { context_window_tokens, rate_limited, extra_links, + status_text, }); } out @@ -172,6 +179,20 @@ 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 { + 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()) } +} + +fn read_status_text(name: &str) -> Option { + read_agent_status_text(name) +} + /// Read the agent's most recent completed turn from its turn-stats /// `SQLite`: the context-window size (prompt tokens) and the model name. /// Returns `None` when the file is absent or has no rows. Best-effort diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 7e682114..c13246d4 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -480,11 +480,36 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp }, } } - ManagerRequest::Whoami => ManagerResponse::Whoami { - name: MANAGER_AGENT.to_owned(), - role: "manager".to_owned(), - hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), - }, + ManagerRequest::Whoami => { + let status_text = crate::container_view::read_agent_status_text(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, + } + } + ManagerRequest::SetStatus { text } => { + let path = Coordinator::agent_notes_dir(MANAGER_AGENT).join("hyperhive-status"); + let result = if text.trim().is_empty() { + std::fs::remove_file(&path) + .or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + }) + } else { + std::fs::write(&path, format!("{}\n", text.trim())) + }; + match result { + Ok(()) => { + let coord2 = Arc::clone(coord); + tokio::spawn(async move { coord2.rescan_containers_and_emit().await }); + ManagerResponse::Ok + } + Err(e) => ManagerResponse::Err { message: format!("set_status write failed: {e}") }, + } + } ManagerRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end( coord, MANAGER_AGENT, diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index c743b939..42aa6fd1 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -443,6 +443,10 @@ pub enum AgentRequest { /// identity after a rename or session-continue boundary where the /// system-prompt-substituted label is no longer reliable. Whoami, + /// Set a free-text status string visible on the dashboard. Persisted + /// to `{state_dir}/hyperhive-status` so it survives harness restarts. + /// Pass an empty string to clear the status. + SetStatus { text: String }, /// Cancel an open thread the agent owns: a `Question` they asked /// (returns `[cancelled by ]` as the answer to the asker) /// or a `Reminder` they scheduled (hard-deletes the row). @@ -507,12 +511,15 @@ pub enum AgentResponse { /// hive-c0re is running against. `role` is `"agent"` for /// sub-agents (the only path that reaches this variant of the /// response). `hyperhive_rev` is `None` only when the configured - /// flake URL has no canonical path. + /// flake URL has no canonical path. `status_text` is the last + /// value written via `SetStatus`, or `None` if none has been set. Whoami { name: String, role: String, #[serde(default, skip_serializing_if = "Option::is_none")] hyperhive_rev: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_text: Option, }, } @@ -843,6 +850,8 @@ pub enum ManagerRequest { /// Manager-flavour self-introspection. Same wire shape as /// `AgentRequest::Whoami`, but `role` is always `"manager"`. Whoami, + /// Mirror of `AgentRequest::SetStatus` on the manager surface. + SetStatus { text: String }, /// Cancel an open thread (question or reminder). Manager surface /// can cancel any row (no owner check) — same dispatch as /// `AgentRequest::CancelLooseEnd` but with privileged auth. @@ -925,5 +934,7 @@ pub enum ManagerResponse { role: String, #[serde(default, skip_serializing_if = "Option::is_none")] hyperhive_rev: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_text: Option, }, } From 8e8e8a771fc265b58fc2c03ab6353f500f779a18 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 23 May 2026 01:08:50 +0200 Subject: [PATCH 2/4] set_status: add status_set_at timestamp (mtime of status file) --- hive-ag3nt/src/mcp.rs | 31 ++++++++++++++++++++++++++++++- hive-c0re/assets/app.js | 11 ++++++++++- hive-c0re/assets/dashboard.css | 1 + hive-c0re/src/agent_server.rs | 3 ++- hive-c0re/src/container_view.rs | 33 +++++++++++++++++++++++---------- hive-c0re/src/manager_server.rs | 4 +++- hive-sh4re/src/lib.rs | 6 ++++++ 7 files changed, 75 insertions(+), 14 deletions(-) diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 460264ae..1a933597 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 ccd24dc3..7d14054b 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 32a223b6..248f5234 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 c0ac2157..f009c457 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 4b780eea..13befa35 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 c13246d4..ab98d03f 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 42aa6fd1..3b2ab597 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, }, } From 77fdaf0d1e1a7b37e106033e0250b252cd1facee Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 23 May 2026 01:25:34 +0200 Subject: [PATCH 3/4] set_status: add get_agent_meta tool for inter-agent status queries --- hive-ag3nt/prompts/agent.md | 3 +- hive-ag3nt/src/mcp.rs | 110 +++++++++++++++++++++++++++++++- hive-c0re/src/agent_server.rs | 9 +++ hive-c0re/src/manager_server.rs | 9 +++ hive-sh4re/src/lib.rs | 24 +++++++ 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 46cacc28..12f815bf 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -11,7 +11,8 @@ Tools (hyperhive surface): - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by ]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. - `mcp__hyperhive__whoami()` — self-introspection: returns your canonical agent name (from socket identity, not the prompt-substituted label), role, and current hyperhive rev. No args. Use it when you want a trustworthy identity stamp for state files, commit messages, or cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. -- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"reviewing PR #42"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts. +- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts. +- `mcp__hyperhive__get_agent_meta(name)` — fetch another agent's current status (set via `set_status`). Returns their status text and how long ago it was set. Useful for checking whether a peer is idle before sending a request. - `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 1a933597..1c3ccf23 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -56,6 +56,11 @@ pub enum SocketReply { status_text: Option, status_set_at: Option, }, + AgentMeta { + name: String, + status_text: Option, + status_set_at: Option, + }, } impl From for SocketReply { @@ -85,6 +90,15 @@ impl From for SocketReply { status_text, status_set_at, }, + hive_sh4re::AgentResponse::AgentMeta { + name, + status_text, + status_set_at, + } => Self::AgentMeta { + name, + status_text, + status_set_at, + }, } } } @@ -117,6 +131,15 @@ impl From for SocketReply { status_text, status_set_at, }, + hive_sh4re::ManagerResponse::AgentMeta { + name, + status_text, + status_set_at, + } => Self::AgentMeta { + name, + status_text, + status_set_at, + }, } } } @@ -305,6 +328,40 @@ pub fn format_whoami(resp: Result) -> String { } } +/// Format the result of a `get_agent_meta` call. +#[must_use] +pub fn format_agent_meta(resp: Result) -> String { + match resp { + Ok(SocketReply::AgentMeta { + name, + status_text, + status_set_at, + }) => { + let status = match status_text { + None => "no status set".to_owned(), + Some(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)) + }); + match age { + Some(a) => format!("{s} (set {a} ago)"), + None => s, + } + } + }; + format!("agent: {name}\nstatus: {status}") + } + Ok(SocketReply::Err(m)) => format!("get_agent_meta failed: {m}"), + Ok(other) => format!("get_agent_meta unexpected response: {other:?}"), + Err(e) => format!("get_agent_meta transport error: {e:#}"), + } +} + /// Format a duration in seconds as a human-readable age string. fn format_age_secs(secs: u64) -> String { if secs < 60 { @@ -607,7 +664,7 @@ impl AgentServer { #[tool( description = "Set a free-text status string visible on the operator dashboard. \ Call this at the START of every task to describe what you're working on (e.g. \ - `\"reviewing PR #42\"`, `\"fixing bitburner crash\"`, `\"idle\"`). Pass an empty \ + `\"processing matrix messages\"`, `\"fixing bitburner crash\"`, `\"idle\"`). Pass an empty \ string to clear. The status is shown on your dashboard card and persists across \ harness restarts." )] @@ -620,6 +677,28 @@ impl AgentServer { .await } + #[tool( + description = "Fetch the current status of another agent by name. Returns the \ + agent's self-reported status text (set via `set_status`) and how long ago it \ + was set. Useful for checking whether a peer is idle before sending a request, \ + or for the manager to get a quick overview of what each agent is doing. \ + Returns `no status set` when the agent has never called `set_status` or has \ + cleared it." + )] + async fn get_agent_meta( + &self, + Parameters(args): Parameters, + ) -> String { + let log = args.name.clone(); + run_tool_envelope("get_agent_meta", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::AgentRequest::GetAgentMeta { name: args.name }) + .await; + annotate_retries(format_agent_meta(resp), retries) + }) + .await + } + #[tool( description = "Cancel an open thread you own — a `question` you asked (the \ asker gets `[cancelled by ]` as the answer and unblocks) or a `reminder` \ @@ -786,6 +865,12 @@ pub struct SetStatusArgs { pub text: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetAgentMetaArgs { + /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). + pub name: String, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct StartArgs { /// Sub-agent name (without the `h-` container prefix). @@ -1335,6 +1420,27 @@ impl ManagerServer { .await } + #[tool( + description = "Fetch the current status of another agent by name. Returns the \ + agent's self-reported status text (set via `set_status`) and how long ago it \ + was set. Useful for the manager to get a quick overview of what each agent is \ + doing without querying the dashboard. Returns `no status set` when the agent \ + has never called `set_status` or has cleared it." + )] + async fn get_agent_meta( + &self, + Parameters(args): Parameters, + ) -> String { + let log = args.name.clone(); + run_tool_envelope("get_agent_meta", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::ManagerRequest::GetAgentMeta { name: args.name }) + .await; + annotate_retries(format_agent_meta(resp), retries) + }) + .await + } + #[tool( description = "Cancel any open thread in the swarm — a `question` (cancels \ with the operator-override sentinel so the asker unblocks) or a `reminder` \ @@ -1455,6 +1561,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "get_loose_ends", "whoami", "set_status", + "get_agent_meta", "cancel_loose_end", ], Flavor::Manager => &[ @@ -1474,6 +1581,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "remind", "whoami", "set_status", + "get_agent_meta", "cancel_loose_end", ], }; diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index f009c457..fdb5d0d9 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -254,6 +254,15 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> Err(e) => AgentResponse::Err { message: format!("set_status write failed: {e}") }, } } + AgentRequest::GetAgentMeta { name } => { + let (status_text, status_set_at) = + crate::container_view::read_agent_status(name); + AgentResponse::AgentMeta { + name: name.clone(), + status_text, + status_set_at, + } + } AgentRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end( coord, agent, *kind, *id, ) diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index ab98d03f..4864c9d1 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -512,6 +512,15 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp Err(e) => ManagerResponse::Err { message: format!("set_status write failed: {e}") }, } } + ManagerRequest::GetAgentMeta { name } => { + let (status_text, status_set_at) = + crate::container_view::read_agent_status(name); + ManagerResponse::AgentMeta { + name: name.clone(), + status_text, + status_set_at, + } + } ManagerRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end( coord, MANAGER_AGENT, diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 3b2ab597..bf7beb02 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -447,6 +447,10 @@ pub enum AgentRequest { /// to `{state_dir}/hyperhive-status` so it survives harness restarts. /// Pass an empty string to clear the status. SetStatus { text: String }, + /// Fetch the current status of another agent by name. Returns + /// `AgentResponse::AgentMeta` with the target's status fields. + /// If the agent does not exist or has never set a status, fields are `None`. + GetAgentMeta { name: String }, /// Cancel an open thread the agent owns: a `Question` they asked /// (returns `[cancelled by ]` as the answer to the asker) /// or a `Reminder` they scheduled (hard-deletes the row). @@ -525,6 +529,16 @@ pub enum AgentResponse { #[serde(default, skip_serializing_if = "Option::is_none")] status_set_at: Option, }, + /// `GetAgentMeta` result: status metadata for a named agent. + /// `status_text` / `status_set_at` are `None` when the agent has + /// not set a status or the agent name is unknown. + AgentMeta { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_set_at: Option, + }, } // ----------------------------------------------------------------------------- @@ -856,6 +870,8 @@ pub enum ManagerRequest { Whoami, /// Mirror of `AgentRequest::SetStatus` on the manager surface. SetStatus { text: String }, + /// Mirror of `AgentRequest::GetAgentMeta` on the manager surface. + GetAgentMeta { name: String }, /// Cancel an open thread (question or reminder). Manager surface /// can cancel any row (no owner check) — same dispatch as /// `AgentRequest::CancelLooseEnd` but with privileged auth. @@ -943,4 +959,12 @@ pub enum ManagerResponse { #[serde(default, skip_serializing_if = "Option::is_none")] status_set_at: Option, }, + /// Mirror of `AgentResponse::AgentMeta` on the manager surface. + AgentMeta { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status_set_at: Option, + }, } From 73871f18c3cd36357a8825d171142b3956646c67 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 23 May 2026 10:57:27 +0200 Subject: [PATCH 4/4] set_status: consolidate whoami into get_agent_meta with optional name --- docs/turn-loop.md | 22 ++-- hive-ag3nt/prompts/agent.md | 3 +- hive-ag3nt/prompts/manager.md | 3 +- hive-ag3nt/src/bin/hive-ag3nt.rs | 2 +- hive-ag3nt/src/bin/hive-m1nd.rs | 2 +- hive-ag3nt/src/mcp.rs | 170 +++++++++---------------------- hive-c0re/src/agent_server.rs | 23 ++--- hive-c0re/src/container_view.rs | 2 +- hive-c0re/src/manager_server.rs | 19 ++-- hive-sh4re/src/lib.rs | 76 ++++++-------- 10 files changed, 118 insertions(+), 204 deletions(-) diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 22765137..0bb9cd5a 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -214,8 +214,17 @@ it as a stdio child via `--mcp-config`. The hyperhive socket name is short pointer. Each agent's pending-reminder count is capped (default 50, override via `HIVE_REMIND_MAX_PENDING_PER_AGENT`); scheduling a new one fails if the cap is already hit. -- `whoami()` — `{ name, role, pronouns, hyperhive_rev }` for - self-identification without scraping the system prompt. +- `set_status(text)` — set a free-text status string visible on + the operator dashboard. Persisted to + `{state_dir}/hyperhive-status`; survives harness restarts. Pass + an empty string to clear. +- `get_agent_meta(name?)` — fetch identity + status metadata for + an agent: `{ name, role, hyperhive_rev, status_text, + status_set_at }`. Pass `name` to query a peer (e.g. check + whether a sub-agent is idle before sending it work). Omit + `name` to get your own identity stamp — replaces the previous + `whoami` tool. Status fields are `None` when the target has + never called `set_status` or has cleared it. - `request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to @@ -314,10 +323,11 @@ meta's. startup crashes, etc.). Pass the plain logical agent name; hive-c0re resolves the machine name (`h-`, manager `hm1nd`). `lines` defaults to 50, host-capped at 500. -- `remind` / `get_loose_ends` / `cancel_loose_end` / `whoami` — - same as the sub-agent tools above. `get_loose_ends` scopes to - the manager's own items by default; pass `agent: "*"` for a - hive-wide view, or `agent: ""` to inspect one agent. +- `remind` / `get_loose_ends` / `cancel_loose_end` / `set_status` + / `get_agent_meta` — same as the sub-agent tools above. + `get_loose_ends` scopes to the manager's own items by default; + pass `agent: "*"` for a hive-wide view, or `agent: ""` + to inspect one agent. `cancel_loose_end` may cancel any agent's row. The boundary: lifecycle ops on *existing* sub-agents diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 12f815bf..5b70abc7 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -10,9 +10,8 @@ Tools (hyperhive surface): - `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology. - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by ]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. -- `mcp__hyperhive__whoami()` — self-introspection: returns your canonical agent name (from socket identity, not the prompt-substituted label), role, and current hyperhive rev. No args. Use it when you want a trustworthy identity stamp for state files, commit messages, or cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. - `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts. -- `mcp__hyperhive__get_agent_meta(name)` — fetch another agent's current status (set via `set_status`). Returns their status text and how long ago it was set. Useful for checking whether a peer is idle before sending a request. +- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. - `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index f823eaa7..587ffb14 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -17,7 +17,8 @@ Tools (hyperhive surface): - `mcp__hyperhive__get_loose_ends(agent?)` — loose ends. Omit `agent` for your own: pending approvals you submitted + unanswered questions where you are asker/target + your own pending reminders. Pass `agent: "*"` for a hive-wide sweep — every pending approval, unanswered question, and reminder across the swarm — to find stalled threads (sub-agent A asked B something three days ago and B never answered) before they rot. Pass `agent: ""` to inspect one agent's threads. Cheap server-side query. - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel any question or reminder in the swarm (manager bypasses the owner check used on sub-agents). Use for hive-wide cleanup when a sub-agent is offline / can't withdraw its own ask / reminder. - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your own inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Good for deadline follow-ups — "check whether agent X answered the question I relayed". Large payloads auto-spill to a file under `/state/reminders/`; pass `file_path` to control the destination. -- `mcp__hyperhive__whoami()` — self-introspection: canonical name (`manager`), role, current hyperhive rev. No args. Useful for boot announcements and cross-agent attribution that won't drift across config reloads. +- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're doing (e.g. `"reviewing argus's #341 proposal"`, `"approving lifecycle changes"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts. +- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus the target's self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to check on a sub-agent (idle? still working on the task you assigned?) without scrolling the dashboard. Omit `name` for your own identity stamp — useful for boot announcements, state-file headers, cross-agent attribution that won't drift across config reloads. Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (two-step: `request_init_config` + `request_apply_commit`) and *changing* any agent's config (`request_apply_commit`) both go through the approval queue. The operator only signs off on changes; you run the day-to-day. diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 44c0ae66..ae237721 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -194,7 +194,7 @@ async fn serve( | AgentResponse::LooseEnds { .. } | AgentResponse::PendingRemindersCount { .. } | AgentResponse::ReminderRollup { .. } - | AgentResponse::Whoami { .. }, + | AgentResponse::AgentMeta { .. }, ) => { tracing::warn!("recv produced unexpected response kind"); } diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 21e5f3f6..d85dc231 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -160,7 +160,7 @@ async fn serve( | ManagerResponse::LooseEnds { .. } | ManagerResponse::PendingRemindersCount { .. } | ManagerResponse::ReminderRollup { .. } - | ManagerResponse::Whoami { .. }, + | ManagerResponse::AgentMeta { .. }, ) => { tracing::warn!("recv produced unexpected response kind"); } diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 1c3ccf23..ee0c3cdb 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -49,18 +49,13 @@ pub enum SocketReply { LooseEnds(Vec), PendingRemindersCount(u64), ReminderRollup(hive_sh4re::ReminderStats), - Whoami { + AgentMeta { name: String, role: String, hyperhive_rev: Option, status_text: Option, status_set_at: Option, }, - AgentMeta { - name: String, - status_text: Option, - status_set_at: Option, - }, } impl From for SocketReply { @@ -77,25 +72,16 @@ impl From for SocketReply { Self::PendingRemindersCount(count) } hive_sh4re::AgentResponse::ReminderRollup(stats) => Self::ReminderRollup(stats), - hive_sh4re::AgentResponse::Whoami { - name, - role, - hyperhive_rev, - status_text, - status_set_at, - } => Self::Whoami { - name, - role, - hyperhive_rev, - status_text, - status_set_at, - }, hive_sh4re::AgentResponse::AgentMeta { name, + role, + hyperhive_rev, status_text, status_set_at, } => Self::AgentMeta { name, + role, + hyperhive_rev, status_text, status_set_at, }, @@ -118,25 +104,16 @@ impl From for SocketReply { Self::PendingRemindersCount(count) } hive_sh4re::ManagerResponse::ReminderRollup(stats) => Self::ReminderRollup(stats), - hive_sh4re::ManagerResponse::Whoami { - name, - role, - hyperhive_rev, - status_text, - status_set_at, - } => Self::Whoami { - name, - role, - hyperhive_rev, - status_text, - status_set_at, - }, hive_sh4re::ManagerResponse::AgentMeta { name, + role, + hyperhive_rev, status_text, status_set_at, } => Self::AgentMeta { name, + role, + hyperhive_rev, status_text, status_set_at, }, @@ -292,13 +269,14 @@ fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str { } } -/// Format helper for `whoami`: renders the identity block as a short -/// human-readable string. Skips fields that are `None` so the output -/// doesn't carry dead placeholders. +/// Format helper for `get_agent_meta`: renders an agent's identity + +/// current status as a short human-readable block. `name`, `role`, and +/// `hyperhive_rev` are always shown; `status` only appears when one is +/// set, otherwise the line reads `status: `. #[must_use] -pub fn format_whoami(resp: Result) -> String { +pub fn format_agent_meta(resp: Result) -> String { match resp { - Ok(SocketReply::Whoami { + Ok(SocketReply::AgentMeta { name, role, hyperhive_rev, @@ -307,38 +285,8 @@ pub fn format_whoami(resp: Result) -> String { }) => { 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 { - 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 - } - Ok(SocketReply::Err(m)) => format!("whoami failed: {m}"), - Ok(other) => format!("whoami unexpected response: {other:?}"), - Err(e) => format!("whoami transport error: {e:#}"), - } -} - -/// Format the result of a `get_agent_meta` call. -#[must_use] -pub fn format_agent_meta(resp: Result) -> String { - match resp { - Ok(SocketReply::AgentMeta { - name, - status_text, - status_set_at, - }) => { - let status = match status_text { - None => "no status set".to_owned(), + match status_text { + None => out.push_str("\nstatus: "), Some(s) => { let age = status_set_at.and_then(|ts| { let now = std::time::SystemTime::now() @@ -349,12 +297,12 @@ pub fn format_agent_meta(resp: Result) -> String { Some(format_age_secs(secs)) }); match age { - Some(a) => format!("{s} (set {a} ago)"), - None => s, + Some(a) => out.push_str(&format!("\nstatus: {s} (set {a} ago)")), + None => out.push_str(&format!("\nstatus: {s}")), } } - }; - format!("agent: {name}\nstatus: {status}") + } + out } Ok(SocketReply::Err(m)) => format!("get_agent_meta failed: {m}"), Ok(other) => format!("get_agent_meta unexpected response: {other:?}"), @@ -644,23 +592,6 @@ impl AgentServer { .await } - #[tool( - description = "Self-introspection: returns your own canonical agent name (the \ - socket-identity name, NOT the prompt-substituted label), role (`agent`), and \ - the current hyperhive rev hive-c0re is running against. Also returns the \ - current `status` text if one has been set via `set_status`. No args. Useful \ - when you want a trustworthy identity stamp for state files / commit messages / \ - cross-agent attribution that won't drift across renames or session-continue \ - boundaries where the system-prompt label could be stale." - )] - async fn whoami(&self) -> String { - run_tool_envelope("whoami", String::new(), async move { - let (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::Whoami).await; - annotate_retries(format_whoami(resp), retries) - }) - .await - } - #[tool( description = "Set a free-text status string visible on the operator dashboard. \ Call this at the START of every task to describe what you're working on (e.g. \ @@ -678,18 +609,21 @@ impl AgentServer { } #[tool( - description = "Fetch the current status of another agent by name. Returns the \ - agent's self-reported status text (set via `set_status`) and how long ago it \ - was set. Useful for checking whether a peer is idle before sending a request, \ - or for the manager to get a quick overview of what each agent is doing. \ - Returns `no status set` when the agent has never called `set_status` or has \ - cleared it." + description = "Fetch identity + status metadata for an agent. Returns canonical \ + `name`, `role` (`agent` / `manager`), the current `hyperhive_rev` hive-c0re is \ + running against, and the target's self-reported `status` text (set via \ + `set_status`) plus how long ago it was set. Pass `name` to query a peer (e.g. \ + check whether iris is idle before pinging them); omit `name` to get your own \ + identity stamp — handy for state files / commit messages / cross-agent \ + attribution that won't drift across renames or session-continue boundaries \ + where the system-prompt label could be stale. Status reads `` when the \ + target has never called `set_status` or has cleared it." )] async fn get_agent_meta( &self, Parameters(args): Parameters, ) -> String { - let log = args.name.clone(); + let log = args.name.clone().unwrap_or_else(|| "".to_owned()); run_tool_envelope("get_agent_meta", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::GetAgentMeta { name: args.name }) @@ -868,7 +802,10 @@ pub struct SetStatusArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetAgentMetaArgs { /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). - pub name: String, + /// Omit to query your own identity + status — replaces the + /// previous `whoami` self-introspection tool. + #[serde(default)] + pub name: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -1391,21 +1328,6 @@ impl ManagerServer { .await } - #[tool( - description = "Self-introspection for the manager: returns canonical name \ - (`manager`), role (`manager`), and the current hyperhive rev. Also returns \ - the current `status` text if one has been set via `set_status`. Same shape as \ - the agent flavour; useful for cross-agent attribution / boot announcements / \ - state-file headers without trusting prompt substitution." - )] - async fn whoami(&self) -> String { - run_tool_envelope("whoami", String::new(), async move { - let (resp, retries) = self.dispatch(hive_sh4re::ManagerRequest::Whoami).await; - annotate_retries(format_whoami(resp), retries) - }) - .await - } - #[tool( description = "Set a free-text status string visible on the operator dashboard. \ Call this at the START of every task to describe what you're working on. \ @@ -1421,17 +1343,20 @@ impl ManagerServer { } #[tool( - description = "Fetch the current status of another agent by name. Returns the \ - agent's self-reported status text (set via `set_status`) and how long ago it \ - was set. Useful for the manager to get a quick overview of what each agent is \ - doing without querying the dashboard. Returns `no status set` when the agent \ - has never called `set_status` or has cleared it." + description = "Fetch identity + status metadata for an agent. Returns canonical \ + `name`, `role` (`agent` / `manager`), the current `hyperhive_rev` hive-c0re is \ + running against, and the target's self-reported `status` text (set via \ + `set_status`) plus how long ago it was set. Pass `name` to query a sub-agent or \ + peer manager; omit `name` for the manager's own identity stamp — useful for \ + boot announcements, state-file headers, or cross-agent attribution that won't \ + drift across renames. Status reads `` when the target has never called \ + `set_status` or has cleared it." )] async fn get_agent_meta( &self, Parameters(args): Parameters, ) -> String { - let log = args.name.clone(); + let log = args.name.clone().unwrap_or_else(|| "".to_owned()); run_tool_envelope("get_agent_meta", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::GetAgentMeta { name: args.name }) @@ -1516,8 +1441,9 @@ impl ManagerServer { `answer` (respond to a `question_asked` event directed at you), \ `get_loose_ends` (hive-wide loose ends — pending approvals + unanswered \ questions + pending reminders across the swarm), `cancel_loose_end` (cancel any \ - question or reminder row by id), `whoami` (self-introspection — canonical \ - name, role, current hyperhive rev). The manager's own config lives at \ + question or reminder row by id), `set_status` / `get_agent_meta` (publish your \ + own status text + query identity/status of any agent — `get_agent_meta` with \ + no arg replaces the old `whoami` self-introspection). The manager's own config lives at \ `/agents/hm1nd/config/agent.nix`." )] impl ServerHandler for ManagerServer {} @@ -1559,7 +1485,6 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "answer", "remind", "get_loose_ends", - "whoami", "set_status", "get_agent_meta", "cancel_loose_end", @@ -1579,7 +1504,6 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "get_logs", "get_loose_ends", "remind", - "whoami", "set_status", "get_agent_meta", "cancel_loose_end", diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index fdb5d0d9..24facbc8 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -220,16 +220,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> }, } } - AgentRequest::Whoami => { - 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 } => { let path = crate::coordinator::Coordinator::agent_notes_dir(agent) .join("hyperhive-status"); @@ -255,10 +245,19 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } } AgentRequest::GetAgentMeta { name } => { + let target = name.as_deref().unwrap_or(agent); let (status_text, status_set_at) = - crate::container_view::read_agent_status(name); + crate::container_view::read_agent_status(target); + let role = if target == hive_sh4re::MANAGER_AGENT { + "manager" + } else { + "agent" + } + .to_owned(); AgentResponse::AgentMeta { - name: name.clone(), + name: target.to_owned(), + role, + hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), status_text, status_set_at, } diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 13befa35..adc9a424 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -187,7 +187,7 @@ fn is_rate_limited(name: &str) -> bool { /// 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`. +/// or empty. `pub` so `agent_server` and `manager_server` can populate `AgentMeta`. pub fn read_agent_status(name: &str) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 4864c9d1..ae29b830 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -480,17 +480,6 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp }, } } - ManagerRequest::Whoami => { - 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 } => { let path = Coordinator::agent_notes_dir(MANAGER_AGENT).join("hyperhive-status"); let result = if text.trim().is_empty() { @@ -513,10 +502,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } } ManagerRequest::GetAgentMeta { name } => { + let target = name.as_deref().unwrap_or(MANAGER_AGENT); let (status_text, status_set_at) = - crate::container_view::read_agent_status(name); + crate::container_view::read_agent_status(target); + let role = if target == MANAGER_AGENT { "manager" } else { "agent" }.to_owned(); ManagerResponse::AgentMeta { - name: name.clone(), + name: target.to_owned(), + role, + hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), status_text, status_set_at, } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index bf7beb02..55c9b031 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -437,20 +437,23 @@ pub enum AgentRequest { #[serde(default)] since_secs: u64, }, - /// Self-introspection: who am I, what role, what rev. All values - /// derive from coord state (no env access required); useful for - /// agents to stamp notes / commits / messages with a trustworthy - /// identity after a rename or session-continue boundary where the - /// system-prompt-substituted label is no longer reliable. - Whoami, /// Set a free-text status string visible on the dashboard. Persisted /// to `{state_dir}/hyperhive-status` so it survives harness restarts. /// Pass an empty string to clear the status. SetStatus { text: String }, - /// Fetch the current status of another agent by name. Returns - /// `AgentResponse::AgentMeta` with the target's status fields. - /// If the agent does not exist or has never set a status, fields are `None`. - GetAgentMeta { name: String }, + /// Fetch metadata for an agent: identity (name + role + hyperhive + /// rev) and current status. When `name` is `None` the caller's own + /// identity is returned (self-introspection — replaces the old + /// `Whoami` request). When `name` is `Some`, the target agent's + /// status fields are populated but `role`/`hyperhive_rev` reflect + /// the responding daemon's view (`role` is best-effort: `"manager"` + /// for the manager agent, `"agent"` for everyone else). + /// `status_text` / `status_set_at` are `None` when the target has + /// never set a status or the agent name is unknown. + GetAgentMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + }, /// Cancel an open thread the agent owns: a `Question` they asked /// (returns `[cancelled by ]` as the answer to the asker) /// or a `Reminder` they scheduled (hard-deletes the row). @@ -511,15 +514,15 @@ pub enum AgentResponse { PendingRemindersCount { count: u64 }, /// `ReminderRollup` result: reminder activity stats for the agent. ReminderRollup(ReminderStats), - /// `Whoami` result: identity + role + the current hyperhive rev - /// hive-c0re is running against. `role` is `"agent"` for - /// sub-agents (the only path that reaches this variant of the - /// 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 { + /// `GetAgentMeta` result: identity + status metadata for an agent. + /// `role` is `"agent"` for sub-agents and `"manager"` for the + /// manager. `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` when none has been set or the + /// agent name is unknown. `status_set_at` is a Unix timestamp + /// (seconds since epoch) of when the status was last written; + /// `None` when no status is set. + AgentMeta { name: String, role: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -529,16 +532,6 @@ pub enum AgentResponse { #[serde(default, skip_serializing_if = "Option::is_none")] status_set_at: Option, }, - /// `GetAgentMeta` result: status metadata for a named agent. - /// `status_text` / `status_set_at` are `None` when the agent has - /// not set a status or the agent name is unknown. - AgentMeta { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - status_text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - status_set_at: Option, - }, } // ----------------------------------------------------------------------------- @@ -865,13 +858,15 @@ pub enum ManagerRequest { #[serde(default)] agent: Option, }, - /// Manager-flavour self-introspection. Same wire shape as - /// `AgentRequest::Whoami`, but `role` is always `"manager"`. - Whoami, /// Mirror of `AgentRequest::SetStatus` on the manager surface. SetStatus { text: String }, /// Mirror of `AgentRequest::GetAgentMeta` on the manager surface. - GetAgentMeta { name: String }, + /// `None` returns the manager's own identity (replaces the old + /// `Whoami` request). + GetAgentMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + }, /// Cancel an open thread (question or reminder). Manager surface /// can cancel any row (no owner check) — same dispatch as /// `AgentRequest::CancelLooseEnd` but with privileged auth. @@ -947,9 +942,10 @@ pub enum ManagerResponse { }, /// `ReminderRollup` result: reminder activity stats for the manager. ReminderRollup(ReminderStats), - /// `Whoami` result: manager identity. `role` is always - /// `"manager"`. Mirror of `AgentResponse::Whoami`. - Whoami { + /// Mirror of `AgentResponse::AgentMeta` on the manager surface. + /// `role` is `"manager"` for the manager and `"agent"` for any + /// sub-agent looked up by name. + AgentMeta { name: String, role: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -959,12 +955,4 @@ pub enum ManagerResponse { #[serde(default, skip_serializing_if = "Option::is_none")] status_set_at: Option, }, - /// Mirror of `AgentResponse::AgentMeta` on the manager surface. - AgentMeta { - name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - status_text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - status_set_at: Option, - }, }