{ hyperhivePackage, hyperhiveFrontend, hyperhiveAssets, hyperhiveFlake, hyperhiveNixpkgsUnstable, agentBaseToplevel, managerToplevel, }: { pkgs, lib, config, ... }: let cfg = config.services.hyperhive.c0re; in { # The forge is part of the standard install — hive-c0re mirrors # every agent's applied config repo into it. On by default; opt out # with `services.hyperhive.forge.enable = false`. hive-matrix is # opt-in (off by default) and asserts that `services.hyperhive.domain` # is set before it can be enabled. imports = [ ./hive-ci.nix ./hive-forge.nix ./hive-gateway.nix ./hive-matrix.nix ./hive-network.nix ]; # Top-level hyperhive enable flag. When true, automatically enables # hive-c0re and the on-by-default hyperhive subsystems. options.services.hyperhive.enable = lib.mkEnableOption "hyperhive — the agent swarm coordinator"; # Canonical hive DNS domain shared by every subsystem that needs a # stable hostname. Nullable + default null so existing configs # evaluate unchanged; subsystems that need it (matrix) assert # non-null in their own config block. Full identity-surface # context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var # chain → identity.rs → claude prompt): docs/conventions.md:: # Hive identity (label + domain + display names). options.services.hyperhive.domain = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "darkest.space"; description = '' Canonical host domain for hyperhive subsystems that need a stable name (currently: `services.hyperhive.matrix.serverName` derives from this, defaulting to `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. Exposed to agents as `HYPERHIVE_HIVE_DOMAIN`; consumed by `hive-ag3nt::identity::hive_domain()` for `@` qualified labels. ''; }; # Human display names for hive + swarm. Distinct from the DNS # domain above (machine-readable) — see # docs/conventions.md::Hive identity for the # domain-vs-name-vs-swarm distinction + the env-var # propagation chain. 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 default 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. Exposed to agents as `HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and per-agent system prompt when set. ''; }; # Peer hives in the same swarm. Each entry declares a remote hive # reachable from this host. Serialised to JSON and injected as # `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent # containers via `meta.rs::FORWARDED_VARS`. Consumed by # `identity.rs::peers()` + the dashboard's `peer_hives` state field # (feeds the P33RS dashboard tab). options.services.hyperhive.swarm.peers = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { certFingerprint = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "sha256:abc123..."; description = '' Expected TLS certificate fingerprint for this peer's HTTPS endpoint. Null = trust the system CA bundle (for Let's Encrypt peers). Set to pin a self-signed cert. ''; }; }; } ); default = { }; example = { "lab.example.com" = { certFingerprint = "sha256:abc123"; }; "edge.corp" = { }; }; description = '' Peer hives in the same swarm. The attrset key is the peer's DNS domain — used for dashboard links and Matrix federation discovery. Null `certFingerprint` trusts the system CA bundle; set it to pin a self-signed TLS cert. ''; }; options.services.hyperhive.c0re = { enable = lib.mkOption { type = lib.types.bool; default = config.services.hyperhive.enable; defaultText = lib.literalExpression "config.services.hyperhive.enable"; description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable)."; }; package = lib.mkOption { type = lib.types.package; default = hyperhivePackage pkgs.stdenv.hostPlatform.system; defaultText = lib.literalExpression "hyperhive.packages.\${system}.default"; description = '' hyperhive workspace package. Provides `/bin/hive-c0re` (coordinator daemon + admin-socket CLI) and `/bin/hivectl` (operator-facing host CLI for ad-hoc administration). ''; }; frontend = lib.mkOption { type = lib.types.package; default = hyperhiveFrontend pkgs.stdenv.hostPlatform.system; defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend"; description = '' Bundled frontend dist (see `./nix/frontend.nix`). Output has `dashboard/` and `agent/` subdirectories — hive-c0re serves `dashboard/` via `tower_http::ServeDir` from the path passed in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA; the JSON contract (`/api/state`, the SSE streams, the action endpoints) is the source of truth for any replacement. ''; }; assets = lib.mkOption { type = lib.types.package; default = hyperhiveAssets pkgs.stdenv.hostPlatform.system; defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets"; description = '' Bundled static runtime assets (see `./nix/assets.nix`): the project's branding family + the claude system-prompt template + claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`; passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR` (`hive_sh4re::assets::*` resolve paths underneath). Override to ship customised branding or prompts without rebuilding the rust derivation. ''; }; hyperhiveFlake = lib.mkOption { type = lib.types.str; default = hyperhiveFlake; defaultText = lib.literalMD "the flake's own store path"; description = '' URL of the hyperhive flake (no fragment). Inlined into each per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent flake then pulls `hyperhive.nixosConfigurations.agent-base` to build the container. Defaults to this flake's own store path — only override if you want agents tracking a different ref. ''; }; nixpkgsFlake = lib.mkOption { type = lib.types.str; default = "path:${pkgs.path}"; defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`"; description = '' Store-path URL for the `nixpkgs` input in the generated meta flake. The meta flake declares this as a top-level input and wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so every agent container evaluates with this exact nixpkgs. Defaults to `"path:''${pkgs.path}"` — the store path of the nixpkgs the host NixOS module was evaluated with. When the operator sets `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` in their host flake, `pkgs.path` resolves to the host's own nixpkgs, so agents transparently track the same channel as the host. Override to pin agents to a specific nixpkgs version regardless of the host's channel. ''; }; nixpkgsUnstableFlake = lib.mkOption { type = lib.types.str; default = hyperhiveNixpkgsUnstable; defaultText = lib.literalMD "hyperhive's own `nixpkgs-unstable` store path"; description = '' Store-path URL for the `nixpkgs-unstable` input in the generated meta flake. The meta flake declares this as a top-level input and wires `inputs.hyperhive.inputs.nixpkgs-unstable.follows = "nixpkgs-unstable"` so agents use this exact unstable nixpkgs. Defaults to the store path of the `nixpkgs-unstable` input hyperhive's own `flake.nix` was evaluated with (the channel that carries `claude-code`). Override when you want to track a newer unstable snapshot or a custom `claude-code` package. ''; }; dashboardPort = lib.mkOption { type = lib.types.port; default = 7000; description = "TCP port the hive-c0re dashboard listens on."; }; operatorPronouns = lib.mkOption { type = lib.types.str; default = "she/her"; example = "they/them"; description = '' Operator pronouns, free text. Threaded into every agent container as the `HIVE_OPERATOR_PRONOUNS` env var; the harness substitutes it into the agent / manager system prompt at boot so claude refers to the operator naturally in third person ("ask her", "tell them", etc.). Changes propagate to running agents on the next `↻ R3BU1LD` — forwards as a meta flake env-var bump, no per-agent approval needed. ''; }; preBuildAgentTemplates = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Pre-fetch the per-container system closures (agent-base + manager toplevels) into the host's /nix/store as part of this host's NixOS build, instead of letting the first agent spawn do all the work. Closes #97. Enabling this adds roughly the full nixpkgs runtime closure + claude-code + the harness binary to your system closure size (low single-digit GB), but the first `nixos-container start` for any agent then completes in seconds instead of minutes because nothing's left to fetch. Off by default because the toplevels are pinned to `x86_64-linux` (nixos-containers run native arch). Enabling on an aarch64 host would force nix to build the x86 closure via cross or a remote builder, which is rarely what you want. Flip to `true` on an x86_64 host when you care more about first-spawn latency than host store size — or just `nix build ${hyperhiveFlake}#agent-base-toplevel` once manually to warm the store. ''; }; contextWindowTokens = lib.mkOption { type = lib.types.attrsOf lib.types.int; default = { haiku = 200000; sonnet = 1000000; opus = 1000000; }; example = { haiku = 150000; sonnet = 900000; }; description = '' Per-model context-window sizes in tokens. Each key is a model-family short name matched case-insensitively as a substring of the active model name at runtime (e.g. `"sonnet"` matches `"claude-sonnet-4-5"`). The defaults cover the known Anthropic families; add entries for new models or override existing ones here to change the window for all agents at once. Passed to `hive-c0re serve` as JSON and injected into every container's harness service environment as `HIVE_CONTEXT_WINDOW_TOKENS_`. Changes propagate on the next `↻ R3BU1LD` — no per-agent approval needed. ''; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package pkgs.git ]; # Pull the per-container toplevels into the host system closure # (#97). `system.extraDependencies` adds paths to the system build # without referencing them at runtime — nixos-rebuild fetches / # builds them, they end up in /nix/store, and the first # nixos-container update + start for an agent has nothing left to # do. Gated because the closure is sizeable and pinned to x86_64. system.extraDependencies = lib.optionals cfg.preBuildAgentTemplates [ agentBaseToplevel managerToplevel ]; # Open the per-agent web-port range when the gateway is *off* — # otherwise the gateway nginx is the sole external entry point. # See `docs/gateway.md::Firewall posture (host-level)`. networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) { allowedTCPPortRanges = [ { from = 8100; to = 8999; } ]; }; systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; path = [ pkgs.git "/run/current-system/sw" ]; environment = { HYPERHIVE_GIT = "${pkgs.git}/bin/git"; # Path to the dashboard static dist. The hive-c0re axum router # serves this via `tower_http::ServeDir` for any path it doesn't # match against an API/action route. HIVE_STATIC_DIR = "${cfg.frontend}/dashboard"; # Path to the base agent frontend dist. hive-c0re's # gateway_nginx.rs uses this to generate split location # blocks in agents.conf — static HTML/CSS/JS served from the # nix store directly; dynamic API paths still proxied to the # agent daemon. The nix store is shared across nspawn # containers, so this path is reachable from inside the # gateway container's nginx. HIVE_AGENT_FRONTEND_DIR = "${cfg.frontend}/agent"; # Path to the static runtime asset tree (branding + claude # prompts). `hive_sh4re::assets::*` reads paths underneath. # `forge.rs` reads the avatar PNGs from here on startup. HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; } // lib.optionalAttrs (config.services.hyperhive.domain != null) { # Identity env vars threaded into c0re's own service env and # forwarded by meta.rs into every sub-agent's harness env — # full chain in docs/conventions.md::Hive identity. HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; } // lib.optionalAttrs (config.services.hyperhive.hiveName != null) { HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName; } // lib.optionalAttrs (config.services.hyperhive.swarmName != null) { HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName; } // lib.optionalAttrs config.services.hyperhive.forge.enable { # Loopback for in-cluster calls (agents share host netns; # external `forge.` sub-domain isn't DNS-resolvable # from inside nspawn). See # `docs/gateway.md::HIVE_FORGE_URL: loopback for in-cluster, # sub-domain for the operator`. HIVE_FORGE_URL = "http://127.0.0.1:${toString config.services.hyperhive.forge.httpPort}"; } // lib.optionalAttrs config.services.hyperhive.matrix.gui.enable { # Availability flags read by the dashboard's `/api/state`. # Matrix GUI lives entirely on the gateway nginx (matrix tab # only shows when both flags are on). Gateway routing detail: # docs/gateway.md::Vhost map. HIVE_MATRIX_GUI_ENABLED = "1"; } // lib.optionalAttrs config.services.hyperhive.gateway.enable { # When true the dashboard builds same-origin `/agent//` # links; when false it falls back to direct `:` TCP. HIVE_GATEWAY_ENABLED = "1"; } // lib.optionalAttrs (config.services.hyperhive.forge.enable && config.services.hyperhive.forge.behindGateway) { # Public URL of the forge vhost served by hive-gateway. The # dashboard uses this to build browser-facing forge links # instead of hardcoding `:3000`, which breaks when # the operator accesses the dashboard through the gateway # (forge sub-domain has no port; direct port URL would be # wrong). Absent when `behindGateway = false` — dashboard # falls back to `:3000`. HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}"; } // lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) { # Peer hives serialised as a JSON array of {domain, cert_fingerprint} # objects. Consumed by hive-ag3nt::identity::peers() + the dashboard's # peer_hives StateSnapshot field (P33RS tab). Domain is the attrset key; # cert_fingerprint is null for CA-trusted peers. HYPERHIVE_PEERS = builtins.toJSON ( lib.mapAttrsToList (domain: p: { inherit domain; cert_fingerprint = p.certFingerprint; }) config.services.hyperhive.swarm.peers ); }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive"; RuntimeDirectoryMode = "0750"; RuntimeDirectoryPreserve = "yes"; StateDirectory = "hyperhive"; }; }; # Socket unit for the hive-c0re admin socket. systemd creates and holds # `/run/hyperhive/host.sock` before hive-c0re starts, then passes the fd # via LISTEN_FDS (socket activation). Benefits: `hivectl` can connect # the moment the socket unit is active — no racy retry window — and a # hive-c0re restart never drops the socket inode, so queued commands # drain cleanly. # # `hive-c0re serve` reads LISTEN_FDS via the `listenfd` crate and # accepts the fd in preference to its own `bind()` path. When invoked # directly (dev, CI, without the socket unit) LISTEN_FDS is absent and # the traditional bind path runs unchanged — no regression. systemd.sockets.hive-c0re = { description = "hive-c0re admin socket"; wantedBy = [ "sockets.target" ]; socketConfig = { # Must match the `--socket` arg passed to `hive-c0re serve`. ListenStream = "/run/hyperhive/host.sock"; # 0660 root:root — `hivectl` is a host-only tool run as root. SocketMode = "0660"; # Parent dir inherits the RuntimeDirectory mode (0750) set on the # service unit; DirectoryMode is only consulted when the dir is # absent at socket-unit activation. DirectoryMode = "0750"; }; }; # Socket unit for hive-priv — the narrow root helper that executes # privileged operations on behalf of hive-c0re. Systemd creates and # holds `/run/hive/priv.sock` before the first connection arrives. # # Mode 0660 root:root is correct for phase 1 (hive-c0re still runs as # root and is the only caller). Phase 2 (privsep: hive-c0re drops to a # non-root user) will add `SocketGroup = hive-core` so the unprivileged # hive-c0re process can still connect. systemd.sockets.hive-priv = { description = "hive-priv privileged helper socket"; wantedBy = [ "sockets.target" ]; socketConfig = { ListenStream = "/run/hive/priv.sock"; SocketMode = "0660"; # Create /run/hive/ if absent; 0755 so future unprivileged callers # can traverse into it to reach the socket. DirectoryMode = "0755"; }; }; # Service unit for hive-priv. Runs as root — it genuinely needs root to # invoke `nixos-container`, write `/etc/nixos-containers/`, write # systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`. # Every request is validated against a strict container-name allowlist # inside the binary; the attack surface is narrow by design. # # Socket-activated: systemd starts hive-priv on the first connection # (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv # reads them to accept the pre-bound socket fd instead of binding its # own. systemd.services.hive-priv = { description = "hive-priv privileged helper"; # No wantedBy — socket-activated exclusively. The socket unit is the # entry point; systemd starts this service on first connect. after = [ "hive-priv.socket" ]; requires = [ "hive-priv.socket" ]; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-priv"; Type = "simple"; User = "root"; PrivateTmp = true; ProtectHome = true; # hive-priv needs to write to /etc/nixos-containers/ and # /run/systemd/system/ — "strict" would block both. ProtectSystem = "false"; }; }; }; }