Your Agent's Status Is a Screenshot: capture-pane Is Not an API

There is a specific moment when tmux stops being a tool and becomes an architecture. You have four sessions named after repos. tmux ls is your inventory. Then someone asks the obvious question — which one is waiting on me? — and you write your first scrape: tmux capture-pane -p -t api:auth -S -50, piped into grep for the word "proceed."
That grep is now load-bearing. It decides whether a human gets interrupted, whether a build keeps moving, whether a destructive command runs. And it is reading a grid of characters off a fake television screen.
If you orchestrate coding agents in tmux, this is the layer nobody designs. The tmux-plus-agents pattern is genuinely good advice: the tmux server owns the pty, so sessions survive a closed laptop and a dropped SSH link; you get many named agents in one window; and any shell anywhere can type into a running agent or read its screen without you building an IPC layer. That third property — scriptable from outside — is exactly why tmux keeps appearing in orchestration scripts. It is also the property that will eventually page you at 2am.
A screen is a rendering, not a record
capture-pane does not return what your agent said. It returns what the pane looks like right now, as a grid of characters. Everything between the process and that grid is lossy in ways that matter.
- Wrapping. A long prompt wraps at the current pane width. Grep for a phrase that straddles the wrap and you get nothing. Resize the pane and your answer changes. Your detector's accuracy now depends on a terminal window's dimensions.
- Scrollback. tmux keeps a bounded history per pane — 2,000 lines by default. A chatty agent produces that in minutes. The evidence you wanted may already be gone, and the absence looks identical to the event never having happened.
- Overwrites. Spinners, progress bars, and status lines redraw in place. The intermediate line you were hoping to match was overwritten before your poller ran. You are trying to read a message that existed for 200 milliseconds.
- Ambiguity. A cursor sitting at a prompt and a cursor sitting under a thinking spinner are, in a still capture, the same picture. One means "blocked on a human." The other means "busy." Those need opposite responses.
The question you actually care about is not "what did it print?" It is "is it blocked on me?" That question has no reliable answer in the pixels, because the pixels are a downstream rendering of state that the agent already knows perfectly well.
Concretely: two agents, both silent for ninety seconds. One is compiling a large project. One is politely asking permission to run something destructive. If your rule is "no output for ninety seconds means stuck," you get two false alarms. If your rule is "output ends in a question mark," you get zero detections, because the prompt might end in a colon, a box-drawing character, or a line that wrapped.
The substrate moves under you without warning
The Claude Code docs ship a flag that looks like the whole answer: claude --worktree --tmux creates a git worktree and a tmux session in one command. Two footnotes matter enormously. First, --tmux errors without --worktree, so it is a worktree feature that happens to make a session, not a general session flag. Second, and worse: on iTerm2 it uses native panes instead of tmux — which means, in the docs' own words, the tmux commands have nothing to attach to.
Read that again from an orchestrator's point of view. You pass a flag documented as "create a tmux session." On the most popular Mac terminal, you get a different substrate. tmux ls shows nothing. Your agent inventory comes back empty, and an empty inventory is indistinguishable from "every agent finished." A silent zero is the most dangerous output a monitoring system can produce.
Smaller trap, same category: tmux session names cannot contain a dot or a colon, because tmux uses them as target separators. Name a session after a repo called api.v2 and your script either fails or addresses the wrong target. Boring. Guaranteed to bite.
Invert it: the process that knows should publish
The fix is not a better regex. It is moving the source of truth from the screen to the process.
Give every agent a small state file at a predictable path — one JSON object, four or five fields: session name, current task, state, and the timestamp of the last update. When the agent is blocked, it records the exact question. When it finishes, it records the exit code.
Write it atomically. Write the full file to a temporary name in the same directory, then rename it over the old one. Rename is atomic on the same filesystem, so a reader never observes a half-written document. If you instead open the file and truncate it in place, your poller will eventually read zero bytes at exactly the wrong moment and conclude the agent died. It did not die. It was mid-write. You built a race condition into your monitoring.
Now your orchestrator reads files, and capture-pane goes back to being what it should be: something a human runs when they want to look at an agent. The view stays. The dependency goes away.
Keep the state machine small enough to be true
Four states, each with a derivation you can defend:
- done. The process exited with code 0. This is the only signal that is free, exact, and impossible to fake. Use it as the backbone of everything.
- failed. Non-zero exit. Have the wrapper capture the last twenty lines of stderr into a file. You want the failure text as data, not as a scrollback you hope is still there.
- needs_input. A marker the agent emits, either through a hook or by writing the state file immediately before it asks. This is the state that pays for the whole system, because it is the one your grep was trying to detect and could not.
- working. A heartbeat. The agent touches the file every N seconds. Three missed heartbeats means stalled — and stalled is an alert, not a status.
Add one more: unknown. If no file exists, or it is older than your threshold, say unknown rather than guessing "probably still going." Unknown is cheap and honest. A wrong "working" is expensive and invisible.
The trade-off is real. Heartbeats mean one more line of code inside a loop you did not write, and state files mean cleanup, permissions, and a directory that grows if you forget about it. But a heartbeat is a fact about the process. A screenshot is an interpretation of a rendering. When the cost of being wrong is a person's afternoon, pay the line of code.
When scraping is actually fine
Be honest about the boundary. Scrape when a human is watching the output anyway and the scrape just saves them a keystroke. Scrape in a throwaway script whose worst-case failure is that you run it again. Scrape a tool that offers no other interface, where the alternative is nothing at all.
Do not scrape when the result triggers a decision. If the answer pages someone, spends money, merges a branch, or tells a manager that a task is on track, it needs a source of truth a rendering can't contradict. "The pane looked busy" is not an audit trail.
The expensive failure is silence that looks like progress
A crash announces itself. The failure that costs you days is the agent that stopped, in a session that still exists, with a pane that still shows a cursor. Your poller checks every thirty seconds, your staleness threshold is five minutes, and the agent quietly died at 9:05. At 9:10 you find out. In between you answered three emails, started a second task, and told someone the build was running.
Multiply that by four parallel agents and you have manufactured a scheduling problem out of a monitoring gap. The throughput was never the issue. The visibility was.
tmux is an excellent place to run agents. It is a terrible place to ask them questions. Let the process answer, let the file record it, and let the terminal do the one thing it has always done well: show a human what is happening.
Key Takeaways
- capture-pane returns a rendering. Wrapping, bounded scrollback, and in-place redraws mean the text you grep for may never exist in the capture, or may already be gone.
- The state you need is "blocked on a human," and it isn't in the pixels. A waiting prompt and a thinking spinner look identical in a still frame.
- Your substrate can change under a documented flag. claude --tmux falls back to iTerm2 native panes, leaving tmux scripts attached to nothing — and an empty agent list reads as "all done."
- Publish state from the process. One JSON file per agent, written atomically via temp-file-plus-rename, so readers never see a truncated document.
- Four states plus unknown. Exit code for done/failed, an explicit marker for needs_input, a heartbeat for working, and an honest "unknown" when the file is missing or stale.
- Draw the line at consequences. Scraping is fine for a human eyeballing a screen. It is not fine when the result pages someone, spends money, or gates a merge.