shedix

tmux for coding agents: the commands that matter

A person uses tmux by attaching to a session and watching it. An agent mostly can't. It runs one command at a time over SSH with no terminal attached, its commands time out, and it may run a command again when the first attempt looked stuck. The tmux commands that matter for agents are the ones that hold up under those conditions.

Our posts on closing the laptop and git worktrees cover starting, detaching and reattaching. This one picks up there, with the patterns from shedix's guide for agents. We ran each example below on tmux 3.4, the version Ubuntu 24.04 installs.

Start it only if it isn't running

A start command an agent might repeat has to be safe to run twice. tmux new -d refuses a name that's taken and exits with duplicate session: api. tmux new -A, which attaches when the session exists, suits a person and not a script: without a terminal it fails with open terminal failed: not a terminal.

Ask first instead. The tmux manual says has-session exits with 0 when the session exists and 1 when it doesn't:

tmux has-session -t api 2>/dev/null || tmux new -d -s api -c ~/work/app/api 'npm run dev > ~/work/api.log 2>&1'

Run it twice and you still have one server. This matters most after a timeout. Claude Code waits two minutes for a foreground command by default, then moves it to the background rather than stopping it, so the start it gave up on is probably still going. Check tmux ls before starting anything again.

One session per service, and its output in a file

When the command in a session exits, the session closes, and whatever it printed goes with it. A dev server that crashed on start leaves nothing to read. So give each service its own session, named after it, and send its output to a file:

tmux has-session -t api 2>/dev/null || tmux new -d -s api "cd ~/work/app/api && (npm ci && npm run dev) > ~/work/api.log 2>&1"
tmux has-session -t web 2>/dev/null || tmux new -d -s web "cd ~/work/app/web && (npm ci && npm run dev) > ~/work/web.log 2>&1"

To restart one service, end its session with tmux kill-session -t api and run its line again. The others keep running.

For a job that finishes, write down how it ended, since nobody will be watching:

tmux new -d -s tests "cd ~/work/app && npm test > ~/work/tests.log 2>&1; echo \"exit \$?\" >> ~/work/tests.log"

tail -n 30 ~/work/tests.log then shows the end of the run and its exit code. To keep the screen itself instead, tmux set -g remain-on-exit on leaves a pane open, marked dead, after its program exits.

Two more habits from the guide:

  • Close stdin on installs. An install that asks a question waits forever in a detached session. Add < /dev/null and it fails at once instead.
  • Run servers in tmux, not as the agent's background tasks. Claude Code's docs say its background tasks are cleaned up when it exits. A server in its own tmux session outlives the agent, your SSH connection and the next agent.

The environment comes from the server

The manual explains that a tmux server copies the environment once, when it starts, and new sessions get that copy rather than the environment of the shell that created them. Only the few variables in the update-environment option, such as SSH_AUTH_SOCK and DISPLAY, are refreshed. Try it where no tmux server is running yet:

FOO=first tmux new -d -s one 'sleep 60'
FOO=second tmux new -d -s two 'echo "FOO=$FOO" > ~/two.txt; sleep 5'
cat ~/two.txt

That prints FOO=first. So export DATABASE_URL=… followed by tmux new -d … works on a fresh server and quietly does nothing on a running one. Pass the variable with -e DATABASE_URL="$DATABASE_URL", or keep it in the .env file the app reads.

The command also runs as your shell's -c, not as a login shell. In our test it read neither .bashrc nor .profile, so whatever those set up, such as a version manager's PATH, may be missing. Wrap the command in bash -lc '…' or use full paths. tmux won't activate a Python virtual environment either, so start the app with uv run … or .venv/bin/….

Wait for the port, or for the job

A server that has started isn't necessarily ready. Wait for its port, and check its session on every pass, so a service that dies stops the wait at once, with its log, instead of running out the clock. This is the guide's loop for two services:

end=$((SECONDS+600))
for sp in api:4000 web:3000; do
  s=${sp%%:*} p=${sp##*:}
  until ss -Hltn "sport = :$p" | grep -q .; do
    tmux has-session -t "$s" 2>/dev/null || { echo "$s stopped"; tail -n 40 ~/work/$s.log; exit 1; }
    [ $SECONDS -lt $end ] || { echo "$s: port $p never opened"; tail -n 40 ~/work/$s.log; exit 1; }
    sleep 2
  done
done
echo "all ports open"

ss exits with 0 whether or not a port matches, which is why its output goes through grep -q .. An open port only means something is listening, so finish with a real request to the app.

For a job, tmux can do the waiting. wait-for blocks until another command signals the same channel:

tmux new -d -s tests "cd ~/work/app && npm test > ~/work/tests.log 2>&1; echo \"exit \$?\" >> ~/work/tests.log; tmux wait-for -S tests-done"
timeout 110 tmux wait-for tests-done; tail -n 20 ~/work/tests.log

Put ; before the signal, not &&, or a failing run never signals. If the job finishes before anyone waits, the next wait-for returns at once, but only one does. The timeout keeps each wait inside Claude Code's two-minute default, and a wait cut off that way can be run again: it still gets the signal.

Read a session without attaching

Services write logs, but an agent's own screen doesn't. capture-pane -p prints a pane to standard output:

tmux capture-pane -p -t claude
tmux capture-pane -p -J -S -200 -t claude

The first prints what's on screen. The second adds 200 lines of history (-S - starts at the very beginning) and joins wrapped lines. A session started with -d is 80 columns wide until someone attaches, so long lines wrap: in our test, a 138-character error came back as two lines without -J and as one with it, which is what you want before a grep.

Typing into a session uses send-keys, which has two traps. An argument that is a key name, such as Enter or Escape, is sent as that key unless you add -l. And a semicolon at the end of an argument ends the tmux command, even with -l, so const a = 1; arrives without it unless you write \;. Send the text and the Enter key separately:

tmux send-keys -t claude -l 'Run the tests again and fix what fails'
tmux send-keys -t claude Enter

crystl, who make a terminal app for Claude Code, cover typing into agents in more depth in their tmux guide, including a short pause before the Enter. To know when an agent is waiting for you without reading its screen, Manuel Schipper uses a Claude Code hook that rings the terminal bell when the agent goes idle, and has tmux highlight that window.

Three settings worth changing

Put these in ~/.tmux.conf before you start sessions:

set -sg escape-time 0
set -g history-limit 50000
set -g mouse on
  • escape-time: after an Esc, tmux waits to see whether more keys follow. tmux 3.4 waits 500 ms, and 3.5 cut the default to 10 ms. Esc is how you interrupt Claude Code, so half a second is noticeable.
  • history-limit: the default is 2,000 lines, a short stretch of agent output. In 3.4 it applies only to windows created after the change.
  • mouse: the wheel or trackpad scrolls back through history. Without it, Ctrl-b [ enters copy mode and q leaves it.

Claude Code's Ctrl+B, which backgrounds a running command, is also tmux's prefix, so inside tmux you press it twice.

After a reboot

tmux keeps sessions through lost connections, not through restarts. The server is an ordinary process: when the machine restarts, every session and everything running in it is gone. Files stay, logs included.

That's when guarded starts pay off: run the same start commands again, and each service comes back. Claude Code keeps conversations on disk, so claude --continue in the same folder reopens the latest one (sessions docs).

How shedix uses this

A shedix machine that goes unused for an hour falls asleep, and sleep is a stop: for tmux, a reboot. Turn on Keep awake for a long job nobody is watching.

The Terminal, Claude Code and Codex tabs on a machine's page are tmux sessions, opened with tmux new -A. When an agent starts Claude Code in a session called claude, as the guide's hand-off does, the Claude Code tab opens that session from a browser or your phone. The machines come with the three settings above, and the agents' guide is built on these patterns: with it, two fresh agents each had a project running in about three minutes.