L'état des agents dans Paneflow : des règles d'écran à OSC 7501
Depuis Paneflow 0.17.7, la barre latérale prend l'état du tour d'un agent à un seul endroit : un statut que l'agent écrit lui-même dans son terminal. Un agent qui s'arrête pour demander une permission écrit une séquence de cette forme (l'exemple vient de la spec) :
ESC ] 7501 ; state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQs... ESC \On part souvent du principe qu'un outil qui fait tourner des agents doit les observer de l'extérieur : reconnaître le spinner, repérer la demande de permission, remarquer que la zone de saisie est revenue. J'ai construit ça, plusieurs fois, et je l'ai supprimé. Ma position aujourd'hui : l'état du tour appartient à l'agent, le seul processus qui le connaît vraiment, et le travail du terminal est de transporter cette déclaration sans rien deviner.
C'est ce qui est livré dans Paneflow 0.19.0. Ça couvre les agents qui émettent OSC 7501 : Claude Code à partir de la v2.1.295, et Codex, qui a mergé le support dans openai/codex#52725 et #54114 en octobre. Un agent qui ne déclare rien n'a pas d'état de tour, et c'est voulu.
L'article suit un tour de Claude Code de l'agent jusqu'à la barre latérale, puis la forme générale, les six approches qui ont précédé, ce que ça coûte, et l'objection la plus sérieuse.
Ce qu'est OSC 7501
Si tu as lu l'article de Mitchell sur le protocole, passe cette section.
Mitchell Hashimoto a publié le Program Status Protocol le 6 octobre 2026, comme "a completely generic, terminal-native specification" (une spec générique, native au terminal), issue de son travail sur Superlogical et Ghostty (son article). Un programme écrit OSC 7501 avec des paires key=value séparées par des deux-points :
state:idle,working,blocked,doneouerror, plusclearcomme action.kind, seulement avecblocked:permission,questionouauth.progress,app,title, etmsg(en base64, jusqu'à 2 048 octets).
Un programme demande si le terminal le supporte avec OSC 7501 ; ?. Il n'y a pas de heartbeat. Quand le prompt du shell revient (OSC 133 A) ou que le programme se termine, le terminal retire working et blocked ; done et error restent jusqu'à ce que le terminal décide de les masquer, par exemple à la frappe suivante.
Deux propriétés comptent pour Paneflow. L'état vient du seul processus qui le connaît. Et les règles de durée de vie font que le terminal n'a jamais besoin d'expirer un état au bout d'un délai.
Un tour de Claude Code, de l'agent à la barre latérale
Suivons un seul tour à travers les trois processus concernés : le session host qui possède les PTY, le worker qui réduit l'état, et la fenêtre desktop.
Paneflow répond comme Ghostty. Le host démarre chaque shell avec TERM_PROGRAM=ghostty (spawn_env.rs), et son moteur de terminal est libghostty-vt. Quand Claude Code demande si le terminal supporte le protocole, le moteur répond :
ESC ] 7501 ; ? : states=idle,working,done,blocked,error : kinds=permission,question,auth ESC \Le host voit cette réponse partir vers le PTY et marque la session program_status_capable. Seul le host répond. Le desktop embarque aussi libghostty pour le rendu, mais n'installe aucun callback OSC 7501, donc l'agent ne reçoit jamais deux réponses ; un test nommé desktop_terminals_neither_answer_nor_report_program_status le garantit.
Tu envoies un prompt, Claude Code écrit state=working. Ces octets passent par le même parser libghostty qui maintient déjà la grille, donc Paneflow ne les relit jamais une seconde fois. libghostty transmet le statut parsé à un callback, et la boucle d'événements du host le transforme en enregistrement :
ghostty::BackendEvent::ProgramStatus(report) => {
self.program_status.apply(report);
self.program_status_capable = true;
}Un thread du host passe sur les sessions toutes les 500 ms, saute celles où rien n'a bougé, et n'écrit la déclaration courante dans le manifeste de la session que si elle a changé. Le worker suit le host par un flux en push.
Le worker réduit. paneflow-serve se place entre le host et chaque client : le desktop, la CLI paneflow, le bridge MCP. Il vérifie d'abord que la session fait tourner un agent connu, puis traduit la déclaration. Voici toute la machine d'état (state.rs) :
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)
}Aucun timer, aucun hook, aucune cloche ne l'alimente. Un processus terminé n'est jamais occupé ni en attente, alors qu'un error survit à la sortie. Chaque changement de l'état réduit incrémente un compteur state_seq, que suivent paneflow wait --idle et paneflow send --submit.
Claude Code demande une permission. Il écrit state=blocked:kind=permission avec la question dans msg. Le worker passe la session en Attention et, comme c'est une transition vers Attention, décide une notification "Needs input" qui porte ce message. Le desktop décide seulement si tu as déjà le panneau sous les yeux. La ligne affiche Need you et remonte en tête de la section Agents de la barre latérale.
Le tour se termine par state=done. Le worker publie une notification "Finished" et marque la session comme non lue. La ligne affiche Done jusqu'à ce que tu ouvres l'onglet. Le host garde l'enregistrement done jusqu'à ce que tu tapes dans le panneau, et le texte envoyé par paneflow send ou par l'outil MCP write_pane ne compte pas comme une frappe.
La forme générale
Chaque processus a un seul rôle. Le host parse et garde les enregistrements, avec des bornes : 256 enregistrements par session, et au-delà d'une rafale de 256 Kio d'événements de statut en attente, il vide tout plutôt que de garder un état partiel. Le worker réduit et décide des notifications, et au redémarrage il reconstruit chaque session à partir des manifestes. Le desktop affiche ce qu'il reçoit par un flux en push, avec des réveils regroupés.
Ce que l'agent déclare, et ce que tu vois :
| Déclaré | Barre latérale |
|---|---|
working | Spinner |
blocked | Need you, plus une notification avec le message de l'agent |
error | Error, plus une notification |
done | Done tant que tu ne l'as pas vu, plus une notification |
idle | Rien |
| Rien au bout de 3 s | No status |
Ce que font encore les hooks
Claude Code et Codex reçoivent toujours les hooks de Paneflow, neuf événements chacun. Ils transportent ce qu'OSC 7501 ne porte pas : l'identifiant de session du fournisseur et le chemin du transcript qui permettent de reprendre une conversation, le prompt qui nomme l'onglet, le dernier résultat qui remplit la notification, le début et la fin des sous-agents. Ils ne changent jamais l'état, et un test le verrouille : a_hook_event_carries_metadata_but_never_moves_the_declared_state.
Un piège à connaître : Claude Code exécute ces hooks de façon synchrone ("async": false, timeout: 5). Le reporter se donne 350 ms pour se connecter et 350 ms pour la requête, puis sort avec le code 0 sans rien écrire sur stdout. Au pire, un Paneflow absent coûte environ 700 ms par événement.
Six approches avant celle-ci
Chaque version précédente de cette fonctionnalité déduisait l'état. Dans l'ordre :
- Les titres de fenêtre. Au départ, un détecteur surveillait les titres OSC pour y trouver des spinners en braille. Je l'ai retiré le 7 avril au profit des hooks d'un script wrapper (
107098da1). - L'activité du PTY. En juin, les agents sans hooks ont eu droit à une heuristique : une rafale de sortie faisait passer l'agent en travail, 3 s de calme le faisaient redescendre, l'écho des frappes était filtré. Une semaine plus tard, je l'ai retirée parce qu'elle "produced false spinners (a dev server streaming under a bare-shell thread, TUI redraws)" (elle allumait de faux spinners) (
4200e8d49). Échec. - Un timer d'inactivité. Un état
Stalledse déclenchait après 300 s de silence. Il est parti en septembre : "A long turn is a long turn, not a fault." (un tour long reste un tour long, pas une panne). - Des sources sans hooks. Une organisation peut couper les hooks de Claude Code avec
disableAllHooksouallowManagedHooksOnly. En août, j'ai ajouté la progression OSC 9;4, les notifications OSC 777 et le fichier de registre de sessions de Claude, classés face aux hooks avec une reprise après 20 s (0f3da66ea). - Un reducer durable. En septembre, les hooks sont devenus l'autorité une fois la session verrouillée, un bail de cinq minutes réarmé par les changements d'écran bornait un
Stopperdu, et un scan du viewport cherchait les menus. En octobre, la cloche du terminal s'est ajoutée comme signal d'attention. - Des règles d'écran déclaratives. Le 2 octobre, chaque agent a reçu un
screen.tomlde règles regex priorisées, avec un catalogue distant signé et un corpus de captures d'écran (c1d48e580). C'est sorti dans la 0.17.6.
Les notes de conception de l'approche 5 listent les symptômes que je n'arrêtais pas de corriger : un Stop perdu laissait un panneau en réflexion pour toujours, et Claude Code documente que Stop ne se déclenche pas quand l'utilisateur interrompt, donc un Échap en plein tour laissait tourner le spinner. Ses menus numérotés ne déclenchent aucun hook, donc une session qui attendait un choix apparaissait comme occupée. Chaque couche existait pour corriger celle du dessous.
Le 7 octobre, j'ai re-pinné libghostty pour récupérer son API OSC 7501. Trois jours plus tard, 328a8fe12 a fait de la déclaration la seule source d'état de tour : 119 fichiers, 1 020 lignes ajoutées, 12 417 supprimées. Les règles d'écran, le catalogue distant, les commandes de capture, l'attention par la cloche et la gestion d'Échap sont tous partis.
Trois choses ont tranché. Claude Code et Codex déclaraient désormais leur état eux-mêmes, et une déclaration de l'agent vaut mieux que n'importe quelle déduction faite de l'extérieur. Les états déduits étaient faux d'une manière que je passais mon temps à rattraper. Et les règles avaient un coût de maintenance à chaque version d'agent : le corpus, la signature du catalogue, les fixtures.
Les règles n'étaient pourtant pas fausses sur leurs fixtures. La baseline du corpus supprimé donnait une précision de 1.0 pour chaque agent, sur 4 à 7 captures chacun. Ce chiffre mesurait les captures que j'avais, pas la prochaine version de l'agent.
L'alternative était de garder les règles en repli pour les agents qui ne déclarent rien. La 0.17.7 n'a aucun repli : "Paneflow offers no replacement: the status comes back once the agent itself emits OSC 7501." (Paneflow ne propose aucun remplacement : le statut revient dès que l'agent émet lui-même OSC 7501).
L'identité est reconnue, l'état ne l'est pas
Paneflow est spécifique à chaque agent à un seul endroit : savoir quel agent tourne dans un panneau. Le catalogue runtimes/ décrit 13 agents, et le host compare le processus au premier plan du panneau à ce catalogue.
Sous Linux, il parcourt /proc/<pid>/task/<tid>/children et lit cmdline. Sous macOS, il utilise proc_listpids(PROC_PGRP_ONLY) et KERN_PROCARGS2. Sous Windows, sans groupes de processus, il parcourt jusqu'à 64 descendants du leader de session et lit la ligne de commande de chacun dans le PEB du processus. Un wrapper node, bun ou python est résolu par le chemin du script, donc un Claude Code installé avec npm qui tourne sous node.exe est quand même reconnu, alors que -e, -c et -m ne désignent jamais un agent. Le start time du noyau est lu avant et après les arguments, donc un pid recyclé n'est jamais attribué au mauvais agent.
Astuce : l'alias fx est aussi le nom d'un visualiseur JSON. Paneflow ne compte fx comme agent que tant que le titre du panneau commence par fx v.
Une mauvaise correspondance peut se tromper sur l'agent qui tourne dans un panneau. Elle ne peut pas inventer ce que fait cet agent.
Ce que ça coûte
Sous Windows 11 Pro, Ryzen 7 7800X3D, RTX 4070 Ti SUPER, build release de la 0.18.0 (b57da648), un panneau a fait tourner pendant 62 s un programme de test qui déclarait working via OSC 7501 (résumé du bench) :
| Processus | CPU (un cœur) |
|---|---|
| Host | 0,00 % |
| Worker | 0,05 % |
| Desktop | 4,80 %, dont 3,66 % sur le thread principal |
Le desktop a dessiné 11,13 rendus racine par seconde, et ce sont ceux du spinner. Une seule horloge d'animation partagée à 90 ms anime tous les spinners de la barre latérale, des tests fixent entre 10 et 12 réveils par seconde de la barre latérale pour 1 ou 8 agents au travail, et reduce_motion dessine un spinner statique sans aucune horloge.
La latence a eu son propre correctif. Le host lit déjà OSC 133 A via l'intégration shell, et depuis le 31 octobre un début de prompt réveille le scan au lieu d'attendre le prochain passage à 500 ms. Le délai entre la sortie de l'agent et sa libération est passé de 434 à 491 ms à 6 à 9 ms, sur cinq cycles (96c525144).
Important : l'argument CPU contre les règles d'écran est plus faible qu'il n'y paraît. Les miennes tournaient à "20 rules on 200x60 evaluate in 24 us p95". Ce que j'ai supprimé, c'est la maintenance et les états faux, pas une boucle coûteuse. Aucun benchmark n'isole le coût d'OSC 7501 lui-même, puisqu'il profite d'un parsing que le terminal fait de toute façon.
Et les agents qui ne disent rien ?
C'est l'objection la plus sérieuse, et elle est légitime. Claude Code et Codex déclarent. Tout autre agent du catalogue n'obtient un état de tour qu'une fois qu'il émet lui aussi OSC 7501 ; d'ici là, il n'en a pas. Après 3 s sans requête de support ni déclaration, la ligne affiche un badge atténué No status au lieu de faire semblant d'être inactive, avec une infobulle qui nomme la première version qui rapporte son état, quand Paneflow la connaît (Claude Code 2.1.295).
Pour un tel agent, paneflow send --submit répond no_signal, et paneflow wait --idle attend que la sortie se calme au lieu de rendre la main à la fin du tour.
Quelques pièges tirés de la doc terminal de Claude Code : dans tmux, Claude Code demande le support à tmux et pas à ton terminal ; une session en arrière-plan ne rapporte pas son statut ; et CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 coupe le rapport. Pour Codex, je ne sais pas encore quelle release embarque la première les deux PR.
Herdr prend l'autre côté de ce compromis et affiche un état pour plus d'agents aujourd'hui. Pour Claude Code et Codex, sa doc d'intégration indique que l'état "comes from Herdr's screen manifest detection" (vient de la détection par manifeste d'écran), les hooks rapportant l'identité de session ; pour Pi, OpenCode et quelques autres, il utilise l'état que rapportent leurs hooks. Son manifeste Claude est un travail soigné : des règles priorisées sur des régions de l'écran, avec des commentaires qui suivent les glyphes du spinner de Claude Code version après version. Mitchell a noté que l'historique de ce fichier "shows ten changes in three months for just Claude Code" (montre dix changements en trois mois rien que pour Claude Code), en ajoutant que ce n'est pas une critique de Herdr. Je suis d'accord. C'est le coût que j'ai choisi de ne pas payer, et No status en est le prix.
Merci à Mitchell Hashimoto d'avoir écrit la spec, au projet Ghostty pour le parser libghostty-vt qui transmet le statut à Paneflow, et aux équipes de Claude Code et de Codex de l'avoir livré.
Si tu maintiens une CLI qui attend parfois un humain, émets OSC 7501. Tu peux voir ce qu'un terminal en fait en lançant ceci dans un panneau Paneflow, où l'en-tête affiche blocked: permission · Apply? pendant dix secondes :
printf '\033]7501;state=blocked:kind=permission:app=deploy:msg=QXBwbHk/\033\\'; sleep 10