~ruther/agent-environments

33260b4cb5856a62bf2442e8eac8521885c4c290 — František Boháček a month ago e66f59a
Add launchers.<cmd>.sharedDirs: share a state-dir directory between launchers

Launchers with different ids share nothing, which is right for logins but
not for Claude's projects (sessions and memory) or Codex's memories. A
shared dir is a managed symlink to the other launcher's copy, created on
demand so the link holds whichever launcher starts first, and removed with
the declaration while the data stays.
2 files changed, 29 insertions(+), 1 deletions(-)

M README.md
M modules/launcher.nix
M README.md => README.md +1 -0
@@ 74,6 74,7 @@ Runnable examples: [`examples/basic`](examples/basic) and
| `packages` | Packages put on every launcher's `PATH`, e.g. tools that hooks in `claude.settings` call. |
| `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. |
| `launchers.<cmd>.sharedDirs.<dir>` | Share one directory of the state dir with another launcher, e.g. `projects = "claude-work"` for sessions and memory across logins. A managed symlink to that launcher's `<stateDir>/<dir>`, created if missing. |
| `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 modules/launcher.nix => modules/launcher.nix +28 -1
@@ 41,6 41,11 @@ let
    let
      agent = build.agents.${launcher.agent};
      links = managedLinks agent;
      # Shared directories: relative path -> shell expression for the other
      # launcher's copy, which may live under $HOME.
      shared = lib.mapAttrs (
        dir: other: "${stateDirShell config.launchers.${other}.stateDir}/${lib.escapeShellArg dir}"
      ) launcher.sharedDirs;
      runtimePath = lib.makeBinPath (
        lib.optional (companionBin != null) companionBin ++ build.runtimeInputs
      );


@@ 65,6 70,7 @@ let
        ${
          lib.concatStrings (
            lib.mapAttrsToList (p: target: "  [${lib.escapeShellArg p}]=${lib.escapeShellArg target}\n") links
            ++ lib.mapAttrsToList (p: target: "  [${lib.escapeShellArg p}]=${target}\n") shared
          )
        })
        declare -A owned=()


@@ 92,6 98,10 @@ let
          fi
        done

        # A shared directory is created where it lives, so the link never dangles.
        ${lib.concatStrings (
          lib.mapAttrsToList (p: _: "${mkdir} -p \"\${wanted[${lib.escapeShellArg p}]}\"\n") shared
        )}
        for p in "''${!wanted[@]}"; do
          # No -f: a unique temp name that already exists is an error, not something to delete.
          tmp="$state/$p.agent-env-tmp.$$"


@@ 141,6 151,11 @@ in
              default = "~/.local/agent-environments/${config.id}";
              description = "State dir (CLAUDE_CONFIG_DIR / CODEX_HOME): an absolute path, or one starting with ~/ for $HOME. Taken literally.";
            };
            sharedDirs = mkOption {
              type = types.attrsOf types.str;
              default = { };
              description = "Directories of the state dir shared with another launcher: `<dir> = \"<launcher>\"` links <dir> to that launcher's <dir>, creating it if missing.";
            };
          };
        }
      )


@@ 184,7 199,19 @@ in
          assertion = config.build.agents ? ${l.agent};
          message = "launcher '${name}' uses ${l.agent}, but ${l.agent}.enable is false";
        }
      ]) config.launchers
      ]
      ++ lib.concatLists (
        lib.mapAttrsToList (dir: other: [
          {
            assertion = agentEnvLib.safeName dir;
            message = "launcher '${name}': shared dir '${dir}' is not a safe path component";
          }
          {
            assertion = other != name && config.launchers ? ${other};
            message = "launcher '${name}': shared dir '${dir}' names '${other}', which is not another launcher";
          }
        ]) l.sharedDirs
      )) config.launchers
    );
  };
}