
Claude Code를 병렬로 실행할 때 '어느 것이 입력 대기 중인지' 알 수 없는 문제와 macOS 네이티브 앱을 통한 해결
요약
Claude Code를 병렬로 사용할 때 발생하는 입력 대기 상태 인지 문제를 해결하기 위한 macOS 앱 'AgentManager'의 구현 과정을 다룹니다. 파일 기반의 hook 메커니즘과 FSEvents를 활용하여 데몬 없이도 세션 상태를 효율적으로 감시하는 아키텍처를 소개합니다.
핵심 포인트
- Claude Code의 hooks 메커니즘을 활용한 세션 라이프사이클 이벤트 구독
- 데몬이나 소켓 없이 파일 기반의 단순하고 견고한 상태 관리 아키텍처
- 병렬 실행 시 데이터 유실을 방지하기 위한 flock 및 atomic rename 적용
- Notification 이벤트를 분석하여 '사용자 입력 대기' 상태를 판정하는 로직
「정신 차려보니 10분 동안 멈춰 있었다」 문제
Claude Code를 깊게 사용할수록 세션은 병렬로 늘어납니다. 리포지토리 A에서 기능 구현, 리포지토리 B에서 버그 조사, worktree에서 리팩터링——여기까지는 쾌적하지만, 병렬 수가 늘어나면 반드시 다음과 같은 상황이 발생합니다.
- 세션을 실행해 두고 다른 작업으로 이동한다
- 그 이면에서 세션이 권한 확인(
Do you want to allow...?) 단계에서 멈추는 것을 알아차리지 못한다 - 10분 뒤에 확인하러 가보니 "계속 기다리고 있었구나..."라고 깨닫게 된다
에이전트의 실행 시간보다, **인간이 "기다리고 있다는 사실을 알아차리기까지의 시간"이 병목(bottleneck)**이 되는 것입니다. 터미널 탭을 육안으로 순회하는 방식은 병렬 수가 3을 넘어서는 시점부터 한계에 부딪힙니다.
이 문제를 해결하기 위해, Claude Code의 모든 세션을 감시하여 "입력 대기 상태가 된 순간에만" 플로팅 윈도우(floating window)로 알려주는 macOS 앱인 AgentManager를 만들었습니다. 이 기사에서는 그 이면의 구현——특히 "어떻게 세션 상태를 감지하는가", "어떻게 올바른 터미널로 점프하는가"에 대해 작성하겠습니다.
전체 아키텍처: 데몬 없음, 소켓 없음, 전부 파일
구성은 철저하게 단순하게 만들었습니다.
Claude Code
└─ hook 발화(SessionStart, PreToolUse, Notification, Stop, ...)
└─ 컴파일된 hook 바이너리(Swift)
...
Claude Code에는 hooks라는 메커니즘이 있어, 세션의 라이프사이클 이벤트(세션 시작, 툴 실행 전후, 알림, 정지 등)에서 임의의 명령을 실행할 수 있습니다. AgentManager는 이 9가지 이벤트를 모두 구독하고, 받은 이벤트를 세션 ID별 JSON 파일로 응축(fold)합니다. GUI 측은 해당 디렉토리를 FSEvents(DispatchSource)로 감시하기만 하면 됩니다.
데몬(daemon)도 Unix 소켓도 IPC도 사용하지 않았습니다. 파일 기반으로 만든 이유는 다음과 같습니다:
- hook 프로세스는 매번 실행되고 즉시 종료된다. 상주 프로세스와의 연결 관리가 필요 없는 파일 추가(정확히는 교체)가 가장 고장이 적다. GUI가 종료되어 있어도 이벤트는 손실되지 않는다(파일에 남음).
- 디버깅이 쉽다.
cat ~/.claude/agent-manager/sessions/*.json명령으로 모든 상태를 볼 수 있다.
단, 파일 기반 방식에는 병행성(concurrency)의 함정이 있습니다. 서브 에이전트가 병렬로 실행되면 동일한 세션의 JSON에 여러 hook 프로세스가 동시에 쓰려고 하기 때문에, **세션 단위의 flock으로 잠금(lock)**을 걸고, tmp 파일에 작성한 뒤 rename(2)를 통해 원자적(atomic)으로 교체하고 있습니다. 이를 도입하기 전에는 lost update로 인해 서브 에이전트의 종료 이벤트가 사라지는 경우가 있었습니다.
가장 어려운 것은 「waiting」 판정
「실행 중(processing)」「완료(done)」는 간단합니다. 어려운 것은 "인간의 입력을 기다리고 있는가"의 판정이며, 이것이 이 앱의 존재 의의 그 자체입니다.
hook 이벤트와 세션 상태의 매핑은 순수 함수인 상태 머신(reducer)으로 구현했습니다. 테스트를 작성하기 쉽기 때문입니다. 기본 구조는 다음과 같습니다:
| hook 이벤트 | 상태 |
|---|---|
SessionStart | idle |
UserPromptSubmit / PreToolUse / PostToolUse | processing |
Stop | done |
Notification | 내용에 따라 다름 (이것이 핵심) |
Notification은 종류에 따라 분기한다
「입력 대기」는 Notification 이벤트로 전달되지만, Notification에는 종류가 있어 전부를 waiting으로 처리하면 오작동(false positive)이 발생합니다.
permission_prompt(권한 확인),elicitation_dialog(질문 다이얼로그) $
ightarrow$ waitingidle_prompt(한동안 조작이 없을 때의 알림) $
ightarrow$ waiting으로 만들지 않음. 이를 waiting으로 만들면 "완료되어 방치된 세션"이 확인 대기 상태로 변해, 양치기 소년 앱이 되어버림.- 인증 성공 등 기타 알림 $
ightarrow$ 상태 유지
Notification이 오지 않는데 기다리고 있는 케이스
더욱 까다로운 것은, 사용자에게 선택을 요구하고 있음에도 Notification(알림)이 발생하지 않는 케이스입니다. 구체적으로는
AskUserQuestion (선택지 질문)이나 ExitPlanMode (플랜 승인)와 같은 경우입니다. 이들은 화면상으로는 명확하게 "인간의 차례"임에도 불구하고, 알림 이벤트가 날아오지 않습니다. 그래서 이 도구들은 PreToolUse (도구 실행 직전의 이벤트) 시점에 waiting 상태로 전환하는 특별 처리를 하고 있습니다. "이 도구의 실행 = 인간에게 질문"이므로, 실행 전 이벤트가 그대로 입력 대기 알림으로 사용될 수 있기 때문입니다.
서브 에이전트(Sub-agent)에서 "너무 빠른 done"을 방지하기
Claude Code는 서브 에이전트를 병렬로 실행할 때가 있습니다. 이때 메인의 Stop이 먼저 오더라도, 서브 에이전트가 아직 작동 중이라면 작업이 끝난 것이 아닙니다. 단순히 Stop → done으로 처리하면 "완료되었다고 표시되었는데 여전히 열심히 돌아가고 있는" 현상이 발생합니다.
따라서 상태값에 가동 중인 서브 에이전트의 ID 집합을 갖게 하여,
SubagentStart에서 집합에 추가- 메인의
Stop이 와도 집합이 비어 있지 않으면processing을 유지 - 마지막
SubagentStop에서done으로 처리
하도록 구현했습니다.
"어느 터미널에서 실행 중인가"는 프로세스 트리로 판정한다
점프 기능(후술)을 위해서는 각 세션이 어느 앱의 터미널에서 실행되고 있는지를 기록해야 합니다.
처음에는 환경 변수(__CFBundleIdentifier나 ITERM_SESSION_ID)를 확인했으나, 이는 함정이었습니다. 환경 변수는 자식 프로세스에 상속되기 때문에, 예를 들어 iTerm2에서 Android Studio를 실행하면 Android Studio의 통합 터미널에서 실행되는 Claude Code에도 ITERM_SESSION_ID가 따라붙습니다. 이를 "iTerm2의 세션"이라고 오판하게 되는 것입니다.
현재는 hook 바이너리가 ps -axww -o pid=,ppid=,comm= 스냅샷을 한 번 찍고, 프로세스 트리를 거슬러 올라가 가장 가까운 조상의 .app 번들 내 Info.plist에서 CFBundleIdentifier를 읽는 방식을 사용하고 있습니다. 프로세스의 부모-자식 관계는 상속으로 오염되지 않으므로, 이 방식으로 통합 터미널 문제를 해결했습니다.
두 가지 세부적인 함정도 소개합니다.
- PID 재사용: 세션의 생사 판정에 PID를 사용하면, OS가 PID를 재사용했을 때 "죽은 세션이 살아있는 것"으로 간주될 수 있습니다.
kill(pid, 0)을 통한 생존 확인에 더해, 프로세스의 시작 시각(ps -o lstart=)을 기록 시점과 대조하여 동일한 프로세스인지 확인하고 있습니다. - 로케일 (Locale): 해당
lstart의 문자열 표현은 로케일에 따라 달라집니다. 기록하는 쪽과 대조하는 쪽의 로케일이 다르면, 살아있는 세션을 고아(orphan)로 오판하여 삭제할 수 있습니다. 양쪽 모두LC_ALL=C로 고정하여 해결했습니다.
터미널로의 점프는 "호스트마다 전부 다르다"
목록의 세션을 클릭하면 해당 세션이 실행 중인 터미널로 이동합니다. 이 기능은 호스트 앱마다 구현 수단이 제각각이라, 구현 과정은 완전히 꾸준한 각개격파의 연속이었습니다.
| 호스트 | 수단 | 정밀도 |
|---|---|---|
| iTerm2 | AppleScript. 세션 고유의 GUID (ITERM_SESSION_ID)로 모든 window/tab/session을 탐색하여 select | 페인(Pane) 단위 (최강) |
| Terminal.app | AppleScript. GUID가 없으므로 세션의 PID로부터 ps -o tty=를 통해 TTY를 도출하고, 각 탭의 tty 속성과 대조 | 탭 단위 |
| Ghostty 1.3+ | AppleScript. 공개된 정보는 id / name / working directory뿐임. cwd가 정확히 1건 일치할 때만 포커스 (0건/복수 건일 경우 앱을 전면으로 가져오기만 하여 오작동 방지) | 탭/페인 단위 (조건부) |
| VS Code / Cursor / Zed | 각 CLI (code 등)에 --reuse-window <cwd> 사용 | 프로젝트 윈도우 |
| Warp / kitty / WezTerm / Alacritty | open -b로 전면화만 수행 | 앱 단위 |
| tmux | 의도적으로 미지원 (감지는 함) | — |
tmux를 미지원으로 둔 것은 기술적으로 불가능해서가 아니라, 오작동할 바에는 차라리 작동하지 않는 편이 낫기 때문입니다. tmux는 서버가 데몬(Daemon)화되므로, 프로세스 트리(Process Tree)를 거슬러 올라가도 '지금 사용자가 보고 있는 클라이언트 터미널'에 도달할 수 없습니다. 잘못된 윈도우로 이동하는 기능은 없는 기능보다 사용자 경험(UX)이 나쁩니다.
또한, AppleScript 실행(osascript)은 전용 시리얼 큐(Serial Queue)를 통해 직렬화하고 있습니다. 점프 기능을 연타했을 때 병렬로 실행되면 동작이 깨질 수 있고, 메인 스레드에서 실행하면 앱 전체가 비치볼(Beachball, 응답 없음) 상태가 되기 때문입니다.
타인의 settings.json을 수정한다는 긴장감
hook의 등록 대상은 ~/.claude/settings.json, 즉 사용자의 설정 파일입니다. 이곳을 기계적으로 덮어쓰는 것은 개발자의 도트파일(Dotfiles) 환경을 파괴하는 가장 빠른 길입니다. 실제로 고려한 사항은 다음과 같습니다:
- JSON 전체를 파싱하여 다시 쓰지 않음: JSON 전체를 파싱하고 다시 쓰면 키 순서, 인덴트(Indent), 마지막 줄 바꿈이 모두 바뀌어 버립니다. 그래서 balanced-brace 스캔을 통해 최상위
hooks키의 값만 교체하도록,hooks값의 범위만 특정하여 텍스트 치환을 수행하는 미니 스플라이서(Mini-splicer)를 작성했습니다. - 심볼릭 링크(Symlink)를 보존함: dotfiles 관리(GNU stow 등)를 통해
settings.json이 심볼릭 링크로 되어 있는 경우, 단순하게 'tmp에 쓰고 rename'하는 방식은 링크를 실제 파일로 대체하여 링크를 파괴합니다. 링크 대상을 해결(Resolve)한 후 기록합니다. - 파싱할 수 없는 파일(손상된 JSON)은 건드리지 않고 에러 처리함
- 쓰기 전에
.bak백업을 생성함 - hook 명령에는
$HOME기반의 상대 표현을 사용하여, 터미널 고유의 절대 경로가 설정 파일에 남지 않도록 함
이러한 부분들은 기능이라기보다 신뢰의 문제이며, '멋대로 settings.json을 수정하는 앱'이 수용될 수 있는지 여부를 가르는 분수령이라고 생각합니다.
자잘한 macOS 네이티브 구현 이야기
본론은 아니므로 빠르게 넘어가겠습니다.
- 플로팅 윈도우(Floating Window)는
NSPanel(level = .floating, 모든 Space 표시)을 사용합니다. SwiftUI를NSHostingView로 얹는 하이브리드 구성입니다. - 자동 표시/숨김은 'waiting/done 상태의 세션 ID 집합의 에지(Edge)'로 판정합니다. 개수로 판정하면 사용자가 수동으로 닫은 후 폴링(Polling) 과정에서 멋대로 다시 나타날 수 있습니다.
- 세션은 고양이로서 픽셀 아트 방에 살고 있으며,
session_id의 FNV-1a 해시로부터 털 색깔, 걷는 속도, 애니메이션 위상(Phase)을 결정론적으로 생성합니다. 동일한 세션은 언제 보더라도 같은 고양이가 됩니다 (난수를 저장하지 않고 시드(Seed)로부터 재구축). - 도트 그래픽은 SwiftUI Canvas + Core Graphics를 사용하며, 안티앨리어싱(Anti-aliasing)과 보간(Interpolation)을 완전히 끄고 1 논리 픽셀을 사각형 채우기로 베이크(Bake)합니다.
- 8Hz 시뮬레이션이지만, 스냅샷이 이전과 동일하면 재렌더링을 스킵합니다. 윈도우가 완전히 가려져 있을 때는 (오클루전(Occlusion) 알림을 통해) 틱(Tick) 자체를 멈춥니다.
이 부분에 대한 이야기는 각각 별도의 기사 한 편을 쓸 수 있을 정도의 분량이므로, 반응이 있다면 후속편을 작성하겠습니다.

요약
- Claude Code의 병렬 운용은 "기다리고 있다는 사실을 인지하기까지의 시간"이 병목 현상(Bottleneck)임
- hook → 로컬 JSON → FSEvents라는 파일 기반 구성은 단순한 데 반해 고장이 적고 디버깅하기 쉬움
- "waiting" 판정은 Notification 종류 분기 +
PreToolUse의 특별 취급 + 서브 에이전트(Sub-agent) 추적을 조합한 방식 - 터미널 점프(Terminal Jump)에 은탄환(Silver Bullet)은 없다. 호스트별로 개별 격파 + "오작동할 바에는 실행하지 않는다"
AgentManager는 macOS 13+(Apple Silicon / Intel)를 지원하며, 7일간의 무료 트라이얼(카드 등록 불필요)이 있습니다. 병렬 세션의 "무작정 기다림"을 경험해 보셨다면, 한번 시도해 보세요.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기