From ac5ea6e5e484d6e9ef48ea5230c0ab2c5d860276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 31 Jul 2026 19:58:18 +0200 Subject: [PATCH 1/2] metrics to exponentials for local claude --- homeConfigurations/muede/claude.nix | 87 ++++++++++++++++++++++++++++ homeConfigurations/muede/default.nix | 2 +- nixosModules/constellation-swarm.nix | 46 ++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 homeConfigurations/muede/claude.nix diff --git a/homeConfigurations/muede/claude.nix b/homeConfigurations/muede/claude.nix new file mode 100644 index 0000000..2185c77 --- /dev/null +++ b/homeConfigurations/muede/claude.nix @@ -0,0 +1,87 @@ +{ + config, + lib, + osConfig, + pkgs, + ... +}: +let + claude-code = pkgs.unstable.claude-code; + + # Endpoint and wire protocol come straight off the hive's own OTLP config in + # nixosModules/constellation-swarm.nix, so there is one place to change them. + # Only those two are shared: the hive's `agent=` / `hive=` / `swarm=` resource + # labels are deliberately not carried over. + hiveOtel = osConfig.services.hyperhive.otel; + + # Written by the `claude-otel-user-header` oneshot in that same module, which + # copies the hive's root-only auth header into /run owned by this user. + # Absent on hosts without `my.constellation-swarm`, where the wrapper starts + # claude with no telemetry at all rather than retrying an unauthenticated + # export every interval. + headerFile = "${osConfig.my.constellation-swarm.userOtelHeaderDir}/${config.home.username}"; + + # OTEL's `host.arch` is an enum of its own spelling, not uname's. + hostArch = + { + x86_64-linux = "amd64"; + aarch64-linux = "arm64"; + } + .${pkgs.stdenv.hostPlatform.system} or pkgs.stdenv.hostPlatform.uname.processor; + + # `deployment.environment.name` is what keeps these interactive sessions + # apart from the agent fleet in the same backend, now that the hive's own + # identifying labels are gone. + resourceAttributes = lib.concatStringsSep "," [ + "service.name=claude-code" + "host.arch=${hostArch}" + "os.type=linux" + "deployment.environment.name=workstation" + ]; + + # Env claude-code reads to export telemetry. Set by the wrapper rather than + # written into ~/.claude/settings.json, because that file is self-mutating — + # /model, /config and plugin toggles all write to it, so home-manager cannot + # own it without breaking them. + otelEnv = { + CLAUDE_CODE_ENABLE_TELEMETRY = "1"; + OTEL_METRICS_EXPORTER = "otlp"; + # Interactive sessions carry far more sensitive content than hive agents, + # so prompt events and tool-decision records never leave the machine. + OTEL_LOGS_EXPORTER = "none"; + OTEL_TRACES_EXPORTER = "none"; + OTEL_EXPORTER_OTLP_PROTOCOL = hiveOtel.protocol; + OTEL_EXPORTER_OTLP_ENDPOINT = hiveOtel.endpoint; + # claude-code defaults to DELTA, which Prometheus/Mimir-family backends + # silently drop without a deltatocumulative processor. + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; + OTEL_METRICS_INCLUDE_VERSION = "1"; + } + # Follow the hive's export cadence too, so retuning it stays a one-line + # change over in constellation-swarm.nix. Null there leaves claude-code's + # own 60s default. `debug` is pointedly not mirrored: the OTEL SDK's stderr + # diagnostics would scribble over an interactive TUI. + // lib.optionalAttrs (hiveOtel.metricIntervalMs != null) { + OTEL_METRIC_EXPORT_INTERVAL = toString hiveOtel.metricIntervalMs; + }; + + exports = lib.concatStrings ( + lib.mapAttrsToList (name: value: " export ${name}=${lib.escapeShellArg value}\n") otelEnv + ); + + # Shadows claude-code's own `bin/claude`, so claude-code itself is kept out + # of home.packages (see ./default.nix) — two derivations installing the same + # binary is a home-manager collision, not a precedence win. + claude-wrapper = pkgs.writeShellScriptBin "claude" '' + hdr=${lib.escapeShellArg headerFile} + if [ -r "$hdr" ]; then + ${exports} export OTEL_EXPORTER_OTLP_HEADERS="$(cat "$hdr")" + export OTEL_RESOURCE_ATTRIBUTES=${lib.escapeShellArg resourceAttributes}",host.name=$(${pkgs.coreutils}/bin/uname -n)" + fi + + exec ${claude-code}/bin/claude "$@" + ''; +in +{ + home.packages = [ claude-wrapper ]; +} diff --git a/homeConfigurations/muede/default.nix b/homeConfigurations/muede/default.nix index c77b369..93843ac 100644 --- a/homeConfigurations/muede/default.nix +++ b/homeConfigurations/muede/default.nix @@ -2,6 +2,7 @@ { imports = [ # keep-sorted start + ./claude.nix ./editorconfig.nix ./element.nix ./fonts.nix @@ -90,7 +91,6 @@ tea telegram-desktop thunderbird - unstable.claude-code vlc wireguard-tools wirelesstools diff --git a/nixosModules/constellation-swarm.nix b/nixosModules/constellation-swarm.nix index 9919cea..0274b5a 100644 --- a/nixosModules/constellation-swarm.nix +++ b/nixosModules/constellation-swarm.nix @@ -7,11 +7,26 @@ }: let cfg = config.my.constellation-swarm; + adminUsers = [ "muede" ]; + otelCredential = "/etc/hyperhive/otel-cred"; + runtimeDirName = "claude-otel"; in { options.my.constellation-swarm = { enable = lib.mkEnableOption "hyperhive / constellation swarm"; hiveName = lib.mkOption { type = lib.types.str; }; + + userOtelHeaderDir = lib.mkOption { + type = lib.types.str; + default = "/run/${runtimeDirName}"; + readOnly = true; + description = '' + Directory holding the per-admin copies of the hive's OTLP auth header, + one 0400 file per user named after them. Read-only so the home-manager + side (homeConfigurations/muede/claude.nix) can point at it via + `osConfig` instead of repeating the path. + ''; + }; }; imports = [ hyperhive.nixosModules.default ]; @@ -39,12 +54,39 @@ in otel = { enable = true; endpoint = "https://exponentials.vibec0re.mov/otel"; - headersCredential = "/etc/hyperhive/otel-cred"; + headersCredential = otelCredential; }; c0re = { - adminUsers = ["muede"]; + inherit adminUsers; agentMemoryMax = "6G"; claudeCodePackage = pkgs.unstable.claude-code; }; }; + + # `otelCredential` is root:root 0600, so the interactive `claude` an admin + # runs as themselves cannot read it — only the hive's agent units can, via + # LoadCredential. Hand each admin their own copy so the wrapper in + # homeConfigurations/muede/claude.nix can pick it up. It lands in /run + # (tmpfs) rather than the nix store because the store is world-readable, and + # rather than /etc because the header should not outlive a reboot. + config.systemd.services.claude-otel-user-header = lib.mkIf cfg.enable { + description = "Expose the hive OTLP auth header to interactive admin users"; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = otelCredential; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RuntimeDirectory = runtimeDirName; + # Traversable by everyone; the per-user files inside are the 0400 part. + RuntimeDirectoryMode = "0755"; + ExecStart = pkgs.writeShellScript "claude-otel-user-header" '' + set -eu + umask 077 + for user in ${lib.escapeShellArgs adminUsers}; do + ${pkgs.coreutils}/bin/install -m0400 -o "$user" -g root \ + ${lib.escapeShellArg otelCredential} "$RUNTIME_DIRECTORY/$user" + done + ''; + }; + }; } From a7c7df8bf8e10c1d4aaf0797f1c07f95992c22b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 31 Jul 2026 20:58:54 +0200 Subject: [PATCH 2/2] enable trollshell pet --- homeConfigurations/muede/trollshell.nix | 90 ++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/homeConfigurations/muede/trollshell.nix b/homeConfigurations/muede/trollshell.nix index a039b25..b2144a2 100644 --- a/homeConfigurations/muede/trollshell.nix +++ b/homeConfigurations/muede/trollshell.nix @@ -1,9 +1,97 @@ -{ ... }: +{ + config, + lib, + pkgs, + trollshell, + ... +}: +let + trollshellPkgs = trollshell.packages.${pkgs.stdenv.hostPlatform.system}; + + # The claude bridge's loopback port, shared between the daemon and the pet + # that talks to it so there is one number to change. The address is fixed at + # 127.0.0.1 by the bridge and is not configurable: the bridge validates no + # bearer token at all, so reachability *is* the authorization boundary. + bridgePort = 8787; +in { programs.trollshell = { enable = true; weather.fallbackCity = "Berlin"; cliphist.enable = true; stats.layout = "split"; + plugins = { + pet = { + enable = true; + package = trollshellPkgs.hytte-plugin-pet; + env = { + PET_NAME = "foo"; + PET_LLM_URL = "http://127.0.0.1:${toString bridgePort}"; + # Not a real key. `hytte_ai_providers::load_key` checks this env + # override *before* ~/.config/trollshell/openrouter.key, so this dummy + # is what stops a genuine OpenRouter key being shipped to a loopback + # port. It is a security control, not a placeholder — don't drop it. + OPENROUTER_API_KEY = "local-bridge"; + }; + }; + departures = { + enable = true; + package = trollshellPkgs.hytte-plugin-departures; + }; + usage = { + enable = true; + package = trollshellPkgs.hytte-plugin-usage; + }; + weather = { + enable = true; + package = trollshellPkgs.hytte-plugin-weather; + }; + }; + }; + + # The keyless loopback shim that puts an OpenAI-compatible face on headless + # `claude --print`, so pet rides the Claude Code subscription instead of + # OpenRouter. trollshell ships the package and a reference unit under `etc/` + # but no module option for it, so the unit is declared here — without it the + # pet's PET_LLM_URL above points at nothing and the plugin stays canned-only. + systemd.user.services.trollshell-claude-bridge = { + Unit = { + Description = "Keyless loopback OpenAI-compatible bridge to headless Claude Code"; + PartOf = [ config.programs.trollshell.systemd.target ]; + After = [ config.programs.trollshell.systemd.target ]; + }; + + Service = { + Type = "simple"; + ExecStart = lib.getExe trollshellPkgs.hytte-claude-bridge; + Restart = "on-failure"; + RestartSec = 5; + + Environment = [ + "RUST_LOG=hytte_claude_bridge=info" + "CLAUDE_BRIDGE_PORT=${toString bridgePort}" + # `claude --model` for the child. Worth pinning: the bridge's default + # per-request budget is 8s (it must stay under the client's 10s), and + # the child otherwise inherits the model from ~/.claude/settings.json — + # currently opus, which would blow that budget on nearly every reply. + "CLAUDE_BRIDGE_MODEL=claude-haiku-4-5" + "CLAUDE_BRIDGE_TIMEOUT_SECS=9" + # Belt-and-braces; the copy that actually prevents a leak is the one on + # the pet above, because load_key runs in the plugin's process. + "OPENROUTER_API_KEY=local-bridge" + # The bridge shells out to `claude`, which is the OTEL wrapper from + # ./claude.nix — so pet chatter is counted as workstation usage. + "PATH=${config.home.profileDirectory}/bin:/run/current-system/sw/bin" + ]; + + # SECURITY CONTROL — do not drop. These four would silently move `claude` + # off the subscription and onto metered API credits (or Bedrock/Vertex). + # The bridge cannot scrub them itself (`std::env::remove_var` is unsafe + # under edition 2024 and that workspace forbids unsafe), so it fails + # closed instead: it *refuses to start* if it finds any of them set. + UnsetEnvironment = "ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX"; + }; + + Install.WantedBy = [ config.programs.trollshell.systemd.target ]; }; }