hyperhive/nix/agent-modules/claude-settings.nix
atlas 14f141625a feat(agent): ship Anthropic's skill-creator plugin by default
Agents had the official marketplace configured out of the box but an
empty plugin list, so nothing was installed from it unless an agent's
own config asked. skill-creator is the one plugin that pays for itself
generically: it teaches an agent to write, refine, and evaluate its own
skills, which is exactly the capability an agent can't bootstrap by
being told about it once in a prompt.

Defaulting the option keeps this consistent with claudeMarketplaces,
which already ships the official marketplace the same way. Documented
the list-option semantics next to both: a per-agent definition replaces
the default rather than extending it, so an agent that sets its own
plugin list has to name skill-creator alongside its entries.
2026-07-26 20:28:51 +02:00

386 lines
17 KiB
Nix

# Everything that shapes claude-code's own configuration inside the
# container: the managed settings json (base env + OTEL), the
# onboarding/trust seed, the runtime OTEL auth-header injection, and
# the plugin/marketplace install lists the harness reads at boot.
{
pkgs,
lib,
config,
...
}:
let
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";
# 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 "<hive>-<agent>" for identification.
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
};
# 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.
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;
};
in
{
# OTEL stats export is configured ONCE at host level via
# `services.hyperhive.otel.*` (see nix/host-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 <token>`). 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.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 = [ "skill-creator@claude-plugins-official" ];
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`.
Defaults to Anthropic's `skill-creator`, which teaches an agent
to write, refine, and evaluate its own skills agents get it
out of the box, matching the default marketplace above.
Note that a per-agent definition REPLACES this default rather
than extending it (ordinary NixOS list-option semantics, same as
`claudeMarketplaces`). An agent that wants extra plugins AND the
default should list `skill-creator@claude-plugins-official`
alongside them.
'';
};
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.
'';
};
config = {
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 doesn't pass
# `--settings` or copy 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 = "${config.hyperhive.packages.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-agent.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-agent.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"
'';
};
};
};
}