폰에서 Claude Code 세션들을 조종하기 — 디스코드를 붙였다
Mac mini에서 tmux로 Claude Code 세션을 여러 개 띄워두고 쓰는데, 자리를 뜨면 어떤 세션이 끝났고 어떤 게 멈춰 있는지를 알 방법이 없었어요. 그래서 세션 상태를 폰에서 보고, 필요하면 개입할 수 있게 만든 게 Agent Office라는 작은 대시보드예요. 이 글은 거기에 디스코드를 붙인 이야기입니다.
문제 — 세션이 여러 개면 시야가 안 따라와요
Claude Code를 세션 하나로 쓸 땐 그냥 그 창을 보면 됩니다. 그런데 프로젝트별로 tmux 세션을 여러 개 띄워두고 일을 시키기 시작하니까, 지금 어떤 세션이 무슨 상태인지가 한눈에 안 잡히더라고요.
- 어떤 건 작업이 끝났고
- 어떤 건 권한 프롬프트나 질문에서 멈춰서 나를 기다리는 중이고
- 어떤 건 에러로 죽었고
자리에 앉아 있으면 창을 넘겨보면 되지만, 잠깐 나가 있으면 깜깜했어요. 멈춰서 기다리는 세션은 특히 아까웠습니다 — 내가 승인 한 번만 눌러주면 계속 갈 텐데, 그걸 몰라서 몇 시간을 그냥 서 있는 거니까요.
기반 — Agent Office
먼저 만든 건 상태를 보여주는 대시보드였어요. 각 세션에 Claude Code 훅을 걸어서, 상태가 바뀔 때마다 로컬 백엔드로 이벤트를 쏘게 했습니다. 백엔드는 그걸 상태머신으로 정리해서 WebSocket으로 픽셀 사무실 화면에 뿌려요 (세션 하나가 사무실의 자리 하나예요).
원칙 두 개를 지켰어요.
- Anthropic API를 직접 안 부른다 — 오직
claude세션 + tmux만. 이건 관측·조종 도구지 또 다른 에이전트가 아니에요. - Tailnet 전용 — 외부에 안 열고 Tailscale 안에서만. 폰에서 접속하는 것도 tailnet 주소로요.
여기까지 만들고 폰 사파리에서 “홈 화면에 추가”로 PWA처럼 쓰니 보는 것은 해결됐는데, 막상 멈춘 세션을 발견해도 개입하려면 결국 Mac 앞으로 가야 했어요. 알림과 조종을 폰에서 한 곳에 묶고 싶었고, 이미 매일 켜두는 앱이 디스코드라 거기에 붙였습니다.
세 개의 레이어
디스코드 연계는 세 겹으로 나뉘어요.
| 레이어 | 정체 | 하는 일 |
|---|---|---|
| A 알림 | 커스텀 봇 | 세션이 완료·검수대기·에러 되면 채널에 자동으로 알림 |
| B 조종 | 같은 봇의 슬래시 커맨드 | /status /msg /chat /spawn 등으로 폰에서 조작 |
| C 대화 | 같은 봇의 대화 모드(DISCORD_CHAT) | 채널을 세션에 묶으면, 거기 타이핑한 게 그 세션으로 들어감 |
셋 다 제가 discord.js로 만든 같은 봇 하나가 담당해요. C는 그 봇의 대화 모드(DISCORD_CHAT=1)인데,
바인딩된 채널의 메시지를 MessageCreate로 읽어서 세션에 그대로 전달합니다 — 사실상 그 세션과 1:1로 주고받는 거예요.
이 모드만 Message Content Intent가 필요해서 명시적으로 켤 때만 도는데, 이 얘긴 뒤 보안 파트에서 다시 나와요.
셋을 한 번에 다 붙이진 않았고, A(알림)부터 켜서 안정되는 걸 보고 하나씩 늘렸습니다.
알림이 그냥 알림이 아니에요
처음엔 “세션이 멈췄어요” 한 줄만 보내려고 했는데, 그러면 결국 Mac으로 가야 하니 반쪽이더라고요. 그래서 검수 대기가 뜨면 봇이 그 tmux 화면을 읽어서, 거기 떠 있는 질문과 보기를 실제 디스코드 선택 버튼으로 만들어 보내게 했어요. 폰에서 그 버튼을 누르면 세션에 답이 들어갑니다.
이 “화면을 읽어서 버튼으로 바꾸는” 파싱이 생각보다 까다로워서,
그 이야기는 tmux 화면을 파싱해서 폰에서 프롬프트에 답하기에 따로 적었어요.
(요약하면, Claude Code TUI가 프롬프트 박스를 다 그리기 전에 캡처하는 렌더 레이스가 있어서,
파싱될 때까지 짧게 재시도하고 끝내 실패하면 단순한 [승인] [거부] 카드로 폴백해요.)
조종 쪽은 슬래시 커맨드로 열어뒀어요.
/status/workers/usage— 읽기 전용, 함대 요약·세션 목록·사용량(추정)/msg <워커> <텍스트>— 세션에 한 줄 주입/chat <워커>— 이 채널을 세션에 묶기. 그 뒤론 채널에 타이핑하면 그 세션으로 들어가요/spawn <이름> <경로>— 새 세션을 기동하고 이 채널에 자동 연결
그런데 이게 위험한 짓이에요
여기서 멈춰서 생각해야 했어요. 원격에서 내 머신의 세션에 키를 쏜다는 건, 잘못 설계하면 디스코드 메시지 하나가 내 컴퓨터에서 명령을 실행하게 만드는 거예요. 편의 기능 하나가 곧 공격 표면이 됩니다.
그래서 만들기 전에 “무엇을 못 하게 할지”를 먼저 정했어요.
1. 봇이 채널 텍스트를 명령으로 실행하지 않게.
디스코드 봇은 Guilds 인텐트만 씁니다. Message Content Intent는 아예 안 켜요.
즉 봇은 채널에 흘러가는 자유 텍스트를 읽지 않습니다. 조작은 오직 슬래시 커맨드·버튼·모달로만 들어와요.
누가 채널에 아무 문장이나 쳐도 그게 세션 명령이 될 길이 구조적으로 없는 거예요.
(채널을 세션에 묶는 대화 모드는 Message Content Intent가 필요해서, 그건 명시적으로 켤 때만 동작하게 뒀어요.)
// 조종 동작(/msg, [답장] 모달, /report)은 화이트리스트로만.
export function isAllowed(userId) {
// 화이트리스트가 비어 있으면 전부 거부 — fail closed.
if (DISCORD.allowedUserIds.length === 0) return false;
return DISCORD.allowedUserIds.includes(String(userId));
}
2. 조종은 화이트리스트, 그리고 fail-closed.
/msg처럼 실제로 세션을 건드리는 동작은 허용 유저 ID 목록에 있는 사람만 할 수 있어요.
중요한 건 목록이 비어 있으면 아무도 못 하게 한 거예요. 설정을 깜빡했을 때 열려버리는 게 아니라 닫혀버리게요.
허용 안 된 유저가 누르면 다른 사람에겐 안 보이는(ephemeral) 거부 메시지가 떠요.
3. send-keys 엔드포인트는 토큰으로. 세션에 키를 넣는 백엔드 엔드포인트는 공유 토큰으로 막아뒀고, 토큰을 안 넣고 켜면 임시 토큰을 만들어서 로그에 찍어요. 실수로 인증 없이 열리는 일이 없게요.
정직하게 — 편해도 안 넘은 선
권한 프롬프트는 폰에서 [승인] [거부] 버튼으로 답할 수 있게 만들었어요.
[승인]을 누르면 세션에 1이, [거부]를 누르면 Escape가 들어갑니다.
그런데 딱 거기까지로 선을 그었어요.
- 원격에서 답할 수는 있어도, 자동으로 답하진 않아요. 승인은 여전히 내가 버튼을 눌러야 일어나요. 사람이 매번 한 번은 개입하는 구조를 일부러 유지했어요.
- 이걸 없애는
--dangerously-skip-permissions옵션이 있긴 한데, 그러면 원격 메시지가 내 확인 없이 실행돼버려요. Plando 프로덕션을 만지는 세션엔 절대 안 씁니다. 기본은 프롬프트를 유지하는 쪽이에요. - 화면 파싱이 실패하면(자유 입력 요청 같은 경우) 옵션 버튼 대신 단순한
[승인] [거부]카드로 폴백해요. 세밀한 선택까진 못 해도, 최소한 진행할지 멈출지는 폰에서 정할 수 있게요.
이 선이 조금 불편하긴 한데, 저는 이게 맞는 불편이라고 생각했어요. 원격에서 자동 승인까지 열어버리면 편하겠지만, 그 편함의 대가가 너무 크니까요.
돌아보면
- 편의 기능을 만들기 전에 “무엇을 막을지”를 먼저 정한 게 맞았던 것 같아요. 원격 조종은 만들고 나서 보안을 덧대면 이미 늦어요. 인텐트를 안 켜는 것, fail-closed로 두는 것 같은 결정은 처음 골격에 넣어야 했거든요.
- 어디까지만 되게 할지를 정하는 것도 설계라고 봤어요. 원격에서 답은 되게 하되 자동 실행은 막아둔 건 게을러서가 아니라, 그 선을 넘으면 위험이 편의보다 커진다고 판단해서였어요.
- 이 도구 자체는 팔거나 자랑하려고 만든 게 아니라, 내가 여러 세션을 편히 굴리려고 만든 개인 도구예요. 그래도 만들면서 “원격 조종의 안전”에 대해 꽤 많이 생각하게 됐습니다.