Desde [Paneflow 0.17.7](https://github.com/arthjean/paneflow/releases/tag/v0.17.7), la barra lateral toma el estado del turno de un agente de un único sitio: un estado que el agente escribe en su propio terminal. Un agente que se detiene para pedir un permiso escribe una secuencia con esta forma (el ejemplo viene de la especificación):

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

Lo habitual es suponer que una herramienta que ejecuta agentes tiene que vigilarlos desde fuera: reconocer el spinner, detectar el aviso de permiso, notar que ha vuelto el cuadro de entrada. Yo construí eso, varias veces, y lo eliminé. Hoy mi postura es que el estado del turno pertenece al agente, el único proceso que lo conoce de verdad, y que el trabajo del terminal es transportar la declaración sin adivinar.

Esto es lo que trae Paneflow 0.19.0. Cubre los agentes que emiten OSC 7501: Claude Code desde la v2.1.295, y Codex, que integró el soporte en [openai/codex#52725](https://github.com/openai/codex/pull/52725) y [#54114](https://github.com/openai/codex/pull/54114) en octubre. Un agente que no declara nada no recibe estado de turno, a propósito.

El artículo sigue un turno de Claude Code desde el agente hasta la barra lateral y luego recorre la forma general, los seis enfoques anteriores, lo que cuesta y la objeción más fuerte.

## Qué es OSC 7501

Si has leído el artículo de Mitchell sobre el protocolo, sáltate esta sección.

Mitchell Hashimoto publicó el [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status) el 6 de octubre de 2026, como "a completely generic, terminal-native specification" (una especificación completamente genérica y nativa del terminal) surgida de su trabajo en Superlogical y Ghostty ([su artículo](https://mitchellh.com/writing/program-status-osc7501)). Un programa escribe `OSC 7501` con pares `key=value` separados por dos puntos:

- `state`: `idle`, `working`, `blocked`, `done` o `error`, además de `clear` como acción.
- `kind`, solo con `blocked`: `permission`, `question` o `auth`.
- `progress`, `app`, `title` y `msg` (base64, hasta 2048 bytes).

Un programa pregunta si hay soporte con `OSC 7501 ; ?`. No hay heartbeat. Cuando vuelve el prompt del shell (`OSC 133 A`) o el programa termina, el terminal descarta `working` y `blocked`; `done` y `error` se mantienen hasta que el terminal decide ocultarlos, por ejemplo con la siguiente pulsación de tecla.

Dos propiedades importan para Paneflow. El estado viene del único proceso que lo conoce. Y las reglas de duración implican que el terminal nunca tiene que hacer caducar un estado.

## Un turno de Claude Code, del agente a la barra lateral

Sigamos un único turno a través de los tres procesos implicados: el host de sesiones, dueño de los PTY; el worker, que reduce el estado; y la ventana de escritorio.

**Paneflow responde como Ghostty.** El host inicia cada shell con `TERM_PROGRAM=ghostty` ([`spawn_env.rs`](https://github.com/arthjean/paneflow/blob/v0.19.0/crates/paneflow-host/src/host/spawn_env.rs)), y su motor de terminal es `libghostty-vt`. Cuando Claude Code pregunta si el terminal soporta el protocolo, el motor responde:

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

El host ve salir esa respuesta hacia el PTY y marca la sesión como `program_status_capable`. Solo responde el host. La aplicación de escritorio también integra libghostty para dibujar, pero no instala ningún callback de OSC 7501, así que el agente nunca recibe dos respuestas; un test llamado `desktop_terminals_neither_answer_nor_report_program_status` garantiza que siga así.

**Envías un prompt y Claude Code escribe `state=working`.** Esos bytes pasan por el mismo parser de libghostty que ya mantiene la cuadrícula, así que Paneflow nunca los analiza una segunda vez. libghostty entrega el estado ya interpretado a un callback, y el bucle de eventos del host lo convierte en un registro:

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

Un hilo del host recorre las sesiones cada 500 ms, se salta las que no tienen nada nuevo y escribe la declaración actual en el manifiesto de la sesión solo cuando ha cambiado. El worker sigue al host a través de un stream push.

**El worker reduce.** `paneflow-serve` se sitúa entre el host y todos los clientes: la aplicación de escritorio, la CLI `paneflow` y el puente MCP. Primero comprueba que la sesión ejecuta un agente conocido y luego mapea la declaración. Esta es toda la máquina de estados ([`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)
}
```

Ningún temporizador, hook ni bell la alimenta. Un proceso terminado nunca está ocupado ni esperando, mientras que un `error` sobrevive a la salida. Cada cambio del estado reducido incrementa un contador `state_seq`, que es lo que siguen `paneflow wait --idle` y `paneflow send --submit`.

**Claude Code pide un permiso.** Escribe `state=blocked:kind=permission` con la pregunta en `msg`. El worker pasa la sesión a Attention y, como es una transición hacia Attention, decide una notificación "Needs input" que lleva ese mensaje. La aplicación de escritorio solo decide si ya tienes el panel delante. La fila muestra `Need you` y sube al principio de la sección Agents de la barra lateral.

**El turno termina con `state=done`.** El worker publica una notificación "Finished" y marca la sesión como no leída. La fila muestra `Done` hasta que abres la pestaña. El host conserva el registro `done` hasta que escribes en el panel, y el texto enviado con `paneflow send` o con la herramienta MCP `write_pane` no cuenta como escribir.

## La forma general

Cada proceso tiene un único trabajo. El host interpreta y conserva registros, con límites: 256 registros por sesión, y si la ráfaga de eventos de estado pendientes supera 256 KiB lo borra todo en lugar de quedarse con un estado parcial. El worker reduce y decide las notificaciones, y al reiniciarse reconstruye todas las sesiones a partir de los manifiestos. La aplicación de escritorio renderiza lo que recibe por un stream push, con los despertares agrupados.

Lo que declara el agente y lo que ves:

| Declarado | Barra lateral |
|---|---|
| `working` | Spinner |
| `blocked` | `Need you`, más una notificación con el mensaje del agente |
| `error` | `Error`, más una notificación |
| `done` | `Done` hasta que lo hayas visto, más una notificación |
| `idle` | Nada |
| Nada tras 3 s | `No status` |

## Lo que siguen haciendo los hooks

Claude Code y Codex siguen recibiendo los hooks de Paneflow, nueve eventos cada uno. Transportan lo que OSC 7501 no lleva: el ID de sesión del proveedor y la ruta del transcript que permiten reanudar una conversación, el prompt que da nombre a la pestaña, el último resultado que rellena la notificación, los inicios y paradas de subagentes. Nunca mueven el estado, y un test lo fija: `a_hook_event_carries_metadata_but_never_moves_the_declared_state`.

Un detalle a tener en cuenta: Claude Code ejecuta estos hooks de forma síncrona (`"async": false`, `timeout: 5`). El reporter se da 350 ms para conectar y 350 ms para la petición, y después sale con código 0 sin escribir nada en stdout. En el peor caso, que falte Paneflow cuesta unos 700 ms por evento.

## Seis enfoques antes de este

Todas las versiones anteriores de esta función inferían el estado. Por orden:

1. **Títulos de ventana.** Al principio, un detector vigilaba los títulos OSC buscando spinners en braille. Lo eliminé el 7 de abril en favor de hooks desde un script wrapper ([`107098da1`](https://github.com/arthjean/paneflow/commit/107098da1)).
2. **Actividad del PTY.** En junio, los agentes sin hooks recibieron una heurística: una ráfaga de salida ascendía al agente a working, 3 s de silencio lo degradaban, y el eco de las teclas se filtraba. Una semana después la eliminé porque "produced false spinners (a dev server streaming under a bare-shell thread, TUI redraws)" (producía spinners falsos) ([`4200e8d49`](https://github.com/arthjean/paneflow/commit/4200e8d49)). Fracaso.
3. **Un temporizador de inactividad.** Un estado `Stalled` se activaba tras 300 s de silencio. Se fue en septiembre: "A long turn is a long turn, not a fault." (un turno largo es un turno largo, no un fallo).
4. **Fuentes sin hooks.** Una organización puede desactivar los hooks de Claude Code con `disableAllHooks` o `allowManagedHooksOnly`. En agosto añadí el progreso OSC 9;4, las notificaciones OSC 777 y el archivo de registro de sesiones de Claude, jerarquizados frente a los hooks con un relevo a los 20 s ([`0f3da66ea`](https://github.com/arthjean/paneflow/commit/0f3da66ea)).
5. **Un reducer duradero.** En septiembre, los hooks pasaron a ser la autoridad una vez que la sesión quedaba fijada, un lease de cinco minutos que se rearmaba con los cambios de pantalla acotaba un `Stop` perdido, y un escaneo del viewport buscaba menús. En octubre se sumó el bell del terminal como señal de atención.
6. **Reglas de pantalla declarativas.** El 2 de octubre, cada agente recibió un `screen.toml` de reglas regex priorizadas, con un catálogo remoto firmado y un corpus de pantallas capturadas ([`c1d48e580`](https://github.com/arthjean/paneflow/commit/c1d48e580)). Salió en la 0.17.6.

Las notas de diseño del enfoque 5 enumeran los síntomas que no paraba de parchear: un `Stop` perdido dejaba un panel pensando para siempre, y Claude Code documenta que `Stop` no se dispara cuando el usuario interrumpe, así que un Escape a mitad de turno mantenía el spinner girando. Sus menús numerados no disparan ningún hook, así que una sesión que esperaba una elección aparecía como ocupada. Cada capa existía para corregir la de debajo.

El 7 de octubre volví a fijar libghostty para obtener su API de OSC 7501. Tres días después, [`328a8fe12`](https://github.com/arthjean/paneflow/commit/328a8fe12) hizo de la declaración la única fuente del estado del turno: 119 archivos, 1020 líneas añadidas y 12 417 eliminadas. Las reglas de pantalla, el catálogo remoto, los comandos de captura, la atención por bell y la gestión del Escape desaparecieron.

Tres cosas lo decidieron. Claude Code y Codex ya declaraban su estado ellos mismos, y una declaración del agente gana a cualquier inferencia que yo pueda hacer desde fuera. Los estados inferidos fallaban de formas que no paraba de parchear. Y las reglas tenían un coste de mantenimiento por cada versión de agente: el corpus, la firma del catálogo, los fixtures.

Eso sí, las reglas no fallaban con sus fixtures. La línea base del corpus eliminado daba una precisión de 1,0 para todos los agentes, con 4 a 7 capturas cada uno. Esa cifra medía las capturas que yo tenía, no la siguiente versión del agente.

La alternativa era mantener las reglas como respaldo para los agentes que no declaran nada. La 0.17.7 no incluye ningún respaldo: "Paneflow offers no replacement: the status comes back once the agent itself emits OSC 7501." (Paneflow no ofrece sustituto: el estado vuelve cuando el propio agente emite OSC 7501).

## La identidad se reconoce, el estado no

Paneflow es específico de cada agente en un solo punto: saber qué agente se ejecuta en un panel. El catálogo [`runtimes/`](https://github.com/arthjean/paneflow/tree/v0.19.0/runtimes) describe 13 agentes, y el host compara con él el job en primer plano del panel.

En Linux recorre `/proc/<pid>/task/<tid>/children` y lee `cmdline`. En macOS usa `proc_listpids(PROC_PGRP_ONLY)` y `KERN_PROCARGS2`. En Windows, sin grupos de procesos, recorre hasta 64 descendientes del líder de sesión y lee la línea de comandos de cada uno desde el PEB del proceso. Un wrapper `node`, `bun` o `python` se resuelve a través de la ruta del script, de modo que un Claude Code instalado con npm que corre bajo `node.exe` se sigue reconociendo, mientras que `-e`, `-c` y `-m` nunca identifican un agente. La hora de inicio del kernel se lee antes y después de los argumentos, así que un pid reciclado nunca se atribuye al agente equivocado.

**Consejo:** el alias `fx` también es el nombre de un visor de JSON. Paneflow solo cuenta `fx` como el agente mientras el título del panel empiece por `fx v`.

Una coincidencia errónea puede etiquetar mal qué agente se ejecuta en un panel. No puede inventarse lo que ese agente está haciendo.

## Lo que cuesta

En Windows 11 Pro, Ryzen 7 7800X3D, RTX 4070 Ti SUPER, build de release de la 0.18.0 (`b57da648`), un panel ejecutó durante 62 s un programa de prueba que declaraba `working` por OSC 7501 ([resumen del benchmark](https://github.com/arthjean/paneflow/blob/main/bench/results/hardware-summary-2026-10-27-windows.md)):

| Proceso | CPU (un núcleo) |
|---|---|
| Host | 0,00 % |
| Worker | 0,05 % |
| Escritorio | 4,80 %, del cual 3,66 % en el hilo principal |

La aplicación de escritorio dibujó 11,13 renders raíz por segundo, y esos son el spinner. Un único reloj de animación compartido de 90 ms mueve todos los spinners de la barra lateral, los tests fijan entre 10 y 12 despertares de la barra lateral por segundo con 1 u 8 agentes trabajando, y `reduce_motion` dibuja un spinner estático sin ningún reloj.

La latencia tuvo su propio arreglo. El host ya lee `OSC 133 A` de la integración con el shell, y desde el 31 de octubre el inicio de un prompt despierta el escaneo en lugar de esperar a la siguiente pasada de 500 ms. El paso de la salida del agente a la liberación bajó de entre 434 y 491 ms a entre 6 y 9 ms en cinco ciclos ([`96c525144`](https://github.com/arthjean/paneflow/commit/96c525144)).

**Importante:** el argumento de CPU contra las reglas de pantalla es más débil de lo que parece. Las mías corrían a "20 rules on 200x60 evaluate in 24 us p95" (20 reglas sobre 200x60 se evalúan en 24 us en el p95). Lo que eliminé fue el mantenimiento y los estados erróneos, no un bucle caliente. Ningún benchmark aísla el coste del propio OSC 7501, porque viaja sobre un parseo que el terminal hace de todos modos.

## ¿Y los agentes que no dicen nada?

Esa es la objeción más fuerte, y es justa. Claude Code y Codex declaran. Cualquier otro agente del catálogo solo recibe un estado de turno cuando también emite OSC 7501; hasta entonces, no recibe ninguno. Tras 3 s sin una consulta de soporte ni una declaración, la fila muestra una insignia atenuada `No status` en lugar de fingir que está inactiva, con un tooltip que indica la primera versión que reporta, cuando Paneflow la conoce (Claude Code 2.1.295).

Para un agente así, `paneflow send --submit` reporta `no_signal`, y `paneflow wait --idle` espera a que la salida se calme en lugar de volver al final del turno.

Algunos detalles de la [documentación del terminal](https://code.claude.com/docs/en/terminal-config) de Claude Code: dentro de tmux, Claude Code consulta el soporte a tmux en lugar de a tu terminal; una sesión en segundo plano no reporta su estado; y `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` desactiva el reporte. En el caso de Codex, todavía no sé qué versión incluye primero los dos PR.

[Herdr](https://github.com/ogulcancelik/herdr) toma el otro lado de este compromiso y hoy muestra un estado para más agentes. Para Claude Code y Codex, su [documentación de integraciones](https://herdr.dev/docs/integrations/) dice que el estado "comes from Herdr's screen manifest detection" (viene de la detección por manifiesto de pantalla de Herdr), con los hooks reportando la identidad de la sesión; para Pi, OpenCode y algunos otros, usa el estado que reportan sus hooks. Su [manifiesto de Claude](https://raw.githubusercontent.com/ogulcancelik/herdr/HEAD/distribution/agent-detection/claude.toml) es un trabajo cuidadoso: reglas priorizadas sobre regiones de la pantalla, con comentarios que siguen los glifos del spinner de Claude Code versión a versión. Mitchell señaló que el historial del archivo "shows ten changes in three months for just Claude Code" (muestra diez cambios en tres meses solo para Claude Code), y añadió que no es una crítica a Herdr. Estoy de acuerdo. Ese es el coste que decidí no pagar, y `No status` es el precio.

Gracias a Mitchell Hashimoto por escribir la especificación, al proyecto Ghostty por el parser `libghostty-vt` que entrega el estado a Paneflow, y a los equipos de Claude Code y Codex por implementarlo.

Si mantienes una CLI que alguna vez espera a un humano, emite OSC 7501. Puedes ver qué hace un terminal con ella ejecutando esto en un panel de Paneflow, donde la cabecera muestra `blocked: permission · Apply?` durante diez segundos:

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