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..5ff7898 --- /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" + ''} + + 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}" \ + --effort max \ + --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; + }) + ]); +}