PTY 없이 Claude CLI 헤드리스로 돌리기
인터랙티브 CLI 에이전트를 무인 자동화로 돌릴 때, PTY 대신 --input-format stream-json + stdin/EOF 로 깔끔하게 헤드리스 실행하는 방법.
인터랙티브 CLI 에이전트(Claude Code 같은)를 무인 자동화 파이프라인에서 돌리려 하면 곤란한 지점이 하나 있다. 이 글은 그 문제를 --input-format stream-json 하나로 깔끔하게 푼 과정을 정리한다. PTY(가짜 터미널)도, 내부 소켓 프로토콜도 필요 없다.
문제 — "대화형"과 "스크립트"는 원래 상극
Claude Code CLI 같은 에이전트는 두 가지 실행 모드가 있다.
- print 모드 (
claude -p "프롬프트"): 프롬프트를 인자로 받아 한 번 처리하고 결과를 출력하고 끝. 스크립트 친화적이지만 "한 방"이다. - 인터랙티브 모드 (
claude): 사람이 타이핑하는 대화형 TUI. 세션이 살아있고 훅·권한 흐름이 대화형 그대로 동작한다.
자동화 파이프라인에서 인터랙티브 모드의 동작이 필요한데 사람 없이 돌려야 하는 상황이 있다. 흔한 해법은 PTY(pseudo-terminal, 예: pexpect)로 인터랙티브 TUI를 띄우고 키 입력을 흉내내는 것이다.
그런데 이 PTY 방식이 문제를 일으켰다. 자동화 오케스트레이터의 asyncio 이벤트 루프 아래에서 PTY 기반 래퍼가 내부적으로 자식 프로세스를 fork 할 때, 이벤트 루프의 자식 관리(SIGCHLD)와 충돌해 약 10~25% 확률로 간헐적으로 실패했다. 게다가 그 실패가 "응답 없음(타임아웃)" 으로 가려져 원인 파악이 어려웠다.
재현 실험으로 원인을 좁혔다.
| 구동 방식 | 실패율 |
|---|---|
subprocess.run (순수 블로킹) | 0/10 |
subprocess.Popen + 스레드 | ~1/10 |
asyncio.create_subprocess_exec | ~2/8 |
즉 래퍼 자체가 간헐 실패를 내고, asyncio 루프가 그 확률을 키웠다.
핵심 발견 — --input-format stream-json
한동안 "인터랙티브 모드를 쓰려면 PTY로 TUI를 몰아야 한다"고 믿었다. 틀린 전제였다. 놓친 플래그가 하나 있었다.
claude --input-format stream-json --output-format stream-json(즉-p/--print없이) 로 띄우면, CLI 가 TUI 대신 헤드리스 스트리밍 모드로 동작한다. stdin 으로 JSON 메시지를 넣고 stdin 을 닫으면(EOF), 그 턴을 수행하고 스스로 정상 종료(exit 0) 한다.
정리하면:
-p없음 → print 모드가 아니다 = 인터랙티브 세션의 동작을 그대로 유지--input-format stream-json→ stdin 으로 JSON 사용자 메시지를 받는 헤드리스 입력- stdin 을 닫음(EOF) → 일회성 실행처럼 깔끔히 종료
PTY 도, 별도의 내부 소켓 프로토콜도 필요 없다. 그냥 표준 OS 파이프로 프로세스의 stdin/stdout 을 붙잡으면 된다.
동작 흐름
포인트는 프롬프트를 argv 가 아니라 stdin 으로 주입한다는 것. argv 길이 제한이 없어 대용량 프롬프트도 안전하다.
구현 — 파이프 + JSON + EOF
핵심 코드는 세 줄로 요약된다(파이썬 예시).
import json, subprocess, threading
cmd = [
"claude",
"--input-format", "stream-json", # ← 이게 핵심. -p 없음.
"--output-format", "stream-json",
"--verbose",
"--dangerously-skip-permissions",
"--model", "opus",
]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
def feed():
msg = {"type": "user", "message": {"role": "user",
"content": [{"type": "text", "text": PROMPT}]}}
proc.stdin.write((json.dumps(msg) + "\n").encode())
proc.stdin.flush()
proc.stdin.close() # ← EOF: 턴 수행 후 스스로 종료
threading.Thread(target=feed, daemon=True).start() # 대용량 프롬프트 데드락 방지
for line in proc.stdout: # stream-json 이벤트 소비
event = json.loads(line)
...
주의점 하나: 대용량 프롬프트가 파이프 버퍼(보통 64KB)를 넘기면, 자식이 아직 stdin 을 다 읽지 않은 상태에서 write 가 블로킹돼 데드락이 날 수 있다. 그래서 stdin write 를 별도 스레드에서 처리한다.
기존 명령이 claude -p "프롬프트" [플래그...] 형태라면, 변환은 단순하다 — -p 프롬프트 를 떼고 맨 앞에 --input-format stream-json 을 붙인 뒤, 프롬프트는 stdin 으로 보낸다. 나머지 플래그(--output-format, --model, --max-turns, --resume, 세션 이름 등)는 같은 바이너리라 그대로 유효하다.
검증에서 확인된 것들
실측으로 다음을 모두 확인했다.
- 일회성 종료: 프롬프트 주입 후 stdin 을 닫으면 그 턴을 수행하고 exit 0 로 깔끔히 끝난다.
- 도구 호출: 인터랙티브 stream-json 모드에서도 파일/셸 등 도구 호출이 정상 동작한다(tool_use → tool_result → result).
- 세션 이어가기:
--resume <세션ID>로 이전 대화를 정확히 회상한다(직전 세션에 넣어둔 비밀 단어를 재개 후 그대로 답함). - 세션 메타 보존: 세션 이름 플래그가 세션 기록 첫 줄에 남아 나중에 세션을 조회·이어가기 좋다.
실제 자동화 파이프라인의 한 작업(약 10분 30초 소요)이 이 방식으로 처음부터 끝까지 간헐 실패 없이 안정적으로 완주했고, 후속 단계로 자동 연결까지 됐다.
로깅은 공짜로 따라온다
인터랙티브 세션을 유지하기 때문에, CLI 의 훅(hook) 시스템이 그대로 발화한다. 세션 시작·프롬프트 제출·도구 호출 전후·종료 시점에 훅이 실행되어 각 이벤트를 로그로 남길 수 있다(실측: SessionStart · UserPromptSubmit · PreToolUse · PostToolUse · Stop 전부 발화).
이는 print 모드나 PTY 래퍼 대비 관측성 이점이 크다. 특히 도구 호출 로그(PreToolUse/PostToolUse) 까지 훅으로 자연스럽게 수집되므로, "이 자동 실행이 어떤 파일을 읽고 어떤 명령을 돌렸는지"를 이벤트 타임라인으로 그대로 볼 수 있다.
| 이벤트 | print(-p) | PTY 래퍼 | stream-json 인터랙티브 |
|---|---|---|---|
| 세션/프롬프트 이벤트 | 훅 | 수동 보완 | 훅 ✅ |
| 도구 호출 로그 | 훅 | 누락되기 쉬움 | 훅 ✅ |
| 최종 상태/소요시간 | 완료 콜백 | 완료 콜백 | 완료 콜백 ✅ |
정리
- 인터랙티브 CLI 에이전트를 무인 자동화로 돌릴 때, PTY 로 TUI 를 흉내내는 대신
--input-format stream-json을 쓰자. -p를 빼고 stdin 으로 JSON 메시지를 넣은 뒤 stdin 을 닫으면(EOF), 인터랙티브 동작을 유지한 채 일회성·스크립트 친화적으로 실행된다.- 표준 파이프만 쓰므로 PTY 의 이벤트 루프 충돌, 소켓 누수, 프로토콜 역설계 같은 복잡도가 전부 사라진다.
- 인터랙티브 세션이라 훅 기반 로깅/관측성이 그대로 따라온다.
작은 플래그 하나가 "대화형이면서 스크립트 가능한" 실행을 열어준 사례였다.
Comments
No comments yet. Be the first to comment!
★Reviews for this material
Write a reviewNo reviews for this material yet.