Voici ce que Claude Code 2.1.303 écrit dans son terminal quand il demande à exécuter une commande. Les octets viennent d'une vraie capture qu'osFoundry conserve comme fixture de test (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 ...
(Les retours à la ligne sont ajoutés pour la lecture. La capture n'en contient aucun.)
Deux choses là-dedans cassent le détecteur évident. Il n'y a pas d'espace entre "Do" et "you" : \x1b[5G place le curseur en colonne 5, et l'espace n'est qu'une cellule jamais écrite. Et il n'y a aucun \n : chaque ligne commence par \r suivi d'un curseur-vers-le-bas \x1b[1B. Les 11 captures de Claude Code du dossier de fixtures (de 2,099 à 7,672 octets chacune) contiennent zéro octet de saut de ligne.
L'application de bureau d'osFoundry répond à la question « cet agent est-il en train de m'attendre ? » pour chaque panneau de terminal de son tableau Sessions. Ci-dessous : ce qu'elle fait, pourquoi, et un petit superviseur en Python qui compare les trois approches habituelles (motifs de prompt, minuteurs d'inactivité sur la sortie, état du processus) sur un PTY réel. Il n'y a pas de binaire claude sur ce Mac, donc la partie en direct utilise un faux agent qui reprend la formulation des prompts de Claude Code et sa façon de redessiner l'écran. La partie fixtures utilise les vrais octets capturés.
Sur quoi osFoundry fait-il réellement correspondre ?
Sur un fichier TOML par agent. Celui de Claude Code est apps/web/src-tauri/resources/agent-detection/claude.toml, et le même fichier est servi à https://osfoundry.io/agent-detection/claude.toml. Ses règles, dans l'ordre :
[[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']
La première règle qui correspond l'emporte (CompiledManifest::evaluate dans runtime/detect.rs). blocked passe en premier parce que le pied de page de Claude Code affiche « esc to interrupt » pendant qu'un prompt est ouvert. Si working était testé d'abord, le prompt ne serait jamais vu. Un écran qu'aucune règle ne reconnaît est signalé comme idle, jamais comme blocked.
Les commentaires du fichier expliquent ce qui a été retiré et pourquoi. Would you like to a été supprimé parce que c'est la formulation des prompts de Codex, et que les réponses ordinaires de Claude disent « Would you like me to… » en permanence.
runtime/mod.rs fait un passage sur tous les panneaux toutes les 700 ms (SWEEP_INTERVAL) au lieu de classer à chaque écriture. Un spinner se redessine de nombreuses fois par seconde, et classer à chaque écriture reviendrait à lancer les regex des centaines de fois par seconde pour obtenir la même réponse.
Pourquoi ne pas faire un grep sur les derniers Ko de sortie ?
À cause des octets du début. Ici, les règles sont portées en Python et appliquées à chaque fixture de Claude Code de deux façons. La première prend les 8 derniers Ko, retire les séquences d'échappement et garde, pour chaque ligne, le texte situé après le dernier \r. La seconde envoie les octets dans un émulateur d'écran 32×120 (une centaine de lignes, vt.py plus bas) et relit les lignes.
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
La fin nettoyée tombe juste sur tous les idle uniquement parce que idle est la valeur par défaut. Elle ne voit jamais de prompt. Sans \n, toute la capture forme une seule « ligne », et ne garder que le texte après le dernier \r jette tout sauf le dernier fragment. Même sans cette étape, les mots ressortent collés : Doyouwanttoproceed?.
Le code d'osFoundry dit la même chose. terminal_pty.rs garde un vt100::Parser par session, et le passage classe screen_text(), c'est-à-dire uniquement l'écran émulé. Les 200 lignes d'historique conservées au-dessus sont exclues volontairement. Le commentaire l'explique : un prompt auquel l'utilisateur a déjà répondu et qui est remonté avec le défilement « would report the agent as blocked forever ».
L'émulateur n'a pas besoin d'être bon. Celui-ci gère le texte, \r \n \b, les déplacements du curseur (A B C D E F G H f d) et l'effacement (J K), et ignore les couleurs, les modes et les titres OSC. Il place un caractère par cellule, donc un glyphe large peut décaler une colonne. Pour des regex appliquées à des lignes entières, ça ne change rien. Il rend les 11 captures suffisamment bien pour les règles :
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
Pourquoi ne pas simplement attendre que la sortie s'arrête ?
C'est le détecteur que la plupart des gens écrivent en premier : si l'agent n'a rien affiché depuis N secondes, il a sans doute besoin de vous. Pour le confronter aux règles d'écran, un superviseur lance un agent dans un PTY et note toutes les 50 ms ce que pense chaque détecteur.
L'agent est fake_agent.py, un remplaçant, puisqu'aucun binaire claude n'est installé sur ce Mac. Il reproduit les habitudes qui comptent :
- un spinner redessiné avec
\rà 10 Hz, 60 octets par image, contenant « esc to interrupt » - des appels d'outils qui n'affichent rien pendant leur exécution (de 0.5 à 6 s), comme beaucoup de CLI
- une demande d'autorisation avec les mots de Claude Code, chaque mot placé par
CSI n Gcomme dans la vraie capture, puis effacée à la manière d'Ink avec curseur-vers-le-haut et\x1b[June fois la réponse donnée - un prompt inactif avec
? for shortcutsà la fin de chaque tour, parfois après une réponse qui se termine par « Would you like me to add a regression test? » - dans le prompt comme dans l'état inactif, le processus est bloqué dans
readline()sur le PTY
Il écrit son état réel dans un fichier à part que seul l'évaluateur lit. Un bot joue l'utilisateur : il répond à chaque prompt 3 à 8 s après son apparition, et tape la tâche suivante 3 à 10 s après la fin d'un tour. Le cœur du superviseur, raccourci :
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
Une exécution avec la graine 7 : 30 tâches, 10.6 minutes, 25 demandes d'autorisation, 144 s passées à attendre leur réponse. La latence est le temps entre l'affichage du prompt et le moment où le détecteur dit « a besoin de vous ». La rémanence est la durée pendant laquelle il continue à le dire après la réponse. Les fausses alertes sont comptées en épisodes / secondes au total, séparées selon ce que l'agent faisait vraiment.
| Détecteur | Latence médiane | Max | Manqués | Rémanence | Fausses, agent au travail | Fausses, agent inactif |
|---|---|---|---|---|---|---|
| Règles d'écran, toutes les 50 ms | 0.03 s | 0.06 s | 0 / 25 | 0 s | 0 | 0 |
| Règles d'écran, passage de 700 ms | 0.26 s | 0.70 s | 0 / 25 | 0.4 s | 0 | 0 |
| Mêmes règles sur la fin nettoyée de 8 Ko | 0.03 s | 0.06 s | 0 / 25 | 77.0 s | 0 | 0 |
| Une ligne d'écran qui finit par « ? » | 0.03 s | 0.06 s | 0 / 25 | 58.0 s | 0 / 231 s | 4 / 143 s |
| Sortie silencieuse ≥ 0.5 s | 0.52 s | 0.56 s | 0 / 25 | 0 s | 31 / 78 s | 30 / 168 s |
| Sortie silencieuse ≥ 1 s | 1.02 s | 1.06 s | 0 / 25 | 0 s | 28 / 63 s | 30 / 153 s |
| Sortie silencieuse ≥ 2 s | 2.03 s | 2.06 s | 0 / 25 | 0 s | 21 / 38 s | 30 / 123 s |
| Sortie silencieuse ≥ 3 s | 3.02 s | 3.06 s | 0 / 25 | 0 s | 17 / 19 s | 30 / 93 s |
| Sortie silencieuse ≥ 5 s | 5.02 s | 5.05 s | 9 / 25 | 0 s | 3 / 1 s | 18 / 46 s |
| Sortie silencieuse ≥ 8 s | – | – | 25 / 25 | 0 s | 0 | 6 / 7 s |
Aucun seuil ne fonctionne pour un minuteur d'inactivité. Sa latence est le seuil, par définition. En dessous de 5 s, il se déclenche à chaque appel d'outil silencieux. À tous les seuils jusqu'à 3 s, il se déclenche sur les 30 fins de tour, parce que « terminé, j'attends la tâche suivante » est exactement aussi silencieux que « j'attends une autorisation ». À partir de 5 s, il commence à rater des prompts auxquels quelqu'un a répondu à temps. La ligne à 8 s rate les 25 uniquement parce que le bot n'attend jamais plus de 8 s, mais le constat vaut pour n'importe quel temps de réponse : le détecteur est soit bruyant, soit en retard.
Une ligne d'écran qui finit par « ? » attrape tous les prompts, et sur les vraies captures elle paraîtrait parfaite : seules les quatre fixtures blocked contiennent une telle ligne. Dans l'exécution en direct, elle se déclenche sur la propre question de l'agent, « Would you like me to add a regression test? », et cette ligne reste à l'écran jusque dans la tâche suivante. C'est la même raison pour laquelle claude.toml a retiré Would you like to.
La fin nettoyée détecte les prompts dans cette exécution parce que le faux agent écrit ❯ 1. Yes avec de vrais espaces. Ensuite, elle continue à signaler le prompt pendant 77 s en moyenne après la réponse, parce que les octets du prompt effacé restent dans les 8 derniers Ko jusqu'à ce qu'environ 8 Ko de nouvelle sortie les poussent dehors. À 600 octets de spinner par seconde, plus les périodes silencieuses, ça prend longtemps. L'écran émulé oublie le prompt dès que le curseur-vers-le-haut et l'effacement arrivent.
Le passage de 700 ms coûte ce qu'on attend : jusqu'à un intervalle de latence (médiane 0.26 s, max 0.70 s) et jusqu'à un intervalle de rémanence. Pour un badge regardé par une personne, c'est très bien.
Le système d'exploitation ne peut-il pas dire que le processus lit stdin ?
Pas sous macOS. Le superviseur a échantillonné ps -o stat=,time= pour l'agent deux fois par seconde :
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
Un processus bloqué dans read() sur son terminal et un processus qui dort entre deux images du spinner sont tous les deux S. La colonne wchan, qui sur certains systèmes indique le canal d'attente du noyau, affichait - dans les deux cas. Le temps CPU ne les distingue pas non plus, parce qu'un spinner ne coûte presque rien. Une CLI basée sur Node comme Claude Code attend dans sa boucle d'événements qu'elle travaille ou non, donc sa lettre d'état vous en dirait encore moins.
Ce que la table des processus dit bien, c'est quel programme détient le terminal. osFoundry lit le groupe de processus au premier plan du tty (MasterPty::process_group_leader, autrement dit tcgetpgrp) dans runtime/signals.rs. Ça lui indique que le panneau exécute claude même s'il a été lancé à la main dans un shell ordinaire. Ça lui indique aussi quand l'agent s'est terminé et que le shell est revenu au premier plan, alors que le dernier prompt de l'agent est encore affiché.
Existe-t-il mieux que lire l'écran ?
Oui : que l'agent le dise lui-même. runtime/signals.rs pondère trois sources. Quand osFoundry lance Claude Code lui-même, runtime/integrations.rs ajoute des hooks via --settings. Ce flag ajoute des réglages pour ce lancement uniquement, et les hooks de l'utilisateur continuent de s'exécuter.
{"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 déclenche Notification à la fois pour une demande d'autorisation et après une minute d'attente d'une saisie. osf-pane notify lit donc le payload et associe permission à blocked, et tout le reste à idle. Via ACP (avec l'adaptateur claude-agent-acp déclaré dans claude.toml), une requête session/request_permission est l'agent qui demande explicitement l'autorisation : un fait, pas une déduction.
C'est l'ordre dans signals::combine qui est intéressant. Un blocked lu à l'écran l'emporte toujours. Viennent ensuite un rapport récent de l'agent, puis « le shell est revenu au premier plan », puis les autres règles d'écran. Un rapport peut être périmé ou absent, par exemple quand un hook a échoué sans rien dire. Un prompt à l'écran est une règle précise et la seule alerte qui ne doit jamais être supprimée.
Deux décisions plus petites en découlent. Les notifications ont un délai de 30 s par session et par état (runtime/notify.rs), parce qu'un prompt qui se redessine peut osciller blocked → working → blocked. Et avant qu'un outil quelconque tape dans un panneau, runtime/tools.rs lance un classify_session frais au lieu de se fier au dernier passage. Un panneau en blocked n'accepte que runtime_answer, que l'utilisateur confirme, jamais le runtime_send ordinaire.
Si vous le mettez en place vous-même
- Classez un écran émulé, pas une fin de flux d'octets. Claude Code place les mots par déplacements du curseur, sans sauts de ligne, et un prompt auquel on a répondu reste dans les octets pendant des minutes.
- Faites correspondre la formulation et le pied de page du prompt lui-même (
Esc to cancel · Tab to amend), pas « une question » ni « silencieux depuis N secondes ». Vus de l'extérieur, les fins de tour et les appels d'outils silencieux ressemblent exactement à un prompt. - Placez les règles
blockedau-dessus des règlesworking, et faites en sorte qu'« aucune règle ne correspond » signifie idle. Un faux « a besoin de vous » coûte plus cher qu'un signal en retard. - Gardez les règles dans un fichier de données que vous pouvez mettre à jour sans nouvelle version, et gardez-en à l'écart le programme qui les exécute.
- Utilisez la table des processus pour savoir ce qui tourne et quand ça s'arrête, pas pour détecter l'attente. Utilisez des hooks ou ACP quand vous lancez l'agent vous-même, mais laissez un prompt à l'écran primer sur un rapport.
- Un passage toutes les quelques centaines de millisecondes suffit pour une personne. Revérifiez juste avant de taper dans le panneau.
Le code tient en quatre petits fichiers, vt.py, rules.py, fake_agent.py et supervisor.py, plus fixtures.py et analyze.py pour les tableaux. L'écran qui affiche le résultat est le tableau Sessions pour les sessions d'agents dans l'application de bureau osFoundry.
Top comments (0)