osFoundry는 Claude Code의 승인 대기를 어떻게 잡아낼까
Claude Code 2.1.303이 명령 실행 권한을 물어볼 때 터미널로 나오는 바이트는 이렇다. osFoundry가 테스트용으로 저장해 둔 실제 캡처(resources/agent-detection/fixtures/claude.blocked.raw)에서 잘라 왔다. \r\x1b[1C\x1b[1B\x1b[39mDo\x1b[5Gyou\x1b[9Gwant\x1b[14Gto\x1b[17Gpr
Claude Code 2.1.303이 명령 실행 권한을 물어볼 때 터미널로 나오는 바이트는 이렇다. osFoundry가 테스트용으로 저장해 둔 실제 캡처(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로 시작한다. fixtures 폴더에 있는 Claude Code 캡처 11개(각 2,099~7,672바이트) 모두 개행 바이트가 0개였다.
osFoundry 데스크톱 앱은 Sessions 보드에 올라간 터미널 pane마다 "이 에이전트가 지금 내 대답을 기다리나?"를 판정한다. 이 글에서는 그 구조를 뜯어보고, 흔한 방식 세 가지(프롬프트 문구 매칭, 출력이 멈춘 시간 타이머, 프로세스 상태)를 Python으로 짠 작은 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가 맨 위인 이유는, 프롬프트가 떠 있는 동안에도 푸터에 "esc to interrupt"가 남아 있어서다. working을 먼저 보면 프롬프트는 영영 안 잡힌다. 어떤 규칙에도 안 걸린 화면은 idle이 되고, 절대 blocked로 가지 않는다.
파일 주석에는 뺀 패턴과 이유도 적혀 있다. Would you like to는 Codex의 프롬프트 문구고, Claude는 평범한 답변에서도 "Would you like me to…"를 자주 써서 뺐다고 한다.
판정은 출력이 올 때마다 하지 않는다. runtime/mod.rs가 700 ms마다 모든 pane을 한 바퀴 돈다(SWEEP_INTERVAL). 스피너는 1초에 여러 번 다시 그려지니까, 쓸 때마다 판정하면 같은 답을 내려고 정규식을 초당 수백 번 돌리게 된다.
출력 끝 몇 KB를 grep하면 안 되나
맨 위 바이트가 답이다. 규칙을 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
tail 방식이 idle을 다 맞힌 건 idle이 기본값이라서일 뿐이다. 프롬프트는 한 번도 못 봤다. \n이 없으니 캡처 전체가 한 "줄"이 되고, 마지막 \r 뒤만 남기면 끝 조각밖에 안 남는다. 그 처리를 빼도 단어가 붙어서 Doyouwanttoproceed?가 된다.
osFoundry 코드도 같은 결론이다. terminal_pty.rs는 세션마다 vt100::Parser를 하나씩 들고 있고, 판정에는 에뮬레이트한 화면만 돌려주는 screen_text()를 쓴다. 화면 위로 남겨 두는 히스토리 200행은 일부러 판정에서 뺐다. 주석에 따르면, 이미 대답해서 위로 스크롤된 프롬프트를 읽으면 에이전트가 "계속 blocked"로 남아 버린다.
에뮬레이터는 잘 만들 필요가 없다. 여기서 쓴 건 문자, \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초 동안 아무것도 안 찍으면 아마 날 기다리는 거겠지. 화면 규칙과 비교하려고, PTY에 에이전트를 띄우고 50 ms마다 감지기별 판정을 로그로 남기는 슈퍼바이저를 붙였다.
에이전트는 fake_agent.py라는 대역이다(이 Mac에는 claude 바이너리가 없다). 결과에 영향을 주는 습관만 흉내 냈다.
-
\r로 10 Hz 다시 그리는 스피너. 프레임당 60바이트, "esc to interrupt" 포함 - 실행 중에 아무것도 안 찍는 도구 호출(0.5~6초). CLI 상당수가 이렇다
- Claude Code 문구 그대로의 권한 프롬프트. 실제 캡처처럼 단어마다
CSI n G로 배치하고, 대답하면 Ink처럼 커서를 올려\x1b[J로 지운다 - 턴이 끝나면
? for shortcuts가 붙은 대기 상태. 가끔 "Would you like me to add a regression test?"로 끝나는 답변 뒤에 온다 - 프롬프트 중에도 대기 중에도 프로세스는 PTY의
readline()에서 블록돼 있다
실제 상태는 별도 파일에 쓰고, 채점 스크립트만 그걸 읽는다. 사용자 역할 봇은 프롬프트가 뜨고 3~8초 뒤에 대답하고, 턴이 끝나고 3~10초 뒤에 다음 작업을 친다. 슈퍼바이저 핵심만 추리면 이렇다.
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초. 지연은 프롬프트가 그려지고 감지기가 "대기 중"이라고 말하기까지 걸린 시간, 잔류는 대답한 뒤에도 그렇게 말한 시간이다. 오탐은 "횟수 / 합계 초"로, 에이전트가 실제로 뭘 하고 있었는지로 나눴다.
| 감지기 | 지연(중앙값) | 최대 | 놓침 | 잔류 | 오탐(작업 중) | 오탐(대기 중) |
|---|---|---|---|---|---|---|
| 화면 규칙, 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 |
idle 타이머에는 제대로 먹히는 임계값이 없다. 지연은 정의상 임계값 그 자체다. 5초 미만이면 조용히 도는 도구 호출마다 울린다. 3초 이하에서는 턴 종료 30번에 전부 울린다. "끝내고 다음 작업을 기다리는 중"은 "권한을 기다리는 중"과 똑같이 조용하니까. 5초 이상으로 올리면 제때 대답한 프롬프트를 놓치기 시작한다. 8초에서 25개를 다 놓친 건 봇이 8초 넘게 안 기다려서일 뿐이지만, 경향은 대답 속도와 상관없다. 시끄럽거나 늦거나 둘 중 하나다.
"?"로 끝나는 줄은 프롬프트를 다 잡는다. 실제 캡처에서는 완벽해 보인다. 그런 줄이 있는 건 blocked 4개뿐이다. 그런데 라이브에서는 에이전트가 직접 묻는 "Would you like me to add a regression test?"에도 반응하고, 그 줄은 다음 작업까지 화면에 남는다. claude.toml이 Would you like to를 뺀 것과 같은 이유다.
끝 8 KB 방식이 이번 측정에서 프롬프트를 잡은 건 가짜 에이전트가 ❯ 1. Yes를 진짜 공백으로 찍어서다. 대신 대답한 뒤에도 평균 77초 동안 프롬프트가 있다고 우긴다. 지운 프롬프트의 바이트가 새 출력 8 KB에 밀려날 때까지 끝에 남아 있어서다. 스피너가 초당 600바이트인데 중간에 조용한 구간까지 있으니, 밀려나기까지 오래 걸린다. 에뮬레이트한 화면은 커서 이동과 지우기가 도착하는 순간 프롬프트를 잊는다.
700 ms 주기의 비용은 예상대로다. 최대 한 주기만큼의 지연(중앙값 0.26초, 최대 0.70초)과 최대 한 주기만큼의 잔류. 사람이 보는 배지라면 이걸로 충분하다.
OS한테 "stdin 읽는 중이냐"고 물어보면 안 되나
macOS에서는 안 됐다. 슈퍼바이저가 ps -o stat=,time=를 초당 2번 찍어 뒀다.
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를 거의 안 먹어서 CPU 시간으로도 못 가른다. Claude Code 같은 Node 기반 CLI는 작업 중이든 아니든 이벤트 루프에서 기다리니까, 상태 문자로 알 수 있는 건 더 적을 거다.
프로세스 테이블로 확실히 알 수 있는 건 누가 터미널을 쥐고 있느냐다. osFoundry는 runtime/signals.rs에서 터미널의 포그라운드 프로세스 그룹(MasterPty::process_group_leader, 즉 tcgetpgrp)을 읽는다. 그래서 그냥 셸에 친 claude도 알아보고, 에이전트가 종료돼서 셸이 다시 앞으로 나온 것도 안다. 화면에는 마지막 프롬프트가 그대로 남아 있어도 말이다.
화면 읽는 것보다 나은 방법은 없나
있다. 에이전트가 직접 말하게 하면 된다. runtime/signals.rs는 정보원 세 개를 맞춰 본다. osFoundry가 Claude Code를 직접 띄울 때는 runtime/integrations.rs가 --settings로 훅을 붙인다. 이 플래그는 그 실행 한 번에만 설정을 더하는 거라, 사용자 본인의 훅도 그대로 돈다.
{"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은 권한 프롬프트 때도, 입력 대기가 1분 이어졌을 때도 발생한다. 그래서 osf-pane notify가 payload를 보고 permission이면 blocked, 나머지는 idle로 나눈다. ACP(claude.toml에 선언된 claude-agent-acp 어댑터 경유)라면 session/request_permission 요청 자체가 권한 요청이라, 추측이 아니라 사실이 된다.
재밌는 건 signals::combine의 우선순위다. 화면에서 읽은 blocked는 항상 이긴다. 그다음이 에이전트의 최신 보고, 그다음이 "셸이 다시 앞에 있음", 마지막이 나머지 화면 규칙이다. 보고는 오래될 수도 있고, 훅이 조용히 실패해서 아예 안 올 수도 있다. 반면 화면 위 프롬프트는 정확한 규칙이고, 절대 묻히면 안 되는 단 하나의 알림이다.
여기서 작은 설계 두 개가 따라 나온다. 알림은 세션·상태별로 30초 쿨다운이 있다(runtime/notify.rs). 다시 그릴 때마다 blocked → working → blocked로 깜빡일 수 있어서다. 그리고 도구가 pane에 글자를 치기 전에는 runtime/tools.rs가 직전 주기 결과를 믿지 않고 classify_session을 다시 돌린다. blocked인 pane은 사용자가 확인하는 runtime_answer만 받고, 일반 runtime_send는 막힌다.
직접 붙인다면
- 바이트 끝부분이 아니라 에뮬레이트한 화면으로 판정한다. Claude Code는 단어를 커서 이동으로 배치하고 개행을 안 쓴다. 대답한 프롬프트는 바이트에 몇 분씩 남는다.
- "물음표가 있다", "N초 조용하다" 말고 프롬프트 고유의 문구와 푸터(
Esc to cancel · Tab to amend)에 매칭한다. 턴 종료도, 조용한 도구 실행도 밖에서 보면 프롬프트와 똑같다. -
blocked규칙은working보다 위에 두고, "아무것도 안 걸림"은 대기로 처리한다. 틀린 "대기 중"이 늦은 "대기 중"보다 비싸다. - 규칙은 릴리스 없이 갈아 끼울 수 있는 데이터 파일로 두고, 실행할 프로그램은 거기서 못 바꾸게 한다.
- 프로세스 테이블은 "누가 돌고 있나"와 "끝났나"에만 쓰고 "기다리나"에는 쓰지 않는다. 직접 띄운다면 훅이나 ACP를 쓰되, 화면 위 프롬프트를 보고보다 우선한다.
- 사람이 볼 거라면 수백 ms 주기면 충분하다. pane에 입력하기 직전에만 다시 판정한다.
코드는 vt.py, rules.py, fake_agent.py, supervisor.py 네 파일에, 표를 만드는 fixtures.py, analyze.py가 전부다. 결과가 보이는 화면은 osFoundry 데스크톱 앱의 에이전트 세션 보드다.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.