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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
claude_session
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -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` | `<root>/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.
|
||||||
@@ -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 => ../../
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user