| name | tmux-control |
| description | Use when sending commands to tmux panes, reading pane output, creating windows/panes, or monitoring tmux sessions. Covers reliable targeting, synchronization, and output capture patterns. |
| license | MIT |
| metadata | {"author":"eins78","repo":"https://github.com/eins78/agent-skills","version":"1.0.0"} |
tmux Control Patterns
Reliable patterns for programmatic tmux interaction. The #1 source of bugs is targeting — follow these rules strictly.
Golden Rules
- Use unique IDs for targeting:
%N (pane), @N (window), $NAME (session) — never bare indexes
- Use
wait-for instead of sleep for synchronization
- Pass commands to
new-window/split-window directly — avoid send-keys to freshly created panes (race condition)
- Always use
-d (detached) with new-window and split-window — never steal focus from the user's active window
- Verify targets exist before sending — use
list-panes -a to check panes, list-windows for windows, has-session for sessions
- Use full binary paths in commands sent to panes — the pane's environment may differ from yours
Targeting
The format is SESSION:WINDOW.PANE. All three parts are optional but be explicit.
Discover targets first — always
tmux list-panes -a -F '#{pane_id} #{session_name}:#{window_index}.#{pane_index} #{pane_current_command}'
tmux list-sessions -F '#{session_id} #{session_name}'
tmux list-windows -F '#{window_id} #{window_index} #{window_name}'
Targeting syntax
| Target | Meaning | Reliability |
|---|
%42 | Pane ID (unique, stable) | Best |
@3 | Window ID (unique, stable) | Good |
$main | Session by name | Good |
main:2.0 | Session:Window.Pane by index | Fragile — indexes shift |
:2 | Window 2 in current session | OK for interactive use |
-t 2 | Ambiguous (session? window?) | Avoid |
Capture pane ID at creation
PANE_ID=$(tmux split-window -d -P -F '#{pane_id}' 'echo hello; exec bash')
PANE_ID=$(tmux new-window -d -P -F '#{pane_id}' 'echo hello; exec bash')
Creating Panes and Windows
Preferred: pass command directly (avoids race condition)
tmux new-window -d 'echo "hello from new window"; exec bash'
tmux split-window -d 'npm run dev; exec bash'
PANE_ID=$(tmux new-window -d -P -F '#{pane_id}' 'npm test; exec bash')
Why send-keys to new panes is fragile
send-keys fires immediately, but the shell in a new pane may not be initialized yet (especially with heavy shell configs like oh-my-zsh). The command arrives before the prompt is ready and gets lost.
If you must use send-keys on a new pane, add a brief delay:
PANE_ID=$(tmux split-window -d -P -F '#{pane_id}')
sleep 0.5
tmux send-keys -t "$PANE_ID" 'your command' Enter
Sending Commands
Basic pattern
tmux send-keys -t "$TARGET" 'your command here' Enter
Quoting rules
- Outer quotes: use single quotes around the command to prevent local shell expansion
- Inner quotes: if the command itself needs quotes, use double quotes inside singles, or escape
- Pipes and redirects: work fine inside quotes
tmux send-keys -t "$PANE" 'ls -la /tmp' Enter
tmux send-keys -t "$PANE" 'ps aux | grep node' Enter
tmux send-keys -t "$PANE" 'echo "hello world" > /tmp/out.txt' Enter
echo 'complex command "with" pipes | and stuff' > /tmp/tmux-cmd.sh
tmux send-keys -t "$PANE" 'bash /tmp/tmux-cmd.sh' Enter
Nested Claude Code sessions
Running claude inside an existing Claude Code session fails due to a guard variable. Workaround:
tmux send-keys -t "$PANE" 'env -u CLAUDECODE claude -p "your prompt"' Enter
Reading Output
capture-pane (visible buffer)
tmux capture-pane -t "$PANE" -p
tmux capture-pane -t "$PANE" -p -S -20
tmux capture-pane -t "$PANE" -p -S - -J
tmux capture-pane -t "$PANE" -p -e
File-based pattern (for long output)
When output exceeds scrollback or you need the complete result:
CHAN="cmd-done-$$-$RANDOM"
tmux send-keys -t "$PANE" "your-command > /tmp/result.out 2>&1; tmux wait-for -S $CHAN" Enter
tmux wait-for "$CHAN"
cat /tmp/result.out
Prompt detection (is the pane idle?)
tmux capture-pane -t "$PANE" -p | tail -1 | grep -qE '(\$|>|#|%)\s*$' && echo "idle" || echo "busy"
Caution: capture-pane returns empty if the pane hasn't rendered yet. Don't check immediately after creating a pane.
Synchronization with wait-for
wait-for provides channel-based synchronization — far more reliable than sleep.
Pattern: run command and wait for completion
CHAN="done-$$-$RANDOM"
tmux send-keys -t "$PANE" "your-command; tmux wait-for -S $CHAN" Enter
tmux wait-for "$CHAN"
tmux capture-pane -t "$PANE" -p -S -20
Pattern: timeout with wait-for
CHAN="done-$$-$RANDOM"
tmux send-keys -t "$PANE" "long-command; tmux wait-for -S $CHAN" Enter
if timeout 30 tmux wait-for "$CHAN"; then
echo "Command completed"
else
echo "Command timed out"
fi
Monitoring
pipe-pane: stream output to file
tmux pipe-pane -t "$PANE" -o 'cat >> /tmp/pane-log.txt'
tmux pipe-pane -t "$PANE"
tmux pipe-pane -t "$PANE" -o 'grep --line-buffered ERROR >> /tmp/errors.txt'
Note: only one pipe per pane. Setting a new pipe replaces the old one.
monitor-silence: detect idle pane
tmux set-option -t "$WINDOW" monitor-silence 10
tmux display-message -t "$WINDOW" -p '#{window_silence_flag}'
monitor-activity: detect new output
tmux set-option -t "$WINDOW" monitor-activity on
macOS-Specific
Full Disk Access (FDA)
tmux sessions started from a GUI terminal (Terminal.app, iTerm2) inherit FDA. Sessions started over SSH do not. This matters for accessing ~/Documents/, ~/Desktop/, and NFS mounts.
Rule: for file system operations requiring FDA, send commands to a tmux pane that was created from a GUI terminal — never to an SSH-initiated session.
PATH and environment
The pane's shell environment may differ from yours — don't assume your aliases, functions, or PATH are available:
- Don't rely on aliases — use full commands (e.g.,
/opt/homebrew/bin/tmux not tmux)
- PATH may differ — use absolute paths for non-standard binaries
- Shell functions may not exist — write scripts to temp files if needed
Recipes
Run command and get output
PANE=$(tmux list-panes -F '#{pane_id}' | head -1)
CHAN="cmd-$$"
tmux send-keys -t "$PANE" "whoami; tmux wait-for -S $CHAN" Enter
tmux wait-for "$CHAN"
tmux capture-pane -t "$PANE" -p -S -5
Start long process and check later
PANE=$(tmux new-window -d -P -F '#{pane_id}' -n 'build')
tmux send-keys -t "$PANE" 'npm run build > /tmp/build.out 2>&1; echo "BUILD_DONE" >> /tmp/build.out' Enter
grep -q "BUILD_DONE" /tmp/build.out 2>/dev/null && echo "finished" || echo "still running"
cat /tmp/build.out
Monitor pane for completion
tmux pipe-pane -t "$PANE" -o 'cat >> /tmp/pane-watch.txt'
tmux send-keys -t "$PANE" 'make all' Enter
while ! grep -q '\$' <(tail -1 /tmp/pane-watch.txt 2>/dev/null); do
sleep 2
done
echo "Command completed"
tmux pipe-pane -t "$PANE"
Helper Scripts
Two helper scripts in ${CLAUDE_SKILL_DIR}/scripts/ wrap common patterns into single commands. Always use the full ${CLAUDE_SKILL_DIR}/scripts/ path — these scripts are bundled with this skill, not in the project being worked on.
tmux-run.sh — run command and get output
${CLAUDE_SKILL_DIR}/scripts/tmux-run.sh -t %42 'npm test'
${CLAUDE_SKILL_DIR}/scripts/tmux-run.sh -t %42 -T 60 'make build'
output=$(${CLAUDE_SKILL_DIR}/scripts/tmux-run.sh -t %42 -q 'git status')
${CLAUDE_SKILL_DIR}/scripts/tmux-run.sh -t %42 'npm test' || echo "tests failed"
- stdout: command output only
- stderr: status messages (suppress with
-q)
- Exit code: mirrors remote command (128 = timeout)
- Requires pane ID (
%N format)
tmux-watch.sh — wait for pattern in pane output
${CLAUDE_SKILL_DIR}/scripts/tmux-watch.sh -t %42 'BUILD_DONE'
${CLAUDE_SKILL_DIR}/scripts/tmux-watch.sh -t %42 -T 300 -i 5 'Tests:.*passed'
${CLAUDE_SKILL_DIR}/scripts/tmux-watch.sh -t %42 '\$\s*$'
- Pattern is extended regex (grep -E)
- stdout: matching line(s)
- Exit 0 = found, 1 = timeout, 2 = pane not found
- Default: 300s timeout, 2s poll interval
Multi-Agent Patterns
Patterns for running multiple Claude Code instances (or any agents) in parallel tmux panes.
Spawn parallel agents
PANE1=$(tmux new-window -d -n 'agents' -P -F '#{pane_id}')
PANE2=$(tmux split-window -d -h -t "$PANE1" -P -F '#{pane_id}')
PANE3=$(tmux split-window -d -v -t "$PANE2" -P -F '#{pane_id}')
for PANE in $PANE1 $PANE2 $PANE3; do
tmux send-keys -t "$PANE" \
"env -u CLAUDECODE claude -p 'your task...' --output-format json > /tmp/agent-${PANE}.json 2>&1; echo DONE > /tmp/agent-${PANE}.signal" \
Enter
done
for PANE in $PANE1 $PANE2 $PANE3; do
while [ ! -f "/tmp/agent-${PANE}.signal" ]; do sleep 5; done
done
for PANE in $PANE1 $PANE2 $PANE3; do
echo "=== Agent $PANE ==="
jq -r '.result' "/tmp/agent-${PANE}.json"
done
Monitor agents with tmux-watch.sh
for PANE in $PANE1 $PANE2 $PANE3; do
${CLAUDE_SKILL_DIR}/scripts/tmux-watch.sh -t "$PANE" -T 600 -q '\$\s*$' &
done
wait
echo "All agents complete"
File-based coordination (proven pattern)
The ralph-sprint system proves this coordination model at scale:
- Git as shared memory — agents coordinate via branches, PRs, committed files
- File-based signals — write marker files, poll for existence
- Promise signals — use
<promise>COMPLETE</promise> in agent output for unambiguous completion detection (plain-text markers get false-positives from echoed prompt text)
- Stateless iterations — each agent starts fresh from git state, avoiding context compaction issues
Anti-patterns
- Don't send-keys to a pane running Claude Code — it reads stdin, your keystrokes become input to the agent
- Don't use
monitor-activity for coordination — it's window-level, not pane-level
- Don't share a pane between agents — one agent per pane, always
- Don't use sequential loops for independent tasks — use parallel panes instead
- Don't rely on
sleep for inter-agent timing — use file signals or wait-for channels
Self-Improvement
If you encounter a tmux pattern that fails, a missing recipe, or incorrect guidance in this skill, don't just work around it — fix the skill:
- Create a PR from a fresh worktree of
eins78/agent-skills on a new branch, fixing the issue directly
- Or file an issue on
eins78/agent-skills with: what failed, the actual tmux behavior, and the suggested fix
Never silently work around a skill gap. The fix benefits all future sessions.