Rutas CCR en el harness
gateway-server- GET/PUT .../worker + GET .../worker/events/stream (SSE)
- POST .../worker/events + /delivery + /heartbeat + /internal-events
- archivos: runner.ws.ts + broker.ws.ts
Dossier técnico · Reverse engineering · CLI 2.1.185
El harness de mks-agentics spawnea el CLI claude --sdk-url para controlarlo como backend de sesión. Tras actualizar el binary a 2.1.185, todo CLI spawneado moría con exit code 1 en menos de 500 ms — crash-relaunch loop infinito. El handoff previo dejó cinco hipótesis abiertas (TTY, stdin, channels, --print, PTY) y ninguna era la causa.
La causa real: Anthropic cambió el transporte de --sdk-url de WebSocket a un protocolo CCR (SSE downstream + REST upstream). El wss:// se reescribe internamente a https://, el CLI nunca abre un WebSocket y cuelga sub-paths REST/SSE sobre el path del sdk-url. El harness sólo servía un WebSocket en /ws/cli/:id → el primer GET .../worker/events/stream recibía 404 → exit 1. diagnóstico cerrado fix validado end-to-end
Todo el harness se sostiene sobre un descubrimiento: el flag oculto --sdk-url del CLI (marcado .hideHelp() en Commander). Reverseado en su día desde el binary, ese flag hace que el CLI actúe como cliente de un servidor que tú controlas, hablando el mismo protocolo NDJSON que usa por stdin/stdout — pero por un canal de red.
Eso fue el leak que se convirtió en columna vertebral: permitió envolver el CLI real (claude --print --sdk-url …) y construir encima un control plane — sesiones, multi-provider, agent2agent, recordings — sin tocar el binary, sin Agent SDK, sin tmux/PTY hacks. El reversing original quedó documentado en companion-CC/WEBSOCKET_PROTOCOL_REVERSED.md (CLI v2.1.37).
Control plane sobre el CLI nativo, no translation layer. Se spawnea el
proceso claude real y se le habla su protocolo. Lo que cambia entre
versiones es cómo viajan los bytes (el transporte), no qué se dice
(el protocolo NDJSON de mensajes). Esa distinción es la clave de todo este
dossier.
El CLI conectaba (TCP/TLS verde) pero moría en milisegundos. El stderr interno decía SDKStartup: exiting without result: zero messages drained. El handoff de diagnóstico previo acumuló cinco hipótesis — todas plausibles, todas falsas.
Resultó cosmético — sólo apaga las líneas SDKStartup en stderr.
El flag de channels no toca el arranque del transporte.
El system prompt no participa en la conexión.
isTTY no afecta el exit code; el crash ocurre antes de tocar stdin.
Al contrario — --sdk-url auto-habilita --print + stream-json.
La causa real — ni siquiera estaba en la lista de hipótesis.
Diagnóstico por triangulación: fan-out de agentes sobre el bundle real, análisis byte-level y un capture server que reproduce el harness. Nada se aceptó por fe — el comentario del propio código (stdin EOF) resultó ser una teoría incorrecta del autor del handoff, descartada al ir al binary.
El factory de transporte del bundle 2.1.185 es inequívoco: con sdkUrl siempre instancia el cliente CCR, sin rama WebSocket ni flag de escape.
# @209233524 — selección de transporte $ return t.sdkUrl ? new xht(t.sdkUrl, ...) : new v2t(...) # xht = cliente CCR (SSE+REST) · v2t = stdin/stream-json → con --sdk-url SIEMPRE xht. No hay rama WebSocket. # @206433678 — construcción del transporte $ let l = new URL(t) $ l.pathname = l.pathname.replace(/\/$/,"") + "/worker/events/stream" $ let c = new MWt(l, ...) # MWt = SSE transport → cuelga /worker/events/stream encima del path del sdk-url # @206428738 — parser del evento SSE entrante $ handleSSEFrame(e, t) { $ if (e !== "client_event") { warn cli_sse_unexpected_event_type; return } $ n = JSON.parse(t); r = n.payload $ if (r && typeof r === "object" && "type" in r) $ this.onData(JSON.stringify(r) + "\n") # emite el payload como NDJSON $ }
El primer GET <base>/worker/events/stream golpea el propio harness
(127.0.0.1), no api.anthropic.com. El gateway sólo servía un WebSocket en
/ws/cli/:id → esa ruta SSE no existe → 404 → el transporte marca
permanentCloseCode → exit(1). El CLI muere antes de tocar Anthropic.
El reversing previo (WEBSOCKET_PROTOCOL_REVERSED.md, v2.1.37) ya documentaba una jerarquía de transportes. Cruzándolo con 2.1.185 emerge una migración en tres etapas — el harness se quedó congelado en la primera.
Cerrar el round-trip fue iterativo: cada smoke contra el capture server destapó el siguiente gate. Cuatro saltos hasta el PONG.
$ GET .../worker/events/stream # SSE abierto, 15ms $ PUT .../worker {worker_status:idle} # registro # → inyectamos user_message por SSE (event: client_event) → delivery ACK {status:"received"} # el CLI recibió el input $ PUT .../worker {worker_status:running} # arranca el turno → delivery ACK {status:"processing"} → POST .../worker/events [system/init] $ PUT .../worker {last_served_model:claude-opus-4-8} → POST .../worker/events [assistant] # PONG → POST .../worker/events [result/success] # result=PONG → delivery ACK {status:"processed"} $ PUT .../worker {worker_status:idle} # vuelve a idle
Todo cuelga del path base del sdk-url, que pasa de ser un WebSocket a un prefijo HTTP con SSE + REST.
| Método | Path | Rol | Si falla |
|---|---|---|---|
| GET | .../worker/events/stream | SSE downstream (server→CLI) | 404 → exit 1 |
| PUT | .../worker | register + worker_epoch | 401/403/404 → exit 1 |
| GET | .../worker | restore | warning |
| POST | .../worker/events | upstream (CLI→server) | retry/backoff |
| POST | .../worker/events/delivery | acks: received/processing/processed | drop 4xx |
| POST | .../worker/heartbeat | liveness | bloquea procesamiento |
| POST | .../worker/internal-events | eventos internos | drop 4xx |
# gate: el campo SSE event DEBE ser client_event (otro valor = drop) # framing LF puro (\n), nunca CRLF (\r\n) $ event: client_event $ id: 1 $ data: {"event_id":"<uuid>","sequence_num":1,"event_type":"user","payload":{...}} $ (línea en blanco) # payload = mensaje NDJSON de siempre: $ {"type":"user","session_id":"","parent_tool_use_id":null,"message":{"role":"user","content":"..."}}
La pregunta que dimensiona el fix. El protocolo de mensajes es invariante; sólo cambia la cañería.
| Capa | Antes (WS) | Ahora (CCR) | Veredicto |
|---|---|---|---|
| Mensajes (NDJSON) | user/assistant/result/control_* | idénticos | REUSAR adapter entero |
| Transporte | WS frames bidi | SSE-recv + REST-POST | REESCRIBIR |
| Envelope | frames sueltos | event_id + batch + delivery acks | NUEVO |
| Handshake | ninguno | PUT /worker + epoch + auth | NUEVO |
Para la sesión de implementación. El capture server de diagnóstico (/tmp/ccr-server.ts) es la referencia funcional mínima.
Verificado que no hay flag de escape para forzar WS en 2.1.185 (el factory no lo expone). La única alternativa a implementar CCR es pinear el binary a una versión pre-CCR — deuda: pierde updates, frágil con auto-update, insostenible para spawn-plane en workspaces VPS. El fix correcto es servir CCR.