# 0036 — Agente Claude Code completo con streaming de progreso **Estado:** pendiente ## Objetivo Transformar el provider `claude-code` para soportar streaming en tiempo real, permitiendo que los agentes que lo usan muestren en Matrix el progreso de uso de herramientas (e.g. "🔧 Ejecutando Bash: ls..." → "📝 Editando file.go..." → respuesta final). El usuario debe ver al agente trabajar como una sesion completa de Claude Code, no solo esperar en silencio hasta que termine. ## Contexto - El provider `claude-code` vive en `shell/llm/claudecode.go` y ejecuta `claude -p --output-format json` como subproceso. - Actualmente usa `bytes.Buffer` para capturar stdout completo, espera a que el proceso termine, y luego parsea el JSON final. Durante todo este tiempo el usuario solo ve el typing indicator. - Claude CLI soporta `--output-format stream-json` que emite lineas JSON individuales conforme trabaja: eventos de `tool_use`, `tool_result`, `text` parcial, y `result` final. - El cliente Matrix (`shell/matrix/client.go`) tiene `SendMarkdown`, `SendReplyMarkdown`, `SendThreadMarkdown` pero **no tiene** `EditMessage` (m.replace). Las relaciones `m.relates_to` ya se manejan para threads. - El runner de effects (`shell/effects/runner.go`) ejecuta `[]decision.Action` pero no tiene concepto de mensajes progresivos. - `ClaudeCodeCfg` en `internal/config/schema.go` ya tiene campos para binary, timeout, tools, working_dir, permission_mode, model, etc. No tiene campos de streaming. ## Arquitectura ### Patron pure core / impure shell - `pkg/llm/types.go` — tipos puros para streaming events (solo datos, sin I/O) - `shell/llm/claudecode.go` — impuro: pipe de stdout, parsing de stream JSON, invocacion de callbacks - `shell/matrix/client.go` — impuro: nuevo metodo `EditMessage` usando m.replace - `devagents/handler.go` — composicion: conectar progress reporter cuando el provider es claude-code con streaming - `internal/config/schema.go` — datos puros: nuevos campos en `ClaudeCodeCfg` ### Fase 1 — Streaming del subproceso ``` claude -p --output-format stream-json │ ├── {"type":"tool_use", "tool":"Bash", "input":"ls -la"} ← tool empezando ├── {"type":"tool_result", "tool":"Bash", "output":"..."} ← tool terminó ├── {"type":"text", "content":"Analizando...", "partial":true} ← texto parcial └── {"type":"result", "result":"...", "usage":{...}} ← resultado final ``` Se parsea linea a linea y se emite un `StreamEvent` por cada linea. El caller recibe eventos via callback `StreamFunc`. ### Fase 2 — Mensajes progresivos en Matrix ``` StreamEvent(tool_use, "Bash", "ls -la") → sender.SendMarkdown("⏳ Procesando...") ← mensaje inicial → sender.EditMessage(eventID, "🔧 Bash: ls -la") ← editar con progreso StreamEvent(tool_use, "Read", "main.go") → sender.EditMessage(eventID, "📖 Read: main.go") ← editar de nuevo StreamEvent(result, content) → sender.EditMessage(eventID, content) ← reemplazar con resultado final ``` Un solo mensaje que se edita progresivamente. Evita spam de multiples mensajes. ### Fase 3 — Config y UX Nuevos campos en `ClaudeCodeCfg`: ```yaml claude_code: streaming: true # usar stream-json en vez de json show_tool_progress: true # mostrar progreso de tools en Matrix ``` ## Tareas ### Fase 1 — Streaming del subproceso - [ ] **1.1** Añadir tipos puros de streaming a `pkg/llm/types.go`: - `StreamEventKind` (string type): `StreamToolUse`, `StreamToolResult`, `StreamText`, `StreamResult`, `StreamError` - `StreamEvent` struct: `Kind StreamEventKind`, `ToolName string`, `ToolInput string`, `Content string`, `IsPartial bool`, `Error error` - `StreamFunc` callback type: `func(event StreamEvent)` - Estos tipos son datos puros, sin side effects — coherente con el resto de `pkg/llm/` - [ ] **1.2** Refactorizar `NewClaudeCodeComplete` para soportar modo streaming: - Nueva funcion `NewClaudeCodeStream(cfg, log) (CompleteFunc, StreamFunc setter)` o añadir `StreamFunc` al closure - Cuando `cfg.Streaming == true`: usar `cmd.StdoutPipe()` en vez de `bytes.Buffer` - Leer stdout linea a linea con `bufio.Scanner` - Parsear cada linea JSON e invocar `StreamFunc(event)` si no es nil - Acumular el resultado final para retornarlo como `CompletionResponse` normal (compatibilidad) - Si `cfg.Streaming == false` o `StreamFunc == nil`: mantener comportamiento actual (buffered) - [ ] **1.3** Implementar parser de eventos stream-json: - Investigar formato exacto de `claude --output-format stream-json` (ejecutar `claude --help` para confirmar) - Funcion pura `parseStreamLine(line []byte) (StreamEvent, error)` en `shell/llm/claudecode.go` - Mapear los tipos de evento del CLI a `StreamEventKind` - Manejar lineas vacias y JSON malformado sin crash - [ ] **1.4** Preservar cleanup de process group: - `cmd.SysProcAttr`, `cmd.Cancel` y el kill post-Run deben funcionar identicamente en modo streaming - El pipe de stdout debe cerrarse correctamente cuando el contexto se cancela - [ ] **1.5** Tests unitarios para parsing de eventos: - Test `parseStreamLine` con samples de cada tipo de evento - Test de streaming completo con mock de stdout (io.Pipe) - Test de fallback a modo buffered cuando streaming == false - Test de cancelacion via contexto durante streaming ### Fase 2 — Mensajes progresivos en Matrix - [ ] **2.1** Añadir `EditMessage` a `shell/matrix/client.go`: - Metodo `EditMessage(ctx, roomID, eventID, markdown string) error` - Usar `m.relates_to` con `rel_type: "m.replace"` y `event_id: ` - Incluir `m.new_content` con el nuevo body (formatted_body para markdown) - Retornar error si el eventID original no existe o la edicion falla - [ ] **2.2** Añadir `SendMarkdownWithID` o modificar `SendMarkdown` para retornar `eventID`: - El progress reporter necesita el eventID del mensaje inicial para poder editarlo - Evaluar: nuevo metodo que retorna `(id.EventID, error)` vs cambiar firma existente (breaking change) - Recomendacion: nuevo metodo `SendMarkdownGetID(ctx, roomID, markdown) (string, error)` para no romper callers existentes - [ ] **2.3** Implementar progress reporter en `shell/effects/progress.go` (NEW): - `ProgressReporter` struct con: sender (interface), roomID, eventID del mensaje actual - Metodo `HandleEvent(event StreamEvent)` que: - En primer evento: envia mensaje inicial "⏳ Procesando..." y guarda eventID - En `StreamToolUse`: edita a "🔧 {ToolName}: {ToolInput truncado a 80 chars}" - En `StreamToolResult`: edita a "✅ {ToolName} completado" - En `StreamText` parcial: ignora (demasiadas ediciones) - En `StreamResult`: edita con el contenido final completo - En `StreamError`: edita con "❌ Error: {mensaje}" - Rate limiter interno: maximo 1 edit por segundo para evitar rate limits de Matrix - Formateo con emojis configurable via config - [ ] **2.4** Conectar progress reporter en `devagents/handler.go`: - En el flujo LLM del handler, si el provider es `claude-code` y `cfg.ClaudeCode.Streaming`: - Crear `ProgressReporter` con el sender y roomID - Pasar `reporter.HandleEvent` como `StreamFunc` al provider - El resultado final llega como `CompletionResponse` normal (el handler no cambia su flujo) - Si streaming deshabilitado: flujo actual sin cambios - [ ] **2.5** Tests del progress reporter: - Test con mock sender que registra llamadas a SendMarkdown y EditMessage - Verificar secuencia: Send → Edit → Edit → Edit(final) - Verificar rate limiting: multiples eventos rapidos → solo 1 edit/segundo - Verificar truncado de ToolInput largo ### Fase 3 — Config y polish - [ ] **3.1** Añadir campos a `ClaudeCodeCfg` en `internal/config/schema.go`: ```go Streaming bool `yaml:"streaming"` // use stream-json output format (default false) ShowToolProgress bool `yaml:"show_tool_progress"` // show tool progress via message edits (default false) ``` - [ ] **3.2** Actualizar `buildClaudeArgs` para usar `--output-format stream-json` cuando `cfg.Streaming == true` - [ ] **3.3** Actualizar templates de config (`agents/_template/config.yaml`) con opciones de streaming (comentadas): ```yaml # claude_code: # streaming: true # streaming en tiempo real (default: false) # show_tool_progress: true # mostrar progreso de tools en Matrix ``` - [ ] **3.4** Documentar en el system prompt template (`.claude/templates/security-prompt.md` o README) que el agente puede mostrar progreso de trabajo ### Fase 4 — Tests de integracion y cleanup - [ ] **4.1** Test de integracion: simular un flujo completo stream → progress reporter → mock sender - [ ] **4.2** Verificar que agentes con `streaming: false` no se ven afectados (regression) - [ ] **4.3** Actualizar `CLAUDE.md` si se añaden nuevas secciones de arquitectura relevantes ## Ejemplo de uso ### Config del agente ```yaml # agents/asistente-2/config.yaml llm: primary: provider: claude-code claude_code: working_dir: "/tmp/claude-agents/asistente-2" permission_mode: "bypassPermissions" streaming: true show_tool_progress: true ``` ### Flujo en Matrix ``` Usuario: Analiza la estructura de este proyecto y dame un resumen Bot (mensaje inicial): ⏳ Procesando... Bot (edit 1, ~2s despues): 🔧 Bash: find . -name '*.go' | head -20 Bot (edit 2, ~4s despues): 📖 Read: cmd/launcher/main.go Bot (edit 3, ~6s despues): 🔧 Bash: wc -l pkg/**/*.go Bot (edit final, ~15s despues): ## Estructura del proyecto El proyecto es un monorepo Go con 45 archivos .go organizados en: - `pkg/` — core puro con tipos y reglas de decision - `shell/` — I/O impuro (Matrix, LLM, SSH) - `agents/` — definiciones de agentes [... respuesta completa ...] ``` ### Sin streaming (comportamiento actual) ``` Usuario: Analiza la estructura de este proyecto y dame un resumen [typing indicator durante 15 segundos] Bot: ## Estructura del proyecto [... respuesta completa ...] ``` ## Decisiones de diseño 1. **Edit en vez de multiples mensajes**: usar `m.replace` para editar un solo mensaje progresivamente evita spam en el chat. Si el homeserver no soporta ediciones, el fallback es enviar solo el resultado final (sin progreso intermedio). 2. **StreamFunc como callback, no como channel**: un callback `func(StreamEvent)` es mas simple que un channel y no requiere goroutine de consumo. El caller decide que hacer con cada evento sincrónicamente. 3. **Rate limit de 1 edit/segundo**: Matrix homeservers tipicamente tienen rate limits de 5-10 requests/segundo. Con 1 edit/segundo dejamos margen para otros mensajes del agente y evitamos 429 Too Many Requests. 4. **Tipos puros en `pkg/llm/`**: `StreamEvent` y `StreamFunc` son tipos de datos sin I/O. Estan en el package puro porque describen el contrato entre el provider (impuro) y el consumer (impuro). El tipo en si no tiene side effects. 5. **Backward compatible**: `streaming: false` (default) mantiene el comportamiento actual exacto. La refactorizacion de `claudecode.go` no cambia la firma de `CompleteFunc` — el streaming es un side channel via `StreamFunc`. 6. **`SendMarkdownGetID` nuevo en vez de cambiar firma**: añadir un metodo nuevo que retorne el eventID evita romper todos los callers existentes de `SendMarkdown`. El progress reporter usa el metodo nuevo; el resto del codigo no cambia. ## Prerequisitos - Verificar que `claude --output-format stream-json` existe y documentar el formato exacto de sus eventos. Si el CLI no soporta `stream-json`, investigar alternativas: - `--output-format json` con lectura line-buffered del proceso (puede no emitir JSON parcial) - Parsear stderr del proceso para eventos de progreso - Usar la API directa de Anthropic con streaming en vez del CLI - El campo `ClaudeCodeCfg` ya existe en el schema — solo se añaden campos nuevos. - `m.relates_to` con `m.replace` ya se parsea para threads en el listener — la logica de edicion es el inverso (enviar en vez de recibir). ## Riesgos | Riesgo | Mitigacion | |--------|------------| | Formato de `stream-json` cambia entre versiones del CLI | Parseo defensivo: lineas no reconocidas se ignoran silenciosamente, el resultado final siempre se captura | | Rate limits de Matrix en message edits | Rate limiter de 1 edit/segundo configurable; si falla un edit, se loguea warning y se continua | | Tareas largas (>5min) timeout del subproceso | Ya manejado via `context.WithTimeout` y `cfg.Timeout`. El progress reporter muestra el ultimo estado antes del timeout | | Homeserver no soporta m.replace | Detectar error 400 en primer edit; si falla, desactivar ediciones para esa sesion y enviar solo resultado final | | Stdout pipe se bloquea si no se lee | `bufio.Scanner` en goroutine consume stdout continuamente; el pipe no se bloquea mientras el scanner este activo | | `claude` CLI no esta instalado o no soporta stream-json | Fallback a modo buffered con warning en logs. La feature es opt-in (`streaming: false` por defecto) |