tmux for agent fleets: sessions, send-keys, wait-for
tmux for agent fleets is the terminal-multiplexer layer around coding agents: it keeps sessions alive over SSH, shows which agent needs a human, and scripts messages and barriers between agents. Claude Code, Codex, and Cursor now create their own git worktrees, so tmux no longer owns file isolation. It owns persistence, visibility, and plumbing that works across different agents.
This page is for developers and tech leads who run three or more terminal agents at once, often on a remote box. The situation it fixes: five agents are running, one has sat on a permission prompt for 40 minutes, another finished and is waiting for a migration that landed an hour ago, and you closed your laptop on the way home.
What you’ll set up
Section titled “What you’ll set up”- The built-ins to reach for first:
claude --worktree … --tmux, split-pane agent teams, and cross-session messaging. - A
bin/fleetscript that fans out N tasks without a singlegit worktreecommand. send-keys,capture-pane, andwait-foras the message, the poll, and the barrier, with the flags that stop each from failing silently.- Hooks that turn “this agent is blocked” into an exact signal, and a verification gate so “done” means “tests pass”, not “the agent stopped talking”.
What do Claude Code and Codex already do before you script tmux?
Section titled “What do Claude Code and Codex already do before you script tmux?”You need tmux 3.4 or later; every tmux behaviour on this page was checked on 3.4.
brew install tmux # macOSsudo apt install tmux # Debian, Ubuntutmux -V # prints: tmux 3.4 (or later)Check the built-ins first. Every flag below was checked against claude --help (Claude Code 2.1.283 on 2026-09-26, re-checked on 2.1.285) and codex --help (codex-cli 0.157.1).
claude --worktree NAME --tmux creates the worktree and opens it in its own tmux session. The help text: --tmux “Create a tmux session for the worktree (requires —worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.”
# terminal, at the repo rootclaude --worktree feature-auth --tmux # iTerm2 panes when available, else tmuxclaude --worktree feature-auth --tmux=classic # always a plain tmux sessionThe worktree lands in .claude/worktrees/feature-auth/ on branch worktree-feature-auth. If one session per task is all you need, stop here: this flag replaces most “worktree + tmux” scripts.
Agent teams in split panes. Agent teams are experimental and off by default; enable them with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1. The default display mode is in-process, where every teammate runs in your main terminal. To give each teammate its own pane, set teammateMode in ~/.claude/settings.json:
{ "teammateMode": "auto"}"auto" uses split panes when you are already inside tmux, or in iTerm2 with the it2 CLI installed, and falls back to in-process otherwise. "tmux" forces split panes and detects tmux or iTerm2; "iterm2" forces iTerm2 panes and needs it2 plus iTerm2 → Settings → General → Magic → Enable Python API. Split panes are not supported in VS Code’s integrated terminal, Windows Terminal, or Ghostty (agent teams docs).
Cross-session messaging. From v2.1.224 on macOS, Linux and WSL 2 (v2.1.234 on native Windows), Claude Code sessions on one machine can message each other, and from v2.1.236 one session can ask another for a single notice when it next goes idle. For a Claude-only fleet this replaces most of the send-keys and wait-for plumbing below, with delivery between tool calls instead of keystrokes (cross-session messaging docs).
Codex runs a session in a managed worktree with codex --worktree (also on codex exec), and codex queue --thread THREAD --message TEXT queues a message for an existing session, where THREAD is a session UUID or exact session name.
For the full comparison of built-ins, desktop apps, and third-party orchestrators, see running agents in parallel.
Who owns what in an agent fleet?
Section titled “Who owns what in an agent fleet?”| Concern | Owner | Why not the other one |
|---|---|---|
| File isolation | The agent | claude --worktree, codex --worktree, Cursor worktrees. Scripting it yourself duplicates a supported feature |
| Runtime isolation (ports, dev DB, container names) | You | Nobody does this for you. A worktree isolates files and nothing else |
| Visibility (which agent needs me) | tmux, or claude agents for Claude-only fleets | Status line and choose-tree see every agent; each agent sees itself |
| Persistence (close the lid, reattach over SSH) | tmux server | An agent dies with its terminal unless something outside it survives |
| Messages between agents | Claude Code messaging, codex queue; tmux for mixed fleets | tmux is the one channel that reaches Claude Code, Codex, and any other CLI agent alike |
How should you lay out tmux sessions for many agents?
Section titled “How should you lay out tmux sessions for many agents?”Use one tmux session per task, not one pane per task. Panes work up to about three agents; after that they get too narrow to read a diff and you lose track of which is which. Sessions are named, so you jump to them by name. Inside each session, keep one window for the agent and one for the dev server or tests.
tmux switch-client -t =agent-auth-refresh # jump by exact nametmux choose-tree -Zs # browse the whole fleet as a treePut the fleet in your status line so “which agent needs me” is answerable without switching anywhere:
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 5set -g history-limit 50000Inside #(), double every inner #. tmux format-expands the command before it runs it, so a single #{session_name} becomes the current session’s name and the status line repeats one name once per session.
For the base tmux layer (prefix, pane navigation, notifications), see terminal mastery.
How do you fan out agent tasks with one tmux script?
Section titled “How do you fan out agent tasks with one tmux script?”bin/fleet creates one detached session per task and starts the agent inside it. Note what is missing: any git worktree call. The agent owns that.
#!/usr/bin/env bash# bin/fleet -- one tmux session per task; the agent owns its own worktreeset -euo pipefail
[ $# -gt 0 ] || { echo "usage: FLEET_AGENT=claude|codex bin/fleet <slug> [slug...]" >&2; exit 1; }agent="${FLEET_AGENT:-claude}"
for slug in "$@"; do # send-keys types into a shell, so a slug like 'x;id' would run `id`. case "$slug" in *[!a-zA-Z0-9._-]*|"") echo "invalid slug: $slug" >&2; exit 1 ;; esac
case "$agent" in claude) cmd="claude --worktree $slug --name $slug" ;; codex) cmd="codex --worktree" ;; *) echo "unknown FLEET_AGENT: $agent" >&2; exit 1 ;; esac
session="agent-$slug" # '=' forces an exact match; without it 'agent-api' matches 'agent-api-v2' if tmux has-session -t "=$session" 2>/dev/null; then echo "exists, skipping: $session" >&2 continue fi
tmux new-session -d -s "$session" -c "$PWD" -n agent tmux send-keys -t "=$session:agent" -l "$cmd" tmux send-keys -t "=$session:agent" C-m 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-idempotencyWhat you should see in another terminal:
$ tmux lsagent-auth-refresh: 2 windows (created Sat Sep 26 10:02:11 2026)agent-rate-limit-headers: 2 windows (created Sat Sep 26 10:02:11 2026)agent-stripe-webhook-idempotency: 2 windows (created Sat Sep 26 10:02:11 2026)Three sessions, three agents, three worktrees under .claude/worktrees/. The session you attached to also shows (attached). --name also makes each Claude session addressable as @auth-refresh for cross-session messages.
How do you send a message to another agent with tmux send-keys?
Section titled “How do you send a message to another agent with tmux send-keys?”send-keys types into a pane. The agent cannot tell that from you typing, which is why it works as a channel between different agents. Between two Claude Code sessions, prefer cross-session messaging; use send-keys when one side is Codex, another CLI agent, or a script.
tmux send-keys -t =agent-api:agent -l "The migration in agent-auth-refresh landed on main. Rebase and re-run the integration tests."tmux send-keys -t =agent-api:agent C-m-lsends the text literally. Without it, tmux parses words such asEnterorC-cin your prose as key names.- Submit in a separate call.
C-mis a key name, so it cannot ride inside the-largument. - Give the exact target a window.
=exact match needs a window part (=name:or=name:window) for pane commands such assend-keysandcapture-pane; session commands (has-session,attach,switch-client) take=namealone. On tmux 3.4,-t =agent-apihere fails withcan't find pane.
How do you poll an agent’s state with capture-pane?
Section titled “How do you poll an agent’s state with capture-pane?”tmux capture-pane -p -J -t =agent-api:agent -S -200-p prints to stdout. -J joins wrapped lines; without it a long path or stack frame is split at the terminal width and your grep misses it. -S -200 starts 200 lines back in the scrollback.
A fallback supervisor built on it:
#!/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:"*|*"FAIL"*) echo "ERROR $session"; break ;; esac sleep 15doneThis is screen-scraping, and a new prompt wording breaks it. Hooks, below, are the mechanism; this loop is the fallback for agents without them.
How do you make one agent wait for another with wait-for?
Section titled “How do you make one agent wait for another with wait-for?”wait-for is a barrier: the waiting process blocks until something signals the channel. Channel names live on the tmux server, so any pane can signal any other with no shared files.
tmux wait-for -S migration-applied # in the worker, after the step succeededtmux wait-for migration-applied # in the orchestrator: blocks until signalledtmux send-keys -t =agent-api:agent -l "Migration applied. Rebase onto main and re-run integration tests."tmux send-keys -t =agent-api:agent C-mOn tmux 3.4, a signal sent before anyone waits is remembered once, so the next wait-for returns immediately. That saves a race, and it also means a stale signal from an earlier run releases a waiter early.
How do agent hooks replace screen-scraping?
Section titled “How do agent hooks replace screen-scraping?”Polling infers state from rendered text. Hooks invert it: the agent tells tmux what happened when it happens. In Claude Code, a Stop hook renames the window and signals a channel named after its own session; a Notification hook flags “I need you”. Put this in .claude/settings.json (project) or ~/.claude/settings.json (user):
{ "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" } ] } ] }}$TMUX_PANE is set in every tmux pane, so the hook targets itself. The 2>/dev/null and trailing true keep it harmless outside tmux. The channel name is derived from the session because wait-for carries no payload: a single global agent-done channel would let the first agent to stop wake every waiter. Codex also has a Stop hook event (12 events in 0.157.1). It reads the same JSON shape from a hooks.json next to its config (~/.codex/hooks.json, or .codex/hooks.json in a trusted project), or the equivalent tables in config.toml:
[[hooks.Stop]][[hooks.Stop.hooks]]type = "command"command = "..." # the same Stop command as aboveCodex runs a new hook only after you trust it; review it in /hooks.
How do you verify a fleet’s output without reading every diff?
Section titled “How do you verify a fleet’s output without reading every diff?”tmux makes fan-out cheap, which makes it easy to exceed your review bandwidth. The fix is a gate that every worker passes before anyone reads its diff. Run each task through the same loop:
-
Plan with a pass condition. Each task slug gets one acceptance command before the agent starts, for example
npm run test:api. A task without one does not go into the fleet. -
Build in its own session.
bin/fleetstarts the agent; therunwindow stays free for the dev server. -
Verify on the signal, not on the agent’s word. When
done-agent-<slug>fires, run the acceptance command in the worktree, outside the agent:Terminal window slug=api-rate-limitif ! timeout 3600 tmux wait-for "done-agent-$slug"; thenecho "agent-$slug: no done signal within an hour; tests not run" >&2elif (cd ".claude/worktrees/$slug" && npm run test:api); thentmux wait-for -S "ok-$slug"elsetmux send-keys -t "=agent-$slug:agent" -l "npm run test:api fails in your worktree. Fix it; do not edit the tests."tmux send-keys -t "=agent-$slug:agent" C-mfiA timeout skips the tests rather than running them on a half-finished worktree. The
elsebranch types prose, which suits an interactive agent; an exec-mode Codex worker needs a command there instead (see the Codex tab below). -
Review with an agent, then a human on the summary. Run
/code-reviewin the finished worktree (Claude Code) orcodex review(Codex), and read the findings, not every line. -
Ship through CI. The pull request merges only when CI is green; the human who owns the task signs off. Dependents wait on
ok-<slug>, never ondone-agent-<slug>.
Keep the tests out of the agent’s reach, or the gate measures nothing: see protecting the oracle and how strong is your oracle.
How do you drive each agent from a tmux pane?
Section titled “How do you drive each agent from a tmux pane?”claude --worktree NAME creates .claude/worktrees/NAME/ on branch worktree-NAME; --worktree "#1234" branches from a pull request. On exit, Claude removes a clean worktree of an unnamed session automatically; a named session (bin/fleet uses --name) and any worktree with work in it get a keep/remove prompt. Keeping one prints the claude --worktree <name> --resume command to return to it. worktree.baseRef defaults to "fresh" (the remote default branch); set it to "head" when workers must build on your unpushed work.
For a headless worker, claude -p --worktree NAME "task" skips the trust check, but it has no exit prompt, so nothing removes the worktree. Clean up with git worktree remove (run git worktree unlock first if git refuses). For a Claude-only fleet, claude agents (agent view, research preview) shows background sessions grouped by state; see agent view.
codex --worktree starts an interactive session in a managed worktree; codex exec --worktree "TASK" runs one non-interactively and exits, which pairs cleanly with wait-for. The exit code says only that the agent stopped, so signal done-agent-api after it whatever the code, never ok-api: that is the done-agent-$slug channel the verification gate waits on (with slug=api). A run that exited non-zero then fails the gate’s tests like any bad diff, instead of leaving the gate blocked for an hour on a channel nobody signals:
tmux send-keys -t =agent-api:agent -l 'codex exec --worktree "add rate-limit headers to every route"; tmux wait-for -S done-agent-api'tmux send-keys -t =agent-api:agent C-mOnce codex exec exits, the pane is back at a shell prompt, so the gate’s failure branch must not type prose into it: every sentence would run as a command line. For an exec-mode worker, replace the two send-keys lines in the gate’s else branch with a command that resumes the run with the fix-it message and signals again. codex exec prints session id: <uuid> in its header; pass that id, because --last picks the newest session for the current directory and a fleet has several:
tmux send-keys -t "=agent-$slug:agent" -l "codex exec resume SESSION_ID 'npm run test:api fails in your worktree. Fix it; do not edit the tests.'; tmux wait-for -S done-agent-$slug"tmux send-keys -t "=agent-$slug:agent" C-mCodex keeps managed worktrees under ~/.codex/worktrees/<id>/<repo>/ (set git-worktree-root under [desktop] in config.toml to move them), not under .claude/worktrees/. Find the path with git worktree list and use it in the gate’s cd.
To hand a running Codex session a message without typing into its pane, use codex queue --thread THREAD --message TEXT. Resume a closed session with codex resume --last. See Codex worktrees.
Cursor’s worktrees let its agent work in isolated git checkouts inside the editor (Cursor docs, checked 2026-08-28), so for interactive work you run agents in Cursor, not behind tmux.
tmux earns its place with Cursor’s CLI in print mode (-p, --print) on a remote box, driven by the same send-keys, capture-pane, and wait-for plumbing. The CLI’s command name was not re-verified for this page (cursor.com was unreachable on 2026-09-26), so read your installed binary’s --help before you script it.
How do you keep the fleet alive when the laptop closes?
Section titled “How do you keep the fleet alive when the laptop closes?”-
Detach instead of quitting. Press the prefix, then
d. The agents keep running;tmux attach -t =agent-auth-refreshpicks them back up, from another machine too. -
Run the server where it can stay up. A small VPS or a spare desktop means closing the laptop stops nothing:
ssh -t box tmux attach -t =agent-auth-refresh. For phone access to Claude Code sessions, compare remote and mobile clients. -
Survive reboots deliberately. tmux-resurrect restores the session and window layout, not the agent conversations. Install it through TPM with
set -g @plugin 'tmux-plugins/tmux-resurrect'in~/.tmux.conf, then save with prefix +Ctrl-sand restore with prefix +Ctrl-r. Pair it with the agent’s own resume:claude --resume, orclaude --worktree NAME --resumefor a kept worktree, andcodex resume. -
Raise the scrollback first. The default
history-limitis 2000 lines; one refactor overflows it, andcapture-panecannot read what tmux already dropped.
Should you use Zellij instead of tmux for agents?
Section titled “Should you use Zellij instead of tmux for agents?”Zellij covers the persistence and messaging half: named sessions you detach from and reattach to, and zellij action write-chars and zellij action dump-screen, which do the jobs of send-keys and capture-pane. Two gaps matter for fleets. Claude Code’s --tmux flag and split-pane agent teams target tmux or iTerm2, not Zellij. And the Zellij CLI definition on its main branch, read 2026-09-26, has no counterpart to wait-for, so barriers need files or sockets instead. Pick Zellij if you already live in it and your fleet is small; pick tmux for scripted fleets.
As of 2026-09-26, tmux has 49,505 GitHub stars and Zellij 35,546 (GitHub, read 2026-09-26). tmux itself adds nothing to an agent’s context window; every message you send with send-keys or cross-session messaging costs tokens like a typed prompt.
When tmux agent fleets break
Section titled “When tmux agent fleets break”Two worktrees per task, or an agent deletes a directory your script expects. You scripted worktrees the agent already manages. Remove the git worktree calls; the agent is the one owner.
A message landed in the wrong session. tmux matched a prefix: -t agent-api hits agent-api-v2 when agent-api does not exist (reproduced on tmux 3.4). Use exact targets: -t =agent-api for session commands (has-session, attach, switch-client) and -t =agent-api:agent (or =agent-api:) for pane commands (send-keys, capture-pane).
Your instruction answered a permission prompt. send-keys typed into a yes/no dialog. Recover by reading the pane, rejecting or undoing what the agent did, and re-sending. Prevent it with capture-pane before every write, or drive handoffs off hooks.
The message arrived mangled or killed the agent. You forgot -l, and tmux read Enter or C-c in your prose as keys. Add -l and send C-m separately.
A capture-pane grep misses an obvious match. Wrapped lines split the path. Add -J.
A dependent task started too early. Either it waited on done-agent-* (which fires every turn) or a stale remembered signal released it. Gate on ok-* from the test step, and start each run with fresh channel names.
wait-for hangs forever. The worker failed before the signal line. Signal both paths (&& tmux wait-for -S done-x || tmux wait-for -S fail-x), and wrap waits in timeout.
Split panes never appear. You are outside tmux under "auto", in an unsupported terminal (VS Code, Windows Terminal, Ghostty), or it2 is missing. Start inside tmux, or set "tmux" and check which tmux.
A tmux session outlives its agent team. Run tmux ls and tmux kill-session -t NAME for the session the team created.
Every agent stalls at once. Every pane spends the same account’s quota; an agent that “hung” has often hit a limit. Check the pane before you debug the script. For what parallel fan-out does to spend, see cost optimization, and for the per-developer ceiling, see team parallelism.