# Spawn tmux base-hoist — design
Date: 2026-07-21
Status: approved, ready for planning
Board: spawn-tmux project, card #2 "Hoist _launch_via_tmux to base membrane"
Goal
Make the internal tmux-session launch on swarph spawn provider-non-discriminatory: every
provider membrane gets a named tmux session by default (when a spawn name is given), instead of only
the two membranes that happen to override pre_launch.
Problem (current 0.34.0 behavior)
tmux-launch is opt-in per membrane, not in the base — so it discriminates by provider:
ProviderMembrane.pre_launch(base) →return None(no tmux).ClaudeMembrane.pre_launch→ overrides:if session_name and _launch_via_tmux(...): return 0,
GrokMembrane.pre_launch→ overrides: the same_launch_via_tmuxcall — a duplicate of Claude's
CodexMembrane,AntigravityMembrane→ nopre_launchoverride → inherit the base
return None → spawn bare (no tmux session, no send-keys supervision).
So swarph spawn runs without a session. In production these providers only
get tmux because they're launched via the provider-agnostic external claude-tmux@.service template,
which masks the gap — an operator spawning them interactively gets no session.
The OS-generalization already landed independently: the tmux call site has no win32 gate, and the
per-OS mechanics (os.execve on POSIX, blocking subprocess.run on Windows) live inside
_launch_via_tmux / launch(). This design changes only the provider dimension.
Design
A hoist plus a dedup — it generalizes the existing session-gated behavior, adds no new machinery.
1. Base ProviderMembrane.pre_launch — do the session-gated tmux launch
Replace the base's return None with the branch currently duplicated in Claude/Grok:
`python
def pre_launch(self, cell, binary, argv, *, no_banner, session_name=None):
# A named spawn runs the cell in a tmux session (durable + send-keys-supervisable).
# No-op when unnamed / already inside tmux / tmux absent (then fall through to None).
if session_name and _launch_via_tmux(binary, argv, cell.cwd, session_name):
return 0
return None
`
All four membranes now get tmux-when-named by default. The session_name gate is the interim
per-spawn opt-out (spawn unnamed → no session). A richer none/tmux/service onboarding tier is a
separate later card, out of scope here.
2. ClaudeMembrane.pre_launch — keep only the Claude-specific extra
Delegate the tmux part to the base, then add Claude's own fallback (the WT-relaunch + conhost warning exist only for Claude's Ink-TUI/PowerShell rendering bug):
`python
def pre_launch(self, cell, binary, argv, *, no_banner, session_name=None):
rc = super().pre_launch(cell, binary, argv, no_banner=no_banner, session_name=session_name)
if rc is not None:
return rc
# Claude-only: relaunch in Windows Terminal / warn on legacy conhost. (unchanged body)
...
`
3. GrokMembrane.pre_launch — delete
Once the base carries the tmux branch, Grok's override is byte-identical to the base → remove it; Grok inherits the base. Closes the previously-flagged Claude/Grok duplication.
4. CodexMembrane, AntigravityMembrane — unchanged
No override added; they inherit the new base tmux behavior automatically. Their launch() (execve)
is untouched. No provider-specific extra is needed — every provider's TUI renders correctly under a
plain tmux PTY (validated live: all mesh peers already run inside tmux).
Safety / non-regression
- No double-tmux via the external template: the
$TMUXloop-breaker inside_launch_via_tmux
claude-tmux@.service template (which wraps it in tmux, then runs swarph spawn inside) re-enters
with $TMUX set → in-place exec, no second session. Adding internal tmux to codex/antigravity does
not double-wrap the template path.
- Claude behavior unchanged: the WT-relaunch + conhost warning still fire in the same
None).
- Unnamed spawns unchanged:
session_nameunset → base returnsNone→ current bare behavior.
Testing
- Base
pre_launchcalls_launch_via_tmux(binary, argv, cwd, session_name)and returns0when
session_name is set and the launch succeeds; returns None when session_name is unset.
ClaudeMembrane.pre_launchreturns the base's0when the base took over (no WT-relaunch reached);
None, still reaches its WT-relaunch/conhost path.
GrokMembranehas no ownpre_launch(resolves toProviderMembrane.pre_launch).CodexMembrane/AntigravityMembraneresolvepre_launchto the base and take the tmux path when
$TMUXset →_launch_via_tmuxdoes the in-place exec branch (no second session created).- Mock
_launch_via_tmux(and the WT-relaunch helper) — assert call/return, not real process launch.
Scope / constraints
- Single repo (
swarph-cli), single file (src/swarph_cli/commands/spawn.py) + its tests. - Stdlib only (no new deps).
- Changes live spawn behavior → ships as a reviewed PR; merge is commander-gated (no auto-merge).
- Onboarding-tier selector (none / tmux / tmux+service) is a separate later card, not this one.