Adds a per-agent hyperhive.availableModels list option (default [ haiku sonnet opus ]) rendered into the HIVE_AVAILABLE_MODELS env var (comma-separated) so the per-agent web UI model quick-picker lists exactly the configured models instead of a hardcoded set. Operators set a shared default hive-wide or narrow it per-agent. An assertion guards that hyperhive.model is present in the list so the picker can always offer the model the agent is actually running.
1438 lines
62 KiB
Nix
1438 lines
62 KiB
Nix
{
|
|
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}";
|
|
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_<KEY_UPPER>` 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.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;
|
|
};
|
|
|
|
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__<server-key>__` 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__<key>__<tool>`). 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 `<state>/matrix-token` is missing,
|
|
and a `systemd.paths` watcher restarts it the moment
|
|
hive-c0re provisions the token (same path-trigger shape
|
|
as `matrix-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] <sender> in <room>: <first 100c>…`)
|
|
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 = "http://localhost:8008";
|
|
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.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 (`/<target>/...`) 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 '<target>' 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 <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
|
(= `/agents/<name>/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.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 <source>`
|
|
(`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 <spec>` 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.
|
|
'';
|
|
};
|
|
|
|
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";
|
|
}
|
|
# 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;
|
|
|
|
# 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_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
|
|
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;
|
|
}
|
|
// 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.
|
|
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 <verb>: 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.
|
|
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
|
|
];
|
|
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
|
|
# `<state>/forge-token` appears. Mirrors the matrix-avatar-sync
|
|
# 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;
|
|
};
|
|
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/<name>/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":"<base64>"} — 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_URL = config.hyperhive.matrix.url;
|
|
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
|
|
RUST_LOG = "info";
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${pkgs.hyperhive}/bin/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("sh")` 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 `sh` 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 (sh, 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/<name>/harness and /agents/<name>/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";
|
|
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";
|
|
};
|
|
};
|
|
|
|
# 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 matrix-token appears";
|
|
wantedBy = [ "multi-user.target" ];
|
|
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token";
|
|
};
|
|
|
|
# Path-trigger sibling: re-fires matrix-avatar-sync the moment
|
|
# `<state>/matrix-token` appears. Same first-boot-ordering pattern
|
|
# as hive-matrix-daemon above.
|
|
systemd.paths.matrix-avatar-sync = {
|
|
description = "trigger matrix-avatar-sync when matrix-token appears";
|
|
wantedBy = [ "multi-user.target" ];
|
|
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token";
|
|
};
|
|
|
|
# One-shot: hyperhive.icon → matrix profile avatar (two-step media
|
|
# upload + set avatar_url). Shape contract:
|
|
# docs/conventions.md::Best-effort oneshot services. Protocol +
|
|
# why RemainAfterExit = false:
|
|
# docs/persistence.md::matrix-avatar-sync.
|
|
systemd.services.matrix-avatar-sync = {
|
|
description = "sync agent icon to matrix profile avatar (best-effort)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
# No `after = [ "tea-login.service" ]` — matrix has no
|
|
# equivalent prerequisite; we just need the homeserver up.
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
# RemainAfterExit = false so the .path trigger can re-fire
|
|
# the unit (see docs/persistence.md::matrix-avatar-sync).
|
|
RemainAfterExit = false;
|
|
};
|
|
path = [
|
|
pkgs.curl
|
|
pkgs.coreutils
|
|
pkgs.jq
|
|
pkgs.librsvg
|
|
];
|
|
script = ''
|
|
ICON=/etc/hyperhive/icon.svg
|
|
if [ ! -f "$ICON" ]; then
|
|
echo "matrix-avatar-sync: no icon configured; skipping"
|
|
exit 0
|
|
fi
|
|
# Token written by `hive-c0re::matrix::ensure_user_for` to the
|
|
# agent's bind-mounted state dir. $HYPERHIVE_STATE_DIR is set
|
|
# system-wide by the meta flake (systemd.globalEnvironment) to
|
|
# `/agents/<name>/state`.
|
|
TOKEN_FILE="$HYPERHIVE_STATE_DIR/matrix-token"
|
|
if [ ! -f "$TOKEN_FILE" ]; then
|
|
echo "matrix-avatar-sync: no matrix-token at $TOKEN_FILE; skipping"
|
|
exit 0
|
|
fi
|
|
# Hash-based idempotency: skip the upload if the icon hasn't
|
|
# changed since the last successful sync. Every upload mints a
|
|
# new mxc:// URI which triggers a profile state event in every
|
|
# joined room — uploading the same bytes again produces timeline
|
|
# spam without changing the visible avatar. The hash file lives
|
|
# in $HYPERHIVE_STATE_DIR (survives restart, wiped on purge so
|
|
# purge + re-provision gets a fresh upload). Delete to force
|
|
# re-upload.
|
|
HASH_FILE="$HYPERHIVE_STATE_DIR/matrix-avatar-icon-hash"
|
|
CURRENT_HASH=$(sha256sum "$ICON" | cut -d' ' -f1)
|
|
if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE" 2>/dev/null)" = "$CURRENT_HASH" ]; then
|
|
echo "matrix-avatar-sync: icon unchanged (hash matches); skipping"
|
|
exit 0
|
|
fi
|
|
TOKEN=$(cat "$TOKEN_FILE")
|
|
# Local tuwunel reachable on shared host netns at the
|
|
# default matrix-spec port. Override via
|
|
# `hyperhive.matrix.url` if the operator runs the
|
|
# homeserver elsewhere.
|
|
MATRIX_URL=http://localhost:8008
|
|
# whoami → user_id. Needed to scope the avatar set call.
|
|
# Tolerant of the homeserver being unreachable (`-f` makes
|
|
# curl fail on 4xx/5xx; `|| true` swallows the exit).
|
|
USER_ID=$(curl -sf --max-time 5 \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
"$MATRIX_URL/_matrix/client/v3/account/whoami" 2>/dev/null \
|
|
| jq -r '.user_id // empty' || true)
|
|
if [ -z "$USER_ID" ]; then
|
|
echo "matrix-avatar-sync: whoami failed or homeserver unreachable; skipping"
|
|
exit 0
|
|
fi
|
|
# Rasterize SVG → PNG (matrix media accepts any image type
|
|
# but we already standardise on PNG for the forge sync).
|
|
PNG=$(mktemp --suffix=.png)
|
|
if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then
|
|
echo "matrix-avatar-sync: rsvg-convert failed; skipping"
|
|
rm -f "$PNG"
|
|
exit 0
|
|
fi
|
|
# Step 1: upload bytes → mxc:// URI.
|
|
MXC=$(curl -sf --max-time 10 \
|
|
-X POST "$MATRIX_URL/_matrix/media/v3/upload" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: image/png" \
|
|
--data-binary "@$PNG" 2>/dev/null \
|
|
| jq -r '.content_uri // empty' || true)
|
|
rm -f "$PNG"
|
|
if [ -z "$MXC" ]; then
|
|
echo "matrix-avatar-sync: media upload failed; skipping"
|
|
exit 0
|
|
fi
|
|
# Step 2: set avatar_url on the profile.
|
|
PAYLOAD=$(jq -n --arg url "$MXC" '{avatar_url:$url}')
|
|
CODE=$(curl -s --max-time 10 \
|
|
-X PUT "$MATRIX_URL/_matrix/client/v3/profile/$USER_ID/avatar_url" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$PAYLOAD" \
|
|
-o /dev/null -w "%{http_code}" 2>/dev/null || true)
|
|
if [ "$CODE" = "200" ]; then
|
|
echo "matrix-avatar-sync: avatar set on $USER_ID"
|
|
# Persist hash so subsequent runs skip the upload when the
|
|
# icon hasn't changed.
|
|
echo "$CURRENT_HASH" > "$HASH_FILE"
|
|
else
|
|
echo "matrix-avatar-sync: avatar PUT 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.
|
|
# 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;
|
|
};
|
|
environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks;
|
|
script = ''
|
|
# Sub-agents have their state dir bind-mounted at /agents/<name>/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/<name>/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";
|
|
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";
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
|
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";
|
|
};
|
|
}
|