From 6ae0ea389dadd61712693f2090dba54608d54e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Boh=C3=A1=C4=8Dek?= Date: Sat, 26 Sep 2026 21:53:01 +0200 Subject: [PATCH] Add companions: every launcher of an environment callable from its sessions --- README.md | 1 + checks/default.nix | 40 ++++++++++++++++++ lib/default.nix | 3 +- modules/companions.nix | 94 ++++++++++++++++++++++++++++++++++++++++++ modules/launcher.nix | 18 ++++++-- 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 modules/companions.nix diff --git a/README.md b/README.md index 85d6f4c3ec09be7e1c3aa5d806eee151f317d98b..b21cd501eff05f6abc997c333214577defbb3c05 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Runnable examples: [`examples/basic`](examples/basic) and | `skills.packages` | `mkSkill` packages to enable. Their skill dependencies are added automatically. | | `modules.skills..enable` | Declared by `mkSkillModule`; adds that skill. | | `launchers..{agent,id,stateDir}` | A command. `id` defaults to the command name; `stateDir` defaults to `~/.local/agent-environments/`; it must be absolute or start with `~/`, and is taken literally. | +| `companions.enable` | Put every launcher of the environment on the agent's `PATH` (as companions, which cannot start companions themselves) and add a generated `companions` skill listing each one with its non-interactive call. Launchers export `AGENT_ENV_LAUNCHER` (their command name). | ## What a launcher does diff --git a/checks/default.nix b/checks/default.nix index b11748e2588140964d46176072d26a9c07158a93..4580eed19007a05d059d36301515c9da5cc99fe3 100644 --- a/checks/default.nix +++ b/checks/default.nix @@ -15,6 +15,7 @@ let echo "CLAUDE_CONFIG_DIR=''${CLAUDE_CONFIG_DIR:-}" echo "CODEX_HOME=''${CODEX_HOME:-}" echo "AGENT_ENV_LAUNCHER=''${AGENT_ENV_LAUNCHER:-}" + echo "PATH=$PATH" if command -v hello >/dev/null; then echo "HELLO=yes"; else echo "HELLO=no"; fi } >"$log.env" ''; @@ -245,6 +246,45 @@ in grep -qxF "CLAUDE_CONFIG_DIR=$HOME/odd \"dir\"/\$(touch injected)" $STUB_LOG.env || fail "$(cat $STUB_LOG.env)" ''; + # With companions, a session can start every profile of its environment, but a + # companion session cannot start further companions. + companions = + let + env = mkEnvironment { + inherit pkgs; + modules = [ + stubs + { + claude.enable = true; + codex.enable = true; + companions.enable = true; + launchers = { + claude-work.agent = "claude"; + codex-pers.agent = "codex"; + }; + } + ]; + }; + in + runTest "companions" '' + ${env.launchers.claude-work}/bin/claude-work + grep -qxF "AGENT_ENV_LAUNCHER=claude-work" $STUB_LOG.env || fail "AGENT_ENV_LAUNCHER not exported" + session_path=$(sed -n 's/^PATH=//p' $STUB_LOG.env) + companion=$(PATH=$session_path command -v codex-pers) || fail "codex-pers not on the session PATH" + PATH=$session_path command -v claude-work >/dev/null || fail "own profile not available as companion" + + skill=$HOME/.local/agent-environments/claude-work/skills/companions/SKILL.md + grep -qF '`codex-pers exec --sandbox read-only' $skill || fail "codex call missing from skill" + grep -qF '`claude-work -p ""' $skill || fail "claude call missing from skill" + + # The companion runs with its own login and without companions of its own. + $companion exec x + grep -qxF "CODEX_HOME=$HOME/.local/agent-environments/codex-pers" $STUB_LOG.env || fail "companion login" + grep -qxF "AGENT_ENV_LAUNCHER=codex-pers" $STUB_LOG.env || fail "companion name" + nested_path=$(sed -n 's/^PATH=//p' $STUB_LOG.env) + if PATH=$nested_path command -v claude-work >/dev/null; then fail "companion can start companions"; fi + ''; + # The stubs only prove what the launcher hands over; this proves the real Codex # reads it: AGENTS.md, linked skills and the settings profile all reach the prompt. codex-integration = diff --git a/lib/default.nix b/lib/default.nix index 787c44ad30f2e497f2ed17a49f3b3683a493ff68..21d875b5fcc253293acbc6d1699907ddec6fc28e 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -47,10 +47,11 @@ in ../modules/claude.nix ../modules/codex.nix ../modules/launcher.nix + ../modules/companions.nix { _module.args = { inherit pkgs; - agentEnvLib = { inherit safeName; }; + agentEnvLib = { inherit safeName mkSkill; }; }; claude.package = lib.mkDefault (defaultPackages pkgs.stdenv.hostPlatform.system).claude; codex.package = lib.mkDefault (defaultPackages pkgs.stdenv.hostPlatform.system).codex; diff --git a/modules/companions.nix b/modules/companions.nix new file mode 100644 index 0000000000000000000000000000000000000000..4f0f0ed8ac7ce4c5cee87e204d690d1eb877cca8 --- /dev/null +++ b/modules/companions.nix @@ -0,0 +1,94 @@ +{ + config, + lib, + pkgs, + agentEnvLib, + ... +}: +let + agentNames = { + claude = "Claude Code"; + codex = "Codex"; + }; + call = { + claude = name: ''`${name} -p "" < /dev/null`''; + codex = + name: + ''`${name} exec --sandbox read-only --skip-git-repo-check -o "" < /dev/null`''; + }; + rows = lib.concatStrings ( + lib.mapAttrsToList ( + name: l: "| `${name}` | ${agentNames.${l.agent}} | `${l.id}` | ${call.${l.agent} name} |\n" + ) config.launchers + ); + + skill = agentEnvLib.mkSkill { + inherit pkgs; + name = "companions"; + src = pkgs.writeTextDir "SKILL.md" '' + --- + name: companions + description: Use when you need a companion agent - a second opinion, a reviewer, a delegated task - when the user names one or a fallback order ("use claude work as your companion, fall back to codex work"), or when a companion run hits a usage limit. + --- + + # Companion agents + + Each command below starts another agent under its own login (profile) and + prints its answer. `$AGENT_ENV_LAUNCHER` names the profile you are running + as. Run companions unsandboxed: they need their own state dir and the network. + + | Command | Agent | Profile | Non-interactive call | + |---|---|---|---| + ${rows} + ## Choosing a companion + + 1. The user named companions or an order ("claude work, then codex work"): + use exactly that, in that order. + 2. Otherwise: the other agent family than yours. Among those, prefer the one + on the same account as yours (compare the command names); else the first + listed. + + ## Calling + + - **Claude Code** (`-p`): prints the final answer on stdout. Tools that need + approval are refused in this mode, so it is effectively read-only. Allow + specific commands with `--allowedTools 'Bash(git diff:*)'` when needed. + - **Codex** (`exec`): the final answer goes to `-o `, the + transcript to stderr. Keep `< /dev/null`: with stdin open it waits forever + ("Reading additional input from stdin..."). + - Long runs: start them in the background, redirect stderr to a log, and + read the answer when the command exits. + + ## Usage limits and fallback + + A companion is limited when it exits non-zero and its output talks about a + usage or rate limit ("usage limit", "limit reached", "hit your limit"). + Then: + + 1. Record which companion was limited, and the reset time if it says one. + 2. Send the same prompt to the next companion in the order; if there is no + order, use the next choice from "Choosing a companion". + 3. Do not retry a limited companion in the same task unless its reset time + has passed. + 4. When every companion is limited, say so and follow the calling skill's + rule for an unreachable companion. + + Always report which companion actually answered. + + ## Nesting + + A companion session does not have these commands on its `PATH`. If + `command -v ` finds nothing, you are running as a companion + yourself: do not start further companions. + ''; + }; +in +{ + options.companions.enable = lib.mkEnableOption '' + companions: put every launcher of this environment on the agent's PATH (without + companions of their own) and add a generated `companions` skill describing them''; + + config = lib.mkIf config.companions.enable { + skills.packages = [ skill ]; + }; +} diff --git a/modules/launcher.nix b/modules/launcher.nix index d362be4338bd4fab13fa3a46409c85cc4c988e7a..77029ad960a5960f472e7da736c70b7feb55603c 100644 --- a/modules/launcher.nix +++ b/modules/launcher.nix @@ -37,11 +37,13 @@ let lib.escapeShellArg dir; mkLauncher = - name: launcher: + companionBin: name: launcher: let agent = build.agents.${launcher.agent}; links = managedLinks agent; - runtimePath = lib.makeBinPath build.skillRuntimeInputs; + runtimePath = lib.makeBinPath ( + lib.optional (companionBin != null) companionBin ++ build.skillRuntimeInputs + ); in pkgs.writeShellApplication { inherit name; @@ -147,7 +149,17 @@ in config = { build = { - launchers = lib.mapAttrs mkLauncher config.launchers; + # Companions on PATH are launchers without companions of their own: a store + # path cannot contain itself, and companions should not start companions. + launchers = lib.mapAttrs (mkLauncher ( + if config.companions.enable then + pkgs.symlinkJoin { + name = "agent-companions"; + paths = lib.attrValues (lib.mapAttrs (mkLauncher null) config.launchers); + } + else + null + )) config.launchers; package = pkgs.symlinkJoin { name = "agent-environment"; paths = lib.attrValues config.build.launchers;