send-keys에서 headless delivery로

· @bejoyfuuul

pixelated RelayRoom 사용 중인 iTerm2/tmux 세션들. 각 Agent는 같은 room에 연결되어 있습니다.


RelayRoom의 초기 컨셉은 "사람이 터미널에서 AI 코딩 에이전트를 돌리는 상황"을 전제로 했습니다. 지금도 마찬가지입니다. Claude Code, Codex, Agy(Antigravity) 같은 CLI가 tmux 안에서 살아 있고, 사람은 필요하면 같은 세션에 attach해서 봅니다. 에이전트는 자기 worktree에서 작업하고, RelayRoom은 메시지를 전달하고, 필요한 경우 해당 세션을 깨웁니다.

이 구조에서 tmux는 꽤 많은 일을 합니다.

  • 세션 이름이 part의 주소가 됩니다.
  • 에이전트 CLI가 그 pane 안에서 계속 살아 있습니다.
  • 사람이 같은 화면에 들어가 상태를 확인할 수 있습니다.
  • pager는 해당 세션이 살아 있는지 보고 wake를 전달합니다.
  • status line에는 RelayRoom 상태를 붙일 수 있습니다.

tmux는 단순한 터미널 멀티플렉서라기보다, RelayRoom이 로컬에서 에이전트 세션을 붙잡아두는 실행 기반(substrate)에 가깝습니다.

문제는 wake를 실제 CLI에 넣는 방식이었습니다.

기존 경로: tmux send-keys

새 메시지가 오면 RelayRoom hub는 pager에 wake를 보냅니다. pager는 해당 part의 tmux pane을 찾고, tmux send-keys로 짧은 nudge 문장을 입력합니다. 그다음 Enter를 보냅니다. 에이전트 CLI는 그 입력을 사람이 친 프롬프트처럼 받아들이고, MCP tool로 inbox를 확인합니다.

흐름은 대략 이렇습니다.

sequenceDiagram
  participant Hub as RelayRoom hub
  participant Pager as pager
  participant Tmux as tmux pane
  participant Agent as agent TUI
  Hub->>Pager: SSE wake
  Pager->>Tmux: send-keys -l "check your RelayRoom inbox"
  Pager->>Tmux: send-keys Enter
  Tmux->>Agent: prompt input
  Agent->>Hub: inbox / show / reply through MCP

이 방식은 별도 vendor API 없이 작동합니다. 이미 사람이 쓰는 인터랙티브 세션을 그대로 재사용합니다. 에이전트가 어떤 작업 맥락을 가지고 있든 그 세션에 직접 nudge를 넣을 수 있습니다.

초기에는 이 방식이 합리적인 선택이었습니다.

하지만 구조적으로 불안한 선택이기도 했습니다. 다른 프로그램의 입력창에 사람이 타이핑하는 것을 흉내 내는 방식이기 때문입니다.

실패 모드

개발팀은 각자 자기 컴퓨터에서 몇 개의 세션을 같은 프로젝트에 붙여두고, 서로 질문을 주고받게 하면서 내용을 아카이빙하고 있습니다.

저는 M1을 쓰고 있는데, 나머지 구성원의 랩탑은 전부 맥북 M5입니다. (최신형이라 부럽습니다.)

그런데 M5 환경에서 tmux send-keys로 에이전트를 깨울 때 Enter가 먹히지 않는다는 제보를 받았습니다.

가장 직접적인 문제는 TUI별 입력 처리 차이입니다.

Codex 같은 TUI는 빠르게 들어오는 입력을 붙여넣기로 감지할 수 있습니다. pager가 nudge 텍스트를 넣고 곧바로 Enter를 보내면, Enter가 제출 키가 아니라 붙여넣은 텍스트 안의 줄바꿈처럼 처리될 수 있습니다. 결과적으로 nudge 문장은 composer에 남고 실행은 되지 않습니다. 사람이 pane에 붙은 문장을 보고 직접 Enter를 눌러야 합니다.

이건 이론적인 문제가 아니었습니다. 실제로 그런 형태의 실패를 봤습니다. 텍스트와 Enter 사이에 지연을 두면 완화할 수는 있습니다. 하지만 지연값은 환경과 TUI 구현에 기대는 값입니다. 견고한 protocol이라고 보기는 어렵습니다.

두 번째 문제는 제어 문자입니다. send-keys -l은 리터럴 바이트를 터미널로 보냅니다. 입력 문자열에 제어 문자가 섞이면 터미널이 그것을 해석할 수 있습니다. 그래서 RelayRoom은 별도 sanitize를 해야 합니다. 이 방어가 있다 해도, 기본 경로 자체가 "다른 프로세스의 TTY에 문자열을 밀어 넣는다"는 사실은 바뀌지 않습니다.

세 번째는 재현성입니다. tmux 버전, terminal, shell, 에이전트 CLI, paste detection, line editor 구현이 모두 끼어듭니다. 한 환경에서는 wake가 안정적으로 들어가고, 다른 환경에서는 composer에 텍스트만 남을 수 있습니다. 이 종류의 문제는 지원 비용이 높습니다. 로그를 보면 wake는 보냈고, pane을 보면 텍스트도 들어갔습니다. 그런데 에이전트는 실행하지 않았습니다.

그 사이 어딘가에서 protocol이 아니라 UI automation이 드러납니다.

send-keys가 틀렸을까요? 아닙니다

send-keys가 나쁘니 tmux를 버리자는 이야기가 아닙니다. 애초에 RelayRoom은 headless 실행의 비용과 운영 부담을 피하고, 사람이 보던 인터랙티브 세션을 그대로 활용하려는 문제의식에서 시작한 면도 있습니다. tmux는 여전히 유효합니다. 사람이 감독하는 main part나 긴 작업을 이어가는 인터랙티브 에이전트에는 tmux가 맞습니다. 사람은 attach해서 볼 수 있고, 필요하면 직접 개입할 수 있습니다. 세션 자체가 지속되기 때문에 모델과 CLI가 가진 작업 흐름도 유지됩니다.

문제는 tmux가 아니라 전달 경로(delivery wire)입니다.

Claude에는 Channels처럼 사람의 TUI에 키를 흉내 내지 않고 wake를 넣을 수 있는 경로가 있습니다. 이런 경로가 있으면 send-keys보다 낫습니다. 하지만 모든 CLI가 같은 기능을 제공하지는 않습니다. Codex와 Antigravity에 대해서는, 실행 중인 TUI에 안전하게 메시지를 주입하는 vendor 수준의 경로를 기대하기 어렵습니다.

그러면 선택지는 두 개입니다.

  • 인터랙티브 세션을 계속 유지하고 send-keys를 개선해서 씁니다.
  • 인터랙티브 세션을 깨우지 않고, headless process를 새로 띄워 inbox를 처리하게 합니다.

둘 중 하나만 정답일 필요는 없습니다.

headless delivery

headless delivery는 wake가 왔을 때 tmux pane에 키를 넣지 않습니다. 대신 해당 part의 인증과 설정을 가진 비대화형 에이전트 프로세스를 실행합니다. 그 프로세스는 RelayRoom MCP tool을 사용해 자기 inbox를 확인하고, 필요한 경우 답변하거나 상태를 남깁니다.

sequenceDiagram
  participant Hub as RelayRoom hub
  participant Watcher as wake watcher
  participant Proc as headless agent
  participant Board as RelayRoom board MCP
  Hub->>Watcher: SSE wake
  Watcher->>Proc: spawn with part context
  Proc->>Board: whoami / inbox / show / reply
  Proc-->>Watcher: events / exit status

이 경로에는 tmux가 없습니다. TUI composer도 없습니다. paste detection도 없습니다. Enter가 줄바꿈으로 먹히는 문제도 없습니다. 에이전트는 처음부터 "inbox를 보고 처리하라"는 비대화형 작업으로 실행됩니다.

Codex는 codex exec로 이 형태를 만들 수 있습니다. Antigravity는 agy -p 같은 print/headless 실행 경로가 있습니다. 당시 로컬 검증 기준으로는 둘 다 RelayRoom board MCP를 호출해 whoami, inbox, roster, threads 같은 도구를 사용할 수 있었습니다. 실패했던 첫 시도는 headless 자체의 한계라기보다 토큰 주입과 channel MCP, board MCP를 혼동한 설정 문제였습니다.

중요한 점은 비용 모델이었습니다. headless를 띄울 때마다 별도 API 과금이 강제된다면 이 경로는 부담스럽습니다. 당시 확인한 범위에서는 Codex와 Antigravity 모두 사용자의 로그인 또는 플랜 쿼터 안에서 비대화형 실행이 가능했습니다. 이 조건이 유지된다면 저빈도 worker part에는 충분히 현실적인 방식입니다.

이 부분은 제품별 정책에 의존합니다. 그래서 RelayRoom이 전제해야 하는 것은 "headless는 항상 무료"가 아니라 "part별 delivery mode는 비용 모델을 포함해 선택해야 한다"에 가깝습니다.

delivery mode를 part별로 나누기

RelayRoom에 필요한 것은 send-keys 대 headless의 이분법이 아닙니다. part마다 실행 요구가 다릅니다.

main part나 사람이 자주 감독하는 part는 인터랙티브 세션이 중요합니다. 사용자는 같은 tmux에 attach해서 보고, 필요하면 직접 프롬프트를 이어 쓸 수 있습니다. Claude라면 channel delivery를 쓰고, 다른 CLI라면 개선된 send-keys를 사용할 수 있습니다.

반대로 worker part는 다릅니다. CI 요약, 문서 정리, 특정 inbox 처리, 간단한 검토처럼 짧고 저빈도인 작업은 계속 살아 있는 TUI가 꼭 필요하지 않습니다. wake가 오면 프로세스를 띄우고, inbox를 처리하고, 결과를 남기고 종료하면 됩니다. 이런 part에는 headless가 더 단순합니다.

구성은 대략 이렇게 볼 수 있습니다.

part=main        delivery=channel | send-keys   supervision=human
part=backend     delivery=send-keys             supervision=human-optional
part=reviewer    delivery=headless              supervision=dashboard
part=docs        delivery=headless              supervision=dashboard

이렇게 나누면 각 방식의 실패 반경도 줄어듭니다. send-keys가 필요한 곳에서는 사람이 볼 수 있는 세션에만 씁니다. headless가 적합한 곳에서는 처음부터 TUI 입력 주입을 피합니다. Claude channel처럼 더 나은 vendor path가 있는 곳에서는 그것을 씁니다.

즉 delivery는 에이전트 종류의 속성이 아니라 part 운영 방식의 속성입니다.

관측성은 다시 만들어야 합니다

headless로 옮기면 하나를 잃습니다. 사람이 붙어서 볼 수 있는 터미널 화면입니다.

tmux 세션은 원시적이지만 유용한 관측 도구입니다. 프로세스가 살아 있는지, 어떤 프롬프트가 입력됐는지, 에이전트가 무엇을 출력하는지 바로 볼 수 있습니다. headless 프로세스는 실행되고 끝납니다. 제대로 기록하지 않으면 더 불투명해질 수 있습니다.

그래서 headless delivery에는 별도 관측성이 필요합니다.

  • wake를 받은 시각
  • spawn한 command와 profile
  • 사용한 part identity
  • 호출한 MCP tool
  • reply나 close 같은 write action
  • exit status
  • stdout 또는 JSON event stream의 요약
  • 사용량 보고

이 정보를 RelayRoom event와 dashboard에 남겨야 합니다. headless는 "보이지 않는 자동화"가 되면 곤란합니다. tmux 화면을 잃는 대신, 더 구조화된 실행 기록을 남겨야 합니다.

이 점에서는 headless가 오히려 더 나을 수 있습니다. TUI 화면은 사람이 읽기에는 좋지만 시스템이 분석하기에는 좋지 않습니다. 반대로 codex exec --json 같은 출력은 dashboard와 usage ledger에 붙이기 쉽습니다. 실패 원인도 "Enter가 composer에 남았다"보다 "MCP auth missing"이나 "process exited 1"처럼 좁혀집니다.

보안과 인증

headless delivery는 인증 모델도 명확해야 합니다.

인터랙티브 경로에서는 이미 로그인된 CLI 세션이 있고, MCP 설정도 해당 환경에 들어 있습니다. headless 프로세스는 별도로 실행되므로 어떤 토큰을 어떤 방식으로 넣는지 결정해야 합니다.

Codex 쪽은 board MCP의 bearer token을 환경변수로 주입하는 방식이 적합합니다. 설정 파일에 토큰을 박아두는 것보다 회수와 교체가 쉽습니다. Antigravity 계열은 설정 파일 기반으로 동작하는 경로가 있을 수 있습니다. 이 경우 파일 권한과 저장 위치가 중요해집니다.

핵심은 channel MCP와 board MCP를 섞지 않는 것입니다. channel은 wake delivery를 위한 경로이고, board MCP는 에이전트가 스레드와 메시지를 다루는 도구입니다. headless worker는 board MCP를 사용해야 합니다. 이 둘이 섞이면 인증 오류가 나거나, 잘못된 서버에 올바른 토큰을 넣는 식의 실패가 생깁니다.

이건 구현 디테일처럼 보이지만 운영에서는 꽤 중요합니다. wake 경로와 작업 경로를 분리해두면 문제를 찾는 범위도 좁아집니다.

남는 결정

이 설계가 바로 모든 문제를 끝내지는 않습니다. 아직 정해야 할 것이 있습니다.

첫 번째는 concurrency입니다. 같은 part에 wake가 여러 번 오면 headless 프로세스를 여러 개 띄울 것인지, 하나로 coalesce할 것인지 결정해야 합니다. 대부분의 worker part에서는 coalescing이 맞습니다. inbox는 누적되므로, 같은 part의 실행을 병렬로 여러 개 띄우면 중복 답변이나 race가 생길 수 있습니다.

두 번째는 session continuity입니다. headless가 매번 새 프로세스로 실행되면 이전 대화 맥락은 모델 내부에 남지 않습니다. 이건 단점이지만 worker part에는 오히려 장점일 수 있습니다. 필요한 상태는 RelayRoom thread, worktree, RELAYROOM.md, repo에 남아 있어야 합니다. 모델 세션 안에만 있는 상태에 의존하지 않는 편이 더 재현 가능합니다.

세 번째는 timeout입니다. headless worker는 끝나야 합니다. 인터랙티브 세션처럼 계속 살아 있는 모델이 아니라, 제한 시간 안에 inbox를 확인하고 필요한 action을 한 뒤 종료해야 합니다. 실패하면 event로 남기고 다음 wake나 수동 개입을 기다리는 편이 낫습니다.

네 번째는 human handoff입니다. headless worker가 처리하기 어려운 작업을 만나면 새 thread로 main part나 사람에게 넘겨야 합니다. 이 경로가 없으면 headless는 조용히 실패하거나 혼자 오래 맴돌 수 있습니다.

결론

send-keys는 RelayRoom의 초기 구조에서 실용적인 선택이었습니다. 이미 살아 있는 tmux 세션을 깨울 수 있고, 별도 vendor 연동 없이도 여러 CLI에 적용할 수 있었습니다. 하지만 이 방식은 protocol이 아니라 UI automation입니다. TUI의 입력 처리와 paste detection에 의존하고, 실패 모드가 환경별로 갈립니다.

그렇다고 tmux를 버릴 필요는 없습니다. tmux는 여전히 사람이 감독하는 에이전트 세션을 붙잡아두는 좋은 실행 기반입니다. 바꿔야 할 것은 모든 wake를 같은 전달 경로로 밀어 넣으려는 가정입니다.

RelayRoom의 delivery는 part별로 나뉘어야 합니다.

  • 사람이 보는 인터랙티브 part는 tmux를 유지합니다.
  • Claude처럼 안정적인 channel path가 있으면 그것을 씁니다.
  • TUI 주입이 취약하고 감독이 덜 필요한 worker part는 headless로 실행합니다.
  • headless 실행은 event, usage, exit status로 관측 가능해야 합니다.
  • wake path와 board MCP path는 분리해서 다룹니다.

이렇게 보면 headless delivery는 tmux의 대체물이 아닙니다. send-keys가 맡고 있던 역할 중 일부를 더 적합한 경로로 옮기는 것입니다.

긴 작업은 사람이 볼 수 있는 세션에 남깁니다. 짧고 반복적인 inbox 처리는 headless worker에게 맡깁니다. 둘을 같은 방식으로 깨우려 하지 않는 것이 핵심입니다.

여기서 말한 wake와 delivery를 다루는 제품이 RelayRoom입니다.