{ 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 ? { }, ... }: { # Shared scaffolding for any hyperhive harness container — both # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend # this. The systemd service that actually runs the harness binary # differs per role and lives in the child module. # 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 ]; 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.hive-c0re.contextWindowTokens` on the host. ''; }; options.hyperhive.allowedBashPatterns = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "git *" "ls *" "cat /agents/*/state/*" ]; description = '' Shell command patterns auto-approved for the `Bash` built-in tool. Empty list (the default) grants wholesale `Bash` approval — claude can run any shell command without a prompt. Non-empty list replaces `Bash` in `--allowedTools` with one `Bash(pattern)` entry per item; only commands matching a pattern are auto-approved; all others require confirmation (which in `--print` mode means they will not run). Use to sandbox agents to a known-safe command vocabulary. Patterns use the same glob syntax claude accepts in `Bash(…)`: `*` matches any string within a word, shell-style. ''; }; 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.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 — consumers (`agent-base.nix`, `manager.nix`) reference this in their systemd service environment; 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 /state/forge-token)"` so the agent's claude can shell out to `tea` without an extra auth dance. No-op when `/state/forge-token` is missing (i.e. hive-forge isn't running on the host). ''; }; options.hyperhive.forge.keepSubscriptions = lib.mkOption { type = lib.types.bool; default = true; description = '' When true (the default), the forge notification poller will NOT auto-unsubscribe from repo watches after delivering a "subscribed"-reason notification. Sub-agents keep their broad subscriptions so they stay informed about repos they contribute to. Set to false for agents (e.g. the manager) that use reason-based filtering and do not need firehose-level repo visibility — they will auto-unsubscribe after receiving a watched-repo notification. ''; }; options.hyperhive.forge.skipNotifyReasons = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "subscribed" "participating" ]; description = '' Forgejo notification `reason` values to suppress in the forge notification poller. Notifications with these reasons are marked read and silently dropped; all others — including notifications with a null or unrecognised reason — are delivered. Drop-list is safer than an allow-list: directed signals (`review_requested`, `assigned`, `mention`) are never silently missed even if Forgejo returns an unexpected reason string. Empty list (the default) delivers all notifications. Set to `[ "subscribed" "participating" ]` for agents like the manager that want only direct mentions and reviews, not the full repo firehose. Rendered to the `HIVE_FORGE_NOTIFY_SKIP_REASONS` environment variable consumed by the harness poller at runtime. ''; }; 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. ''; }; 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. ''; }; config = { 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\")"; } # 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"; } # 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. ''; } ]; 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; }; environment.etc."hyperhive/bash-allow.json".text = builtins.toJSON config.hyperhive.allowedBashPatterns; 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; # 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_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the # host-level `services.hive-c0re.contextWindowTokens` option — not set here. environment.variables = { HIVE_DEFAULT_MODEL = config.hyperhive.model; SHELL = "${pkgs.bashInteractive}/bin/bash"; } // 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.forge.keepSubscriptions { HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1"; } // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) { HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons; }; 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" ]; # Containers bind-mount the host's nix-daemon socket. The host daemon # may be configured with remote builders or strict sandbox settings # (sandbox-fallback = false) that make local `nix build` invocations # fail inside the container. Enable sandbox-fallback so builds that # can't set up the sandbox (no user-namespaces in nspawn) fall back # to unsandboxed local builds rather than failing outright. # mkForce overrides the nixpkgs nix module which sets this to false # at normal priority -- without it agents get a conflicting definition # error on rebuild. Security implications: see docs/security.md. nix.settings.sandbox-fallback = lib.mkForce true; # `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: write tea's config.yml from the seeded forge token so # the agent can use `tea` without interactive prompts. Runs on # every boot so a rotated token (hive-c0re remints on each agent # rebuild) is always reflected. *Always* exits 0 — never fail a # NixOS switch-to-configuration over a missing/temperamental forge. 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; }; path = [ pkgs.curl pkgs.python3 pkgs.coreutils ]; script = '' # No `set -e`: any subshell failure must not propagate. # A failed unit aborts `nixos-container update` which blocks rebuilds. FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # Manager bind-mounts state at /state; sub-agents at # /agents//state. Glob both — each container only sees # its own mount, so there is exactly one hit (or zero when # the forge hasn't been seeded yet). TOKEN_FILE="" for f in /state/forge-token /agents/*/state/forge-token; do if [ -f "$f" ]; then TOKEN_FILE="$f" break fi done if [ -z "$TOKEN_FILE" ]; then echo "tea-login: no forge-token found; 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 # tea reads config from ~/.config/tea/config.yml (for root: /root/.config/tea/config.yml). # Write it directly so we control default:true and always # refresh a rotated token — no 'tea login add' interactive dance. # $HOME is unset in systemd service context (causing writes to # /.config/). Hardcode /root — always correct for NixOS containers # where the harness runs as root. CONFIG="/root/.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 echo "tea-login: configured for $FORGE_URL as $USER" ''; }; # One-shot: upload the agent's configured icon to its Forgejo user avatar # so the icon shows up on commits / PRs / issue comments in the forge. # Only runs when `/etc/hyperhive/icon.svg` is present (set via # `hyperhive.icon`). No-op when the forge is unreachable or the icon # is not set. *Always* exits 0. 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 = true; }; 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} TOKEN_FILE="" for f in /state/forge-token /agents/*/state/forge-token; do if [ -f "$f" ]; then TOKEN_FILE="$f" break fi done if [ -z "$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 ''; }; # Write declared dashboardLinks to the state dir so hive-c0re can read # them without accessing the container's /etc/ from the host. # Runs every boot; idempotent (overwrite). Always exits 0. systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) { description = "write declarative dashboardLinks to agent state dir"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks; script = '' # 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"; }; }; system.stateVersion = "25.11"; }; }