| name | tmux-control |
| description | Drive tmux from the command line to manage terminal sessions, windows, and panes — split the current pane, run a command in a new pane, capture output, send keystrokes, resize/navigate panes, or spin up a fully isolated background tmux server for sandboxed work. Use this whenever you need to run something in a terminal pane beside your work, watch a long-running process (dev server, build, test watcher, log tail), drive an interactive CLI/REPL, set up a multi-pane layout, or run commands in a tmux server that stays invisible to the user's own `tmux ls`. Trigger on any mention of tmux, splitting/managing panes or windows, "run this in a pane", "watch the output", side-by-side terminals, or background/isolated terminal sessions — even when tmux isn't named explicitly but the task clearly needs terminal-pane control. |
tmux control for agents
tmux lets you place commands in terminal panes you can read, drive, and tear
down programmatically — instead of blocking on a single shell. This skill is the
command vocabulary for doing that well as an agent.
The mental model (read this first)
tmux nests three things: a server owns sessions, a session owns
windows (full-screen tabs), a window owns panes (the split rectangles).
Everything is addressed by an ID: panes are %0 %1 …, windows @0 @1 …,
sessions by name. IDs are stable for the life of the object — capture them once
and reuse them; never parse them out of human-formatted output.
Two facts make tmux ideal for agents, and shape everything below:
- Sessions can be detached (
-d). A detached session runs with no terminal
attached — the process lives, you read/drive it over the CLI, and the user
need not see it. This is how you run work "in the background" without a job
control mess.
- The foreground process holds the pane. When you start
vim/python/a
dev server in a pane, it owns the terminal until it exits. That's why you
drive interactive tools with send-keys rather than piping.
The golden rule: capture IDs at creation
Every create command can print exactly the IDs you need with -P -F
(Print, Format). Always do this — it removes all guessing about which
pane you just made:
tmux new-session -d -s work -P -F '#{session_name} #{window_id} #{pane_id}'
tmux split-window -h -P -F '#{pane_id}'
Store the printed %id and address every later command with -t %id. This is
the single most important habit — pane indices shift when panes close, but IDs
never do.
Start here: you are inside a pane — orient first
You are not an outside operator typing at named panes. You are a process running
inside one specific pane right now, and almost everything you do is
relative to that pane — split next to it, look at its siblings, reuse a pane
you own. So step one, before any tmux action, is to find your own context.
Your anchor is $TMUX_PANE. tmux sets this environment variable for the
process running in a pane — it is your pane id. Capture it once and derive
your window and session from it. Address it explicitly with -t every time;
don't rely on tmux's no--t default, which resolves to the server's active
pane — not necessarily you.
[ -n "$TMUX" ] || echo "NOT in tmux — there is no current pane; create your own session instead"
MYPANE="$TMUX_PANE"
MYWIN=$(tmux display-message -p -t "$MYPANE" '#{window_id}')
MYSESSION=$(tmux display-message -p -t "$MYPANE" '#{session_name}')
Now everything is relative to $MYPANE / $MYWIN. Two distinct ways to work,
and you should consciously pick one:
- Relative / in-place — operate around your own pane in the user's session:
split next to
$MYPANE, survey $MYWIN, reuse a pane you own. The user sees
it. This is the default for "run this beside me / show me output."
- Isolated ("headless") — a separate disposable server of your own
(
-L sock -f /dev/null new-session), do the work, then kill-server. The user
never sees it. This is for sandboxed/throwaway jobs. (See "Isolated" below.)
Note "headless" here means a separate server you spin up and tear down, NOT a
split of your current window.
Edge case: if $TMUX_PANE ever seems wrong (rare — usually it's exactly your
pane), ground-truth it by PID: find the pane whose #{pane_pid} is an ancestor
of your shell's $$. A PID can't lie; env vars and active-flags occasionally
can. Use this only as a tiebreaker, not the default path.
Survey your surroundings before you act
With your anchor known, build a picture of what's around you. Acting blind is how
agents stomp on the user's panes or pile up duplicates.
What other panes share my window, and what is each doing? One
list-panes gives the whole picture — id, whether it's the active pane, the
foreground command, its PID, working dir, and size:
tmux list-panes -t "$MYWIN" -F \
'#{pane_id} active=#{pane_active} cmd=#{pane_current_command} pid=#{pane_pid} cwd=#{pane_current_path} #{pane_width}x#{pane_height}'
Is a given pane free, or busy with an app? tmux has no "idle" field —
infer it from #{pane_current_command}. If it's the shell (zsh, bash,
fish, sh…), the pane is at a prompt and free. Anything else (node,
vim, python, npm…) means an app holds it — busy, leave it alone. This
is how you answer "is the CLI running in that split still going, or done?".
cmd=$(tmux display-message -p -t %1 '#{pane_current_command}')
case "$cmd" in
zsh|bash|fish|sh|-zsh|-bash) echo "free (shell at prompt)";;
*) echo "busy: $cmd";;
esac
Caveat: this is a heuristic. A shell running a script (bash deploy.sh) shows
bash yet is busy; a pane could be at a shell prompt mid-way through your own
workflow. It's right the vast majority of the time — pair it with intent (did
you put something there?) for the rest.
Which pane is active (where the user is looking)? The one with
#{pane_active} == 1. Don't disrupt it without reason.
Did I create this pane, or is it the user's? tmux records no "creator",
so mark your own panes when you make them, using a pane-scoped user option
(-p is essential — without it the option is set session-wide and leaks to
every pane). Split relative to your own pane ($MYPANE) and mark the result:
NEW=$(tmux split-window -v -t "$MYPANE" -P -F '#{pane_id}')
tmux set-option -p -t "$NEW" '@owned_by' agent
owner=$(tmux display-message -p -t "$NEW" '#{@owned_by}')
This is the reliable answer to "is this mine to reuse/kill?" — far safer than
guessing from layout. Treat unmarked panes as the user's: read them if asked,
but don't resize, reuse, or kill them.
Putting it together — the safe-split decision. Before creating yet another
pane, check whether you already have a free one of your own to reuse: a pane you
marked @owned_by agent whose pane_current_command is a shell. If so, reuse
it; otherwise split a new one and mark it. This keeps a window from filling with
abandoned agent panes. (references/situational-awareness.md has a ready-made
function for this.)
The core relative workflow
The thing you do most: orient, open a working pane next to yourself, run a CLI in
it, watch whether it's still busy, read its output, then clean up. End to end:
MYPANE="$TMUX_PANE"
MYWIN=$(tmux display-message -p -t "$MYPANE" '#{window_id}')
WORK=$(tmux split-window -v -t "$MYPANE" -P -F '#{pane_id}')
tmux set-option -p -t "$WORK" '@owned_by' agent
tmux send-keys -t "$WORK" 'npm run build 2>&1; echo "AGENT_DONE:$?"' Enter
rc=""
for i in $(seq 1 600); do
line=$(tmux capture-pane -t "$WORK" -p -J | grep -oE '^AGENT_DONE:[0-9]+$' | tail -1)
[ -n "$line" ] && { rc="${line#AGENT_DONE:}"; break; }
sleep 0.5
done
echo "exit code: ${rc:-timed-out}"
tmux capture-pane -t -p -S -2000
tmux kill-pane -t
Every -t here is an explicit id you captured — never a bare default. You split
relative to where you are, you only drive and kill the pane you own, and the
sentinel gives you both "it finished" and the exit code in one robust signal.
Notes on the sentinel:
- Need the exit code of a piped command (
build 2>&1 | tee log)? $? would be
tee's. Use echo "AGENT_DONE:${pipestatus[1]:-${PIPESTATUS[0]}}" (zsh/bash).
- The foreground-command probe (
#{pane_current_command} — shell name means
idle) remains the right tool for the other question — "is that pane busy
right now?" during a survey. Use the sentinel to wait for completion of a
command you launched; use the probe to assess panes you didn't.
In-place: manage the pane/window you're in
These act on the current server (the one the user is attached to). Use them to
work beside the user.
| Goal | Command |
|---|
| Split current pane, right (vertical divider) | tmux split-window -h -P -F '#{pane_id}' |
| Split current pane, below (horizontal divider) | tmux split-window -v -P -F '#{pane_id}' |
| Split a specific pane | tmux split-window -h -t %2 -P -F '#{pane_id}' |
| Run a command in the new pane | tmux split-window -h 'npm run dev' (pane closes when cmd exits unless remain-on-exit) |
| New window (tab) | tmux new-window -n build -P -F '#{window_id} #{pane_id}' |
| List panes w/ size, command, path | tmux list-panes -t @0 -F '#{pane_id} #{pane_width}x#{pane_height} #{pane_current_command} #{pane_current_path}' |
| List windows | tmux list-windows -F '#{window_id} #{window_name} #{window_active}' |
| Resize a pane (absolute) | tmux resize-pane -t %3 -x 100 -y 30 |
| Resize a pane (relative) | tmux resize-pane -t %3 -R 10 (also -L -U -D) |
| Move focus between panes | tmux select-pane -t %3 (or -L/-R/-U/-D) |
| Even out a layout | tmux select-layout -t @0 tiled (also even-horizontal, main-vertical) |
| Rename window / session | tmux rename-window -t @0 logs · tmux rename-session -t work api |
| Kill one pane / window / session | tmux kill-pane -t %3 · tmux kill-window -t @1 · tmux kill-session -t work |
Send input to a running thing (the pane's foreground process receives it):
tmux send-keys -t %3 'echo hello' Enter
tmux send-keys -t %3 C-c
tmux send-keys -t %3 q
Note send-keys interprets key names (Enter, C-c, Tab, Escape). To
send text that might collide with a key name, pass -l (literal):
tmux send-keys -t %3 -l 'Enter' types the five letters, not Return.
Read what's on a pane (the workhorse for seeing output):
tmux capture-pane -t %3 -p
tmux capture-pane -t %3 -p -S -200
tmux capture-pane -t %3 -p -e
Isolated: a separate, invisible tmux server
When work should NOT touch the user's terminal — sandboxed commands, throwaway
background jobs, parallel agents — start a second tmux server on its own
socket with -L <name>, and load no user config with -f /dev/null on the
command that creates it. Every command targeting it must repeat the same -L.
tmux -L agentbox -f /dev/null new-session -d -s job -P -F '#{pane_id}'
tmux -L agentbox send-keys -t %0 'pytest -q' Enter
tmux -L agentbox capture-pane -t %0 -p -S -500
tmux -L agentbox list-sessions
tmux -L agentbox kill-server
The -L socket is the isolation boundary: forget it on one command and you'll
hit the user's real server (or "no server running"). Pick a memorable socket
name and reuse it. Always kill-server when finished so you don't leak
detached sessions.
Critical: -L alone does not guarantee an empty server. If the user
runs tmux-resurrect / tmux-continuum (very common), their ~/.tmux.conf
auto-restores all their saved sessions into any new server — so your "clean"
sandbox suddenly contains copies of their work, and list-sessions is
polluted. Passing -f /dev/null on the new-session skips their config
(and therefore the restore hooks), giving a genuinely empty, isolated server.
Verify with tmux -L sock list-sessions — you should see only what you made.
Patterns agents actually need
These compose the primitives above. For fuller treatments (readiness polling,
exit codes, REPL driving), read references/agent-patterns.md.
Run a command and collect its output without blocking a shell:
P=$(tmux split-window -v -P -F '#{pane_id}')
tmux send-keys -t "$P" 'make build 2>&1' Enter
tmux capture-pane -t "$P" -p -S -1000
Wait until a process is ready by polling its output for a marker (a dev
server printing "Listening on", a build printing "compiled"):
P=$(tmux -L agentbox -f /dev/null new-session -d -s dev -P -F '#{pane_id}')
tmux -L agentbox send-keys -t "$P" 'npm run dev' Enter
for i in $(seq 1 30); do
tmux -L agentbox capture-pane -t "$P" -p | grep -q 'Listening on' && break
sleep 1
done
Detect a pane finished / went idle: pane_current_command returns to the
shell name (bash/zsh) when the foreground command exits:
tmux list-panes -t %3 -F '#{pane_current_command}'
Gotchas that bite agents
- A pane closes when its command exits, discarding output. To inspect a
finished command's last screen, set
tmux set-option -t %3 remain-on-exit on
before it exits, or capture into a file: … 'make 2>&1 | tee /tmp/out.log'.
- No server yet? The first
new-session starts one. Targeting a socket with
no server ("no server running on …") just means nothing's there yet — create,
don't panic.
send-keys needs Enter as a separate argument to actually run a typed
command — send-keys 'ls' types ls but never presses Return.
- Prefer IDs over names/indices.
kill-pane -t 1 targets pane index 1
which shifts as panes close; kill-pane -t %5 is unambiguous forever. If you
must target a session by name, -t =name (leading =) forces an exact match
on tmux versions where bare names can prefix/fuzzy-match.
- "The first pane" is not necessarily
.0 — and .0 may not even error.
With pane-base-index 1 (common in user configs), panes are indexed 1,2,….
Verified on tmux 3.6a: -t sess:1.0 did NOT fail — it silently resolved to
the second pane (%53 when panes were 1:%52 2:%53). A wrong-but-valid
answer is worse than an error. Never assume index 0; get real ids from
list-panes -F '#{pane_id}' (or $TMUX_PANE for yourself).
- Sentinel greps can match the command you typed. The pane shows your typed
line (
…; echo "AGENT_DONE:$?") as well as its output (AGENT_DONE:0).
A naive grep AGENT_DONE fires on the typed line and reports completion
instantly. Defuse it by requiring the expanded exit code:
grep -E '^AGENT_DONE:[0-9]+$' — the typed line has a literal $?, never
digits — and capture with -J so a wrapped command line can't start a line
with the sentinel text.
- Don't chain tmux commands with
; in one invocation. In
tmux cmd1 \; cmd2 the escaped ; separates tmux commands, and quoting
layers (your shell → tmux → the pane's shell) get confusing fast. Run one tmux
command per invocation, and single-quote format strings so your
shell doesn't eat the braces.
Reference material
references/situational-awareness.md — knowing where you are: $TMUX_PANE,
surveying the window, free-vs-busy, pane ownership markers, and a ready-made
find-or-create-your-work-pane function.
references/command-reference.md — exhaustive flag/format-variable tables for
every command, including all #{...} format variables worth knowing.
references/agent-patterns.md — robust readiness polling, capturing exit
codes, driving REPLs/TUIs, running parallel isolated jobs, cleanup discipline.