From 778f00cd1170d1358c90583259d2939b8848a40c Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 26 May 2026 21:30:47 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Add=20claude-code=20harness=20runtime=20?= =?UTF-8?q?=E2=80=94=20wrapper,=20systemd=20service,=20PTY=20via=20tmux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires declared agents into running processes. For each services.forge.agents. with harness = "claude-code", the new modules/harnesses/claude-code.nix generates: - a wrapper script forge-agent- that sets up the agent's state dir, writes CLAUDE.md from the declared role, plumbs the discord bot token from the secret file (if discordBot is set), and execs claude through a shared tmux server (-L forge) for PTY + attachability - a systemd service running as the runAs user with restart-on-failure - claude-code, tmux, and the wrappers in environment.systemPackages Adds claude-code-nix (github:sadjow/claude-code-nix) as a flake input and passes it to modules via specialArgs. Decisions baked into this PR (open questions from PR #9): - linger NOT used; system services with User= autostart natively via wantedBy = [ "multi-user.target" ] - state dir at ~/.local/state/forge/agents// (XDG, per-user) - Restart=on-failure, RestartSec=5s, StartLimitBurst=3 - tmux always on (claude-code needs a PTY); shared "forge" socket - harness lives in modules/harnesses/.nix so codex and future custom processes swap in cleanly without touching the schema Updates deployments/agicash-team-forge/configuration.nix with a commented-out example agent paired with the sops bot-token declaration, ready to uncomment after the secrets bootstrap. Commits the first flake.lock for reproducibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agicash-team-forge/configuration.nix | 28 ++- flake.lock | 203 ++++++++++++++++++ flake.nix | 4 +- modules/default.nix | 1 + modules/harnesses/claude-code.nix | 131 +++++++++++ 5 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 flake.lock create mode 100644 modules/harnesses/claude-code.nix diff --git a/deployments/agicash-team-forge/configuration.nix b/deployments/agicash-team-forge/configuration.nix index 2c7a97e..c64d31e 100644 --- a/deployments/agicash-team-forge/configuration.nix +++ b/deployments/agicash-team-forge/configuration.nix @@ -61,25 +61,37 @@ sshKeys = sshPublicKeys; }; - # --- Secrets (uncomment after bootstrap) --- + # --- Secrets + first agent (uncomment after bootstrap) --- # # The forge `modules/secrets.nix` module wires sops-nix plumbing in - # the background. To start using encrypted secrets: + # the background. To start using encrypted secrets and the first + # agent: # 1. Follow docs/secrets-bootstrap.md to generate an age key, # register it in .sops.yaml, and create the encrypted - # secrets.yaml file. - # 2. Uncomment the block below to declare which keys are decrypted - # and which agents/bots consume them. - # - # The Discord bot manifest takes a path; sops produces that path at - # `/run/secrets/`, which the existing schema accepts unchanged. + # secrets.yaml file containing `team-bot-token`. + # 2. Uncomment the block below: it declares the secret, the + # Discord bot that consumes it, and the first agent that uses + # the bot. + # 3. Redeploy. systemd starts `forge-agent-coordinator` on boot; + # the agent joins Discord and responds to @mentions. # # sops.secrets."team-bot-token" = { # owner = "gudnuf"; # }; + # # services.forge.discord.bots.team = { # tokenFile = config.sops.secrets."team-bot-token".path; # }; + # + # services.forge.agents.coordinator = { + # role = '' + # agicash team coordinator — the on-call agent in the team + # Discord channel. Listen for @mentions, respond helpfully, + # and learn the team's workflows as we collaborate. + # ''; + # runAs = "gudnuf"; + # discordBot = "team"; + # }; # --- Nix GC --- nix.gc = { diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..7b96c04 --- /dev/null +++ b/flake.lock @@ -0,0 +1,203 @@ +{ + "nodes": { + "claude-code": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1779845892, + "narHash": "sha256-s1vbh/lcCP7Cu0fdIiJhk3UO75W484EtYgZ9L2YGE/E=", + "owner": "sadjow", + "repo": "claude-code-nix", + "rev": "b70482664c9b6f50b745a83539676905ae41fb6b", + "type": "github" + }, + "original": { + "owner": "sadjow", + "repo": "claude-code-nix", + "type": "github" + } + }, + "deploy-rs": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": "nixpkgs_2", + "utils": "utils" + }, + "locked": { + "lastModified": 1770019181, + "narHash": "sha256-hwsYgDnby50JNVpTRYlF3UR/Rrpt01OrxVuryF40CFY=", + "owner": "serokell", + "repo": "deploy-rs", + "rev": "77c906c0ba56aabdbc72041bf9111b565cdd6171", + "type": "github" + }, + "original": { + "owner": "serokell", + "repo": "deploy-rs", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779694939, + "narHash": "sha256-Ly4j75O8ICaSQx3uxPnwk2x7PMF0XQvn5r0c3yBA7FI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f9d8b65950353691ab56561e7c73d2e1063d810b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1743014863, + "narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "claude-code": "claude-code", + "deploy-rs": "deploy-rs", + "nixpkgs": "nixpkgs_3", + "sops-nix": "sops-nix" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777944972, + "narHash": "sha256-VfGRo1qTBKOe3s2gOv8LSoA6Fk19PvBlwQ1ECN0Evn8=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "c591bf665727040c6cc5cb409079acb22dcce33c", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 62aefee..8630d0a 100644 --- a/flake.nix +++ b/flake.nix @@ -6,9 +6,10 @@ deploy-rs.url = "github:serokell/deploy-rs"; sops-nix.url = "github:Mic92/sops-nix"; sops-nix.inputs.nixpkgs.follows = "nixpkgs"; + claude-code.url = "github:sadjow/claude-code-nix"; }; - outputs = { self, nixpkgs, deploy-rs, sops-nix, ... }: + outputs = { self, nixpkgs, deploy-rs, sops-nix, claude-code, ... }: let system = "x86_64-linux"; @@ -35,6 +36,7 @@ ]; specialArgs = { inherit (agicashTeamForgeConfig) sshPublicKeys; + claudeCode = claude-code.packages.${system}.default; }; }; diff --git a/modules/default.nix b/modules/default.nix index 6284f20..8a73b43 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -6,6 +6,7 @@ ./discord.nix ./agents.nix ./secrets.nix + ./harnesses/claude-code.nix ]; options.services.forge = { diff --git a/modules/harnesses/claude-code.nix b/modules/harnesses/claude-code.nix new file mode 100644 index 0000000..4fe5750 --- /dev/null +++ b/modules/harnesses/claude-code.nix @@ -0,0 +1,131 @@ +{ config, lib, pkgs, claudeCode, ... }: + +# claude-code harness: turns declared agents into running processes. +# +# For each services.forge.agents. with harness = "claude-code": +# 1. A wrapper script `forge-agent-` that: +# - Ensures the agent's state dir exists (~/.local/state/forge/agents/) +# - Writes CLAUDE.md from the declared role +# - If discordBot is set, reads the bot token from the secret file and +# wires a .env + DISCORD_STATE_DIR for the claude discord plugin +# - Execs claude-code through a shared tmux server (socket: "forge") +# for PTY + attachability. claude-code needs a TTY; tmux provides it +# and makes the running session attachable. +# 2. A system systemd service running under the runAs user, supervised +# with restart-on-failure and start-rate limit. +# 3. claude-code + tmux + jq installed system-wide so any operator can +# attach or invoke wrappers manually. +# +# Operator cheat sheet (on the box): +# systemctl start forge-agent- # start +# systemctl status forge-agent- # state +# journalctl -u forge-agent- -f # logs +# sudo -u tmux -L forge attach -t agent- # attach +# sudo -u tmux -L forge ls # list all agents on this user +# +# The harness lives in a per-harness module (modules/harnesses/.nix) +# so swapping or adding harnesses (codex, custom processes) doesn't touch +# the agent schema or any other module. + +let + cfg = config.services.forge; + + claudeAgents = + lib.filterAttrs (_: a: a.harness == "claude-code") cfg.agents; + + # Build CLAUDE.md as a build-time file so the role text doesn't have to + # be embedded (and escaped) in the wrapper shell. The wrapper copies it + # into the agent's state dir on every start (idempotent overwrite). + mkClaudeMd = name: agent: + pkgs.writeText "CLAUDE-${name}.md" '' + # ${name} + + ${agent.role} + ''; + + mkWrapper = name: agent: + let + claudeMd = mkClaudeMd name agent; + hasDiscord = agent.discordBot != null; + tokenFile = + if hasDiscord then cfg.discord.bots.${agent.discordBot}.tokenFile else ""; + in + pkgs.writeShellApplication { + name = "forge-agent-${name}"; + runtimeInputs = [ pkgs.tmux pkgs.jq pkgs.coreutils claudeCode ]; + text = '' + set -euo pipefail + + STATE_DIR="$HOME/.local/state/forge/agents/${name}" + mkdir -p "$STATE_DIR" + + # Refresh CLAUDE.md from the declared role on every start. Treat the + # module-declared role as source of truth. + cp -f "${claudeMd}" "$STATE_DIR/CLAUDE.md" + + ${lib.optionalString hasDiscord '' + # Plumb the discord plugin: read the bot token from the secret + # file and write a per-agent .env. The claude discord plugin + # consumes DISCORD_BOT_TOKEN from $DISCORD_STATE_DIR/.env. + if [ -f "${tokenFile}" ]; then + printf 'DISCORD_BOT_TOKEN=%s\n' "$(cat "${tokenFile}")" > "$STATE_DIR/.env" + chmod 600 "$STATE_DIR/.env" + else + echo "WARN: discord bot token file ${tokenFile} not found; starting without discord" >&2 + fi + export DISCORD_STATE_DIR="$STATE_DIR" + ''} + + export CLAUDE_CODE_MAX_THINKING_TOKENS=-1 + cd "$STATE_DIR" + + # claude-code is interactive — it needs a PTY. tmux provides the + # PTY and makes the session attachable. Shared "forge" socket so + # `tmux -L forge ls` enumerates every agent on this user. + exec tmux -L forge new-session -A -s "agent-${name}" \ + claude \ + --model "${agent.model}" \ + --dangerously-skip-permissions \ + ${lib.optionalString hasDiscord + ''--channels "plugin:discord@claude-plugins-official"''} + ''; + }; + + mkUnit = name: agent: { + description = "Forge agent: ${name}"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = agent.runAs; + Group = "forge"; + ExecStart = "${mkWrapper name agent}/bin/forge-agent-${name}"; + Restart = "on-failure"; + RestartSec = "5s"; + StartLimitBurst = 3; + StartLimitIntervalSec = 60; + }; + }; + +in +{ + config = lib.mkIf cfg.enable (lib.mkMerge [ + # Always install claude-code + tmux when forge is enabled, so operators + # have them whether or not any agent is declared yet. + { + environment.systemPackages = [ claudeCode pkgs.tmux ]; + } + + # Per-agent: systemd service + wrapper installed in systemPackages so + # `forge-agent-` is available on PATH for manual invocation too. + (lib.mkIf (claudeAgents != { }) { + environment.systemPackages = + lib.mapAttrsToList mkWrapper claudeAgents; + + systemd.services = lib.mapAttrs' + (name: agent: lib.nameValuePair "forge-agent-${name}" (mkUnit name agent)) + claudeAgents; + }) + ]); +} From 251090fb3d818775bb29cd89a4111951fa46fdfb Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 27 May 2026 10:39:39 -0700 Subject: [PATCH 2/2] Replace dead CLAUDE_CODE_MAX_THINKING_TOKENS env with --effort max Verified against claude-code 2.1.150 unwrapped binary: only CLAUDE_CODE_EFFORT_LEVEL and CLAUDE_CODE_SIMPLE are read. CLAUDE_CODE_MAX_THINKING_TOKENS is not referenced anywhere in the binary; the setting was carried over from v1's launch-agent but no longer does anything. Replace with `--effort max` on the claude invocation, matching v1's default effort level. Per-agent override via an `effort` agent option is a follow-up if/when we want it. Addresses gudnuf's review comment on PR #11. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/harnesses/claude-code.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/harnesses/claude-code.nix b/modules/harnesses/claude-code.nix index 4fe5750..5ff7898 100644 --- a/modules/harnesses/claude-code.nix +++ b/modules/harnesses/claude-code.nix @@ -76,7 +76,6 @@ let export DISCORD_STATE_DIR="$STATE_DIR" ''} - export CLAUDE_CODE_MAX_THINKING_TOKENS=-1 cd "$STATE_DIR" # claude-code is interactive — it needs a PTY. tmux provides the @@ -85,6 +84,7 @@ let exec tmux -L forge new-session -A -s "agent-${name}" \ claude \ --model "${agent.model}" \ + --effort max \ --dangerously-skip-permissions \ ${lib.optionalString hasDiscord ''--channels "plugin:discord@claude-plugins-official"''}