Zeph Docs
CLI & SDK

원격 조종

살아 있는 Claude Code · Codex · Cursor · Gemini 세션을 폰에서 조종한다.

폰에서 살아 있는 세션 안으로 메시지를 보낸다 — zeph_ask 폴링 창이 이미 닫힌 뒤에도.

폰에서 살아 있는 Claude Code 세션을 미러링하는 Zeph 앱. 에이전트 상태줄, Esc·방향키·Enter·Tab
키 행, 그리고 터미널로 바로 입력되는 입력창이 보인다.

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초 보고 주기가 쓸 비용의 일부만 쓴다.

설정

  1. tmux를 설치한다. listener는 send-keys를 쓰고 래퍼는 이름 붙은 세션을 띄운다. macOS는 brew install tmux, Debian·Ubuntu는 apt install tmux.

  2. ~/.zeph/config.jsonwsUrl을 추가한다 — Zeph 백엔드의 WebSocket 엔드포인트이며, CDK 출력 WsApiUrl이다:

    {
      "apiKey": "ak_...",
      "hookId": "hook_...",
      "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
    }

    또는 셸 환경에 ZEPH_WS_URL을 설정해도 된다.

  3. 에이전트를 래퍼로 실행한다. 그게 전부다.

    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 CodeUserPromptSubmit → 플러그인의 zeph-remote.shZeph 플러그인
Gemini CLIBeforeAgentzeph remote-hook geminizeph setup
Codex CLIUserPromptSubmitzeph remote-hook codexzeph 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.pidps -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 cclistener.version을 설치된 버전과 비교해 설치본이 더 새로우면 데몬을 대신 재시작한다. 실행한 zeph cc보다 더 새로운 데몬은 건드리지 않으므로, 한 머신에 여러 설치본이 있어도 서로 다투지 않는다:

zeph: listener 1.25.0 is stale — restarting on 1.26.0

PID 파일이 없으면 — 다른 계정이 데몬을 띄웠거나 누가 지웠거나 — 싱글턴 가드가 그것을 보지 못한다. 실제 프로세스를 직접 찾는다:

ps aux | grep '[c]li.js listener'

SDK 자체를 개발할 때처럼 포그라운드로 돌리려면:

zeph listener

listener.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에 타이핑하기 때문이다. 방어는 겹겹이다:

  1. Pane 가드. 주입 전에 listener가 tmux display-message -p '#{pane_current_command}'를 확인한다. pane이 대화형 셸(bash·zsh·fish·sh·dash·ksh·tcsh·csh·pwsh)에 있으면 주입을 거부한다. 에이전트가 종료됐다고 해서 폰이 셸을 자유롭게 쓰게 되지 않는다.
  2. 리터럴 주입. tmux send-keys -l은 페이로드를 데이터로 받으므로, 메시지 안의 tmux 이스케이프 시퀀스가 다른 tmux 명령을 실행시킬 수 없다.
  3. 세션 이름 허용목록. 세션 대상으로는 [A-Za-z0-9._-]+만 받으므로 셸 메타문자가 tmux argv에 닿지 않는다.
  4. 세션별 속도 제한. 세션당 분당 30회 주입 토큰 버킷이 폭주하거나 탈취된 발신자를 막는다.
  5. 에이전트 권한 게이트는 그대로 켜져 있다. 파괴적 도구 호출 앞에는 여전히 에이전트의 권한 프롬프트가 있다. 폰은 말할 수는 있어도 rm -rf를 대신 승인해줄 수는 없다.

WebSocket 전송은 API 키와 push:read 스코프로 인증된다. 그 위를 지나는 내용을 백엔드가 읽을 수 있는지는 암호화가 켜져 있는지에 달렸다:

  • 암호화 꺼짐 — 기본값. 폰이 listener에게 건넬 디바이스 키쌍을 갖고 있지 않으므로, pane 프레임도 당신이 입력한 메시지도 평문으로 릴레이를 지난다.
  • 암호화 켜짐. 폰이 구독할 때 자기 디바이스 공개키를 보내고, 모든 pane 프레임이 ECDH P-256 + AES-256-GCM 봉투에 담겨 돌아오며, 당신의 키 입력은 순번 스탬프를 암호문 안에 봉인한 채 listener 앞으로 봉인된다 — 그래서 릴레이가 재생 공격을 할 수 없다. 암호화에 실패한 프레임은 평문으로 내려가는 대신 버려진다.

어느 쪽이든 남는 구멍이 둘 있다. 살아 있는 스트림이 없을 때 보낸 메시지는 REST로 폴백하며, 이 경로는 서버에 평문이다. 그리고 봉인된 채널이 사는 것은 수동적인 릴레이에 대한 기밀성뿐이다 — 양쪽 모두 상대 키를 wire에서 배우므로, 자기 키쌍을 만들어낸 백엔드는 listener 행세를 할 수 있다.

이 페이지에서