Since [Paneflow 0.17.7](https://github.com/arthjean/paneflow/releases/tag/v0.17.7), the sidebar takes an agent's turn state from exactly one place: a status the agent writes into its own terminal. An agent that stops for a permission writes a sequence of this shape (the example comes from the spec):

```text
ESC ] 7501 ; state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQs... ESC \
```

The usual assumption is that a tool running agents has to watch them from the outside: match the spinner, spot the permission prompt, notice the input box came back. I built that, several times, and deleted it. My position now is that the turn state belongs to the agent, the only process that actually knows it, and that the terminal's job is to carry the declaration without guessing.

This is what ships in Paneflow 0.19.0. It covers the agents that emit OSC 7501: Claude Code from v2.1.295, and Codex, which merged support in [openai/codex#52725](https://github.com/openai/codex/pull/52725) and [#54114](https://github.com/openai/codex/pull/54114) in October. An agent that declares nothing gets no turn state, on purpose.

The post follows one Claude Code turn from the agent to the sidebar, then the general shape, the six approaches that came before, what it costs, and the strongest objection.

## What OSC 7501 is

If you have read Mitchell's post on the protocol, skip this section.

Mitchell Hashimoto published the [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status) on October 6, 2026, as "a completely generic, terminal-native specification" that came out of his work on Superlogical and Ghostty ([his post](https://mitchellh.com/writing/program-status-osc7501)). A program writes `OSC 7501` with `key=value` pairs separated by colons:

- `state`: `idle`, `working`, `blocked`, `done` or `error`, plus `clear` as an action.
- `kind`, with `blocked` only: `permission`, `question` or `auth`.
- `progress`, `app`, `title`, and `msg` (base64, up to 2,048 bytes).

A program asks for support with `OSC 7501 ; ?`. There is no heartbeat. When the shell prompt returns (`OSC 133 A`) or the program exits, the terminal drops `working` and `blocked`; `done` and `error` survive until the terminal decides to hide them, for example on the next keystroke.

Two properties matter for Paneflow. The state comes from the one process that knows it. And the lifetime rules mean the terminal never has to time a state out.

## One Claude Code turn, from the agent to the sidebar

Let's follow a single turn through the three processes involved: the session host that owns the PTYs, the worker that reduces state, and the desktop window.

**Paneflow answers like Ghostty.** The host starts every shell with `TERM_PROGRAM=ghostty` ([`spawn_env.rs`](https://github.com/arthjean/paneflow/blob/v0.19.0/crates/paneflow-host/src/host/spawn_env.rs)), and its terminal engine is `libghostty-vt`. When Claude Code asks whether the terminal supports the protocol, the engine replies:

```text
ESC ] 7501 ; ? : states=idle,working,done,blocked,error : kinds=permission,question,auth ESC \
```

The host sees that reply leave for the PTY and marks the session `program_status_capable`. Only the host answers. The desktop also embeds libghostty for drawing, but installs no OSC 7501 callback, so the agent never gets two replies; a test named `desktop_terminals_neither_answer_nor_report_program_status` keeps it that way.

**You submit a prompt, Claude Code writes `state=working`.** Those bytes go through the same libghostty parser that already maintains the grid, so Paneflow never scans them a second time. libghostty hands the parsed status to a callback, and the host's event loop turns it into a record:

```rust
ghostty::BackendEvent::ProgramStatus(report) => {
    self.program_status.apply(report);
    self.program_status_capable = true;
}
```

A host thread passes over the sessions every 500 ms, skips those with nothing new, and writes the current declaration to the session's manifest only when it changed. The worker follows the host over a push stream.

**The worker reduces.** `paneflow-serve` sits between the host and every client: the desktop, the `paneflow` CLI, the MCP bridge. It first checks that the session runs a known agent, then maps the declaration. This is the whole state machine ([`state.rs`](https://github.com/arthjean/paneflow/blob/v0.19.0/crates/paneflow-serve/src/state.rs)):

```rust
fn declared_verdict(&self) -> (Status, ActivitySource) {
    let Some(declared) = self.agent_declaration() else {
        return (Status::Idle, ActivitySource::None);
    };
    let running = self.lifecycle.is_running();
    let status = match declared.state.as_str() {
        DECLARED_ERROR => Status::Errored,
        DECLARED_WORKING if running => Status::Busy,
        DECLARED_BLOCKED if running => Status::Attention,
        _ => Status::Idle,
    };
    (status, ActivitySource::Declared)
}
```

No timer, hook, or bell feeds it. An exited process is never busy or waiting, while an `error` survives the exit. Each change of the reduced state bumps a `state_seq` counter, which is what `paneflow wait --idle` and `paneflow send --submit` follow.

**Claude Code asks for a permission.** It writes `state=blocked:kind=permission` with the question in `msg`. The worker moves the session to Attention and, because it is a transition into Attention, decides a "Needs input" notification carrying that message. The desktop only decides whether you already have the pane in front of you. The row shows `Need you` and moves to the top of the sidebar's Agents section.

**The turn ends with `state=done`.** The worker publishes a "Finished" notification and marks the session unread. The row shows `Done` until you open the tab. The host keeps the `done` record until you type in the pane, and text sent by `paneflow send` or the MCP `write_pane` tool does not count as typing.

## The general shape

Each process has one job. The host parses and keeps records, with bounds: 256 records per session, and past a 256 KiB burst of pending status events it clears everything instead of keeping a partial state. The worker reduces and decides notifications, and on restart it rebuilds every session from the manifests. The desktop renders what it receives over a push stream, with wakeups coalesced.

What the agent declares, and what you see:

| Declared | Sidebar |
|---|---|
| `working` | Spinner |
| `blocked` | `Need you`, plus a notification with the agent's message |
| `error` | `Error`, plus a notification |
| `done` | `Done` until you have seen it, plus a notification |
| `idle` | Nothing |
| Nothing after 3 s | `No status` |

## What the hooks still do

Claude Code and Codex still get Paneflow's hooks, nine events each. They carry what OSC 7501 does not: the provider session id and transcript path that make a conversation resumable, the prompt that names the tab, the last result that fills the notification, subagent starts and stops. They never move the state, and a test pins it: `a_hook_event_carries_metadata_but_never_moves_the_declared_state`.

One gotcha: Claude Code runs these hooks synchronously (`"async": false`, `timeout: 5`). The reporter gives itself 350 ms to connect and 350 ms for the request, then exits 0 with nothing on stdout. At worst, a missing Paneflow costs about 700 ms per event.

## Six approaches before this one

Every earlier version of this feature inferred the state. In order:

1. **Window titles.** At first, a detector watched OSC titles for braille spinners. I removed it on April 7 for hooks from a wrapper script ([`107098da1`](https://github.com/arthjean/paneflow/commit/107098da1)).
2. **PTY activity.** In June, agents without hooks got a heuristic: an output burst promoted the agent to working, 3 s of quiet demoted it, keystroke echo was filtered out. A week later I removed it because it "produced false spinners (a dev server streaming under a bare-shell thread, TUI redraws)" ([`4200e8d49`](https://github.com/arthjean/paneflow/commit/4200e8d49)). Failure.
3. **An inactivity timer.** A `Stalled` state fired after 300 s of silence. It went in September: "A long turn is a long turn, not a fault."
4. **Sources without hooks.** An organization can turn off Claude Code hooks with `disableAllHooks` or `allowManagedHooksOnly`. In August I added OSC 9;4 progress, OSC 777 notifications and Claude's session registry file, ranked against hooks with a 20 s takeover ([`0f3da66ea`](https://github.com/arthjean/paneflow/commit/0f3da66ea)).
5. **A durable reducer.** In September, hooks became the authority once a session latched, a five-minute lease re-armed by screen changes bounded a lost `Stop`, and a viewport scan looked for menus. In October the terminal bell joined as an attention signal.
6. **Declarative screen rules.** On October 2, each agent got a `screen.toml` of prioritized regex rules, with a signed remote catalog and a corpus of captured screens ([`c1d48e580`](https://github.com/arthjean/paneflow/commit/c1d48e580)). It shipped in 0.17.6.

The design notes from approach 5 list the symptoms I kept patching: a lost `Stop` left a pane thinking forever, and Claude Code documents that `Stop` does not fire on a user interrupt, so an Escape mid-turn kept the spinner going. Its numbered menus fire no hook at all, so a session waiting for a choice showed as busy. Each layer existed to correct the one below it.

On October 7, I re-pinned libghostty to get its OSC 7501 API. Three days later, [`328a8fe12`](https://github.com/arthjean/paneflow/commit/328a8fe12) made the declaration the only source of turn state: 119 files, 1,020 lines added, 12,417 removed. Screen rules, the remote catalog, the capture commands, the bell attention and the Escape handling all went.

Three things decided it. Claude Code and Codex now declared their state themselves, and a declaration from the agent beats any inference I can make from outside. The inferred states were wrong in ways I kept patching. And the rules had a maintenance cost per agent release: the corpus, the catalog signing, the fixtures.

The rules were not wrong on their fixtures, though. The deleted corpus baseline scored an accuracy of 1.0 for every agent, on 4 to 7 captures each. That number measured the captures I had, not the next agent release.

The alternative was to keep the rules as a fallback for agents that declare nothing. 0.17.7 ships no fallback: "Paneflow offers no replacement: the status comes back once the agent itself emits OSC 7501."

## Identity is matched, state is not

Paneflow is agent-specific in one place: knowing which agent runs in a pane. The [`runtimes/`](https://github.com/arthjean/paneflow/tree/v0.19.0/runtimes) catalog describes 13 agents, and the host matches the pane's foreground job against it.

On Linux it walks `/proc/<pid>/task/<tid>/children` and reads `cmdline`. On macOS it uses `proc_listpids(PROC_PGRP_ONLY)` and `KERN_PROCARGS2`. On Windows, with no process groups, it walks up to 64 descendants of the session leader and reads each command line from the process's PEB. A `node`, `bun` or `python` wrapper resolves through the script path, so an npm-installed Claude Code running under `node.exe` is still recognized, while `-e`, `-c` and `-m` never name an agent. The kernel start time is read before and after the arguments, so a recycled pid is never attributed to the wrong agent.

**Tip:** the `fx` alias is also the name of a JSON viewer. Paneflow counts `fx` as the agent only while the pane title starts with `fx v`.

A wrong match can mislabel which agent runs in a pane. It cannot invent what that agent is doing.

## What it costs

On Windows 11 Pro, Ryzen 7 7800X3D, RTX 4070 Ti SUPER, release build of 0.18.0 (`b57da648`), one pane ran a fixture program declaring `working` through OSC 7501 for 62 s ([bench summary](https://github.com/arthjean/paneflow/blob/main/bench/results/hardware-summary-2026-10-27-windows.md)):

| Process | CPU (one core) |
|---|---|
| Host | 0.00 % |
| Worker | 0.05 % |
| Desktop | 4.80 %, of which main thread 3.66 % |

The desktop drew 11.13 root renders per second, and those are the spinner. One shared 90 ms animation clock drives every spinner in the sidebar, tests pin 10 to 12 sidebar wakeups per second for 1 or 8 working agents, and `reduce_motion` draws a static spinner with no clock at all.

Latency got a fix of its own. The host already reads `OSC 133 A` from shell integration, and since October 31 a prompt start wakes the scan instead of waiting for the next 500 ms pass. Agent exit to release went from 434 to 491 ms to 6 to 9 ms over five cycles ([`96c525144`](https://github.com/arthjean/paneflow/commit/96c525144)).

**Important:** the CPU case against screen rules is weaker than it sounds. Mine ran at "20 rules on 200x60 evaluate in 24 us p95". What I removed was the maintenance and the wrong states, not a hot loop. No benchmark isolates the cost of OSC 7501 itself, since it rides on a parse the terminal does anyway.

## What about agents that say nothing?

That is the strongest objection, and it is fair. Claude Code and Codex declare. Any other agent in the catalog gets a turn state only once it emits OSC 7501 too; until then it gets none. After 3 s without a support query or a declaration, the row shows a muted `No status` badge instead of pretending to be idle, with a tooltip naming the first version that reports, when Paneflow knows it (Claude Code 2.1.295).

For such an agent, `paneflow send --submit` reports `no_signal`, and `paneflow wait --idle` waits for the output to go quiet instead of returning at the end of the turn.

A few gotchas from Claude Code's [terminal docs](https://code.claude.com/docs/en/terminal-config): inside tmux, Claude Code checks tmux for support instead of your terminal; a background session does not report its status; and `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` turns reporting off. For Codex, I don't know yet which release first ships the two PRs.

[Herdr](https://github.com/ogulcancelik/herdr) takes the other side of this trade and shows a state for more agents today. For Claude Code and Codex, its [integration docs](https://herdr.dev/docs/integrations/) say the state "comes from Herdr's screen manifest detection", with the hooks reporting session identity; for Pi, OpenCode and a few others, it uses the state their hooks report. Its [Claude manifest](https://raw.githubusercontent.com/ogulcancelik/herdr/HEAD/distribution/agent-detection/claude.toml) is careful work: prioritized rules over screen regions, with comments tracking Claude Code's spinner glyphs version by version. Mitchell noted that the file's history "shows ten changes in three months for just Claude Code", and added that this isn't a criticism of Herdr. I agree. That is the cost I chose not to pay, and `No status` is the price.

Thanks to Mitchell Hashimoto for writing the spec, to the Ghostty project for the `libghostty-vt` parser that hands Paneflow the status, and to the Claude Code and Codex teams for shipping it.

If you maintain a CLI that ever waits on a human, emit OSC 7501. You can see what a terminal does with it by running this in a Paneflow pane, where the header shows `blocked: permission · Apply?` for ten seconds:

```sh
printf '\033]7501;state=blocked:kind=permission:app=deploy:msg=QXBwbHk/\033\\'; sleep 10
```