We stopped guessing what the agent was doing
Knowing whether an agent is working, done, or waiting for you is the foundation everything else sits on. We built it by reading the screen. Then the screens changed. Here's why we tore it out and asked the agents to tell us instead.
Everything depends on one bit
Is this agent busy or not? Almost every feature we've built rests on that answer. The status dot on the tab. The chime when a turn finishes. The activity dashboard. Auto-standby. Whether a peer message gets typed into a terminal now or parked until later. Get that bit wrong and the whole surface lies to you at once.
For a long time we answered it the only way available: by reading the terminal. The output parser knew each agent's chrome — the spinner glyphs, the composer row, the ready prompt — and inferred state from what was painted. It worked. It also broke every single time an agent shipped a redesign, and it broke silently.
Three ways screen-reading failed
These aren't hypotheticals. Each one is a bug we reproduced live, with a stopwatch.
Grok moved its composer inside a rounded box. Our ready-screen adapter matched a bare prompt character, which no current build emits any more. "Ready" never fired. We watched a tab report busy 132 seconds after the turn had visibly finished. The fixtures still encoded the older layout, so the test suite was green the entire time — the tests were asserting yesterday's Grok.
Grok also paints a scrollbar. Once output outgrows the viewport, a column appears down the right edge, and every otherwise-blank row gets trimmed to a single block glyph. Our spinner detection read that column as Aider's animated progress bar. The screen therefore never classified as ready, and the tab stayed busy for the rest of the process — observed on a finished turn still reporting busy two minutes later.
pi wasn't even visible. Its process path resolves to the Node interpreter, so classification saw node, there was no screen adapter at all, and a finished turn left the tab busy forever. Reproduced at 71.6 seconds with the composer already back on screen.
The pattern is the same in all three: an agent's UI is not an API. It has no compatibility contract, no deprecation window, and no reason to warn you. Every redesign is a silent breaking change, and your fixtures keep passing because they encode the version you last looked at.
Ask, don't infer
Modern coding agents have something better than a screen: a hook system. Claude Code, Gemini, Grok and Codex all let you register a shell command against lifecycle events. So we stopped inferring and started listening.
TUICommander installs a small hook into the agent's own settings file. When the agent starts a turn, finishes one, or blocks on a question, the hook writes a single escape sequence to the terminal:
\033]7770;state=busy\033\\
\033]7770;state=idle\033\\
\033]7770;state=awaiting\033\\
OSC 7770 is our own private sequence. TUICommander's terminal parses it out of the stream, the rest of the world ignores it. No screen parsing, no heuristics, no fixtures encoding someone else's layout. The agent says what it is doing, in one byte-cheap message, at the exact moment it changes.
The hook has to be a good guest
This hook runs inside someone else's tool, on every turn, on your machine. Three properties are non-negotiable, and each one is a line of the generated command:
It can never block the agent. The command always exits 0, with output redirected to /dev/null. If it fails, it fails invisibly and the agent carries on. A monitoring hook that can wedge the thing it monitors is worse than no hook.
It is inert outside TUICommander. The whole command is guarded on $TUIC_SESSION. Run the same agent in a plain terminal, or in another tool, and the hook does nothing at all — it doesn't spray escape sequences into a terminal that never asked for them.
It has to find the terminal. This one was the surprise. A hook has no controlling tty of its own, and its stdout is captured by the agent — so writing to stdout writes into the agent's own output stream, not to the screen. The hook resolves its parent's tty instead (ps -o tty= -p $PPID) and writes there directly.
Every command ends with a trailing # tuic-managed-hook comment. That sentinel is the entire ownership model.
Editing a file you don't own
Your settings.json is yours. It may already contain your own hooks, your team's hooks, hooks from other tooling. We are a guest in that file, and the installer is built around that:
- Ownership is only ever the sentinel. The installer prunes exclusively commands carrying
# tuic-managed-hook. Your entries are never read as ours, never reordered, never dropped. - One read-modify-write. Load, prune ours, append the canonical set, write once, atomically. Never a write on an error path — a settings file we can't parse gets reported, not corrupted.
- Idempotent. Installing twice equals installing once, because it's always prune-then-append.
- Clean uninstall. Removing our hooks drops the groups that became empty as a result, leaving the file exactly as if TUICommander had never touched it.
Settings also tells you the truth about the current state: not installed, installed, or outdated — the last one meaning our hooks are there but from an older version and worth reinstalling.
Every agent needs its own map
The escape sequence is universal; which events mean what is not. Each agent gets a hand-built map.
Claude Code is the richest. UserPromptSubmit and a broad PreToolUse drive busy, Stop drives idle, and Notification drives awaiting. There's one extra entry that matters: a PreToolUse matcher on AskUserQuestion, which flips the tab to awaiting the instant the agent decides to ask you something — before the question has finished rendering.
Codex is the sparse end. Its PreToolUse is Bash-only and not useful for this, and it has no session-end event at all. So the map is deliberately minimal: SessionStart and UserPromptSubmit for busy, Stop for idle, and the badge clears on idle rather than on teardown.
OpenCode has no shell-hook system, so it gets a generated plugin instead — a few lines of JavaScript that check TUIC_SESSION, write the same OSC 7770 to /dev/tty, and never throw. Different mechanism, identical contract.
Two sources of truth is one too many
The subtle part came after it worked. Once an agent is instrumented, the old heuristics are still running — and they're now competing with a source that is actually authoritative.
The specific collision was question detection. Our parser guesses "this looks like a prompt waiting for an answer" from the shape of the output. An instrumented agent reports awaiting explicitly. Leave both on and a false-positive heuristic can mark a working agent as awaiting your input, overriding the agent's own statement that it is busy.
So heuristic question events are dropped for instrumented sessions. Not deprioritised, not merged, not resolved by a tie-breaker — dropped. When you have a real answer, a guess in the same channel is noise with a chance of being wrong.
What we'd tell you to steal
If you're building anything that supervises AI agents, the transferable lesson is that an agent's terminal UI is not an interface. Treat it as one and you inherit a maintenance burden that grows with every agent you support and every release they ship, and that fails in the worst possible way: silently, while your tests stay green.
Wherever an agent offers hooks, an event stream, or a status file, take it. It's a fraction of the code, it survives redesigns, and it tells you about state changes the screen never shows — like Claude deciding to ask you a question a moment before it prints one.
Heuristics still ship, because not every agent exposes hooks and we're not dropping support for the ones that don't. They're the fallback now instead of the foundation. That's the right place for a guess.