From c41bf1b562947f2af9d8f3b8f21400611cf3be27 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 11:35:47 +0200 Subject: [PATCH] harness: surface hive + swarm display names to agents (#701) --- CLAUDE.md | 5 +- README.md | 9 +++ hive-ag3nt/src/identity.rs | 130 +++++++++++++++++++++++++++++++++++-- hive-c0re/src/meta.rs | 50 +++++++++++--- nix/modules/hive-c0re.nix | 61 ++++++++++++++++- 5 files changed, 239 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b487bd74..92af0502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,10 @@ hive-ag3nt/ in-container harness crate; produces ONE `hive` src/identity.rs hive-qualified agent label (#589 phase A): `label()` / `qualified_label()` / `qualify(label)`. Reads `HYPERHIVE_HIVE_DOMAIN`; falls back to short - name when unset. + name when unset. Display-name accessors (#701): + `hive_name()` / `swarm_name()` read + `HYPERHIVE_HIVE_NAME` / `HYPERHIVE_SWARM_NAME`, + both `Option`. src/login.rs probe $HOME/.claude/ (post-#658 `/home//.claude`) for a valid session src/login_session.rs drives `claude auth login` over stdio pipes diff --git a/README.md b/README.md index d5632449..042ad564 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,15 @@ name. No default — subsystems that require it (currently: `services.hyperhive.matrix`) assert non-null at eval time with a clear error message if it is missing. +Optional: set `services.hyperhive.hiveName = "pr1ma";` and / or +`services.hyperhive.swarmName = "constellat1on";` to give the hive and +the wider swarm human-readable display labels. Distinct from +`services.hyperhive.domain` (the DNS address): the names surface in +the dashboard chrome and the per-agent system prompt; the domain is +how things are addressed on the wire. Federated hives at different +domains can share a swarm name. Both default to null — chrome falls +back to the domain, the prompt simply doesn't mention them (#701). + Optional: set `services.hyperhive.matrix.enable = true;` to spin up a private [matrix-tuwunel](https://github.com/matrix-construct/tuwunel) homeserver in a nixos-container. Requires either diff --git a/hive-ag3nt/src/identity.rs b/hive-ag3nt/src/identity.rs index 0bc4ce51..4cbbd1d9 100644 --- a/hive-ag3nt/src/identity.rs +++ b/hive-ag3nt/src/identity.rs @@ -1,4 +1,5 @@ -//! Agent identity helpers — short label + hive-qualified long name. +//! Agent identity helpers — short label + hive-qualified long name + +//! human display names for the hive and swarm. //! //! `HIVE_LABEL` is the short, hive-local agent name (e.g. `iris`, `damocles`). //! `HYPERHIVE_HIVE_DOMAIN` is the hive's canonical DNS domain (e.g. @@ -13,11 +14,19 @@ //! `from` / `to` qualification + dashboard rendering of cross-hive //! identities are subsequent follow-ups inside #589. //! -//! The hive name itself IS the operator's DNS domain — `hyperhive.hiveName` -//! was deliberately dropped in #589 spec discussion (mara #6577 / iris -//! #6582) so there's one source of truth. Matrix MXIDs already use the -//! same convention (`@iris:darkest.space`), so federation lookups Just Work -//! without a separate slug. +//! `HYPERHIVE_HIVE_NAME` + `HYPERHIVE_SWARM_NAME` are human-readable +//! display names for the local hive (`pr1ma`) and the wider swarm +//! (`constellat1on`) — added in #701 after mara's +//! `internal-requests#9` ("we want to persist this name somewhere in +//! the hive"). They're **distinct** from the DNS domain above: the +//! domain may carry the hive name as its leftmost label by +//! convention, but the convention isn't machine-readable, and +//! federated hives at different DNS domains can share a swarm name. +//! Both reverse the earlier #589 spec decision (mara #6577 / iris +//! #6582 dropped `hyperhive.hiveName` in favour of "the domain IS the +//! name") — turns out humans want both: the address (`@darkest.space`) +//! AND the prose name (`pr1ma`). Matrix MXIDs still use the +//! domain-based convention untouched. use std::env; @@ -40,6 +49,30 @@ pub fn hive_domain() -> Option { .filter(|s| !s.is_empty()) } +/// Human display name of this hive (e.g. `pr1ma`). Distinct from +/// [`hive_domain`] — the domain is the machine-readable DNS address; +/// this is the prose label humans use in conversation. Returns None +/// when the host-side `services.hyperhive.hiveName` option is unset, +/// in which case callers fall back to the domain or the short label +/// at their discretion (#701). +#[must_use] +pub fn hive_name() -> Option { + env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Human display name of the wider swarm this hive belongs to (e.g. +/// `constellat1on`). Federated hives at different DNS domains can +/// share a swarm name. Returns None when the host-side +/// `services.hyperhive.swarmName` option is unset (#701). +#[must_use] +pub fn swarm_name() -> Option { + env::var("HYPERHIVE_SWARM_NAME") + .ok() + .filter(|s| !s.is_empty()) +} + /// Hive-qualified agent identity. When the hive domain is configured, returns /// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just /// the short label so callers can render a single string regardless of @@ -82,9 +115,24 @@ mod tests { /// to recover so a single test failure doesn't cascade through the /// whole module. fn with_env(label: Option<&str>, domain: Option<&str>, f: F) { + with_full_env(label, domain, None, None, f); + } + + /// Extended form of [`with_env`] covering the #701 display-name env + /// vars (hive name + swarm name) alongside label + domain. Same + /// SAFETY contract — serialised on `ENV_LOCK`, restore in scope. + fn with_full_env( + label: Option<&str>, + domain: Option<&str>, + hive_name: Option<&str>, + swarm_name: Option<&str>, + f: F, + ) { let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let prev_label = env::var("HIVE_LABEL").ok(); let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok(); + let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok(); + let prev_swarm_name = env::var("HYPERHIVE_SWARM_NAME").ok(); // SAFETY: serialised by ENV_LOCK above; restore in the same scope. unsafe { match label { @@ -95,6 +143,14 @@ mod tests { Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v), None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"), } + match hive_name { + Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v), + None => env::remove_var("HYPERHIVE_HIVE_NAME"), + } + match swarm_name { + Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v), + None => env::remove_var("HYPERHIVE_SWARM_NAME"), + } } f(); unsafe { @@ -106,6 +162,14 @@ mod tests { Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v), None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"), } + match prev_hive_name { + Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v), + None => env::remove_var("HYPERHIVE_HIVE_NAME"), + } + match prev_swarm_name { + Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v), + None => env::remove_var("HYPERHIVE_SWARM_NAME"), + } } } @@ -157,4 +221,58 @@ mod tests { assert_eq!(label(), ""); }); } + + #[test] + fn hive_name_returns_some_when_env_set() { + with_full_env(Some("iris"), None, Some("pr1ma"), None, || { + assert_eq!(hive_name().as_deref(), Some("pr1ma")); + }); + } + + #[test] + fn hive_name_returns_none_when_env_unset_or_empty() { + with_full_env(Some("iris"), None, None, None, || { + assert!(hive_name().is_none()); + }); + with_full_env(Some("iris"), None, Some(""), None, || { + assert!(hive_name().is_none(), "empty string treated as unset"); + }); + } + + #[test] + fn swarm_name_returns_some_when_env_set() { + with_full_env(Some("iris"), None, None, Some("constellat1on"), || { + assert_eq!(swarm_name().as_deref(), Some("constellat1on")); + }); + } + + #[test] + fn swarm_name_returns_none_when_env_unset_or_empty() { + with_full_env(Some("iris"), None, None, None, || { + assert!(swarm_name().is_none()); + }); + with_full_env(Some("iris"), None, None, Some(""), || { + assert!(swarm_name().is_none(), "empty string treated as unset"); + }); + } + + #[test] + fn name_accessors_independent_from_domain() { + // hive_name + swarm_name surface without HYPERHIVE_HIVE_DOMAIN + // being set — the names are display labels, not derived from + // the DNS domain (#701, mara on internal-requests#9). + with_full_env( + Some("iris"), + None, + Some("pr1ma"), + Some("constellat1on"), + || { + assert!(hive_domain().is_none()); + assert_eq!(hive_name().as_deref(), Some("pr1ma")); + assert_eq!(swarm_name().as_deref(), Some("constellat1on")); + // qualified_label still degrades to short label without domain. + assert_eq!(qualified_label(), "iris"); + }, + ); + } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 65b64fa0..531d356e 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -280,6 +280,33 @@ fn render_flake( /// meta-level reference (#355). const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"]; +/// Env vars hive-c0re forwards from its own systemd unit env into every +/// sub-agent's harness service env. Each entry is `(env_var_name, +/// host_value)`. Empty / unset vars are filtered out so absent options +/// don't render no-op `FOO = ""` lines into the meta flake. +/// +/// Returns owned `String` values so the result is `'static`-friendly + +/// trivial to stub from tests (which build their own slice instead of +/// touching process-wide env). +const FORWARDED_VARS: &[&str] = &[ + "HIVE_FORGE_URL", + "HYPERHIVE_HIVE_DOMAIN", + "HYPERHIVE_HIVE_NAME", + "HYPERHIVE_SWARM_NAME", +]; + +fn forwarded_env_vars() -> Vec<(&'static str, String)> { + FORWARDED_VARS + .iter() + .filter_map(|&name| { + std::env::var(name) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| (name, v)) + }) + .collect() +} + /// Read an agent's applied `flake.lock` and return the subset of /// `CANONICAL_INPUTS` it declares as direct (root-level) inputs. /// Returns an empty vec when the lock is missing or unparsable — @@ -446,14 +473,21 @@ where " HIVE_CONTEXT_WINDOW_TOKENS_{upper_key} = \"{val}\";" ); } - // Forge URL — injected when hive-c0re itself has HIVE_FORGE_URL set - // (the NixOS module derives it from hyperhive.forge.{domain,httpPort}). - // Agents use it in forge_notify to poll Forgejo for PR/review events. - if let Ok(forge_url) = std::env::var("HIVE_FORGE_URL") - && !forge_url.is_empty() - { - let escaped = forge_url.replace('\\', "\\\\").replace('"', "\\\""); - let _ = writeln!(out, " HIVE_FORGE_URL = \"{escaped}\";"); + // Forwarded env vars — picked up from hive-c0re's own systemd unit + // env (`services.hyperhive.*` options flow through nix/modules/ + // hive-c0re.nix into the host process). We copy whatever's set into + // each sub-agent's harness service env so the in-container surfaces + // (`identity.rs`, `forge_notify`) see a consistent view across the + // whole hive. Absent host-side env (option not set) → skip emission + // → in-container accessors fall back to None / defaults gracefully. + // + // - HIVE_FORGE_URL: agents poll this for Forgejo notifications. + // - HYPERHIVE_HIVE_DOMAIN: machine-readable hive DNS (#589). + // - HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME: human display + // names for hive + swarm (#701). + for (var, val) in forwarded_env_vars() { + let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); + let _ = writeln!(out, " {var} = \"{escaped}\";"); } out.push_str( r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state"; diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index d5e5c4f1..e1915c03 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -48,7 +48,47 @@ in `matrix.''${services.hyperhive.domain}` when `serverName` is null). No default — subsystems that opt to require it assert non-null in their own config and fail eval with a helpful - message if it's missing. + message if it's missing. Exposed to agents as + `HYPERHIVE_HIVE_DOMAIN`; consumed by + `hive-ag3nt::identity::hive_domain()` for `@` + qualified labels (#589). + ''; + }; + + # Display-name identities for the swarm + hive — distinct from the + # DNS domain above, which is the machine-readable address. The + # display names are how humans address the constellation in + # conversation (`pr1ma`, `constellat1on`); the DNS subdomain may + # carry the hive name as its leftmost label by convention but the + # convention isn't machine-readable. Both nullable + default null + # so existing deploys evaluate unchanged (#701). + options.services.hyperhive.hiveName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "pr1ma"; + description = '' + Human-readable name of this single-host hive instance. + Distinct from `services.hyperhive.domain` (the machine- + addressable DNS name): the domain may carry the hive name as + its leftmost label by convention, but this option is the + canonical readable identity. Exposed to agents as + `HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and + per-agent system prompt when set. Null falls back to the + pre-#701 behaviour (chrome shows the domain, prompt doesn't + mention a hive name). + ''; + }; + + options.services.hyperhive.swarmName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "constellat1on"; + description = '' + Human-readable name of the wider swarm this hive belongs to. + Hives at different DNS domains can share a swarm name when + they federate together (#589). Exposed to agents as + `HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and + per-agent system prompt when set. ''; }; @@ -248,6 +288,25 @@ in # `forge.rs` reads the avatar PNGs from here on startup. HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; } + // lib.optionalAttrs (config.services.hyperhive.domain != null) { + # Canonical hive DNS domain — surfaced to identity.rs as + # HYPERHIVE_HIVE_DOMAIN. meta.rs forwards the same env var + # into every sub-agent's harness service env so they all see + # a consistent qualified label (#589 + #701). + HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; + } + // lib.optionalAttrs (config.services.hyperhive.hiveName != null) { + # Display name of this hive instance (#701). meta.rs + # forwards into sub-agent harness env so identity.rs can + # expose hive_name() to claude. + HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName; + } + // lib.optionalAttrs (config.services.hyperhive.swarmName != null) { + # Display name of the wider swarm this hive belongs to (#701). + # meta.rs forwards into sub-agent harness env so identity.rs + # can expose swarm_name() to claude. + HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName; + } // lib.optionalAttrs config.services.hyperhive.forge.enable { # Agents poll this URL for Forgejo notifications. Derived from # services.hyperhive.forge.{domain,httpPort} so it tracks forge config changes.