aily: 터미널에서 시작하고, Slack에서 이어가는 AI 에이전트 세션
AI 코딩 에이전트를 쓰다 보면 워크플로우가 이렇게 됩니다. tmux 세션에서 Claude Code에 작업을 시키고, 시간이 걸리니까 자리를 비우고, 돌아와서 터미널을 확인합니다. 문제는 마지막 단계입니다. 에이전트가 5분 전에 끝났을 수도 있고, 질문을 던져놓고 기다리고 있을 수도 있습니다. SSH 호스트가 여러 대이고 세션이 동시에 돌아가면 어떤 세션이 끝났는지 알 방법이 없습니다.
“그냥 Discord에 알림 하나 오면 안 되나?” — 이 생각으로 만들기 시작한 게 aily입니다. tmux에서 돌아가는 에이전트 세션과 Discord/Slack을 양방향으로 연결하는 브릿지입니다. 터미널에서 시작한 작업을 자리를 비운 뒤에는 메신저 스레드에서 그대로 이어갑니다. tmux에 직접 릴레이하기 때문에 안에서 Claude가 돌든 Codex가 돌든 상관없습니다.
이 글은 그 33일의 기록입니다.
토요일 밤, 297줄
2026년 2월 7일 토요일 밤 10시 56분, 첫 커밋. 파일 5개, 297줄이 전부였습니다. Claude Code의 Notification 훅이 실행되면 백그라운드 서브셸에서 5초 기다렸다가(세션 JSONL에 응답이 다 쓰이길 기다리는 겁니다), 파이썬 스크립트로 마지막 어시스턴트 메시지를 뽑아 Discord 스레드에 올리는 bash 스크립트였습니다. 스레드 이름은 [agent] <tmux 세션명>. 세션마다 전용 스레드가 생깁니다.
훅 파일 이름이 notify-clawdia.sh였습니다. Clawdia는 제가 쓰던 Claude 봇의 별명인데, 이때까지만 해도 이 도구가 Claude 전용이라고 생각했다는 흔적입니다.
하루 만에 버린 첫 설계
첫날 밤에 써둔 아키텍처 문서의 원래 계획은 이랬습니다. Discord 쪽 입력은 이미 K8s에 떠 있던 AI 봇이 받아서, AGENTS.md에 적어둔 규칙(“[agent] 스레드의 메시지는 해당 tmux 세션으로 보내라”)에 따라 SSH로 포워딩한다. AI가 메시지를 이해하고 알아서 전달해 주는 그림이죠.
다음 날 오후에 버렸습니다. AI가 릴레이 경로에 끼면 100% 신뢰할 수 없습니다. 어떤 때는 정확히 전달하고, 어떤 때는 챗봇처럼 자기가 응답해 버립니다. 메시지 전달에 확률적 요소가 있으면 안 됩니다. 2월 8일 커밋 메시지가 이 결정을 그대로 담고 있습니다: “Replaces AI-based message forwarding with a reliable bridge script.” [agent] 스레드에 메시지가 오면 무조건 해당 tmux 세션으로 보낸다, 끝. AI로 만든 도구지만 동작 자체에는 AI가 없는 결정론적 브릿지가 됐고, 이게 프로젝트의 정체성이 됐습니다.
같은 날 밤 이름도 claude-hooks에서 aily로 바꿨습니다. Claude 전용이 아니게 됐으니까요.
tmux가 가르쳐준 것들
첫 주말에 tmux 삽질을 두 개 했습니다.
하나는 세션 감지 버그. tmux display-message -p '#S'가 훅이 실행되는 세션이 아니라 attach된 클라이언트의 세션 이름을 반환합니다. 세션 A에서 돌던 훅이 세션 B의 스레드에 알림을 올리는 상황이 나옵니다. tmux가 모든 pane에 심어주는 $TMUX_PANE 환경변수를 써야 합니다. 2월 8일 오후에 고쳤습니다.
# attach된 클라이언트가 아니라, 훅이 실행 중인 pane의 세션을 잡는다TMUX_SESSION=$(tmux display-message -t "$TMUX_PANE" -p '#{session_name}')다른 하나는 입력 전달. tmux send-keys로 텍스트와 Enter를 한 번에 보내면 Claude Code가 Enter를 줄바꿈(Shift+Enter)으로 해석합니다. 텍스트를 먼저 보내고, 0.3초 쉬고, Enter를 따로 보내야 합니다. 이건 문서 어디에도 없어서 직접 부딪혀야 알 수 있었습니다.
이후 진도는 빨랐습니다. 2월 9일에 tmux 세션 생성/종료와 스레드 생성/아카이브를 연동했고, 같은 날 밤 Slack 지원을 넣으면서 훅과 플랫폼 사이에 디스패처(post.sh)를 끼워 넣었습니다. 에이전트별 훅은 플랫폼을 모르고, 디스패처가 설정된 토큰을 보고 Discord/Slack 어느 쪽이든(둘 다면 병렬로) 보냅니다. 단방향 알림이 양방향 릴레이가 되니 쓰임새가 완전히 달라졌습니다. 에이전트가 “어떤 방법으로 할까요?” 하고 멈춰 있을 때, 터미널로 돌아갈 필요 없이 스레드에 답장만 하면 됩니다.
2월 12일에는 Dockerfile과 Gitea CI까지 붙였습니다. 시작한 지 6일째에 이미 K8s 배포 파이프라인이 있었던 셈인데, 정작 안심하고 켜둘 수 있게 되기까지는 한 달이 더 걸립니다. 이 얘기는 뒤에서 합니다.
대시보드: 세 에이전트가 따로 기획하고, 500줄로 합치다
메신저 알림만으로는 “지금 전체 세션이 어떤 상태인가”를 보기 어려워서 웹 대시보드를 붙이기로 했습니다. 기획은 Claude, Gemini, Codex 세 에이전트에게 같은 요구사항을 주고 각자 진행시켰습니다. 결과물은 Claude의 아키텍처 문서 1,704줄, Gemini의 UI 명세 1,564줄, Codex의 구현 명세 2,259줄. 이걸 그대로 쓰는 게 아니라 500줄짜리 merged 플랜으로 추려서 구현에 들어갔고, 2월 15일 하루에 백엔드부터 WebSocket 실시간 UI까지 올라갔습니다.

기술 스택은 의식적으로 눌렀습니다. PostgreSQL 대신 SQLite(사용자가 한 명이라 쓰기 경합이 없고, 백업은 파일 복사면 됩니다), FastAPI 대신 이미 브릿지에서 쓰던 aiohttp, React 대신 빌드 파이프라인이 필요 없는 Alpine.js. 세 에이전트가 뽑아준 기획서는 수천 줄이었지만, 1인용 도구에 과한 엔지니어링을 들이지 않는 게 이번 프로젝트의 원칙이었습니다.

같은 원칙으로 접은 게 하나 더 있습니다. 2월 16일에 Rust 재작성을 검토했는데, 분석해 보니 메시지 릴레이 한 건의 경로에서 SSH 왕복이 50300ms, 플랫폼 API 호출이 100500ms인 반면 Python CPU는 1ms 정도, 전체의 0.1%였습니다. Rust로 바꾸면 ~800ms가 ~795ms가 됩니다. 대신 같은 날 SSH ControlMaster 연결 재사용, 병렬 호스트 스캔, HTTP 세션 재사용을 넣었습니다. 체감 차이는 이쪽이 압니다.
설정을 4번의 입력으로
기능이 붙을수록 설정이 무거워졌습니다. .env에 손으로 채워야 하는 변수가 10개를 넘었고, 실제로 다른 기기에 설치해 보니 저조차 번거로웠습니다. 특히 필수도 아닌 대시보드 URL을 첫 단계부터 물어보는 게 최악이었죠.
2월 25~26일에 aily init 흐름을 갈아엎었습니다. 플랫폼 선택 → 봇 토큰 → 채널 ID → “기본값 쓸래?”에 y — 이 4번의 입력이면 끝나고, SSH 호스트나 대시보드 같은 고급 설정은 n을 눌렀을 때만 나옵니다. 이때 설정 파일도 ~/.claude/hooks/.notify-env에서 ~/.config/aily/env로 옮겼습니다. Claude 전용 훅 시절의 잔재가 경로에 남아 있었는데, Codex·Gemini·OpenCode까지 지원하는 마당에 설정이 .claude/ 안에 있는 건 이상하니까요. XDG 스펙을 따르고, 구 경로는 자동 마이그레이션합니다.
이 작업 중에 발견한 소소한 UX 버그 하나. read -rs로 시크릿을 입력받으면 붙여넣기를 해도 화면에 아무것도 안 나와서, 붙여넣기가 된 건지 알 수가 없습니다. 입력 후 ****를 찍어주도록 고쳤습니다. 커밋 한 줄짜리 수정인데 설치 경험 차이가 컸습니다.
배포 전날 밤: 19개 수정
3월에 들어서면서 집을 비워도 24시간 돌아가도록 K8s에 올리는 작업을 했습니다. Docker 이미지 하나를 BRIDGE_MODE 환경변수로 discord 브릿지/slack 브릿지/대시보드 모드로 나눠 띄우는 구조인데, 컨테이너 환경은 어김없이 새 삽질을 안겨줬습니다. 3월 12일 자정 넘어서 한 커밋 두 개가 그 기록입니다. 로컬에서는 보안상 127.0.0.1에 바인딩하던 대시보드가 컨테이너 안에서는 liveness probe를 통과 못 해서 0.0.0.0 바인딩으로 고쳤고, 읽기 전용으로 마운트된 ~/.ssh 때문에 SSH control socket을 /tmp로 옮겼고, 환경변수명 불일치로 인증 토큰을 못 읽던 것도 잡았습니다.
그리고 같은 날 저녁, 배포 전 마지막으로 전체 코드베이스 보안 점검을 돌렸습니다. 결과는 솔직히 뜨끔했습니다 — CRITICAL 4개, HIGH 8개, MEDIUM 7개, 총 19개.
제일 심각한 건 대시보드의 XSS였습니다. 에이전트 메시지를 marked.js로 HTML 변환해서 x-html로 DOM에 그대로 꽂고 있었는데, AI 세션 트랜스크립트에 스크립트가 섞여 들어오면 그대로 실행됩니다. 더 나쁜 건 인증 토큰이 <meta> 태그에 노출되어 있어서 XSS 하나로 토큰 탈취까지 이어질 수 있었다는 점입니다. DOMPurify로 sanitize를 걸고, 토큰은 60초짜리 일회용 nonce로 바꿨습니다. 그 외에도 대시보드의 command queue가 SSH 호스트에서 아무 명령이나 실행할 수 있던 걸 tmux 명령만 허용하도록 막고, Discord !new 명령의 작업 디렉토리 파라미터로 쉘 메타문자가 주입되는 구멍을 막고, 로그인 rate limiting과 CSP 헤더, StrictHostKeyChecking=yes 같은 것들을 채워 넣었습니다. 18개 파일, 203줄짜리 커밋입니다.
배운 것: “나만 쓰는 도구니까 괜찮겠지”는 SSH와 웹이 연결된 시스템에서는 통하지 않습니다. 대시보드 한 곳이 뚫리면 SSH가 닿는 모든 머신이 같이 뚫립니다.
AI로 AI 도구 만들기
aily는 AI 에이전트 세션을 관리하는 도구를 AI 에이전트 세션으로 만든, 다소 메타적인 프로젝트입니다. 33일간 커밋 143개가 쌓였고, 거의 전부 에이전트와의 페어 작업이었습니다. 대시보드 기획을 세 에이전트가 병렬로 한 것처럼 리뷰도 여럿에게 시켰는데, 3월 9일 밤 커밋 로그에는 “address gemini review”, “address claude review”, “address codex review”가 10분 간격으로 찍혀 있습니다. 서로 다른 모델이 서로 다른 걸 잡아주는 게 실제로 도움이 됐습니다.
아이러니한 건, aily를 만드는 동안 aily가 가장 필요했다는 겁니다. “Claude 끝났나?” 확인하러 터미널을 왔다갔다 하면서, 바로 그 문제를 해결하는 도구를 만들고 있었으니까요. 중반부터는 실제로 aily가 aily 개발 알림을 보내줬습니다. 도그푸딩이 저절로 되는 구조라, **** 붙여넣기 피드백이나 알림 중복 제거 같은 개선은 전부 제가 직접 걸려 넘어져서 나온 것들입니다.
git clone https://github.com/jiunbae/aily.gitcd aily && ./aily init4번의 입력이면 됩니다. 소스는 GitHub에 있습니다.
Comments
Loading comments...