{ config, lib, pkgs, ... }: with lib; let cfg = config.coding.agents.pi; mcpCfg = config.programs.mcp or null; hasPiPackage = pkgs ? pi; defaultPiImageArchive = if hasPiPackage then pkgs.dockerTools.buildLayeredImage { name = "pi-agent"; tag = "latest"; contents = with pkgs; [ bashInteractive bun cacert coreutils findutils git gnugrep gnused nix nodejs pi ]; config = { Env = [ "PATH=/bin:/usr/bin" "NIX_REMOTE=daemon" "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; WorkingDir = "/tmp"; Cmd = ["${pkgs.coreutils}/bin/sleep" "infinity"]; }; } else null; in { options.coding.agents.pi = { enable = mkEnableOption "Pi agent management via canonical agent.toml definitions"; container = mkOption { description = "Run Pi through a rootless Podman container while keeping a native host UX."; default = {}; type = types.submodule { options = { enable = mkEnableOption "Containerized Pi wrapper"; name = mkOption { type = types.str; default = "pi-agent"; description = "Container name used by the Pi wrapper."; }; image = mkOption { type = types.str; default = if hasPiPackage then "pi-agent:latest" else "docker.io/nixos/nix:latest"; description = '' Podman image to run for Pi. Defaults to a local declarative Pi-ready image when `pkgs.pi` exists, otherwise falls back to docker.io/nixos/nix:latest. ''; }; imageArchive = mkOption { type = types.nullOr types.path; default = defaultPiImageArchive; description = '' Optional OCI/Docker archive path to load into Podman when `image` is missing locally. By default, a Pi-ready local image archive is generated when `pkgs.pi` is available. ''; }; projectRoots = mkOption { type = types.listOf types.str; default = []; description = '' Allowlisted absolute host roots that may be mounted into the container. Wrapper exits with a clear error when cwd is outside these roots. ''; example = ["/home/m3tam3re/p"]; }; autoStart = mkOption { type = types.bool; default = true; description = "Automatically start container when wrapper is invoked and it is not running."; }; autoNixDevelop = mkOption { type = types.bool; default = false; description = '' If true and cwd contains flake.nix, run Pi as: nix develop -c pi ... inside the container. ''; }; extraRunArgs = mkOption { type = types.listOf types.str; default = []; description = "Additional Podman create args appended after safe defaults."; }; extraEnv = mkOption { type = types.attrsOf types.str; default = {}; description = "Extra environment variables passed to the container."; }; }; }; }; mcpServers = mkOption { type = types.attrsOf types.anything; default = if mcpCfg != null then mcpCfg.servers else {}; defaultText = literalExpression "config.programs.mcp.servers"; description = '' MCP server configurations for Pi (pi-mcp-adapter). Written to ~/.pi/agent/mcp.json. Automatically inherits from config.programs.mcp.servers. ''; }; agentsInput = mkOption { type = types.nullOr types.anything; default = null; description = '' The `agents` flake input (your personal AGENTS repo). When set, the primary agent's system prompt is rendered as SYSTEM.md, all agents are listed in AGENTS.md, and subagent .md files are deployed. ''; }; modelOverrides = mkOption { type = types.attrsOf types.str; default = {}; description = '' Per-agent model overrides for Pi subagents. Maps agent slug to model string, e.g.: { chiron = "anthropic/claude-sonnet-4"; chiron-forge = "anthropic/claude-sonnet-4"; } ''; }; primaryAgent = mkOption { type = types.nullOr types.str; default = null; description = '' Override which canonical agent is used as primary for SYSTEM.md. When null, the first agent with mode="primary" is used. ''; }; settings = mkOption { type = types.submodule { freeformType = types.attrsOf types.anything; options = { packages = mkOption { type = types.listOf types.str; default = []; description = '' Pi packages to install (npm:, git:, or local paths). These are written to ~/.pi/agent/settings.json. ''; }; defaultProvider = mkOption { type = types.nullOr types.str; default = null; description = "Default LLM provider (e.g. 'anthropic', 'openai', 'zai')."; }; defaultModel = mkOption { type = types.nullOr types.str; default = null; description = "Default model ID."; }; defaultThinkingLevel = mkOption { type = types.nullOr (types.enum ["off" "minimal" "low" "medium" "high" "xhigh"]); default = null; description = "Default extended thinking level."; }; theme = mkOption { type = types.nullOr types.str; default = null; description = "Pi theme name."; }; hideThinkingBlock = mkOption { type = types.nullOr types.bool; default = null; description = "Hide thinking blocks in output."; }; quietStartup = mkOption { type = types.nullOr types.bool; default = null; description = "Hide startup header."; }; compaction = mkOption { type = types.nullOr (types.submodule { options = { enabled = mkOption { type = types.nullOr types.bool; default = null; }; reserveTokens = mkOption { type = types.nullOr types.int; default = null; }; keepRecentTokens = mkOption { type = types.nullOr types.int; default = null; }; }; }); default = null; description = "Auto-compaction settings."; }; enabledModels = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "Model patterns for Ctrl+P cycling."; }; sessionDir = mkOption { type = types.nullOr types.str; default = null; description = "Directory where session files are stored."; }; extensions = mkOption { type = types.listOf types.str; default = []; description = "Local extension file paths or directories."; }; skills = mkOption { type = types.listOf types.str; default = []; description = "Local skill file paths or directories."; }; }; }; default = {}; description = '' Pi settings written to ~/.pi/agent/settings.json. Only non-null values are included in the generated JSON. See pi docs/settings.md for all options. ''; }; }; config = mkIf cfg.enable (let # Build settings.json by filtering out null values recursively filterNulls = attrs: lib.filterAttrs (_: v: v != null) ( builtins.mapAttrs (_: v: if builtins.isAttrs v then let filtered = filterNulls v; in if filtered == {} then null else filtered else v) attrs ); piSettings = filterNulls cfg.settings; projectRoots = map toString cfg.container.projectRoots; projectRootsShell = concatStringsSep " " (map escapeShellArg projectRoots); extraRunArgsShell = concatStringsSep " " (map escapeShellArg cfg.container.extraRunArgs); extraEnvPairs = map (k: "${k}=${cfg.container.extraEnv.${k}}") (builtins.attrNames cfg.container.extraEnv); extraEnvShell = concatStringsSep " " (map escapeShellArg extraEnvPairs); hostPiDir = "${config.home.homeDirectory}/.pi"; hostPiDirShell = escapeShellArg hostPiDir; imageArchiveShell = if cfg.container.imageArchive != null then escapeShellArg (toString cfg.container.imageArchive) else ""; piWrapper = pkgs.writeShellScriptBin "pi" '' set -euo pipefail PODMAN="${pkgs.podman}/bin/podman" REALPATH="${pkgs.coreutils}/bin/realpath" CONTAINER_NAME=${escapeShellArg cfg.container.name} IMAGE=${escapeShellArg cfg.container.image} IMAGE_ARCHIVE=${imageArchiveShell} AUTO_START=${ if cfg.container.autoStart then "1" else "0" } AUTO_NIX_DEVELOP=${ if cfg.container.autoNixDevelop then "1" else "0" } HOST_PI_DIR=${hostPiDirShell} PI_CONFIG_MOUNT="/pi-config" WRAPPER_SCHEMA_VERSION="4" RUNTIME_HOME="/tmp" RUNTIME_NPM_PREFIX="/tmp/.npm-global" RUNTIME_NPM_CACHE="/tmp/.npm-cache" RUNTIME_TMPDIR="/tmp/.runtime-tmp" PROJECT_ROOTS=(${projectRootsShell}) EXTRA_RUN_ARGS=(${extraRunArgsShell}) EXTRA_ENV_VARS=(${extraEnvShell}) err() { printf "pi-wrapper: %s\n" "$1" >&2 exit 1 } if [ "''${#PROJECT_ROOTS[@]}" -eq 0 ]; then err "No allowed projectRoots configured. Set coding.agents.pi.container.projectRoots." fi if ! command -v "$PODMAN" >/dev/null 2>&1; then err "podman binary not found at $PODMAN" fi CWD="$($REALPATH -m "$PWD")" cwd_allowed=0 NORMALIZED_ROOTS=() for root in "''${PROJECT_ROOTS[@]}"; do norm_root="$($REALPATH -m "$root")" NORMALIZED_ROOTS+=("$norm_root") case "$CWD/" in "$norm_root/"*) cwd_allowed=1 ;; esac done if [ "$cwd_allowed" -ne 1 ]; then { printf "pi-wrapper: cwd '%s' is outside allowed projectRoots.\n" "$CWD" printf "Allowed roots:\n" for root in "''${NORMALIZED_ROOTS[@]}"; do printf " - %s\n" "$root" done } >&2 exit 1 fi tty_args=() if [ -t 0 ] && [ -t 1 ]; then tty_args=(-it) fi ensure_image_available() { if [ -n "$IMAGE_ARCHIVE" ] && [ -f "$IMAGE_ARCHIVE" ]; then "$PODMAN" load -i "$IMAGE_ARCHIVE" >/dev/null fi if ! "$PODMAN" image exists "$IMAGE"; then err "Container image '$IMAGE' is not available and no valid imageArchive was provided." fi } create_container() { mount_args=() for root in "''${NORMALIZED_ROOTS[@]}"; do mount_args+=("-v" "$root:$root:rw") done if [ ! -S /nix/var/nix/daemon-socket/socket ]; then err "Host Nix daemon socket not found at /nix/var/nix/daemon-socket/socket" fi mount_args+=("-v" "/nix/var/nix/daemon-socket/socket:/nix/var/nix/daemon-socket/socket:rw") mkdir -p "$HOST_PI_DIR" mount_args+=("-v" "$HOST_PI_DIR:$PI_CONFIG_MOUNT:rw") if [ -d /nix/store ]; then mount_args+=("-v" "/nix/store:/nix/store:ro") fi if [ -e /etc/nix/nix.conf ]; then mount_args+=("-v" "/etc/nix/nix.conf:/etc/nix/nix.conf:ro") fi if [ -d /etc/ssl/certs ]; then mount_args+=("-v" "/etc/ssl/certs:/etc/ssl/certs:ro") fi if [ -d /etc/pki ]; then mount_args+=("-v" "/etc/pki:/etc/pki:ro") fi env_args=() for kv in "''${EXTRA_ENV_VARS[@]}"; do env_args+=("--env" "$kv") done "$PODMAN" create \ --name "$CONTAINER_NAME" \ --hostname "$CONTAINER_NAME" \ --label io.m3ta.pi-wrapper-version="$WRAPPER_SCHEMA_VERSION" \ --userns keep-id \ --user "$(${pkgs.coreutils}/bin/id -u):$(${pkgs.coreutils}/bin/id -g)" \ --security-opt no-new-privileges \ --workdir /tmp \ --tmpfs /tmp:rw,nodev,nosuid,mode=1777 \ --env HOME="$RUNTIME_HOME" \ --env PI_CODING_AGENT_DIR="$PI_CONFIG_MOUNT/agent" \ --env NIX_REMOTE=daemon \ --env TMPDIR="$RUNTIME_TMPDIR" \ --env NPM_CONFIG_PREFIX="$RUNTIME_NPM_PREFIX" \ --env npm_config_prefix="$RUNTIME_NPM_PREFIX" \ --env NPM_CONFIG_CACHE="$RUNTIME_NPM_CACHE" \ --env npm_config_cache="$RUNTIME_NPM_CACHE" \ --env PATH="$RUNTIME_NPM_PREFIX/bin:/bin:/usr/bin" \ "''${mount_args[@]}" \ "''${env_args[@]}" \ "''${EXTRA_RUN_ARGS[@]}" \ "$IMAGE" \ sleep infinity >/dev/null } runtime_env_args=( --env "HOME=$RUNTIME_HOME" --env "PI_CODING_AGENT_DIR=$PI_CONFIG_MOUNT/agent" --env NIX_REMOTE=daemon --env "TMPDIR=$RUNTIME_TMPDIR" --env "NPM_CONFIG_PREFIX=$RUNTIME_NPM_PREFIX" --env "npm_config_prefix=$RUNTIME_NPM_PREFIX" --env "NPM_CONFIG_CACHE=$RUNTIME_NPM_CACHE" --env "npm_config_cache=$RUNTIME_NPM_CACHE" --env "PATH=$RUNTIME_NPM_PREFIX/bin:/bin:/usr/bin" ) recreate_container() { "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true ensure_image_available create_container } ensure_container_running() { if ! "$PODMAN" container exists "$CONTAINER_NAME"; then ensure_image_available create_container else schema_version="$("$PODMAN" inspect -f '{{ index .Config.Labels "io.m3ta.pi-wrapper-version" }}' "$CONTAINER_NAME" 2>/dev/null || true)" if [ "$schema_version" != "$WRAPPER_SCHEMA_VERSION" ]; then recreate_container fi fi running="$("$PODMAN" inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" if [ "$running" != "true" ]; then if [ "$AUTO_START" = "1" ]; then "$PODMAN" start "$CONTAINER_NAME" >/dev/null else err "Container '$CONTAINER_NAME' is not running and autoStart=false. Start it manually with: podman start $CONTAINER_NAME" fi fi } ensure_runtime_dirs() { if ! "$PODMAN" exec "''${runtime_env_args[@]}" --workdir /tmp "$CONTAINER_NAME" sh -lc 'mkdir -p "$NPM_CONFIG_PREFIX" "$NPM_CONFIG_CACHE" "$TMPDIR"'; then err "Failed to prepare writable npm runtime directories in container '$CONTAINER_NAME'." fi if ! "$PODMAN" exec "''${runtime_env_args[@]}" --workdir /tmp "$CONTAINER_NAME" sh -lc 'test -w "$NPM_CONFIG_PREFIX" && test -w "$NPM_CONFIG_CACHE" && test -w "$TMPDIR"'; then err "Container runtime dirs are not writable for npm. Remove stale container with: podman rm -f $CONTAINER_NAME" fi } ensure_container_running ensure_runtime_dirs if [ "$AUTO_NIX_DEVELOP" = "1" ] && [ -f "$CWD/flake.nix" ]; then exec "$PODMAN" exec "''${tty_args[@]}" "''${runtime_env_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" nix develop -c pi "$@" fi if "$PODMAN" exec "''${runtime_env_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" sh -lc 'command -v pi >/dev/null 2>&1'; then exec "$PODMAN" exec "''${tty_args[@]}" "''${runtime_env_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" pi "$@" fi err "Container '$CONTAINER_NAME' does not have 'pi' in PATH (image: $IMAGE). Use a Pi-ready image or run from a flake project with autoNixDevelop=true." ''; # Rendered agents (only computed when agentsInput is set) rendered = if cfg.agentsInput != null then (import ../../../../lib {inherit lib;}).agents.renderForPi { inherit pkgs; canonical = cfg.agentsInput.lib.loadAgents; modelOverrides = cfg.modelOverrides; primaryAgent = cfg.primaryAgent; } else null; # Dynamic home.file entries for agent .md files agentFiles = if cfg.agentsInput != null then let agentNames = builtins.attrNames cfg.agentsInput.lib.loadAgents; in builtins.listToAttrs ( map (name: { name = ".pi/agent/agents/${name}.md"; value = {text = builtins.readFile "${rendered}/agents/${name}.md";}; }) agentNames ) else {}; skillsSource = if cfg.agentsInput != null then cfg.agentsInput.lib.mkOpencodeSkills { inherit pkgs; customSkills = "${cfg.agentsInput}/skills"; } else null; in { assertions = [ { assertion = cfg.container.enable || hasPiPackage; message = "coding.agents.pi.enable requires pkgs.pi when container mode is disabled."; } ] ++ optional cfg.container.enable { assertion = cfg.container.projectRoots != []; message = "coding.agents.pi.container.projectRoots must contain at least one absolute path when container mode is enabled."; } ++ optional cfg.container.enable { assertion = all (path: hasPrefix "/" (toString path)) cfg.container.projectRoots; message = "coding.agents.pi.container.projectRoots entries must be absolute paths."; }; home.packages = (optional cfg.container.enable piWrapper) ++ (optional (!cfg.container.enable && hasPiPackage) pkgs.pi); home.file = mkMerge [ # ── MCP servers from programs.mcp → ~/.pi/agent/mcp.json ─────── (mkIf (cfg.mcpServers != {}) { ".pi/agent/mcp.json".text = builtins.toJSON {mcpServers = cfg.mcpServers;}; }) # ── ~/.pi/agent/settings.json ────────────────────────────────── { ".pi/agent/settings.json".text = builtins.toJSON piSettings; } # ── AGENTS.md — agent descriptions and specialist listing ────── (mkIf (cfg.agentsInput != null) { ".pi/agent/AGENTS.md".text = builtins.readFile "${rendered}/AGENTS.md"; }) # ── SYSTEM.md — primary agent's system prompt ────────────────── (mkIf (cfg.agentsInput != null) { ".pi/agent/SYSTEM.md".text = builtins.readFile "${rendered}/SYSTEM.md"; }) # ── Agents — pi-subagents .md files ──────────────────────────── agentFiles # ── Skills symlinked from AGENTS repo (non-container mode) ───── (mkIf (cfg.agentsInput != null && !cfg.container.enable) { ".pi/agent/skills".source = skillsSource; }) ]; home.activation.piMaterializeSkills = mkIf (cfg.container.enable && cfg.agentsInput != null) ( lib.hm.dag.entryAfter ["writeBoundary"] '' skillsSrc=${escapeShellArg "${skillsSource}"} skillsDst=${escapeShellArg "${config.home.homeDirectory}/.pi/agent/skills"} skillsParent="$(${pkgs.coreutils}/bin/dirname "$skillsDst")" skillsNew="$skillsParent/.skills-new-$$" skillsOld="$skillsParent/.skills-old-$$" ${pkgs.coreutils}/bin/mkdir -p "$skillsParent" ${pkgs.coreutils}/bin/rm -rf "$skillsNew" ${pkgs.coreutils}/bin/mkdir -p "$skillsNew" ${pkgs.coreutils}/bin/cp -aL "$skillsSrc"/. "$skillsNew"/ if [ -e "$skillsDst" ]; then ${pkgs.coreutils}/bin/mv "$skillsDst" "$skillsOld" fi ${pkgs.coreutils}/bin/mv "$skillsNew" "$skillsDst" if [ -e "$skillsOld" ]; then ${pkgs.coreutils}/bin/chmod -R u+w "$skillsOld" 2>/dev/null || true ${pkgs.coreutils}/bin/rm -rf "$skillsOld" 2>/dev/null || true fi '' ); }); }