tmux for Agent Fleets: Sessions, send-keys, wait-for
Every “tmux + AI agents” guide written in the last year opens the same way: a loop that calls git worktree add, then a tmux new-window per worktree, then send-keys to launch the agent. That advice is now one layer too low. Claude Code creates and tears down its own worktrees with --worktree, Cursor has /worktree in the Agents Window, and Codex runs worktree threads natively. The isolation problem tmux scripts were solving got absorbed into the agents.
So what is tmux still for? Three things no agent does for you: you cannot see six agents at once, your laptop lid closes, and agents cannot talk to each other. Those are terminal-multiplexer problems, and tmux is very good at them. This article is about that seam — not about git worktree add in a for-loop.
What you’ll walk away with
Section titled “What you’ll walk away with”- A clear division of labour: what the agent owns, what tmux owns, and what neither owns
- A
bin/fleetscript that fans out N tasks without managing a single worktree by hand send-keysas a real agent-to-agent channel, including the two flags that stop it mangling your messagescapture-paneandwait-foras the poll and the barrier — how an orchestrator agent supervises workers- Agent hooks wired into tmux, so “this agent is blocked” is an exact signal instead of a screen-scrape
Who Owns What
Section titled “Who Owns What”| Concern | Owner | Why not the other one |
|---|---|---|
| File isolation | The agent | claude --worktree, Cursor /worktree, Codex worktree threads. Scripting this yourself now duplicates a supported feature |
| Runtime isolation (ports, dev DB, Docker names) | You | Nobody does this for you. A worktree isolates files and nothing else |
| Visibility (which agent needs me right now) | tmux | Windows, status line, choose-tree. The agent only knows about itself |
| Persistence (close the lid, reattach over SSH) | tmux server | The agent process dies with its terminal unless something outside it survives |
| Messaging between agents | tmux | send-keys, capture-pane, wait-for. No agent has an inbox |
Session Per Task, Not Pane Per Task
Section titled “Session Per Task, Not Pane Per Task”The instinct is to split panes. It works up to three agents and falls apart at six: panes get too narrow to read an agent’s diff, and you lose track of which is which. Sessions scale better because they are named and you jump to them by name.
The layout that holds up: one tmux session per task, and inside it one window for the agent, one for the dev server, one for tests.
# jump by name, no counting panestmux switch-client -t agent-auth-refresh
# or browse the whole fleet as a treetmux choose-tree -ZsPut the fleet in your status line so “which agent needs me” is answerable without switching anywhere. Session names sorted by activity are enough:
set -g status-left-length 80set -g status-left '#{?client_prefix,#[reverse],}[#S] 'set -g status-right '#(tmux list-sessions -F "#{session_name}#{?session_attached,*,}" | tr "\n" " ")'set -g status-interval 5For the base tmux configuration — prefix rebinding, pane navigation, history-limit for long agent transcripts — see terminal mastery. This article assumes that layer exists and builds the fleet layer on top.
The Fan-Out Script
Section titled “The Fan-Out Script”The whole script, and note what is missing from it: any mention of git worktree. The agent handles that.
#!/usr/bin/env bash# bin/fleet -- one tmux session per task; the agent owns its own worktreeset -euo pipefail
[ $# -gt 0 ] || { echo "usage: bin/fleet <slug> [slug...]" >&2; exit 1; }
for slug in "$@"; do # send-keys types into a shell, so a slug like 'x;id' would run `id`. # Validate before it ever reaches a pane. case "$slug" in *[!a-zA-Z0-9._-]*|"") echo "invalid slug: $slug" >&2; exit 1 ;; esac
session="agent-$slug"
if tmux has-session -t "$session" 2>/dev/null; then echo "exists, skipping: $session" >&2 continue fi
# -d detached, -c so the agent starts at the repo root tmux new-session -d -s "$session" -c "$PWD" -n agent tmux send-keys -t "$session:agent" "claude --worktree $slug" C-m
# a second window for whatever this task needs to run tmux new-window -d -t "$session" -n run -c "$PWD"done
first="agent-$1"if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$first"else tmux attach -t "$first"fibin/fleet auth-refresh rate-limit-headers stripe-webhook-idempotencyThree sessions, three agents, three worktrees under .claude/worktrees/, each on its own worktree-<slug> branch. You did not type a git command.
send-keys: The Agent-to-Agent Channel
Section titled “send-keys: The Agent-to-Agent Channel”send-keys types into a pane. The agent cannot tell the difference between that and you typing — which is exactly why it works as a message channel between agents.
tmux send-keys -t agent-api -l "The migration in agent-auth-refresh just landed on main. Rebase and re-run your integration tests."tmux send-keys -t agent-api C-mTwo details make the difference between this working and silently corrupting your message:
-lsends the text literally. Without it, tmux parses the argument as key names. A message containingEnter,Space, or a;gets interpreted rather than typed, and you get a mangled prompt or a stray command. Always-lfor prose.- Submit as a separate call.
C-mis a key name, so it cannot ride along in the same-largument. Send the text, then send the newline.
capture-pane: The Poll
Section titled “capture-pane: The Poll”tmux capture-pane -p -J -t agent-api -S -200-pprints to stdout instead of a paste buffer, so you can pipe it.-Jjoins wrapped lines. Skip this and every long file path or stack frame is split across lines at your terminal width, so yourgrepmisses it. This flag is the difference between a reliable poll and a flaky one.-S -200starts 200 lines back in the scrollback rather than at the top of the visible pane.
A supervisor loop built on it — the shape an orchestrator agent uses to watch a worker:
#!/usr/bin/env bash# bin/agent-watch <session> -- report when a worker needs a humanset -euo pipefailsession="$1"
while tmux has-session -t "$session" 2>/dev/null; do tail=$(tmux capture-pane -p -J -t "$session:agent" -S -40) case "$tail" in *"Do you want"*|*"Allow "*|*"(y/n)"*) echo "BLOCKED $session"; break ;; *"Error"*|*"error:"*|*"FAIL"*) echo "ERROR $session"; break ;; esac sleep 15doneThat is screen-scraping, and it is as fragile as it looks — a new prompt wording breaks it. Use it as a fallback, not as the mechanism. The mechanism is hooks, two sections down.
wait-for: The Barrier
Section titled “wait-for: The Barrier”Polling burns cycles and adds latency. wait-for is a real barrier: the waiting process blocks until something signals the channel.
# in the worker, after the thing that mattered succeededtmux wait-for -S migration-applied# in the orchestratortmux wait-for migration-appliedtmux send-keys -t agent-api -l "Migration applied. Rebase onto main and re-run integration tests."tmux send-keys -t agent-api C-mChannel names live on the tmux server, so any pane can signal any other with no shared files and no PID juggling. This is the cleanest dependency primitive available to a fleet: task B waits on channel A-done, task A signals it when it lands, and nothing polls.
Hooks: Exact State Instead of Screen-Scraping
Section titled “Hooks: Exact State Instead of Screen-Scraping”The reason capture-pane polling feels fragile is that it is fragile — you are inferring state from rendered text. Agent hooks invert it: the agent tells tmux what happened, at the moment it happens.
A Stop hook renames the window when the agent finishes, and signals a channel named after its own session so a waiter can block on one specific worker. A Notification hook does the same for “I need permission.” Both are one line of tmux.
{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "S=$(tmux display-message -p -t \"$TMUX_PANE\" '#{session_name}' 2>/dev/null); tmux rename-window -t \"$TMUX_PANE\" done 2>/dev/null; [ -n \"$S\" ] && tmux wait-for -S \"done-$S\" 2>/dev/null; true" } ] } ], "Notification": [ { "hooks": [ { "type": "command", "command": "tmux rename-window -t \"$TMUX_PANE\" 'NEEDS-YOU' 2>/dev/null; tmux display-message -d 3000 'agent needs approval' 2>/dev/null; true" } ] } ] }}Four things to note. $TMUX_PANE is set inside any tmux pane, so the hook targets itself without hardcoding a session. The channel name is derived from the session rather than fixed — this matters more than it looks, and the next paragraph is about why. The 2>/dev/null and trailing true keep the hook harmless when the agent is running outside tmux — otherwise a non-zero exit surfaces as a hook failure on every stop. And the window name is now load-bearing: a glance at the status line tells you which agent wants you, with no polling at all.
Once hooks are in place, bin/fleet-deps stops scraping and starts waiting on a specific worker:
# the session is agent-db-migration, so its Stop hook signals done-agent-db-migrationtmux wait-for done-agent-db-migrationDriving Each Agent
Section titled “Driving Each Agent”claude --worktree <name> creates .claude/worktrees/<name>/ on branch worktree-<name> and starts there. --worktree "#1234" branches from a pull request instead. On exit, Claude offers to remove the worktree if it is clean, and prompts when there is work in it.
Two settings matter for fleets. worktree.baseRef defaults to "fresh" (branch from the remote default branch); set it to "head" when workers must build on your unpushed work. And .worktreeinclude at the repo root copies gitignored files like .env into every new worktree.
For non-interactive workers in a pane, claude -p skips the trust check entirely — but it also means no exit prompt, so nothing cleans up the worktree. Remove those with git worktree remove yourself.
Codex has its own worktree threads, so the pane command is just codex and you pick worktree mode in the session. For scripted workers, codex exec "<task>" runs non-interactively and exits — which pairs cleanly with wait-for:
tmux send-keys -t agent-api -l 'codex exec "add rate-limit headers to every route" && tmux wait-for -S api-done'tmux send-keys -t agent-api C-mEach worktree carries its own AGENTS.md, which is the cleanest way to scope a worker to one slice of the repo — write the narrow instruction file into the worktree before you prompt.
Cursor’s Agents Window already runs agents across local workspaces, worktrees, cloud, and SSH, so for interactive work you generally do not want tmux in front of it.
Where tmux earns its place with Cursor is the headless path: cursor-agent in a pane, driven by the same send-keys / capture-pane / wait-for plumbing as anything else. Per-worktree .cursor/rules then scopes each worker, since the agent reads the rules of the directory it was started in.
Persistence: The Whole Point of the Server
Section titled “Persistence: The Whole Point of the Server”An agent mid-refactor dies with its terminal. A tmux server does not.
-
Detach instead of quitting.
prefix + d. The agents keep running.tmux attach -t agent-auth-refreshpicks the fleet back up, including from a different machine over SSH. -
Run the fleet where it can stay up. A small VPS or a spare desktop running the tmux server means closing your laptop stops nothing.
ssh box -t tmux attach -t agent-auth-refreshis the whole workflow. -
Survive reboots deliberately.
tmux-resurrectsaves and restores the session/window layout;tmux-continuumsaves it on a timer. Note what they restore: the layout, not the agent conversations. Pair it with the agent’s own resume (claude --resume,codex resume) to get back to real state. -
Raise the scrollback before you need it.
set -g history-limit 50000. An agent transcript blows through the default 2000 lines in one refactor, andcapture-pane -S -200cannot read what tmux already dropped.
When This Breaks
Section titled “When This Breaks”You scripted worktrees your agent already manages. The symptom is two worktrees per task, or an agent that cleans up a directory your script still expects. Pick one owner. In 2026 that owner is the agent.
send-keys landed in a permission prompt. Your instruction answered a yes/no dialog and the agent did something you did not ask for. Always capture-pane before send-keys, or better, drive handoffs off hooks so you only write to agents you know are idle.
send-keys mangled the message. You forgot -l, and tmux read Enter or C-c inside your prose as a key. Symptoms range from a truncated prompt to a killed agent.
capture-pane greps miss obvious matches. You forgot -J, so a wrapped path or stack frame is split at the terminal width. Add -J and the same grep starts working.
wait-for hangs forever. Nothing signalled the channel — usually because the worker failed before reaching the signal line. Signal on both paths (cmd && tmux wait-for -S ok || tmux wait-for -S failed) and have the waiter distinguish them, or wrap the wait with timeout.
Scrollback ate the evidence. Default history-limit is 2000 lines. Raise it to 50000 before a long run, not after.
Six panes did not make you faster. tmux makes fan-out cheap, which makes it easy to exceed your own review bandwidth. The ceiling is how many diffs you can actually judge — see team parallelism for calibrating it, and cost optimization for what parallel fan-out does to token burn.
Rate limits, not tmux, stopped your fleet. Every pane spends the same account’s quota. An agent that “hung” is often one that hit a limit. Check the pane before you debug your script.