← Rallyteıs
WorkStart a project

Why we stopped using tmux for AI coding agents

Agency, our app for running coding agents in parallel, is out. The most expensive week of building it was spent learning that we had picked the wrong seam: we were forwarding a terminal stream when we should have been owning the screen.

We stopped using tmux to run AI coding agents in Agency because tmux keeps each agent’s screen to itself, and an app sitting on top of it has no clean way to get that screen back. Driving tmux from underneath a desktop app gave us two bugs that no patch fixed: closing a view killed the agent inside it, and switching back to a running agent showed a blank pane. After eight days we replaced it with a small background program that owns every terminal and keeps each agent’s screen in memory, so coming back to an agent just means sending what it already holds.

Agency is our free Mac app for running coding agents like Claude Code and Codex side by side, each in its own git worktree (a separate copy of the project on its own branch). If you only want to run a few agents yourself, tmux is still a good tool for it, and our step-by-step guide to running agents in parallel covers that. This post is the build log, for anyone writing tooling of their own.

Pane is dead (status 0).

Close a view in Agency, come back, and the agent that had been working inside it was gone. Not paused. Dead, halfway through whatever it had been doing, with the branch left where it fell.

Agency runs the coding agent CLIs you already use, several at a time, each in its own git worktree on its own branch. The whole promise is that you can set six of them going and walk away. An app that quietly kills a run when you look at something else does not have a product in it.

tmux was the obvious answer

Durable sessions, PTYs that outlive the thing watching them, scrollback, resize, a program that keeps drawing when nobody is attached. Every one of those is a problem terminal multiplexers solved decades ago, and we were not going to be the people who solved them again worse.

So every run got its own tmux session on a private socket. The app attached to a session by spawning tmux attach-session inside a PTY and forwarding the raw stream up to xterm.js in the UI. Capture was capture-pane -p. Input was send-keys. Status was list-panes -F '#{pane_dead} #{pane_dead_status}'.

It worked on the first afternoon, which is the trap.

Every bug was the same bug

Two symptoms kept coming back.

The first was the dead pane. Killing the attach client abruptly sent EOF to the pane’s shell, so tearing down a view tore down the run inside it. We shipped a graceful detach on 23 June. It mitigated the symptom and did not cure it.

The second was worse: no repaint on reattach. Switch away from a running agent and back, and you got an empty pane until the program inside it next decided to draw something. For a coding agent sitting at a prompt waiting on you, that can be a long time. The run was fine. The window was a lie.

Both symptoms lived in the same place: the attach seam. We were driving a terminal client over a PTY and forwarding bytes, which means the screen state lived inside a process that had no interest in handing it to us.

We rewrote it in control mode, then deleted that too

tmux has a control mode that gives you structured output instead of a raw stream. That looked like the fix. Over 25 June we built a streaming parser for control-mode output, an octal un-escape for it, a control client, and put the whole thing behind an AGENCY_TMUX_CONTROL flag.

Then we found the thing nobody writes down until you are standing in it: control mode does not repaint the screen on attach either. There is no clean way to reconstruct it from the outside. We had built a more sophisticated way to ask the same question and got the same answer.

Three days of work, reverted. The mistake was not the rewrite, it was the framing. We spent two weeks trying to get screen state out of a process that was never going to give it up, when the actual move was to stop asking.

Own the state, not the stream

On 28 June we specced the replacement and had it merged by the 30th. agency-termd is a small daemon that owns every PTY and runs a headless terminal emulator over it — alacritty_terminal, pinned at =0.26.0 — and holds the screen grid and scrollback as the source of truth. The app is a client over a Unix socket in Application Support.

Reattach stopped being a repaint problem because it stopped being a question: attach now means send me the grid you are already holding, then stream the deltas. The dead pane stopped happening because there is no attach client left to kill.

OWN       the screen state
SERVE     a snapshot, then deltas
NEVER     a raw stream across an attach seam

It is not tmux rebuilt. tmux is excellent at being tmux; what it gets wrong for this job is the wire format, and the wire format is the one part we needed to own.

The daemon also settled a requirement we had been carrying separately. Agents are long-running and autonomous, so a run has to survive the app quitting or crashing. That rules out an in-process emulator on its own, because the PTYs would be children of the GUI and die with it. Whatever owns work that outlives a window cannot live inside that window.

An agent in focus view in Agency: a coding agent working an issue in its own worktree, other runs listed on the left, and the diff and commit history on the right
One run, full size. The rail on the left is everything else still going.

The other thing we got wrong

Eight commits authored by an agent reached main through merged PRs before anyone noticed.

Not the messages. Every message was clean, because a commit-msg hook checks them and rejects any AI attribution. The problem was the author and committer fields, which a commit-msg hook is never passed. The hook could not have caught it, and we had assumed it did.

The fix is a job that runs on every pull request, over the commits in the range rather than the merge result:

git log --format='%H|%an <%ae>|%cn <%ce>' "$BASE..$HEAD"

Match on the email, never the display name. Claude is an ordinary French given name, and a guard that rejects a contributor called Claude Dupont is worse than the thing it was guarding against. Use %ae/%ce and not %aE/%cE, so a .mailmap cannot launder a bad identity into a passing check.

A hook runs where the commit was made. CI runs where it lands. If a rule matters, enforce it where it lands.

What eleven weeks of this looks like

788 commits since 18 June. 163 of them merges from agent/* branches the app cut for itself. 167 issues in its own markdown tracker. Roughly 99,000 lines of Rust and TypeScript. The first branch Agency cut for Agency merged on 7 July, nineteen days after the first commit.

Those are commit counts, not quality, and the distinction is the whole point. Every one of those branches was read as a diff before it merged, by a person, in the review panel. That step does not parallelise, which is exactly why it got as much attention in the build as the run grid did. Six agents produce six branches; they do not produce six times the judgement.

Source control in Agency: a side-by-side diff open for review, a commit form for the agent's branch, and the branch history graph
Per-run diffs. You review one agent's work at a time.

Rules for running more than one agent

If you are building something in this shape, or just running several agents by hand, these are the ones that cost us something to learn.

Isolation is a git feature, not an app feature. One worktree per run, one branch per run. Two agents in one working tree means separating their work by hand afterwards, and you will get it wrong at least once.

Never wrap the CLI. Spawn it in a real PTY and let it draw. Agent CLIs change every few weeks; anything that reimplements one is permanently a version behind, and you lose the TUI, the interrupt and the ability to just type at it.

If an agent should manage state, store the state as files. Our issue tracker is markdown under .agency/issues/. An agent can pick an issue up, move it, comment on it and file a follow-up, because every one of those is an edit. A tracker behind an API is a tracker your agent needs a tool call and a token for.

Push live state into the context at dispatch. Telling an agent to go and read a file is not the same as the agent knowing what is in it. Anything that matters gets injected with its facts already in it.

Exclude anything you write into someone’s worktree from git, or it turns up in every diff and every PR they open for the rest of the project.

The Issues tab in Agency: a board of markdown issues grouped by backlog, todo and in progress, with one issue open and an agent assigned to it
The tracker is a folder of markdown files in your repo.

It is live

Agency is out today: macOS 11 or later on Apple Silicon, free, Apache-2.0. No account, no telemetry, no backend of ours. The only network call it makes on its own is a version check against GitHub’s public API, and that is a toggle in Settings. It is pre-1.0 and in beta, and Linux and Windows are planned rather than done.

getagency.dev has the download, and the case study has the rest of the build. If you’re still choosing a tool, our comparison of apps for running agents in parallel puts Agency next to the alternatives.

FAQ

Is tmux a good way to run Claude Code or other coding agents? For a person at a keyboard, yes. It keeps agents running after you close the terminal and splits the screen so you can watch several at once, and as of October 2026 both Claude Squad and Claude Code’s own agent teams use it. It’s the wrong layer when an app has to draw those terminals itself.

Why is my tmux pane blank after reattaching? Attaching through a terminal client only forwards what the program draws from then on. An agent sitting at a prompt waiting for you draws nothing, so the pane stays empty until it does. Something has to hold the current screen and send it first.

Does tmux control mode (tmux -CC) fix the blank pane? Not on its own. Control mode gives you structured output instead of a raw byte stream, but the tmux wiki describes its %output messages as new output only and leaves catching up to the client, for example with capture-pane. We built it and got the same blank pane in a tidier format.

What does “Pane is dead (status 0)” mean? The program inside the pane exited with code 0, which normally means it finished cleanly. In our case the agent hadn’t finished: closing the attach client sent end-of-input to the pane’s shell, so closing a view ended the run.

How do you keep an agent running after the app quits, without tmux? Run the terminals in a separate background process, not inside the app. Ours owns every PTY (the pseudo-terminal a command-line program runs in) and runs a headless terminal emulator over each one, so it always knows what every screen looks like. The app connects over a local socket and can quit or crash without taking a run with it.

We build apps like this at Tennnis. If you have one that needs building, say hello.

Rally, by email

Just the good stuff. No spam.