여러 Claude Code 세션의 상태를 한눈에 보는 위젯을 만들며, 믿기 어려운 신호를 다른 신호로 교차 검증한 과정을 정리했습니다.
AI 사용에 익숙해지면서 Claude Code를 여러 탭에 띄워 두고 병렬로 작업하는 일이 많아졌습니다. 문제는 세션이 권한 승인을 기다리며 멈춰 있어도 바로 알아차리기 어렵다는 것이었습니다. 알림이 뜨더라도 어느 세션이 대기 중인지는 탭을 하나씩 열어 봐야 알 수 있어서, 승인을 기다리는 세션을 한참 뒤에야 발견하곤 했습니다. 그래서 세션마다 지금 어떤 상태인지 한눈에 보여주는 데스크탑 위젯을 만들었습니다.
| 기간 | 3일 (개인) |
| 배포 | Homebrew |
| 링크 | GitHub |
| 구분 | 스택 |
|---|---|
| 앱 | |
| 상태 및 데이터 | |
| 빌드 및 배포 |
Claude Code의 터미널 UI가 React Ink로 만들어졌다고 알려져 있는데, React가 웹을 넘어 터미널까지 커버한다는 점이 인상적이었습니다. 그래서 React로 웹 브라우저 바깥의 애플리케이션을 만들어 보고 싶었습니다. 다만 이 위젯은 상시로 켜 둬야 하는 도구라 터미널 안에 갇힌 형태보다 데스크탑 앱이 낫다고 판단해, Ink 대신 Electron을 골랐습니다.
여기서 처음의 전제 하나가 틀렸다는 걸 나중에 깨닫게 됩니다. React가 웹을 벗어나 훨씬 넓은 곳까지 간다고 기대했는데, 막상 만들어 보니 화면을 그리는 Renderer 프로세스는 Chromium 그 자체였습니다. React가 웹을 벗어난 게 아니라, 웹이 데스크탑 안으로 들어온 것에 가까웠습니다. 웹과 달랐던 지점은 React가 아니라 그 바깥, 곧 프로세스 분리와 IPC, 파일 접근 권한이었습니다.
그 차이가 구조로 드러난 곳이 파일 접근이었습니다. Electron에는 브라우저가 대신 처리해 주던 보안이 없고, 코드가 파일 시스템에 직접 닿을 수 있습니다. 그래서 ~/.claude/projects/ 아래 세션 파일 접근은 전부 Node.js가 도는 Main 프로세스에서 처리하고, Renderer는 IPC로 결과만 받는 구조를 세웠습니다.
Claude Code는 자신의 상태를 알려주는 API를 제공하지 않습니다. 그래서 상태를 읽어 올 실마리부터 찾아야 했고, 후보는 두 갈래였습니다.
Hook — 도구 실행 전후, 권한 요청, 세션 종료 등 특정 시점에 외부 명령을 실행해주는 Claude Code의 기능입니다. 빠르고 정확하지만, 위젯 앱이 켜지기 전에 시작된 세션은 알 수 없고 hook이 항상 오는 것도 아닙니다.
세션 기록 파일 — ~/.claude/projects/ 아래 세션마다 쌓이는 JSONL 파일입니다. 느리지만 모든 세션에 대해 항상 존재하는 근거입니다.
각각 약점이 뚜렷했기 때문에, 하나만 고르는 대신 둘 다 쓰고 한 곳에서 합치는 쪽을 선택했습니다. hook 이벤트는 앱 안의 HTTP 서버가 받아 메모리 상태(Map)에 남기고, 세션 기록 파일은 별도의 watcher가 변경을 감지해 파싱합니다. 두 경로는 한 함수로 모여 세션 ID로 join된 뒤 IPC로 Renderer에 전달됩니다.
다만 두 신호를 처음부터 함께 쓴 것은 아닙니다. 초기에는 기록 파일을 주기적으로 읽는 폴링 방식이라 Renderer가 먼저 묻는 요청-응답이면 충분했습니다. 하지만 폴링 주기만큼 반영이 늦어서 hook을 더했고, Renderer로 전달하는 방향도 그에 맞춰 Main이 외부 이벤트를 먼저 받아 webContents.send로 Renderer에 push하는 구조로 바꿨습니다.
hook으로 실행되는 스크립트는 Claude Code가 띄우는 짧은 수명의 별개 프로세스입니다. 이 스크립트가 앱의 HTTP 서버 포트를 알아낼 표준적인 방법이 없었습니다. 포트를 고정하면 간단하지만, 다른 프로그램이 이미 그 포트를 쓰고 있으면 충돌이 납니다.
앱이 켜질 때 포트 0으로 서버를 열어 OS가 빈 포트를 골라주게 하고(listen(0), 127.0.0.1에만 바인딩), 그 번호를 ~/.claude/.pet-hook-port 파일에 적어둡니다. hook 스크립트는 stdin으로 받은 이벤트를 이 포트로 전달하기만 합니다. 앱이 종료되면 파일을 지우므로, 앱이 꺼져 있으면 hook 스크립트는 파일이 없는 것을 확인하고 즉시 종료합니다.
hook은 사용자의 실제 작업 세션 안에서 실행됩니다. 여기서 에러가 나거나 응답이 오래 걸리면, 위젯 하나 때문에 진짜 작업이 지연됩니다.
그래서 hook 스크립트는 성공하든 실패하든 타임아웃되든 항상 exit(0)으로 종료합니다. hook 자체도 비동기로 등록해, 작업 세션이 hook의 응답을 기다리지 않게 했습니다.
// 어떤 경로로 끝나든 exit(0) — Claude Code에 에러를 노출하지 않는다
req.on("error", () => process.exit(0));
req.on("timeout", () => process.exit(0));
파일이 바뀔 때마다 전체를 다시 읽으면 앱이 점점 느려지는 구조였습니다.
파일 전체를 다시 읽는 대신, 어디까지 읽었는지 바이트 위치를 기억해뒀다가 새로 늘어난 부분만 읽고, 파싱도 이전 결과에 새 줄만 이어 붙입니다. 파일이 재작성되거나 회전돼 갑자기 작아지면 엉뚱한 위치부터 읽어 깨진 JSON을 만나므로, 그 경우만 처음부터 다시 읽습니다.
// 파일이 줄었으면 잘린 것이므로 처음부터 다시 읽는다
const start = size < lastOffset ? 0 : lastOffset;
권한 요청이 오면 hook이 상태를 waiting_permission으로 바꾸고, 사용자가 거부하면 PermissionDenied hook이 와서 다시 풀어줘야 합니다. 그런데 이 hook이 오지 않는 경우가 있었습니다. 그 결과 이미 끝난 세션이 화면에서는 계속 "승인 대기 중"으로 표시됐습니다.
hook만으로는 이렇게 멈춘 상태를 풀 방법이 없습니다. 여기서 설계 단계에서 합쳐 둔 기록 파일을 썼습니다. hook이 준 waiting_permission 상태를, 기록 파일에 남은 실제 이벤트로 다시 확인하도록 했습니다.
도구 실행 결과가 기록에 남았다는 것은 그 호출이 어떤 식으로든 끝났다는 뜻입니다. hook이 오지 않아도 이걸로 판단할 수 있고, 두 출처를 한 곳에서 합쳐 두었기 때문에 가능한 판단이었습니다.
3일짜리 작은 프로젝트였지만, 만들면서 남은 것은 크게 두 가지입니다.
하나, 믿을 수 없는 신호가 있으면 믿을 수 있는 다른 신호와 교차 확인할 수 있는 구조를 만들어두는 편이 낫다는 것입니다. hook만 썼다면 승인 대기에 멈춘 세션을 고칠 방법이 없었고, 기록 파일만 썼다면 반응이 느렸을 겁니다.
둘, 시작할 때 품었던 전제가 틀렸다는 것을 직접 확인한 것입니다. React로 웹 바깥을 만든다고 생각했지만, 정작 새로 배운 것은 프로세스 분리와 IPC, 파일 접근 권한처럼 전부 React 바깥에 있었습니다.
배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
특정 요청도, 붐비는 시간대도 아닌데 502가 간헐적으로 떴고 다시 보내면 멀쩡했습니다. 무작위처럼 보이는 이 오류는 어디서 생긴 걸까요?