agents
Stop babysitting your AI coding agent
A long agent run ends in four distinct ways and only one is success — why exit codes can't tell them apart, and what reading the pane gets you instead.
A coding agent you've walked away from can be in exactly four states: it finished, it's waiting
on you, it's stuck, or it died. Only the first one is success, and a process exit code can only
reliably tell you about the last one. The other two — waiting and stuck — look identical to a
script that only checks $?, which is why "just check if it's still running" is not a monitoring
strategy. It's a coin flip you've dressed up as automation.
Babysitting is a symptom, not a habit
Nobody sits and watches an agent by choice. You tab back every ninety seconds because the last three times you didn't, you came back forty minutes later to a session that had been asking "proceed? (y/n)" since minute four. The behavior isn't paranoia, it's a rational response to a monitoring gap. The fix isn't discipline. It's making the four outcomes distinguishable from outside the terminal, so checking back is a choice again instead of insurance.
The four states, and why three of them get merged into one
Treat "still running" as a single bucket and you've thrown away the distinction that matters most: a run that's making progress and a run that's been dead in the water since you left.
Done — Migration script finished: 40k rows. An idle prompt returned, or the wrapped process
exited 0. This is the only one of the four that means you can stop looking.
Blocked — Overwrite src/config.ts? (y/n). Not running, not exited, not failed. A process
sitting at a prompt has done none of those three things: it is doing exactly what it was told to
do, which was ask you first, and it will keep doing it all afternoon if you let it.
Stalled — No output for 11 minutes in build:2. No exit, no prompt, no error. Usually a hung
install, a rate limit with no backoff message, or a lost network connection the process hasn't
noticed yet.
Failed — Test suite exited 1: 3 failures. The one state a naive exit-code check actually
catches, which is exactly why it's tempting to build your whole monitoring approach around it and
stop there.
All four of those could be the same run, fourteen minutes after you looked away, and from the outside — a terminal window that hasn't scrolled since — they can look identical.
Why $? isn't enough
Checking a process's exit code answers one question: did the process terminate, and with what code. That's a real and useful signal, and it's also the smallest part of the problem. Two of the four states above involve a process that has not terminated at all:
- Blocked — the process is alive, in a foreground read, waiting on stdin. It will sit there
as long as you let it.
$?doesn't exist yet because nothing has exited. - Stalled — also alive, but not obviously waiting on anything. No prompt, no output, no exit. This is the state that best fools a person glancing at a terminal, because it looks exactly like slow, real work.
A script that polls wait $PID; echo $? will sit there right alongside you, uninformed, for as
long as the process refuses to exit. It's a perfectly correct check for the thing it checks. It
just isn't the thing you actually needed to know.
Forty minutes later, that shell is exactly as informative as it was at minute one. No exit code has been produced, because none of the four things that could happen — finish, need you, stall, die — has resolved into an exit yet. Two of the four never will, on their own.
Detecting the other three
If exit codes only speak to one outcome, the other three need to be inferred from what the pane is actually doing:
- Done is an idle shell prompt returning, or a wrapped command exiting 0 — the case where the exit code genuinely is the answer.
- Blocked is a known prompt pattern on screen —
(y/n), "Allow command?", "approve" — paired with no new output since it appeared. - Stalled is silence: no new bytes written to the pane for longer than the run's own normal
cadence, with no exit and no matched prompt. tmux already implements exactly this check —
monitor-silenceraises an alert when a window produces nothing for N seconds, andset-hook -g alert-silencegives you somewhere to hang a command; both are in the tmux manual. - Failed is a non-zero exit, a pane whose process died, or output matching a known error signature before anything exited.
That's the logic behind the hook and polling approaches in
Claude Code notifications and
Codex CLI notifications — both tools give you partial coverage
of this natively, and both leave gaps you have to fill by watching the pane's actual text, not
just its exit status. Coding agent notifications does the same
audit across the rest of the field, and tmux notifications covers the
layer underneath both — monitor-activity, monitor-silence and set-hook, which fire on the
pane rather than on the agent and therefore catch the stalled case neither tool reports.
| State | What it looks like | What $? shows | What's actually needed |
|---|---|---|---|
| Done | idle prompt, or exit 0 | 0 — accurate | nothing, or a glance at the diff |
| Blocked | y/n prompt, no new output | nothing — hasn't exited | an answer, now |
| Stalled | silence past the normal cadence | nothing — hasn't exited | investigation, maybe a kill |
| Failed | non-zero exit, or dead pane | non-zero — accurate | the last N lines, and a decision |
The actual cost of merging these into one bucket
Treat blocked and stalled as the same "still going, check later" state and the cost isn't symmetric. A blocked run is cheap to unblock — you're one keystroke away from unsticking forty minutes of dead time — but only if you know it's blocked now, not when you happen to look. A stalled run is a different problem: something is actually wrong, and the fix is investigation, not a keystroke. Conflating them means you either interrupt a genuinely fine long-running process to check, or you leave a one-keystroke fix sitting for an hour because you assumed it was still working.
This is also why a single "it's done" notification undersells what a monitoring setup should tell you. "Done" needs to distinguish from "failed" for you to trust it without checking — and both need to be distinguishable from "still going," which itself splits into "fine, just slow" and "stuck, needs you." Four states, not two.
What to actually build or use
You have three honest options, in increasing order of effort. Wire your agent's native hooks —
Claude Code's Stop/Notification, Codex CLI's notify — and accept the gaps each one has.
Write a pane-polling script that greps for prompt text and silence — tmux capture-pane -p is the
primitive, and tmux commands has the scripting subset — then pipe the result
into a pager like ntfy and maintain the whole thing as your agents' UIs change.
Or make looking cheap enough that you stop needing a signal at all: mtmux serves the tmux panes
your agents already run in to a browser on any device you've paired, over an end-to-end encrypted
tunnel with nothing to port-forward, so telling the four apart is a glance at the real pane rather
than an inference from a banner. The agents page covers that workflow, the session
handling is documented at
docs.mtmux.com/web-app/sessions, and the
comparison page is honest about which option is less machinery for which use case.
None of the three sends you a notification on its own — the first two hand off to a separate
notifier, and the third is pull, not push.
The point isn't the specific tool. It's that "still running" was never a real answer to "is everything okay" — and that reading the pane is still the only thing that separates all four, so what's worth shortening is how long it takes you to get in front of one.
Why can't I just check if the process is still running?
Because "still running" covers three different situations — working normally, waiting on a prompt, and stalled with no progress — and they need three different responses. A liveness check alone can't distinguish them.
What's the difference between a blocked and a stalled agent?
Blocked means the agent is waiting on a specific answer from you — a permission prompt, a y/n — and will resume the instant you respond. Stalled means no new output and no exit, with nothing obviously waiting on you; it usually means something is actually wrong.
Do exit codes catch failures reliably?
Yes, for the failures that involve exiting — a non-zero return code is a solid signal. They catch nothing for a hung or blocked process, because nothing has exited yet.
How long should I wait before calling a quiet agent 'stalled' instead of 'thinking'?
It depends on the tool and the task — a long test suite and a chat response have very different normal cadences. Most setups use a fixed idle threshold (a few minutes) as a starting point and tune it per project once they see a few false positives.
Does this apply to non-AI long-running scripts too?
Yes — the four-state framing is really about any unattended terminal process, agent or not. A database migration or a long build can sit blocked on a prompt or stall on a network hiccup in exactly the same way.
Put this to work — mtmux attaches to the tmux server you already run.