Worktree Environment Initialization — Design Document¶
Status: Partially shipped — see notes below Iterations 1 & 2 shipped (v0.18.x). Iterations 3–5 remain backlog/deferred.
What Has Shipped¶
Iteration 1 — env gap fixed (v0.18.0): template.env is now applied at session creation.
Both the initial pane (via set -gx fish commands) and subsequent panes (via tmux set-environment) receive template env vars.
Iteration 2 — setup_commands (v0.18.x): setup_commands: list[str] on both
SessionTemplateConfig and TemplateMixinConfig. Commands run in the initial pane before the agent
launches. extends semantics: child replaces; mixins semantics: appended after base.
[template.git] identity block (v0.26.x): Per-session git identity via a dedicated
[template.git] section — user_name, user_email, commit_template, branch_prefix.
See Local Templates — Per-Session Git Identity.
Current State (The Bug)¶
SessionTemplateConfig.env (models/config.py:227) is fully parsed and merged but never
applied. Both lifecycle functions (create: lines 667-669, fork: lines 805-807) set only:
await tmux.async_set_environment(tmux_session, "SHOAL_SESSION_ID", session.id)
await tmux.async_set_environment(tmux_session, "SHOAL_SESSION_NAME", session_name)
# <- template_cfg.env never looped here
Why tmux set-environment alone is insufficient¶
set-environment affects only NEW panes (new-window/split-window), not the initial pane
created by new-session. The agent runs in that initial pane via send-keys
(lifecycle.py:298-299). Its shell won't see vars set after it started.
The fix requires two mechanisms:
- Initial pane: fish set -gx KEY VALUE via send-keys before agent command
- Subsequent panes: tmux set-environment (created after, so they inherit correctly)
Approach 1 — Fix the Existing Gap (Bug Fix)¶
After the SHOAL_SESSION_ID/NAME lines in both create and fork:
if template_cfg and template_cfg.env:
for key, value in template_cfg.env.items():
await tmux.async_set_environment(tmux_session, key, value)
initial_pane = f"{tmux_session}:0.0"
for key, value in template_cfg.env.items():
await tmux.async_send_keys(
initial_pane, f"set -gx {shlex.quote(key)} {shlex.quote(value)}"
)
Pros: ~10 lines, no schema changes, fixes declared-but-ignored contract immediately.
Cons: Fish-specific set -gx (low priority given fish-first scope).
Impact on backlog worker: Transparent (goes through create_session_lifecycle).
Recommendation: Ship as a bug fix. One iteration, no schema impact.
Approach 2 — Template setup_commands (HIGH VALUE)¶
New setup_commands: list[str] field on SessionTemplateConfig and TemplateMixinConfig.
Commands run via send-keys in the initial pane BEFORE the agent launches.
[template]
name = "python-dev"
setup_commands = [
"uv sync --quiet",
"source .venv/bin/activate.fish",
]
Variable interpolation: reuse existing context dict (work_dir, git_root, session_name,
branch_name, worktree, template_name).
Template inheritance:
- extends: child's setup_commands replaces parent's (same semantics as windows)
- mixins: appended after resolved base (additive, same as windows in mixins)
Implementation (in _run_template_startup_async, before pane commands):
if template.setup_commands:
initial_pane = f"{tmux_session}:0.0"
for cmd in template.setup_commands:
await tmux.async_send_keys(initial_pane, _format_value(cmd, context, "setup command"))
Failure handling: send-keys has no exit code capture — warn-and-continue default.
Add setup_strict = true flag later for hard failures.
Pros: Declarative, template-driven, inherits through extends/mixins, covers venv/sync.
Cons: Slow commands block session creation; fish-first only; exit code opacity.
Impact on backlog worker: Transparent.
Recommendation: HIGH VALUE — canonical venv activation answer.
Files: models/config.py, services/lifecycle.py, docs/LOCAL_TEMPLATES.md
Approach 3 — Project-Level .shoal.toml (MEDIUM)¶
.shoal.toml at project root (committed to git), loaded when work_dir is in that git tree.
default_tool = "omp"
default_template = "base-dev"
setup_commands = ["uv sync --quiet", "source .venv/bin/activate.fish"]
[env]
PYTHONDONTWRITEBYTECODE = "1"
Discovery: Path(git_root) / ".shoal.toml" — git_root already known at session creation.
Worktrees get it for free (file lives in the git tree).
Precedence: .shoal.toml env < template.env < CLI flags
Pros: Travels with repo, zero per-user setup, team-shareable.
Cons: Third config layer; new file format.
Recommendation: Medium priority — implement after setup_commands.
Approach 4 — Per-Project in ~/.config/shoal/config.toml (LOW)¶
[projects."/home/user/work/myproject"]
env = { PYTHONDONTWRITEBYTECODE = "1" }
setup_commands = ["uv sync"]
Pros: Single config file, user-private. Cons: Machine-specific paths, not portable, doesn't travel with repo. Recommendation: Low priority — Approach 3 is strictly better.
Approach 5 — direnv / mise Integration (DEFERRED, OPT-IN ONLY)¶
New env_manager = "mise" | "direnv" | "none" field on SessionTemplateConfig.
- mise: sends
mise trust --quiet && mise install --quietbefore agent - direnv: sends
direnv allow .— activates via fish hook thereafter
CRITICAL: Auto-detection of .envrc/mise.toml must NEVER happen without opt-in.
direnv allow . is a trust grant; mise install makes network calls.
Impact on backlog worker: Needs explicit invocation (non-interactive shell).
Recommendation: Explicit opt-in only. Implement after setup_commands is stable.
Interaction Matrix¶
| Approach | Template inheritance | Portability | backlog-run | Code delta | Schema delta |
|---|---|---|---|---|---|
| 1. Fix env gap | Works (resolved pre-lifecycle) | N/A | Transparent | ~10 lines | None |
| 2. setup_commands | extends=replace, mixins=append | Via template | Transparent | ~30 lines | +1 field |
| 3. .shoal.toml | Lifecycle layer, lower precedence | Travels with repo | Transparent | ~50 lines | New file format |
| 4. Per-project cfg | Lifecycle layer, lower precedence | User-local | Transparent | ~30 lines | +section |
| 5. direnv/mise | New env_manager field | Tool-dependent | Explicit invoke | ~60 lines | +1 field |
Recommended Sequencing¶
Iteration 1 (bug fix): Fix silent env drop in lifecycle.py create + fork.
Files: src/shoal/services/lifecycle.py
Iteration 2 (feature): Add setup_commands to models + lifecycle + docs.
Files: models/config.py, services/lifecycle.py, docs/LOCAL_TEMPLATES.md
Iteration 3 (feature, lower priority): .shoal.toml project-level config loading.
Files: core/config.py, services/lifecycle.py
Iteration 4 (deferred): env_manager opt-in for direnv/mise.
Open Questions¶
- Shell portability: Fish only for now (fish-first scope).
- Failure handling: Warn-and-continue default;
setup_strict = trueflag later. - direnv trust: NEVER automatic — require explicit
env_manager = "direnv". - Backlog worker venv (immediate): After Iteration 2, add
setup_commandsto the backlog template:["uv sync --quiet", "source .venv/bin/activate.fish"].