Inilah yang ditulis Claude Code 2.1.303 ke terminalnya saat meminta izin menjalankan sebuah perintah. Byte-nya berasal dari tangkapan asli yang disimpan osFoundry sebagai test fixture (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 ...
(Jeda baris ditambahkan supaya mudah dibaca. Tangkapan aslinya tidak punya jeda baris.)
Ada dua hal di situ yang merusak detektor yang paling jelas. Tidak ada spasi antara "Do" dan "you". \x1b[5G memindahkan kursor ke kolom 5, dan "spasi"-nya hanyalah sel yang tidak disentuh. Selain itu tidak ada \n sama sekali: setiap baris diawali \r dan perintah kursor turun \x1b[1B. Ke-11 tangkapan Claude Code di folder fixture (masing-masing 2,099 sampai 7,672 byte) tidak berisi satu pun byte newline.
Aplikasi desktop osFoundry menjawab pertanyaan "apakah agen ini sedang menunggu saya?" untuk setiap panel terminal di papan Sessions-nya. Di bawah ini: apa yang dilakukannya, kenapa, dan sebuah supervisor Python kecil yang membandingkan tiga pendekatan yang biasa dipakai (pola prompt, timer output diam, status proses) pada PTY yang hidup. Di Mac ini tidak ada binary claude, jadi bagian live menjalankan agen palsu yang meniru kata-kata prompt dan kebiasaan redraw Claude Code. Bagian fixture memakai byte asli hasil tangkapan.
Apa sebenarnya yang dicocokkan osFoundry?
Satu file TOML per agen. Milik Claude Code adalah apps/web/src-tauri/resources/agent-detection/claude.toml, dan file yang sama disajikan di https://osfoundry.io/agent-detection/claude.toml. Aturannya, berurutan:
[[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']
Aturan pertama yang cocok yang menang (CompiledManifest::evaluate di runtime/detect.rs). blocked diletakkan paling atas karena footer Claude Code menampilkan "esc to interrupt" selama prompt sedang tampil. Kalau working diuji lebih dulu, prompt-nya tidak akan pernah terlihat. Layar yang tidak cocok dengan aturan mana pun dilaporkan sebagai idle, tidak pernah blocked.
Komentar di file itu menjelaskan apa yang dihapus dan alasannya. Would you like to dibuang karena itu kata-kata prompt Codex, dan balasan biasa Claude sering sekali berbunyi "Would you like me to…".
runtime/mod.rs menjalankan satu sweep ke semua panel setiap 700 ms (SWEEP_INTERVAL), bukan mengklasifikasi di setiap write. Spinner digambar ulang berkali-kali per detik, jadi klasifikasi di setiap write berarti menjalankan regex ratusan kali per detik hanya untuk mendapat jawaban yang sama.
Kenapa tidak grep beberapa KB terakhir dari output?
Karena byte di bagian atas tadi. Di sini aturannya di-port ke Python dan dijalankan pada setiap fixture Claude Code dengan dua cara. Cara pertama mengambil 8 KB terakhir, membuang escape sequence, dan menyimpan teks setelah \r terakhir di setiap baris. Cara kedua memasukkan byte ke emulator layar 32×120 (sekitar 100 baris, vt.py di bawah) lalu membaca kembali baris-barisnya.
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
Tail yang sudah di-strip menebak semua idle dengan benar hanya karena idle adalah nilai fallback. Ia tidak pernah melihat prompt. Tanpa \n, seluruh tangkapan menjadi satu "baris", dan menyimpan teks setelah \r terakhir membuang semuanya kecuali potongan terakhir. Bahkan tanpa langkah itu, kata-katanya keluar menempel: Doyouwanttoproceed?.
Kode osFoundry mengatakan hal yang sama. terminal_pty.rs menyimpan satu vt100::Parser per sesi, dan sweep mengklasifikasi screen_text(), yaitu hanya layar hasil emulasi. 200 baris riwayat yang disimpan di atasnya sengaja tidak diikutkan. Komentar di sana: prompt yang sudah dijawab pengguna dan sudah tergulir ke atas "would report the agent as blocked forever".
Emulatornya tidak perlu bagus. Yang ini menangani teks, \r \n \b, perpindahan kursor (A B C D E F G H f d) dan penghapusan (J K), dan melewati warna, mode, serta judul OSC. Ia menaruh satu karakter di setiap sel, jadi glyph lebar bisa menggeser satu kolom. Regex yang dijalankan pada baris utuh tidak peduli. Emulator ini merender ke-11 tangkapan dengan cukup baik untuk aturan-aturan tadi:
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
Kenapa tidak tunggu saja sampai output berhenti?
Itulah detektor yang biasanya ditulis orang pertama kali: kalau agen tidak mencetak apa pun selama N detik, kemungkinan ia butuh kamu. Untuk mengujinya melawan aturan layar, sebuah supervisor menjalankan agen di dalam PTY dan mencatat setiap 50 ms apa pendapat masing-masing detektor.
Agennya adalah fake_agent.py, sebuah pengganti, karena tidak ada binary claude yang terpasang di Mac ini. Ia meniru kebiasaan yang penting:
- spinner yang digambar ulang dengan
\rpada 10 Hz, 60 byte per frame, berisi "esc to interrupt" - tool call yang tidak mencetak apa pun selama berjalan (0.5 sampai 6 s), seperti banyak CLI lain
- prompt izin dengan kata-kata Claude Code, setiap kata ditempatkan dengan
CSI n Gseperti tangkapan asli, lalu dihapus ala Ink dengan cursor-up dan\x1b[Jsetelah dijawab - prompt idle dengan
? for shortcutsdi akhir setiap giliran, kadang setelah balasan yang diakhiri "Would you like me to add a regression test?" - baik saat prompt maupun saat idle, proses tertahan di
readline()pada PTY
Ia menulis status sebenarnya ke file samping yang hanya dibaca oleh penilai. Sebuah bot berperan sebagai pengguna: menjawab setiap prompt 3 sampai 8 s setelah prompt muncul, dan mengetik tugas berikutnya 3 sampai 10 s setelah satu giliran selesai. Inti supervisor-nya, dipangkas:
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
Satu run dengan seed 7: 30 tugas, 10.6 menit, 25 prompt izin, 144 s dihabiskan untuk menunggu prompt itu. Latensi adalah waktu dari prompt digambar sampai detektor bilang "butuh kamu". Linger adalah berapa lama detektor masih bilang begitu setelah prompt dijawab. Alarm palsu dihitung sebagai episode / total detik, dipisah menurut apa yang sebenarnya sedang dilakukan agen.
| Detektor | Median latensi | Maks | Terlewat | Linger | Palsu, agen bekerja | Palsu, agen idle |
|---|---|---|---|---|---|---|
| Aturan layar, setiap 50 ms | 0.03 s | 0.06 s | 0 / 25 | 0 s | 0 | 0 |
| Aturan layar, sweep 700 ms | 0.26 s | 0.70 s | 0 / 25 | 0.4 s | 0 | 0 |
| Aturan yang sama pada tail 8 KB yang di-strip | 0.03 s | 0.06 s | 0 / 25 | 77.0 s | 0 | 0 |
| Baris layar yang diakhiri "?" | 0.03 s | 0.06 s | 0 / 25 | 58.0 s | 0 / 231 s | 4 / 143 s |
| Output diam ≥ 0.5 s | 0.52 s | 0.56 s | 0 / 25 | 0 s | 31 / 78 s | 30 / 168 s |
| Output diam ≥ 1 s | 1.02 s | 1.06 s | 0 / 25 | 0 s | 28 / 63 s | 30 / 153 s |
| Output diam ≥ 2 s | 2.03 s | 2.06 s | 0 / 25 | 0 s | 21 / 38 s | 30 / 123 s |
| Output diam ≥ 3 s | 3.02 s | 3.06 s | 0 / 25 | 0 s | 17 / 19 s | 30 / 93 s |
| Output diam ≥ 5 s | 5.02 s | 5.05 s | 9 / 25 | 0 s | 3 / 1 s | 18 / 46 s |
| Output diam ≥ 8 s | – | – | 25 / 25 | 0 s | 0 | 6 / 7 s |
Tidak ada threshold yang berhasil untuk timer idle. Latensinya, menurut definisi, sama dengan threshold-nya. Di bawah 5 s ia menyala di setiap tool call yang diam. Pada setiap threshold sampai 3 s ia menyala di ke-30 akhir giliran, karena "selesai, menunggu tugas berikutnya" sama diamnya dengan "menunggu izin". Mulai 5 s ke atas ia mulai melewatkan prompt yang dijawab tepat waktu. Baris 8 s melewatkan ke-25 prompt hanya karena bot tidak pernah menunggu lebih dari 8 s, tetapi polanya berlaku untuk waktu menjawab berapa pun: detektornya kalau tidak berisik, ya terlambat.
Baris layar yang diakhiri "?" menangkap setiap prompt, dan pada tangkapan asli ia akan terlihat sempurna: hanya keempat fixture blocked yang punya baris seperti itu. Di run live, ia menyala pada pertanyaan agen sendiri, "Would you like me to add a regression test?", dan baris itu tetap ada di layar sampai tugas berikutnya. Ini alasan yang sama kenapa claude.toml membuang Would you like to.
Tail yang di-strip berhasil mendeteksi prompt di run ini karena agen palsu menulis ❯ 1. Yes dengan spasi sungguhan. Tapi setelah prompt dijawab, ia terus melaporkan prompt rata-rata selama 77 s, karena byte prompt yang sudah dihapus tetap ada di 8 KB terakhir sampai sekitar 8 KB output baru mendorongnya keluar. Dengan spinner 600 byte per detik, ditambah jeda-jeda diam, itu butuh waktu lama. Layar hasil emulasi melupakan prompt begitu cursor-up dan erase datang.
Sweep 700 ms punya biaya yang sudah bisa ditebak: latensi sampai satu interval (median 0.26 s, maks 0.70 s) dan linger sampai satu interval. Untuk badge yang dilihat manusia, itu tidak masalah.
Bukankah OS bisa memberi tahu bahwa proses sedang membaca stdin?
Tidak di macOS. Supervisor mengambil sampel ps -o stat=,time= untuk agen dua kali per detik:
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
Proses yang tertahan di read() pada terminalnya dan proses yang tidur di antara frame spinner sama-sama berstatus S. Kolom wchan, yang di beberapa sistem menyebut kernel wait channel, mencetak - untuk keduanya. Waktu CPU juga tidak bisa membedakannya, karena spinner nyaris tidak memakan apa-apa. CLI berbasis Node seperti Claude Code menunggu di event loop-nya entah sedang bekerja atau tidak, jadi huruf statusnya akan memberi tahu lebih sedikit lagi.
Yang bisa dijawab tabel proses adalah program mana yang memegang terminal. osFoundry membaca foreground process group milik tty (MasterPty::process_group_leader, yaitu tcgetpgrp) di runtime/signals.rs. Dari situ ia tahu panel itu sedang menjalankan claude meskipun diketik di shell biasa. Ia juga tahu kapan agen sudah keluar dan shell kembali ke depan, sementara prompt terakhir agen masih ada di layar.
Adakah cara yang lebih baik daripada membaca layar?
Ada: minta agennya sendiri yang memberi tahu. runtime/signals.rs menimbang tiga sumber. Saat osFoundry menjalankan Claude Code sendiri, runtime/integrations.rs menambahkan hook lewat --settings. Flag itu menambahkan pengaturan hanya untuk peluncuran tersebut, dan hook milik pengguna tetap berjalan.
{"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 memicu Notification baik untuk prompt izin maupun setelah satu menit menunggu input. Jadi osf-pane notify membaca payload-nya dan memetakan permission ke blocked dan sisanya ke idle. Lewat ACP (melalui adapter claude-agent-acp yang dideklarasikan di claude.toml), request session/request_permission adalah agen yang meminta izin secara langsung, sebuah fakta, bukan tebakan.
Bagian yang menarik adalah urutan di signals::combine. blocked yang dibaca dari layar selalu dipakai. Berikutnya laporan baru dari agen, lalu "shell kembali ke depan", lalu sisa aturan layar. Laporan bisa basi atau tidak ada, misalnya saat sebuah hook gagal tanpa suara. Prompt di layar adalah aturan yang presisi dan satu-satunya peringatan yang tidak boleh pernah diredam.
Dari situ muncul dua keputusan yang lebih kecil. Notifikasi punya cooldown 30 s per sesi dan status (runtime/notify.rs), karena prompt yang digambar ulang bisa berkedip blocked → working → blocked. Dan sebelum tool apa pun mengetik ke sebuah panel, runtime/tools.rs menjalankan classify_session yang baru alih-alih memercayai sweep terakhir. Panel yang sedang blocked hanya menerima runtime_answer, yang dikonfirmasi pengguna, dan tidak pernah runtime_send biasa.
Kalau kamu mau merakitnya sendiri
- Klasifikasikan layar hasil emulasi, bukan tail byte. Claude Code menempatkan kata dengan perpindahan kursor dan tidak memakai newline, dan prompt yang sudah dijawab tetap ada di byte selama bermenit-menit.
- Cocokkan kata-kata dan footer prompt itu sendiri (
Esc to cancel · Tab to amend), bukan "sebuah pertanyaan" atau "diam selama N detik". Dari luar, akhir giliran dan tool call yang diam terlihat sama dengan prompt. - Letakkan aturan
blockeddi atas aturanworking, dan jadikan "tidak ada aturan yang cocok" berarti idle. "Butuh kamu" yang palsu lebih mahal daripada yang terlambat. - Simpan aturan di file data yang bisa kamu perbarui tanpa rilis, dan jangan taruh di dalam program yang menjalankannya.
- Pakai tabel proses untuk identitas dan exit, bukan untuk menunggu. Pakai hook atau ACP kalau kamu menjalankan agennya sendiri, tapi biarkan prompt di layar mengalahkan laporan.
- Sweep beberapa ratus milidetik sudah cukup untuk manusia. Periksa ulang tepat sebelum kamu mengetik ke panel.
Kodenya terdiri dari empat file kecil, vt.py, rules.py, fake_agent.py dan supervisor.py, ditambah fixtures.py dan analyze.py untuk tabel-tabelnya. Layar yang menampilkan hasilnya adalah papan Sessions untuk sesi agen di aplikasi desktop osFoundry.
Top comments (0)