Repository navigation
Fix #6447: Claude Code 2.1.183 agent-team teammates open split panes again - #6499
Conversation
Regression coverage for #6447. Claude Code 2.1.183 launches agent-team teammates with `respawn-pane -k -- "cd <dir> && env … <claude> …"`. cmux forwards that command to the surface as the pane's process command, which on macOS Ghostty execs via `exec -l <command>` — that only works for a single executable, so a `cd …&&… claude` shell expression makes Ghostty try to exec the `cd` builtin as a binary, the pane dies immediately, and the teammate never gets a visible pane. This test asserts respawn-pane forwards a login-shell-invoked command while keeping tmux_start_command raw. It fails until the fix lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code 2.1.183 changed how agent-team teammates open panes: instead of
`split-window` + `send-keys` (which typed the command into a live shell), it now
creates a placeholder pane (`split-window -- cat`) and runs the real teammate
command via `respawn-pane -k -- "cd <dir> && env … <claude> …"`.
cmux forwarded that command to the surface as the pane's process command. On
macOS, Ghostty execs the surface command via `exec -l <command>`
(ghostty/src/termio/Exec.zig), which only works for a single executable. The
teammate command is a shell expression, so `exec -l cd <dir> && …` tried to exec
the `cd` builtin as a binary, failed, and the pane exited before Claude ever
started — so teammates silently fell back to in-process with no visible pane.
Claude Code 2.1.181 was unaffected because it typed the command into a running
shell.
respawn-pane now rewrites shell-expression commands to run through
`/bin/zsh -lc '<command>'` (matching real tmux's `$SHELL -c` semantics) so
Ghostty execs the shell. Commands that are already a single executable —
including the `/bin/sh -c "…"` form OMO uses — are left unchanged so they are not
double-wrapped. `tmux_start_command` stays the raw command so
`#{pane_start_command}` / OMX-HUD detection are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughUpdates ChangesTmux login-shell command rewriting
Sequence Diagram(s)sequenceDiagram
participant CLI as respawn-pane CLI
participant Wrapper as tmuxShellInvokedStartCommand
participant Server as surface.respawn (Ghostty)
CLI->>Wrapper: commandText
Wrapper->>Wrapper: trim and check if empty
alt empty after trim
Wrapper-->>CLI: return original
else non-empty
Wrapper->>Wrapper: single-quote via tmuxShellQuote
Wrapper-->>CLI: ${SHELL:-/bin/zsh} -lc 'quoted'
end
CLI->>Server: command = wrapped, tmux_start_command = raw commandText
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 22 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (22 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryFixes a clean regression introduced in Claude Code 2.1.183, where agent-team teammates silently fell back to in-process execution instead of opening split panes. The root cause was that the new
Confidence Score: 5/5Safe to merge — the shell-wrapping fix is unconditional and correct for all respawn-pane callers, and the trust-gate bypass is properly gated behind an explicit opt-in with ambient-marker cleanup preventing cross-session leakage. Both changed code paths (the /bin/sh -c wrapper and the CLAUDE_CODE_SANDBOXED propagation) are narrow, well-tested, and backed by empirical root-cause analysis. The option-boundary-aware scanner correctly distinguishes flag-shaped prompt tokens from genuine options, the tmux_start_command field is kept raw so OMX-HUD detection is unaffected, and the cross-session marker cleanup is verified by a dedicated regression test. No files require special attention. All changed production sources have corresponding test coverage, and the budget file is updated to match the new line counts. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant cmux as cmux claude-teams
participant TmuxCompat as __tmux-compat
participant Surface as Surface / Ghostty
User->>cmux: cmux claude-teams [--dangerously-skip-permissions] prompt
cmux->>cmux: claudeTeamsExtraEnvVars() sets CLAUDE_CODE_SANDBOXED if opted-in
cmux->>cmux: clear ambient markers if NOT opted-in
cmux->>Surface: execv claude with nudge system-prompt appended
Note over Surface: Claude Code 2.1.183 teammate launch
Surface->>Surface: split-window placeholder pane
Surface->>TmuxCompat: __tmux-compat respawn-pane -k -- cd dir and env claude
TmuxCompat->>TmuxCompat: tmuxClaudeTeamsRespawnEnvironment() reads CMUX_CLAUDE_TEAMS_SANDBOXED
TmuxCompat->>TmuxCompat: tmuxRespawnStartCommand() wraps as /bin/sh -c with optional env exports
TmuxCompat->>Surface: "surface.respawn command=/bin/sh -c wrapped tmux_start_command=raw"
Surface->>Surface: exec -l /bin/sh succeeds - teammate pane runs correctly
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant cmux as cmux claude-teams
participant TmuxCompat as __tmux-compat
participant Surface as Surface / Ghostty
User->>cmux: cmux claude-teams [--dangerously-skip-permissions] prompt
cmux->>cmux: claudeTeamsExtraEnvVars() sets CLAUDE_CODE_SANDBOXED if opted-in
cmux->>cmux: clear ambient markers if NOT opted-in
cmux->>Surface: execv claude with nudge system-prompt appended
Note over Surface: Claude Code 2.1.183 teammate launch
Surface->>Surface: split-window placeholder pane
Surface->>TmuxCompat: __tmux-compat respawn-pane -k -- cd dir and env claude
TmuxCompat->>TmuxCompat: tmuxClaudeTeamsRespawnEnvironment() reads CMUX_CLAUDE_TEAMS_SANDBOXED
TmuxCompat->>TmuxCompat: tmuxRespawnStartCommand() wraps as /bin/sh -c with optional env exports
TmuxCompat->>Surface: "surface.respawn command=/bin/sh -c wrapped tmux_start_command=raw"
Surface->>Surface: exec -l /bin/sh succeeds - teammate pane runs correctly
Reviews (11): Last reviewed commit: "Document that the claude-teams trust byp..." | Re-trigger Greptile |
| func tmuxShellInvokedStartCommand(_ command: String) -> String { | ||
| let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmed.isEmpty, tmuxCommandRequiresLoginShell(trimmed) else { return command } | ||
| return "/bin/zsh -lc \(tmuxShellQuote(trimmed))" | ||
| } |
There was a problem hiding this comment.
Hardcoded
/bin/zsh diverges from tmuxStartupScript's ${SHELL:-/bin/sh} and from the doc comment's own claim of "matching real tmux's $SHELL -c semantics." tmuxStartupScript (line 256 in this same file) writes exec "${SHELL:-/bin/sh}" -lc … so the two code paths pick different shells for the same purpose. The fallback commandText of "exec ${SHELL:-/bin/sh} -l" also starts with exec — a member of tmuxShellExecBuiltins — so it gets re-wrapped as /bin/zsh -lc 'exec ${SHELL:-/bin/sh} -l', meaning every pane that respawns without an explicit command goes through an extra zsh process before exec-ing the user's configured shell.
| func tmuxCommandRequiresLoginShell(_ command: String) -> Bool { | ||
| let words = tmuxShellWords(command) | ||
| guard let first = words.first else { return false } | ||
| if Self.tmuxShellExecBuiltins.contains(first) { return true } | ||
| return words.contains { Self.tmuxShellControlOperators.contains($0) } | ||
| } |
There was a problem hiding this comment.
Quoted single-character operators produce false-positive wrapping.
tmuxShellWords strips the surrounding quotes but keeps the content as a token, so a command like prog --delim '|' yields the token |, which matches tmuxShellControlOperators and causes the whole command to be needlessly wrapped in /bin/zsh -lc. This is a false positive (the wrapping is functionally safe but applied when it shouldn't be). Checking whether the word came from a quoted span before testing it against the operator set would prevent this.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Two follow-ups from review of the #6447 fix: - Detect shell operators at the character level (respecting quotes) instead of only matching whitespace-separated `tmuxShellWords` tokens, so commands with operators that lack surrounding spaces (`echo a;echo b`, `a&&b`) are still recognized as shell expressions and wrapped. - Run the wrapped command through `${SHELL:-/bin/zsh}` (resolved by Ghostty's shell wrapper at exec time) instead of hardcoding zsh, matching tmux's configured-shell semantics. Single-executable commands (including OMO's `/bin/sh -c "…"`) still match no builtin/operator and are forwarded unchanged. Expanded the regression test to cover the spaced operator, non-spaced operator, plain-executable, and already-shell-invoked cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
CLI/CMUXCLI+TmuxCompatSupport.swift (1)
70-74:⚠️ Potential issue | 🟡 MinorAdd missing shell builtins to prevent command exec failures.
The
tmuxShellExecBuiltinsset is missing common shell builtins:exit,return,read,break,continue, andshift. When these appear as the first token in arespawn-panecommand, they are not detected as requiring a login shell, causing the tmux compatibility layer to attempt a direct exec that fails (these builtins cannot be executed as standalone binaries). Add these 6 missing builtins to the set.The test
respawnPaneRunsShellExpressionsThroughLoginShell()does not cover these cases and should be expanded to catch this regression.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLI/CMUXCLI`+TmuxCompatSupport.swift around lines 70 - 74, Add the six missing shell builtins (exit, return, read, break, continue, and shift) to the tmuxShellExecBuiltins set to prevent command exec failures when these builtins appear as the first token in a respawn-pane command. Additionally, expand the respawnPaneRunsShellExpressionsThroughLoginShell() test to include test cases for each of these newly added builtins to ensure they are properly detected as requiring a login shell and prevent regression.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@CLI/CMUXCLI`+TmuxCompatSupport.swift:
- Around line 70-74: Add the six missing shell builtins (exit, return, read,
break, continue, and shift) to the tmuxShellExecBuiltins set to prevent command
exec failures when these builtins appear as the first token in a respawn-pane
command. Additionally, expand the
respawnPaneRunsShellExpressionsThroughLoginShell() test to include test cases
for each of these newly added builtins to ensure they are properly detected as
requiring a login shell and prevent regression.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: c982e2dc-1389-459f-b1ff-191b62592dd7
📒 Files selected for processing (2)
CLI/CMUXCLI+TmuxCompatSupport.swiftcmuxTests/CLITmuxCompatRemoteSplitTests.swift
…ll invocations
The "detect what needs a shell" classifier was whack-a-mole — review surfaced
non-spaced operators, then assignment-prefix commands (`FOO=bar claude`), and
more forms (tilde, globs) would follow. Invert it: run every tmux respawn
shell-command through `${SHELL:-/bin/zsh} -lc` (matching tmux's `$SHELL -c`
semantics), and skip wrapping only for a command that is already a clean shell
invocation — a known shell (`sh`/`bash`/`zsh`/…) with a `-c`-style flag, e.g.
OMO's `/bin/sh -c "…"` — to avoid redundant double-wrapping. The skip is
conservative: an unrecognized form is harmlessly wrapped, never wrongly left
unwrapped. Updated the regression test to cover the assignment-prefix case and
the already-shell-invoked passthrough.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he last token A shell invocation with a trailing operator (`/bin/sh -c "setup" && claude`) is still a shell expression — the `&& claude` runs after the inner shell — so it cannot be forwarded raw onto Ghostty's `exec -l` path. Tighten the skip: only a genuine complete `<shell> … -c <arg>` where the argument is the final token qualifies; anything with tokens after the `-c` argument is wrapped like every other shell expression. Added a regression case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… skip)
`tmuxShellWords` is a whitespace tokenizer, not a shell parser, so any "is this
already a clean shell invocation" check is fooled by operators with no
surrounding whitespace (`/bin/sh -c "x";y` tokenizes as three words). Rather than
keep refining a classifier that cannot be made reliable, run every tmux respawn
shell-command through `${SHELL:-/bin/zsh} -lc '<command>'`. Single-quoting the
whole original makes it round-trip verbatim, so it runs correctly regardless of
operators or quoting, and there is no longer any classification to get wrong.
Commands that are themselves a shell invocation (e.g. OMO's `/bin/sh -c "…"`) are
run through one more shell that execs straight into them — harmless and matching
tmux's `$SHELL -c` semantics. Updated the OMO respawn test accordingly;
`tmux_start_command` still carries the raw command.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public `cmux respawn-pane --command …` CLI is a separate handler from `__tmux-compat respawn-pane` and is not part of the #6447 shell-wrapping change, so its forwarded command stays raw. Only the `__tmux-compat respawn-pane` assertions expect the login-shell wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`/bin/csh` and `/bin/tcsh` are valid macOS login shells that reject `-l`, so a
combined `-lc` would make respawn fail outright for those users. Every shell
accepts `-c`, and on macOS Ghostty already execs the wrapper with a login-style
argv0 (`exec -l`), so the inner shell is still a login shell. Switch the wrapper
to `${SHELL:-/bin/zsh} -c`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public `cmux respawn-pane --command …` handler reaches the same surface.respawn / Ghostty `exec -l <command>` path as `__tmux-compat respawn-pane`, so a shell-expression command (`cd /tmp && env FOO=bar tool`) hit the same failure. Wrap its command through `tmuxShellInvokedStartCommand` too, keeping tmux_start_command raw. Updated the public-respawn OMO assertion to expect the wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shell-wrap call sites are self-documenting via tmuxShellInvokedStartCommand and its doc comment, so drop the redundant inline comments to keep cmux.swift at net-zero growth (workflow-guard-tests swift-file-length-budget). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wrapped commands are POSIX `sh` syntax — Claude Code's `cd … && env …` and
the no-command fallback `exec ${SHELL:-/bin/sh} -l` — so they must run under a
POSIX shell. `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter
expansion or `NAME=value` command prefixes, so routing these through the user's
`$SHELL` would still exit immediately for those users. Use `/bin/sh -c` (always
present, POSIX, and the body interpreter every command was written for); Ghostty
still supplies a login-style argv0 on macOS. Updated tests to expect the /bin/sh
prefix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…panes (#6447) Regression coverage beyond the respawn shell-wrap, for the two startup gaps that still left `cmux claude-teams` non-functional: - The lead and every respawned teammate must start with CLAUDE_CODE_SANDBOXED so Claude Code's interactive "Do you trust this folder?" gate (which --dangerously-skip-permissions does not cover) cannot deadlock the unattended panes. cmuxTests asserts the teammate respawn command exports it; the env test asserts the lead receives it. - `cmux claude-teams` must append a system-prompt nudge so a plain "make a demo team with 5 subagents" spawns NAMED split-pane teammates instead of nameless in-process subagents (or a "demo what?" question). These fail without the follow-up fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anes Two startup gaps remained after the respawn shell-wrap, both of which made the agent-team teammates look broken: 1. Trust gate deadlock. The lead and every teammate `claude` blocked forever on Claude Code's interactive "Do you trust this folder?" dialog (which `--dangerously-skip-permissions` does NOT cover), so teammate panes opened but never checked in. Set CLAUDE_CODE_SANDBOXED=1 — the first short-circuit in Claude Code's trust check — for the lead (extraEnvVars) and inject it into each teammate respawn command via tmuxRespawnStartCommand / tmuxClaudeTeamsRespawnEnvironment (teammates are respawned by cmux, so they do not inherit the launcher env). Scoped to claude-teams; OMO and the public respawn-pane are unchanged. 2. Hard-to-start teams. A plain `cmux claude-teams "make a demo team with 5 subagents"` tended to run nameless in-process subagents (no panes) or stop to ask "demo what?". Append a small system-prompt nudge (claudeTeamsExec Arguments / claudeTeamsTeamSpawnGuidance) steering the lead to named, split-pane teammates for team/parallel requests, so no elaborate prompt is needed. Kept out of the exported restore command (which re-invokes claude-teams and re-applies the nudge); skipped if the user passes their own system prompt. Verified on a dev build: `cmux claude-teams --dangerously-skip-permissions "make a demo of this feature with 5 subagents"` opens 5 named teammate panes (@Researcher/@Architect/@Builder/@Tester/@scribe) that check in and complete, with no trust prompts and no manual steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-skip-permissions Autoreview (P1) correctly flagged that unconditionally exporting CLAUDE_CODE_SANDBOXED removed Claude Code's "Do you trust this folder?" safety check for every `cmux claude-teams` launch, including untrusted/cloned checkouts. Only waive the trust gate when the user has already opted into skipping safety prompts with --dangerously-skip-permissions: - lead: claudeTeamsExtraEnvVars adds CLAUDE_CODE_SANDBOXED only when the claude-teams args carry the flag; - teammates: tmuxClaudeTeamsRespawnEnvironment(forCommand:) injects it only when the respawn command carries the flag (Claude Code adds --dangerously-skip-permissions to the teammate command only in bypass mode). Without the flag the trust prompt is left in place and the user vets the directory normally. Tests cover both the bypass and gated-off paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…not command text Autoreview (P1) flagged that inferring the --dangerously-skip-permissions opt-in from the teammate respawn command text is unsafe: that substring can appear in a cwd, quoted value, or other non-flag position, granting the trust bypass even when the real Claude argv is not in dangerous-skip mode. Decide once, from the launcher's own argv, and propagate explicitly: - the lead records the opt-in in CMUX_CLAUDE_TEAMS_SANDBOXED (claudeTeamsExtraEnvVars), which the tmux shim already propagates to the __tmux-compat process; - tmuxClaudeTeamsRespawnEnvironment() injects CLAUDE_CODE_SANDBOXED only when that marker is set, with no command-text matching. Tests: the teammate inject case is driven purely by the marker, and a new case proves a bare --dangerously-skip-permissions substring in the command does NOT grant the bypass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on parsing, not a raw arg scan Autoreview (P1) flagged that scanning every forwarded Claude argument for --dangerously-skip-permissions can promote prompt text into a safety opt-in: in claude-teams, tokens after a prompt-boundary option (--tmux), after --, or consumed as another option's value are prompt/value text, not options. Add AgentLaunchSanitizer.claudeTeamsLaunchHasOption, which walks the args with the Teams policy's option/value/prompt-boundary rules and matches how Claude itself treats positions — crucially it honors an option that follows the positional prompt (verified on a dev build: `claude "do x" --dangerously-skip-permissions` enables bypass mode), while ignoring tokens after --tmux/-- or in a value slot. claudeTeamsHasDangerousSkipPermissions now defers to it. Adds CMUXAgentLaunch unit coverage for leading/after-prompt/=value detection and the --tmux / -- / value-slot negatives. Budget bumped for the new helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pt boundary Autoreview (P1) noted that claudeTeamsLaunchHasOption stopped at any `--tmux` token, so `cmux claude-teams --tmux classic --dangerously-skip-permissions ...` lost the trust-gate opt-in and its teammates could still hang on the trust prompt. The existing claude-teams sanitizer treats `--tmux classic` / `--tmux=classic` as a launch mode and keeps scanning later flags. Reuse that exact handling: claudeTeamsLaunchHasOption now defers to consumePromptBoundaryOption, so a `--tmux` launch mode is skipped and scanning continues, while only a real `--tmux <prompt>` payload (or `--`, or a value slot) ends the options. Unit tests cover `--tmux classic` / `--tmux=classic` detection plus the real-payload negative. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… values as values Two trust-boundary fixes flagged by autoreview: 1. Ambient marker leak. The launcher only added CMUX_CLAUDE_TEAMS_SANDBOXED / CLAUDE_CODE_SANDBOXED when --dangerously-skip-permissions was present, never clearing one inherited from a parent opted-in session — so a nested, non-opted `cmux claude-teams` could bypass the trust gate. configureClaudeTeamsEnvironment now unsets both markers whenever this invocation did not opt in, so the bypass reflects only the current command. 2. File-option values misread as flags. claudeTeamsLaunchHasOption relies on the policy's option widths to skip value slots, but --append-system-prompt-file / --system-prompt-file were not in the claude policy's valueOptions, so `--append-system-prompt-file --dangerously-skip-permissions` treated the path value as a real opt-in. Add both to valueOptions. Tests: new CLI test asserts the bypass markers are set only on opt-in and cleared when an ambient marker is inherited without opt-in; unit tests add the file-option value-slot negatives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ms-panes # Conflicts: # .github/swift-file-length-budget.tsv
…ch (not restored) Autoreview noted a restored teammate pane relaunches from the raw tmux_start_command without CLAUDE_CODE_SANDBOXED and re-shows the trust prompt. That is intentional, not a regression: persisting the bypass into restore would re-introduce the trust-boundary leak the earlier fixes closed (a restored pane is not a fresh --dangerously-skip-permissions opt-in, and after an app restart the agent team/parent session is gone, so the pane is an orphan). Falling back to the trust prompt is the safe behavior; record the invariant where the env is supplied. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the respawn-pane/respawnp case to the Go remote daemon's tmux-compat dispatcher, mirroring the local Swift CLI (CLI/cmux.swift, added in #5465, refined in #6499): require -k, resolve the -t target to its surface, and forward to the existing surface.respawn RPC. The command after -- is shell-invoked via /bin/sh -c for the pane process (#6447) and kept raw in tmux_start_command; without a command, the surface's stored start command and then a login shell are used; -c becomes working_directory; the claude-teams sandbox opt-in recorded in CMUX_CLAUDE_TEAMS_SANDBOXED is re-exported inside the wrapping shell. Claude Code >= 2.1.183 launches agent-team teammate panes with `split-window ... cat` followed by `respawn-pane -k ...`, so over `cmux ssh` the shimmed tmux previously failed with "unsupported tmux command: respawn-pane" and teammate panes never started. Forwarding-only: no new daemon backend, and independent of the Swift-side surface.respawn env-propagation rework in #6309. Fixes #7014 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Fixes #6447. Starting a Claude Code agent team in cmux stopped opening a visible split pane per teammate — teammates silently fell back to in-process with no pane. Clean regression: Claude Code 2.1.181 works, 2.1.183 is broken.
Root cause (pinned empirically)
I diffed the two Bun-compiled
claudebinaries (~/.local/share/claude/versions/2.1.18{1,3}) and drove a realclaude --teammate-mode autoagainst a loggingtmuxshim in cmux's exact env. Claude Code changed how it launches a teammate pane while running inside tmux:split-window … -P -F '#{pane_id}'(default-shell pane) →send-keys -t <pane> -l -- <cmd>+send-keys Enter— i.e. it typed the command into a live shell.split-window -d … -P -F '#{pane_id}' -- cat(placeholdercatpane) →set-option remain-on-exit failed→respawn-pane -k -t <pane> -- "cd <dir> && env … <claude> …"(respawn-pane/remain-on-exitappear 0× in 2.1.181, 2× in 2.1.183).cmux forwards the
respawn-panecommand to the surface as the pane's process command. On macOS, Ghostty execs the surface command viaexec -l <command>(ghostty/src/termio/Exec.zig), which only works for a single executable. The teammate command is a shell expression, so it becameexec -l cd <dir> && env … claude …— bash execs thecdbuiltin as a binary, fails, and the pane exits before Claude ever starts. 2.1.181 was unaffected because it typed the command into a running shell.Confirmed directly (no build needed):
Fix
Both respawn-pane CLI paths (
__tmux-compat respawn-paneand the publicrespawn-pane) now run their command through/bin/sh -c '<command>'via a shared helper, so Ghostty execs a shell rather than a bare builtin/expression. The whole command is single-quoted, so it round-trips verbatim regardless of operators/quoting — there is no fragile attempt to classify which commands "need" a shell.tmux_start_commandstays the raw command so#{pane_start_command}/ OMX-HUD detection are unaffected.A POSIX shell (
/bin/sh) is used deliberately rather than the user's$SHELL: the wrapped bodies are POSIXshsyntax (Claude Code'scd … && env …, and the no-command fallbackexec ${SHELL:-/bin/sh} -l), andcsh/tcshcannot parse${VAR:-default}/NAME=valueprefixes. Ghostty still supplies a login-style argv0 on macOS.Tests
CLITmuxCompatRemoteSplitTests.respawnPaneRunsShellExpressionsThroughLoginShelldrives__tmux-compat respawn-paneagainst a mock socket and asserts thesurface.respawncommand is/bin/sh-wrapped (spaced operator, no-space operator, assignment prefix, already-shell-invoked) whiletmux_start_commandstays raw. Added to the already-wiredCLITmuxCompatRemoteSplitTests.swift(no pbxproj change).tests/test_cli_omo_tmux_respawn_pane.pyupdated: both__tmux-compatand public respawn now expect the wrapped command.Verification
cd /tmp && echo … > marker && exec sleep 600) through cmux's respawn-pane wrote the marker file and the pane persisted — i.e. the command that the old code could not run now runs in a real respawned pane. The teammate command (cd <dir> && env … claude) follows the identical path.Localizable.xcstrings/ message-catalog updates required.🤖 Generated with Claude Code