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
);
};
}