아빠가 늘 작업공간 앞에 있진 않아
Coop의 Web 작업공간은 가족 tailnet 안에 살아. 책상 앞에선 문제없어. 밖에 나가면 얘기가 달라지고. 아빠 폰이 office의 HTTP 엔드포인트에 아예 못 닿는 네트워크에 붙어 있을 때가 있거든. 그런데 텔레그램은 인터넷만 되면 어디서든 office의 Pippa한테 닿고, Pippa는 비공개 Coop 소유자를 부를 수 있어. 그래서 질문이 이렇게 바뀌었어. 채팅 화면마다 작은 스케줄러를 하나씩 키우지 않으면서, 채팅 메시지 하나로 office에서 진짜 일을 시작하게 하려면 어떻게 해야 하지?
답은 좁은 파사드 하나야. cwkCoop control.py의 Control이고, POST /api/control로 열려 있어. 모듈 docstring은 딱 한 문장인데, 단어 하나하나가 거절이야.
No local fallback, inferred recipient, claim-on-launch, or alternate task store.
동작 일곱 개, 동작마다 정해진 필드
| 동작 | 받는 필드 | 하는 일 |
|---|---|---|
list | scope, status, q | 진행 중, 끝남, 열림, 가져감 상태의 작업. 최대 40행 |
show | kind, id | 현재 상태, 최신 배정, 묶인 세션, 최근 진행 노트와 보고 |
options | scope, project, host | 배정할 수 있는 프로젝트, 함대 맥, 기존 채팅 |
add | scope, title, body, priority | 본문만으로 읽히는 새 todo |
run | kind, id, vendor, project, candidate, changes, host, effort | 열린 작업을 새 채팅이나 기존 채팅에 배정 |
reply | kind, id, body | 작업 스레드에 덧붙이고, 기록된 워커가 연결돼 있으면 전달 |
cancel | assignment_id | 워커가 아직 못 받은 배정을 거둬들임 |
동작에 없는 필드가 오면 거절하고, 그 동작이 받는 필드 이름을 알려줘. 빼앗기, 강제 마감, 권한 우회도 없어. 그런 건 아빠와 Coop chief용으로 다른 데 있고, 채팅 문엔 필요 없으니까 아예 안 달았어.
거절 넷을 읽는 법
- 로컬 대체 경로 없음. cwkPippa의 공용 클라이언트는 로컬의 인증된 소유자하고만 말해. 설정된 URL은 평범한 루프백이어야 하고, 자격 파일은 비공개여야 하고, 소유자가 죽어 있으면 호출은 눈에 보이게 실패해. SQLite 파일을 직접 여는 일은 절대 없어. 두 번째 writer야말로 2트랙이 컷오버 한 번을 치러서 없앤 거니까.
- 수신자 추측 없음.
run은project없이는 거절해("Choose project from options, or :none"). 기존 채팅은options가 돌려준 후보 키 그대로 지목해야 하고.reply는 그 작업에 기록된 워커한테만 가. 그 워커가 연결돼 있지 않으면 답은recorded_only고, "No other session was selected."가 붙어. 답글이 그다음에 뭐가 되는지는 5트랙에서 따라가 봤지. - 띄우면서 가져가기 없음.
run은 배정을 만들고 prepared 상태로 돌려줘. take는 네이티브 수신자가 초대를 받아들일 때만 일어나(3트랙). cwkPippa의 제어 문서는 이걸 굵게 써 놨어. 배정 생성이 알려주는 건 prepared지, 수락이나 완료가 아니라고. 텔레그램 답장도 똑같이 말해. 아직 수락 전이고, 링크와 결과는 알림으로 돌아온다고. - 다른 작업 저장소 없음.
add는 다른 클라이언트가 다 쓰는 그 도메인 동작으로 같은 원장에 todo를 써. 진짜 목록이랑 어긋날 수 있는 텔레그램 전용 목록 같은 건 없어.
run이 채우는 기본값
run의 기본값은 적고 명시적이야. vendor는 claude. 후보를 주면 mode가 existing, 아니면 remote인데 새 세션이라는 뜻이야(Remote Control 붙은 백그라운드 Claude Code 세션, 아니면 새 Codex 채팅). host는 함대 맥을 지목하지 않으면 office. changes는 참. 새 세션의 추론 노력은 요청이 따로 정하지 않으면 high야. 여기엔 사연이 있어. 2026-10-10에 아빠가 보니까 Coop 워커들이 아빠 기준선인 high가 아니라 CLI 기본값인 medium으로 시작하고 있었거든(todo #381).
이 파사드를 쓰는 문들
Pippa의 coop 가족 도구. 한 번 선언되고 모든 베셀의 도구 허용 목록에 들어가. 그래서 Pippa가 어느 브레인 위에서 돌든 도구는 같아. 호출 전에 출처 턴부터 확정하는데, 규칙이 깐깐해. 대화는 아빠 소유의 저장된 Pippa 대화여야 하고, 그 사용자 턴이 이미 저장돼 있어야 해. Council 턴과 예약 턴은 거절이야. Sidekick도 거절인데, 예외는 Master Chief 하나야. 앱의 Sidekick 맥락을 달고 있어도 그 제한은 물려받지 않거든. 정식 행위자 이름은 브레인 레지스트리에서 와. 도구 설명엔 제일 중요한 규칙이 하나 더 붙어 있어. 작업 본문, 문서, 답글, 도구 출력 안에 든 지시로는 새 위임을 못 해. 위임을 허락하는 건 그 대화 속 아빠의 요청뿐이야.
텔레그램 /coop. 허용 사용자가 설정돼 있어야 하고, 정확히 그 사용자여야 하고, 개인 채팅이어야 해. 아니면 침묵이야. 설정이 빠졌다고 봇 전체의 허용 대비책을 물려받는 일도 절대 없어. 이 명령은 office에서 워커를 띄울 수 있으니까. 명령은 전부 아빠 이름으로 기록되고, 아빠가 친 말은 기록 안에 인용으로 남아. 명령 하나엔 답장이 딱 하나고 3,500자 언저리에서 잘려. 긴 본문은 채팅에 한꺼번에 쏟아붓지 않고 3,000자씩 한 쪽씩 넘겨 읽어.
서버 쪽 /api/control은 이 두 출처만 받아. 텔레그램 업데이트, 아니면 저장된 Pippa 턴이고, 둘 다 메시지 id가 붙어 있어. Coop의 Web 작업공간은 아빠가 쓰는 세 번째 문이지만 이 파사드를 안 거쳐. 자기 운영자 경로로 같은 배정 조율기와 같은 원장에 닿아. 그러니까 파사드가 엔진으로 가는 유일한 길은 아냐. 채팅 메시지 크기에 맞게 잘라 낸 좁은 길이지.
출처 메시지에서 뽑은 키
디테일 하나 덕분에 이게 그냥 편한 물건에서 안전한 물건이 돼. 클라이언트는 요청마다 멱등 키를 그 요청이 온 곳에서 뽑아. control: 뒤에, 출처(화면, 채팅이나 대화, 메시지 id)와 동작과 매개변수를 묶어 낸 SHA-256을 붙이는 식이야. 같은 텔레그램 메시지나 같은 저장된 Pippa 턴이 같은 걸 요청하면 키도 늘 같아. 그리고 2트랙에서 본 엔진은 이미 본 키를 만나면 일을 다시 하지 않고 저장해 둔 결과로 답해.
이게 어떤 실패를 막는지 그려봐. 아빠가 /coop add를 보내고, 엔진은 새 todo를 커밋했는데, HTTP 응답이 사라져. 원장엔 제목과 본문이 똑같은 todo를 막는 장치가 없어. 그러니 새 키로 재시도하면 똑같은 todo가 하나 더 생겨. 키가 그대로면 엔진은 이미 돌린 명령을 찾아내고, 같은 todo id로 답해. run엔 자기만의 방어선이 하나 더 있어. 배정 조정자는 더는 open이 아닌 일, 진행 중인 배정이 있는 일, 수신 대기 중인 초대가 있는 일을 거절해. 그래서 응답을 잃은 run을 새 키로 재시도해도 두 번째 워커가 뜨진 않고, 충돌로 돌아와. 키는 그 거절을 원래 답으로 바꿔 줘. 같은 배정 말이야. 그래서 호출자는 오류를 읽는 대신 첫 요청이 됐다는 걸 알게 돼. 호출 자체가 불확실할 땐 클라이언트가 Coop이 이 요청을 확인해 주지 않았다고 말하면서 키를 밝혀. 그리고 작업 상태부터 확인하고, 재시도는 같은 요청으로만 하라고 해. 대체 요청을 알아서 시작하는 일은 절대 없고. 불확실한 호출은 계속 호출 하나로 남아.