This is what Claude Code 2.1.303 writes to its terminal when it asks to run a command. The bytes come from a real capture that osFoundry keeps as a test fixture (resources/agent-detection/fixtures/claude.blocked.raw):
\r\x1b[1C\x1b[1B\x1b[39mDo\x1b[5Gyou\x1b[9Gwant\x1b[14Gto\x1b[17Gproceed? \r\x1b[1C\x1b[1B\x1b[38;2;177;185;249m❯\x1b[4G\x1b[38;2;153;153;153m1. \x1b[38;2;177;185;249mYes \r\x1b[3C\x1b[1B\x1b[38;2;153;153;153m2. \x1b[39mYes,\x1b[12Gand\x1b[16Galways\x1b[23Gallow ... (Line breaks added for reading. The capture has none.)
Two things in there break the obvious detector. There is no space between "Do" and "you". \x1b[5G moves the cursor to column 5, and the space is just an untouched cell. And there is no \n anywhere: each row starts with \r and a cursor-down \x1b[1B. All 11 Claude Code captures in the fixture folder (2,099 to 7,672 bytes each) contain zero newline bytes.
osFoundry's desktop app answers "is this agent waiting on me?" for every terminal pane on its Sessions board. Below: what it does, why, and a small Python supervisor that compares the three usual approaches (prompt patterns, output-idle timers, process state) on a live PTY. There is no claude binary on this Mac, so the live part runs a fake agent that copies Claude Code's prompt wording and redraw habits. The fixture part uses the real captured bytes.
What does osFoundry actually match?
A TOML file per agent. Claude Code's is apps/web/src-tauri/resources/agent-detection/claude.toml, and the same file is served at https://osfoundry.io/agent-detection/claude.toml. Its rules, in order:
[[rules]] state = "blocked" any = [ 'Do you want to (proceed|continue|make this edit|create)', '❯\s*1\.\s*Yes', 'Esc to cancel · Tab to amend', 'Enter to select · ↑/↓ to navigate', 'Exit plan mode\?', ] [[rules]] state = "working" any = ['esc to interrupt'] [[rules]] state = "idle" any = ['\? for shortcuts', '(?:⏸|⏵⏵) [a-z ]+ mode on'] The first rule that matches wins (CompiledManifest::evaluate in runtime/detect.rs). blocked comes first because Claude Code's footer says "esc to interrupt" while a prompt is up. If working were tested first, the prompt would never be seen. A screen that no rule matches is reported as idle, never blocked.
The comments in the file say what was removed and why. Would you like to was dropped because it is Codex's prompt wording, and Claude's ordinary replies say "Would you like me to…" all the time.
runtime/mod.rs runs one sweep over every pane every 700 ms (SWEEP_INTERVAL), instead of classifying on every write. A spinner redraws many times a second, so classifying on each write would mean running the regexes hundreds of times a second to get the same answer.
Why not grep the last few KB of output?
Because of the bytes at the top. Here the rules are ported to Python and run over each Claude Code fixture two ways. The first way takes the last 8 KB, removes escape sequences, and keeps the text after the last \r on each line. The second way feeds the bytes into a 32×120 screen emulator (about 100 lines, vt.py below) and reads the rows back.
fixture truth last 8 KB, stripped emulated 32x120 screen claude.blocked.edit.raw blocked idle ✗ blocked claude.blocked.plan.raw blocked idle ✗ blocked claude.blocked.question.raw blocked idle ✗ blocked claude.blocked.raw blocked idle ✗ blocked claude.idle.after.raw idle idle idle claude.idle.auto.raw idle idle idle claude.idle.edit.raw idle idle idle claude.idle.question.raw idle idle idle claude.idle.raw idle idle idle claude.working.auto.raw working idle ✗ working claude.working.raw working idle ✗ working correct: tail 5/11, screen 11/11 The stripped tail gets every idle right only because idle is the fallback. It never sees a prompt. With no \n, the whole capture is one "line", and keeping the text after the last \r throws away everything but the final fragment. Even without that step, the words come out glued: Doyouwanttoproceed?.
osFoundry's code says the same. terminal_pty.rs keeps a vt100::Parser per session, and the sweep classifies screen_text(), the emulated screen only. The 200 rows of history kept above it are left out on purpose. The comment there: a prompt the user answered that has scrolled up "would report the agent as blocked forever".
The emulator does not have to be a good one. This one handles text, \r \n \b, cursor moves (A B C D E F G H f d) and erase (J K), and skips colours, modes and OSC titles. It puts one character in each cell, so a wide glyph can shift a column. Regexes over whole rows don't care. It renders all 11 captures well enough for the rules:
CSI = re.compile(r"\x1b\[([0-9;?]*)([ -/]*)([@-~])") OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") class Screen: def feed(self, text): i, n = 0, len(text) while i < n: c = text[i] if c == "\x1b": m = OSC.match(text, i) if m: i = m.end(); continue m = CSI.match(text, i) if m: self._csi(m.group(1), m.group(3)) # move x/y or blank cells i = m.end(); continue i += 2; continue # ESC 7, ESC =, ... if c == "\r": self.x = 0 elif c == "\n": self._lf() # scrolls at the bottom row elif c == "\b": self.x = max(0, self.x - 1) elif c >= " ": if self.x >= self.cols: self.x = 0; self._lf() self.grid[self.y][self.x] = c self.x += 1 i += 1 Why not just wait for the output to stop?
That is the detector most people write first: if the agent has printed nothing for N seconds, it probably wants you. To test it against the screen rules, a supervisor runs an agent in a PTY and logs every 50 ms what each detector thinks.
The agent is fake_agent.py, a stand-in, since no claude binary is installed on this Mac. It copies the habits that matter:
- a spinner redrawn with
\rat 10 Hz, 60 bytes a frame, with "esc to interrupt" in it - tool calls that print nothing while they run (0.5 to 6 s), as many CLIs do
- a permission prompt in Claude Code's words, with each word placed by
CSI n Glike the real capture, then erased Ink-style with cursor-up and\x1b[Jonce answered - an idle prompt with
? for shortcutsat the end of each turn, sometimes after a reply that ends in "Would you like me to add a regression test?" - in both the prompt and the idle state, the process blocks in
readline()on the PTY
It writes its true state to a side file that only the scorer reads. A bot plays the user and answers each prompt 3 to 8 s after it appears, and types the next task 3 to 10 s after a turn ends. The core of the supervisor, trimmed:
pid, fd = pty.fork() if pid == 0: os.execvp(sys.executable, [sys.executable, "fake_agent.py"]) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 32, 120, 0, 0)) screen, dec, raw = Screen(32, 120), codecs.getincrementaldecoder("utf-8")("replace"), bytearray() while True: r, _, _ = select.select([fd], [], [], 0.01) if r: chunk = os.read(fd, 65536) raw += chunk; del raw[:-65536] screen.feed(dec.decode(chunk)) last_out = time.time() if time.time() >= next_tick: # every 50 ms log(screen=classify(screen.text())[0], # the rules above, on the emulated screen tail=classify(strip_tail(bytes(raw)))[0], # the same rules, on the stripped 8 KB tail question=any(l.rstrip().endswith("?") for l in screen.text().splitlines()), quiet=time.time() - last_out) # for the idle timers next_tick += 0.05 One run with seed 7: 30 tasks, 10.6 minutes, 25 permission prompts, 144 s spent waiting on them. Latency is the time from the prompt being drawn to the detector saying "needs you". Linger is how long it kept saying so after the answer. False alarms are counted as episodes / total seconds, split by what the agent was really doing.
| Detector | Median latency | Max | Missed | Linger | False, agent working | False, agent idle |
|---|---|---|---|---|---|---|
| Screen rules, every 50 ms | 0.03 s | 0.06 s | 0 / 25 | 0 s | 0 | 0 |
| Screen rules, 700 ms sweep | 0.26 s | 0.70 s | 0 / 25 | 0.4 s | 0 | 0 |
| Same rules on stripped 8 KB tail | 0.03 s | 0.06 s | 0 / 25 | 77.0 s | 0 | 0 |
| A screen line ending in "?" | 0.03 s | 0.06 s | 0 / 25 | 58.0 s | 0 / 231 s | 4 / 143 s |
| Output quiet ≥ 0.5 s | 0.52 s | 0.56 s | 0 / 25 | 0 s | 31 / 78 s | 30 / 168 s |
| Output quiet ≥ 1 s | 1.02 s | 1.06 s | 0 / 25 | 0 s | 28 / 63 s | 30 / 153 s |
| Output quiet ≥ 2 s | 2.03 s | 2.06 s | 0 / 25 | 0 s | 21 / 38 s | 30 / 123 s |
| Output quiet ≥ 3 s | 3.02 s | 3.06 s | 0 / 25 | 0 s | 17 / 19 s | 30 / 93 s |
| Output quiet ≥ 5 s | 5.02 s | 5.05 s | 9 / 25 | 0 s | 3 / 1 s | 18 / 46 s |
| Output quiet ≥ 8 s | – | – | 25 / 25 | 0 s | 0 | 6 / 7 s |
An idle timer has no threshold that works. Its latency is the threshold, by definition. Below 5 s it fires on every silent tool call. At every threshold up to 3 s it fires on all 30 turn ends, because "finished, waiting for the next task" is exactly as quiet as "waiting for permission". From 5 s up it starts missing prompts that someone answered in time. The 8 s row misses all 25 only because the bot never waits longer than 8 s, but the shape holds for any answer time: the detector is either noisy or late.
A screen line ending in "?" catches every prompt, and on the real captures it would look perfect: only the four blocked fixtures have such a line. In the live run it fires on the agent's own question, "Would you like me to add a regression test?", and that line stays on screen into the next task. This is the same reason claude.toml dropped Would you like to.
The stripped tail detects prompts in this run because the fake agent writes ❯ 1. Yes with real spaces. It then keeps reporting the prompt for 77 s on average after the answer, because the erased prompt's bytes stay in the last 8 KB until about 8 KB of new output pushes them out. At 600 bytes a second of spinner, plus the silent stretches, that takes a long time. The emulated screen forgets the prompt as soon as the cursor-up and erase arrive.
The 700 ms sweep costs what you would expect: up to one interval of latency (median 0.26 s, max 0.70 s) and up to one interval of linger. For a badge that a person looks at, that is fine.
Can't the OS tell you the process is reading stdin?
Not on macOS. The supervisor sampled ps -o stat=,time= for the agent twice a second:
working samples 603 state S 99.5% R 0.5% CPU 0.04% of a core idle samples 357 state S 100.0% R 0.0% CPU 0.02% of a core blocked samples 280 state S 100.0% R 0.0% CPU 0.02% of a core A process blocked in read() on its terminal and a process sleeping between spinner frames are both S. The wchan column, which on some systems names the kernel wait channel, printed - for both. CPU time does not separate them either, because a spinner costs almost nothing. A Node-based CLI like Claude Code waits in its event loop whether it is working or not, so its state letter would tell you even less.
What the process table does answer is which program holds the terminal. osFoundry reads the tty's foreground process group (MasterPty::process_group_leader, i.e. tcgetpgrp) in runtime/signals.rs. That tells it the pane is running claude even when it was typed into a plain shell. It also tells it when the agent has exited and the shell is back in front, while the agent's last prompt is still on screen.
Is there something better than reading the screen?
Yes: have the agent say so. runtime/signals.rs weighs three sources. When osFoundry launches Claude Code itself, runtime/integrations.rs adds hooks through --settings. That flag adds settings for this one launch, and the user's own hooks still run.
{"hooks": { "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "osf-pane report working >/dev/null 2>&1 || true"}]}], "PreToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "osf-pane report working >/dev/null 2>&1 || true"}]}], "Notification": [{"hooks": [{"type": "command", "command": "osf-pane notify >/dev/null 2>&1 || true"}]}], "Stop": [{"hooks": [{"type": "command", "command": "osf-pane report done >/dev/null 2>&1 || true"}]}] }} Claude Code fires Notification both for a permission prompt and after a minute of waiting for input. So osf-pane notify reads the payload and maps permission to blocked and everything else to idle. Over ACP (through the claude-agent-acp adapter declared in claude.toml), a session/request_permission request is the agent asking for permission outright, a fact rather than an inference.
The order in signals::combine is the interesting part. A blocked read from the screen always stands. Next comes a fresh report from the agent, then "the shell is back in front", then the remaining screen rules. A report can be stale or missing, for example when a hook failed silently. A prompt on screen is a precise rule and the one alert that must never be suppressed.
Two smaller decisions follow from that. Notifications have a 30 s cooldown per session and state (runtime/notify.rs), because a redrawing prompt can flap blocked → working → blocked. And before any tool types into a pane, runtime/tools.rs runs a fresh classify_session instead of trusting the last sweep. A pane that is blocked only accepts runtime_answer, which the user confirms, never the ordinary runtime_send.
If you wire this up yourself
- Classify an emulated screen, not a byte tail. Claude Code places words with cursor moves and has no newlines, and an answered prompt stays in the bytes for minutes.
- Match the prompt's own wording and footer (
Esc to cancel · Tab to amend), not "a question" or "quiet for N seconds". Turn ends and silent tool calls look the same as a prompt from outside. - Put
blockedrules aboveworkingrules and make "no rule matched" mean idle. A false "needs you" costs more than a late one. - Keep the rules in a data file you can update without a release, and keep the program that runs out of it.
- Use the process table for identity and exit, not for waiting. Use hooks or ACP when you launch the agent yourself, but let a prompt on screen beat a report.
- A sweep of a few hundred milliseconds is enough for a person. Re-check right before you type into the pane.
The code is four small files, vt.py, rules.py, fake_agent.py and supervisor.py, plus fixtures.py and analyze.py for the tables. The screen that shows the result is the Sessions board for agent sessions in the osFoundry desktop app.
