這是 Claude Code 2.1.303 要求執行一個指令時,寫進終端機的內容。這些位元組來自一份真實的擷取,osFoundry 把它保留為測試 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 ... 

(為了方便閱讀加上了換行,原始擷取裡沒有換行。)

這段內容裡有兩件事,會讓最直覺的偵測方式失效。第一,「Do」和「you」之間沒有空白。\x1b[5G 把游標移到第 5 欄,那個「空白」只是一個沒被寫過的格子。第二,整段完全沒有 \n:每一列都是以 \r 加上游標下移的 \x1b[1B 開頭。fixture 資料夾裡全部 11 份 Claude Code 擷取(每份 2,099 到 7,672 位元組),沒有任何一個換行位元組。

osFoundry 的桌面應用程式會在 Sessions 看板上,替每一個終端機窗格回答「這個代理是不是在等我?」。以下說明它怎麼做、為什麼這樣做,再用一個小型 Python supervisor,在實際運作的 PTY 上比較三種常見做法(提示文字比對、輸出閒置計時、行程狀態)。這台 Mac 上沒有 claude 執行檔,所以即時測試的部分跑的是一個假代理,它模仿 Claude Code 提示的用字和重繪方式。fixture 的部分則使用真實擷取到的位元組。

osFoundry 到底比對的是什麼?

每個代理一個 TOML 檔。Claude Code 的是 apps/web/src-tauri/resources/agent-detection/claude.toml,同一個檔案也放在 https://osfoundry.io/agent-detection/claude.toml。它的規則依序如下:

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

第一條符合的規則勝出(runtime/detect.rs 裡的 CompiledManifest::evaluate)。blocked 放在最前面,是因為提示出現時,Claude Code 的頁尾依然顯示「esc to interrupt」。如果先測 working,就永遠看不到提示。沒有任何規則符合的畫面會回報為 idle,絕不會是 blocked。

檔案裡的註解寫了拿掉哪些規則、為什麼拿掉。Would you like to 被刪掉,因為那是 Codex 的提示用語,而 Claude 一般的回覆裡,「Would you like me to…」出現得非常頻繁。

runtime/mod.rs 每 700 ms(SWEEP_INTERVAL)對所有窗格做一次掃描,而不是每次寫入都分類一次。旋轉指示器一秒會重繪很多次,每次寫入都分類,就等於一秒跑幾百次正規表示式,只為了得到同樣的答案。

為什麼不直接 grep 輸出的最後幾 KB?

原因就是開頭那段位元組。這裡把規則移植到 Python,對每一份 Claude Code fixture 用兩種方式執行。第一種取最後 8 KB,去掉跳脫序列,每一行只保留最後一個 \r 之後的文字。第二種把位元組餵進一個 32×120 的畫面模擬器(大約 100 行,就是下面的 vt.py),再把每一列讀回來。

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 

去掉跳脫序列的尾端之所以把 idle 全部判對,只是因為 idle 是預設值。它從來沒看到過提示。沒有 \n,整份擷取就是一「行」,只保留最後一個 \r 之後的文字,等於把最後一小段以外的內容全部丟掉。就算不做這一步,字也會黏在一起:Doyouwanttoproceed?。

osFoundry 的程式碼也是這麼說的。terminal_pty.rs 為每個 session 保留一個 vt100::Parser,掃描時只分類 screen_text(),也就是模擬出來的畫面。畫面上方保留的 200 列歷史刻意不納入。那裡的註解寫著:使用者已經回答、捲到上面去的提示「would report the agent as blocked forever」。

模擬器不需要做得很好。這個版本處理文字、\r \n \b、游標移動(A B C D E F G H f d)和清除(J K),並略過顏色、模式和 OSC 標題。它在每個格子放一個字元,所以全形字元可能讓欄位偏移一格。對整列做比對的正規表示式不在意這點。對這些規則來說,它把全部 11 份擷取都渲染得夠好了:

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 

為什麼不等輸出停下來就好?

這是大多數人最先寫出來的偵測方式:代理如果 N 秒都沒印任何東西,大概就是在等你。為了拿它和畫面規則比較,一個 supervisor 在 PTY 裡執行代理,每 50 ms 記錄一次每種偵測方式的判斷。

代理是 fake_agent.py,一個替身,因為這台 Mac 沒有安裝 claude 執行檔。它模仿了關鍵的行為:

  • 用 \r 以 10 Hz 重繪的旋轉指示器,每格畫面 60 位元組,內容包含「esc to interrupt」
  • 執行期間什麼都不印的工具呼叫(0.5 到 6 s),很多 CLI 都是這樣
  • 用 Claude Code 原本用字的權限提示,每個字都像真實擷取一樣用 CSI n G 定位,回答之後再像 Ink 那樣用游標上移和 \x1b[J 清掉
  • 每一輪結束時出現帶有 ? for shortcuts 的閒置提示,有時前面的回覆以「Would you like me to add a regression test?」結尾
  • 不論是權限提示還是閒置狀態,行程都卡在 PTY 上的 readline()

它把真實狀態寫進一個只有評分程式會讀的旁路檔案。一個 bot 扮演使用者,在每個提示出現後 3 到 8 s 回答,並在一輪結束後 3 到 10 s 輸入下一個任務。supervisor 的核心,精簡後如下:

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 

以 seed 7 跑一次:30 個任務、10.6 分鐘、25 次權限提示,花在等待這些提示上的時間共 144 s。延遲是從提示畫出來,到偵測器說「需要你」的時間。殘留是回答之後,偵測器還繼續這麼說多久。誤報以「次數 / 總秒數」計算,並依代理當下實際在做什麼分開統計。

偵測方式 延遲中位數 最大值 漏報 殘留 誤報(代理工作中) 誤報(代理閒置)
畫面規則,每 50 ms 0.03 s 0.06 s 0 / 25 0 s 0 0
畫面規則,700 ms 掃描 0.26 s 0.70 s 0 / 25 0.4 s 0 0
同樣的規則,套在去除跳脫序列的 8 KB 尾端 0.03 s 0.06 s 0 / 25 77.0 s 0 0
畫面上有一行以「?」結尾 0.03 s 0.06 s 0 / 25 58.0 s 0 / 231 s 4 / 143 s
輸出靜止 ≥ 0.5 s 0.52 s 0.56 s 0 / 25 0 s 31 / 78 s 30 / 168 s
輸出靜止 ≥ 1 s 1.02 s 1.06 s 0 / 25 0 s 28 / 63 s 30 / 153 s
輸出靜止 ≥ 2 s 2.03 s 2.06 s 0 / 25 0 s 21 / 38 s 30 / 123 s
輸出靜止 ≥ 3 s 3.02 s 3.06 s 0 / 25 0 s 17 / 19 s 30 / 93 s
輸出靜止 ≥ 5 s 5.02 s 5.05 s 9 / 25 0 s 3 / 1 s 18 / 46 s
輸出靜止 ≥ 8 s – – 25 / 25 0 s 0 6 / 7 s

閒置計時器沒有一個門檻值是可行的。依定義,它的延遲就等於門檻值。低於 5 s 時,每個安靜的工具呼叫都會觸發它。在 3 s 以下的每個門檻,30 次回合結束全部都會觸發,因為「做完了,等下一個任務」和「等待權限」一樣安靜。從 5 s 開始,它會漏掉那些使用者及時回答的提示。8 s 那一列 25 次全漏,只是因為 bot 從來不會等超過 8 s,但不管回答時間是多少,結果的形狀都一樣:這種偵測方式不是太吵,就是太慢。

「畫面上有一行以 ? 結尾」能抓到每一個提示,在真實擷取上看起來也會很完美:只有四份 blocked fixture 有這樣的一行。但在即時測試中,它會因為代理自己的問題「Would you like me to add a regression test?」而觸發,而且那一行會一直留在畫面上,進到下一個任務。這也正是 claude.toml 拿掉 Would you like to 的原因。

去除跳脫序列的尾端在這次測試中抓得到提示,是因為假代理寫的 ❯ 1. Yes 用的是真正的空白。但在回答之後,它平均還會繼續回報提示 77 s,因為被清掉的提示位元組會一直留在最後 8 KB 裡,直到大約 8 KB 的新輸出把它們擠出去。旋轉指示器每秒 600 位元組,再加上安靜的時段,這要花很長的時間。模擬畫面則在游標上移和清除一到,就把提示忘掉了。

700 ms 掃描的代價就如預期:最多一個間隔的延遲(中位數 0.26 s,最大 0.70 s),以及最多一個間隔的殘留。對一個給人看的狀態徽章來說,這完全沒問題。

作業系統不能直接告訴你行程正在讀 stdin 嗎?

在 macOS 上不行。supervisor 每秒對代理取樣兩次 ps -o stat=,time=:

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 

在終端機上卡在 read() 的行程,和在旋轉指示器兩格畫面之間睡眠的行程,狀態都是 S。wchan 欄位在某些系統上會顯示核心的等待通道名稱,在這裡兩者都只印出 -。CPU 時間也分不出來,因為旋轉指示器幾乎不花資源。像 Claude Code 這種以 Node 為基礎的 CLI,不論有沒有在工作都是在事件迴圈裡等待,所以它的狀態字母能告訴你的資訊更少。

行程表真正能回答的,是哪個程式掌握著終端機。osFoundry 在 runtime/signals.rs 裡讀取 tty 的前景行程群組(MasterPty::process_group_leader,也就是 tcgetpgrp)。這讓它知道窗格裡跑的是 claude,即使它是在一般 shell 裡手動輸入啟動的。它也能知道代理什麼時候已經結束、shell 回到前景,而代理最後的提示還留在畫面上。

有沒有比讀畫面更好的方法?

有:讓代理自己說。runtime/signals.rs 綜合評估三個來源。當 osFoundry 自己啟動 Claude Code 時,runtime/integrations.rs 會透過 --settings 加上 hook。這個旗標只會為這一次啟動加入設定,使用者自己的 hook 照樣會執行。

{"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 在出現權限提示時,以及等待輸入滿一分鐘後,都會觸發 Notification。所以 osf-pane notify 會讀取 payload,把 permission 對應到 blocked,其他都對應到 idle。透過 ACP(經由 claude.toml 裡宣告的 claude-agent-acp 轉接器),session/request_permission 請求就是代理直接要求權限,這是事實,而不是推測。

有意思的是 signals::combine 裡的順序。從畫面讀到的 blocked 一定成立。接著是代理剛送來的回報,然後是「shell 回到前景」,最後才是其餘的畫面規則。回報可能過時或根本沒有,例如某個 hook 默默失敗的時候。畫面上的提示是精確的規則,也是唯一一個絕對不能被壓掉的警示。

由此衍生出兩個比較小的決定。通知對每個 session 和狀態有 30 s 的冷卻時間(runtime/notify.rs),因為一直重繪的提示可能在 blocked → working → blocked 之間來回跳動。另外,任何工具要在窗格裡輸入之前,runtime/tools.rs 都會重新執行一次 classify_session,而不是相信上一次的掃描結果。處於 blocked 的窗格只接受由使用者確認的 runtime_answer,絕不接受一般的 runtime_send。

如果你要自己實作

  • 分類模擬出來的畫面,而不是位元組尾端。Claude Code 用游標移動來擺放文字、沒有換行,而已經回答的提示會在位元組裡留上好幾分鐘。
  • 比對提示本身的用字和頁尾(Esc to cancel · Tab to amend),而不是「一個問句」或「靜止 N 秒」。從外面看,回合結束和安靜的工具呼叫跟提示一模一樣。
  • 把 blocked 規則放在 working 規則之上,並讓「沒有規則符合」代表閒置。錯誤的「需要你」比晚到的代價更高。
  • 把規則放在不用發新版本就能更新的資料檔裡,並和執行它的程式分開。
  • 用行程表判斷身分和是否已結束,不要拿來判斷是否在等待。自己啟動代理時就用 hook 或 ACP,但畫面上的提示要優先於回報。
  • 對人來說,幾百毫秒的掃描間隔就夠了。在窗格裡輸入之前,再重新檢查一次。

程式碼是四個小檔案:vt.py、rules.py、fake_agent.py 和 supervisor.py,另外加上產生表格用的 fixtures.py 和 analyze.py。顯示結果的畫面,是 osFoundry 桌面應用程式裡的代理 session 的 Sessions 看板。