Skip to content

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.

  • The built-ins to reach for first: claude --worktree … --tmux, split-pane agent teams, and cross-session messaging.
  • A bin/fleet script that fans out N tasks without a single git worktree command.
  • send-keys, capture-pane, and wait-for as 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.

Terminal window
brew install tmux # macOS
sudo apt install tmux # Debian, Ubuntu
tmux -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 window
# terminal, at the repo root
claude --worktree feature-auth --tmux # iTerm2 panes when available, else tmux
claude --worktree feature-auth --tmux=classic # always a plain tmux session

The 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.

ConcernOwnerWhy not the other one
File isolationThe agentclaude --worktree, codex --worktree, Cursor worktrees. Scripting it yourself duplicates a supported feature
Runtime isolation (ports, dev DB, container names)YouNobody does this for you. A worktree isolates files and nothing else
Visibility (which agent needs me)tmux, or claude agents for Claude-only fleetsStatus line and choose-tree see every agent; each agent sees itself
Persistence (close the lid, reattach over SSH)tmux serverAn agent dies with its terminal unless something outside it survives
Messages between agentsClaude Code messaging, codex queue; tmux for mixed fleetstmux 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.

Terminal window
tmux switch-client -t =agent-auth-refresh # jump by exact name
tmux choose-tree -Zs # browse the whole fleet as a tree

Put the fleet in your status line so “which agent needs me” is answerable without switching anywhere:

~/.tmux.conf
set -g status-left-length 80
set -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 5
set -g history-limit 50000

Inside #(), 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 worktree
set -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"; fi
Terminal window
bin/fleet auth-refresh rate-limit-headers stripe-webhook-idempotency

What you should see in another terminal:

Terminal window
$ tmux ls
agent-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.

Terminal window
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
  • -l sends the text literally. Without it, tmux parses words such as Enter or C-c in your prose as key names.
  • Submit in a separate call. C-m is a key name, so it cannot ride inside the -l argument.
  • Give the exact target a window. = exact match needs a window part (=name: or =name:window) for pane commands such as send-keys and capture-pane; session commands (has-session, attach, switch-client) take =name alone. On tmux 3.4, -t =agent-api here fails with can'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?”
Terminal window
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 human
set -euo pipefail
session="$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 15
done

This 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.

Terminal window
tmux wait-for -S migration-applied # in the worker, after the step succeeded
Terminal window
tmux wait-for migration-applied # in the orchestrator: blocks until signalled
tmux 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-m

On 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:

~/.codex/config.toml
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "..." # the same Stop command as above

Codex 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:

  1. 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.

  2. Build in its own session. bin/fleet starts the agent; the run window stays free for the dev server.

  3. 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-limit
    if ! timeout 3600 tmux wait-for "done-agent-$slug"; then
    echo "agent-$slug: no done signal within an hour; tests not run" >&2
    elif (cd ".claude/worktrees/$slug" && npm run test:api); then
    tmux wait-for -S "ok-$slug"
    else
    tmux 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-m
    fi

    A timeout skips the tests rather than running them on a half-finished worktree. The else branch types prose, which suits an interactive agent; an exec-mode Codex worker needs a command there instead (see the Codex tab below).

  4. Review with an agent, then a human on the summary. Run /code-review in the finished worktree (Claude Code) or codex review (Codex), and read the findings, not every line.

  5. 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 on done-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.

How do you keep the fleet alive when the laptop closes?

Section titled “How do you keep the fleet alive when the laptop closes?”
  1. Detach instead of quitting. Press the prefix, then d. The agents keep running; tmux attach -t =agent-auth-refresh picks them back up, from another machine too.

  2. 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.

  3. 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-s and restore with prefix + Ctrl-r. Pair it with the agent’s own resume: claude --resume, or claude --worktree NAME --resume for a kept worktree, and codex resume.

  4. Raise the scrollback first. The default history-limit is 2000 lines; one refactor overflows it, and capture-pane cannot 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.

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.