원격 조종
살아 있는 Claude Code · Codex · Cursor · Gemini 세션을 폰에서 조종한다.
폰에서 살아 있는 세션 안으로 메시지를 보낸다 — zeph_ask 폴링 창이 이미 닫힌 뒤에도.

MCP 도구 zeph_ask / zeph_prompt / zeph_input은 120~600초의 고정 타임아웃을 기다린다. 그
창이 닫히면 세션은 아직 돌고 있는데도 폰에서 주소를 지정할 수 없게 된다. zeph listener
데몬이 이걸 해결한다 — Zeph으로 향하는 WebSocket을 계속 열어두고, 일치하는 메시지를
tmux send-keys로 이름 붙은 tmux 세션에 주입한다.
구조
[폰 — Zeph 앱의 "Active Agents" 선택기]
│ 세션 선택, 메시지 입력
▼ POST /pushes/send { type: 'agent.command',
│ agentSessionName: 'zeph-myapp',
│ body: '리팩토링 마무리해줘' }
[Zeph 백엔드]
│ WebSocket fan-out (push.new)
▼
[zeph listener — 상주 데몬, `zeph cc`가 자동으로 띄운다]
│ tmux send-keys -l -t zeph-myapp "리팩토링 마무리해줘" + Enter
▼
[tmux 세션 "zeph-myapp"에서 도는 claude / codex / cursor-agent / gemini]listener는 tmux 세션 목록을 5초마다 확인하고, 변화가 있을 때만 서버에 보고한다 — 새 세션, 에이전트 상태 전이, 활동. 변화 없는 목록은 30초 idle 하트비트로만 재전송된다. 폰 선택기는 수동 설정 없이 동기 상태를 유지하고, 놀고 있는 listener는 고정 5초 보고 주기가 쓸 비용의 일부만 쓴다.
설정
-
tmux를 설치한다. listener는
send-keys를 쓰고 래퍼는 이름 붙은 세션을 띄운다. macOS는brew install tmux, Debian·Ubuntu는apt install tmux. -
~/.zeph/config.json에wsUrl을 추가한다 — Zeph 백엔드의 WebSocket 엔드포인트이며, CDK 출력WsApiUrl이다:{ "apiKey": "ak_...", "hookId": "hook_...", "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>" }또는 셸 환경에
ZEPH_WS_URL을 설정해도 된다. -
에이전트를 래퍼로 실행한다. 그게 전부다.
zeph cc # claude → tmux 세션 "zeph-<project>" zeph codex # codex → tmux 세션 "zeph-<project>" zeph cursor # cursor-agent → tmux 세션 "zeph-<project>" zeph gemini # gemini → tmux 세션 "zeph-<project>"
zeph cursor는 Cursor의 터미널 에이전트인 **cursor-agent**를 실행한다. 이건 Cursor IDE와
별개 설치다 — PATH에 있는 맨 cursor는 에디터 실행기라 즉시 종료되고 조종할 수 없다.
listener는 스스로 뜬다
머신에서 첫 zeph cc가 백그라운드 listener를 자동으로 띄운다. 싱글턴이며 PID 파일은
~/.zeph/listener.pid, 출력은 ~/.zeph/listener.log에 있다.
zeph listener를 손으로 실행할 일은 없다. 모든 zeph cc가 PID 파일을 확인하고 이미 살아 있으면
띄우지 않으므로, 터미널을 열두 개 열어도 데몬이 열두 개 생기지 않는다. 데몬은 zeph cc 호출
사이에도 살아 있다.
프로젝트 이름과 인자 전달
프로젝트 이름은 CLAUDE_PROJECT_DIR·CURSOR_PROJECT_DIR·WINDSURF_PROJECT_DIR이 설정돼 있으면
거기서, 아니면 git 저장소 루트에서, 그것도 아니면 현재 디렉터리 이름에서 정해진다.
명령 뒤에 붙인 인자는 에이전트로 그대로 전달된다:
zeph cc --resume "abc123"
zeph cc --dangerously-skip-permissions
zeph codex --model gpt-5-high "fix the failing test"한 프로젝트에서 여러 세션
같은 폴더에서 터미널을 하나 더 열고 zeph cc를 다시 실행하면 래퍼가 접미사를 붙인다. 첫
세션이 zeph-encl이면 다음 것은 zeph-encl-2, 그다음은 zeph-encl-3이 된다. 폰 선택기에는
encl · Claude, encl · Claude #2, encl · Claude #3으로 보인다.
zeph-encl이 이미 있지만 detached 상태(아무도 붙어 있지 않음)면 래퍼는 새로 띄우지 않고
거기에 다시 붙는다. 터미널을 닫고 나중에 돌아와 하던 자리에서 이어가면 된다.
이미 tmux 세션 안이면($TMUX가 설정돼 있으면) 래퍼는 바깥 tmux를 건너뛰고 현재 pane에서
에이전트를 실행한다. 그 경우 listener가 이름 없는 세션을 지정할 수 없지만, 기존 멀티플렉서
설정은 그대로 유지된다.
원격 출처 감지 (sticky REMOTE 모드)
send-keys로 주입된 메시지는 직접 타이핑한 것과 구별되지 않는다. 그래서 listener는 주입할
때마다 일회용 마커도 기록한다 — epoch와 텍스트의 sha256을, pane의 프로젝트 디렉터리로 키를 잡아
저장한다. 에이전트 쪽의 prompt-submit hook이 제출된 프롬프트를 그 마커와 대조하고, 정확히
일치하면 사용자가 폰에서 세션을 조종 중이라고 모델에 알린다. 그것이 sticky REMOTE 모드이며, 이
모드에서는 모든 응답이 답할 수 있는 zeph_ask로 끝난다.
| 에이전트 | Hook | 설치 주체 |
|---|---|---|
| Claude Code | UserPromptSubmit → 플러그인의 zeph-remote.sh | Zeph 플러그인 |
| Gemini CLI | BeforeAgent → zeph remote-hook gemini | zeph setup |
| Codex CLI | UserPromptSubmit → zeph remote-hook codex | zeph setup |
| Cursor CLI | — 아직 없음 | — |
감지는 정확 일치라서, 폰 메시지와 경쟁하는 터미널 키 입력이 잘못 표시될 일이 없다. 음소거된 프로젝트는 절대 표시되지 않는다.
Cursor는 한 문장이 필요하다
zeph cursor에는 원격 출처 hook이 없어 스스로 sticky REMOTE 모드에 들어가지 않는다. 그냥
요청하면 된다 — 세션당 한 번, 한 줄이면 충분하다:
이 세션을 폰에서 조종하고 있어. 버튼으로 답할 수 있게 모든 응답을
zeph_ask로 끝내줘.
그 외에 빠진 것은 없다. 주입은 동작하고, MCP 도구도 zeph_ask를 포함해 전부 있다.
cursor-agent mcp list-tools zeph로 확인하면 된다 — mcp list는 설정된 것이 아니라 승인된
목록만 출력하므로, 서버가 연결돼 있어도 비어 보인다.
자동이 아닌 이유: hooks.json은 Cursor IDE는 존중하지만 cursor-agent는 존중하지 않고,
beforeSubmitPrompt의 출력 스키마는 {continue, user_message}라 마커 일치를 전달할 컨텍스트
채널이 없다. zeph setup이 설치하는 stop hook 자동 푸시가 Cursor IDE는 덮지만
zeph cursor pane은 덮지 못하는 것도 같은 이유다. 필요하면 zeph_notify를 요청하면 된다.
진단
자동으로 뜬 listener는 ~/.zeph/ 아래에 파일 세 개를 쓴다:
listener.pid— 도는 데몬의 PID.cat ~/.zeph/listener.pid후ps -p <pid>로 살아 있는지 확인한다.listener.version— 데몬이 부팅한 CLI 버전.zeph cc가 설치된 패키지와 비교해 낡은 데몬을 잡아내는 근거다.listener.log— 데몬의 stdout·stderr.tail -f로 본다.
데몬은 첫 줄에 자기 버전을 남기는데, 오래 도는 프로세스가 어느 빌드인지 알 수 있는 유일하게 믿을 만한 방법이다:
[xx:xx:xx] zeph listener starting — v1.26.0 — wss://ws.zeph.to건강한 listener 로그는 주기마다 한 줄씩 남는다:
[xx:xx:xx] reported 2 session(s): zeph-myapp, zeph-otherapp
[xx:xx:xx] ✓ server persisted 2 session(s)대신 ! server rejected listener.sessions: ...가 보이면, 메시지가 실패 지점(인증, 없는 디바이스
레코드 등)을 가리키므로 짐작하지 않고 실제 문제를 고칠 수 있다.
재시작
zeph listener --restart@zeph-to/cli를 업그레이드한 뒤에도 보통은 할 필요가 없다. npm i -g는 디스크의 패키지를
바꾸지만 이미 옛 빌드로 도는 데몬은 바꾸지 않는다. 그 데몬은 푸시에 계속 응답하므로 에이전트
채팅은 멀쩡해 보이는데, 부팅 이후 추가된 메시지 서브타입은 조용히 무시한다.
zeph cc가 listener.version을 설치된 버전과 비교해 설치본이 더 새로우면 데몬을 대신
재시작한다. 실행한 zeph cc보다 더 새로운 데몬은 건드리지 않으므로, 한 머신에 여러 설치본이
있어도 서로 다투지 않는다:
zeph: listener 1.25.0 is stale — restarting on 1.26.0PID 파일이 없으면 — 다른 계정이 데몬을 띄웠거나 누가 지웠거나 — 싱글턴 가드가 그것을 보지 못한다. 실제 프로세스를 직접 찾는다:
ps aux | grep '[c]li.js listener'SDK 자체를 개발할 때처럼 포그라운드로 돌리려면:
zeph listenerlistener.log에서 tail로 볼 로그와 같은 것이 나온다.
커스텀 tmux 소켓
listener는 tmux 소켓을 자동 탐색한다. 기본 위치를 살피고, 사용자별 $TMPDIR 경로(macOS는
/var/folders/.../T/)를 훑고, /tmp/tmux-<uid>/로 폴백한 뒤, 마지막으로 lsof로 도는 tmux
서버를 찾아 낡은 소켓 파일이 탐색을 방해하지 않게 한다.
tmux를 tmux -L <name>이나 비표준 -S <path>로 쓴다면 오버라이드를 명시한다:
export ZEPH_TMUX_SOCKET=/path/to/socket래퍼가 자동으로 띄우는 listener에 환경을 전달하므로 셸 rc에 넣어두면 충분하다.
와이어 포맷
listener는 type='agent.command'이고 tmux 세션 이름을 agentSessionName에, 메시지를 body에
담은 푸시에만 반응한다. 다른 푸시(Stop hook 자동 푸시, zeph_ask 응답, 채널 브로드캐스트, 일반
노트)는 무시한다. 끝에서 끝까지:
tmux send-keys -l -t <agentSessionName> "<body>"
tmux send-keys -t <agentSessionName> Enter디버깅이나 스크립팅으로 커맨드라인에서 하나 보내야 한다면, 구조화된 푸시를 직접 만든다:
curl -X POST "$ZEPH_BASE_URL/pushes/send" \
-H "X-API-Key: $ZEPH_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"type": "agent.command",
"targetDeviceId": "dev_listener_<sha8(hostname)>",
"agentSessionName": "zeph-myapp",
"body": "테스트 통과시키고 PR 올려줘"
}'방어
listener는 설계상 원격 코드 실행 표면이다 — 셸에 인접한 pane에 타이핑하기 때문이다. 방어는 겹겹이다:
- Pane 가드. 주입 전에 listener가
tmux display-message -p '#{pane_current_command}'를 확인한다. pane이 대화형 셸(bash·zsh·fish·sh·dash·ksh·tcsh·csh·pwsh)에 있으면 주입을 거부한다. 에이전트가 종료됐다고 해서 폰이 셸을 자유롭게 쓰게 되지 않는다. - 리터럴 주입.
tmux send-keys -l은 페이로드를 데이터로 받으므로, 메시지 안의 tmux 이스케이프 시퀀스가 다른 tmux 명령을 실행시킬 수 없다. - 세션 이름 허용목록. 세션 대상으로는
[A-Za-z0-9._-]+만 받으므로 셸 메타문자가 tmux argv에 닿지 않는다. - 세션별 속도 제한. 세션당 분당 30회 주입 토큰 버킷이 폭주하거나 탈취된 발신자를 막는다.
- 에이전트 권한 게이트는 그대로 켜져 있다. 파괴적 도구 호출 앞에는 여전히 에이전트의 권한
프롬프트가 있다. 폰은 말할 수는 있어도
rm -rf를 대신 승인해줄 수는 없다.
WebSocket 전송은 API 키와 push:read 스코프로 인증된다. 그 위를 지나는 내용을 백엔드가 읽을 수
있는지는 암호화가 켜져 있는지에 달렸다:
- 암호화 꺼짐 — 기본값. 폰이 listener에게 건넬 디바이스 키쌍을 갖고 있지 않으므로, pane 프레임도 당신이 입력한 메시지도 평문으로 릴레이를 지난다.
- 암호화 켜짐. 폰이 구독할 때 자기 디바이스 공개키를 보내고, 모든 pane 프레임이 ECDH P-256 + AES-256-GCM 봉투에 담겨 돌아오며, 당신의 키 입력은 순번 스탬프를 암호문 안에 봉인한 채 listener 앞으로 봉인된다 — 그래서 릴레이가 재생 공격을 할 수 없다. 암호화에 실패한 프레임은 평문으로 내려가는 대신 버려진다.
어느 쪽이든 남는 구멍이 둘 있다. 살아 있는 스트림이 없을 때 보낸 메시지는 REST로 폴백하며, 이 경로는 서버에 평문이다. 그리고 봉인된 채널이 사는 것은 수동적인 릴레이에 대한 기밀성뿐이다 — 양쪽 모두 상대 키를 wire에서 배우므로, 자기 키쌍을 만들어낸 백엔드는 listener 행세를 할 수 있다.