M README.md => README.md +1 -0
@@ 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.<name>.enable` | Declared by `mkSkillModule`; adds that skill. |
| `launchers.<cmd>.{agent,id,stateDir}` | A command. `id` defaults to the command name; `stateDir` defaults to `~/.local/agent-environments/<id>`; 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
M checks/default.nix => checks/default.nix +40 -0
@@ 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 "<prompt>"' $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 =
M lib/default.nix => lib/default.nix +2 -1
@@ 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;
A modules/companions.nix => modules/companions.nix +94 -0
@@ 0,0 1,94 @@
+{
+ config,
+ lib,
+ pkgs,
+ agentEnvLib,
+ ...
+}:
+let
+ agentNames = {
+ claude = "Claude Code";
+ codex = "Codex";
+ };
+ call = {
+ claude = name: ''`${name} -p "<prompt>" < /dev/null`'';
+ codex =
+ name:
+ ''`${name} exec --sandbox read-only --skip-git-repo-check -o <answer-file> "<prompt>" < /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 <answer-file>`, 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 <command>` 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 ];
+ };
+}
M modules/launcher.nix => modules/launcher.nix +15 -3
@@ 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;