{ pkgs, lib, config, # Flake inputs routed through _module.args by the agent flake.nix. # Default to {} so the module evaluates cleanly even when the agent # flake doesn't set up the routing pattern (e.g. during standalone # nixos-rebuild without a flake wrapper). flakeInputs ? { }, ... }: let # Agent user metadata. `userName` defaults to `"agent"` when the # meta-flake doesn't inject the per-agent override (stand-alone # `nixos-rebuild` against `nixosConfigurations.agent-base` works # without erroring on a missing per-agent name). `homeDir` derives # from `userName` to keep them coupled. userName = config.hyperhive.user.name; homeDir = "/home/${userName}"; # Hive-wide OpenTelemetry config (host-driven; baked in per-agent by # meta.rs `otel_config`). otelCfg = config.hyperhive.otel; # Hive/swarm display names are forwarded into each agent's build by # meta.rs as `environment.variables` (per-agent, build-time strings), # so they can be baked into the resource attributes below without a # runtime shell. Absent (option unset) → "unknown". hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown"; swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown"; # OTEL environment Claude Code reads to export metrics/logs/traces. # Shipped via the managed claude settings json (below), which claude # auto-discovers for BOTH the harness turn-loop and `hivectl choom` — # so telemetry parity is declarative, with no launch wrapper. The # auth header (`otel.headersCredential`) is deliberately NOT included # here: it's a secret and this file lives in the world-readable nix # store. It's injected at *runtime* into the agent's `0600` # `~/.claude/settings.json` by the `hive-otel-header` oneshot below # (claude merges the `env` from the user settings on top of these # managed ones), so the token is read from disk at start and never # touches the store. # Base claude-code environment applied to every agent regardless of OTEL. # Shipped via the managed settings `env` block so claude and `hivectl # choom` both inherit them without a launch wrapper. baseClaudeEnv = { # Suppress analytics, survey pings, and other non-essential outbound # traffic — agents are headless and don't need any of that. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; DO_NOT_TRACK = "1"; # Disable claude's self-update machinery; package management is nix's job. DISABLE_AUTOUPDATER = "1"; DISABLE_UPDATES = "1"; # Keep plugin updates synchronized on install; prefer HTTPS for fetches. CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1"; CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1"; FORCE_AUTOUPDATE_PLUGINS = "1"; # Suppress the "install GitHub app" prompt — not applicable in-hive. DISABLE_INSTALL_GITHUB_APP_COMMAND = "1"; # Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own. ENABLE_CLAUDEAI_MCP_SERVERS = "0"; # Resume an interrupted turn on reconnect (recovers from transient MCP flaps). CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1"; # Use the simpler system prompt variant suited to headless operation. CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1"; # Tag remote-control sessions with "-" for identification. CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}"; }; otelSettingsEnv = { CLAUDE_CODE_ENABLE_TELEMETRY = "1"; # Attach feedback-survey data to the OTEL pipeline. CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1"; OTEL_METRICS_EXPORTER = "otlp"; OTEL_LOGS_EXPORTER = "otlp"; OTEL_TRACES_EXPORTER = "otlp"; OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol; OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint; # Force CUMULATIVE temporality — Claude Code defaults to DELTA, # which Prometheus/Mimir-family backends (incl. grafana-lgtm) # silently drop without a deltatocumulative processor. OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; OTEL_RESOURCE_ATTRIBUTES = "service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}" + lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}"; # Include the Claude Code version label in emitted metrics. OTEL_METRICS_INCLUDE_VERSION = "1"; } // lib.optionalAttrs (otelCfg.metricIntervalMs != null) { OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs; }; # Single source of truth for the default matrix homeserver URL, shared # by the `hyperhive.matrix.url` option default and the daemon-unit guard # that decides whether to set a unit-level HIVE_MATRIX_URL (so the two # cannot drift). Matches the daemon's own built-in default # (`paths::DEFAULT_HOMESERVER`). matrixUrlDefault = "http://localhost:8008"; # Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a # 512x512 PNG so the matrix daemon can upload it as each account's avatar # over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar). # Replaces the old `matrix-avatar-sync` curl oneshot. Only forced when an # icon is configured — the `HIVE_ICON_PNG` daemon-env entry is gated on # `hyperhive.icon != null`, so this binding stays lazy when no icon is set. iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out ''; in { # Shared scaffolding for every hyperhive harness container. # `agent-base.nix` and `manager.nix` both import this; all agents # use the same service unit regardless of which entry-point they came from. # Optional feature modules. Each declares its own `hyperhive.*` # option(s), default-off, so every agent has them available but # only opts in from its own `agent.nix`. imports = [ ./weston-vnc.nix ]; # Per-agent unix user the harness + co-process daemons run as. # Defaults to `"agent"` so a standalone evaluation (e.g. # `nix flake check` against `nixosConfigurations.agent-base`) builds # cleanly; the meta-flake's per-agent module rebinds this to the # agent name (`"damocles"`, `"iris"`, …) so each container has a # uniquely-named user matching its agent label. UID auto-assigned # by NixOS (the auto-allocation range for normal users); no hard- # coded UID. options.hyperhive.user.name = lib.mkOption { type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$"; default = "agent"; example = "iris"; description = '' Unix user the harness service runs as inside the container. The meta-flake overrides this to the agent's own name so the user inside the container matches the agent label (`HIVE_LABEL`). Stand-alone evaluation defaults to `"agent"` so module evaluation without the meta-flake wrapper still builds. Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start, total length ≤ 31, no special characters. UID is auto-assigned by NixOS; no `uid =` override surface (intentional — pinning across rebuilds isn't a concern when the home and state dirs stay bind-mounted from the host). ''; }; options.hyperhive.user.passwordlessSudo = lib.mkOption { type = lib.types.bool; default = true; example = false; description = '' Grant `${config.hyperhive.user.name}` passwordless sudo (`NOPASSWD: ALL`). True by default so claude's `Bash` tool keeps working for tools that expect root inside the container (`systemctl`, package managers in dev shells, etc.) — the same surface the previous root-user shape had, just elevated explicitly instead of implicitly. Flip to `false` for agents that should be strictly unprivileged. Anything claude shells out to that needs root will then fail loudly with the standard sudo error rather than silently succeeding — easier to spot the leak. ''; }; options.hyperhive.web.useUnixSocket = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Deprecated. Unix socket mode is now always enabled for all agents. Setting this option to `true` has no effect and the option will be removed in a future version. Safe to drop from agent configs. ''; }; options.hyperhive.model = lib.mkOption { type = lib.types.str; default = "haiku"; example = "sonnet"; description = '' Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL` environment variable; the harness applies it at boot and it takes priority over any persisted runtime override. The operator can still switch the model at runtime via the per-agent web UI — that choice is tracked in the state dir for the current session but is reset by any rebuild that changes this option. Valid values are the short model names that `claude --model` accepts: `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context window sizes are looked up at runtime from the `HIVE_CONTEXT_WINDOW_TOKENS_` env vars injected by the meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens` on the host. ''; }; options.hyperhive.availableModels = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "haiku" "sonnet" "opus" ]; example = [ "sonnet" "opus" ]; description = '' Models offered in the per-agent web UI's model quick-picker. Rendered into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated) which the harness surfaces to the agent UI, so the picker lists exactly these models instead of a hardcoded set. Configure hive-wide by setting a shared default (e.g. in your `agent-base.nix`) or per-agent to narrow the menu — for example a haiku-only agent can hide `opus` and `sonnet`. The *current* model is still set by `hyperhive.model` and remains switchable at runtime via the UI; this option only controls which choices the picker presents. Values are the short model names that `claude --model` accepts: `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). ''; }; options.hyperhive.effortLevel = lib.mkOption { type = lib.types.enum [ "medium" "high" "xhigh" ]; default = "medium"; example = "high"; description = '' Baseline claude effort level for this agent. Rendered into the `HIVE_DEFAULT_EFFORT` environment variable; the harness resolves effort as operator-override-file → this env → built-in `"medium"`, and passes the result to `claude --effort` at turn launch. `"medium"` (the default) keeps token spend low and works well with the harness across families. `"high"` matches the current platform default; `"xhigh"` is recommended for coding / high-autonomy work on capable models (Opus 4.8+) at higher token cost. The operator can override at runtime per-agent via the web UI (applied on the next session); any rebuild that changes this option resets that override. ''; }; options.hyperhive.docs.enable = lib.mkEnableOption '' make the hyperhive reference docs (the repo `docs/` tree, shipped read-only as the standalone `hyperhive-docs` derivation) available in-container. When enabled the harness exposes the docs dir to claude via `claude --add-dir`, so the markdown is readable at `$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's system prompt so it knows the docs exist (see `hive-ag3nt::prompt::render`). Default-on for the root/manager agent (see `manager.nix`), off elsewhere; any agent can flip it from its `agent.nix`. ''; options.hyperhive.docs.source = lib.mkOption { type = lib.types.path; default = pkgs.hyperhive-docs; defaultText = lib.literalMD "`pkgs.hyperhive-docs` (built from `../docs`)"; description = '' Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR` when `hyperhive.docs.enable` is set. Defaults to the overlay's `pkgs.hyperhive-docs` (the `nix/reference-docs.nix` build from `../docs`) so a standalone container build from a full checkout works unchanged. The generated meta flake overrides this with the narrow `hyperhive-docs` flake input so a doc edit only re-locks that input instead of rebuilding the container from a re-hashed `hyperhive` source. ''; }; options.hyperhive.allowedBashPatterns = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = '' Deprecated - has no effect. The built-in Bash tool is fully disabled regardless of this list; agents use mcp__bash__run instead. Remove this option from your agent.nix. ''; visible = false; }; # OTEL stats export is configured ONCE at host level via # `services.hyperhive.otel.*` (see nix/modules/hive-c0re.nix) and # injected into every agent's build by the meta-flake renderer # (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are # the build-time implementation surface that injection writes into; # they are not meant to be set directly in an agent.nix. Marked # `internal` so the host option is the only documented operator knob. options.hyperhive.otel = { enable = lib.mkOption { type = lib.types.bool; default = false; internal = true; description = '' Export this agent's Claude Code stats (token usage, cost, tool calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry. Each agent's harness exports directly to the collector, so it keeps working even when hive-c0re is down. Host-driven: set `services.hyperhive.otel.enable` instead. ''; }; endpoint = lib.mkOption { type = lib.types.str; default = ""; internal = true; description = '' OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. Host-driven via `services.hyperhive.otel.endpoint`. ''; }; protocol = lib.mkOption { type = lib.types.enum [ "http/protobuf" "http/json" "grpc" ]; default = "http/protobuf"; internal = true; description = '' OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. Host-driven via `services.hyperhive.otel.protocol`. ''; }; headersCredential = lib.mkOption { # `str`, not `path`: a `path`-typed *relative* literal (e.g. # `./otel-headers`) is hash-copied into the world-readable nix store # at eval time, which would defeat the whole point of this option. # Keep it a string and require an absolute runtime path so the secret # is only ever read from disk by systemd at start, never nix-stored. type = lib.types.nullOr lib.types.str; default = null; internal = true; description = '' Absolute path to an operator-provided secret file whose contents become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `Authorization=Bearer `). Host-driven via `services.hyperhive.otel.headersCredential`. The rest of the OTEL config ships in the world-readable managed claude settings json, but the header is a secret, so it's handled separately: hive-c0re forwards this file into the container's systemd credential store, and the `hive-otel-header` oneshot reads it at runtime (`LoadCredential`) and writes it into the agent's `0600` `~/.claude/settings.json` `env` block. The token is read from disk at start and never copied into the nix store or the world-readable settings file. ''; }; extraResourceAttributes = lib.mkOption { type = lib.types.str; default = ""; internal = true; description = '' Extra comma-separated entries appended to `OTEL_RESOURCE_ATTRIBUTES` after the built-in `service.name` / `agent` / `hive` / `swarm` labels. Host-driven via `services.hyperhive.otel.extraResourceAttributes`. ''; }; metricIntervalMs = lib.mkOption { type = lib.types.nullOr lib.types.ints.positive; default = null; internal = true; description = '' Metric export interval in milliseconds, set as `OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s default. Host-driven via `services.hyperhive.otel.metricIntervalMs`. ''; }; debug = lib.mkOption { type = lib.types.bool; default = false; internal = true; description = '' Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`). Host-driven via `services.hyperhive.otel.debug`. ''; }; }; options.hyperhive.allowedRecipients = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "alice" "manager" ]; description = '' Names this agent is allowed to `send` to via `mcp__hyperhive__send`. Empty list (the default) means unrestricted — the agent can message any peer, the operator, or the manager. Non-empty list constrains the surface: only the listed names + the manager (always allowed) get through; anything else returns an error string to claude without touching the broker. The operator (`operator`) needs to be in the list if the agent should be able to surface output on the dashboard. Useful for sandboxing untrusted sub-agents — set `[ "manager" ]` to scope them to manager-only chatter. The manager itself is always exempt; this option only affects sub-agent `send`. ''; }; options.hyperhive.extraMcpServers = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { command = lib.mkOption { type = lib.types.str; description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`."; }; args = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = "Args passed to the MCP server binary."; }; env = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; description = "Environment variables for the MCP server child process."; }; allowedTools = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "*" ]; example = [ "send_message" "join_room" ]; description = '' Tool names this MCP server is auto-approved to call via `--allowedTools`. Single entry `"*"` (the default) means "every tool from this server" — convenient but trusting. Tighten to a specific list when you only want a subset. Names are bare (e.g. `send_message`); the harness prepends `mcp____` at build time. ''; }; }; } ); default = { }; example = lib.literalExpression '' { matrix = { command = "/run/current-system/sw/bin/mcp-matrix"; args = [ "--config" "/state/matrix.toml" ]; env.MATRIX_HOMESERVER = "https://matrix.example.org"; allowedTools = [ "send_message" "join_room" ]; }; } ''; description = '' Extra MCP servers claude sees alongside the hyperhive tool surface. Keys are the server names (claude addresses tools as `mcp____`). Rendered to `/etc/hyperhive/extra-mcp.json` at activation time; the harness reads that file at boot and merges it into `--mcp-config` + `--allowedTools`. Take effect on the agent's next harness restart (no operator approval needed beyond whatever brought the new agent.nix into deployed/*). ''; }; options.hyperhive.matrix.enable = lib.mkOption { type = lib.types.bool; default = true; description = '' Enable per-agent matrix integration via `hive-matrix-mcp`. When true (the default), the harness: - runs `hive-matrix-daemon` as a systemd unit that holds a matrix-sdk Client + sync against the homeserver at `HIVE_MATRIX_URL` (default `http://localhost:8008` — the in-host tuwunel from `nix/modules/hive-matrix.nix`). The daemon auto-skips when `/matrix-token` is missing, and a `systemd.paths` watcher restarts it the moment hive-c0re provisions the token (same path-trigger shape as `forge-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, list_room_members, read_room) to claude via an auto-injected `extraMcpServers.matrix` entry. Claude spawns the stdio `hive-matrix-mcp` bridge per turn, which forwards each tool call to the daemon over `/run/hive-matrix/socket`. - wakes the agent on incoming room events via a short teaser Wake signal (`[matrix] in : …`) to the hyperhive control socket; the full event stays unread server-side until `read_room` consumes it. Set to `false` for agents that should NOT have matrix tools at all (e.g. agents on a host without `hyperhive.matrix.enable` on the meta side). When token file is absent the daemon and MCP both no-op cleanly anyway, so `false` is rarely necessary. ''; }; options.hyperhive.matrix.url = lib.mkOption { type = lib.types.str; default = matrixUrlDefault; example = "https://matrix.darkest.space"; description = '' Matrix homeserver URL the agent's `hive-matrix-daemon` connects to. Default points at the in-host tuwunel (shared netns). Override per-agent when an agent should talk to an external homeserver instead (e.g. a federation-only setup or a remote hive's tuwunel reached via a vpn). ''; }; options.hyperhive.matrixAccounts = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { tokenFile = lib.mkOption { type = lib.types.str; example = "/agents/dmatrix/state/matrix-token-ccc"; description = '' Path to this account's bearer-token file. The daemon reads the token from here to restore the matrix session; how the file gets populated is the provisioner's concern (an operator-supplied secret for an external account). The daemon skips an extra account whose token file is absent. ''; }; sessionDir = lib.mkOption { type = lib.types.str; example = "/agents/dmatrix/state/matrix-sdk-state-ccc"; description = '' Per-account matrix-sdk sqlite store directory (crypto keys + event cache). Must differ between accounts so their sessions do not collide. ''; }; homeserver = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "https://matrix.example.org"; description = '' Homeserver URL for this account. When null (the default), the account falls back to `hyperhive.matrix.url`. Set it for an account on a different homeserver than the agent's default (e.g. an external public-matrix account). ''; }; }; } ); default = { }; example = lib.literalExpression '' { ccc = { tokenFile = "/agents/dmatrix/state/matrix-token-ccc"; sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc"; homeserver = "https://matrix.example.org"; }; } ''; description = '' Declare *additional* matrix accounts served by the single `hive-matrix-daemon` (one matrix-sdk Client + sync loop each), beyond the agent's built-in hive-internal account. This replaces the wasteful "one MCP server + daemon per account" pattern. The attribute name keys each account (unique by construction) and is the handle the matrix MCP tools target via their `account` argument. The **hive-internal account is always present and is the primary**: it is named `main`, synthesized by the daemon from `hyperhive.matrix.url` + `/matrix-token` + `/matrix-sdk-state`, and is the account a tool call acts as when it omits `account`. You never declare it here --- this option is only for the extras (e.g. an external public-matrix account). Leave empty (the default) for the common single-account case: the agent then has only `main`, exactly as before. When non-empty, the extras are serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment variable and the daemon appends them after `main`. Requires `hyperhive.matrix.enable` (there is no `main` to extend otherwise). ''; }; options.hyperhive.frontend.dist = lib.mkOption { type = lib.types.package; default = pkgs.hyperhive-frontend; defaultText = lib.literalExpression "pkgs.hyperhive-frontend"; description = '' The shipped frontend dist (built by `nix/frontend.nix`). Output layout: `dashboard/` (used by hive-c0re on the host) and `agent/` (used here, layered with `extraFiles` below at activation time). Override to ship a fully custom per-agent SPA; the JSON contract (`/api/state`, `/events/stream`, the action endpoints) is the source of truth for any replacement. ''; }; options.hyperhive.frontend.mergedDist = lib.mkOption { type = lib.types.package; readOnly = true; description = '' Computed: the merged static tree consumed by the harness via `HIVE_STATIC_DIR`. Composed at evaluation time by copying `hyperhive.frontend.dist`'s `agent/` subdir as the base, then layering each `extraFiles` entry on top. Read-only — do not set directly. ''; }; options.hyperhive.frontend.extraFiles = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule ( { name, ... }: { options = { source = lib.mkOption { type = lib.types.path; description = '' Source file or directory to layer over the default agent dist. A path (relative to `agent.nix` or absolute) — nix copies its contents into the merged static tree. ''; }; target = lib.mkOption { # First char must be alphanumeric/underscore (rules out # leading `/`, leading `.`, leading `-`); inner chars # include `.` and `/` so nested layouts like # `"games/bitburner"` work. This is the shape check — # the `..`-segment traversal check is the assertion in # `config.assertions` below (regex alone can't reject # mid-path `..` segments without lookahead, which nix # POSIX regex doesn't support). type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$"; default = name; defaultText = lib.literalMD "the attribute name"; description = '' Destination path within the merged static tree, used as both the served URL prefix (`//...`) and the on-disk layout in the merged derivation. Defaults to the attribute name. Use forward slashes for nested layouts (e.g. `"games/bitburner"`). Constrained shape: must start with an alphanumeric or `_`, and only contain alphanumerics, `_`, `.`, `/`, `-`. `..` segments are separately rejected at config eval time. ''; }; }; } ) ); default = { }; example = lib.literalExpression '' { bitburner = { source = ./bitburner-dist; # served at GET /bitburner/... }; } ''; description = '' Per-agent additions layered on top of the default frontend dist. Each entry copies its `source` into the served static tree under `target`. Useful for shipping a self-contained agent-specific surface alongside the standard agent UI (e.g. the bitburner agent's game page at `/bitburner/`). The default agent UI remains served at `/`; entries here only add new routes and never replace the default. Overwrite semantics are **hard-fail**: if `target` collides with an existing file or directory in the default dist (or with a prior entry's target), the `mergedDist` build aborts with `refusing to overwrite existing path '' in the default dist`. To override a default file, fork the dist via `hyperhive.frontend.dist` instead — `extraFiles` is for pure additions. `target` must be a relative path inside the static dir. An assertion rejects leading `/` and `..` segments at config eval time (string-concat-into-paths safety, even though agent.nix goes through operator review before deploy). ''; }; options.hyperhive.forge.url = lib.mkOption { type = lib.types.str; default = "http://localhost:3000"; example = "http://forge.internal:3000"; description = '' Base URL of the hyperhive-managed Forgejo. Used at container boot by a oneshot systemd unit that calls `tea login add --url --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` (= `/agents//state/forge-token`) so the agent's claude can shell out to `tea` without an extra auth dance. No-op when the forge-token file is missing (i.e. hive-forge isn't running on the host). ''; }; 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" ]; example = [ "anthropics/claude-plugins-official" "anthropics/claude-plugins-community" ]; description = '' Claude Code plugin marketplaces to add at harness boot. Each entry is passed to `claude plugin marketplace add ` (`owner/repo`, full git URL, or local path). Idempotent — re-adding an existing marketplace is treated as success. Required before `hyperhive.claudePlugins` entries that reference a marketplace (e.g. `foo@claude-plugins-official`). Rendered to `/etc/hyperhive/claude-marketplaces.json`. Defaults to Anthropic's official marketplace; agents get it out of the box without any per-agent.nix wiring. ''; }; options.hyperhive.claudePlugins = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "formatter@my-marketplace" "thinking-tools@anthropics" ]; description = '' Claude Code plugins to install at harness boot. Each entry is passed verbatim to `claude plugin install ` once per container start, before the turn loop opens. `claude plugin install` is expected to be idempotent, so reinstalling on every boot is cheap. Failures log a warning but do not abort boot — a missing plugin is preferable to a non-serving agent. Rendered to `/etc/hyperhive/claude-plugins.json`; the harness reads it via `plugins::install_configured`. ''; }; options.hyperhive.claudePluginsAutoUpdate = lib.mkOption { type = lib.types.bool; default = false; description = '' When true, the harness runs `claude plugin marketplace update` before installing plugins at boot, pulling the latest index from all configured marketplaces. Disabled by default — most agents want pinned plugin versions and the network round-trip adds to boot time. Enable for agents that should always install the latest available version of their plugins. ''; }; options.hyperhive.icon = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; example = lib.literalExpression "./icon.svg"; description = '' Path to an SVG file used as this agent's icon — shown on the dashboard and the per-agent web UI (header + favicon). Commit the SVG into the agent's config repo next to `agent.nix` and reference it as a relative path (`./icon.svg`). When null (the default) the agent falls back to the shared hyperhive logo. The harness serves the icon (configured or default) at `GET /icon` on the per-agent web port. ''; }; # Internal accumulator for shell snippets that should land in # `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via # `lib.mkIf` gated on their own option; the lines type merges # all contributions across modules into one file. Loaded via # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool # runs `bash -c`) and via `programs.bash.interactiveShellInit` # for interactive shells. Generic by design so future hooks # don't need to rename this file or invent a parallel dispatcher. options.hyperhive._bashEnvFragments = lib.mkOption { type = lib.types.lines; default = ""; internal = true; description = '' Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`. Feature hooks contribute via `lib.mkIf` gated on their own option. When empty, the file isn't created, `BASH_ENV` stays unset, and the interactive bashrc hook is omitted — zero cost when no feature is on. Internal — set indirectly via the per-feature options that own the gate (e.g. `hyperhive.cargo.shortMessages`). ''; }; options.hyperhive.cargo.shortMessages = lib.mkOption { type = lib.types.bool; default = true; example = false; description = '' Auto-inject `--message-format short` on cargo compile subcommands (`build`, `check`, `clippy`, `test`, `run`, `doc`, `bench`, `install`, `rustc`, `fix`) when claude (or anything else) invokes `cargo` inside this container. Saves tokens + context — the verbose default output floods the response window with per-crate progress lines that carry no signal beyond the warning/error summary. Implementation: contributes a `cargo` shell function to `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). Loaded via `BASH_ENV` for non-interactive shells (`bash -c` — what the claude `Bash` tool runs) and sourced from `programs.bash.interactiveShellInit` for interactive shells. The function: - handles the `+toolchain` selector prefix (`cargo +nightly build` works); - passes through cleanly when the caller already specified `--message-format` (any form); - leaves non-compile subcommands (`new`, `add`, `search`, third-party `cargo-*` subcommands) untouched so they don't error on the unknown flag. Set to `false` for agents that need full cargo output (e.g. tooling that parses `--message-format json` programmatically and doesn't pass the flag explicitly). ''; }; options.hyperhive.autoCompact = lib.mkOption { type = lib.types.bool; default = true; description = '' Enable proactive watermark-based compaction. When `true` (the default) the harness automatically runs a notes-checkpoint turn followed by `/compact` once the context window crosses 75% of the model's limit, keeping later turns from hitting the hard overflow path. Set to `false` to disable proactive compaction entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path (compact-on-overflow when the session is already past the limit) still applies. Disable for agents that run large-context models (sonnet/opus) where the heuristic fires too early and discards useful history before the session is actually close to the limit. ''; }; options.hyperhive.mcp.httpPort = lib.mkOption { type = lib.types.nullOr lib.types.port; default = null; example = 8790; description = '' Serve the built-in hyperhive MCP surface as a persistent streamable-http daemon on this loopback port instead of the default per-turn stdio child. When null (the default) claude spawns a fresh `hive … mcp` stdio subprocess every turn — the shape that carries the per-turn MCP re-registration race (a resumed turn can emit its first tool call before that turn's async `initialize`/`tools-list` completes, stranding the agent with `No such tool`). When set, a long-lived `hive-mcp-http` systemd unit runs `hive mcp --http 127.0.0.1:` and `render_claude_config` points claude at the stable `http://127.0.0.1:/mcp` URL, which survives the per-turn claude re-spawn (and a host-side hive-c0re restart — each tool call dials the control socket fresh). Extra MCP servers (matrix/bash) stay stdio bridges regardless. Bound loopback-only; the rmcp streamable-http transport's default `allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host headers from anywhere else, so no auth token is required for a container-local endpoint. Failure-mode note: enabling this flips the MCP surface from a transient self-healing race (stdio child re-registers each turn) to a hard dependency on the `hive-mcp-http` daemon's uptime — while it's up there is no race ever, but while it's down claude hits a dead URL with no stdio fallback until the unit restarts (guarded by `Restart=always`, `RestartSec=3`). Net-better when up; operators should know the signature flips from "flaps + recovers" to "dead until the unit restarts". Also pick a port unique per host: two agents sharing a host that both set the same `httpPort` collide on bind and the loser Restart-loops. ''; }; config = { warnings = lib.optional (config.hyperhive.allowedBashPatterns != [ ]) '' hyperhive.allowedBashPatterns is deprecated and has no effect. The built-in Bash tool is fully disabled; agents use mcp__bash__run instead. Remove allowedBashPatterns from your agent.nix. ''; assertions = [ # Guard the inputs-routed-as-output pattern: the agent flake.nix is # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. # If `self` leaks into flakeInputs the agent gets a spurious attrset # entry that can shadow real inputs and is almost certainly a bug. # Guard with `or {}` so standalone evaluation stays clean when # flakeInputs is absent from _module.args. { assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { })); message = '' hyperhive: `flakeInputs` must not contain "self". In your agent flake.nix, use: _module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ]; ''; } # hyperhive.model must be a non-empty string — an empty value causes # the harness to pass an invalid model flag to claude. { assertion = config.hyperhive.model != ""; message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")"; } # The current model must appear in the quick-picker menu, otherwise the # UI would offer no way back to the model the agent is actually running. { assertion = config.hyperhive.availableModels == [ ] || builtins.elem config.hyperhive.model config.hyperhive.availableModels; message = "hyperhive.model (\"${config.hyperhive.model}\") must be one of " + "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) " + "— add it to the list or change the model."; } # hyperhive.forge.url must look like an HTTP URL when non-default. { assertion = config.hyperhive.forge.url == "" || lib.hasPrefix "http://" config.hyperhive.forge.url || lib.hasPrefix "https://" config.hyperhive.forge.url; message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; } # hyperhive.icon must reference an SVG file when set. { assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); message = "hyperhive.icon must point to an .svg file"; } # Extra matrix accounts only make sense alongside the hive-internal # `main` account they extend, which exists only when matrix is # enabled. { assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable; message = "hyperhive.matrixAccounts requires hyperhive.matrix.enable = true " + "(the extras extend the hive-internal `main` account, which only " + "exists when matrix is enabled)."; } # `main` is reserved for the synthesized hive-internal account; a # declared extra by that name would silently collide with it. { assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts; message = "hyperhive.matrixAccounts cannot contain a key named \"main\" " + "--- that name is reserved for the hive-internal account."; } # Token files must land at the `matrix-token*` name the daemon # path-watcher globs (`/agents/*/state/matrix-token*`), or the account # never gets picked up live (it loads only on a full daemon restart). # Enforce the basename prefix so a deviating name (e.g. the historical # `matrix-catgirl-token`) is caught at build time, not silently. { assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) ( lib.attrValues config.hyperhive.matrixAccounts ); message = "every hyperhive.matrixAccounts..tokenFile basename must start with " + "\"matrix-token\" so the daemon path-watcher glob " + "(/agents/*/state/matrix-token*) picks it up live. Offending: " + lib.concatStringsSep ", " ( lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") ( lib.filterAttrs ( _n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile) ) config.hyperhive.matrixAccounts ) ) + "."; } # hyperhive.frontend.extraFiles[*].target is concatenated into # $out during the mergedDist build. The option's strMatching # type already rejects leading `/`, leading `.`, and the # weirder characters; this assertion catches mid-path `..` # segments (e.g. `foo/../etc/passwd`) that the type's regex # can't easily express without lookahead. agent.nix is # operator-reviewed, so this is belt-and-braces — but it's the # kind of mistake that's easy to make and hard to spot. { assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) ( lib.attrValues config.hyperhive.frontend.extraFiles ); message = '' hyperhive.frontend.extraFiles: `target` must not contain `..` path segments. ''; } ]; # Per-agent unix user. Runs the hive harness + # co-process daemons under a non-root principal. UID auto-assigned by # NixOS. The container activation script (hive-agent-user-migrate) # chowns the bind-mounted state dir — including credential files # written by hive-c0re before the container was built — to this user # on every boot, so agent processes can always read their own tokens. users.users.${userName} = { isNormalUser = true; home = homeDir; createHome = true; group = userName; extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel"; # Matches /bin/bash on NixOS — the harness's claude shell-outs # expect a POSIX shell at $SHELL; bashInteractive is already # the system default for the root user too (see SHELL env # var declaration below). shell = pkgs.bashInteractive; }; users.groups.${userName} = { }; # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool # keep working with anything that expected root (systemctl, # nix-env, etc.) without prompting — same surface as the # previous root-by-default shape, just elevated explicitly. # Flip `hyperhive.user.passwordlessSudo = false` to drop both # the wheel-group membership and this sudoers entry; anything # that needs root then fails loudly instead of silently # succeeding. security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [ { users = [ userName ]; commands = [ { command = "ALL"; options = [ "NOPASSWD" ]; } ]; } ]; # First-boot migration to the per-agent unix user — creates the # home dir, chowns the bind-mounted state + `~/.claude/`, and # (marker-guarded) moves any leftover `/root/.claude` content # from the previous root-run shape. See # `docs/persistence.md::First-boot agent-user migration` for the # step-by-step rationale; this script implements it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' homeDir=${lib.escapeShellArg homeDir} userName=${lib.escapeShellArg userName} mkdir -p "$homeDir" chown "$userName:$userName" "$homeDir" marker=/var/lib/hive-agent-user-migrated if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then mkdir -p "$homeDir/.claude" if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then rm -rf /root/.claude echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude" fi fi mkdir -p "$(dirname "$marker")" : > "$marker" # Scope state + harness chowns to THIS container's own dirs only. # The glob `/agents/*/state` also matches child-agent state dirs that # are bind-mounted into parent containers, which would clobber the # ownership those dirs' own activation scripts set — producing # intermittent EACCES for the child agent's harness between a parent # rebuild and the child's next activation. Config dirs are kept broad # because the parent legitimately owns child proposed-config repos. if [ -d "/agents/$userName/state" ]; then chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true fi if [ -d "/agents/$userName/harness" ]; then chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true fi # The proposed-config repo is RW-mounted into the editing (parent/ # manager) agent and owned by it; hive-c0re only pulls from it. Heal # it to this user too — same as state/harness. In an agent's own # container its config is RO-mounted, so the chown there just fails # harmlessly (|| true). for configDir in /agents/*/config; do [ -d "$configDir" ] || continue chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true done if [ -d "$homeDir/.claude" ]; then chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true # 0755 so hive-core (a different unix user) can list the dir and # detect a valid claude session. Credential files inside are 0600 # so secrets stay private regardless of the directory mode. # ensure_claude_dir sets 0755 on creation but cannot re-chmod after # hive-agent-user-migrate chowns the dir to the agent user; this # activation script runs as root and handles the correction. chmod 755 "$homeDir/.claude" 2>/dev/null || true fi ''; # Auto-inject built-in MCP servers. bash is always present; matrix is # conditional on hyperhive.matrix.enable. Both use lib.mkDefault so # the operator's own agent.nix can override individual entries. hyperhive.extraMcpServers = lib.mkMerge [ { bash = lib.mkDefault { command = "${pkgs.hyperhive}/bin/hive-bash-mcp"; args = [ ]; env.HIVE_BASH_SOCKET = "/run/hive-bash/socket"; allowedTools = [ "*" ]; }; } (lib.mkIf config.hyperhive.matrix.enable { matrix = lib.mkDefault { command = "${pkgs.hyperhive}/bin/hive-matrix-mcp"; args = [ ]; # Same socket path the hive-matrix-daemon service binds # via its `RuntimeDirectory = "hive-matrix"`. Keeps the # bridge + daemon in sync without baking the path into # the Rust default — the env override wins for both. env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; allowedTools = [ "*" ]; }; }) ]; environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; # Operator-set per-agent icon (hyperhive.icon). When configured, the # SVG lands at /etc/hyperhive/icon.svg; the harness serves it at # GET /icon, falling back to the bundled hyperhive logo when absent. environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) { source = config.hyperhive.icon; }; # Cargo `--message-format short` injector. Contributes a `cargo` # shell function to `hyperhive._bashEnvFragments`; the bash-env # infrastructure below packages that into a single file sourced # by both non-interactive and interactive shells. # `command cargo …` falls back to the un-wrapped binary in PATH # (the rust toolchain's cargo — either from `environment.systemPackages` # or from whatever `nix develop` shell the agent's working in). hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' # Auto-injects --message-format short on cargo compile # subcommands so per-crate progress lines don't flood # claude's context. Bypassed when the caller already passes # --message-format (any form). cargo() { # Strip leading +toolchain selectors (cargo +nightly …). local pre=() while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do pre+=("$1") shift done case "''${1:-}" in build|check|clippy|test|run|doc|bench|install|rustc|fix) local sub="$1" shift local arg for arg in "$@"; do case "$arg" in --message-format|--message-format=*) command cargo "''${pre[@]}" "$sub" "$@" return $? ;; esac done command cargo "''${pre[@]}" "$sub" --message-format short "$@" ;; *) command cargo "''${pre[@]}" "$@" ;; esac } ''; # Single bash-env file with all configured shell fragments. # Wiring is gated on at least one fragment being active so a # fully feature-disabled agent has neither the file nor the # `BASH_ENV` / interactive sourcing — zero cost in that case. environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") { text = config.hyperhive._bashEnvFragments; }; environment.etc."hyperhive/send-allow.json".text = builtins.toJSON config.hyperhive.allowedRecipients; environment.etc."hyperhive/claude-plugins.json".text = builtins.toJSON config.hyperhive.claudePlugins; environment.etc."hyperhive/claude-marketplaces.json".text = builtins.toJSON config.hyperhive.claudeMarketplaces; environment.etc."hyperhive/claude-plugins-auto-update.json".text = builtins.toJSON config.hyperhive.claudePluginsAutoUpdate; # Hive-enforced claude settings. claude-code auto-discovers managed # settings at this canonical Linux path (precedence #1, read-only, # un-overridable by user/project/CLI) — so the harness no longer # passes `--settings` or copies the blob per turn. effortLevel is # deliberately NOT shipped here: effort is controlled live via the # `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider), # which managed scope would otherwise override and lock. # Hive-enforced settings merged with a per-agent `env` block at BUILD # time via `jq` (not eval-time `readFile`, which would be import-from- # derivation). The `env` block is always present: `baseClaudeEnv` sets # behaviour flags and the remote-control session prefix for every agent; # `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code # auto-discovers this file in every context (harness turn-loop AND # `hivectl choom`) so no launch wrapper is needed. environment.etc."claude-code/managed-settings.json".source = let baseSettings = "${pkgs.hyperhive-assets}/share/hyperhive/prompts/claude-settings.json"; # Merge base env (always) with OTEL env (when enabled). jq is always # run — `baseClaudeEnv` contains per-agent values (e.g. # CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the # static store asset. allEnv = baseClaudeEnv // lib.optionalAttrs otelCfg.enable otelSettingsEnv // lib.optionalAttrs (otelCfg.enable && otelCfg.debug) { # SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true. CLAUDE_CODE_OTEL_DIAG_STDERR = "1"; }; in pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } '' jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \ '. + { env: $env }' ${baseSettings} > "$out" ''; # Inject the OTEL auth header (a secret) into the agent's *user* # claude settings at runtime, keeping it out of the world-readable # managed settings json above and out of the nix store entirely. # hive-c0re forwards the operator's `headersCredential` file into # this container's systemd credential store; this oneshot reads it # via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS` # into `~/.claude/settings.json` (0600, agent-owned). claude layers # the user `env` on top of the managed one, so both the harness # turn-loop and `hivectl choom` (same agent user) pick it up. Ordering # is best-effort (`before`, not a hard dep): if it fails the harness # still starts and telemetry just exports unauthenticated. systemd.services.hive-otel-header = lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null) { description = "Inject the OTEL auth header into the agent's claude user settings"; wantedBy = [ "multi-user.target" ]; before = [ "hive-ag3nt.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = userName; Group = userName; LoadCredential = [ "otel-headers" ]; ExecStart = pkgs.writeShellScript "hive-otel-header" '' set -eu umask 077 hdr="$CREDENTIALS_DIRECTORY/otel-headers" [ -r "$hdr" ] || exit 0 dir=${homeDir}/.claude settings="$dir/settings.json" mkdir -p "$dir" base='{}' [ -s "$settings" ] && base="$(cat "$settings")" printf '%s' "$base" | ${pkgs.jq}/bin/jq \ --rawfile h "$hdr" \ '.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \ > "$settings.tmp" mv "$settings.tmp" "$settings" chmod 0600 "$settings" ''; }; }; # Seed claude's onboarding + per-project trust state once. claude only # marks `hasCompletedOnboarding` (global) and the project trust dialog # as accepted when run *interactively*; the harness only ever runs it # headless (`--print`) and `claude auth login` doesn't set them either. # So the first interactive launch (`hivectl choom`) would drop the # operator into the onboarding/trust walkthrough despite valid OAuth # creds. This oneshot is the single place hyperhive touches # `~/.claude.json`: it runs before the harness (so nothing races it), # is idempotent (skips when the flags are already set), and is # best-effort (`before`, not a hard dep — a failed seed leaves the file # untouched and the harness still starts). Credentials live in the # separate `~/.claude/.credentials.json`, so this never touches secrets. systemd.services.hive-claude-onboarding = { description = "Seed claude onboarding + project-trust so choom skips the walkthrough"; wantedBy = [ "multi-user.target" ]; before = [ "hive-ag3nt.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = userName; Group = userName; ExecStart = pkgs.writeShellScript "hive-claude-onboarding" '' set -eu umask 077 cfg=${homeDir}/.claude.json dir=/agents/${userName}/state base='{}' [ -s "$cfg" ] && base="$(cat "$cfg")" # Idempotent: nothing to do when already onboarded + trusted. if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \ --arg d "$dir" \ '.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \ >/dev/null 2>&1; then exit 0 fi printf '%s' "$base" | ${pkgs.jq}/bin/jq \ --arg d "$dir" \ '.hasCompletedOnboarding = true | .projects[$d].hasTrustDialogAccepted = true | .projects[$d].hasCompletedProjectOnboarding = true' \ > "$cfg.tmp" mv "$cfg.tmp" "$cfg" chmod 0600 "$cfg" ''; }; }; # Merged frontend static tree. Base = `${frontend.dist}/agent/`, # then each `extraFiles` entry is laid on top at its `target` # path. The runCommand derivation aborts on overwrite so a # filename collision with the default dist surfaces as a build # failure rather than a silent override (operator gets a clear # nix error rather than a confusing 404 / silent dist swap). hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } ( '' mkdir -p $out cp -r ${config.hyperhive.frontend.dist}/agent/. $out/ chmod -R u+w $out '' + lib.concatMapStrings (entry: '' mkdir -p $(dirname $out/${entry.target}) if [ -e $out/${entry.target} ]; then echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2 exit 1 fi cp -r ${entry.source} $out/${entry.target} '') (lib.attrValues config.hyperhive.frontend.extraFiles) ); # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted # model choice exists in the state dir. SHELL must be set so claude's # Bash tool finds a POSIX shell. # HIVE_ASSETS_DIR points at the project's static runtime assets # (branding + claude prompts; see `nix/assets.nix`). Set here so # both the harness binary and any user-shell `cargo run` inside the # container resolve them from the same path. # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the # host-level `services.hyperhive.c0re.contextWindowTokens` option — not set here. environment.variables = { HIVE_DEFAULT_MODEL = config.hyperhive.model; # HIVE_AVAILABLE_MODELS is the comma-separated menu for the per-agent # UI model quick-picker (see hyperhive.availableModels). The harness # surfaces it to the frontend; an empty value falls back to the # built-in default list. HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels; # HIVE_DEFAULT_EFFORT is the per-agent baseline effort (see # hyperhive.effortLevel). The harness resolves operator-override-file # → this env → "medium" and passes it to claude --effort at turn launch. HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; SHELL = "${pkgs.bashInteractive}/bin/bash"; # Route interactive-shell nix invocations through the host daemon. # Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set # regardless of which profile files are sourced. NIX_REMOTE = "daemon"; } // lib.optionalAttrs (!config.hyperhive.autoCompact) { # Zero watermark disables proactive compaction; the reactive path # (compact-on-overflow) still fires when the session is truly full. HIVE_COMPACT_WATERMARK_TOKENS = "0"; } // lib.optionalAttrs config.hyperhive.docs.enable { # hyperhive.docs.enable: the in-container reference-docs tree. The # harness reads HIVE_DOCS_DIR and passes it to claude as `--add-dir` # so the docs are readable, and appends a single pointer sentence to # the system prompt (hive-ag3nt::prompt::render) telling the agent the # docs exist. Source is `hyperhive.docs.source` (the narrow # `hyperhive-docs` meta-flake input, or `pkgs.hyperhive-docs` for # standalone builds). See hive-ag3nt::turn. HIVE_DOCS_DIR = "${config.hyperhive.docs.source}"; } // lib.optionalAttrs (config.hyperhive._bashEnvFragments != "") { # Non-interactive bash invocations (claude's `Bash` tool runs # `bash -c`) source $BASH_ENV at startup — drops every active # feature hook's snippet into scope without touching # `/etc/profile` (login-only). Interactive shells source the # same file via the `interactiveShellInit` hook below so # behaviour matches across both modes. BASH_ENV = "/etc/hyperhive/bash-env.sh"; }; # Interactive shells don't honour BASH_ENV — wire the same file # in via the bashrc hook so operator SSH sessions get the same # hook surface as claude's non-interactive calls. Gated on at # least one fragment being active so we don't write a no-op # source line into `/etc/bashrc` on fully-feature-disabled agents. programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") '' if [ -r /etc/hyperhive/bash-env.sh ]; then . /etc/hyperhive/bash-env.sh fi ''; boot.isNspawnContainer = true; # Every agent gets flakes + the modern `nix` CLI out of the box. # Equivalent to passing `--extra-experimental-features 'nix-command # flakes'` on every invocation. Agents shell out to `nix build` / # `nix flake` constantly (devshells, ad-hoc evals, fetching their # own MCP-server flakes); without this they hit the "experimental # feature not enabled" wall on the first try. nix.settings.experimental-features = [ "nix-command" "flakes" ]; # `lib.mkForce` overrides nixpkgs's normal-priority `false` so # in-container `nix build` invocations fall back to unsandboxed # local builds rather than failing on the missing user-namespace. # See `docs/gotchas.md::Containerized nix-daemon needs # sandbox-fallback = true` + `docs/security.md` for the rationale. # # Note: with NIX_REMOTE=daemon below this becomes a no-op for the # common case — daemon-routed builds run on the host where sandboxing # works. It stays as a belt-and-suspenders fallback for any context # that bypasses the daemon (e.g. direct nix-store invocations). nix.settings.sandbox-fallback = lib.mkForce true; # Route ALL nix invocations in this container through the host # nix-daemon socket, regardless of whether the caller is root or # non-root. Without this, root contexts (PID 1, systemd services # running as root) default to store=auto which resolves to the LOCAL # store — bypassing the shared daemon, its remote builders, and the # host's prebuilt derivation cache, causing spurious full rebuilds. # # systemd.globalEnvironment sets DefaultEnvironment in systemd.conf, # so every unit started by PID 1 inherits NIX_REMOTE=daemon. # Non-root nix clients already default to the daemon socket, so this # is a no-op for them; it only matters for root services that would # otherwise silently use the local store. systemd.globalEnvironment.NIX_REMOTE = "daemon"; # `claude-code` is unfree. Each per-agent container's nixosConfiguration # evaluates its own `nixpkgs` instance, so the operator's host-level # `nixpkgs.config.allowUnfreePredicate` does not propagate into here — # we have to allow it inside the container's config as well. nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; environment.systemPackages = with pkgs; [ hyperhive claude-code bashInteractive coreutils-full # procps for pkill — used by the web UI's /api/cancel to SIGINT the # in-flight claude turn. procps # tea: gitea/forgejo CLI client. Configured at boot by the # tea-login oneshot below if /state/forge-token is present, so # claude can `tea repos create`, `tea pulls create`, etc. tea # jq: JSON processing in shell — useful for parsing API responses, # forge REST calls, sqlite output, etc. jq # curl: HTTP client for forge REST API and other web requests. curl # hive-forge : CLI wrapping common Forgejo REST API operations # (view, pr, issue, comment, assign, close, labels, branches, etc.) (pkgs.callPackage ../packages/hive-forge-tools.nix { }) ]; # One-shot: tea config.yml from the seeded forge token. Shape # contract (always exit 0, no set -e, skip-silently, re-runnable): # docs/conventions.md::Best-effort oneshot services. # Take resolvconf + dhcpcd out of the /etc/resolv.conf loop so the # bridge resolver the oneshot below writes actually sticks. At their # NixOS defaults, resolvconf regenerates resolv.conf from host-tracking # *after* the oneshot has pointed it at the bridge (dhcpcd re-triggers # that when the veth comes up under isolation) — silently clobbering the # bridge nameserver back to the host resolver, which isn't authoritative # for the hive's own zones, so `forge.` stops resolving. We # disable resolvconf and tell dhcpcd not to touch resolv.conf (without # disabling dhcpcd itself, so the veth still gets its address); then # whoever wrote resolv.conf last owns it: the nixos-container host-copy # in shared netns, or the oneshot in isolated mode. (Same "take # resolvconf out of the loop" approach the matrix container uses.) networking.resolvconf.enable = false; networking.dhcpcd.extraConfig = "nohook resolv.conf"; # Point resolv.conf at the hive bridge resolver when the container is # network-isolated. nixos-container copies the *host's* /etc/resolv.conf # into the container at every start — but the host resolver (e.g. # 127.0.0.53) is unreachable from a private netns and isn't # authoritative for the hive's own zones (forge. etc.). The # bridge dnsmasq (gateway IP) is. hive-priv drops the marker # `/etc/hyperhive-bridge-dns` (containing the gateway IP) only when # isolation is on, so this oneshot is inert in shared-netns mode — the # same shared container toplevel does the right thing in both modes. # Ordered before the first DNS consumer (tea-login) and the network # targets so name resolution works for the very first turn. systemd.services.hyperhive-isolated-dns = { description = "point resolv.conf at the hive bridge resolver (isolated containers)"; wantedBy = [ "multi-user.target" ]; after = [ "local-fs.target" ]; # Ordered before every network consumer that does DNS on first # boot. `hive-ag3nt` (the harness) is the load-bearing one: its # first-turn api.anthropic.com lookup must not race the resolv.conf # rewrite (it only declares `after network.target`, so without this # edge the harness can start before we've fixed resolv.conf and the # first turn errors — self-heals next turn, but better not to flap). # `hive-matrix-daemon` likewise syncs over the network; the `before` # is a harmless no-op when matrix is disabled (the unit is absent). before = [ "network-online.target" "tea-login.service" "hive-ag3nt.service" "hive-matrix-daemon.service" ]; unitConfig.ConditionPathExists = "/etc/hyperhive-bridge-dns"; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; # Pin the journal identity; without it systemd derives it from the # generated `script` store-path wrapper (an opaque `-…-start`). SyslogIdentifier = "hyperhive-isolated-dns"; }; path = [ pkgs.coreutils ]; script = '' set -eu gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns) if [ -z "$gw" ]; then echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is" exit 0 fi # resolv.conf is a regular file copied from the host by # nixos-container; replace it (rm first in case it's a symlink). rm -f /etc/resolv.conf printf 'nameserver %s\n' "$gw" > /etc/resolv.conf echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw" ''; }; systemd.services.tea-login = { description = "configure tea CLI from hive-forge token (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "local-fs.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "tea-login"; }; path = [ pkgs.curl pkgs.python3 pkgs.coreutils ]; environment.HOME_DIR = homeDir; environment.AGENT_USER = userName; script = '' # No `set -e`: best-effort posture (see docs pointer above). FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is system-wide via the meta flake. TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" if [ ! -f "$TOKEN_FILE" ]; then echo "tea-login: no forge-token at $TOKEN_FILE; skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") # Resolve the agent username from the forge API. USER=$(curl -sf --max-time 5 \ -H "Authorization: token $TOKEN" \ "$FORGE_URL/api/v1/user" \ | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ 2>/dev/null || true) if [ -z "$USER" ]; then echo "tea-login: could not resolve username from forge API; skipping" exit 0 fi # Config under the agent user's home, chown'd to them; # service stays root-owned (see docs pointer above). CONFIG="$HOME_DIR/.config/tea/config.yml" mkdir -p "$(dirname "$CONFIG")" || true cat > "$CONFIG" << EOF logins: - name: forge url: $FORGE_URL token: $TOKEN default: true ssh_host: "" ssh_key: "" insecure: false ssh_agent: false user: $USER preferences: editor: false flag_defaults: remote: "" EOF chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" ''; }; # Path-trigger sibling: re-fires forge-avatar-sync the moment # `/forge-token` appears. Mirrors the hive-matrix-daemon # token-watcher pattern — on first agent deployment the container boots before # hive-c0re has provisioned the forge-token, so the service fires # too early and exits with "no forge-token found". Without this path # unit, RemainAfterExit=true would prevent systemd from ever # re-running the service. See docs/persistence.md::forge-avatar-sync. systemd.paths.forge-avatar-sync = { description = "trigger forge-avatar-sync when forge-token appears"; wantedBy = [ "multi-user.target" ]; pathConfig.PathExistsGlob = "/agents/*/state/forge-token"; }; # One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract: # docs/conventions.md::Best-effort oneshot services. # RemainAfterExit = false (unlike the old true) so the .path trigger # above can re-fire this unit when the forge-token arrives after boot. systemd.services.forge-avatar-sync = { description = "sync agent icon to Forgejo user avatar (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "tea-login.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = false; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "forge-avatar-sync"; }; path = [ pkgs.curl pkgs.coreutils pkgs.jq pkgs.librsvg ]; script = '' ICON=/etc/hyperhive/icon.svg if [ ! -f "$ICON" ]; then echo "forge-avatar-sync: no icon configured; skipping" exit 0 fi FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake # (systemd.globalEnvironment) to `/agents//state`. TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" if [ ! -f "$TOKEN_FILE" ]; then echo "forge-avatar-sync: no forge-token found; skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") # Rasterize SVG → PNG (Forgejo's Go image library can't decode SVG). PNG=$(mktemp --suffix=.png) if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then echo "forge-avatar-sync: rsvg-convert failed; skipping" rm -f "$PNG" exit 0 fi IMAGE=$(base64 -w 0 < "$PNG") rm -f "$PNG" # Forgejo POST /user/avatar expects {"image":""} — just the # raw base64 string, NOT a data URI (data:image/png;base64,...). # Use jq to build the payload so the large base64 value is safely quoted. PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') RESP=$(curl -sf --max-time 10 \ -X POST "$FORGE_URL/api/v1/user/avatar" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ -w "\n%{http_code}" 2>/dev/null || true) CODE=$(printf '%s' "$RESP" | tail -1) if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" else echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" fi ''; }; # Long-running matrix-sdk client + sync per agent. Holds the unix # socket the stdio `hive-matrix-mcp` bridge connects to + emits # hyperhive wake signals on incoming room events via # `/run/hive/mcp.sock`. See # `docs/persistence.md::Matrix per-agent daemon + token-arrival # trigger` for the socket-path / first-boot-ordering rationale. systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "long-running matrix-sdk Client + MCP daemon socket"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; environment = { HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; RUST_LOG = "info"; } # Homeserver URL: by default the daemon inherits the host-forwarded # HIVE_MATRIX_URL (set isolation-aware by hive-c0re: `matrix.` # via the gateway under private-netns isolation, loopback otherwise), # falling back to the daemon's built-in localhost default if the # forward is absent. A per-agent `hyperhive.matrix.url` override # (non-default) is set unit-level so it wins over the forwarded value; # at the default we deliberately DON'T set it so the forwarded # isolation-aware value isn't shadowed. // lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) { HIVE_MATRIX_URL = config.hyperhive.matrix.url; } # Multi-account: serialize the *extra* accounts to the JSON the # daemon parses (`accounts::configured`). Only set when extras are # declared; the daemon always synthesizes the primary `main` # (hive-internal) account itself from the per-agent paths and # prepends it, so we emit extras only. Each entry is in the # daemon's `AccountCfg` serde shape: name (the attr key) / # token_file / state_dir / optional homeserver. // lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) { HIVE_MATRIX_ACCOUNTS = builtins.toJSON ( lib.mapAttrsToList ( name: a: { inherit name; token_file = a.tokenFile; state_dir = a.sessionDir; } // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } ) config.hyperhive.matrixAccounts ); } # Rasterized agent icon path for the daemon's avatar sync. Only set # when an icon is configured; absent → the daemon skips avatar setting # (hive-matrix-mcp::client::sync_avatar returns early on unset env). // lib.optionalAttrs (config.hyperhive.icon != null) { HIVE_ICON_PNG = "${iconPng}"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; SyslogIdentifier = "hive-matrix-daemon"; Restart = "on-failure"; RestartSec = 5; User = userName; Group = userName; RuntimeDirectory = "hive-matrix"; # Keep /run/hive-matrix across restarts. With the default # `RuntimeDirectoryPreserve=no`, a `switch-to-configuration` # restart races the outgoing instance's stop-time cleanup # (which deletes the dir) against the incoming instance's # start (which creates it + binds the socket inside it). The # cleanup can win and delete the dir out from under the fresh # daemon, which then fails to mkdir under root-owned /run and # exits — looping on Restart=on-failure until the next boot. # `yes` stops systemd removing it on stop; it still creates it # on first start, and it lives on tmpfs so it's gone at # container reboot regardless. See hive-bash-daemon below. RuntimeDirectoryPreserve = "yes"; }; }; # Bash task runner daemon — long-running process that owns subprocess # monitoring + completion wake signals. Always enabled (every agent # needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects # to this daemon's socket per turn. # Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs. systemd.services.hive-bash-daemon = { description = "bash task runner daemon for hive-bash-mcp"; wantedBy = [ "multi-user.target" ]; # The daemon runs every bash task via `Command::new("bash")` and the # commands themselves (hive-forge, git, jq, …) resolve from PATH. # Pre-split this ran inside hive-ag3nt.service and inherited the # agent's PATH; the standalone daemon needs the same or `bash` itself # isn't found (spawn fails with ENOENT, the task is marked done in # 0s with no output / no .out/.err). Mirror the harness unit's PATH: # NixOS appends `/bin` to each entry → /run/wrappers/bin (setuid # sudo) + /run/current-system/sw/bin (bash, coreutils, hive-forge, …). path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { HIVE_BASH_SOCKET = "/run/hive-bash/socket"; HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock"; RUST_LOG = "info"; # HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already # injected via systemd.globalEnvironment by the meta flake # (set to /agents//harness and /agents//state # respectively). Listed here for explicitness — the daemon # uses these to derive its task + loose-ends dir paths. # Without them the daemon falls back to deriving harness/ as a # sibling of state/, which produces the same value but is # less robust if the two vars ever diverge. }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-bash-daemon"; SyslogIdentifier = "hive-bash-daemon"; Restart = "on-failure"; RestartSec = 3; User = userName; Group = userName; RuntimeDirectory = "hive-bash"; # See the matching note on hive-matrix-daemon. Without this, a # post-rebuild restart races stop-time dir cleanup against the # fresh daemon's socket-dir creation; the daemon loses, fails # `mkdir /run/hive-bash` (Permission denied, non-root in /run), # and loops on Restart=on-failure until the next container # boot — i.e. the bash daemon "doesn't come up post-rebuild". RuntimeDirectoryPreserve = "yes"; }; }; # Persistent streamable-http MCP daemon for the built-in hyperhive # surface. Only wired when `hyperhive.mcp.httpPort` is set; otherwise # the surface stays the default per-turn stdio child (rendered by # `render_claude_config`). Long-lived so claude reconnects to the # stable URL each turn instead of respawning + re-registering a stdio # subprocess (the per-turn MCP registration race). It dials the # control socket (`/run/hive/mcp.sock`, the `hive` binary default) # fresh on every tool call, so a host-side hive-c0re restart is # transparent. `before = hive-ag3nt` so the URL is already listening # by the time the harness renders the first turn's config; the # harness/claude also reconnect on their own, so ordering is a # latency nicety not a hard correctness dep. systemd.services.hive-mcp-http = lib.mkIf (config.hyperhive.mcp.httpPort != null) { description = "persistent streamable-http MCP daemon for the hyperhive surface"; wantedBy = [ "multi-user.target" ]; before = [ "hive-ag3nt.service" ]; environment.RUST_LOG = "info"; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}"; SyslogIdentifier = "hive-mcp-http"; # `always` (not `on-failure`): this endpoint is load-bearing when # `httpPort` is set — a down window is total hyperhive-MCP loss with # no stdio fallback and no per-turn self-heal (the URL just stays # dead). `always` also covers any unforeseen clean-return path and # restarts after a stray SIGTERM stops it out from under the harness. Restart = "always"; RestartSec = 3; User = userName; Group = userName; }; }; # Re-fire the daemon when the matrix token appears (hive-c0re # provisions it after agent containers come up). Without this # the daemon would exit 0 silently on first boot and the MCP # would have no backend until next restart. See # `docs/persistence.md` (same section as above). systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "trigger hive-matrix-daemon when a matrix token appears"; wantedBy = [ "multi-user.target" ]; # `matrix-token*` (not just `matrix-token`) so a secondary # multi-account token (e.g. `matrix-token-ccc`) landing also # re-fires the daemon to pick up the freshly-provisioned account. pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; }; # Write declared dashboardLinks to the state dir so hive-c0re can # read them without accessing the container's /etc/ from the host. # Best-effort oneshot (always exit 0): # docs/conventions.md::Best-effort oneshot services. 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; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "hive-dashboard-links"; }; environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks; script = '' # Sub-agents have their state dir bind-mounted at /agents//state. # Use a glob — exactly one match per container at runtime. STATE_DIR=$(echo /agents/*/state) if [ ! -d "$STATE_DIR" ]; then echo "hive-dashboard-links: no state dir found at /agents/*/state; 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 # `user.email` with the agent's identity — values here are `mkDefault` # so the per-agent override wins without needing `mkForce`. programs.git = { enable = true; config = { user = { name = lib.mkDefault "hyperhive"; email = lib.mkDefault "hyperhive@local"; }; init.defaultBranch = lib.mkDefault "main"; }; }; # Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars, # RuntimeDirectory, User=, standalone-eval fallbacks): # docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin # auto-append behaviour: docs/gotchas.md::systemd.services.*.path # appends /bin to every entry. systemd.services.hive-ag3nt = let binary = "hive"; # OTEL is shipped declaratively via the managed claude settings # json (`environment.etc."claude-code/managed-settings.json"`, # `otelSettingsEnv` in the top-level let) — claude reads it for # both the harness turn-loop and `hivectl choom`, so there's no # launch wrapper or per-unit OTEL env here anymore. in { description = "${binary} harness"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; # `/run/wrappers` before `/run/current-system/sw` so setuid # `sudo` resolves first. Passing the bare prefixes (no trailing # `/bin`) is intentional — see docs pointer above. path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { SHELL = "${pkgs.bashInteractive}/bin/bash"; HOME = homeDir; HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; # Unix-socket path for the harness web UI. All agents always bind # here; TCP fallback is removed. Path matches # `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle # bind-mounts and gateway upstream config stay in sync. HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; } // lib.optionalAttrs (config.hyperhive.mcp.httpPort != null) { # Presence tells `render_claude_config` to point claude at the # persistent `hive-mcp-http` daemon's loopback URL instead of a # per-turn stdio child. Kept in sync with the `hive-mcp-http` # unit's `--http` port above via the same option. HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort; } // lib.optionalAttrs config.hyperhive.gui.enable { # Tells the harness which fixed VNC port weston bound, and (by # its presence) that gui is enabled — the harness `/screen/ws` # relay reads this instead of a runtime marker file. The port is # container-local + fixed (network isolation is unconditional), # so the same value for every gui agent is fine. See # nix/templates/weston-vnc.nix::hyperhive.gui.vncPort. HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; # Pin the journal identity to the binary name (otherwise systemd # derives SyslogIdentifier from the ExecStart basename). SyslogIdentifier = binary; Restart = "on-failure"; RestartSec = 2; # Per-service runtime dir owned by `User=` below; the harness # writes its regenerated claude-{mcp-config,settings,system-prompt} # files here (`paths::config_dir`). Separate from /run/hive, # which holds hive-c0re's mcp.sock. RuntimeDirectory = "hive-config"; User = userName; Group = userName; }; }; system.stateVersion = "25.11"; }; }