DEV Community

Cover image for Cómo detecta osFoundry que Claude Code está esperando un permiso
BBT for BBT Español

Posted on

Cómo detecta osFoundry que Claude Code está esperando un permiso

Esto es lo que Claude Code 2.1.303 escribe en su terminal cuando pide permiso para ejecutar un comando. Los bytes vienen de una captura real que osFoundry guarda como 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 ...
Enter fullscreen mode Exit fullscreen mode

(Los saltos de línea están añadidos para que se pueda leer. La captura no tiene ninguno.)

Hay dos cosas ahí que rompen el detector obvio. No hay ningún espacio entre "Do" y "you": \x1b[5G mueve el cursor a la columna 5, y el espacio es simplemente una celda que nadie ha tocado. Y no hay ni un solo \n: cada fila empieza con \r y un cursor-abajo \x1b[1B. Las 11 capturas de Claude Code de la carpeta de fixtures (de 2,099 a 7,672 bytes cada una) tienen cero bytes de salto de línea.

La app de escritorio de osFoundry responde a la pregunta "¿este agente está esperando por mí?" para cada panel de terminal de su tablero Sessions. A continuación verás qué hace, por qué, y un pequeño supervisor en Python que compara los tres enfoques habituales (patrones del prompt, temporizadores de inactividad de la salida y estado del proceso) sobre un PTY en vivo. En este Mac no hay binario claude, así que la parte en vivo usa un agente falso que copia la redacción de los prompts de Claude Code y su forma de redibujar la pantalla. La parte de fixtures usa los bytes reales capturados.

¿Qué busca exactamente osFoundry?

Un archivo TOML por agente. El de Claude Code es apps/web/src-tauri/resources/agent-detection/claude.toml, y el mismo archivo se sirve en https://osfoundry.io/agent-detection/claude.toml. Sus reglas, en orden:

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

Gana la primera regla que coincide (CompiledManifest::evaluate en runtime/detect.rs). blocked va primero porque el pie de Claude Code dice "esc to interrupt" mientras hay un prompt abierto. Si working se comprobara antes, el prompt nunca se vería. Una pantalla que no coincide con ninguna regla se reporta como idle, nunca como blocked.

Los comentarios del archivo explican qué se quitó y por qué. Would you like to se eliminó porque es la redacción de los prompts de Codex, y las respuestas normales de Claude dicen "Would you like me to…" constantemente.

runtime/mod.rs hace una pasada sobre todos los paneles cada 700 ms (SWEEP_INTERVAL) en lugar de clasificar en cada escritura. Un spinner se redibuja muchas veces por segundo, así que clasificar en cada escritura supondría ejecutar las regex cientos de veces por segundo para obtener la misma respuesta.

¿Por qué no hacer grep sobre los últimos KB de salida?

Por los bytes del principio. Aquí las reglas están portadas a Python y se ejecutan sobre cada fixture de Claude Code de dos formas. La primera toma los últimos 8 KB, elimina las secuencias de escape y se queda con el texto que hay después del último \r de cada línea. La segunda mete los bytes en un emulador de pantalla de 32×120 (unas 100 líneas, vt.py más abajo) y vuelve a leer las filas.

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 cola limpiada acierta todos los idle solo porque idle es el valor por defecto. Nunca ve un prompt. Sin \n, toda la captura es una única "línea", y quedarse con el texto posterior al último \r tira todo menos el fragmento final. Incluso sin ese paso, las palabras salen pegadas: Doyouwanttoproceed?.

El código de osFoundry dice lo mismo. terminal_pty.rs mantiene un vt100::Parser por sesión, y la pasada clasifica screen_text(), solo la pantalla emulada. Las 200 filas de historial que se guardan por encima se dejan fuera a propósito. El comentario lo explica: un prompt que el usuario ya respondió y que ha subido con el scroll "would report the agent as blocked forever".

El emulador no tiene por qué ser bueno. Este maneja texto, \r \n \b, movimientos de cursor (A B C D E F G H f d) y borrado (J K), y se salta colores, modos y títulos OSC. Pone un carácter en cada celda, así que un glifo ancho puede desplazar una columna. A las regex sobre filas completas eso les da igual. Renderiza las 11 capturas lo bastante bien para las reglas:

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

¿Por qué no esperar simplemente a que la salida se detenga?

Es el detector que casi todo el mundo escribe primero: si el agente lleva N segundos sin imprimir nada, probablemente te necesita. Para compararlo con las reglas de pantalla, un supervisor ejecuta un agente en un PTY y registra cada 50 ms lo que opina cada detector.

El agente es fake_agent.py, un sustituto, porque en este Mac no hay ningún binario claude instalado. Copia los hábitos que importan:

  • un spinner redibujado con \r a 10 Hz, 60 bytes por fotograma, con "esc to interrupt" dentro
  • llamadas a herramientas que no imprimen nada mientras se ejecutan (de 0.5 a 6 s), como hacen muchas CLI
  • un prompt de permiso con las palabras de Claude Code, cada palabra colocada con CSI n G como en la captura real, y que se borra al estilo Ink con cursor-arriba y \x1b[J una vez respondido
  • un prompt inactivo con ? for shortcuts al final de cada turno, a veces después de una respuesta que termina con "Would you like me to add a regression test?"
  • tanto en el prompt como en el estado inactivo, el proceso se queda bloqueado en readline() sobre el PTY

Escribe su estado real en un archivo aparte que solo lee el evaluador. Un bot hace de usuario: responde a cada prompt entre 3 y 8 s después de que aparezca, y escribe la siguiente tarea entre 3 y 10 s después de que termine un turno. El núcleo del supervisor, recortado:

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

Una ejecución con semilla 7: 30 tareas, 10.6 minutos, 25 prompts de permiso y 144 s esperando a que se respondieran. La latencia es el tiempo desde que se dibuja el prompt hasta que el detector dice "te necesita". La persistencia es cuánto siguió diciéndolo después de la respuesta. Las falsas alarmas se cuentan como episodios / segundos totales, separadas según lo que el agente estaba haciendo de verdad.

Detector Latencia mediana Máx. Perdidos Persistencia Falsas, agente trabajando Falsas, agente inactivo
Reglas de pantalla, cada 50 ms 0.03 s 0.06 s 0 / 25 0 s 0 0
Reglas de pantalla, pasada de 700 ms 0.26 s 0.70 s 0 / 25 0.4 s 0 0
Mismas reglas sobre la cola limpiada de 8 KB 0.03 s 0.06 s 0 / 25 77.0 s 0 0
Una línea de pantalla que termina en "?" 0.03 s 0.06 s 0 / 25 58.0 s 0 / 231 s 4 / 143 s
Salida en silencio ≥ 0.5 s 0.52 s 0.56 s 0 / 25 0 s 31 / 78 s 30 / 168 s
Salida en silencio ≥ 1 s 1.02 s 1.06 s 0 / 25 0 s 28 / 63 s 30 / 153 s
Salida en silencio ≥ 2 s 2.03 s 2.06 s 0 / 25 0 s 21 / 38 s 30 / 123 s
Salida en silencio ≥ 3 s 3.02 s 3.06 s 0 / 25 0 s 17 / 19 s 30 / 93 s
Salida en silencio ≥ 5 s 5.02 s 5.05 s 9 / 25 0 s 3 / 1 s 18 / 46 s
Salida en silencio ≥ 8 s – – 25 / 25 0 s 0 6 / 7 s

Para un temporizador de inactividad no existe un umbral que funcione. Su latencia es el umbral, por definición. Por debajo de 5 s salta con cada llamada a herramienta silenciosa. En todos los umbrales hasta 3 s salta en los 30 finales de turno, porque "he terminado, espero la siguiente tarea" es exactamente igual de silencioso que "espero un permiso". A partir de 5 s empieza a perder prompts que alguien respondió a tiempo. La fila de 8 s pierde los 25 solo porque el bot nunca espera más de 8 s, pero el patrón se mantiene para cualquier tiempo de respuesta: el detector o hace ruido o llega tarde.

Una línea de pantalla que termina en "?" detecta todos los prompts, y con las capturas reales parecería perfecta: solo los cuatro fixtures blocked tienen una línea así. En la ejecución en vivo salta con la propia pregunta del agente, "Would you like me to add a regression test?", y esa línea sigue en pantalla hasta bien entrada la siguiente tarea. Es la misma razón por la que claude.toml eliminó Would you like to.

La cola limpiada detecta los prompts en esta ejecución porque el agente falso escribe ❯ 1. Yes con espacios reales. Pero luego sigue reportando el prompt durante 77 s de media después de la respuesta, porque los bytes del prompt borrado siguen en los últimos 8 KB hasta que unos 8 KB de salida nueva los empujan fuera. A 600 bytes por segundo de spinner, más los tramos en silencio, eso tarda bastante. La pantalla emulada olvida el prompt en cuanto llegan el cursor-arriba y el borrado.

La pasada de 700 ms cuesta lo esperable: hasta un intervalo de latencia (mediana 0.26 s, máx. 0.70 s) y hasta un intervalo de persistencia. Para un indicador que mira una persona, está bien.

¿No puede el sistema operativo decirte que el proceso está leyendo stdin?

En macOS, no. El supervisor muestreó ps -o stat=,time= del agente dos veces por segundo:

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 proceso bloqueado en read() sobre su terminal y un proceso que duerme entre fotogramas del spinner son ambos S. La columna wchan, que en algunos sistemas indica el canal de espera del kernel, mostró - en los dos casos. El tiempo de CPU tampoco los distingue, porque un spinner no cuesta casi nada. Una CLI basada en Node como Claude Code espera en su event loop tanto si está trabajando como si no, así que su letra de estado te diría todavía menos.

Lo que sí responde la tabla de procesos es qué programa tiene el terminal. osFoundry lee el grupo de procesos en primer plano del tty (MasterPty::process_group_leader, es decir, tcgetpgrp) en runtime/signals.rs. Eso le dice que el panel está ejecutando claude aunque se haya lanzado a mano desde una shell normal. También le dice cuándo el agente ha terminado y la shell vuelve a estar en primer plano, aunque el último prompt del agente siga en pantalla.

¿Hay algo mejor que leer la pantalla?

Sí: que el agente lo diga. runtime/signals.rs pondera tres fuentes. Cuando osFoundry lanza Claude Code por su cuenta, runtime/integrations.rs añade hooks mediante --settings. Ese flag añade ajustes solo para ese lanzamiento, y los hooks propios del usuario se siguen ejecutando.

{"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 dispara Notification tanto para un prompt de permiso como después de un minuto esperando entrada. Por eso osf-pane notify lee el payload y asigna permission a blocked y todo lo demás a idle. Por ACP (a través del adaptador claude-agent-acp declarado en claude.toml), una petición session/request_permission es el agente pidiendo permiso directamente, un hecho y no una deducción.

Lo interesante es el orden en signals::combine. Un blocked leído de la pantalla siempre se mantiene. Después va un reporte reciente del agente, luego "la shell vuelve a estar en primer plano", y luego el resto de reglas de pantalla. Un reporte puede estar desactualizado o faltar, por ejemplo cuando un hook ha fallado sin avisar. Un prompt en pantalla es una regla precisa y la única alerta que nunca debe suprimirse.

De ahí salen dos decisiones más pequeñas. Las notificaciones tienen un cooldown de 30 s por sesión y estado (runtime/notify.rs), porque un prompt que se redibuja puede oscilar blocked → working → blocked. Y antes de que cualquier herramienta escriba en un panel, runtime/tools.rs ejecuta un classify_session nuevo en lugar de fiarse de la última pasada. Un panel en blocked solo acepta runtime_answer, que confirma el usuario, nunca el runtime_send normal.

Si lo montas tú mismo

  • Clasifica una pantalla emulada, no una cola de bytes. Claude Code coloca las palabras con movimientos de cursor y no usa saltos de línea, y un prompt ya respondido se queda en los bytes durante minutos.
  • Busca la redacción y el pie del propio prompt (Esc to cancel · Tab to amend), no "una pregunta" ni "N segundos en silencio". Desde fuera, los finales de turno y las llamadas a herramientas silenciosas se ven igual que un prompt.
  • Pon las reglas blocked por encima de las working y haz que "ninguna regla coincide" signifique idle. Un falso "te necesita" sale más caro que uno tardío.
  • Guarda las reglas en un archivo de datos que puedas actualizar sin sacar una versión nueva, y deja fuera de él el programa que las ejecuta.
  • Usa la tabla de procesos para saber qué se ejecuta y cuándo termina, no para detectar esperas. Usa hooks o ACP cuando lances tú el agente, pero deja que un prompt en pantalla tenga prioridad sobre un reporte.
  • Para una persona basta con una pasada cada pocos cientos de milisegundos. Vuelve a comprobar justo antes de escribir en el panel.

El código son cuatro archivos pequeños, vt.py, rules.py, fake_agent.py y supervisor.py, más fixtures.py y analyze.py para las tablas. La pantalla que muestra el resultado es el tablero Sessions para sesiones de agentes de la app de escritorio de osFoundry.

Top comments (0)