Skip to content

agents

Stop babysitting your AI coding agent

A long agent run fails in four distinct ways and only one of them is success — here's why exit codes can't tell them apart, and what actually can.

Nadia Okonkwo7 min read

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.

done14m ago

Migration script finished: 40k rows

Idle prompt returned, or the wrapped process exited 0. This is the only one of the four that means you can stop looking.

blocked14m ago

Waiting: 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.

stalled14m ago

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.

failed14m ago

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.

Notice the timestamps: 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 — 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.

what $? sees vs. what actually happened
[object Object],[object Object]

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.
  • 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.

StateWhat it looks likeWhat $? showsWhat's actually needed
Doneidle prompt, or exit 00 — accuratenothing, or a glance at the diff
Blockedy/n prompt, no new outputnothing — hasn't exitedan answer, now
Stalledsilence past the normal cadencenothing — hasn't exitedinvestigation, maybe a kill
Failednon-zero exit, or dead panenon-zero — accuratethe 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, and maintain it as your agents' UIs change. Or use a tool built around exactly this state machine: mtmux watches the tmux panes your agents already run in and classifies each one into done, blocked, stalled or failed, with presets for Claude Code, Codex CLI, Gemini CLI and aider, and pushes the result to your phone. None of the three are wrong; they trade setup time for coverage differently, and the comparison page is honest about which is less machinery for which use case.

The point isn't the specific tool. It's that "still running" was never a real answer to "is everything okay," and once you can see the other three states, you stop needing to ask.

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.

Related reading