AI

AI 세션 대시보드 만들기

Claude Code 세션을 여러 개 돌리면 어느 게 권한 대기인지, 어떤 탭이 무슨 작업인지 분간이 안 된다. claude-deck이라는 브라우저 터미널 대시보드를 직접 만들어보며 겪은 문제와 경험을 정리했다.

어느 탭이 권한 대기이고, 어느 탭이 무슨 작업일까.

AI로 개발하다 보면 자연스럽게 세션을 여러 개 돌리게 된다. 하나가 리팩터링을 도는 동안 다른 하나에 다음 작업을 시키고, 그러다 보면 켜 둔 창이 계속 늘어난다. 나도 Claude Code 세션을 동시에 적게는 네 개 많게는 여덟 개까지 띄워 놓고 일한다. 세션 수가 늘어날수록 세션 관리가 안 돼서 불편했다. 어떤 탭은 입력이나 승인을 기다리며 멈춰 있고 어떤 탭은 혼자 열심히 돌고 있는데, 탭을 하나씩 눌러 보지 않으면 상태를 알 수가 없었다. 마음에 드는 터미널 테마도 없었고, 차라리 내가 필요한 상태 표시와 제어 기능을 직접 얹어서 내 입맛대로 만들어 쓰는 게 낫겠다는 생각이 들었다.

claude-deck이라는 브라우저 터미널 대시보드를 직접 만들어보며 겪은 문제와 경험을 정리했다. 다만 혼자 쓰는 로컬 도구라 비교 지표는 없으며, 아래 기록은 전적으로 체감에 기반한다.


1. 배경: 세션 관리가 안 되는 이유

기존 방식의 한계 (메뉴바 앱과 Warp)

처음엔 메뉴바 앱으로 풀어 보려 했다. Claude Code 훅이 상태를 떨구면 메뉴바에 세션별 색 동그라미를 띄우는 방식이었고, 여기에 claude-status-bar라는 이름을 붙였다. OS 알림바의 작은 아이콘 하나로는 어떤 세션이 어떤 상태인지 분간이 안 됐다. 동그라미가 몇 개 떠 있어도 “그래서 뭐가 권한 대기인데”가 한눈에 들어오지 않아, 첫 실험작은 거기서 멈췄다.

claude-status-bar의 세션별 상태 동그라미

그다음엔 Warp 터미널을 검토했다. Warp는 분할과 세션 관리가 매끄럽고, 출력을 명령 단위 블록으로 끊어 보여줘 긴 로그에서 실행 하나만 골라 되짚기 좋은 도구다. 하지만 Warp 역시 그 탭 안에서 도는 claude가 지금 권한 대기인지, 작업 중인지, 끝났는지는 알지 못한다. 터미널은 텍스트를 흘려보낼 뿐, 정작 알고 싶은 세션 상태는 그 흐름에서 빠져 있었다.

Warp 탭 목록에 뜬 Claude Code 세션 두 개

탭 두 개가 둘 다 Claude Code라는 같은 이름으로 떠 있다. 목록만 봐서는 어느 쪽이 무슨 작업인지도, 지금 나를 기다리는 쪽이 어느 쪽인지도 알 수 없다.

Tab 1  Claude Code   작업 중?      → 눌러 봐야 안다
Tab 2  Claude Code   권한 대기!    → 열기 전엔 모른다 (방치)
Tab 3  Claude Code   완료          → 이것도 눌러 봐야 안다

설계의 기준이 된 5가지 조건

필요한 건 탭을 빨리 오가는 방법이 아니었다. 어느 탭으로 가야 하는지 묻지 않게 만드는 쪽이었고, 그래서 조건을 다섯 개 적었다.

  1. 모든 세션의 상태가 한 화면에 모여야 한다.
  2. 상태를 보는 데서 끝내지 않고 그 자리에서 응답까지 해야 한다.
  3. 대시보드를 닫아도 세션은 살아 있어야 한다.
  4. 터미널 안 claude의 조작을 하나도 잃지 않아야 한다.
  5. 도구가 죽어도 평소 터미널 흐름으로 돌아갈 수 있어야 한다.

프레임워크 선택부터 어떤 기능을 안 만들지까지 판단이 갈릴 때마다 이 목록으로 돌아갔으니, 이 다섯 개가 이후 모든 결정의 척도가 된 셈이다.

claude-deck 전체 화면

좌측 사이드바는 세션을 입력 대기, 작업 중, 대기 순으로 묶고, 상단 탭 줄의 동그라미도 그 색을 그대로 쓴다. 우측은 지금 보고 있는 세션의 git 변경 목록이다.


2. 작업 환경 선택: 아키텍처와 프레임워크 결정

PTY와 WebSocket을 활용한 브라우저 중계

가장 먼저 풀어야 할 건 터미널에서 도는 claude를 어떻게 브라우저 화면에 띄우느냐였다. claude는 원래 터미널 CLI이고 브라우저에는 터미널이 없으니, 터미널의 입출력을 가로채 브라우저로 중계하는 다리부터 놓아야 한다.

여기에 함정이 하나 먼저 있는데, claude를 그냥 자식 프로세스로 띄우면 색도 선택 메뉴도 없는 일반 텍스트만 나온다는 것이다. claude 같은 인터랙티브 CLI는 자기 출력이 진짜 터미널 화면에 꽂혔는지 아니면 파일이나 파이프로 새는지를 OS에 물어보고 동작을 바꾼다. 터미널이면 색을 입히고 화살표 선택 메뉴를 켜지만, 파이프면 그 장식을 다 끈다. 평범한 프로세스로 띄운 claude는 출력이 파이프로 샌다고 판단해 장식을 끈 일반 텍스트 출력으로 내려간다.

그래서 PTY(pseudo-terminal)가 필요하다. OS가 진짜 터미널 장치로 인정하는 가짜 터미널이다. node-pty로 claude를 PTY 안에서 띄우면 claude가 “내 출력이 터미널에 꽂혔나” 물었을 때 OS가 그렇다고 답하고, claude는 색과 선택 메뉴를 켠 채로 돈다. 이 가짜 터미널의 입출력을 양방향으로 중계하는 것도 node-pty의 몫이다.

이 차이는 직접 확인해 볼 수 있다. 터미널에서 node -p "process.stdout.isTTY"를 치면 출력이 터미널에 꽂혀 있다는 뜻으로 true가 뜨고, 같은 명령 끝에 | cat을 붙이면 undefined가 뜬다. 출력이 파이프로 새는 순간 프로그램이 그걸 감지한다는 뜻이고, claude도 이 신호를 보고 장식을 켤지 끌지를 가른다. 전체 경로는 이렇게 흐른다.

claude (PTY 안에서 실행)
  ↓ 출력 바이트
node-pty (가짜 터미널, 입출력 중계)
  ↓ WebSocket
xterm.js (브라우저 안의 웹 터미널)
  ↓
화면

입력은 정확히 역방향이라, 브라우저에서 키를 치면 xterm.js가 그 바이트를 WebSocket으로 서버에 보내고, node-pty가 PTY에 써넣고, claude가 받는다.

통신 프로토콜로는 SSE 대신 WebSocket을 골랐다. 출력이 거의 다 서버에서 브라우저로 가는 한 방향이라 SSE(Server-Sent Events)로도 되고 재연결도 공짜로 따라오지만, SSE는 단방향이라 키 입력을 보낼 채널이 없다. 터미널은 키 한 글자가 즉시 가야 하는데 입력용 HTTP 요청을 매번 새로 여는 건 군더더기라, 한 연결에서 양방향을 처리하는 WebSocket이 맞았다.

웹 화면이지만 파일 제어와 프로세스 조작은 그대로 된다. 브라우저에 뜬 화면은 껍데기일 뿐이고, PTY를 만들고 프로세스를 띄우고 파일을 읽는 건 그 뒤에서 도는 로컬 Node 서버가 전부 한다. 외부에 호스팅한 웹이 아니라 내 맥 안에서 도는 웹이라 로컬 서버가 OS 권한을 그대로 쥐고, 브라우저 샌드박스에 갇히지 않는다.

그만큼 네트워크 노출은 조심해야 한다. 이 서버는 인증이 없어서 접속만 되면 누구든 그대로 터미널을 쥔다. 기본은 127.0.0.1에 바인딩해 내 맥에서만 닿게 막았다. 127.0.0.1은 내 맥 자신만 가리키는 주소라 같은 와이파이에서 누가 포트를 두드려도 닿지 않는다. 반면 0.0.0.0은 LAN 주소까지 다 열어 같은 네트워크의 다른 기기가 들어올 수 있다. 다른 기기에서 접속할 일이 있으면 GLANCE_HOST=0.0.0.0으로 명시해 열되, 그조차 인증이 없으니 신뢰된 네트워크에서만 켠다.

스택은 백엔드 Node + TypeScript + Fastify + ws + better-sqlite3 + node-pty에 프론트 SvelteKit으로 잡았다.

프레임워크 선택: SvelteKit vs React

프론트엔드 프레임워크는 이 화면이 까다로운 만큼 가장 고민이 길었다. 초당 수십 번 들어오는 상태 이벤트를 흘리면서 xterm.js라는 명령형 터미널 위젯을 감싸야 한다. xterm은 자기 DOM을 직접 그리는 라이브러리라 선언형 프레임워크와 궁합이 미묘하다.

항목ReactSvelte
세밀한 반응성리렌더 단위가 컴포넌트, 수동 최적화 필요컴파일 타임 추적, 바뀐 값만 갱신
명령형 위젯 감싸기ref·effect로 가능하나 장황직접 DOM 접근이 자연스러움
익숙함·자료압도적상대적으로 적음
채용 시장주류소수

순수한 기술 적합도와 개인적인 학습 목적을 함께 고려해 SvelteKit을 골랐다. 적합도만 보면 Svelte가 살짝 유리했고, 나머지 절반은 이 프로젝트가 업무가 아니라 학습과 기록을 겸한 사이드 프로젝트라는 사정이었다. 익숙한 React로 빠르게 끝내는 것보다, 안 써 본 Svelte로 반응성 모델을 직접 부딪쳐 보는 쪽이 남는 게 많다고 봤다. React가 나쁜 선택이라는 뜻은 아니고, 팀이 붙고 유지보수가 길어지는 제품이었다면 자료와 인력이 두꺼운 쪽이 맞다.

대신 트레이드오프는 안고 간다. 막히면 참고할 자료가 React만큼 많지 않고, 이 경험이 채용 시장에서 React만큼 통용되지도 않는다. 게다가 터미널 출력은 Svelte 반응성을 거치지 않고 term.write로 직접 쓰는데, 초당 수십 번 들어오는 출력을 store에 태우면 반응성 오버헤드만 늘기 때문이다. 프레임워크의 강점과 그걸 우회해야 할 지점을 같이 배운 셈이다.

만들면서 오래 미뤄 둔 질문이 하나 더 있었다. 웹 말고 네이티브로 갔어야 하지 않나. 따져 보니 비교 대상 자체가 틀렸다. claude-deck은 데몬과 클라이언트로 나뉘어 있어서, tmux와 훅과 상태머신을 쥔 서버는 Swift로 가도 그대로 남고 바뀌는 건 클라이언트뿐이다. 웹 앱 대 네이티브 앱이 아니라 SvelteKit 화면 대 SwiftUI 화면의 비교였다.

항목웹(현재)Swift
폰 접속PWA + Tailscale로 끝iOS 앱을 따로 만들어야 함
OS 알림Notification API·Web Push로 가능설정 없이 확실함
터미널 위젯xterm.js 함정을 이미 다 밟아 놓음SwiftTerm에서 처음부터 다시
메뉴바·전역 단축키불가가능
개발 속도익숙한 스택, 빌드가 짧음학습 + Xcode 사이클

네이티브가 이기는 칸은 알림과 메뉴바뿐이었다. 알림은 브라우저 API로 대부분 덮이고, 정 아쉬우면 첫 실험작인 메뉴바 앱이 서버를 구독해 알림만 쏘면 된다. 반대로 웹을 버리면 대체화면 스크롤, OSC 8 하이퍼링크, Shift+Enter, 한글 인코딩처럼 뒤에서 기능마다 하나씩 나올 함정을 전부 다시 밟아야 한다. 그래서 웹으로 남기기로 했다. 네이티브 셸이 정말 필요해지는 날엔 지금 화면을 Tauri로 감싸면 되니, 재작성이 아니라 포장이다.


3. 핵심 기능 및 트러블슈팅

아래는 전부 만들어서 쓰고 있는 것들이고, 각 항목에 어떤 동작을 안 하게 해 주는지를 같이 적었다.

  • 세션 상태를 한 화면에 모은다. 사이드바가 세션을 권한 대기·작업 중·대기로 묶고, 대표 세션의 이름은 탭 제목에 상태는 파비콘 색에 실린다.
  • 권한 승인 카드가 뜨면 그 자리에서 승인하거나 거부한다. 주황이 뜬 세션을 찾아 터미널로 건너가 1을 누르고 돌아오는 왕복이 통째로 빠진다.
  • 우측 패널에서 워킹트리 변경과 diff, 브랜치가 베이스 대비 바꾼 것, PR 번호와 CI 상태를 본다. git status를 다시 칠 일이 없다.
  • 커밋과 푸시와 PR 생성까지 대시보드 안에서 끝난다.
  • 화면 분할로 세션을 나란히 띄운다. 중앙 탭바는 열어 둔 세션을 크롬 탭처럼 전환하는데, 분할과는 별개로 도는 축이라 세션 여섯 개를 열어 두고 그중 둘만 나란히 띄우는 조합이 성립한다.
  • 세션 유지는 tmux가 맡아서, 브라우저 탭을 실수로 닫아도 작업 중이던 claude가 죽지 않는다.
  • 폰에서 접속해, 자리를 뜬 사이에 올라온 권한 요청을 눌러 줄 수 있다.
  • MCP 상태 패널이 어떤 서버가 붙어 있는지를 사이드바에 띄운다. 매번 /mcp를 치지 않아도 된다.
  • 브로드캐스트는 체크한 세션들에 같은 지시를 한 번에 보낸다.

조작은 대부분 키보드에 걸어 뒀고, 자주 쓰는 것만 추리면 이렇다.

키동작
⌘K커맨드 팔레트
⌥1~⌥9번호로 세션 전환
⌥↑ ⌥↓이전/다음 세션
⌥N새 터미널
⌥D화면 분할(오른쪽에 새 터미널)
⌥G우측 패널 토글
⌥[ ⌥]패널 간 포커스 이동
⌥Q세션 종료

파비콘 색상 기반 상태 표시

이 대시보드의 출발점이자 메뉴바 앱이 못 풀었던 그 문제다. Claude Code 훅 8개를 상태머신에 흘려 색으로 환산하고, 좌측 패널의 동그라미 색이 그대로 세션 상태다.

훅 이벤트언제상태색
SessionStart세션 시작idle⚪ 회색
UserPromptSubmit프롬프트 제출working🟢 초록
PreToolUse도구 실행 직전working🟢 초록
PostToolUse도구 실행 직후working / error🟢 초록 / 🔴 빨강
Notification권한·질문 대기waiting-permission🟠 주황
SubagentStop서브에이전트 종료working🟢 초록
Stop응답 종료idle⚪ 회색
SessionEnd세션 종료idle⚪ 회색

활동성 이벤트는 전부 초록이고, 멈춤과 시작은 회색이고, 사람을 기다리는 Notification만 주황이다. SubagentStop이 초록인 건 서브에이전트 하나가 끝나도 메인은 계속 작업 중이기 때문이다.

이 푸시 모델엔 빈틈이 있다. 상태머신은 훅 이벤트가 와야 색이 바뀌기 때문에, claude가 작업 중 강제 종료되거나 Stop 훅이 유실되면 상태를 되돌릴 이벤트가 안 와 초록에 멈춘다. 시간으로 거르려 해도 claude는 thinking이나 긴 도구 실행으로 한참 조용할 수 있어 “몇 분 지나면 식음” 같은 임계값이 잘 안 맞는다. 답은 시간이 아니라 상태다. 세션 목록은 이미 주기적으로 동기화되니 그 김에 claude가 셸 프롬프트로 빠졌는지를 보면, 시간과 무관하게 식은 세션을 가려낼 수 있다. 푸시에 풀을 한 겹 더하는 것인데, 이건 다음 과제로 남겨 뒀다.

브라우저 대시보드라 얻은 게 하나 더 있다. 탭 줄 자체가 상태 표시 자리가 된다는 것이다. 이 발상은 토스증권에서 가져왔다. 종목 페이지를 열어 두면 탭 제목의 현재가와 등락이 실시간으로 갱신되고 파비콘도 오르내림에 따라 바뀐다. 탭을 앞으로 꺼내지 않아도 브라우저가 항상 그려 주는 자리만 흘끔 보면 된다는 뜻이다. 같은 발상을 GitHub Actions에서도 본다. 실행 중이면 노란 점, 끝나면 초록 체크로 탭 파비콘 자체를 바꾼다.

실행 중(노란 점)과 성공(초록 체크)을 색으로 구분하는 GitHub Actions의 탭 파비콘

claude-deck도 같은 자리를 쓰되 제목과 파비콘에 서로 다른 정보를 실었다. 탭 제목에는 대표 세션(권한 대기 > 작업 중 > 최근 순)의 이름이, 파비콘 색에는 그 세션의 상태가 들어간다. 제목이 “어느 세션인가”를 맡고 색이 “지금 어떤 상태인가”를 맡으니, 탭 하나에 두 정보가 겹치지 않고 실린다.

// 탭 타이틀은 대표 세션명만, 파비콘 점 색으로 상태를 표시한다.
$effect(() => {
    const s = store.headSession;
    document.title = s ? s.title : 'claude-deck';
    setStatusFavicon(s ? s.status : null);
});

claude-deck 탭 두 개의 제목과 파비콘 색

탭 제목은 대표 세션의 이름이고 앞에 붙은 점 색이 그 세션의 상태다. 왼쪽 초록은 작업 중, 오른쪽 주황은 권한 대기다.

덕분에 대시보드를 다른 탭 뒤에 숨겨 두고 딴 일을 하다가도, 탭 줄만 보면 어느 작업이 지금 나를 기다리는지 알 수 있다. 메뉴바 아이콘이 못 준 가시성을 탭 줄이 대신 준 셈이다.

상태를 보겠다고 깐 훅이 정작 모든 작업을 가로막은 사고도 있었다. 프로젝트 폴더명을 바꾸자 모든 Claude Code 세션에서 도구 호출이 전부 막혔는데, 원인은 내가 깐 훅이었다. 훅 command에 스크립트 절대경로가 박혀 있었고, 폴더명을 바꾸니 그 경로가 사라져 스크립트가 exit 2로 죽었다. 하필 PreToolUse 훅이 exit 2를 내면 Claude Code는 그 도구 호출을 블록한다. 훅이 경로를 박아 두는 이상 폴더 이동은 훅 제거, 폴더 이동, 재설치 순으로 가야 한다. 설치기를 멱등하게 만들어 둔 게 그나마 다행이어서, # glance-hook 마커가 달린 항목만 걷어내고 옮긴 뒤 다시 설치하면 깨끗하게 복구된다.

훅을 제자리에 돌려놓고 나서는 사이드바로 눈이 갔다. 세션을 나열하려면 이름이 필요한데, ccd_a1b2c3d4 같은 tmux 세션명을 그대로 띄울 순 없다. 사람이 “아, 그 작업”이라고 알아볼 이름이어야 한다.

방식아이디어한계
앞 N자 자르기프롬프트 앞 8자를 라벨로단어 중간이 잘림 (“리팩토링 좀” → “리팩토”)
명사 추출 휴리스틱조사·종결어미 제거 후 명사만한국어 활용형이 다양해 규칙이 끝없이 늘어남
Claude의 요약 제목Claude가 만든 제목을 그대로별도 작업 거의 없음

명사 추출 휴리스틱에는 꽤 공을 들였다. 조사를 정규식으로 떼고, “X하는/하기/해줘”를 X로 줄이고, 동사형 종결을 버리고, 불용어를 걸렀다. 한국어 활용형은 끝이 없어서 규칙을 더할수록 예외가 새로 튀어나왔다. 그러다 Claude Code가 이미 세션마다 요약 제목을 만든다는 걸 발견했다. 대화 맥락을 요약해 터미널 타이틀로 설정하고, tmux는 그 타이틀을 pane_title로 잡아 둔다. tmux에 한 줄만 물으면 바로 나온다. 정규식 수백 줄로 풀려던 문제를 한 필드가 풀었다.

원클릭 권한 승인

세션이 주황으로 바뀌었을 때 그 탭으로 이동하지 않고 대시보드에서 바로 승인하는 기능이다. 처음부터 만들려던 것인데 가장 늦게 붙었고, 그전까지는 결국 해당 탭으로 건너가 1을 눌러야 했으니 확인 비용만 줄고 이동 비용은 그대로였다.

Claude Code가 권한을 물음
  ↓ Notification 훅
서버: tmux capture-pane으로 화면을 통째로 캡처
  ↓ 다이얼로그 파싱(도구명·명령)
approval.request 브로드캐스트 → 브라우저에 카드
  ↓ 승인/거부 클릭
approval.respond → tmux send-keys "1" 또는 Escape

핵심은 화면을 읽어 되돌려 준다는 것이다. 훅 페이로드에는 권한이 필요하다는 사실만 있고 어떤 명령인지는 없는데, 그건 터미널 화면에만 그려져 있어서 pane을 캡처해 파싱한다. 승인도 API가 아니라 키 입력으로 되돌려 넣으니, 대시보드가 사람 대신 그 자리에 앉아 화면을 읽고 키를 누르는 셈이다.

파싱에서 크게 걸렸다. 처음 만든 파서는 “Do you want to proceed?” 줄을 찾은 뒤 위로 거슬러 올라가며 줄을 모았는데, 박스 문자로 둘러싸인 다이얼로그를 가정했기 때문이다. 실제로 붙여 보니 카드 제목에 이전 답변의 마지막 문장이 박혔고, 명령 블록에는 직전 대화가 통째로 딸려 왔다. 실제 화면은 박스가 아니었다.

────────────────────────────────────────────────
 Bash command

   sw_vers -productVersion
   macOS 버전 출력

 This command requires approval

 Do you want to proceed?

다이얼로그를 가르는 건 박스가 아니라 가로줄 한 줄이었다. 파서는 그 줄을 경계로 인식하지 못해 위로 24줄을 훑었고, 그 안에 이전 대화가 들어 있었다. 가로줄과 박스 테두리를 모두 경계선으로 보고 거기서 멈추게 고쳤다. 경계선을 못 찾으면 6줄만 보는데, 넓게 잡을수록 남의 대화가 섞이기 때문이다.

카드가 뜨면 안 되는 자리에 뜬 적도 있다. 아무것도 물어본 적 없는 세션에 승인 카드가 떠 있었다. Claude Code의 Notification 훅은 권한 요청에만 오지 않고, 입력 없이 60초가 지나도 같은 훅이 온다. 승인 매니저는 훅이 오면 화면을 캡처해 카드를 만들었으므로, 대기 알림에도 도구 이름 없는 카드가 만들어졌다. 문제는 그 카드에도 승인 버튼이 달려 있었다는 점이다. 눌렀다면 아무것도 묻지 않은 터미널에 1이 한 글자 흘러들어갔을 것이다. 도구 이름이 없으면 승인할 대상이 없다고 보고, 상태 색만 바꾸고 카드는 만들지 않게 고쳤다.

새로고침에서도 한 번 걸렸다. 브라우저를 새로 열면 세션은 여전히 주황인데 카드가 없었다. 승인 요청이 접속 이전에 방송됐고 서버는 새 클라이언트에게 세션 목록만 보냈기 때문이다. 푸시 모델에서 반복되는 실수인데, 이벤트는 그 순간 연결된 클라이언트에게만 닿고 나중에 붙은 쪽은 자기가 뭘 놓쳤는지도 모른다.

침대에서 폰으로 승인만 눌러도 되면 좋겠다는 생각에 폰 접속도 붙였다. 서버를 LAN에 여는 순간 인증이 필요해진다. 같은 와이파이의 아무나 내 터미널에 키를 넣을 수 있게 되기 때문에 토큰을 필수로 뒀고, QR로 한 번 넣으면 쿠키로 남아 그다음부터 주소만으로 열린다. 토큰은 302로 URL에서 즉시 털어내는데, 주소창에 남으면 스크린샷 한 장으로 새어 나가기 때문이다.

원래 하려던 걸 하나 잘라냈다. Web Push는커녕 이미 만들어 둔 브라우저 알림조차 폰에서는 동작하지 않는다. 서비스 워커와 Notification API는 secure context 전용이라 HTTPS나 localhost에서만 사는데, LAN의 평문 HTTP 주소로 들어온 폰은 어느 쪽도 아니다. 폰 알림까지 가려면 HTTPS 종단이 먼저인 셈이라 이번엔 접고 README에 제약으로 적어 뒀다. 안 되는 걸 안 된다고 적어 두지 않으면 다음에 또 같은 걸 시도하게 된다.

폰 폭으로 화면을 좁혀 보니 데스크톱에서는 영영 드러나지 않을 버그도 나왔다. 헤더의 세션 제목이 여섯 줄로 접혀 화면 위쪽을 다 먹고 있었는데, 타입체크도 빌드도 통과한 코드고 넓은 화면에서는 제목이 한 줄에 들어가니 보일 리가 없었다.

세션 유지 및 화면 분할

⌥D로 패널을 추가하면 세션이 좌에서 우로 나란히 붙고, ⌥[ ⌥]로 포커스를 옮긴다. 한 세션은 빌드를 돌리고 옆 세션은 코드를 보는 식으로, 탭을 오가지 않고 동시에 본다.

분할 자체는 CSS로 화면을 N등분하면 끝이다. 문제는 터미널의 크기가 픽셀이 아니라 글자 격자, 즉 cols × rows라는 데 있다. 패널이 좁아지면 ResizeObserver가 변화를 감지하고 xterm의 FitAddon이 “이 픽셀엔 75칸 들어가겠다”를 역산하는데, 이 새 크기를 브라우저만 알아선 안 되고 claude까지 전해야 한다. claude는 줄글만 뱉는 게 아니라 박스와 표와 선택 메뉴를 커서로 직접 그리고, 그릴 때 칸 수를 자기가 세기 때문이다.

claude는 "75칸"이라 믿고 윗변을 75칸 그린다:
┌─────────────────────────────────────┐
화면이 60칸이면 그 윗변이 넘쳐 다음 줄로 흘러 박스가 깨진다.

평문이라면 xterm이 알아서 줄바꿈하니 너비를 몰라도 되지만, claude가 칸을 세어 그린 레이아웃은 자동 줄바꿈이 오히려 어긋나게 만든다. 그래서 역산한 cols를 WebSocket으로 PTY까지 전파해 claude가 처음부터 그 너비에 맞춰 그리게 했다.

이 경로를 붙이고 나니 다른 증상이 나왔다. claude가 thinking 중일 때 터미널이 깜빡 포커스를 뺏고 화면이 한 줄씩 덜컹거렸다. 원인은 재attach였다. 세션 상태가 바뀔 때마다 프론트가 터미널을 다시 붙이고 그때마다 term.focus()가 불려 포커스가 튀었고, 동시에 서버 쪽 재attach가 리사이즈를 유발해 tmux가 화면을 다시 그리며 지글거렸다. 상태 갱신이 초당 여러 번 들어오니 그때마다 화면이 흔들린 것이다. 해결은 attach를 멱등하게 만드는 것이어서, 이미 같은 host에 붙어 있으면 재attach와 재fit과 포커스를 전부 생략했다. 상태가 바뀔 때마다 다시 한다를 이미 그 상태면 안 한다로 바꾼 게 전부다.

그다음은 세션의 수명이었다. 평소 터미널에서 claude를 실행하면 claude는 그 터미널의 자식 프로세스가 되고, 부모인 터미널이 닫히면 자식도 함께 죽는다. tmux가 이 부모-자식 관계를 바꾼다. tmux는 백그라운드에서 도는 데몬이라, claude를 tmux 세션 안에서 실행하면 claude의 부모는 터미널이 아니라 tmux 서버가 된다.

터미널 (부모)
  └─ claude (자식)         ← 터미널 닫으면 같이 죽음

tmux 서버 (데몬, 백그라운드)
  └─ claude (자식)         ← 터미널 닫아도 안 죽음
대시보드                   ← tmux attach 로 잠깐 들여다볼 뿐

대시보드는 이 구조 위에 올라탄다. 세션을 만들 땐 tmux new-session -d로 띄우고, 화면을 보여줄 땐 node-pty가 tmux attach로 붙어 입출력을 중계한다. 대시보드 탭을 닫으면 detach만 일어나고 세션은 죽지 않는다.

구조는 단순한데 붙이는 데서 걸렸다. tmux에 attach하는 node-pty가 PTY를 만들자마자 posix_spawnp failed로 죽었다. 원인은 패키지 매니저였다. node-pty는 PTY를 띄울 때 spawn-helper라는 prebuilt 바이너리를 실행하는데 여기엔 실행 비트가 있어야 한다. pnpm이 설치 과정에서 그 권한을 보존하지 않았고, 실행 비트가 빠진 바이너리를 spawn하려니 OS가 거부했다. 설치 후 chmod로 권한을 복구하는 postinstall 스크립트를 붙여 막았다. tmux 실행 파일명을 그냥 tmux로 넘기면 PATH 해석이 환경에 따라 실패하는 것도 같은 종류인데, GUI에서 띄운 Node 프로세스는 셸 PATH를 못 물려받는 경우가 있어 절대경로로 박아 호출한다.

세션이 tmux 위에 올라가고 나니 마우스 휠도 tmux와 협상해야 했다. 터미널 안에서 휠로 스크롤백을 보고 싶었는데, tmux 마우스 모드는 켜느냐 끄느냐에 따라 정반대 문제를 냈다.

tmux mouse휠 스크롤링크 클릭드래그 복사
on됨안 됨안 됨
off안 됨(휠이 claude 히스토리 ↑를 띄움)됨됨

mouse on이면 tmux가 마우스 이벤트를 전부 먹어 링크 클릭과 드래그 복사가 막히고, off면 휠이 방향키 ↑로 해석돼 claude 입력 히스토리가 튀어나온다. 둘 다 만족하는 설정이 없어서, mouse off로 두고 휠만 직접 구현했다. 휠 이벤트를 capture 단계에서 가로채 claude로 흘러가지 못하게 막고, 누적 delta를 줄 수로 환산해 서버가 tmux copy-mode로 스크롤하게 한다. 스크롤백 상태가 브라우저가 아니라 tmux 쪽에 있다는 게 이 구조의 제약이라, 스크롤바 위치는 폴링으로 따라갈 수밖에 없다.

Git 패널과 PR 통합

대화 옆에 git 변경분이 항상 떠 있으면 “얘가 방금 뭘 건드렸지”를 확인하려고 터미널에 git status를 치지 않아도 된다. Changes 탭은 워킹트리 변경 목록을 만들고 파일을 누르면 그 자리에서 diff를 펼친다.

구현을 마치고 화면을 보니 아무것도 안 바뀌어 있었다. 빌드 시각도 최신이고 서버가 내려주는 번들도 새것인데 화면만 그대로였다. 오래 열어 둔 탭이 옛 JS를 붙들고 있었다. SPA는 첫 진입 때 받은 index.html을 다시 받지 않으니, 새로고침을 눌러도 문서 캐시가 살아 있으면 옛 번들 참조가 그대로다. 배포 파이프라인이 없는 로컬 도구라 캐시를 생각할 일이 없다고 여겼는데, 캐시는 서버가 아니라 브라우저에 있었다.

Changes로는 못 하는 게 하나 분명해졌는데, 커밋하는 순간 목록이 비기 때문이다. Changes는 지금 고치는 중인 것만 보여 주는데, 브랜치가 베이스 대비 무엇을 바꿨는지는 다른 질문이다. Review 탭을 하나 더 둬서 베이스 브랜치를 자동으로 잡고 커밋 목록과 파일 목록과 diff를 함께 보여 준다.

여기 함정이 하나 있다. 파일 diff는 git diff base...HEAD(점 셋)이고 커밋 목록은 git log base..HEAD(점 둘)다. 점 셋은 merge-base 기준이라 베이스가 앞서 나가도 내 변경만 남지만, 점 둘로 diff를 뜨면 남이 만든 변경이 내 것처럼 섞인다. 같은 화면에 두 표기가 나란히 들어가는 자리라 계획서에 미리 못 박아 뒀다.

브랜치별로 세션을 갈라 쓰려면 워크트리가 필요한데, 사이드바에서 브랜치명을 넣으면 워크트리를 만들고 그 폴더에서 새 세션을 연다. 세션을 종료하면 워크트리도 정리하되 git status가 깨끗할 때만 지운다. 자동 정리의 편의보다 커밋 안 된 작업이 종료 클릭 한 번에 날아가지 않는 게 훨씬 비싸다.

브로드캐스트에도 밟기 쉬운 함정이 있었다. 터미널 입력은 평소 PTY 브리지로 흘리는데, 그 브리지는 클라이언트가 화면에 띄워 attach한 세션에만 존재한다. 안 보이는 세션에 쓰면 조용히 아무 일도 일어나지 않고 에러도 안 난다. 브로드캐스트는 안 보이는 세션에 보내는 게 목적이니 이 경로로는 기능 자체가 성립하지 않아서, 승인 카드가 쓰는 것과 같은 tmux send-keys로 갔다. 검증도 그 지점을 노려서, 브라우저에 한 번도 띄운 적 없는 세션 둘을 골라 명령을 보내고 tmux 페인을 직접 떠서 양쪽 다 실행됐는지 봤다.

이 패널을 붙이는 동안 엉뚱한 데서도 한 번 걸렸다. 새 API만 404가 나서 한참 헤맸는데, 코드도 라우트 등록도 멀쩡한데 /changes는 되고 /review만 없는 것처럼 굴었다. 재시작이 실패해 있었다. 프로세스를 죽인다고 친 pkill 패턴이 실제 프로세스와 안 맞아서, 새로 띄운 서버는 포트 충돌로 즉시 죽고 응답을 계속하던 건 살아남은 옛 서버였다. 겉으로는 정상이지만 옛 코드에는 새 라우트가 없으니 그 요청만 404였다. 프로세스가 살아 있다는 신호와 내 코드가 돌고 있다는 신호는 다르다.

처음엔 커밋은 터미널에서 하고 대시보드는 읽는 화면이라고 못 박아 뒀는데, 네 페이즈를 돌리고 나서 그 판단을 뒤집었다. 계기는 Review 탭을 쓰는 내 손이었다. 베이스 대비 diff를 다 읽고 나면 다음 동작은 커밋하고, 푸시하고, PR을 여는 것으로 정해져 있는데, 그 셋만 터미널에 있었다. 확인 비용을 줄이려고 만든 도구가 마지막에 이동 비용을 다시 청구하는 구조였다. 판단이 틀렸던 게 아니라 판단의 전제가 바뀌었다. 그때는 Changes 탭도 Review 탭도 없었고, 읽을 게 없는 화면에 쓰기 버튼만 있으면 그건 그냥 위험한 버튼이다.

동작판단
Commit만듦. 단 스테이징을 고르는 UI가 먼저다
Push만듦
Open PR만듦
Merge안 만듦. 되돌리기가 가장 비싼 동작이다
하단 컴포저안 만듦. 한 번 만들었다 걷어냈고, 터미널에 직접 치는 게 낫다

Merge를 뺀 기준은 되돌리는 비용이다. 잘못 만든 커밋은 리셋하면 되고 잘못 연 PR은 닫으면 되지만, 잘못 머지한 브랜치는 원격 히스토리에 남는다. 클릭 한 번이 낼 결과가 그 클릭으로 아끼는 이동 비용보다 비싸면, 그 버튼은 만들지 않는다.

커밋에서 가장 먼저 못 박은 건 스테이징 방식이다. git add -A는 쓰지 않고 Changes 탭에서 체크한 파일만 경로로 넘긴다. 관계없는 파일이 딸려 올라간 사고를 이미 겪은 뒤라 이건 취향 문제가 아니었다. Claude가 돌고 있는 워킹트리에는 내가 방금 고친 파일과 에이전트가 만든 임시 파일이 섞여 있어서, 옆에서 보고 있어도 -A 한 번이면 둘이 같이 올라간다.

사이드바 세션 행에는 PR 번호와 CI 요약을 뱃지로 붙였다. 그런데 통과한 PR의 뱃지가 계속 노랑이어서, GitHub에서는 체크가 전부 초록인데 대시보드만 진행 중이라고 우겼다. gh가 돌려주는 rollup에 두 종류가 섞여 오기 때문이었다. Actions 워크플로우는 CheckRun으로 와서 status와 conclusion을 갖는 반면, Vercel 배포처럼 커밋 상태로 보고하는 쪽은 StatusContext로 와서 state만 갖는다. 처음 쓴 요약 함수는 status !== 'COMPLETED'만 봤으니 status가 아예 없는 StatusContext는 전부 미완료로 잡혔고, 하나만 그렇게 잡혀도 요약은 pending이 된다.

rollup.some((c) =>
  c.state !== undefined
    ? c.state === 'PENDING' || c.state === 'EXPECTED'
    : c.status !== 'COMPLETED'
)

필드가 있는지로 두 형식을 가른 뒤 각자의 완료 조건을 물었다. 한쪽 형식만 보고 만든 판정은 그 형식만 오는 리포에서 잘 돌다가, 다른 형식이 하나 섞이는 순간 에러 없이 틀린다.

앱 단축키와 입력 충돌 해결

수식키로 ⌥(Option)을 고른 데는 이유가 있다. 터미널은 Ctrl 조합 상당수를 제어문자로 이미 쓰는데, Ctrl+C는 인터럽트이고 Ctrl+D는 EOF다. 대시보드 단축키가 Ctrl을 쓰면 터미널 안 claude의 조작과 충돌하는 반면, ⌥은 상대적으로 충돌이 적어 앱 단축키로 비워 두기 좋았다.

⌥1로 세션을 점프하는 단축키를 걸면 문제가 하나 따라온다. 그 키 입력이 동시에 터미널로도 흘러 들어가 claude한테 입력돼 버린다. xterm은 안 보이는 textarea를 두고 keydown만 받아 키 하나하나를 즉시 바이트로 바꿔 PTY로 보내기 때문이다.

키 누름
  │  ① window keydown (capture): 앱 단축키면 → 동작 실행 + preventDefault
  │     (전파는 안 막아 이벤트는 xterm까지 내려간다)
  ▼
xterm
  │  ② attachCustomKeyEventHandler: 앱 단축키면 → false, 여기서 끊김(PTY로 안 감)
  ▼     (일반 키만 통과)
WebSocket → 서버 → node-pty → PTY → claude

①은 단축키 동작을 실행하고 브라우저 기본동작만 막는다. 전파는 막지 않으니 키 이벤트가 xterm까지 내려가고, ②에서 다시 한 번 앱 단축키니 PTY로 보내지 말라고 끊어 준다. 단축키 동작을 실행하는 일과 터미널로 새는 걸 차단하는 일은 다른 자리에서 일어나는 별개의 작업이다.

함정이 하나 더 있다. macOS에서 ⌥1은 ¡, ⌥2는 ™ 같은 특수문자로 입력된다. keydown의 e.key는 ¡가 돼 “1을 눌렀나” 확인이 깨지므로, Option을 눌러도 안 변하는 레이아웃 독립적인 e.code(Digit1)로 물리 키 위치를 봐야 한다.

화면에 적힌 안내 문구 때문에 한 번 헤맨 적도 있다. ⌥+숫자로 세션이 안 바뀌고 모니터가 넘어갔는데, 타일링 WM이 alt-1/2/3을 모니터 이동에 쓰고 있었다. 그런데 앱 코드는 이미 다른 조합으로 피해 놨고, 사이드바 안내 문구만 옛날 그대로 “⌥1~9 전환”으로 남아 있었다. 안내대로 누르면 앱은 반응이 없고 WM만 반응하니 충돌이 안 고쳐진 것처럼 보였다. 키를 옮길 때 화면에 적힌 안내와 주석까지 같이 옮기지 않으면 이런 일이 생긴다.

사이드바 정렬에서도 비슷한 게 나왔다. 세션을 “지금 내 손이 필요한가”로 묶는 축은 좋았는데, 그룹 안의 순서를 최근 이벤트 순으로 둔 게 문제였다. 작업 중 그룹은 도구가 하나 돌 때마다 이벤트가 들어오니, 세션 셋이 동시에 일하면 목록이 초 단위로 자리를 바꾼다. 무엇보다 ⌥1~⌥9가 목록 순서에 걸려 있어서 방금 확인한 번호가 다음 순간 다른 세션을 가리킨다. 그래서 그룹 안은 생성 순으로 고정했다. 최신성과 위치 안정성이 부딪히면 단축키가 걸린 쪽이 이긴다. 어느 세션이 방금 움직였는지는 색이 이미 말해 주고 있었으니, 순서까지 같은 정보를 나를 필요가 없었다.

Shift+Enter 줄바꿈 및 한글 인코딩 처리

여러 줄 프롬프트를 넣으려면 줄바꿈이 필요한데, 터미널에서 Shift+Enter가 그냥 Enter처럼 동작해 프롬프트가 바로 전송됐다. Option+Enter는 멀쩡히 줄바꿈이 되는데 Shift+Enter만 안 됐다.

원인은 터미널의 키 인코딩 한계다. 전통적 터미널은 Enter와 Shift+Enter를 똑같은 바이트 CR(0x0d) 하나로 보낸다. Shift라는 수식키 정보가 인코딩 단계에서 사라지기 때문이다. 둘을 구분하려면 kitty keyboard protocol을 터미널과 앱이 둘 다 지원해야 하는데 xterm.js는 기본 미지원이다. 반면 Option+Enter가 되는 건 우연이 아니다. 터미널 관례상 Alt 조합은 ESC 접두 + 키로 인코딩돼 ESC+CR(0x1b 0x0d)이라는 다른 바이트가 나가고, claude는 이걸 줄바꿈으로 인식한다.

1차 시도는 실패했다. Shift+Enter를 감지해 ESC+CR을 직접 주입하고 return false로 기본 동작을 막았는데도 줄바꿈은 그대로 안 됐다. 추측을 멈추고 바이트를 측정했다. 왜 안 되는지 머리로 짜내는 대신 서버의 입력 수신 지점에 들어오는 바이트를 hex로 찍었더니, Shift+Enter를 한 번 누르면 1b 0d(ESC+CR, 줄바꿈) 다음에 0d(CR, 전송)가 쌍으로 나가고 있었다. return false로 막았다고 믿은 기본 동작이 실제로는 안 막혀서 xterm이 Enter의 CR을 추가로 더 보낸 것이다. 결국 줄바꿈 직후 곧바로 전송이 돼서 줄바꿈이 안 되는 것처럼 보였다.

2차 해결은 직접 주입을 버리는 것이다. 바이트를 손으로 끼워 넣는 대신 xterm이 만들어내는 출력을 한 곳에서 변환하는 쪽으로 틀었다. keydown에서는 플래그만 세우고 기본 동작을 통과시키면 xterm이 CR 하나를 onData로 emit하는데, 그 단일 CR을 onData 단계에서 ESC+CR로 치환해 보낸다.

방식동작결과
직접 주입 + return falsekeydown에서 ESC+CR 주입, 기본 동작 차단 시도1b 0d 뒤에 0d가 또 붙어 전송됨
플래그 + onData 치환keydown은 통과, onData의 단일 CR을 ESC+CR로 치환ESC+CR만 한 번 나가 줄바꿈 성공

이때 tmux의 escape-time은 0이어야 한다. 기본값이면 ESC와 CR이 시간상 쪼개져 ESC 따로, Enter 따로로 해석된다.

터미널 입력은 추측하지 말고 실제 바이트를 hex로 찍어서 디버깅해야 한다. 막았다고 생각한 기본 동작이 실제로는 안 막혀 있었고, 그건 코드를 노려봐도 안 보이다가 hex 로그에서 단번에 드러났다. 키 조합도 마찬가지여서, 직접 주입하기보다 프레임워크가 만들어내는 출력을 한 곳에서 변환하는 편이 중복과 경합을 피한다.

이 문제는 한때 별도 입력창(textarea) 우회로 풀려다 접은 적이 있다. textarea는 줄바꿈은 되지만 claude의 화살표 선택 메뉴·이미지 붙여넣기·Esc 인터럽트를 전부 막아 인터랙티브 UI를 통째로 잃었다. 결국 터미널 직접 입력을 유지한 채 바이트 레벨에서 푸는 게 답이었다.

입력 쪽을 갈라 놓고 나니 반대 방향에서도 한 건 나왔다. 터미널에 한글을 치면 î° 같은 깨진 글자가 떴고 이모지와 파워라인 글리프도 같이 깨졌다. 한글 가 한 글자가 앞에서 그린 중계 경로를 타고 가다 어디서 부서지는지 따라가 봤다. 서버는 PTY 출력을 base64로 인코딩해 WebSocket으로 보내는데, 터미널 출력 말고도 세션 상태와 목록 같은 여러 메시지를 같은 연결로 주고받기 때문에 전부 JSON 한 형식으로 묶으려고 그렇게 했다. 깨짐은 이 base64를 푸는 단계에서 났다. 가는 UTF-8로 234 176 128 세 바이트다.

[서버]
1. claude가 "가" 출력
   → PTY가 raw 바이트로 내뱉음:   234 176 128
2. base64로 인코딩
   234 176 128  →  "6rCA"
3. JSON 봉투에 담아 text frame으로 전송  ──►  WebSocket

[브라우저]
4. JSON.parse → v = "6rCA" 꺼냄
5. atob("6rCA") → "바이트당 글자 하나" 문자열 (코드: 234 176 128)
     ✗ 글자로 읽으면:   234→ê  176→°  128→제어문자   →  깨짐
     ✓ 바이트만 뽑으면: [234,176,128] → Uint8Array  →  "가"

5번 분기가 전부다. atob 결과는 바이트 하나당 글자 하나인 문자열인데, 이걸 글자로 읽으면 한 글자가 셋으로 부서진다. 핵심 착각은 한글은 텍스트니까 글자로 다루면 된다는 생각이다. text frame의 텍스트는 사람이 읽는 글자가 아니라 UTF-8로 유효한 바이트 시퀀스를 뜻한다. 한글도 컴퓨터 안에선 바이트라, 그 바이트를 온전히 나르기만 하면 안 깨진다.

const bin = atob(dataBase64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
term.write(bytes); // 문자열 말고 바이트 배열로

bin을 문자열로 쓰지 않고 charCodeAt으로 바이트만 뽑아 xterm에 넘기면, xterm이 이 3바이트가 한글 한 글자라고 UTF-8 디코딩을 알아서 한다. 한 줄 차이가 한글을 살리기도 죽이기도 했다.


4. 세션 분류: 상태 그룹에서 사용자 그룹으로

세션이 늘어날수록 사이드바의 묶음 축이 안 맞기 시작했다. 입력 대기·작업 중·대기라는 세 그룹은 “지금 내 손이 필요한가”에 답하지만, 같은 초록 안에 리팩터링 도는 세션과 글 쓰는 세션이 나란히 들어 있으면 그 답만으로는 부족하다. 상태는 이미 각 행의 색이 말하고 있었으니, 묶음까지 같은 정보를 나를 이유가 없었다.

상태로 묶던 사이드바를 손으로 묶기

그룹을 사람이 만들게 바꿨다. 이름을 직접 붙이고 세션을 배정한다. 상태 색은 그대로 행에 남으니, 묶음 축과 색 축이 서로 다른 정보를 맡는다.

서버는 session_groups 테이블을 새로 두고, 배정은 sessions.group_id에 넣었다. 이 컬럼은 원래 스키마에 있었지만 값이 한 번도 안 들어가던 죽은 필드였다.

여기서 마이그레이션 구조에 걸렸다. 이 프로젝트의 마이그레이션은 매 부팅마다 전체가 다시 실행되는 형태라 ALTER TABLE을 넣으면 두 번째 부팅에서 죽는다. 새 테이블을 CREATE TABLE IF NOT EXISTS로 만드는 쪽만 몇 번을 돌려도 같은 결과가 나온다. 기존 테이블을 건드리지 않고 새 테이블에 배정을 담은 것은 취향이 아니라 이 구조가 강제한 선택이다.

입력에서도 한 번 걸렸다. 그룹 라벨 하나에 클릭(접기)과 더블클릭(이름 변경)을 같이 걸었더니, 더블클릭할 때마다 접힘이 두 번 토글됐다. 브라우저는 더블클릭 이벤트를 보내기 전에 클릭을 두 번 먼저 보내기 때문이다. 클릭 처리를 250ms 뒤로 미루고 더블클릭이 오면 그 타이머를 취소하게 했다.

배정은 처음에 메뉴에서 고르는 방식이었는데, 쓰다 보니 끌어다 놓는 쪽이 빨랐다. 드래그 앤 드롭을 붙이면서 드롭 대상을 그룹 헤더로 잡았더니 그룹 안 세션 목록 위에 떨어뜨렸을 때 아무 일도 안 났다. 눈에는 그 그룹인데 코드에는 헤더만 그룹이었다. 드롭 영역을 헤더와 세션 목록을 함께 감싸는 컨테이너로 넓혔다.

넓히니 하이라이트가 깜빡였다. 커서가 컨테이너 안 자식 요소 사이를 지날 때마다 dragleave가 뜨기 때문이다. 밖으로 나가는 것도 leave이고 안에서 옮겨 다니는 것도 leave다. 둘을 가르는 건 이벤트 이름이 아니라 커서가 다음에 어디로 들어가느냐다.

ondragleave={(e) => {
  // 자식(세션 행) 사이를 지날 때도 dragleave가 뜬다 — 그룹 밖으로 나갈 때만 지운다.
  const to = e.relatedTarget;
  if (!(to instanceof Node) || !e.currentTarget.contains(to)) dropTarget = null;
}}

워크스페이스를 별도 화면이 아니라 셀렉트로

워크스페이스 전환은 전용 선택 화면을 거쳐야 했다. 작업하다 다른 워크스페이스로 가려면 화면 하나를 왕복하는 셈이라, 사이드바 상단 셀렉트로 옮겼다.

전환 방식 자체는 안 바꿨다. 셀렉트를 고르면 경로가 바뀌고 페이지가 통째로 다시 뜬다. SPA 안에서 갈아 끼우는 쪽도 따져 봤는데, 패널 구성과 탭 목록과 그룹 접힘 상태가 전부 워크스페이스 키로 갈려 있어서 결국 스토어를 전면 재초기화해야 한다. 아끼는 건 로딩 몇 백 밀리초고, 늘어나는 건 초기화가 반쪽만 되는 버그의 여지다. 풀 로드는 그 여지가 아예 없다.

셀렉트에서는 리뷰어가 스타일 문제를 잡았다. 드롭다운 화살표가 사라진 것이다. Tailwind forms 플러그인은 base 레이어에서 select에 appearance: none을 걸고 chevron을 배경 이미지로 그려 넣는데, 커스텀 스타일이 background 단축 속성을 쓰면 지정하지 않은 하위 속성이 전부 초기값으로 밀린다. background-image에 있던 chevron이 그렇게 지워졌다. appearance: none은 남아 있으니 브라우저 기본 화살표도 안 돌아온다. background-color로 바꿔 필요한 것만 덮게 했다. 단축 속성이 무엇을 같이 지우는지는 diff에서는 안 보인다.

다른 워크스페이스의 승인 대기 놓치지 않기

사이드바는 현재 워크스페이스의 세션만 보여 준다. 워크스페이스를 나눈 목적이 그거라 맞는 동작인데, 대가로 다른 워크스페이스에서 올라온 승인 대기가 안 보인다. 주황이 떠 있어도 내가 그쪽에 있지 않으면 모른다. 처음 세운 조건 중 첫 번째가 워크스페이스 단위로 다시 깨진 셈이다.

⌥A로 여는 오버레이를 붙였다. 전체 워크스페이스의 세션을 입력 대기·작업 중·대기 세 컬럼으로 늘어놓고, 카드를 누르면 그 세션으로 건너간다.

서버는 한 줄도 안 고쳤다. 클라이언트가 이미 전체 워크스페이스의 세션 목록을 들고 있었고 사이드바가 화면에서만 걸러 왔기 때문이다. 필요한 데이터는 처음부터 와 있었고 안 쓰고 있었을 뿐이다.

리뷰어는 경과 시간 라벨이 굳는다는 것을 먼저 짚었다. Date.now()는 Svelte 반응성에 안 걸리는 값이라 보드를 연 순간의 값에서 안 움직인다. 이벤트가 들어오는 세션은 다른 이유로 다시 그려지며 라벨도 같이 갱신되지만, 이벤트가 없는 세션 — 오래 멈춰 있어서 보드가 알리려는 바로 그 세션 — 만 값이 고정된다. 갱신이 필요한 순서와 실제로 갱신되는 순서가 정확히 뒤집혀 있었다.

let now = $state(Date.now());
$effect(() => {
	const t = setInterval(() => (now = Date.now()), 30000);
	return () => clearInterval(t);
});

소속 워크스페이스가 없는 카드에서도 하나 나왔다. 밖에서 만든 tmux 세션을 채택했거나 워크스페이스가 지워진 세션은 workspaceId가 비는데, 점프 코드가 그 빈 값을 기본값으로 뭉개고 있었다. 그 카드를 누르면 /로 이동해 워크스페이스 선택 화면으로 튕긴다. 지금 자리를 안 잃고 다른 세션을 보러 가라고 만든 화면인데 특정 카드만 정확히 그 반대를 했다. 갈 곳이 없는 카드는 이동을 막고 흐리게 표시했다.

리뷰어가 하나 더 짚었다. 다른 워크스페이스의 세션으로 건너뛸 때 새로 뜬 화면이 저장해 둔 패널 구성을 복원하면서, 주소에 실어 보낸 지목 세션을 덮어쓴다. 세션 목록이 도착하는 순간 복원이 같이 돌기 때문이다. 스토어에 pendingFocus를 두고 지목이 있으면 저장 구성 복원을 건너뛰게 했다. 탭 목록도 지목한 세션을 잃지 않게 같이 처리했다.

고치고 나서도 재검증이 계속 실패로 나왔다. 코드를 먼저 의심했는데 틀린 건 판정 기준이었다. localStorage에 저장되는 패널 구성 값을 읽어 판정했는데, 이 키는 복원이 끝난 뒤에만 쓰인다. 로드 직후에는 테스트하려고 심어 둔 옛 값이 그대로 남아 있으니, 화면 상태와 아무 상관 없는 값을 보고 실패를 선언한 셈이다.

페이지 로드 전에 Storage.prototype.setItem을 후킹해 그 키에 쓰기가 언제 일어나는지 봤다. 한 번도 일어나지 않았다. 관측값을 사이드바에서 활성 행이 몇 번째인지로 바꿔 다시 쟀고 — 사이드바는 생성 순으로 고정 정렬이라 순서가 안정적이다 — 지목한 세션이 제대로 열려 있었다. 통과다.

지연 저장되는 값을 상태의 대리 지표로 쓰면 안 된다. 그 값은 상태를 따라오는 캐시지 상태가 아니라서, 아직 안 따라온 시점에 읽으면 멀쩡한 수정이 계속 실패로 보인다.


5. 우측 패널: 읽는 화면에서 고치는 화면으로

우측 패널은 변경 목록과 diff와 PR을 보여 주는 읽는 화면이었다. 한 줄 고치려면 결국 터미널로 돌아가거나 에디터를 따로 띄워야 했으니, 확인 비용은 줄었는데 수정 비용은 그대로였다. Review 탭을 만들고 커밋 버튼을 붙였을 때와 같은 구조다.

diff를 터미널이 그리던 모양으로

패널의 diff는 변경 줄에 색만 입혀 늘어놓는 형태였다. 정작 눈에 익은 건 Claude Code가 터미널에 그리는 쪽이라, 그 모양을 가져왔다.

  • 헝크(hunk)마다 Added N lines, removed M lines 요약을 머리에 붙인다. 그 덩어리가 몇 줄을 건드렸는지 직접 셀 일이 없어진다.
  • 줄번호는 단일 컬럼이다. 삭제줄은 변경 전 번호를, 추가줄과 문맥줄은 변경 후 번호를 쓴다. 좌우 두 컬럼을 두는 것보다 좁은 패널에서 읽기 좋다.
  • 문법 하이라이팅은 highlight.js로 넣되 언어를 서브셋만 등록한다. 기본 엔트리는 190여 개 언어를 다 싣는데, 이 패널에서 실제로 열어 보는 건 열 개 남짓이다.
import hljs from 'highlight.js/lib/core';
import typescript from 'highlight.js/lib/languages/typescript';
// ...
hljs.registerLanguage('typescript', typescript);

고치려고 파일을 여니 같은 코드가 두 벌 있었다. Changes 탭과 Review 탭이 diff 렌더 로직과 CSS를 각자 들고 있었고, 한쪽만 고치면 나머지 탭에서 옛 모양이 그대로 나온다. DiffView.svelte 하나로 합치고 두 탭이 같은 컴포넌트를 쓰게 했다. 복제는 만들 때가 아니라 고칠 때 값을 청구한다.

orca에서 가져올 것 고르기

파일 탐색기와 에디터를 붙인 계기는 orca(stablyai/orca)라는 ADE다. 겹치는 도구를 뜯어보면 내가 안 만든 것 중 무엇이 실제로 아쉬운지가 빨리 갈린다. 가져온 건 파일 탐색기와 코드 에디터, 그리고 앞 절의 세션 보드다.

Files 탭은 폴더를 열 때마다 그 층만 조회하는 지연 트리다. 항목마다 git 상태를 색으로 얹고, 파일을 누르면 오른쪽에 에디터가 뜬다.

에디터는 Monaco 대신 CodeMirror 6를 골랐다.

항목MonacoCodeMirror 6
번들수 MB코어 + 필요한 언어만
모바일상정 밖터치 지원
기능 깊이IntelliSense까지편집에 필요한 만큼

Monaco는 VS Code가 쓰는 엔진이라 기능 깊이로는 상대가 안 된다. 그럼에도 이 화면은 폰에서 LAN으로 붙어 보는 자리이기도 한데, 그 조건에서 수 MB 번들과 데스크톱 전용 상호작용은 처음부터 안 맞는다. CodeMirror 6는 언어 모드를 동적 import로 나눠 둘 수 있어서 확장자에 맞는 것만 그때 가져온다. IDE를 만들려던 게 아니라 패널에서 한 줄 고치고 저장하는 자리를 만들려던 것이니, 기능 깊이는 애초에 비교 항목이 아니었다.

자동저장을 일부러 뺀 이유

orca는 자동저장을 한다. 여기서는 뺐다.

이 도구는 Claude 세션이 같은 파일을 상시 고치는 환경 위에 얹혀 있다. 파일을 열어 둔 채 다른 창으로 갔다 오는 사이에 Claude가 그 파일을 고쳤다면, 포커스를 잃을 때 저장하는 동작은 내 화면에 남아 있던 옛 내용으로 Claude의 편집을 덮어쓴다. 사람 둘이 붙은 파일이면 충돌이 눈에 보이지만, 한쪽이 에이전트면 아무도 안 본다. 자동저장은 편집자가 하나일 때 편한 기능이다.

저장은 ⌘S로 명시할 때만 한다. 저장 요청에는 읽은 시점의 mtime을 같이 보내고, 서버는 디스크의 mtime이 다르면 쓰지 않고 409를 돌려준다. 화면은 그때 “다시 읽기 / 덮어쓰기”를 묻는다.

// Claude 세션이 같은 파일을 상시 고친다 — 읽은 시점의 mtime과 다르면 덮어쓰지 않고 409.
// mtimeMs는 소수부가 있어 JSON 왕복의 부동소수 오차로 어긋날 수 있다 → 반올림 비교.
if (typeof baseMtimeMs === 'number' && Math.round(st.mtimeMs) !== Math.round(baseMtimeMs)) {
	return reply.code(409).send({ mtimeMs: st.mtimeMs });
}

덮어쓰기를 못 하게 막은 게 아니라, 덮어쓰는 순간을 사람이 보게 만든 것이다.

리뷰어가 잡은 경로 처리 결함

파일 API는 세션 디렉터리 밖으로 못 나가야 한다. 처음 쓴 경로 검사는 .. 조합과 절대경로 입력을 막는 문자열 비교였고, 그 자체로는 맞다. 리뷰어가 짚은 건 검사가 끝난 다음이었다. fs.readdir도 stat도 writeFile도 전부 심볼릭 링크를 따라간다. 리포 안에 밖을 가리키는 링크가 하나 있으면, 경로 문자열은 세션 디렉터리 안에 있는 채로 실제 접근만 밖에서 일어난다.

검사 통과:  <세션>/link/secret.txt      ← 문자열은 세션 안
실제 접근:  /Users/me/secret.txt        ← link 가 밖을 가리키면 여기로 간다

트리 나열과 파일 읽기만 뚫린 게 아니라 PUT 쓰기까지 같이 뚫렸다. 이 도구에서 실제 위협인 이유는 리포 안에 심링크를 만들 수 있는 주체가 나만이 아니라 Claude 세션이기도 하다는 점이다.

realpath로 링크를 끝까지 따라간 실제 경로가 세션 디렉터리의 실제 경로 아래인지 다시 검사하게 고쳤다. 기준 쪽도 링크 뒤에 있을 수 있어서(macOS의 /tmp가 /private/tmp다) 양쪽 다 realpath를 거쳐 비교하고, 이후 fs 호출은 문자열이 아니라 그 실제 경로를 쓴다.

const abs = resolveInside(base, rel);
if (abs === null) return { err: 'outside' };
const [real, realBase] = await Promise.all([fs.realpath(abs), fs.realpath(base)]);
if (real !== realBase && !real.startsWith(realBase + path.sep)) return { err: 'outside' };
return { path: real };

고쳤다는 말로 끝내지 않고 실제 API로 확인했다. 리포 안에 밖을 가리키는 심링크 폴더를 만들어 두고 트리 나열, 파일 읽기, PUT 쓰기를 차례로 쏘고 ../와 절대경로도 같이 넣었다. 전부 400이 돌아왔다.

같은 파일에서 하나 더 잡혔다. 트리의 git 상태 색이 한글 파일명에만 안 붙었다. git status --porcelain은 기본 설정에서 ASCII 밖 경로를 큰따옴표로 감싸고 옥탈 이스케이프로 바꿔 내놓는다.

git status --porcelain      →  ?? "\353\254\270\354\204\234.md"
git status --porcelain -z   →  ?? 문서.md\0

인용된 경로를 실제 경로와 문자열로 비교하면 당연히 안 맞는다. 에러는 안 나고 색만 조용히 빠지는 종류라, 안 붙는 걸 눈으로 보기 전엔 드러나지 않는다. -z를 붙이면 인용이 아예 없어지고 항목이 NUL로 갈리므로 파싱을 NUL 분리로 바꿨다. 하나 더 있는데, -z에서 리네임과 복사는 새 경로와 옛 경로가 연속된 두 필드로 온다. 옛 경로를 항목으로 잘못 세지 않게 다음 필드를 건너뛴다.

// -z에서 리네임·복사(R/C)는 'XY 새경로\0옛경로' 순서 — 다음 필드(옛 경로)를 건너뛴다.
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') i++;

Review 탭에서 GitHub로 건너뛰기

Review 탭의 커밋과 파일 옆에 ↗를 달았다. 누르면 GitHub의 해당 커밋과 파일이 열린다. 남에게 링크를 던지거나 웹에서 blame을 볼 때 주소를 손으로 만들 일이 없어진다.

origin은 리포마다 SSH(git@github.com:owner/repo.git)로도 https로도 잡혀 있어서, 서버에서 두 형태를 모두 https 웹 주소로 바꾼다. 자격증명이 박힌 주소는 그대로 흘리지 않고, 어느 쪽으로도 안 잡히면 링크를 아예 안 그린다.

붙이고 나니 파일 링크만 404가 났다. 브랜치명을 encodeURIComponent로 통째로 인코딩한 게 원인이다. feat/files-panel이 feat%2Ffiles-panel이 되는데, GitHub는 그런 이름의 브랜치를 못 찾는다. 브랜치명의 슬래시는 이스케이프할 문자가 아니라 URL에서 경로 구분자로 남아야 한다. 파일 경로와 똑같이 세그먼트별로만 인코딩하게 고쳤다.

// 브랜치명의 슬래시는 URL에서 경로 구분자로 남아야 한다 — 통째로 encodeURIComponent하면
// %2F가 되어 GitHub가 못 찾는다. 세그먼트별로만 인코딩한다.
const encPath = (p: string) => p.split('/').map(encodeURIComponent).join('/');

링크 하나만 열어 보고 넘어갔으면 못 봤을 텐데, 화면에 렌더된 링크 82개를 전부 훑다가 걸렸다. 슬래시 없는 브랜치에서는 통째 인코딩도 멀쩡히 동작한다.

패널 탭에 단축키 붙이기

탭이 Files·Changes·Review·PR 넷이 되고 나니 ⌥G로 패널을 열고 탭을 마우스로 고르는 동작이 번거로워졌다. ⌥F 파일, ⌥C 변경, ⌥R 리뷰, ⌥P PR을 넣었고, 이 키들은 닫혀 있던 패널을 함께 연다. 여는 동작과 고르는 동작이 한 번으로 합쳐지니 탭 키 하나로 바로 그 탭이 보인다. 키를 정리하면서 거의 안 쓰던 ⌥⇧N도 같이 지웠다. 사이드바 하단의 단축키 안내도 세 줄로 같이 갱신했는데, 앞서 키를 옮기고 안내 문구만 옛날 그대로 둬서 헤맨 적이 있기 때문이다.

패널에 편집을 들인 대가는 경로 검사와 저장 충돌이라는 자리를 새로 지키게 된 것이다. 읽기만 할 때는 없던 자리다.


6. 마치며

claude-deck을 만들며 처음 세웠던 5가지 조건은 모두 충족되었다. 모든 세션의 상태가 한 화면에 모였고, 권한은 대시보드에서 원클릭으로 승인되며, 탭을 닫아도 세션은 tmux 위에서 안전하게 유지되고, 터미널 고유의 조작감도 그대로 보존되며, 도구가 죽어도 tmux attach 한 줄로 기존 CLI 흐름에 즉시 복귀할 수 있다.

이번 프로젝트에서 가장 남는 건 직접 구현하며 얻은 경험이다. 터미널과 에이전트가 뒤에서 어떻게 맞물려 도는지 내부 구조를 직접 뜯어볼 수 있었고, 내가 필요한 기능을 내 입맛대로 자유롭게 붙여보는 재미가 있었다. 업무의 불편함을 직접 코드로 풀고 나니, 작업 환경에 대한 몰입감과 만족도도 한층 높아졌다.

시스템 재구축을 위한 3가지 필수 레이어

AI 개발 환경을 개선하며 얻은 가장 큰 수확은 화면과 에이전트를 잇는 아키텍처를 분리해 둔 것이다. 이 구조를 다시 지을 때 채워야 할 자리를 정리하면 이렇다.

  1. PTY 레이어. 에이전트 CLI를 node-pty 같은 걸로 감싸는 자리다. 없으면 색도 화살표 선택 메뉴도 안 뜬다.
  2. 세션 데몬. 에이전트의 부모를 터미널이 아니게 만드는 자리다. 나는 tmux를 썼지만, 창을 닫아도 프로세스가 남는 구조면 무엇이든 된다.
  3. 상태 훅. 에이전트가 지금 어떤 상태인지 바깥으로 뱉게 만드는 자리다. Claude Code라면 ~/.claude/settings.json의 훅이고, 남의 설정과 한 파일을 나눠 쓰니 마커를 달아 멱등하게 붙여야 한다.
┌──────────────────────────────────────────────┐
│  Client UI (SvelteKit / React / SwiftUI 등)  │  ← 언제든 교체 가능
└───────────────────▲──────────────────────────┘
                    │ WebSocket
┌───────────────────▼──────────────────────────┐
│  PTY 중계 + 세션 데몬(tmux) + 상태 훅         │  ← 여기는 고정
└──────────────────────────────────────────────┘

이 3가지 축만 단단히 잡아 두면 클라이언트가 웹이든 네이티브 앱이든, 연동하는 에이전트 CLI가 무엇이든 유연하게 갈아 끼울 수 있다.

이제 어느 탭으로 가야 하는지 묻거나 일일이 눌러보지 않는다. 상단 탭의 작은 파비콘 색만으로도 세션 상태를 한눈에 파악할 수 있게 됐다.