Hwip Docs
CLI & SDK

원격 조종

질문이 타임아웃된 뒤에도 Hwip 앱에서 살아 있는 에이전트 세션을 조종한다.

hwip_ask 폴링 창이 만료된 뒤에도 Hwip 앱에서 살아 있는 에이전트 세션 안으로 메시지를 보낸다.

Hwip 앱이 살아 있는 Claude Code 세션을 미러링하는 화면: 에이전트의 상태 줄, Esc·방향키·Enter·Tab 키 한 줄, 터미널에 바로 입력되는 필드.

MCP 도구 hwip_ask는 timeout 만큼, 최대 600초까지 기다린다. 그 창이 닫히면 세션이 아직 돌고 있어도 폰에서 닿을 수 없게 된다. hwip listener 데몬이 이를 해결한다. Hwip에 지속적인 WebSocket을 열어 두고, 맞는 메시지를 tmux send-keys로 이름 붙은 tmux 세션에 주입한다.

아키텍처

[폰 — Hwip 앱의 "Active Agents" 선택기]
   │  세션을 고르고 메시지 입력
   ▼  POST /pushes/send  { type: 'agent.command',
   │                       agentSessionName: 'hwip-myapp',
   │                       body: '리팩토링 마무리해줘' }
[Hwip 백엔드]
   │  WebSocket fan-out (push.new)
   ▼
[hwip listener — 상주 데몬, `hwip cc`가 자동으로 띄움]
   │  tmux send-keys -l -t hwip-myapp "리팩토링 마무리해줘" + Enter
   ▼
[claude / codex / gemini / pi / opencode / … 가 도는 tmux 세션 "hwip-myapp"]

listener는 5초마다 tmux 세션 목록을 확인해, 새 세션·에이전트 상태 전환·활동처럼 바뀐 게 있을 때만 서버에 보고한다. 바뀐 게 없으면 2분짜리 idle heartbeat로만 다시 보낸다. 앱의 선택기는 수동 설정 없이 동기화된 채로 유지된다. (다이어그램의 body는 라우팅을 설명하려고 봉인 전 모습으로 그렸다. 실제 앱은 봉인해서 보낸다. 방어 참고.)

설정

  1. tmux 설치. listener는 send-keys를 쓰고, 래퍼는 이름 붙은 세션을 띄운다. hwip install이 tmux를 확인하고 macOS에 Homebrew가 있으면 brew install tmux 실행을 제안한다. 그 밖의 환경에서는 Debian·Ubuntu의 sudo apt install tmux처럼 실행할 줄을 출력한다. tmux는 Windows 빌드가 없어 Windows에서는 hwip cc에 WSL이 필요하다.

  2. WebSocket URL 확인. listener는 기본으로 wss://ws.hwip.app에 연결하며, 브라우저 로그인이 ~/.hwip/config.json에 wsUrl을 함께 쓰기도 한다. 다른 소켓을 가리킬 때만 직접 설정한다:

    {
      "apiKey": "ak_...",
      "hookId": "hook_...",
      "wsUrl": "wss://<host>"
    }

    baseUrl과 같은 스테이지로 맞춘다. hwip verify가 각각이 어느 스테이지인지와 값의 출처를 알려준다. listener는 시작할 때만 읽으므로 수정한 뒤에는 hwip listener --restart를 실행한다. --ws-url은 한 번의 실행에서만 덮어쓴다. HWIP_WS_URL은 사용 중단 예정이다. 파일보다 후순위이고 파일에 wsUrl이 없을 때만 읽는다.

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

    hwip cc        # claude       → tmux 세션 "hwip-<project>"
    hwip codex     # codex
    hwip gemini    # gemini
    hwip pi        # pi
    hwip opencode  # opencode
    hwip cursor    # cursor-agent
    hwip hermes    # hermes
    hwip kimi · grok · agy · qwen · auggie · kilo · copilot · kiro-cli · droid

    hwip install이 MCP·rules·hook을 연결하는 에이전트는 Claude Code, Cursor, Windsurf, Gemini CLI, Codex CLI, Copilot CLI, Cline, Aider, pi, OpenCode다. kimi, grok, agy, qwen, auggie, kilo, copilot, kiro-cli, droid, hermes, Cursor의 터미널 에이전트는 래퍼가 앱에서 세션에 닿게 해줄 뿐 그 이상은 없다.

hwip cursor는 Cursor의 터미널 에이전트인 **cursor-agent**를 실행한다. Cursor IDE와는 별도 설치다. PATH의 cursor는 에디터 런처라 바로 종료되고 조종할 수 없다.

listener는 스스로 시작한다

머신에서 처음 실행한 hwip cc가 백그라운드 listener를 자동으로 띄운다. 싱글턴이며 PID 파일은 ~/.hwip/listener.pid, 출력은 ~/.hwip/listener.log다. hwip listener를 직접 실행할 일은 없다. hwip cc는 매번 PID 파일을 확인해 이미 살아 있으면 띄우지 않으므로, 터미널을 열두 개 열어도 데몬이 열두 개 생기지 않는다. 데몬은 hwip cc 호출 사이에도 살아남지만 재부팅은 넘기지 못한다. 로그인 시 listener 시작을 본다.

래퍼 플래그와 인자 전달

프로젝트 이름은 CLAUDE_PROJECT_DIR, CURSOR_PROJECT_DIR, WINDSURF_PROJECT_DIR이 설정돼 있으면 그것, 없으면 git 저장소 루트, 그것도 없으면 cwd 이름에서 정해진다.

명령 뒤의 추가 인자는 에이전트로 그대로 넘어간다:

hwip cc --resume "abc123"
hwip cc --dangerously-skip-permissions
hwip codex --model gpt-5-high "fix the failing test"

래퍼 플래그는 인자 맨 앞에서 읽는다. --detach는 붙지 않고 세션만 만들고(TTY가 필요 없어서 스크립트나 다른 에이전트의 pane에서 띄울 수 있다) 그 이름을 출력한다. --label <x>는 -2/-3 계열 대신 hwip-<project>-<x>로 이름을 붙인다. --wt <branch>는 세션을 git worktree에서 띄우며, 아래에서 다룬다.

한 프로젝트의 여러 세션

같은 폴더의 다른 터미널에서 hwip cc를 다시 실행하면 래퍼가 자동으로 접미사를 붙인다. 첫 세션은 hwip-encl, 다음에 붙는 세션은 hwip-encl-2, 그다음은 hwip-encl-3이다. 앱의 선택기에는 encl · Claude, encl · Claude #2, encl · Claude #3으로 보인다. 에이전트가 세션에 직접 이름을 붙였다면(Claude Code, Codex, Hermes, OpenCode 2.x가 그렇다) 선택기는 <그 이름> · <Agent>를 보여준다.

그 프로젝트의 세션이 분리된(detached) 상태, 즉 붙은 사람이 없는 상태로 있으면 래퍼는 새로 띄우는 대신 거기에 다시 붙는다. 터미널을 닫았다가 나중에 돌아와 하던 데서 이어간다. 같은 에이전트를 돌리는 슬롯만 다시 붙는다. hwip pi는 분리된 hwip cc 세션에 다시 붙지 않는다. 분리된 세션을 대신 죽이는 일은 없다.

이미 tmux 세션 안($TMUX 설정됨)이라면 래퍼는 바깥 tmux를 건너뛰고 현재 pane에서 에이전트를 돌린다. 그러면 listener가 이름 없는 세션을 가리킬 수 없지만 기존 멀티플렉서 환경은 유지된다. 앱이 닿게 하려면 --detach를 쓰거나 tmux 밖에서 실행한다.

자기 git worktree에서 도는 세션

hwip cc --wt feat/x는 저장소 메인 체크아웃 옆에 브랜치 feat/x의 worktree(<repo>-wt-feat-x)를 만들고 그 안에서 hwip-<repo>-wt-feat-x라는 이름으로 세션을 시작한다. 앱은 이를 <repo> 아래 wt-feat-x로 묶는다. 같은 명령을 다시 치면 worktree를 재사용한다. git 밖, git이 브랜치로 받아들이지 않는 이름, 이 worktree가 아닌 무언가가 이미 그 경로에 있는 경우에는 한 줄과 0이 아닌 종료 코드로 거부한다.

세션이 끝나고 그 안에 pane이 남지 않으면 listener가 worktree를 회수한다. 단, 잃을 게 없을 때만이다. 커밋하지 않은 파일·추적되지 않은 파일·무시된 파일이 없고, 원격에 없는 커밋도 없어야 한다. 일반 git worktree remove를 쓰며 --force는 절대 쓰지 않는다. --wt가 만든 worktree만 지운다. hwip worktree는 그 목록과 각각을 붙잡는 것을 보여주고, hwip worktree clean은 아무것도 붙잡지 않는 것을 지운다(--dry-run은 말만 한다).

앱이 입력할 수 있는 셸

다른 셸 pane은 모두 거부된다(방어 참고). hwip sh는 그 유일한 예외로, 에이전트의 권한 프롬프트가 막는 작업(재시작, kill, sudo)을 위해 로그인 셸이 도는 hwip-<project>-sh를 연다. 진짜 터미널이라 cd와 export가 유지되고 sudo·ssh 프롬프트도 동작한다.

hwip sh              # → tmux 세션 "hwip-<project>-sh"
hwip sh --detach     # 만들고 이름을 출력한 뒤 종료

--label <x>는 hwip-<project>-<x>로 이름을 붙인다. tmux 안에서는 붙지 않고 대신 tmux switch-client -t <session> 줄을 출력한다. 셸 세션으로 들어가는 입력은 이 머신 앞으로 보낸 것이어야 하고 감사 기록이 남는다.

라이브 타임라인

세션이 일하는 동안 앱은 에이전트가 지금 하는 일을 그려줄 수 있다. 턴을 시작한 프롬프트, 각 도구 호출과 결과, 에이전트 자신의 응답, 메시지별 토큰 수다. 디스크에 있는 에이전트의 transcript를 tail해서 읽는다. 도구가 읽거나 쓴 내용은 머신을 떠나지 않고 이름·짧은 라벨·숫자만 나간다. 에이전트 자신의 할 일 목록(Claude Code의 task 도구)은 봉인해서 보내며, 최대 30개 항목에 항목당 100자다.

에이전트라이브 타임라인
Claude Code지원
pi지원
Codex CLI지원
Gemini CLI지원
OpenCode 2.x지원(opencode의 데이터베이스에서 읽음)
Cursor, Hermes, Kimi, Grok, Antigravity, Qwen, Auggie, Kilo, Copilot, Kiro, Droid아직 없음

원격 출처 감지 (sticky REMOTE 모드)

send-keys로 주입한 메시지는 직접 친 것과 구별되지 않는다. 그래서 listener는 주입할 때마다 한 번 쓰는 마커도 함께 기록한다. 에포크와 텍스트의 sha256이며 pane의 프로젝트 디렉터리를 키로 한다. 에이전트 쪽 프롬프트 제출 hook이 제출된 프롬프트를 그 마커와 대조해, 정확히 일치하면 모델에게 사용자가 폰으로 세션을 조종 중이라고 알려준다. 이것이 sticky REMOTE 모드이고, 모든 응답이 답할 수 있는 hwip_ask로 끝난다.

에이전트Hook설치하는 쪽
Claude CodeUserPromptSubmit → 플러그인의 hwip-remote.shHwip 플러그인
Gemini CLIBeforeAgent → hwip remote-hook geminihwip setup
Codex CLIUserPromptSubmit → hwip remote-hook codexhwip setup
pibefore_agent_start → hwip remote-hook pi (확장 경유)hwip setup
Cursor CLI아직 없음—

감지는 정확히 일치할 때만 되므로, 터미널 키 입력이 폰 메시지와 경합해도 잘못 표시되는 일은 없다. 음소거된 프로젝트는 표시되지 않는다.

그 hook이 없는 에이전트(Cursor, Windsurf, Copilot, Cline, Aider, OpenCode)에게는 들어가는 길이 하나 더 있다. hwipState: "REMOTE"를 알려주는 hwip_ask 답이다. 그래서 이들의 rules 파일은 실제 작업 뒤의 hwip_ask를 그대로 남겨, 앱에 루프를 시작하는 탭할 버튼이 항상 있게 한다.

서브에이전트는 보기 전용

서브에이전트는 앱에서 부모 카드의 자식으로 <session>.<n>이라는 이름으로 나타난다. 실시간으로 스트리밍되지만 입력·키·종료·재개·forget은 모두 거부된다. 폰으로는 보기만 하고, 대화를 부모가 쥐고 있는 터미널에서 답한다.

Cursor와 Hermes는 한 문장이 필요하다

hwip cursor에는 원격 출처 hook이 없어서 스스로는 sticky REMOTE 모드에 들어가지 않는다. 세션마다 한 번, 한 줄로 요청하면 된다:

I'm driving this session from my phone. End every response with hwip_ask so I can answer with a button.

빠진 건 그것뿐이다. 주입은 동작하고 hwip_ask를 포함한 MCP 도구도 전부 있다. cursor-agent mcp list-tools hwip으로 확인한다. mcp list는 설정된 목록이 아니라 승인된 목록만 출력하므로 서버가 연결돼 있어도 비어 보인다.

자동이 아닌 이유: hooks.json은 Cursor IDE가 따르지만 cursor-agent는 따르지 않고, beforeSubmitPrompt의 출력 스키마가 {continue, user_message}라 마커 일치를 전달할 맥락 채널이 없다. 같은 이유로 hwip setup이 설치하는 stop hook 자동 푸시는 Cursor IDE에는 적용되지만 hwip cursor pane에는 적용되지 않는다. 푸시가 필요하면 hwip_notify를 요청한다.

hwip hermes도 같은 처지이고 hwip install은 Hermes도 연결하지 않는다. ~/.hermes/config.yaml의 mcp_servers 아래에 항목 하나로 MCP 서버를 직접 추가하고 Hermes 안에서 /reload-mcp를 실행한다:

mcp_servers:
  hwip:
    command: "hwip"
    args: ["mcp"]

항목을 추가한다. 파일을 통째로 교체하지 않는다. mcp_servers는 설정해 둔 다른 서버와 공유된다.

로그인 시 listener 시작

재부팅하면 데몬이 끝나고, 터미널을 열어 hwip cc를 실행할 때까지 아무도 다시 띄우지 않는다. 그 사이 앱에는 에이전트가 없다고 뜬다.

hwip listener --install-service

macOS에서는 launchd LaunchAgent(app.hwip.listener), Linux에서는 systemd 사용자 유닛 (app.hwip.listener.service)을 등록한다. 둘 다 로그인할 때마다 listener를 시작한다. --uninstall-service가 제거하고 --service-status가 설치된 것이 무엇을 가리키는지 보여준다. hwip install이 설치 중에 이를 제안하고 hwip uninstall이 다시 걷어낸다.

재부팅 뒤에 돌아오는 건 앱에서 재개할 수 있는 지난 세션이지 살아 있는 에이전트가 아니다. tmux도 재부팅을 넘기지 못한다.

  • 사용자 로그인 때 시작하며 부팅 때가 아니다.
  • 서비스에는 node와 CLI의 절대 경로, tmux에 닿는 PATH, UTF-8 LANG이 박힌다. 버전 관리자로 node를 올리는 등 이 중 무엇이 어긋나면 hwip verify가 짚어주고, --install-service를 다시 실행하면 가리키는 곳이 고쳐진다.
  • 서비스는 프록시를 쥐지 않는다. 대신 ~/.hwip/config.json의 "httpsProxy", "httpProxy", "noProxy"를 읽는다. 수정한 뒤에는 hwip listener --restart로 재시작한다.
  • 서비스가 설치되어 있으면 launchd가 프로세스를 소유하므로, hwip cc와 hwip listener --stop|--restart는 PID에 시그널을 보내는 대신 launchd에 요청한다.
  • 아무도 로그인하지 않는 Linux 서버에서 부팅부터 listener를 띄우려면 sudo loginctl enable-linger $USER로 lingering을 직접 켠다. hwip은 그 설정을 바꾸지 않는다.

Mac 깨워 두기

macOS에서 listener는 살아 있는 동안 caffeinate -s -w <its pid>를 돌린다. idle 슬립에 든 Mac은 listener의 아웃바운드 소켓을 죽여서 앱에 "Waiting for the live stream…"이 뜨기 때문이다. -s는 AC 전원에서만 먹는다. 배터리에서는 노트북이 그대로 잔다. 화면은 여전히 꺼지고 덮은 뚜껑은 여전히 잠든다. --no-keep-awake 또는 ~/.hwip/config.json의 "keepAwake": false로 끈다.

예약 세션

hwip schedule add는 listener가 에이전트 세션을 스스로 시작하게 한다. 매일 아침이든 두 시간마다든, 아무도 터미널을 먼저 열지 않아도 앱에 볼 것이 생긴다.

hwip schedule add cc --at 09:00 --days weekdays --prompt "Triage the open issues"
hwip schedule add codex --every 2h --max-runtime 30m
hwip schedule                 # 다음 실행과 마지막 결과가 붙은 예약 목록
hwip schedule rm k3x9aq       # 하나 제거. 이미 시작된 세션은 계속 돈다
  • 시각은 로컬이다. --at HH:MM은 --days(기본 daily, weekdays, 또는 mon,wed,fri 같은 요일 이름)에 실행하고, --every <N>m|<N>h는 예약을 만든 시점부터 센다. 15분보다 자주는 안 된다.
  • 놓친 실행은 늦게 시작하지 않는다. 실행은 listener가 그 시각의 2분 안에 볼 때만 시작한다. 컴퓨터가 잠들었거나 listener가 꺼져 있던 동안 지나간 시각은 건너뛴다.
  • 예약 하나에 세션 하나, 동시에 셋. 지난 실행의 세션이 아직 있으면 그 실행은 건너뛰고, 세 개가 도는 중에 네 번째 예약 세션도 건너뛴다. 예약은 최대 20개다.
  • **--prompt**는 한 번만 입력되며, 에이전트가 pane의 포그라운드 프로그램이고 감지 규칙이 화면을 idle로 읽을 때만 들어간다. 프롬프트를 받을 수 있는 건 Claude Code, Codex, Gemini CLI, pi뿐이고 hwip schedule add … --prompt는 다른 에이전트를 거부한다. --prompt 없는 예약은 어떤 에이전트든 시작한다.
  • --max-runtime(기본 2h, 5m~24h): 그보다 오래 도는 세션은 앱의 세션 종료와 같은 방식으로 끝내 달라는 요청을 받는다. 강제로 죽이지는 않는다.
  • 앱에는 listener 틱마다 건너뛴 것과 놓친 것을 묶은 푸시 한 건으로 알린다. 예약은 ~/.local/state/hwip/schedules에 있다.
  • 앱에서는 컴퓨터의 예약을 보고, 이 컴퓨터가 돌려 본 세션에 대한 예약을 만들고, 하나 제거할 수 있다. 디렉터리, 에이전트 명령, 인자는 앱이 정하지 못한다.

세션 기록

앱이 나중에 대화를 보여줄 수 있도록 listener는 각 에이전트 세션의 transcript(Claude Code, Codex, pi, Gemini CLI, 그리고 데이터베이스에서 읽는 OpenCode 2.x)를 읽어 오간 내용을 작은 봉인된 청크로 업로드한다. 청크에는 프롬프트, 에이전트의 응답, 도구마다 한 줄(이름, 대상, 바뀐 줄 수)이 들어간다. 도구의 인자·출력·파일 내용은 절대 들어가지 않는다. 청크는 이 머신과 계정이 키와 함께 등록한 모든 기기에 봉인되므로 서버는 암호문만 저장한다. 나중에 로그인한 기기는 이전 청크를 열 수 없다.

--no-turn-history 또는 ~/.hwip/config.json의 "turnHistory": false로 끈다.

세션이 승인을 기다릴 때는 listener가 기다리는 대상(도구, 명령 또는 diff, 선택지)도 보내서 앱이 보여주고 답할 수 있게 한다. Claude Code, Gemini CLI, OpenCode, pi 승인 확장이 해당하며 Codex의 승인 대화상자는 아직 읽지 않는다. 앱의 답은 같은 프롬프트가 아직 화면에 있을 때만 입력된다. 하이라이트를 옮겼거나 키보드로 답했다면 명령은 거부된다(prompt_changed).

파일 보기와 파일 전송

앱은 세션의 저장소(추적되는 파일과 .gitignore가 덮지 않는 추적 안 되는 파일)를 둘러보고 diff를 읽을 수 있다. 둘 다 양방향으로 봉인되며 평문으로 답하는 일이 없다. 비밀 파일(.env*, *.pem, *.key, id_rsa* 등)은 목록에는 나오지만 내용은 보여주지 않고, 비밀처럼 보이는 문자열은 최선 노력으로 가린다. hwip file-view off로 끈다(모든 프로젝트는 --global). hwip file-view log는 마지막 50건의 읽기를 출력한다.

앱이 이 머신으로 보낸 파일은 원래 이름으로 ~/Downloads/Hwip/에 저장되고(이름이 겹치면 name (2).ext, 덮어쓰지 않음) 데스크톱 배너가 저장된 위치를 알려준다. 같은 Wi-Fi에서는 listener가 클라우드를 거치지 않고 앱에서 직접 파일을 받을 수 있다. macOS 방화벽이 node의 들어오는 연결을 허용할지 물으면 거절해도 안전하며, 그러면 파일이 릴레이로 간다.

진단

자동으로 뜬 listener는 ~/.hwip/ 아래에 파일 세 개를 쓴다:

  • listener.pid — 실행 중인 데몬의 PID. cat ~/.hwip/listener.pid 다음 ps -p <pid>로 살아 있는지 확인한다.
  • listener.version — 데몬이 부팅한 CLI 버전. hwip cc가 설치된 패키지와 비교해 오래된 데몬을 알아내는 데 쓴다.
  • listener.log — 데몬의 stdout과 stderr. tail -f로 지켜본다.

데몬은 첫 줄에 버전을 기록하며, 오래 돌고 있는 프로세스가 어느 빌드인지 알 유일하게 믿을 만한 방법이다:

[xx:xx:xx] hwip listener starting — v1.26.0 — wss://ws.hwip.app

정상 listener 로그는 주기마다 한 줄씩 보인다:

[xx:xx:xx] reported 2 session(s): hwip-myapp, hwip-otherapp
[xx:xx:xx] ✓ server persisted 2 session(s)

대신 ! server rejected listener.sessions: ...가 보이면 그 메시지가 실패 원인(인증, 기기 레코드 누락 등)을 가리킨다. 나머지는 hwip verify가 점검한다. tmux가 PATH에 있는지, listener, 서비스, WebSocket URL과 스테이지다.

재시작

hwip listener --restart

@hwip/cli를 업그레이드한 뒤에는 보통 할 필요가 없다. npm i -g는 디스크의 패키지를 바꾸지만 이미 옛 빌드로 돌고 있는 데몬은 바꾸지 않는다. hwip cc가 listener.version을 설치된 버전과 비교해, 설치된 쪽이 더 새로우면 데몬을 대신 재시작한다. 실행한 hwip cc보다 새로운 데몬은 건드리지 않는다:

hwip: listener 1.25.0 is stale — restarting on 1.26.0

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

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

SDK 자체를 개발할 때처럼 포그라운드로 실행하려면:

hwip listener

Hermes 세션이 선택기에서 사라졌다면 tmux가 pane의 시작 명령을 버려서 listener에는 Hermes가 exec하는 인터프리터만 보이는 것이다. 세션을 죽이고 hwip hermes로 다시 시작한다. 포그라운드 listener가 이유(no agent in pane (start=…, current=…))를 출력한다.

커스텀 tmux 소켓

listener는 tmux 소켓을 자동으로 찾는다. 먼저 기본 소켓을 시도하고 응답하면 거기서 멈춘다. 아니면 사용자별 tmux-<uid>/ 디렉터리의 소켓 파일을 모두 시도하고, 아무것도 응답하지 않을 때만 lsof로 실행 중인 각 tmux가 쥔 소켓을 묻는다.

tmux가 tmux -L <name>이나 표준이 아닌 -S <path>를 쓴다면 명시적으로 지정한다:

export HWIP_TMUX_SOCKET=/path/to/socket

래퍼가 환경을 자동으로 뜬 listener에 넘기므로 셸 rc에 설정해 두면 충분하다.

와이어 포맷

listener는 tmux 세션 이름이 agentSessionName에, 메시지가 body에 담긴 type='agent.command' 푸시에만 반응한다. 다른 푸시(Stop hook 자동 푸시, hwip_ask 응답, 채널 브로드캐스트, 일반 노트)는 무시한다. 끝에서 끝까지는 이렇다:

tmux send-keys -l -t <agentSessionName> "<body>"
tmux send-keys    -t <agentSessionName> Enter

기기를 한 번 고정한 listener는 평문 agent.command를 거부한다(방어 참고). 그래서 curl로 직접 보내는 방법은 키를 가진 기기를 아직 보지 못한 listener에만 통한다. 다른 컴퓨터나 스크립트에서 세션에 메시지를 보내려면 항상 봉인하는 hwip agent send를 쓴다.

방어

listener는 설계상 원격 코드 실행 표면이다. 셸에 인접한 pane에 입력하기 때문이다. 방어는 겹겹이다:

  1. Pane 가드. 주입하기 전에 listener가 pane의 #{pane_current_command}를 확인한다. pane이 대화형 셸(bash, zsh, fish, sh, dash, ksh, tcsh, csh, pwsh)에 있으면 주입은 거부된다. 종료된 에이전트가 폰의 공짜 셸 접근이 되지는 않는다. 유일한 예외는 hwip sh 세션이다.
  2. 주소가 지정된 셸만. 표시된 셸은 대상 기기가 이 머신일 때만 명령을 받는다. 한 프로젝트에 hwip sh를 연 컴퓨터 둘이 같은 명령을 둘 다 실행하지 않게 하려는 것이다.
  3. 셸이 잘못 읽을 것은 막는다. 표시된 셸에는 첨부, ESC 같은 제어 바이트, insert 안의 개행을 거부한다.
  4. 감사. 셸 세션에 들어간 입력은 ~/.local/state/hwip/shell-audit.log(모드 0600)에 한 줄씩 남는다. 시각, 세션, pane, 종류, 포그라운드 프로그램, 그리고 셸 자신이 앞에 있었을 때만 텍스트다. sudo 비밀번호 프롬프트 같은 다른 대상으로 간 입력은 길이만 기록한다.
  5. 리터럴 주입. 텍스트는 키 이름을 치는 게 아니라 데이터 붙여넣기로 전달되므로, 메시지 안의 tmux 이스케이프 시퀀스가 다른 tmux 명령을 움직일 수 없다.
  6. 세션 이름 허용 목록. 세션 대상으로는 [A-Za-z0-9._-]+만 받아서 셸 메타문자가 tmux argv에 닿지 않는다. hwip agent send는 셸 세션을 건너뛴다.
  7. 세션별 속도 제한. 토큰 버킷이 폭주하거나 탈취된 발신자를 막는다. 셸 세션도 포함한다.
  8. 에이전트 권한 게이트는 유지된다. 구멍 하나가 있다. 에이전트의 권한 프롬프트는 여전히 모든 도구 호출 앞에 서 있다. 하지만 Claude Code의 ! 접두사는 권한 프롬프트 없이 셸 명령을 바로 실행하고, 앱이 주입하는 텍스트는 입력으로 쳐진다. Claude Code 세션에 보낸 !<command>는 이 머신에서 실행된다. 이는 감수한 부분이고, !hwip sh --detach로 폰에서 셸을 여는 방법이기도 하다.

키 이외에는 발신자를 인증하는 층이 없다. API 키를 쥔 쪽이나 백엔드를 제어하는 쪽은 표시된 모든 셸에, 그리고 !를 통해 모든 Claude Code 세션에 입력할 수 있다.

WebSocket 전송은 API 키와 push:read 스코프로 인증된다. 그 위로 무엇이 오가는지는 앱이 listener에 키를 건네느냐에 달려 있다:

  • 기기 키가 있는 앱(현재). 앱이 구독할 때 기기 공개 키를 보내고, 모든 pane 프레임은 봉인되어 (ECDH P-256, HKDF-SHA256, AES-256-GCM) 돌아오며, 당신의 키 입력은 시퀀스 스탬프를 암호문 안에 넣어 listener용으로 봉인되므로 릴레이가 재전송할 수 없다. 암호화에 실패한 프레임은 평문으로 낮추지 않고 버린다.
  • 키가 없는 옛 앱. 건넬 것이 없으므로 pane 프레임과 입력한 메시지가 릴레이를 평문으로 지난다. listener는 기기를 하나 고정하기 전까지만 그렇게 답하고, 그 뒤에는 평문을 요구하는 요청을 거부한다(e2ee_required).

틈이 둘 있다. 라이브 스트림이 열려 있지 않을 때 보낸 메시지는 일반 요청으로 되돌아가는데, 앱이 이 컴퓨터와 전에 봉인한 적이 없으면 서버에 평문이다. listener가 기기를 고정하기 전까지는 hwip sh 셸에 입력한 모든 명령이 그렇다. 그러니 둘 다 비밀은 두지 않는다. 또 listener는 요청한 기기에 대해 계정의 기기 목록이 가진 키와 일치하는 키에만 봉인한다(아니면 subscriber_unverified). 기기 목록 자체는 서명되지 않으며, 이것이 암호화에서 설명한 한계다.

~/.hwip는 0700, listener.log는 0600으로 유지된다. ~/.hwip/config.json은 키가 어디로 가는지를 정하므로 읽을 때 검사한다. 다른 사용자가 소유했거나 누구나 쓸 수 있는 파일은 경고와 함께 무시된다. API 키를 실은 요청은 모두 리디렉션을 따라가지 않는다.

이 페이지에서