diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 4230926..44785ac 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -723,6 +723,21 @@ } body.append(drill); + // ── extra agent-declared links ─────────────────────────────── + if (c.extra_links && c.extra_links.length > 0) { + const extras = el('div', { class: 'drill-ins drill-ins-extra' }); + for (const lnk of c.extra_links) { + extras.append(el('a', { + class: 'panel-trigger', + href: lnk.url, + target: '_blank', + rel: 'noopener', + title: lnk.url, + }, (lnk.icon ? lnk.icon + ' ' : '') + lnk.label + ' ↗')); + } + body.append(extras); + } + li.append(icon, body); ul.append(li); } diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 0027366..2524892 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -9,11 +9,22 @@ use std::collections::HashMap; use std::path::Path; use rusqlite::Connection; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; +/// An agent-declared extra navigation link surfaced on the dashboard card. +/// Written by the `hive-dashboard-links` NixOS oneshot into +/// `{state_dir}/hyperhive-dashboard-links.json` and read by `build_all`. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)] +pub struct DashboardLink { + pub label: String, + #[serde(default)] + pub icon: String, + pub url: String, +} + #[derive(Serialize, Clone, PartialEq, Eq, Debug)] #[allow(clippy::struct_excessive_bools)] pub struct ContainerView { @@ -50,6 +61,13 @@ pub struct ContainerView { /// removes when it resumes. Stale by up to one crash-watch cycle. #[serde(default)] pub rate_limited: bool, + /// Extra navigation links declared by the agent via + /// `hyperhive.dashboardLinks` in `agent.nix`. Written to + /// `{state_dir}/hyperhive-dashboard-links.json` by the + /// `hive-dashboard-links` oneshot at container boot. Empty when + /// the file is absent or the agent declares no links. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_links: Vec, } /// Build the full container list. Wraps `lifecycle::list()` and @@ -87,6 +105,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { .unwrap_or(0); let ctx_tokens = read_last_ctx_tokens(&logical); let rate_limited = is_rate_limited(&logical); + let extra_links = read_dashboard_links(&logical); out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -99,6 +118,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { pending_reminders, ctx_tokens, rate_limited, + extra_links, }); } out @@ -117,6 +137,18 @@ pub fn claude_has_session(dir: &Path) -> bool { .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } +/// Read agent-declared extra dashboard links from +/// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when +/// the file is absent, empty, or unparseable — best-effort, never panics. +fn read_dashboard_links(name: &str) -> Vec { + let path = Coordinator::agent_notes_dir(name).join("hyperhive-dashboard-links.json"); + let text = match std::fs::read_to_string(&path) { + Ok(t) if !t.trim().is_empty() => t, + _ => return Vec::new(), + }; + serde_json::from_str::>(&text).unwrap_or_default() +} + /// Returns true if the agent's harness is currently parked after an API /// rate-limit response. Detected via the sentinel file written by /// `hive_ag3nt::events::Bus::emit_status("rate_limited")`. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 01ec86b..dfc9bde 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -173,6 +173,39 @@ ''; }; + options.hyperhive.dashboardLinks = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + label = lib.mkOption { + type = lib.types.str; + description = "Display label for the link."; + }; + icon = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional icon emoji or short glyph."; + }; + url = lib.mkOption { + type = lib.types.str; + description = "Full URL (may include a different port, e.g. http://localhost:9001/stats)."; + }; + }; + }); + default = [ ]; + example = lib.literalExpression '' + [ + { label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; } + ] + ''; + description = '' + Extra navigation links surfaced on the hive-c0re dashboard card for + this agent. Declare any additional web UI pages the agent exposes — + stats pages, custom UIs, etc. hive-c0re reads the JSON file this + option produces at each container-view snapshot and attaches the + links to the agent card without any code changes. + ''; + }; + options.hyperhive.claudeMarketplaces = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "anthropics/claude-plugins-official" ]; @@ -480,6 +513,34 @@ ''; }; + # Write declared dashboardLinks to the state dir so hive-c0re can read + # them without accessing the container's /etc/ from the host. + # Runs every boot; idempotent (overwrite). Always exits 0. + systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) { + description = "write declarative dashboardLinks to agent state dir"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks; + script = '' + STATE_DIR="" + for d in /state /agents/*/state; do + if [ -d "$d" ]; then + STATE_DIR="$d" + break + fi + done + if [ -z "$STATE_DIR" ]; then + echo "hive-dashboard-links: no state dir found; skipping" + exit 0 + fi + printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json" + echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json" + ''; + }; + # Git is needed by claude's Bash tool (for the agent <-> manager config # request flow) and by hive-c0re's own setup_applied / setup_proposed. # The per-agent `applied//flake.nix` overrides `user.name` and