DEV Community

Cover image for Come osFoundry capisce che Claude Code sta aspettando una richiesta di permesso
BBT for BBT Italiano

Posted on

Come osFoundry capisce che Claude Code sta aspettando una richiesta di permesso

Questo è ciò che Claude Code 2.1.303 scrive nel suo terminale quando chiede di eseguire un comando. I byte arrivano da una cattura reale che osFoundry conserva come fixture di 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 ...
Enter fullscreen mode Exit fullscreen mode

(Gli a capo sono aggiunti per leggibilità. La cattura non ne ha.)

Ci sono due cose qui dentro che rompono il rilevatore più ovvio. Tra "Do" e "you" non c'è nessuno spazio: \x1b[5G sposta il cursore alla colonna 5, e lo spazio è solo una cella mai toccata. E non c'è nessun \n: ogni riga inizia con \r e un cursore-giù \x1b[1B. Tutte le 11 catture di Claude Code nella cartella delle fixture (da 2,099 a 7,672 byte ciascuna) contengono zero byte di newline.

L'app desktop di osFoundry risponde alla domanda "questo agente sta aspettando me?" per ogni pannello di terminale della sua board Sessions. Qui sotto trovi cosa fa, perché, e un piccolo supervisore in Python che confronta i tre approcci classici (pattern sul prompt, timer di inattività sull'output, stato del processo) su un PTY reale. Su questo Mac non c'è un binario claude, quindi la parte dal vivo usa un agente finto che copia le frasi dei prompt di Claude Code e il suo modo di ridisegnare lo schermo. La parte con le fixture usa i byte reali catturati.

Su cosa fa match osFoundry, esattamente?

Su un file TOML per agente. Quello di Claude Code è apps/web/src-tauri/resources/agent-detection/claude.toml, e lo stesso file è servito su https://osfoundry.io/agent-detection/claude.toml. Le sue regole, in ordine:

[[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']
Enter fullscreen mode Exit fullscreen mode

Vince la prima regola che fa match (CompiledManifest::evaluate in runtime/detect.rs). blocked viene prima perché il footer di Claude Code mostra "esc to interrupt" mentre c'è un prompt aperto. Se working venisse controllato prima, il prompt non verrebbe mai visto. Uno schermo su cui non fa match nessuna regola viene segnalato come idle, mai come blocked.

I commenti nel file spiegano cosa è stato tolto e perché. Would you like to è stato eliminato perché è la formulazione dei prompt di Codex, e le normali risposte di Claude dicono "Would you like me to…" in continuazione.

runtime/mod.rs fa un passaggio su tutti i pannelli ogni 700 ms (SWEEP_INTERVAL) invece di classificare a ogni scrittura. Uno spinner si ridisegna molte volte al secondo, e classificare a ogni scrittura vorrebbe dire eseguire le regex centinaia di volte al secondo per ottenere la stessa risposta.

Perché non fare grep sugli ultimi KB di output?

Per via dei byte all'inizio. Qui le regole sono portate in Python ed eseguite su ogni fixture di Claude Code in due modi. Il primo prende gli ultimi 8 KB, rimuove le sequenze di escape e tiene, per ogni riga, il testo dopo l'ultimo \r. Il secondo passa i byte a un emulatore di schermo 32×120 (circa 100 righe, vt.py più sotto) e rilegge le righe.

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
Enter fullscreen mode Exit fullscreen mode

La coda ripulita indovina tutti gli idle solo perché idle è il valore di fallback. Un prompt non lo vede mai. Senza \n, l'intera cattura è un'unica "riga", e tenere solo il testo dopo l'ultimo \r butta via tutto tranne l'ultimo frammento. Anche senza quel passaggio, le parole escono attaccate: Doyouwanttoproceed?.

Il codice di osFoundry dice la stessa cosa. terminal_pty.rs tiene un vt100::Parser per sessione, e il passaggio classifica screen_text(), cioè solo lo schermo emulato. Le 200 righe di cronologia conservate sopra restano fuori di proposito. Il commento lo spiega: un prompt a cui l'utente ha già risposto e che è finito più in alto con lo scroll "would report the agent as blocked forever".

L'emulatore non deve essere un granché. Questo gestisce testo, \r \n \b, spostamenti del cursore (A B C D E F G H f d) e cancellazione (J K), e salta colori, modalità e titoli OSC. Mette un carattere per cella, quindi un glifo largo può spostare una colonna. Alle regex su righe intere non importa. Rende tutte le 11 catture abbastanza bene per le regole:

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
Enter fullscreen mode Exit fullscreen mode

Perché non aspettare semplicemente che l'output si fermi?

È il rilevatore che quasi tutti scrivono per primo: se l'agente non stampa niente da N secondi, probabilmente ha bisogno di te. Per metterlo alla prova contro le regole sullo schermo, un supervisore esegue un agente in un PTY e registra ogni 50 ms cosa pensa ciascun rilevatore.

L'agente è fake_agent.py, un sostituto, visto che su questo Mac non è installato nessun binario claude. Copia le abitudini che contano:

  • uno spinner ridisegnato con \r a 10 Hz, 60 byte per frame, con dentro "esc to interrupt"
  • chiamate a strumenti che non stampano nulla mentre girano (da 0.5 a 6 s), come fanno molte CLI
  • una richiesta di permesso con le parole di Claude Code, ogni parola posizionata con CSI n G come nella cattura reale, poi cancellata in stile Ink con cursore-su e \x1b[J una volta data la risposta
  • un prompt inattivo con ? for shortcuts alla fine di ogni turno, a volte dopo una risposta che finisce con "Would you like me to add a regression test?"
  • sia durante il prompt sia nello stato inattivo, il processo resta bloccato in readline() sul PTY

Scrive il suo stato reale in un file a parte che legge solo il valutatore. Un bot fa la parte dell'utente: risponde a ogni prompt 3-8 s dopo che compare, e digita il task successivo 3-10 s dopo la fine di un turno. Il cuore del supervisore, accorciato:

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
Enter fullscreen mode Exit fullscreen mode

Un'esecuzione con seed 7: 30 task, 10.6 minuti, 25 richieste di permesso, 144 s passati ad aspettare una risposta. La latenza è il tempo tra il momento in cui il prompt viene disegnato e quello in cui il rilevatore dice "ha bisogno di te". La persistenza è per quanto ha continuato a dirlo dopo la risposta. I falsi allarmi sono contati come episodi / secondi totali, divisi in base a cosa stava facendo davvero l'agente.

Rilevatore Latenza mediana Max Mancati Persistenza Falsi, agente al lavoro Falsi, agente inattivo
Regole sullo schermo, ogni 50 ms 0.03 s 0.06 s 0 / 25 0 s 0 0
Regole sullo schermo, passaggio da 700 ms 0.26 s 0.70 s 0 / 25 0.4 s 0 0
Stesse regole sulla coda ripulita da 8 KB 0.03 s 0.06 s 0 / 25 77.0 s 0 0
Una riga dello schermo che finisce con "?" 0.03 s 0.06 s 0 / 25 58.0 s 0 / 231 s 4 / 143 s
Output fermo ≥ 0.5 s 0.52 s 0.56 s 0 / 25 0 s 31 / 78 s 30 / 168 s
Output fermo ≥ 1 s 1.02 s 1.06 s 0 / 25 0 s 28 / 63 s 30 / 153 s
Output fermo ≥ 2 s 2.03 s 2.06 s 0 / 25 0 s 21 / 38 s 30 / 123 s
Output fermo ≥ 3 s 3.02 s 3.06 s 0 / 25 0 s 17 / 19 s 30 / 93 s
Output fermo ≥ 5 s 5.02 s 5.05 s 9 / 25 0 s 3 / 1 s 18 / 46 s
Output fermo ≥ 8 s – – 25 / 25 0 s 0 6 / 7 s

Per un timer di inattività non esiste una soglia che funzioni. La sua latenza è la soglia, per definizione. Sotto i 5 s scatta a ogni chiamata a strumento silenziosa. A ogni soglia fino a 3 s scatta su tutte e 30 le fini di turno, perché "ho finito, aspetto il prossimo task" è silenzioso esattamente quanto "aspetto un permesso". Da 5 s in su comincia a perdere prompt a cui qualcuno ha risposto in tempo. La riga da 8 s li perde tutti e 25 solo perché il bot non aspetta mai più di 8 s, ma lo schema vale per qualsiasi tempo di risposta: il rilevatore o fa rumore o arriva tardi.

Una riga dello schermo che finisce con "?" becca tutti i prompt, e sulle catture reali sembrerebbe perfetta: solo le quattro fixture blocked hanno una riga così. Nell'esecuzione dal vivo però scatta sulla domanda dell'agente stesso, "Would you like me to add a regression test?", e quella riga resta sullo schermo anche durante il task successivo. È lo stesso motivo per cui claude.toml ha eliminato Would you like to.

La coda ripulita rileva i prompt in questa esecuzione perché l'agente finto scrive ❯ 1. Yes con spazi veri. Poi però continua a segnalare il prompt in media per 77 s dopo la risposta, perché i byte del prompt cancellato restano negli ultimi 8 KB finché circa 8 KB di nuovo output non li spingono fuori. Con 600 byte al secondo di spinner, più i tratti silenziosi, ci vuole parecchio. Lo schermo emulato dimentica il prompt appena arrivano il cursore-su e la cancellazione.

Il passaggio da 700 ms costa quello che ti aspetti: fino a un intervallo di latenza (mediana 0.26 s, max 0.70 s) e fino a un intervallo di persistenza. Per un badge che guarda una persona, va benissimo.

Il sistema operativo non può dirti che il processo sta leggendo stdin?

Su macOS no. Il supervisore ha campionato ps -o stat=,time= per l'agente due volte al secondo:

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
Enter fullscreen mode Exit fullscreen mode

Un processo bloccato in read() sul suo terminale e un processo che dorme tra un frame e l'altro dello spinner sono entrambi S. La colonna wchan, che su alcuni sistemi indica il canale di attesa del kernel, ha stampato - per tutti e due. Nemmeno il tempo CPU li distingue, perché uno spinner non costa quasi nulla. Una CLI basata su Node come Claude Code aspetta nel suo event loop sia quando lavora sia quando no, quindi la sua lettera di stato ti direbbe ancora meno.

Quello a cui la tabella dei processi risponde davvero è quale programma ha in mano il terminale. osFoundry legge il gruppo di processi in primo piano del tty (MasterPty::process_group_leader, cioè tcgetpgrp) in runtime/signals.rs. Così sa che nel pannello gira claude anche quando è stato digitato in una shell normale. E sa anche quando l'agente è uscito e la shell è tornata in primo piano, mentre l'ultimo prompt dell'agente è ancora sullo schermo.

C'è qualcosa di meglio che leggere lo schermo?

Sì: farlo dire all'agente. runtime/signals.rs pesa tre fonti. Quando è osFoundry stesso ad avviare Claude Code, runtime/integrations.rs aggiunge degli hook tramite --settings. Quel flag aggiunge impostazioni solo per quell'avvio, e gli hook dell'utente continuano a girare.

{"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"}]}]
}}
Enter fullscreen mode Exit fullscreen mode

Claude Code lancia Notification sia per una richiesta di permesso sia dopo un minuto di attesa di input. Per questo osf-pane notify legge il payload e mappa permission su blocked e tutto il resto su idle. Via ACP (tramite l'adattatore claude-agent-acp dichiarato in claude.toml), una richiesta session/request_permission è l'agente che chiede il permesso in modo esplicito: un fatto, non una deduzione.

La parte interessante è l'ordine in signals::combine. Un blocked letto dallo schermo vale sempre. Poi viene un report recente dell'agente, poi "la shell è tornata in primo piano", poi le altre regole sullo schermo. Un report può essere vecchio o mancare, per esempio quando un hook è fallito senza dire niente. Un prompt sullo schermo è una regola precisa ed è l'unico avviso che non deve mai essere soppresso.

Da qui seguono due decisioni più piccole. Le notifiche hanno un cooldown di 30 s per sessione e stato (runtime/notify.rs), perché un prompt che si ridisegna può oscillare blocked → working → blocked. E prima che uno strumento qualsiasi digiti in un pannello, runtime/tools.rs esegue un classify_session nuovo invece di fidarsi dell'ultimo passaggio. Un pannello in blocked accetta solo runtime_answer, che l'utente conferma, mai il normale runtime_send.

Se lo implementi tu

  • Classifica uno schermo emulato, non una coda di byte. Claude Code posiziona le parole con spostamenti del cursore e non usa newline, e un prompt a cui hai già risposto resta nei byte per minuti.
  • Fai match sulle parole e sul footer del prompt stesso (Esc to cancel · Tab to amend), non su "una domanda" o su "silenzio da N secondi". Dall'esterno, le fini di turno e le chiamate a strumenti silenziose sembrano identiche a un prompt.
  • Metti le regole blocked sopra quelle working e fai in modo che "nessuna regola fa match" significhi idle. Un falso "ha bisogno di te" costa più di uno in ritardo.
  • Tieni le regole in un file di dati che puoi aggiornare senza una nuova release, e tieni fuori da lì il programma che le esegue.
  • Usa la tabella dei processi per sapere cosa gira e quando termina, non per capire se sta aspettando. Usa hook o ACP quando avvii tu l'agente, ma lascia che un prompt sullo schermo abbia la meglio su un report.
  • Per una persona basta un passaggio ogni poche centinaia di millisecondi. Ricontrolla subito prima di digitare nel pannello.

Il codice sta in quattro piccoli file, vt.py, rules.py, fake_agent.py e supervisor.py, più fixtures.py e analyze.py per le tabelle. La schermata che mostra il risultato è la board Sessions per le sessioni degli agenti nell'app desktop di osFoundry.

Top comments (0)