Dev.to AI 🤖 Ai 👁 0 📖 11 min read

Wie osFoundry erkennt, dass Claude Code auf eine Berechtigungsabfrage wartet

Das hier schreibt Claude Code 2.1.303 in sein Terminal, wenn es fragt, ob es einen Befehl ausführen darf. Die Bytes stammen aus einem echten Mitschnitt, den osFoundry als Test-Fixture aufbewahrt (resources/agent-detectio

Das hier schreibt Claude Code 2.1.303 in sein Terminal, wenn es fragt, ob es einen Befehl ausführen darf. Die Bytes stammen aus einem echten Mitschnitt, den osFoundry als Test-Fixture aufbewahrt (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 ...

(Die Zeilenumbrüche sind zur Lesbarkeit eingefügt. Der Mitschnitt hat keine.)

Zwei Dinge darin machen den naheliegenden Detektor kaputt. Zwischen "Do" und "you" steht kein Leerzeichen. \x1b[5G setzt den Cursor auf Spalte 5, und das Leerzeichen ist einfach eine Zelle, die nie beschrieben wurde. Und nirgends gibt es ein \n: Jede Zeile beginnt mit \r und einem Cursor-nach-unten \x1b[1B. Alle 11 Claude-Code-Mitschnitte im Fixture-Ordner (je 2,099 bis 7,672 Bytes) enthalten null Newline-Bytes.

Die Desktop-App von osFoundry beantwortet für jedes Terminal-Pane auf ihrem Sessions-Board die Frage "Wartet dieser Agent gerade auf mich?". Unten steht, was sie tut, warum, und ein kleiner Python-Supervisor, der die drei üblichen Ansätze (Prompt-Muster, Idle-Timer auf die Ausgabe, Prozesszustand) an einem echten PTY vergleicht. Auf diesem Mac gibt es kein claude-Binary, deshalb läuft im Live-Teil ein Fake-Agent, der die Prompt-Formulierungen und das Redraw-Verhalten von Claude Code nachahmt. Der Fixture-Teil nutzt die echten mitgeschnittenen Bytes.

Worauf matcht osFoundry eigentlich?

Auf eine TOML-Datei pro Agent. Die für Claude Code ist apps/web/src-tauri/resources/agent-detection/claude.toml, und dieselbe Datei wird unter https://osfoundry.io/agent-detection/claude.toml ausgeliefert. Ihre Regeln, in Reihenfolge:

[[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']

Die erste passende Regel gewinnt (CompiledManifest::evaluate in runtime/detect.rs). blocked steht vorne, weil die Fußzeile von Claude Code "esc to interrupt" anzeigt, während ein Prompt offen ist. Würde working zuerst geprüft, würde der Prompt nie erkannt. Ein Bildschirm, auf den keine Regel passt, wird als idle gemeldet, nie als blocked.

Die Kommentare in der Datei erklären, was entfernt wurde und warum. Would you like to flog raus, weil das die Prompt-Formulierung von Codex ist und Claude in ganz normalen Antworten ständig "Would you like me to…" schreibt.

runtime/mod.rs läuft alle 700 ms einmal über alle Panes (SWEEP_INTERVAL), statt bei jedem Schreibvorgang zu klassifizieren. Ein Spinner zeichnet sich viele Male pro Sekunde neu, und bei jedem Write zu klassifizieren hieße, die Regexes hunderte Male pro Sekunde laufen zu lassen, nur um dieselbe Antwort zu bekommen.

Warum nicht einfach die letzten paar KB der Ausgabe greppen?

Wegen der Bytes ganz oben. Hier sind die Regeln nach Python portiert und laufen auf zwei Arten über jedes Claude-Code-Fixture. Variante eins nimmt die letzten 8 KB, entfernt Escape-Sequenzen und behält pro Zeile den Text nach dem letzten \r. Variante zwei füttert die Bytes in einen 32×120-Bildschirmemulator (etwa 100 Zeilen, vt.py weiter unten) und liest die Zeilen wieder aus.

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

Der bereinigte Tail liegt bei jedem idle nur deshalb richtig, weil idle der Fallback ist. Einen Prompt sieht er nie. Ohne \n ist der ganze Mitschnitt eine einzige "Zeile", und wer nur den Text nach dem letzten \r behält, wirft alles außer dem letzten Fragment weg. Selbst ohne diesen Schritt kleben die Wörter zusammen: Doyouwanttoproceed?.

Der Code von osFoundry sagt dasselbe. terminal_pty.rs hält pro Session einen vt100::Parser, und der Sweep klassifiziert screen_text(), also nur den emulierten Bildschirm. Die 200 Zeilen Verlauf darüber bleiben absichtlich draußen. Der Kommentar dazu: Ein bereits beantworteter Prompt, der nach oben weggescrollt ist, "would report the agent as blocked forever".

Der Emulator muss nicht gut sein. Dieser hier kann Text, \r \n \b, Cursorbewegungen (A B C D E F G H f d) und Löschen (J K) und überspringt Farben, Modi und OSC-Titel. Er legt ein Zeichen pro Zelle ab, ein breites Glyph kann also eine Spalte verschieben. Regexes über ganze Zeilen stört das nicht. Für die Regeln stellt er alle 11 Mitschnitte gut genug dar:

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

Warum nicht einfach warten, bis keine Ausgabe mehr kommt?

Das ist der Detektor, den die meisten zuerst schreiben: Wenn der Agent N Sekunden lang nichts ausgegeben hat, will er wahrscheinlich etwas von dir. Um ihn gegen die Bildschirmregeln zu testen, startet ein Supervisor einen Agenten in einem PTY und protokolliert alle 50 ms, was jeder Detektor meint.

Der Agent ist fake_agent.py, ein Platzhalter, weil auf diesem Mac kein claude-Binary installiert ist. Er übernimmt die Eigenheiten, auf die es ankommt:

  • ein Spinner, der mit \r bei 10 Hz neu gezeichnet wird, 60 Bytes pro Frame, mit "esc to interrupt" darin
  • Tool-Aufrufe, die während der Laufzeit nichts ausgeben (0.5 bis 6 s), wie bei vielen CLIs
  • eine Berechtigungsabfrage im Wortlaut von Claude Code, jedes Wort per CSI n G platziert wie im echten Mitschnitt, und nach der Antwort im Ink-Stil per Cursor-nach-oben und \x1b[J gelöscht
  • ein Idle-Prompt mit ? for shortcuts am Ende jedes Turns, manchmal nach einer Antwort, die mit "Would you like me to add a regression test?" endet
  • sowohl beim Prompt als auch im Idle-Zustand blockiert der Prozess in readline() auf dem PTY

Seinen tatsächlichen Zustand schreibt er in eine Nebendatei, die nur der Auswerter liest. Ein Bot spielt den Nutzer: Er beantwortet jeden Prompt 3 bis 8 s nach dem Erscheinen und tippt die nächste Aufgabe 3 bis 10 s nach dem Ende eines Turns. Der Kern des Supervisors, gekürzt:

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

Ein Lauf mit Seed 7: 30 Aufgaben, 10.6 Minuten, 25 Berechtigungsabfragen, 144 s Wartezeit darauf. Latenz ist die Zeit vom Zeichnen des Prompts, bis der Detektor "braucht dich" meldet. Nachlauf ist, wie lange er das nach der Antwort noch weiter gemeldet hat. Fehlalarme werden als Episoden / Sekunden gesamt gezählt, aufgeteilt danach, was der Agent wirklich gerade tat.

Detektor Median-Latenz Max Verpasst Nachlauf Falsch, Agent arbeitet Falsch, Agent idle
Bildschirmregeln, alle 50 ms 0.03 s 0.06 s 0 / 25 0 s 0 0
Bildschirmregeln, 700-ms-Sweep 0.26 s 0.70 s 0 / 25 0.4 s 0 0
Dieselben Regeln auf bereinigtem 8-KB-Tail 0.03 s 0.06 s 0 / 25 77.0 s 0 0
Eine Bildschirmzeile, die auf "?" endet 0.03 s 0.06 s 0 / 25 58.0 s 0 / 231 s 4 / 143 s
Ausgabe still ≥ 0.5 s 0.52 s 0.56 s 0 / 25 0 s 31 / 78 s 30 / 168 s
Ausgabe still ≥ 1 s 1.02 s 1.06 s 0 / 25 0 s 28 / 63 s 30 / 153 s
Ausgabe still ≥ 2 s 2.03 s 2.06 s 0 / 25 0 s 21 / 38 s 30 / 123 s
Ausgabe still ≥ 3 s 3.02 s 3.06 s 0 / 25 0 s 17 / 19 s 30 / 93 s
Ausgabe still ≥ 5 s 5.02 s 5.05 s 9 / 25 0 s 3 / 1 s 18 / 46 s
Ausgabe still ≥ 8 s – – 25 / 25 0 s 0 6 / 7 s

Für einen Idle-Timer gibt es keinen Schwellwert, der funktioniert. Seine Latenz ist per Definition der Schwellwert. Unter 5 s schlägt er bei jedem stillen Tool-Aufruf an. Bei jedem Schwellwert bis 3 s schlägt er bei allen 30 Turn-Enden an, denn "fertig, warte auf die nächste Aufgabe" ist genauso still wie "warte auf eine Berechtigung". Ab 5 s verpasst er Prompts, die jemand rechtzeitig beantwortet hat. Die 8-s-Zeile verpasst alle 25 nur, weil der Bot nie länger als 8 s wartet, aber das Muster gilt für jede Antwortzeit: Der Detektor ist entweder laut oder zu spät.

Eine Bildschirmzeile, die auf "?" endet, erwischt jeden Prompt, und auf den echten Mitschnitten sähe das perfekt aus: Nur die vier blocked-Fixtures haben so eine Zeile. Im Live-Lauf schlägt sie aber bei der eigenen Frage des Agenten an, "Would you like me to add a regression test?", und diese Zeile bleibt bis in die nächste Aufgabe hinein auf dem Bildschirm. Aus genau diesem Grund hat claude.toml Would you like to gestrichen.

Der bereinigte Tail erkennt in diesem Lauf Prompts, weil der Fake-Agent ❯ 1. Yes mit echten Leerzeichen schreibt. Danach meldet er den Prompt aber im Schnitt noch 77 s nach der Antwort, weil die Bytes des gelöschten Prompts in den letzten 8 KB bleiben, bis etwa 8 KB neue Ausgabe sie hinausschieben. Bei 600 Bytes Spinner pro Sekunde plus den stillen Phasen dauert das lange. Der emulierte Bildschirm vergisst den Prompt, sobald Cursor-nach-oben und Löschen ankommen.

Der 700-ms-Sweep kostet, was man erwartet: bis zu ein Intervall Latenz (Median 0.26 s, max 0.70 s) und bis zu ein Intervall Nachlauf. Für ein Badge, auf das ein Mensch schaut, ist das in Ordnung.

Kann das Betriebssystem nicht sagen, dass der Prozess von stdin liest?

Unter macOS nicht. Der Supervisor hat für den Agenten zweimal pro Sekunde ps -o stat=,time= abgefragt:

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

Ein Prozess, der in read() auf seinem Terminal blockiert, und ein Prozess, der zwischen zwei Spinner-Frames schläft, sind beide S. Die Spalte wchan, die auf manchen Systemen den Wait-Channel des Kernels nennt, zeigte bei beiden -. Auch die CPU-Zeit trennt sie nicht, weil ein Spinner fast nichts kostet. Eine Node-basierte CLI wie Claude Code wartet in ihrer Event-Loop, egal ob sie arbeitet oder nicht, ihr Statusbuchstabe würde dir also noch weniger verraten.

Was die Prozesstabelle sehr wohl beantwortet: welches Programm das Terminal gerade hält. osFoundry liest in runtime/signals.rs die Vordergrund-Prozessgruppe des tty (MasterPty::process_group_leader, also tcgetpgrp). So weiß es, dass im Pane claude läuft, auch wenn es in einer ganz normalen Shell eingetippt wurde. Und es merkt, wenn der Agent beendet ist und die Shell wieder vorne ist, während der letzte Prompt des Agenten noch auf dem Bildschirm steht.

Gibt es etwas Besseres, als den Bildschirm zu lesen?

Ja: Der Agent sagt es selbst. runtime/signals.rs gewichtet drei Quellen. Wenn osFoundry Claude Code selbst startet, fügt runtime/integrations.rs per --settings Hooks hinzu. Dieses Flag ergänzt Settings nur für diesen einen Start, die eigenen Hooks des Nutzers laufen weiterhin.

{"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 feuert Notification sowohl bei einer Berechtigungsabfrage als auch nach einer Minute Warten auf Eingabe. Deshalb liest osf-pane notify den Payload und bildet permission auf blocked ab, alles andere auf idle. Über ACP (mit dem in claude.toml deklarierten Adapter claude-agent-acp) ist ein session/request_permission-Request der Agent, der ausdrücklich um Erlaubnis bittet, also eine Tatsache und keine Schlussfolgerung.

Spannend ist die Reihenfolge in signals::combine. Ein vom Bildschirm gelesenes blocked gilt immer. Danach kommt ein frischer Report des Agenten, dann "die Shell ist wieder vorne", dann die übrigen Bildschirmregeln. Ein Report kann veraltet sein oder fehlen, etwa wenn ein Hook still fehlgeschlagen ist. Ein Prompt auf dem Bildschirm ist eine präzise Regel und der eine Alarm, der nie unterdrückt werden darf.

Daraus folgen zwei kleinere Entscheidungen. Benachrichtigungen haben einen Cooldown von 30 s pro Session und Zustand (runtime/notify.rs), weil ein Prompt, der neu gezeichnet wird, zwischen blocked → working → blocked flattern kann. Und bevor irgendein Tool in ein Pane tippt, führt runtime/tools.rs ein frisches classify_session aus, statt dem letzten Sweep zu vertrauen. Ein Pane im Zustand blocked akzeptiert nur runtime_answer, das der Nutzer bestätigt, nie das normale runtime_send.

Wenn du das selbst verdrahtest

  • Klassifiziere einen emulierten Bildschirm, keinen Byte-Tail. Claude Code platziert Wörter per Cursorbewegung und hat keine Newlines, und ein beantworteter Prompt bleibt minutenlang in den Bytes.
  • Matche auf den Wortlaut und die Fußzeile des Prompts selbst (Esc to cancel · Tab to amend), nicht auf "eine Frage" oder "N Sekunden still". Turn-Enden und stille Tool-Aufrufe sehen von außen genauso aus wie ein Prompt.
  • Setz die blocked-Regeln über die working-Regeln und lass "keine Regel passt" idle bedeuten. Ein falsches "braucht dich" kostet mehr als ein spätes.
  • Halte die Regeln in einer Datendatei, die du ohne Release aktualisieren kannst, und das Programm, das sie ausführt, getrennt davon.
  • Nutze die Prozesstabelle, um zu wissen, was läuft und wann es beendet ist, nicht zum Erkennen von Warten. Nutze Hooks oder ACP, wenn du den Agenten selbst startest, aber lass einen Prompt auf dem Bildschirm Vorrang vor einem Report haben.
  • Für einen Menschen reicht ein Sweep alle paar hundert Millisekunden. Prüf direkt vor dem Tippen ins Pane noch einmal neu.

Der Code besteht aus vier kleinen Dateien, vt.py, rules.py, fake_agent.py und supervisor.py, plus fixtures.py und analyze.py für die Tabellen. Der Bildschirm, der das Ergebnis anzeigt, ist das Sessions-Board für Agent-Sessions in der osFoundry-Desktop-App.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.