Skip to content

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.

  • A clear division of labour: what the agent owns, what tmux owns, and what neither owns
  • A bin/fleet script that fans out N tasks without managing a single worktree by hand
  • send-keys as a real agent-to-agent channel, including the two flags that stop it mangling your messages
  • capture-pane and wait-for as 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
ConcernOwnerWhy not the other one
File isolationThe agentclaude --worktree, Cursor /worktree, Codex worktree threads. Scripting this yourself now duplicates a supported feature
Runtime isolation (ports, dev DB, Docker names)YouNobody does this for you. A worktree isolates files and nothing else
Visibility (which agent needs me right now)tmuxWindows, status line, choose-tree. The agent only knows about itself
Persistence (close the lid, reattach over SSH)tmux serverThe agent process dies with its terminal unless something outside it survives
Messaging between agentstmuxsend-keys, capture-pane, wait-for. No agent has an inbox

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.

Terminal window
# jump by name, no counting panes
tmux switch-client -t agent-auth-refresh
# or browse the whole fleet as a tree
tmux choose-tree -Zs

Put the fleet in your status line so “which agent needs me” is answerable without switching anywhere. Session names sorted by activity are enough:

~/.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

For 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 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 worktree
set -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"
fi
Terminal window
bin/fleet auth-refresh rate-limit-headers stripe-webhook-idempotency

Three sessions, three agents, three worktrees under .claude/worktrees/, each on its own worktree-<slug> branch. You did not type a git command.

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.

Terminal window
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-m

Two details make the difference between this working and silently corrupting your message:

  • -l sends the text literally. Without it, tmux parses the argument as key names. A message containing Enter, Space, or a ; gets interpreted rather than typed, and you get a mangled prompt or a stray command. Always -l for prose.
  • Submit as a separate call. C-m is a key name, so it cannot ride along in the same -l argument. Send the text, then send the newline.
Terminal window
tmux capture-pane -p -J -t agent-api -S -200
  • -p prints to stdout instead of a paste buffer, so you can pipe it.
  • -J joins wrapped lines. Skip this and every long file path or stack frame is split across lines at your terminal width, so your grep misses it. This flag is the difference between a reliable poll and a flaky one.
  • -S -200 starts 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 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"*|*"error:"*|*"FAIL"*)
echo "ERROR $session"; break ;;
esac
sleep 15
done

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

Polling burns cycles and adds latency. wait-for is a real barrier: the waiting process blocks until something signals the channel.

Terminal window
# in the worker, after the thing that mattered succeeded
tmux wait-for -S migration-applied
Terminal window
# in the orchestrator
tmux wait-for migration-applied
tmux send-keys -t agent-api -l "Migration applied. Rebase onto main and re-run integration tests."
tmux send-keys -t agent-api C-m

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

Terminal window
# the session is agent-db-migration, so its Stop hook signals done-agent-db-migration
tmux wait-for done-agent-db-migration

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.

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.

  1. Detach instead of quitting. prefix + d. The agents keep running. tmux attach -t agent-auth-refresh picks the fleet back up, including from a different machine over SSH.

  2. 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-refresh is the whole workflow.

  3. Survive reboots deliberately. tmux-resurrect saves and restores the session/window layout; tmux-continuum saves 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.

  4. Raise the scrollback before you need it. set -g history-limit 50000. An agent transcript blows through the default 2000 lines in one refactor, and capture-pane -S -200 cannot read what tmux already dropped.

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.