The daemon spawns every nested claude as a plain child, so its cgroup is already the "all subagents" cgroup — but it ran with MemoryHigh=infinity and MemoryMax=infinity, so nothing slowed a subagent down before the kernel's OOM killer stopped the unit and cut every live session with it. MemoryHigh= and not MemoryMax=: a soft ceiling reclaims and stalls the cgroup past two thirds of the container's cap, which turns a silent kill into a visible throttle, while still letting a single subagent exceed its share when the container has memory free. A hard per-agent cap would make overprovisioning impossible, which is not wanted — most of the time nothing in these sessions is compiling. The fraction is taken from hyperhive.claudeMemoryMaxBytes, the container's own effective MemoryMax= that meta.rs already bakes in per agent. When that is null (an `infinity` or percentage cap) the unit renders no ceiling rather than a fabricated constant, and module-eval pins both arms. Refs #4316
331 lines
15 KiB
Nix
331 lines
15 KiB
Nix
# Everything that shapes claude-code's own configuration inside the
|
|
# container: the managed settings json (base env + claude's own
|
|
# telemetry switches), the onboarding/trust seed, and the
|
|
# plugin/marketplace install lists the harness reads at boot.
|
|
#
|
|
# The generic OTLP environment — endpoint, protocol, resource labels —
|
|
# is NOT here: it belongs to every producer in the container, not to
|
|
# claude, and lives in `otel.nix`.
|
|
{
|
|
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`). Options declared in `otel.nix`, which also
|
|
# exports the generic OTLP environment this container's producers read.
|
|
otelCfg = config.hyperhive.otel;
|
|
# Hive display name, read from the per-agent option meta.rs renders
|
|
# (NOT from `environment.variables` — that carries the same name at
|
|
# *runtime* only, so reading it here silently yielded "unknown" on
|
|
# every agent while the process env held the right answer). `null`
|
|
# means the hive did not name itself; "unknown" is then an honest label
|
|
# rather than a guess.
|
|
hiveDisplayName =
|
|
if config.hyperhive.hiveName == null then "unknown" else config.hyperhive.hiveName;
|
|
# Effective per-agent MemoryMax=, in bytes, injected by meta.rs's
|
|
# per-agent flake render (`hyperhive.claudeMemoryMaxBytes`). `null`
|
|
# when the effective cap is unbounded ("infinity") or a RAM
|
|
# percentage — see `resource_limits::effective_memory_bytes`.
|
|
memoryMaxBytes = config.hyperhive.claudeMemoryMaxBytes;
|
|
# 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}";
|
|
}
|
|
# Bun/JavaScriptCore (the runtime under claude-code's `.claude-wrapped`
|
|
# binary) sizes its default heap ceiling off the container's visible
|
|
# `/proc/meminfo`, which under nspawn is the *host's* physical RAM, not
|
|
# the systemd `MemoryMax=` cgroup cap actually enforced on this
|
|
# container — the cap lives on the host's outer cgroup and is invisible
|
|
# from inside (confirmed: `/sys/fs/cgroup/memory.max` reads `max` at
|
|
# every level from in here). That mismatch lets JSC's heap grow well
|
|
# past the real wall before GC kicks in hard, causing choom sessions to
|
|
# hang. Pin JSC's ceiling to 75% of the *actual* effective cap instead,
|
|
# once it's known at build time.
|
|
// lib.optionalAttrs (memoryMaxBytes != null) {
|
|
BUN_JSC_forceRAMSize = toString (memoryMaxBytes * 75 / 100);
|
|
};
|
|
# Claude Code's own telemetry switches — what it emits, and whether it
|
|
# emits at all. Everything an OTEL SDK reads generically (endpoint,
|
|
# protocol, temporality, resource labels) is deliberately NOT here: it
|
|
# lives in `otel.nix` as container environment, because claude is one
|
|
# producer in this container and not the owner of the pipe. Shipping
|
|
# those in claude's managed settings put them on claude's process only,
|
|
# and `hive-metric` — a sibling of claude, not a child — could not see
|
|
# the endpoint at all.
|
|
otelSettingsEnv = {
|
|
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
|
# Attach feedback-survey data to the OTEL pipeline.
|
|
CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1";
|
|
# Which signals claude exports. A different producer in this same
|
|
# container may legitimately emit only metrics, so this stays a
|
|
# claude decision rather than container-wide config.
|
|
OTEL_METRICS_EXPORTER = "otlp";
|
|
OTEL_LOGS_EXPORTER = "otlp";
|
|
OTEL_TRACES_EXPORTER = "otlp";
|
|
# Include the Claude Code version label in emitted metrics.
|
|
OTEL_METRICS_INCLUDE_VERSION = "1";
|
|
};
|
|
in
|
|
{
|
|
# Build-time implementation surface for the JSC-heap-ceiling fix:
|
|
# meta.rs's per-agent flake render injects this from the effective
|
|
# `MemoryMax=` (per-agent `resource-limits.json` override, else the
|
|
# hive-wide `services.hyperhive.agentMemoryMax`) — see
|
|
# `resource_limits::effective_memory_bytes_from`. Not meant to be set
|
|
# directly in an agent.nix, same convention as `hyperhive.otel.*`
|
|
# above; the host option (or `hivectl agent <name> set-limits`) is
|
|
# the real operator knob, and this only reflects the value baked in at
|
|
# the agent's *last rebuild* — `set-limits` still applies the
|
|
# cgroup cap live via a drop-in reload, but this derived heap ceiling
|
|
# needs a rebuild to pick up a new value.
|
|
options.hyperhive.hiveName = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
internal = true;
|
|
description = ''
|
|
Human-readable hive name, rendered per-agent by
|
|
`meta.rs::render_flake` from the host's
|
|
`services.hyperhive.hiveName`. Baked into the OTEL resource
|
|
attributes at build time, which is why it is an option and not
|
|
just the `HYPERHIVE_HIVE_NAME` env var: the env var is read at
|
|
runtime, this is read during evaluation, and wiring only one of
|
|
the two is how every agent ended up reporting `hive=unknown`.
|
|
|
|
`null` means the hive did not name itself.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.swarmName = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
internal = true;
|
|
description = ''
|
|
Human-readable swarm name, rendered per-agent by
|
|
`meta.rs::render_flake` from the host's
|
|
`services.hyperhive.swarm.name`. Same build-time/runtime split as
|
|
`hyperhive.hiveName`.
|
|
|
|
`null` means the hive is not part of a named swarm.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.claudeMemoryMaxBytes = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.ints.positive;
|
|
default = null;
|
|
internal = true;
|
|
description = ''
|
|
Effective per-agent memory cap in bytes, when it's a plain
|
|
byte-size value (null for an unbounded or percentage-based cap).
|
|
This is the whole container's cap, not claude's share of it —
|
|
the name records its first consumer, `BUN_JSC_forceRAMSize` in
|
|
`baseClaudeEnv`. `mcp.nix` reads it too, to size the subagent
|
|
daemon's `MemoryHigh=` against the container it runs in.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.claudeMarketplaces = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [
|
|
"anthropics/claude-plugins-official"
|
|
"${config.hyperhive.packages.claude-plugins}"
|
|
];
|
|
defaultText = lib.literalMD ''
|
|
`[ "anthropics/claude-plugins-official" "''${hyperhive.packages.claude-plugins}" ]`
|
|
(the flake's own local-path plugin marketplace)
|
|
'';
|
|
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 plus hyperhive's
|
|
own `claude-plugins` nix package (see
|
|
`nix/packages/claude-plugins.nix`) — a local-path marketplace
|
|
built as a plain nix store path, registered under the name
|
|
`hyperhive` (from its `marketplace.json`, not the store path
|
|
itself, so plugin specs stay stable across rebuilds). No forge
|
|
repo or git remote needed to ship a hive-authored skill; agents
|
|
get both marketplaces 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"
|
|
"base@hyperhive"
|
|
];
|
|
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` (teaches an agent to
|
|
write, refine, and evaluate its own skills) plus hyperhive's own
|
|
`base` plugin — one plugin bundling every skill that applies to
|
|
*all* agents regardless of role (currently just `state-hygiene`,
|
|
read-before-write + dated-archive discipline for durable
|
|
notes/state files; more all-agent skills land as additional
|
|
skills inside this same plugin, not new plugins — a skill that
|
|
only some agents need gets its own specialized plugin instead).
|
|
Agents get both out of the box, matching the default
|
|
marketplaces 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
|
|
defaults should list both `skill-creator@claude-plugins-official`
|
|
and `base@hyperhive` 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"
|
|
'';
|
|
|
|
# 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"
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
}
|