feat: scaffold claude_wire — respuesta de claude interceptando el SSE de la red
Obtiene el texto del modelo interceptando el stream SSE de api.anthropic.com /v1/messages con un mitmproxy, en vez de parsear el render de la terminal. Dirige la TUI interactiva real (NUNCA claude -p) por el proxy con claude_pipe, y emite el texto exacto token a token como NDJSON. Compone tee_anthropic_sse_py_cybersecurity (addon mitmproxy). Corta por message_stop (sin idle ciego): ~9s vs ~15s de parsear la TUI, y texto exacto sin artefactos. Validado end-to-end contra claude real.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
claude_wire
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: claude_wire
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.1.0
|
||||
description: "Obtiene la respuesta de claude interceptando el stream SSE del modelo en la red (api.anthropic.com /v1/messages) en vez de parsear el render de la terminal. Dirige la TUI interactiva real (NUNCA claude -p) a traves de un mitmproxy que teea el SSE, y emite el texto exacto del modelo token a token como NDJSON. Mas rapido que parsear la TUI (corta por message_stop, sin idle ciego) y exacto (sin artefactos del render)."
|
||||
tags: [cli, claude, mitmproxy, sse, streaming, wire, web-proxy]
|
||||
uses_functions:
|
||||
- tee_anthropic_sse_py_cybersecurity
|
||||
uses_types: []
|
||||
framework: ""
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/claude_wire"
|
||||
icon:
|
||||
phosphor: "wifi-high"
|
||||
accent: "#10b981"
|
||||
e2e_checks:
|
||||
- id: build
|
||||
cmd: "go build -o claude_wire ."
|
||||
timeout_s: 60
|
||||
- id: needs_prompt
|
||||
cmd: "./claude_wire 2>&1 || true"
|
||||
expect_stdout_contains: "no prompt"
|
||||
timeout_s: 10
|
||||
---
|
||||
|
||||
# claude_wire
|
||||
|
||||
## Que hace
|
||||
|
||||
Devuelve la respuesta de `claude` **interceptando el stream del modelo en la red**, no parseando
|
||||
el render de la terminal. Dirige la TUI interactiva real de `claude` (jamas `claude -p`) a traves
|
||||
de un mitmproxy que captura el SSE de `POST api.anthropic.com/v1/messages`, y emite el texto
|
||||
exacto del modelo, token a token, como NDJSON.
|
||||
|
||||
Es la culminacion de la exploracion: parsear el render (`claude_pipe`) es heuristico y lento
|
||||
(warmup + idle ciegos, artefactos del spinner, truncacion por scroll). Interceptar la red da el
|
||||
texto **exacto** del modelo, en **streaming real**, y sabe **exactamente** cuando termina
|
||||
(`message_stop`), eliminando el idle ciego.
|
||||
|
||||
## Como funciona
|
||||
|
||||
```
|
||||
mitmdump + tee_anthropic_sse ── captura el SSE de /v1/messages → NDJSON
|
||||
claude_pipe (PTY, dirige la TUI) ── lanza claude interactivo y teclea el prompt
|
||||
claude_wire (este runner) ── lee el NDJSON del proxy, emite text_delta + result,
|
||||
mata todo en cuanto llega message_stop
|
||||
```
|
||||
|
||||
1. Arranca `mitmdump` con el addon `tee_anthropic_sse_py_cybersecurity` (`FN_WIRE_ONLY_TOOLS=1`
|
||||
para aislar la respuesta principal de las llamadas auxiliares de titulo/clasificador).
|
||||
2. Dirige la TUI interactiva con `claude_pipe` por el proxy (`HTTPS_PROXY` + `NODE_EXTRA_CA_CERTS`
|
||||
apuntando a la CA de mitmproxy). El output parseado de `claude_pipe` se ignora — solo se usa
|
||||
para lanzar claude y teclear el prompt.
|
||||
3. Lee el NDJSON que emite el addon, sigue el primer stream con `has_tools`, emite cada
|
||||
`text_delta`, y al `message_stop` emite el `result` y mata mitmdump + claude_pipe.
|
||||
|
||||
El texto sale **del cable**, no de la pantalla.
|
||||
|
||||
## Prerrequisitos
|
||||
|
||||
- `mitmproxy` instalado (`mitmdump` en el PATH): `uv tool install mitmproxy`.
|
||||
- La CA de mitmproxy generada y confiada: arrancar `mitmdump` una vez crea
|
||||
`~/.mitmproxy/mitmproxy-ca-cert.pem`. claude la acepta via `NODE_EXTRA_CA_CERTS`.
|
||||
- El binario `claude_pipe` compilado (driver de la TUI): `apps/claude_pipe/claude_pipe`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
cd apps/claude_wire
|
||||
go build -o claude_wire .
|
||||
|
||||
./claude_wire --prompt "di tres palabras: uno dos tres" --cwd /home/enmanuel/fn_registry
|
||||
# {"type":"text_delta","text":"T"}
|
||||
# {"type":"text_delta","text":"res palabras: uno dos tres."}
|
||||
# {"type":"result","subtype":"success","result":"Tres palabras: uno dos tres."}
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Que hace |
|
||||
|---|---|---|
|
||||
| `--prompt` | — | Prompt a enviar (o arg posicional al final). |
|
||||
| `--cwd` | `~/fn_registry` | Directorio donde corre claude (MCP aprobados → sin dialogo de arranque). |
|
||||
| `--port` | `8901` | Puerto del mitmproxy. |
|
||||
| `--root` | `~/fn_registry` | Raiz del registry (para localizar el addon por defecto). |
|
||||
| `--addon` | `<root>/python/functions/cybersecurity/tee_anthropic_sse.py` | Addon mitmproxy. |
|
||||
| `--ca` | `~/.mitmproxy/mitmproxy-ca-cert.pem` | CA de mitmproxy para `NODE_EXTRA_CA_CERTS`. |
|
||||
| `--pipe` | `apps/claude_pipe/claude_pipe` | Binario que dirige la TUI. |
|
||||
| `--warmup` | `5s` | Espera de `claude_pipe` para que cargue la TUI antes de teclear. |
|
||||
| `--max` | `120s` | Timeout duro. |
|
||||
|
||||
## Comparativa (por que existe)
|
||||
|
||||
| | `claude_pipe` (parsear TUI) | `claude_wire` (interceptar red) |
|
||||
|---|---|---|
|
||||
| Texto | heuristico, artefactos | **exacto, byte a byte** |
|
||||
| Streaming | snapshots ~150ms | **token real (content_block_delta)** |
|
||||
| Fin de respuesta | idle ciego (4s) | **message_stop exacto** |
|
||||
| Latencia medida | ~15s | **~9s** |
|
||||
| Robustez | fragil (UI cambia) | **protocolo API estable** |
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando quieras el texto exacto del modelo desde la TUI interactiva, en streaming real, sin
|
||||
parsear el render ni pagar el idle ciego.
|
||||
- Como backend de un chat: emite el mismo NDJSON que `claude_pipe --stream`, mas rapido y exacto.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere mitmproxy + CA confiada** (`NODE_EXTRA_CA_CERTS`). Si claude empezara a hacer TLS
|
||||
pinning, dejaria de funcionar (hoy no lo hace).
|
||||
- **Depende de `claude_pipe`** como driver de la TUI (el PTY). No reimplementa el pilotaje.
|
||||
- **Sigue heredando el warmup** de `claude_pipe` (esperar a que la TUI cargue antes de teclear);
|
||||
ahi esta el grueso de la latencia restante. Una deteccion de "TUI lista" lo reduciria mas.
|
||||
- **Una interaccion dispara varias /v1/messages** (respuesta + titulo/clasificador en haiku); el
|
||||
addon filtra por `has_tools` para seguir solo la principal.
|
||||
- **Es trafico de tu propia cuenta y maquina** — observabilidad local, no acceso remoto.
|
||||
- **Linux/Unix** (PTY POSIX heredado de `claude_pipe`).
|
||||
@@ -0,0 +1,241 @@
|
||||
// Command claude_wire gets claude's answer by intercepting the model's network
|
||||
// stream — the SSE response from api.anthropic.com — instead of parsing the
|
||||
// terminal render. It drives the real interactive claude TUI (never `claude -p`)
|
||||
// through a mitmproxy that tees the /v1/messages SSE, and emits the exact model
|
||||
// text token by token as NDJSON.
|
||||
//
|
||||
// Why this beats parsing the TUI render:
|
||||
// - Exact text, byte for byte (no heuristics, no spinner artifacts, no scroll
|
||||
// truncation).
|
||||
// - Real token-level streaming (the API's content_block_delta events).
|
||||
// - No blind idle wait: the message_stop event tells us precisely when the
|
||||
// answer finished, so there is no 4s idle tail.
|
||||
// - Stable protocol (Anthropic SSE) instead of a UI that changes between
|
||||
// claude versions.
|
||||
//
|
||||
// Pipeline:
|
||||
//
|
||||
// mitmdump + tee_anthropic_sse addon ── captures /v1/messages SSE → NDJSON
|
||||
// claude_pipe (drives the TUI via PTY) ── sends the prompt, keeps claude alive
|
||||
// this runner ── reads the proxy NDJSON, emits text_delta
|
||||
// + result, kills everything on message_stop
|
||||
//
|
||||
// claude_wire does NOT use `claude -p`. The TUI is driven exactly as a human would;
|
||||
// the text is read off the wire, not off the screen.
|
||||
//
|
||||
// Output (NDJSON, same shape as claude_pipe --stream):
|
||||
//
|
||||
// {"type":"text_delta","text":"..."}
|
||||
// {"type":"result","subtype":"success","is_error":false,"result":"<full answer>"}
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wireEvent is one NDJSON line emitted by the tee_anthropic_sse addon.
|
||||
type wireEvent struct {
|
||||
Type string `json:"type"`
|
||||
StreamID int `json:"stream_id"`
|
||||
Model string `json:"model"`
|
||||
HasTools bool `json:"has_tools"`
|
||||
Text string `json:"text"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
// outEvent is one NDJSON line this runner emits to its own stdout.
|
||||
type outEvent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
prompt = arg("--prompt", "")
|
||||
cwd = arg("--cwd", "/home/enmanuel/fn_registry")
|
||||
port = arg("--port", "8901")
|
||||
root = arg("--root", "/home/enmanuel/fn_registry")
|
||||
addon = arg("--addon", "")
|
||||
caPath = arg("--ca", os.Getenv("HOME")+"/.mitmproxy/mitmproxy-ca-cert.pem")
|
||||
pipeBin = arg("--pipe", "/home/enmanuel/fn_registry/apps/claude_pipe/claude_pipe")
|
||||
warmup = arg("--warmup", "5s")
|
||||
maxStr = arg("--max", "120s")
|
||||
)
|
||||
// The prompt may also be a trailing positional arg.
|
||||
if prompt == "" {
|
||||
if p := positional(); p != "" {
|
||||
prompt = p
|
||||
}
|
||||
}
|
||||
if prompt == "" {
|
||||
fmt.Fprintln(os.Stderr, "claude_wire: no prompt (use --prompt or a positional arg)")
|
||||
os.Exit(2)
|
||||
}
|
||||
if addon == "" {
|
||||
addon = filepath.Join(root, "python/functions/cybersecurity/tee_anthropic_sse.py")
|
||||
}
|
||||
|
||||
maxDur, err := time.ParseDuration(maxStr)
|
||||
if err != nil {
|
||||
maxDur = 120 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), maxDur)
|
||||
defer cancel()
|
||||
|
||||
// 1. Start mitmdump with the SSE tee addon. FN_WIRE_ONLY_TOOLS isolates the
|
||||
// main Claude Code response (has_tools) from title/classifier calls.
|
||||
mitm := exec.CommandContext(ctx, "mitmdump", "-p", port, "-s", addon, "-q")
|
||||
mitm.Env = append(os.Environ(), "FN_WIRE_ONLY_TOOLS=1")
|
||||
mitm.Stderr = os.Stderr
|
||||
ndjson, err := mitm.StdoutPipe()
|
||||
if err != nil {
|
||||
fail("mitmdump stdout pipe", err)
|
||||
}
|
||||
if err := mitm.Start(); err != nil {
|
||||
fail("start mitmdump", err)
|
||||
}
|
||||
defer kill(mitm)
|
||||
|
||||
// 2. Wait for the proxy to listen.
|
||||
if !waitPort("127.0.0.1:"+port, 10*time.Second) {
|
||||
fail("proxy did not come up", fmt.Errorf("port %s", port))
|
||||
}
|
||||
|
||||
// 3. Drive the interactive claude TUI through the proxy with claude_pipe. Its
|
||||
// own parsed output is irrelevant here — we only need it to launch claude
|
||||
// and type the prompt. A long idle keeps it from cutting before message_stop;
|
||||
// we kill it as soon as the wire reports the answer is done.
|
||||
pipe := exec.CommandContext(ctx, pipeBin,
|
||||
"--cwd", cwd, "--warmup", warmup, "--idle", "30s", "--max", maxStr,
|
||||
"--format", "text", prompt)
|
||||
pipe.Env = append(os.Environ(),
|
||||
"HTTPS_PROXY=http://127.0.0.1:"+port,
|
||||
"HTTP_PROXY=http://127.0.0.1:"+port,
|
||||
"NODE_EXTRA_CA_CERTS="+caPath,
|
||||
"SSL_CERT_FILE="+caPath,
|
||||
"REQUESTS_CA_BUNDLE="+caPath,
|
||||
)
|
||||
pipe.Stdout = nil
|
||||
pipe.Stderr = nil
|
||||
if err := pipe.Start(); err != nil {
|
||||
fail("start claude_pipe", err)
|
||||
}
|
||||
defer kill(pipe)
|
||||
|
||||
// 4. Read the proxy NDJSON. The addon already filtered to the main stream, so
|
||||
// we follow the first stream we see and stop at its message_stop.
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
sc := bufio.NewScanner(ndjson)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var answer strings.Builder
|
||||
mainStream := 0
|
||||
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var ev wireEvent
|
||||
if json.Unmarshal([]byte(line), &ev) != nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Type {
|
||||
case "message_start":
|
||||
if mainStream == 0 && ev.HasTools {
|
||||
mainStream = ev.StreamID
|
||||
}
|
||||
case "text_delta":
|
||||
if ev.StreamID == mainStream {
|
||||
answer.WriteString(ev.Text)
|
||||
_ = enc.Encode(outEvent{Type: "text_delta", Text: ev.Text})
|
||||
}
|
||||
case "message_stop":
|
||||
if ev.StreamID == mainStream {
|
||||
_ = enc.Encode(outEvent{
|
||||
Type: "result",
|
||||
Subtype: "success",
|
||||
IsError: answer.Len() == 0,
|
||||
Result: answer.String(),
|
||||
})
|
||||
return // defers kill mitmdump + claude_pipe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended without a message_stop (timeout / claude died). Emit whatever
|
||||
// we have so the consumer is not left hanging.
|
||||
_ = enc.Encode(outEvent{
|
||||
Type: "result",
|
||||
Subtype: "incomplete",
|
||||
IsError: answer.Len() == 0,
|
||||
Result: answer.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// --- tiny flag/util helpers (no external deps) ---
|
||||
|
||||
func arg(name, def string) string {
|
||||
for i, a := range os.Args[1:] {
|
||||
if a == name && i+2 <= len(os.Args)-1 {
|
||||
return os.Args[i+2]
|
||||
}
|
||||
if strings.HasPrefix(a, name+"=") {
|
||||
return strings.TrimPrefix(a, name+"=")
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// positional returns the last argument if it is not a flag or a flag value.
|
||||
func positional() string {
|
||||
args := os.Args[1:]
|
||||
for i := len(args) - 1; i >= 0; i-- {
|
||||
a := args[i]
|
||||
if strings.HasPrefix(a, "--") {
|
||||
return ""
|
||||
}
|
||||
// Skip if this is the value of a preceding flag.
|
||||
if i > 0 && strings.HasPrefix(args[i-1], "--") && !strings.Contains(args[i-1], "=") {
|
||||
continue
|
||||
}
|
||||
return a
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitPort(addr string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
return true
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kill(c *exec.Cmd) {
|
||||
if c != nil && c.Process != nil {
|
||||
_ = c.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func fail(what string, err error) {
|
||||
fmt.Fprintf(os.Stderr, "claude_wire: %s: %v\n", what, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user