AI

폰에서 에이전트를 조종했다

Collie 로 승인 대기를 풀어낸 3일

한 줄로

에이전트를 오래 돌려보면 일하는 시간보다 승인을 기다리는 시간이 길다.
자리를 비우면 그 대기가 전부 멈춤이 된다.

Collie 는 그 판에 폰을 붙인다. 밖에서 승인 번호를 누르고, 답을 보내고,
/compact 를 친다. 3일 동안 폰에서 문장을 21번 보내고 키를 10번 눌렀다.
그 키 10번 중 8번이 승인 대화상자 응답이었다.


무엇이 병목이었나

Herdr 로 판을 여러 개 띄우면 에이전트가 동시에 돈다.
그런데 동시에 돈다고 시간이 줄지 않는다. 이유가 하나 있다.

에이전트는 혼자 끝까지 못 간다. 중간에 묻는다. 파일을 지워도 되는지,
이 명령을 실행해도 되는지. Herdr 는 그 상태를 blocked 로 알아본다.
알아보기만 한다 — 답은 사람이 해야 한다.

그래서 이런 상태가 된다.

$ herdr workspace list
4  블로그    pane 1  blocked   ← 3분 전부터 여기서 멈춰 있다

3분이면 괜찮다. 문제는 점심 먹으러 나갔을 때다. 1시간을 나가면 1시간이 통째로
날아간다. 에이전트가 느려서가 아니라 답할 사람이 자리에 없어서다.

판을 아무리 늘려도 이건 안 풀린다. 오히려 판이 많을수록 멈춤이 겹친다.
풀리려면 자리를 비운 채로 답할 수 있어야 한다.


Collie 는 무엇인가

터미널에서 도는 에이전트에 폰용 웹 UI를 붙이는 브리지다.
herdr.collie 라는 이름으로 launchd 에 등록되어 배경에 떠 있다.
소스는 AltanS/collie 에 MIT 로 공개돼 있다 —
이 글 뒷부분에서 몇 군데 열어본다.

구조는 네 층이라고 문서가 밝히고 있다.

층 하는 일
Web 폰에서 여는 PWA. 자기를 내려준 브리지와 대화한다
Crew 여러 머신을 한 URL 뒤에 묶는 선택적 연결
Bridge 머신 위의 서비스. PWA 를 내려주고 폰의 요청에 답한다
Mux adapter 이 설치가 비추는 멀티플렉서 하나 — Herdr · tmux · zellij

내 경우 mux 는 Herdr 다. COLLIE_MUX=herdr 이고,
~/.config/herdr/herdr.sock 으로 붙는다.

여기서 Herdr 와 역할이 갈리는 지점을 짚어둔다.

  • Herdr — 판을 나누고, 그 안에서 뭐가 도는지 안다
  • Collie — 그 앎을 밖에서 열람하고 조작하게 한다

Collie 는 에이전트를 새로 띄우지 않는다. 이미 돌고 있던 판에 붙는다.
그래서 폰에서 보내는 것은 새 질문이 아니라 아까 그 대화의 다음 줄이다.


폰에서 누른 키가 판에 닿기까지

폰에서 누른 키가 판에 닿기까지의 경로: 폰의 PWA 가 Tailscale 프론트도어를 거쳐 로컬 브리지에 닿고, 브리지가 Herdr 소켓으로 판에 키를 넣는다. 브리지 앞에 기기 페어링 관문이 있고, 들어온 쓰기는 모두 audit.log 에 남는다 폰에서 누른 키가 판에 닿기까지의 경로: 폰의 PWA 가 Tailscale 프론트도어를 거쳐 로컬 브리지에 닿고, 브리지가 Herdr 소켓으로 판에 키를 넣는다. 브리지 앞에 기기 페어링 관문이 있고, 들어온 쓰기는 모두 audit.log 에 남는다 집 밖 — 내가 든 것 폰의 PWA 판 목록 · 출력 · 입력창 — 앱스토어를 거치지… 회사 맥 안 tailscale serve — 프론트도어 443 → 127.0.0.1:8787 · funnel 이 아니라… collie 브리지 — 페어링 관문 토큰 없는 기기는 읽기만 · 들어온 쓰기는 audi… Herdr 가 들고 있는 것 판 안에서 돌고 있던 에이전트 새로 띄우는 것이 아니라, 아까 그 대화에 그대…

네 칸이지만 실제로 봐야 할 건 강조된 칸 하나다. 나머지는 통로다.

tailscale serve 로 프론트도어를 하나 연다. 내 경우는 이 주소다.

https://<내-머신>.<내-테일넷>.ts.net → http://127.0.0.1:8787

포트를 여는 게 아니다. 브리지는 127.0.0.1 에만 바인딩되고,
tailnet 안에서만 닿는다. 문서가 이 지점에 경고를 박아 뒀다.

🚫 Never use tailscale funnel with Collie. Funnel routes traffic to the
public internet, whereas tailscale serve restricts access to your private tailnet.

funnel 은 공개 인터넷으로 낸다. serve 는 내 tailnet 안에서만 닿는다.
글자 하나 차이인데 한쪽은 인터넷 전체에 셸을 여는 것이다.


실제로 무엇을 했나 — 3일치 기록

Collie 는 들어온 쓰기를 전부 audit.log 에 남긴다.
추정이 아니라 파일에 적힌 것을 센다.

$ wc -l < ~/.local/state/collie/audit.log
59

2026-09-10 ~ 09-13, 59줄이다. 갈래를 세면 이렇다.

행위 횟수
reply — 텍스트를 보냄 39
keys — 키를 직접 누름 10
페어링 · crew · 기기 회수 9
탭 닫기 1

reply 39번 중 실제 텍스트가 실린 건 21번이고 나머지 18번은
텍스트 없이 submit 만 간 것이다. 입력과 전송이 따로 기록되기 때문이라
내가 폰에서 문장을 보낸 횟수는 21번으로 읽는 게 맞다.

판별로는 이렇게 갈렸다.

판 횟수 무엇이 돌고 있었나
wS:p2 31 Collie 자체를 손보던 판
wE:p1 11 주간보고 자동화
wE:p2 4 옵시디언 볼트 정리
wS:p3 3 요약 받던 판

실제로 밖에서 보낸 문장 몇 개를 그대로 옮긴다.

주간보고 잘 썻어?
지금 옵시디언은 어때?? 잘 돌아가?
/compact

/compact 가 섞여 있는 게 이 도구의 성격을 잘 보여준다.
폰에서 한 게 읽기만이 아니라 세션 관리였다는 뜻이다.
실제로 그 판은 컨텍스트가 차서 밖에서 ctrl+k 로 입력줄을 비우고
다시 /compact 를 쳐 넣어야 했다.


승인 번호를 폰에서 누른다

keys 10번이 이 글의 핵심이다. 기록을 보면 대부분 이 모양이다.

{"action":"keys","paneId":"wE:p1","device":"마이폰",
 "detail":{"keys":["5","Enter"]}}

Claude 가 띄운 번호 선택 대화상자에 폰에서 5번을 누른 것이다.
앞서 말한 blocked 가 여기서 풀린다. 자리에 없어도 풀린다.

10번 중 8번에는 promptBinding 이라는 필드가 같이 붙어 있다.

"promptBinding": {
  "checked": true,
  "passed": true,
  "expected": "  결론 3줄   1. 주간보고 못 썼습니다. 09:07에 돌긴 했는데 …"
}

내 폰 화면에 보이던 프롬프트가 무엇이었는지를 같이 보낸다.
checked: true, passed: true 는 그게 판의 현재 화면과 대조되어 통과했다는 뜻이다.
8번 전부 통과했다.

왜 이게 필요한지는 이 도구를 써 보면 바로 안다. 폰 화면은 과거다.
1500ms 폴링이고, 지하철에서는 더 늦는다. 내가 “5번” 을 누를 때
그 판은 이미 다음 질문으로 넘어가 있을 수 있다. 그러면 다음 질문에
엉뚱한 답이 들어간다.
삭제 확인에 “예” 가 꽂히는 사고가 이 구조다.

promptBinding 은 그걸 막는다. 보낼 때 화면을 같이 실어서,
달라졌으면 안 넣는다.

나머지 2번은 이 필드가 없다. 둘 다 ctrl+k·Backspace·Enter 였다 —
번호 선택이 아니라 편집 키다. 고를 선택지가 없으니 대조할 프롬프트도 없다.
대조가 붙는 건 선택지를 고르는 순간뿐이라는 뜻으로 읽었다.

소스를 열어 확인했다

여기까지가 로그만 보고 읽어낸 것이다. promptBinding 은 지금도 공식 문서에 없다 —
docs/ 11개 파일 어디에도 안 나온다. 그런데 소스에는 있다.
bridge/prompt-binding.ts 가 그 구현이고, 추측했던 것보다 규칙이 더 촘촘했다.

대조는 문자열 일치가 아니다. 먼저 정규화를 한 뒤 비교한다.

무시하는 것 보존하는 것
SGR(색상) escape 시퀀스 앞쪽 들여쓰기
줄 끝 공백 · CRLF/CR 차이 문장 중간 정렬 공백
빈 줄이 늘고 주는 것 단어가 바뀌는 것

왼쪽은 터미널이 다시 그리기만 해도 변한다 — 그걸로 막으면 멀쩡한 승인이 계속 튕긴다.
오른쪽을 지키는 이유는 주석에 적혀 있다. diff 와 명령어에서는 공백이 내용이기 때문이다.
실제로 " return value;" 와 " return value;" 를 다른 것으로 판정하는
테스트가 들어 있다. 들여쓰기 2칸짜리 수정과 4칸짜리 수정은 다른 수정이다.

내가 못 봤던 건 실패 이유가 하나가 아니라는 것이다.

{ ok: false, reason: "empty" }        // 대조할 내용이 비었다
{ ok: false, reason: "not_found" }    // 그 프롬프트가 화면에서 사라졌다
{ ok: false, reason: "not_in_tail" }  // 있긴 한데 화면 위쪽으로 밀려났다

세 번째가 이 설계의 핵심이다. 프롬프트가 화면에 남아 있어도 마지막 6줄
바깥으로 밀려났으면 거부한다. 같은 질문이 이미 답변되고 아래에 새 질문이
그려진 상황
을 걸러내기 위해서다. 문자열만 찾았다면 위쪽의 옛 프롬프트에
매치돼서 통과시켰을 것이다.

6이라는 숫자에도 근거가 붙어 있다.

커밋된 픽스처 전체에서 매치 뒤에 따라오는 정규화된 줄은 최대 1줄이다.
6줄이면 상태 표시나 스피너 갱신에는 넉넉하면서, 교체 프롬프트(정규화 후 20~32줄)가
들어오면 낡은 매치를 확실히 바깥으로 밀어낸다.

측정해서 고른 값이지 적당히 잡은 상수가 아니다.

💡 그리고 “대조가 붙는 건 선택지를 고르는 순간뿐” 이라고 읽었던 것 —
소스를 보니 맞았다. 편집 키에는 대조할 프롬프트 자체가 없다.

⚠️ 다만 설계자에게 물어본 것은 아니다. 위는 공개된 소스와 테스트에 적힌 것을
읽은 결과다. 코드가 무엇을 하는지는 확실하고, 왜 그렇게 정했는지는 주석이 말하는 범위까지다.


알림이 조용히 죽어 있었다

밖에서 답하려면 멈췄다는 걸 알아야 한다. 판을 계속 들여다볼 거면
폰을 붙인 의미가 없다. 그래서 Web Push 를 켰다 — 정확히는, 켠 줄 알았다.

09-11 에 폰에서 이 문장을 보냈다.

서버에 VAPID 키가 구성되지 않아 푸시 알림이 비활성화되었습니다 이건 머야

로그를 열어보니 브리지가 뜰 때마다 같은 줄을 찍고 있었다.

[push] disabled (no VAPID keys configured)

VAPID 는 Web Push 를 보낼 때 “이 알림은 이 서버가 보낸 게 맞다” 를
증명하는 키 쌍이다. 없으면 브라우저가 알림을 안 받는다.

문제는 이게 에러로 안 뜬다는 것이다. 브리지는 정상 기동했고,
폰 UI 도 멀쩡했고, 알림만 안 왔다. 알림이 안 오는 건 조용한 증상이다 —
안 왔다는 걸 알려면 와야 했던 순간을 알아야 하는데, 그걸 알려주는 게
알림이다.
collie push-keys 로 키를 만들고 나서야 바뀌었다.

소스를 보면 이게 실수가 아니라 의도된 분기다. bridge/push.ts 의 초기화는
키가 없으면 그냥 로그 한 줄 찍고 돌아온다.

if (!this.cfg.vapidPublic || !this.cfg.vapidPrivate) {
  console.log("[push] disabled (no VAPID keys configured)");
  return;                       // throw 가 아니다 — 브리지는 계속 뜬다
}

throw 였다면 브리지가 안 떠서 내가 5분 만에 알아챘을 것이다.
return 이라서 나머지가 전부 정상 동작하고 알림만 없다. 푸시를 안 쓰는
사람까지 기동 실패로 막지 않으려면 이 선택이 맞다. 대신 쓰려던 사람은 조용히
놓친다
— 내가 그 경우였고, 알아챈 건 이틀 뒤 폰에서 물어봤을 때였다.

키를 넣고 다시 띄우면 enabled 로 바뀐다. 다만 아직 구독한 기기가 없다.

[push] enabled (0 saved subscription(s))

폰에서 알림을 허용하면 그때 구독이 하나 생긴다. 지금은 올라와 있다.

{"endpoint":"https://web.push.apple.com/QEMZ…",
 "userAgent":"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 …)"}

알림은 세 가지를 켜 뒀다.

{"blocked": true, "done": true, "updates": true}

blocked 가 이 글의 그 blocked 다. 에이전트가 물어보는 순간 폰이 울린다.
done 은 Herdr 글에서 정리한 대로
“내가 안 보는 사이에 끝났다” 는 뜻이고, 밖에 있을 때는 항상 그 경우다.


폰이 곧 셸이다 — 페어링을 먼저 한다

문서 첫 줄이 이렇게 시작한다.

Collie provides remote shell access to your machine by design.
Treat the URL as a root login.

과장이 아니다. 이 URL 에 닿는 기기는 판에 키를 넣을 수 있다.
샌드박스도 없고 명령 화이트리스트도 없다 — 걸면 도구가 죽으니 안 건다고
문서가 밝히고 있다.

그래서 기기를 짝지었다. 호스트에서 collie pair 를 치면 8자 코드와 QR 이 나오고,
10분 안에 폰에서 넣으면 토큰을 받는다. 호스트에는 해시만 남는다.

{"label":"마이폰",
 "tokenHash":"4f1a2cfad794…",
 "createdAt":1789032340091}

짝지은 기기는 하나다. 09-10 에 “내 핸드폰” 으로 한 번 짝짓고,
09-11 에 회수한 뒤 “마이폰” 으로 다시 짝지었다. 회수는 즉시 먹는다 —
재시작이 필요 없다고 문서에 적혀 있고, 실제로 그랬다.

관문을 하나 더 세웠다가 도로 내렸다

브리지가 뜰 때마다 찍는 경고가 하나 있다.

[bridge] WARNING: COLLIE_TRUSTED_USER is empty —
any tailnet device/user that reaches the bridge gets full write access.

관문이 둘인데 하나만 세워 뒀다는 뜻이다. 둘은 서로 다른 것을 묻는다.

관문 묻는 것
페어링 이 기기가 내가 발급한 토큰을 갖고 있나
COLLIE_TRUSTED_USER 이 요청이 내 tailnet 로그인으로 들어왔나

페어링만으로도 쓰기는 막힌다. 다만 문서가 짚어두듯 읽기는 남는다 —
read operations remain accessible. tailnet 안의 다른 기기가 URL 을 알면
토큰 없이도 판 출력을 볼 수 있다는 뜻이다.

그래서 켜 봤다. tailscale status --json 이 알려주는 내 로그인 이름이다.

COLLIE_TRUSTED_USER=<내-계정>@github

고치고 collie restart 를 했다. 앞서 crew 에서 겪은 그대로,
파일만 고치면 도는 프로세스는 부팅 때 읽은 값을 계속 들고 있다.

재시작하니 경고 줄이 사라졌다. 그런데 경고가 사라진 건 값이 읽혔다는 증거일 뿐,
차단된다는 증거가 아니다.
직접 찔러 봤다.

$ curl … /api/snapshot                                    # 헤더 없음
403  identity required
$ curl -H "Tailscale-User-Login: <내-계정>@github" …     # 내 계정
200  {"bridge":"connected","agents":[…
$ curl -H "Tailscale-User-Login: someone@else" …          # 남의 계정
403  identity not trusted

제대로 막혔다. 폰도 확인했다 — 헤더는 tailscale serve 가 넣어주는 것이라
그게 안 되면 내 폰까지 잠긴다.

$ curl https://<내-머신>.<내-테일넷>.ts.net/api/snapshot
200

막혔고, 폰은 살아 있다. 여기까지는 성공이었다.

그런데 doctor 가 눈이 멀었다

collie doctor 를 돌려보니 이렇게 나왔다.

skipped: agent-sessions   the bridge did not answer `/api/snapshot`,
                          so no pane can be checked

doctor 도 헤더 없이 자기 브리지를 호출한다. 그래서 자기가 세운 관문에
자기가 막힌다. 브리지는 멀쩡한데 진단 도구만 아무것도 못 본다.

문서에 COLLIE_TRUSTED_USER_OPTIONAL=1 이 있지만 그건 헤더 없는 요청을
전부 통과
시키는 스위치다. 방금 막은 걸 도로 여는 것이라 쓰지 않았다.

그래서 실제 위협이 뭔지를 다시 봤다. 문서가 경고한 건 “모든 로컬 UID 가
포트에 닿는다”
였다. 이 맥에서 그게 성립하는지 센다.

$ dscl . -list /Users UniqueID | awk '$2>=500 && $2<1000'
<내-계정>       501          # 일반 사용자는 나 하나뿐

$ lsof -nP -iTCP:8787 -sTCP:LISTEN
collie … TCP 127.0.0.1:8787 (LISTEN)   # 루프백에만

tailnet 쪽도 셌다. tailscale status 에 노드 5개가 있고 전부 내 계정
소유의 내 기기
다. 남이 없다.

즉 이 관문이 막아주는 시나리오가 이 머신에는 지금 존재하지 않는다.
반면 doctor 가 못 도는 건 매번 겪는 실질적 손해다. 되돌렸다.

$ curl … /api/snapshot     # 다시 200
$ collie doctor            # 판을 다시 읽는다

⚠️ 이건 “안 해도 된다”가 아니라 “이 머신에서는 지금 이득이 없다”이다.
tailnet 에 남의 기기가 들어오는 순간, 또는 이 맥에 계정이 하나 더 생기는
순간 판단이 뒤집힌다. 그때는 doctor 를 포기하고 관문을 세우는 게 맞다.

💡 헛다리를 하나 짚었던 것도 적어둔다. 처음엔 /api/state 로 찔러보고
셋 다 200 이 나와서 “설정이 안 먹었나” 했는데, 그런 경로가 없었다.
PWA 라 없는 경로는 전부 index.html 을 200 으로 돌려준다.
차단을 확인하려면 진짜 있는 엔드포인트로 찔러야 한다.


crew 는 포기했다

머신이 둘이다 — 회사 맥과 개인 맥미니. Collie 에는 crew 라는 게 있어서
여러 머신을 한 URL 뒤에 묶을 수 있다. 폰 앱 하나로 양쪽을 보는 그림이다.

09-10 에 붙였다.

09:10:46  crew.enroll  <내-맥미니>
09:21:41  crew.remove  <내-맥미니>

11분 만에 뺐다. 붙는 중에 이미 다른 문제가 하나 드러났다.

[crew] the trust store changed under this running process —
       it still holds the roster it read at boot.
[crew] this machine is now a lead, but the process is still running as a solo

등록은 파일에 됐는데 도는 프로세스는 예전 명부를 들고 있다.
collie restart 전까지는 반영이 안 된다. 설정을 고쳤는데 안 먹는 상황이
여기서 나온다 — 흔한 함정이고, Collie 는 이걸 로그로 말해준다.

09-12 에 다시 붙였다. 이번엔 다른 데서 막혔다.

[crew] <내-맥미니>: unreachable (snapshot: timed out after 5000ms)
[crew] <내-맥미니>: dropped a stale hello reply, dial 104 of 105
[crew] <내-맥미니>: dropped a stale hello reply, dial 113 of 114

114번을 걸어서 붙지 못했다. lead 가 member 를 불러서(dial) 가져오는 구조라
member 쪽이 안 받으면 방법이 없다. 그날 폰에서 이렇게 물었다.

차라리 맥미니를 중심으로 회사 머신에 붙도록 바꾸는건 어때?

방향을 뒤집어도 같은 벽이었다. 왜 그런지는 나중에 소스를 보고 알았다.
설계 문서(ADR 0013)의 첫 줄이 이렇게 정의한다.

A peer is a Collie instance the lead dials.

부르는 쪽이 lead 로 정의되어 있다. 그러니 “맥미니를 중심으로” 바꾼다는 건
연결 방향만 바꾸는 게 아니라 맥미니를 lead 로 세운다는 뜻이고, 그러면 이번엔
회사 맥이 불려야 하는 쪽이 된다. 받지 못하는 머신이 어느 쪽이냐가 그대로 따라온다.

내가 겪은 dropped a stale hello reply, dial 113 of 114 는 이 구조의 증상이다.
lead 는 계속 걸 수 있고, 안 받는 member 를 끌어올 방법은 없다.

그래서 이렇게 끝냈다.

그냥 따로따로 해서 앱 2개 만들게

머신마다 Collie 를 따로 띄우고 폰에 PWA 를 두 개 깔았다.
한 URL 로 묶는 편의를 버리고 확실히 붙는 쪽을 골랐다.
지금 mode 는 solo 이고 명부는 비어 있다.

{"mode":"solo","roster":[]}

돌아보면 crew 는 내 상황에 필요한 기능이 아니었다. 두 머신을 동시에
보는 일이 거의 없다. 회사 맥을 볼 때와 맥미니를 볼 때가 애초에 다른 시간대다.
붙이느라 쓴 이틀은 기능이 있어서 붙인 것이지 필요해서 붙인 게 아니었다.


doctor 가 말해주는 것

앞에서 두 번 나왔으니 정리해 둔다. doctor 는
조용히 실패하는 것들을 잡아주는 명령이다. 지금 돌리면 하나가 빨간색으로 뜬다.

error: agent-sessions  10 agent pane(s) … 2 report NO session: w9:p2 (claude), wS:p2 (claude)
       — their History link and icon are hidden, and the pane looks otherwise normal

w9:p2 는 지금 이 글을 쓰는 판이다. 훅이 깔리기 전에 시작한 세션이라
Herdr 에 자기 세션 ID 를 보고하지 못했다. 그래서 폰에서 이 판의 History 를
못 연다.

주목할 건 뒷부분이다 — “the pane looks otherwise normal”.
판은 멀쩡해 보인다. 목록에도 뜨고 출력도 보이고 키도 들어간다.
History 만 조용히 없다. doctor 를 안 돌리면 모를 종류의 고장이다.

고치는 법도 같이 알려준다 — 새 세션을 시작하면 된다. 훅은 세션 시작 때
로드되므로 이미 도는 세션에는 못 붙는다.


안 만들기로 한 것들

소스를 받아보고 가장 인상적이었던 건 기능 목록이 아니라 .adr/ 폴더였다.
ADR(Architecture Decision Record) 45개 — 무엇을 만들었는지가 아니라
무엇을 안 만들기로 했는지와 그 이유를 적어둔 문서다.

터미널을 제대로 그려주지 않는 이유

폰 화면의 터미널은 가끔 깨진다. TUI 표가 어긋나고 대화상자가 밋밋한 글자로 떨어진다.
당연히 “제대로 된 터미널 에뮬레이터를 넣자”는 제안이 나왔고,
ADR 0008 은 그걸 “이 레포에서 가장 자주 다시 제안되는 아이디어” 라고 부르며 거절한다.

이유가 내 예상과 달랐다. 성능이나 용량이 아니었다.

pane.read 는 바이트 스트림을 반환하지 않는다. Herdr 가 이미 그려낸 화면을 반환한다.

에뮬레이션이 이미 한 단계 위에서 끝나 있다. 여기에 에뮬레이터를 또 넣으면
“두 번째 렌더러가 첫 번째와 불일치하는” 상태가 된다. 화질이 좋아지는 게 아니다.

그리고 실제 버그들을 표로 늘어놓고 하나씩 “에뮬레이터로 고쳐지나?” 를 묻는다.
답은 전부 아니오였다. 대화상자가 깨진 건 픽셀 문제가 아니라
“어느 줄이 입력창인가”라는 의미의 문제였기 때문이다.

TUI 는 셀을 칠하지, 구조를 칠하지 않는다.
격자가 더 잘 그려진다고 답이 나오지 않는다 — 격자에 애초에 답이 없었다.

폰이 터미널 크기를 건드리지 않는 이유

같은 문서의 개정 노트에 이게 있다. 폰에서 판이 좁게 보이니 자동으로 넓히자는 제안인데,
실측해보니 이런 결과였다.

상황 pane.zoom 효과
데스크톱에 아무도 없음 (= 폰이 유일한 클라이언트) 아무 일도 안 일어남
누군가 데스크톱 앞에 있음 동작함 — 그런데 그 사람 화면이 튐

필요한 경우엔 안 먹고, 먹는 경우엔 남의 화면을 망친다. 그래서 안 한다.
“판의 배치는 조작자의 화면이고, Collie 는 거기에 쓰지 않는다”가 규칙이다.

아이들락은 보안장치가 아니라고 스스로 철회한 기록

ADR 0007 이 재미있다. 원래 문서가 아이들락을 보안 조치라고 설명해뒀는데,
그 설명을 스스로 내린다. 이유는 그게 실제로 그 일을 한 적이 없어서다.

클라이언트 React 상태라 새로고침 한 번이면 지나간다. 대신 비용은 진짜였다 —
탭을 30분 백그라운드에 두고 돌아오면 잠겨 있었고, 잠금 화면이 라우터를
언마운트해서 쓰다 만 답장이 조용히 날아갔다.

코드가 구현한 적 없는 방어를 문서가 주장하게 두는 건 방어가 없는 것보다 나쁘다.
그걸 믿고 진짜 필요한 조치를 건너뛰게 만들기 때문이다.

내가 앞에서 COLLIE_TRUSTED_USER 를 켰다 껐던 것도 같은 종류의 판단이다.
관문이 있다는 사실과 그게 막아준다는 사실은 다르다.

아무것도 수집하지 않는다

ADR 0034 는 텔레메트리를 정책으로 금지한다. 켜는 플래그조차 없다 — 켤 게 없으니까.
논리가 간결하다.

Collie 는 조작자 본인 머신에 대한 원격 셸 접근이다.
그만한 것을 쥔 프로그램이 동시에 집으로 전화를 걸 수는 없다.

여기서 눈에 띈 건 반대 의견을 같이 적어뒀다는 점이다. “신호가 없으면 몇 명이
쓰는지, 어떤 기능이 값어치를 했는지 아무도 모른다 — 그건 진짜 손실이고 감수한다.”
유일한 예외인 업데이트 확인도 범위를 못 박는다. GitHub 태그 목록을 묻는 익명 GET 하나,
설치 ID 도 쿠키도 없음. “무엇이 있냐고 묻는 요청은 무엇을 보고하는 요청이 아니다.”

이걸 왜 적어두나

세 편을 쓰면서 내가 계속 부딪힌 문제가 있다. 동작은 관찰할 수 있는데 의도는 모른다는 것이다.
앞에서 promptBinding 을 두고 “설계 의도를 확인한 건 아니다”라고 단서를 달아야 했던 이유다.

ADR 은 그 간극을 메우려고 있는 문서다. 코드는 결과만 보여주고,
버려진 선택지와 그 이유는 코드에 남지 않는다. 거절된 PR 번호, 실측해보고 뒤집은 가설,
“이건 또 제안될 것이다”라는 예고까지 적혀 있으니 같은 논의가 반복될 때 처음부터 다시 하지 않아도 된다.

⚠️ 물론 ADR 도 작성자의 서술이다. 코드와 일치하는지는 별개 문제이고,
위 내용은 내가 읽은 시점(1.9.0)의 것이다.


정리

  • 에이전트 여러 개를 돌릴 때 진짜 병목은 처리 속도가 아니라 승인 대기다.
    자리를 비우면 그 대기가 전부 멈춤이 된다
  • Collie 는 그 판에 폰을 붙인다. 에이전트를 새로 띄우는 게 아니라
    돌고 있던 대화에 이어 붙는다
  • 3일간 폰에서 문장 21번, 키 10번. 그중 8번이 승인 대화상자 응답이고
    여기서 멈춤이 풀렸다
  • 폰 화면은 과거다. promptBinding 이 보낼 때 화면을 같이 실어 대조한다. 8번 다 통과
  • 그 필드는 문서에 없지만 소스에는 있다. 색·줄끝공백은 무시하고 들여쓰기는 지킨다
    (diff 에서는 공백이 내용이니까). 실패 이유가 3가지인데, 그중 not_in_tail 이 핵심이다 —
    화면에 남아 있어도 마지막 6줄 밖이면 거부해서 “이미 답한 옛 질문”에 매치되는 걸 막는다
  • ⚠️ VAPID 키가 없으면 푸시가 조용히 죽는다. 에러가 안 뜨고 알림만 안 온다.
    알림이 안 온 걸 알려주는 게 알림이라 스스로는 못 알아챈다.
    소스를 보면 throw 가 아니라 return 이라 의도된 분기다 — 안 쓰는 사람을
    기동 실패로 막지 않는 대신, 쓰려던 사람이 조용히 놓친다
  • ⚠️ funnel 이 아니라 serve 다. 한 글자 차이로 인터넷 전체에 셸이 열린다
  • URL 은 root 로그인이다. 관문은 둘이고 묻는 게 다르다 — 페어링은 토큰을,
    COLLIE_TRUSTED_USER 는 tailnet 로그인을 묻는다. 페어링만 세우면
    쓰기는 막히고 읽기는 남는다
  • 두 번째 관문은 켰다가 되돌렸다. doctor 도 헤더 없이 호출하므로
    자기가 세운 관문에 자기가 막힌다. 이 맥은 사용자가 나 하나, tailnet 도 내 기기뿐이라
    막아주는 시나리오가 없는데 진단만 잃는 거래였다.
    ⚠️ 조건이 바뀌면 판단도 뒤집힌다
  • ⚠️ 경고가 사라진 건 차단됐다는 증거가 아니다. 값이 읽혔다는 것뿐이다.
    없는 경로는 200 을 돌려주므로, 진짜 엔드포인트로 403 을 직접 확인해야 한다
  • crew 는 114번 dial 끝에 포기하고 앱 2개로 갔다. 두 머신을 동시에 볼 일이
    애초에 없었다 — 기능이 있어서 붙인 것이었다
  • 방향을 뒤집어도 안 됐던 이유는 설계에 있다. “부르는 쪽이 lead” 라고 정의되어 있어서,
    중심을 바꾸면 못 받는 머신이 반대편으로 옮겨갈 뿐이다
  • 소스의 .adr/ 에 안 만들기로 한 결정 45개가 이유와 함께 있다. 터미널 에뮬레이터를
    거절한 근거(“에뮬레이션은 이미 Herdr 에서 끝났다”), 아이들락을 보안장치라고 한 자기 문서를
    철회한 기록. 코드는 결과만 남기고 버려진 선택지는 안 남는다 — 그걸 적어둔 문서다
  • 설정 파일을 고쳐도 도는 프로세스는 부팅 때 읽은 걸 들고 있다.
    restart 전까지 반영 안 된다
  • doctor 는 멀쩡해 보이는 고장을 잡는다. 내 판 둘이 History 만 조용히 없다

📌 버전 주의. 측정과 로그는 내가 돌리는 1.8.2 에서 나온 것이고,
소스와 ADR 은 1.9.0 을 읽었다. 1.9.0 변경 목록에 promptBinding 이나
위에서 인용한 결정을 뒤집는 항목은 없지만, 화면 구성은 꽤 달라졌다
(대시보드 정렬, 키보드 위 액션 바 등). 두 버전을 나란히 비교한 것은 아니다.

같이 읽을 글:
Herdr 란 ·
문서에 없는 API 를 바이너리에서 꺼냈다 ·
SSH 로는 되는데 Herdr 만 못 찾는다 ·
내가 돌리는 워크플로우 설계