commit d9982d853d1f956d0c16460465b08aa8c0b6225c Author: agent Date: Thu Jun 4 00:40:29 2026 +0200 feat: scaffold claude_session — daemon de sesion claude caliente (NDJSON) Daemon de larga vida que mantiene una TUI claude interactiva viva y responde prompts en ~2.7s, embebible como subproceso via NDJSON por stdin/stdout. Arranca mitmproxy (addon tee_anthropic_sse) + claude TUI en PTY (creack/pty directo, persistente) una vez. Cada prompt se teclea en la TUI viva; la respuesta se lee del SSE de la red (exacta, corta en message_stop). El cold start (~7s) se paga una vez; los siguientes mensajes ~2.7s, con memoria entre turnos. Protocolo: send/restart/shutdown -> ready/text_delta/result/restarted. Validado: 2.7s por mensaje en caliente (vs 15s parseando TUI, vs 9s one-shot), restart relanza la conversacion. Reusa tee_anthropic_sse_py_cybersecurity + vt_render_go_tui. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59276f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +claude_session +*.db-shm +*.db-wal diff --git a/app.md b/app.md new file mode 100644 index 0000000..08a1d3c --- /dev/null +++ b/app.md @@ -0,0 +1,154 @@ +--- +name: claude_session +lang: go +domain: infra +version: 0.1.0 +description: "Daemon de larga vida que mantiene una sesion claude (TUI interactiva) caliente y responde prompts rapido, pensado para embeber en una app como subproceso via NDJSON por stdin/stdout. Arranca mitmproxy + claude TUI una vez; cada prompt se teclea en la TUI viva y la respuesta se lee del SSE de la red (exacta, corta en message_stop). Primer mensaje paga el arranque (~7s); los siguientes ~2-3s, con memoria entre turnos. Comando restart para reiniciar la conversacion." +tags: [cli, claude, daemon, session, ndjson, mitmproxy, web-proxy, streaming] +uses_functions: + - tee_anthropic_sse_py_cybersecurity + - vt_render_go_tui +uses_types: [] +framework: "" +entry_point: "main.go" +dir_path: "apps/claude_session" +icon: + phosphor: "lightning" + accent: "#f59e0b" +e2e_checks: + - id: build + cmd: "go build -o claude_session ." + timeout_s: 60 + - id: bad_cmd + cmd: "echo '{\"cmd\":\"bogus\"}' | timeout 30 ./claude_session 2>/dev/null | grep -q 'unknown cmd' && echo ok || true" + expect_stdout_contains: "ok" + timeout_s: 40 +--- + +# claude_session + +## Que hace + +Daemon que mantiene una sesion `claude` (TUI interactiva real, **nunca `claude -p`**) **caliente** +y responde prompts en ~2-3s. Pensado para embeberse en una app como subproceso de larga vida, +controlado por NDJSON por stdin/stdout. + +Arranca **una vez** un mitmproxy (addon `tee_anthropic_sse`) y la TUI de claude en un PTY, y los +mantiene vivos. Cada prompt se teclea en la TUI viva; la respuesta se lee **del SSE de la red** +(exacta, token a token, cortada por `message_stop`). El cold start (~7s) se paga una sola vez al +arrancar; los mensajes siguientes solo pagan la generacion, y la conversacion **mantiene contexto +entre turnos**. + +## Latencia (medida) + +| Camino | por mensaje | +|---|---| +| `claude_pipe` (parsear render TUI) | ~15s | +| `claude_wire` (interceptar red, one-shot) | ~9s | +| **`claude_session` (daemon caliente)** | **~2.7s** (+ ~7s de arranque, una vez) | + +## Protocolo NDJSON + +Un objeto JSON por linea. + +**stdin (comandos):** + +```json +{"cmd":"send","prompt":"hola"} +{"cmd":"restart"} +{"cmd":"shutdown"} +``` + +**stdout (eventos):** + +```json +{"type":"ready"} +{"type":"text_delta","text":"H"} +{"type":"text_delta","text":"ola"} +{"type":"result","result":"Hola"} +{"type":"restarted"} +{"type":"error","message":"..."} +``` + +- `send`: teclea el prompt en la TUI viva, emite `text_delta` segun llegan del SSE, luego `result` + y un `ready` cuando la TUI vuelve al input box. +- `restart`: mata y relanza la TUI (conversacion **nueva, sin memoria**), mantiene el mitmproxy. + Emite `restarted` + `ready`. +- `shutdown`: mata todo y termina. + +## Arquitectura + +``` +mitmdump + tee_anthropic_sse (persistente — capta el SSE de /v1/messages) +claude TUI en PTY (creack/pty) (persistente — NO se mata entre prompts) +loop NDJSON (teclea prompts, lee el wire, multiplexa por message_stop) +``` + +Reusa `tee_anthropic_sse_py_cybersecurity` (addon SSE) y `vt_render_go_tui` (render del PTY para +detectar readiness del input box). + +## Embeber en una app + +La app arranca el binario como subproceso de larga vida y habla por sus stdin/stdout: + +```python +import subprocess, json +p = subprocess.Popen(["claude_session", "--cwd", "/home/enmanuel/fn_registry"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1) +# esperar {"type":"ready"} +def send(prompt): + p.stdin.write(json.dumps({"cmd": "send", "prompt": prompt}) + "\n"); p.stdin.flush() + for line in p.stdout: + ev = json.loads(line) + if ev["type"] == "result": + return ev["result"] +``` + +## Ejemplo (NDJSON por stdin) + +```bash +cd apps/claude_session && go build -o claude_session . + +printf '%s\n' \ + '{"cmd":"send","prompt":"di solo OK"}' \ + '{"cmd":"send","prompt":"di solo DOS"}' \ + '{"cmd":"shutdown"}' \ +| ./claude_session --cwd /home/enmanuel/fn_registry +# {"type":"ready"} +# {"type":"text_delta","text":"O"} ... {"type":"result","result":"OK"} {"type":"ready"} +# {"type":"text_delta","text":"D"} ... {"type":"result","result":"DOS"} {"type":"ready"} +``` + +## Flags + +| Flag | Default | Que hace | +|---|---|---| +| `--cwd` | `~/fn_registry` | Directorio de la sesion claude (MCP aprobados). | +| `--port` | `8901` | Puerto del mitmproxy. | +| `--root` | `~/fn_registry` | Raiz del registry (para localizar el addon). | +| `--addon` | `/python/functions/cybersecurity/tee_anthropic_sse.py` | Addon mitmproxy. | +| `--ca` | `~/.mitmproxy/mitmproxy-ca-cert.pem` | CA de mitmproxy. | +| `--bin` | `claude` | Binario claude. | +| `--warmup` | `12s` | Espera maxima a que la TUI este lista. | + +## Prerrequisitos + +- `mitmproxy` (`mitmdump` en PATH) + CA generada y confiada via `NODE_EXTRA_CA_CERTS`. +- `claude` en el PATH. + +## Gotchas + +- **Readiness post-restart prematura**: tras `restart`, el `ready` puede emitirse antes de que la + TUI termine de cargar (detecta el input box `❯` pronto). El primer `send` tras un restart puede + tardar mas (~5-6s en vez de ~2.7s) porque el input se teclea mientras claude aun arranca (se + bufferea, no se pierde). Refinamiento pendiente: readiness mas estricta (esperar que cese el + trafico de arranque en el proxy). +- **Sesion secuencial**: un prompt a la vez. No mandes un `send` mientras otro genera. +- **Memoria entre turnos**: la TUI viva acumula la conversacion (es una feature). Usa `restart` + para empezar de cero. +- **Requiere mitmproxy + CA** (`NODE_EXTRA_CA_CERTS`); depende de que claude no haga TLS pinning + (hoy no lo hace). +- **Una interaccion dispara varias /v1/messages**; el addon filtra por `has_tools` para seguir la + respuesta principal. +- **Linux/Unix** (PTY POSIX). +- **Es trafico de tu propia cuenta y maquina** — observabilidad local. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c3980d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module claude_session + +go 1.25.0 + +require ( + fn-registry v0.0.0 + github.com/creack/pty v1.1.24 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect +) + +replace fn-registry => ../../ diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..340abd4 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/main.go b/main.go new file mode 100644 index 0000000..622c13e --- /dev/null +++ b/main.go @@ -0,0 +1,381 @@ +// Command claude_session is a long-lived daemon that keeps an interactive claude +// TUI session hot and answers prompts fast, intended to be embedded in an app as a +// subprocess and driven over stdin/stdout with NDJSON. +// +// It starts a mitmproxy (with the tee_anthropic_sse addon) and a claude TUI in a +// PTY once, then keeps both alive. Each prompt is typed into the live TUI; the +// answer is read off the wire (the model's SSE), so it is exact and finishes at +// message_stop with no blind idle. Because the TUI stays alive, the first prompt +// pays the cold start (~7s) and subsequent prompts only pay generation (~2-3s), +// and the conversation keeps context across turns. +// +// Protocol (NDJSON, one JSON object per line): +// +// stdin (commands): +// {"cmd":"send","prompt":"..."} type a prompt, stream the answer +// {"cmd":"restart"} kill+relaunch the TUI (fresh conversation, proxy kept) +// {"cmd":"shutdown"} stop everything and exit +// stdout (events): +// {"type":"ready"} the TUI is ready for a prompt +// {"type":"text_delta","text":"..."} +// {"type":"result","result":"..."} +// {"type":"restarted"} +// {"type":"error","message":"..."} +// +// This daemon NEVER uses `claude -p`; it drives the real interactive TUI. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/creack/pty" + + "fn-registry/functions/tui" +) + +const ( + ptyRows = 40 + ptyCols = 120 +) + +// wireEvent is one NDJSON line emitted by the tee_anthropic_sse addon. +type wireEvent struct { + Type string `json:"type"` + StreamID int `json:"stream_id"` + HasTools bool `json:"has_tools"` + Text string `json:"text"` + StopReason string `json:"stop_reason"` +} + +// cmdIn is one NDJSON command read from stdin. +type cmdIn struct { + Cmd string `json:"cmd"` + Prompt string `json:"prompt"` +} + +// evOut is one NDJSON event written to stdout. +type evOut struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Result string `json:"result,omitempty"` + Message string `json:"message,omitempty"` +} + +type config struct { + cwd string + port string + addon string + ca string + bin string + warmupS time.Duration +} + +type daemon struct { + cfg config + mitm *exec.Cmd + wireCh chan wireEvent + + ptmx *os.File + claude *exec.Cmd + rawMu sync.Mutex + raw []byte + + outMu sync.Mutex + out *json.Encoder +} + +func main() { + cfg := config{} + flag.StringVar(&cfg.cwd, "cwd", "/home/enmanuel/fn_registry", "cwd for claude (MCP-approved repo)") + flag.StringVar(&cfg.port, "port", "8901", "mitmproxy port") + root := flag.String("root", "/home/enmanuel/fn_registry", "registry root (to locate the addon)") + flag.StringVar(&cfg.addon, "addon", "", "tee_anthropic_sse addon path") + flag.StringVar(&cfg.ca, "ca", os.Getenv("HOME")+"/.mitmproxy/mitmproxy-ca-cert.pem", "mitmproxy CA cert") + flag.StringVar(&cfg.bin, "bin", "claude", "claude binary") + warmup := flag.Duration("warmup", 12*time.Second, "max wait for the TUI to become ready") + flag.Parse() + cfg.warmupS = *warmup + if cfg.addon == "" { + cfg.addon = filepath.Join(*root, "python/functions/cybersecurity/tee_anthropic_sse.py") + } + + d := &daemon{cfg: cfg, out: json.NewEncoder(os.Stdout)} + + if err := d.startProxy(); err != nil { + d.emit(evOut{Type: "error", Message: "start proxy: " + err.Error()}) + os.Exit(1) + } + if err := d.startClaude(); err != nil { + d.emit(evOut{Type: "error", Message: "start claude: " + err.Error()}) + os.Exit(1) + } + if !d.waitReady(cfg.warmupS) { + d.emit(evOut{Type: "error", Message: "claude TUI did not become ready"}) + // keep going — the user can still try; but signal it + } + d.emit(evOut{Type: "ready"}) + + d.loop() +} + +// startProxy launches mitmdump with the SSE tee addon and starts a goroutine that +// parses its NDJSON stdout into wireCh. FN_WIRE_ONLY_TOOLS isolates the main reply. +func (d *daemon) startProxy() error { + cmd := exec.Command("mitmdump", "-p", d.cfg.port, "-s", d.cfg.addon, "-q") + cmd.Env = append(os.Environ(), "FN_WIRE_ONLY_TOOLS=1") + cmd.Stderr = os.Stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + d.mitm = cmd + d.wireCh = make(chan wireEvent, 256) + + go func() { + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var ev wireEvent + if json.Unmarshal([]byte(line), &ev) == nil { + d.wireCh <- ev + } + } + }() + + if !waitPort("127.0.0.1:"+d.cfg.port, 10*time.Second) { + return fmt.Errorf("proxy did not listen on %s", d.cfg.port) + } + return nil +} + +// startClaude launches the interactive claude TUI in a PTY, routed through the +// proxy, and starts a goroutine that accumulates the raw render into d.raw. +func (d *daemon) startClaude() error { + cmd := exec.Command(d.cfg.bin) + cmd.Dir = d.cfg.cwd + cmd.Env = append(os.Environ(), + "HTTPS_PROXY=http://127.0.0.1:"+d.cfg.port, + "HTTP_PROXY=http://127.0.0.1:"+d.cfg.port, + "NODE_EXTRA_CA_CERTS="+d.cfg.ca, + "SSL_CERT_FILE="+d.cfg.ca, + "REQUESTS_CA_BUNDLE="+d.cfg.ca, + ) + ptmx, err := pty.Start(cmd) + if err != nil { + return err + } + _ = pty.Setsize(ptmx, &pty.Winsize{Rows: ptyRows, Cols: ptyCols}) + d.ptmx = ptmx + d.claude = cmd + + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if n > 0 { + d.rawMu.Lock() + d.raw = append(d.raw, buf[:n]...) + // Cap the buffer so it does not grow without bound; keep the tail + // (the current screen lives at the end). + if len(d.raw) > 256*1024 { + d.raw = d.raw[len(d.raw)-128*1024:] + } + d.rawMu.Unlock() + } + if err != nil { + return + } + } + }() + return nil +} + +// screen renders the current PTY buffer to a 2D screen via vt_render. +func (d *daemon) screen() string { + d.rawMu.Lock() + raw := string(d.raw) + d.rawMu.Unlock() + return tui.VTRender(raw, ptyRows, ptyCols) +} + +// isReady reports whether the TUI shows an idle input box (ready for a prompt): +// the prompt marker is present and no generation spinner is active. +func isReady(screen string) bool { + if screen == "" { + return false + } + if strings.Contains(screen, "esc to interrupt") { + return false // generating + } + return strings.Contains(screen, "❯") +} + +// waitReady polls the rendered screen until the input box is idle or timeout. +func (d *daemon) waitReady(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if isReady(d.screen()) { + // small settle so the box is fully drawn + time.Sleep(250 * time.Millisecond) + return true + } + time.Sleep(200 * time.Millisecond) + } + return isReady(d.screen()) +} + +func (d *daemon) emit(ev evOut) { + d.outMu.Lock() + _ = d.out.Encode(ev) + d.outMu.Unlock() +} + +// drainWire empties any buffered wire events (residue from a previous turn). +func (d *daemon) drainWire() { + for { + select { + case <-d.wireCh: + default: + return + } + } +} + +// handleSend types the prompt into the live TUI and streams the answer read from +// the wire until message_stop. +func (d *daemon) handleSend(prompt string) { + if prompt == "" { + d.emit(evOut{Type: "error", Message: "empty prompt"}) + return + } + d.drainWire() + + // Type the prompt, settle, then Enter (a glued \r is treated as a newline). + _, _ = d.ptmx.Write([]byte(prompt)) + time.Sleep(450 * time.Millisecond) + _, _ = d.ptmx.Write([]byte("\r")) + + // Follow the next has_tools stream to its message_stop. + mainStream := 0 + var answer strings.Builder + timeout := time.After(120 * time.Second) + for { + select { + case ev := <-d.wireCh: + 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) + d.emit(evOut{Type: "text_delta", Text: ev.Text}) + } + case "message_stop": + if ev.StreamID == mainStream { + d.emit(evOut{Type: "result", Result: answer.String()}) + // Let the TUI redraw the idle input box before the next prompt. + d.waitReady(8 * time.Second) + d.emit(evOut{Type: "ready"}) + return + } + } + case <-timeout: + d.emit(evOut{Type: "error", Message: "timeout waiting for answer"}) + d.emit(evOut{Type: "ready"}) + return + } + } +} + +// handleRestart kills the claude TUI and relaunches it (fresh conversation). The +// proxy is kept alive. +func (d *daemon) handleRestart() { + if d.claude != nil && d.claude.Process != nil { + _ = d.claude.Process.Kill() + _, _ = d.claude.Process.Wait() + } + if d.ptmx != nil { + _ = d.ptmx.Close() + } + d.rawMu.Lock() + d.raw = nil + d.rawMu.Unlock() + + if err := d.startClaude(); err != nil { + d.emit(evOut{Type: "error", Message: "restart: " + err.Error()}) + return + } + d.waitReady(d.cfg.warmupS) + d.emit(evOut{Type: "restarted"}) + d.emit(evOut{Type: "ready"}) +} + +func (d *daemon) shutdown() { + if d.claude != nil && d.claude.Process != nil { + _ = d.claude.Process.Kill() + } + if d.mitm != nil && d.mitm.Process != nil { + _ = d.mitm.Process.Kill() + } +} + +// loop reads NDJSON commands from stdin and dispatches them. +func (d *daemon) loop() { + sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var c cmdIn + if json.Unmarshal([]byte(line), &c) != nil { + d.emit(evOut{Type: "error", Message: "bad command json"}) + continue + } + switch c.Cmd { + case "send": + d.handleSend(c.Prompt) + case "restart": + d.handleRestart() + case "shutdown": + d.shutdown() + return + default: + d.emit(evOut{Type: "error", Message: "unknown cmd: " + c.Cmd}) + } + } + d.shutdown() +} + +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 +} diff --git a/registry.db b/registry.db new file mode 100644 index 0000000..e69de29