레포: https://github.com/sunmerrr/TermHub

만들게 된 계기

AI 코딩 에이전트(Claude Code 등)를 여러 프로젝트에서 동시에 돌리면 작업이 끝났는지, 권한 요청이 떴는지, 등의 상태를 확인하려고 수시로 터미널을 들여다봐야 한다.
잘 들여다보다가 잠깐 자리를 비우면 작업이 어느 이유로든 멈추고 그걸 나중에서야 발견했던 경험이 늘 답답하게 느껴졌다.

꼭 작업 컴퓨터 앞에 있지않아도 세션 상태를 확인하고 바로 응답할 수 있으면 좋겠다 라는게 TermHub을 만든 가장 큰 이유다.

브라우저 기반으로 만들면 PC든 폰이든 어디서든 접속할 수 있으니까 프론트엔드 개발자 답게 브라우저 기반으로 작업했다.

뭘 하는 프로젝트인가

TermHub은 tmux 기반의 웹 터미널 대시보드다.

핵심 기능은 이렇다:

  • 세션 생성: 원하는 디렉토리에서 원하는 명령어로 터미널 세션을 즉시 생성
  • 실시간 출력: WebSocket으로 1초 간격 터미널 출력을 브라우저에 스트리밍
  • 입력 전송: 브라우저에서 직접 명령어 입력, 특수 키(Esc, Ctrl+C, Tab 등) 전송
  • 상태 감지: running / idle / waiting / completed 상태를 자동 판별
  • 탭/분할 레이아웃: 세션을 탭으로 전환하거나 화면 분할로 동시에 볼 수 있음
  • 세션 복구: 서버 재시작 시 기존 tmux 세션 자동 복구
  • Cloudflare 터널: cloudflared가 설정되어 있으면 자동으로 외부 접속 URL 생성
  • 즐겨찾기/최근 경로: 자주 쓰는 디렉토리를 저장해서 빠르게 세션 생성

기술 스택

서버 프레임워크(Express, Fastify 등)나 프론트엔드 프레임워크(React, Vue 등)는 쓰지 않았다.
그래서 의존성은 딱 두 개뿐이다:

{
  "dependencies": {
    "dotenv": "^17.3.1",
    "ws": "^8.19.0"
  }
}
  • 서버: Node.js 내장 http 모듈 + ws 라이브러리
  • 프론트엔드: 바닐라 JS + CSS
  • 터미널 백엔드: tmux (시스템에 설치된 것을 execSync로 호출)

이 프로젝트의 본질은 “tmux 세션을 웹으로 중계하는 것”이다.
API 엔드포인트가 10개도 안 되고 UI 상태도 단순해서, 프레임워크 없이도 서버 파일 하나(server.js, 약 540줄), HTML 하나, CSS 하나, JS 모듈 5개로 충분했다.

아키텍처

┌─ Browser ────────────────────────┐
│  index.html + public/js/*.js     │
│ ┌─ WebSocket ─┐  ┌─ REST API ──┐ │
│ │ 실시간 출력 │  │ 로그인/생성 │ │
│ │상태 업데이트│  │ 입력/중지   │ │
│ └───────┬─────┘  └───────┬─────┘ │ 
└─────────┼────────────────┼───────┘
          │                │
┌─ server.js ──────────────┼───────┐
│         │                │       │
│  WebSocketServer    HTTP Server  │
│         │                │       │
│  ┌──────┴────────────────┴─────┐ │
│  │     In-Memory State         │ │
│  │  workers: Map<id, Worker>   │ │
│  │  sessions: Map<token, bool> │ │
│  └──────────┬──────────────────┘ │
│             │ execSync           │
│  ┌──────────┴──────────────────┐ │
│  │         tmux                │ │
│  │  new-session / send-keys    │ │
│  │  capture-pane / has-session │ │
│  └─────────────────────────────┘ │
└──────────────────────────────────┘

통신 흐름

  1. 로그인: POST /api/login → 비밀번호 확인 → 랜덤 토큰 발급 → HttpOnly 쿠키 설정
  2. 세션 생성: POST /api/spawntmux new-session -d -s term-{id} -c {cwd} 실행 → 1초 간격 폴링 시작
  3. 출력 스트리밍: 서버가 매초 tmux capture-pane으로 터미널 내용을 읽어옴 → 변경분만 WebSocket으로 브라우저에 broadcast
  4. 입력: POST /api/inputtmux send-keys로 전달

tmux를 선택한 이유

tmux는 터미널 세션을 백그라운드에서 유지해주는 터미널 멀티플렉서라고 한다.
서버 관리하는 개발자라면 대부분 쓰고 있을 텐데, 나는 서버쪽은 잘 몰라서 이번에 작업하면서 알게됐다.

이걸 그대로 활용하면:

  • 서버가 죽어도 세션이 살아있다. (터미널이 예기치 않게 종료돼도 Claude Code 세션은 종료되지 않는다.)
  • tmux attach로 디버깅이 바로 된다.
  • 창 크기 조절, 세션 네이밍 등을 tmux가 알아서 해준다.
  • 구현이 execSync("tmux ...") 한 줄이면 끝난다.

여기서 클로드 말로는 execSync가 실행되는 동안 Node.js 가 다른 일을 못하고 기다려야해서 블록킹이 발생 할 수 있다고 한다.
세션이 많아지면 매초 세션 수만큼 execSync가 동기적으로 실행되니까 병목이 될 수 있다는 뜻이다.
그리고 tmux가 설치돼 있지 않으면 아예 동작하지 않는다 — tmux 명령을 직접 호출하는 구조라서.
하지만 개인 개발 도구로tj는 충분하다고 생각한다.

구현에서 재미있었던 부분들

1. 폴링과 WebSocket의 역할 분리

“WebSocket으로 연결했는데 왜 폴링을 또..” 싶을 수 있다.

이건 서버 ↔ tmux 구간과 서버 → 브라우저 구간의 역할이 달라서 이렇게 됐다.
tmux는 “출력이 바뀜!”이라고 알려주는 기능이 없어서 서버가 직접 tmux capture-pane 명령으로 화면 내용을 읽어와야 한다.
그래서 서버 ↔ tmux 구간은 1초 간격 폴링으로 출력을 가져오고, 변경이 감지되면 서버 → 브라우저 구간은 WebSocket으로 즉시 푸시하는 구조로 되었다.

2. AI 상태 자동 감지

Claude Code 같은 AI CLI를 돌릴 때 가장 불편한 건 “지금 뭐 하고 있는지” 상태를 모른다는 거다.
하지만 TurmHub는 그걸 알기위해 만들었기 때문에 알아야만 했다.

이부분은 터미널 출력을 분석해서 상태를 판별할 수 있도록 구성했다:

function detectWaiting(output) {
  const lines = output.split("\n");
  const recent = lines.slice(-10).join("\n");
  if (/Esc to cancel/.test(recent)) return true;
  if (/Do you want to proceed\?/.test(recent)) return true;
  if (/Allow/.test(recent) && /\?/.test(recent)) return true;
  if (/\([Yy]\/[Nn]\)/.test(recent)) return true;
  // ...
  return false;
}

솔직히 좀 짜치는 방식인 건 맞다.
터미널 출력 문자열을 정규식으로 매칭해서 판단하는 거라 AI CLI의 UI가 바뀌면 같이 수정해야 한다.
그치만 뭐.. 현재로서는 이게 유일한 방법인 것 같다. AI CLI가 상태를 외부에 알려주는 API를 제공하지 않는거 같으니.. 또륵

암튼 그래서.. 출력이 5초 이상 변하지 않으면 idle, 권한 요청 패턴이 감지되면 waiting, 출력이 계속 바뀌면 working. 이 상태에 따라 탭의 상태 dot 색상이 바뛰도록 했다:

  • 파란색: working (열심히 일하는 중)
  • 초록색: idle (할 일 다 한 상태)
  • 노란색(깜빡임): waiting (사용자 입력 대기 중)

(하지만 이게 정확하게 동작하지 않을때가 종종 있다…ㅎㅠ)

3. 프로세스 종료 감지

터미널에서 claude를 실행하면 tmux의 pane에서 돌아가는 프로세스가 claude로 잡힌다.
이걸 이용해서 tmux의 #{pane_current_command}를 매초 확인해서
이 값이 claude에서 zshbash 같은 셸로 바뀌면 claude 프로세스가 종료되고 셸 프롬프트로 돌아왔다는 뜻으로 인식하도록 했다.

const currentPaneCmd = tmux(
  `display-message -t ${w.sessionName} -p "#{pane_current_command}"`
).trim();

// claude 프로세스가 종료되고 셸로 돌아왔는지 확인
const switchedToShell =
  w.seenExpectedCmd &&
  currentPaneCmd !== w.expectedCmd &&
  SHELL_COMMANDS.has(currentPaneCmd);

이때 종료 이유도 최근 액션 히스토리를 보고 추론한다.
대시보드에서 Stop 버튼을 눌렀는지, Ctrl+C를 보냈는지, 아니면 작업이 끝나서 /exit 한건지.
크게 어딘가에 정보를 노출하면서 사용하고 있지는 않지만.. 암튼 그렇다.

4. 프론트엔드

바닐라 JS로 탭 전환, 드래그 정렬, 분할 레이아웃, 반응형까지 구현했다.
React나 Vue 같은 프레임워크를 쓰면 컴포넌트 관리나 상태 바인딩이 편해지겠지만
이 프로젝트는 워커 카드를 만들고 업데이트하는 게 전부라 DOM API만으로 충분할 것으로 판단했다.

디자인

GitHub 다크 테마 색상을 그대로 적용해달라고 AI에게 요청했다.
개발자한테 가장 익숙한 색상 체계라 눈이 편하고, 추가로 디자인을 고민할 필요가 없을 것이라고 판단했다:

용도 색상
배경 #0d1117
카드/헤더 #161b22
텍스트 #e6edf3
보조 텍스트 #8b949e
테두리 #30363d
액센트(파랑) #1f6feb
성공(초록) #3fb950
경고(빨강) #f85149

모바일 반응형도 적용해서 iPad나 폰에서도 세션을 확인할 수 있다.
애초에 모바일에서 쓰려고 만든 거니까 당연한 거겠지만..

프로젝트 구조

termhub/
├── server.js            # 서버 전체 (HTTP + WebSocket + tmux 제어)
├── index.html           # 메인 HTML
├── public/
│   ├── style.css        # 전체 스타일
│   └── js/
│       ├── app.js       # 초기화, 로그인, 이벤트 바인딩, 키보드 단축키
│       ├── layout.js    # 탭/분할 레이아웃, 탭 드래그
│       ├── favorites.js # 즐겨찾기/최근 경로 관리
│       ├── ws.js        # WebSocket 연결, API 호출, 리사이즈
│       └── workers.js   # 워커 카드 UI, 로그 표시, 상태 업데이트
├── config.example.json  # 설정 예시
├── package.json         # 의존성 2개 (dotenv, ws)
└── CLAUDE.md            # AI 에이전트용 프로젝트 설명

배운 점

문제를 해결하기 위해 써보지 않은 기술들을 AI를 활용해서 시도해봤다.
버그도 당연히 있었고, 내가 제대로 가이드를 주지 않은 부분에서는 내 머릿속 그림과 다른 결과물이 나온다는 것도 많이 느꼈다.
결국 AI한테 맡기더라도, 내가 뭘 원하는지 명확하게 전달하는 게 가장 중요하다는 걸 다시 한번 상기시킬 수 있었다.

1. tmux는 과소평가된 인프라다?(나에게만) 세션 관리, 프로세스 분리, 출력 캡처, 입력 전송 — 터미널 멀티플렉서가 이 모든 걸 이미 해결해 놓았다. 바퀴를 다시 발명할 필요가 없다.

2. 의존성이 적을수록 유지보수가 쉽다. 당연한 이야기지만, node_modules에 딱 2개 패키지만 있으니 Node.js 버전만 맞으면 어디서든 돌아간다. 의존성때문에 다른거 고민하거나 할 필요가 없다.

앞으로 할 일

  • xterm.js 도입: 지금은 터미널 출력을 <div> 텍스트로 보여주고 있는데, xterm.js를 쓰면 실제 터미널처럼 ANSI 색상, 커서 이동 등을 완벽하게 렌더링할 수 있다.
  • 상태 감지 고도화: 문자열 매칭 방식이 아닌, Claude Code의 상태 API(나오면)를 활용하는 방향으로 개선
  • 알림 기능: waiting 상태가 감지되면 모바일 푸시 알림을 보내서 바로 확인할 수 있도록
  • 멀티 유저 지원: 현재는 단일 비밀번호 인증인데, 유저별 세션 격리가 되면 팀에서도 쓸 수 있을 것 같다.
  • 한글 버그: 한글 입력시 마지막 글자가 두 번 발송되는 문제가 있는데.. 내가 어떻게 할 수 없는 영역인 것 같지만 암튼 고치긴 해야하지 않을까 싶다.

마무리

TermHub은 “AI 에이전트 여러 개를 동시에 돌릴 때, 모바일에서도 한 화면에서 보고 싶다”는 단순한 불편함에서 시작했다.
기술적으로 대단한 건 없지만 tmux라는 검증된 도구 위에 얇은 웹 레이어를 씌운 것만으로 꽤 쓸만한 도구가 된 것 같아서 나쁘지 않은 경험을 한 것 같다.

npm install && node server.js 한 줄이면 바로 써볼 수 있다.