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.
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//a
dev server in a pane, owns the terminal until it exits. That's why you
drive interactive tools with rather than piping.
python
it
send-keys
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:
# Create a detached session, print its IDs in one shot
tmux new-session -d -s work -P -F '#{session_name} #{window_id} #{pane_id}'# -> work @0 %0# Split the current pane, print the NEW pane's id
tmux split-window -h -P -F '#{pane_id}'# -> %3
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"# you are here, e.g. %2
MYWIN=$(tmux display-message -p -t "$MYPANE"'#{window_id}') # your window, e.g. @0
MYSESSION=$(tmux display-message -p -t "$MYPANE"'#{session_name}') # your session, e.g. work
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:
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?".
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}') # split next to ME
tmux set-option -p -t "$NEW"'@owned_by' agent # mark it mine (NOTE the -p)# Later, decide if a pane is safe to reuse or kill:
owner=$(tmux display-message -p -t "$NEW"'#{@owned_by}') # 'agent' = yours, '' = user's
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:
# 1. orient — who am I?
MYPANE="$TMUX_PANE"
MYWIN=$(tmux display-message -p -t "$MYPANE"'#{window_id}')
# 2. open a pane next to me (relative to MYPANE), remember + mark it
WORK=$(tmux split-window -v -t "$MYPANE" -P -F '#{pane_id}')
tmux set-option -p -t "$WORK"'@owned_by' agent
# 3. run the CLI in MY pane (not the user's), appending an EXIT SENTINEL —# the completion signal AND the exit code in one line of output.
tmux send-keys -t "$WORK"'npm run build 2>&1; echo "AGENT_DONE:$?"' Enter
# 4. wait for the sentinel. Match it ANCHORED WITH DIGITS: the pane also shows# the command line you just typed, which contains the literal text# AGENT_DONE:$? — requiring ^...:[0-9]+$ means only real output (where $?# expanded to a number) can match. -J joins wrapped lines so a wrapped# command line can't put the literal at column 0 either.
rc=""for i in $(seq 1 600); do# bound the wait: 600 x 0.5s = 5 min
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
doneecho"exit code: ${rc:-timed-out}"# 5. read what it produced
tmux capture-pane -t "$WORK" -p -S -2000
# 6. done with it — kill only the pane I made (the user's panes are untouched)
tmux kill-pane -t "$WORK"
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)
Send input to a running thing (the pane's foreground process receives it):
tmux send-keys -t %3 'echo hello' Enter # type text + press Return
tmux send-keys -t %3 C-c # Ctrl-C
tmux send-keys -t %3 q # a single 'q' (e.g. quit a pager)
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 # visible screen as plain text
tmux capture-pane -t %3 -p -S -200 # include 200 lines of scrollback
tmux capture-pane -t %3 -p -e # keep ANSI color escape codes
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.
# Start an isolated server + detached session in one go.# -f /dev/null is what actually keeps it clean (see the warning below).
tmux -L agentbox -f /dev/null new-session -d -s job -P -F '#{pane_id}'# -> %0 (this %0 lives ONLY on the agentbox socket)
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 # only the sessions YOU created# Tear the whole thing down when done — frees every pane and the server
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}') # make a pane, remember its id
tmux send-keys -t "$P"'make build 2>&1' Enter
# …do other work, then read whenever…
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' && breaksleep 1
done
Detect a pane finished / went idle:pane_current_command returns to the
shell name (bash/zsh) when the foreground command exits:
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 onbefore 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 -F '#{...}' 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.