OpenCode como provider: 16 parches, dos bugs, una lección
Hoy fue un mal día con OpenCode. Tres horas, dieciséis parches sobre el adapter, dos bugs que parecían distintos y eran la misma frontera, y un agente en producción contestando errores con cara de querer renunciar. Este post lo escribimos en caliente — no para tirar a OpenCode, que hace algo difícil bien — sino para dejar documentado qué se rompe cuando metes un LLM-server out-of-process detrás de un harness, y por qué creíamos que era opcional cuando en realidad es estructural.
Si construyes un harness de agentes y miras la oferta de modelos en 2026, la tentación es obvia: multi-provider. Claude para producción, DeepSeek para tareas baratas, GPT cuando el cliente lo pide. Si tu harness solo habla con un proveedor, te perdiste el ahorro y la opcionalidad.
OpenCode promete exactamente eso: un proceso que expone un servidor HTTP con la misma interfaz para cualquier LLM. Levantas opencode serve --port=4096, mandas requests, y ya tienes DeepSeek o OpenRouter o Groq detrás del mismo adapter.
Lo metimos como segundo provider en NanoClaw — nuestro harness open source de agentes personales — junto al SDK in-process de Anthropic. La integración funcionó en staging. En producción, Ghosty —nuestro agente de WhatsApp— empezó a devolver errores en pareja:
Error: OpenCode event timeout (300000ms)
Error: OpenCode server exited with code 1 / Failed to start server on port 4096Siempre primero el timeout, después el port-bind failure. Las dos clases de bug que cuento abajo son consecuencia directa de una sola decisión arquitectónica — out-of-process — y los dieciséis parches que terminamos aplicando son distintas caras de defenderse de esa decisión sin abandonarla.
El setup
NanoClaw orquesta un container efímero por sesión donde corre un agent-runner. El runner soporta dos providers:
- Claude harness — usa
@anthropic-ai/claude-agent-sdkdirectamente, in-process. - OpenCode — lanza
opencode servecomo subproceso y se comunica vía HTTP+SSE enlocalhost:4096.
La diferencia entre ambos modos es la frontera: en uno, el LLM vive en tu mismo heap; en el otro, vive en otro proceso, conectado por TCP. Suena a un detalle de implementación. No lo es. Las dos clases de bug que describo abajo son consecuencia directa de esa frontera.
Bug 1 — El binario nativo huérfano que no muere
Cuando llamas spawn('opencode', ['serve', '--port=4096']), lo que crees que pasa es que se lanza un servidor y proc.pid apunta a ese servidor. Lo que realmente pasa:
#!/bin/sh
# /pnpm/opencode (instalado vía pnpm install -g opencode-ai)
exec node /pnpm/global/.../opencode-ai/bin/opencode "$@"opencode es un wrapper shell. Hace exec node bin/opencode, y ese script de Node a su vez levanta el binario nativo bun-compilado opencode-linux-x64. El árbol de procesos resultante:
PID 1 bun run agent-runner.ts
PID 21 node bin/opencode serve (capturado en proc.pid del JS)
PID 35 opencode-linux-x64/bin/opencode (el que realmente bindea :4096)exec preserva el PID dentro de su capa, pero la siguiente capa (node bin/opencode → spawn binary) crea un PID nuevo. JavaScript solo conoce PID 21. Cuando llamas proc.kill('SIGKILL') matas a 21 — y PID 35 se reparenta a init, conservando el bind de :4096.
El siguiente spawnOpencodeServer falla con EADDRINUSE. El usuario ve "Failed to start server on port 4096" sin contexto.
Detalle adicional: el binario de opencode es bun-compilado, no Node. Eso explica por qué sus MCP children heredan file descriptors del listener y, si los mata mal, el puerto puede quedar retenido por uno de ellos en lugar del binario principal. Defunct zombies en ps -ef son OK — los puertos sí se liberan cuando el padre los reclama. El problema no son los zombies, son los procesos vivos.
El fix: matar por nombre, defense in depth
No vale la pena perseguir el PID correcto a través de la cadena de execs. Más simple y más robusto: matar por nombre antes de cada spawn y dentro del destructor.
import { execSync } from 'child_process';
function killStrayOpencodeServers(): void {
try {
execSync(
'pkill -9 -f opencode-linux-x64/bin/opencode 2>/dev/null || true',
{ stdio: 'ignore' }
);
} catch {
// pkill devuelve 1 cuando no encuentra match — es OK
}
}
async function spawnOpencodeServer(config, timeoutMs = 10_000) {
killStrayOpencodeServers();
await new Promise(r => setTimeout(r, 250)); // dar tiempo al kernel a liberar el puerto
return new Promise(/* ... spawn logic existente ... */);
}
export function destroySharedRuntime(): void {
if (sharedRuntime) {
try { sharedRuntime.streamRelease(); } catch {}
try { sharedRuntime.proc.kill('SIGKILL'); } catch {}
killStrayOpencodeServers(); // PID 35 muere aquí
sharedRuntime = null;
}
}El sleep de 250ms post-pkill no es decoración. La mayoría de los kernels libera el puerto entre 100 y 200 ms después de SIGKILL, pero no es síncrono. Sin la espera, el siguiente bind falla esporádicamente con un EADDRINUSE residual.
La lección general: cuando lanzas un CLI como subproceso, audita el árbol de procesos resultante con ps -ef antes de confiar en proc.kill. Si hay wrappers, considera matar por process group (detached: true + process.kill(-pid)) o por nombre con pkill. El SDK in-process de Anthropic resuelve esto por construcción: no hay puerto, no hay proceso, no hay race.
Bug 2 — El iterator SSE que nunca se destraba
Tras parchar el bug del puerto, observamos que el container quedaba "vivo pero mudo" después de un timeout. Los logs del runtime mostraban el mismo mensaje cada cinco segundos, infinito:
[opencode-provider] OpenCode event timeout (300000ms) — clearing session ses_xxx
[opencode-provider] OpenCode event timeout (300000ms) — clearing session ses_xxx
[opencode-provider] OpenCode event timeout (300000ms) — clearing session ses_xxx
... (24+ líneas idénticas)El loop principal del provider se ve así:
turn: while (true) {
if (eventTimedOut) {
throw new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`);
}
const { value: ev, done } = await stream.next(); // <-- colgado para siempre
if (done) break turn;
// ... procesar evento
}stream viene de client.event.subscribe() del SDK de OpenCode. Cuando matas el server con SIGKILL, la TCP socket cierra brutalmente — pero el iterator del SDK nunca resuelve. Ni con done=true, ni con throw, ni con stream.return(undefined). El await queda pendiente indefinidamente.
Como el if (eventTimedOut) está antes del await, una vez que entras al await no vuelves a evaluarlo. El setInterval que setea eventTimedOut y llama destroySharedRuntime sigue firando cada cinco segundos, pero ya no sirve de nada — no hay forma de salir del await desde afuera.
Es cancelación cooperativa fallida sobre I/O de red. El SDK construye el iterator sobre un fetch + ReadableStream subyacente, pero no expone un AbortController ni propaga el cierre TCP como un throw del iterator. stream.return() está implementado en algunos async iterators, pero en este es no-op silencioso.
El fix: hacer el await cancelable
Envolver el await en un Promise.race con una "barrera de timeout" cuyo reject capturas para usar manualmente desde el setInterval.
let timeoutReject: ((err: Error) => void) | null = null;
const timeoutBarrier = new Promise<never>((_, reject) => {
timeoutReject = reject;
});
timeoutBarrier.catch(() => undefined); // evitar UnhandledRejection si el race nunca corre
const timeoutCheck = setInterval(() => {
if (Date.now() - lastEventAt > IDLE_TIMEOUT_MS && !eventTimedOut) {
eventTimedOut = true;
destroySharedRuntime();
const reject = timeoutReject;
timeoutReject = null;
reject?.(new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`));
}
}, 5000);
// dentro del loop:
turn: while (true) {
const { value, done } = await Promise.race([
stream.next(),
timeoutBarrier,
]);
if (done) break turn;
// ... procesar evento
}Tres detalles que importan:
&& !eventTimedOut— sin esto, el setInterval intenta rechazar la barrera múltiples veces y spamea unhandled rejections..catch(() => undefined)sobretimeoutBarrier— si el turn termina normal sin race (caso feliz), Node ve un rejected promise sin handler y lanzaUnhandledPromiseRejection. El catch silencioso lo previene.reject?.(...)sí destraba elPromise.racey rompe el await — aunque elstream.next()interno siga colgado para siempre. Es un leak controlado pero contenido al alcance del turn. ElPromise.raceno destraba elstream.next()real — solo lo abandona.
La lección general: cualquier await sobre un iterator de red que no exponga AbortSignal tiene que envolverse en Promise.race con un timeout explícito. Asumir que el iterator se va a destrabar solo cuando el peer muera es ingenuo — pasa con SSE, websockets, gRPC streaming, todo.
El costo oculto: +30% de CPU
Después de los dos parches el provider quedó estable, pero notamos algo en los gráficos del runtime: el container con OpenCode + DeepSeek consumía ~30% más CPU que el container con Claude in-process haciendo trabajo equivalente. No es un error de medición — es estructural.
La cadena shell → node → binario nativo deja un proceso de Node vivo encima del binario que sí trabaja. Ese Node no está ocioso: el event loop de libuv mantiene polling y callbacks activos para el HTTP server, el SSE pipe, los MCP children y el wrapper del CLI. Todo eso es overhead que en el SDK in-process directamente no existe — porque no hay capa intermedia, no hay HTTP, no hay serialize/deserialize de JSON por evento.
A escala chica no importa. A escala "un container por sesión activa, decenas concurrentes", la diferencia se nota en el bill de Fly.io a fin de mes.
Los 16 parches: filosofía belt-and-suspenders
La cuenta final del día fueron dieciséis cambios sobre container/agent-runner/src/providers/opencode.ts. No son dieciséis features distintos — son dieciséis cinturones y tirantes para el mismo cinturón. La filosofía: si el primer mecanismo falla, el segundo te salva.
Una muestra:
killStrayOpencodeServers()antes de cadaspawn, dentro del destructor, y en el path de error de cualquier reconexión.- Sleep de 250 ms post-pkill — más uno de 500 ms en el path de retry tras EADDRINUSE.
Promise.raceenvolviendo cadaawaitdel iterator, no solo el del loop principal.- Guard
&& !eventTimedOuten el setInterval para que la rejection del barrier no se dispare múltiples veces. .catch(() => undefined)sobre cada promise sintética que pueda quedar pendiente si el path feliz no la consume.- Logs estructurados en cada transición de estado del runtime para poder reconstruir post-mortem qué falló.
- Health check explícito sobre
:4096antes de marcar el server como "ready" — no confiar en el ack del spawn.
Ninguno de esos parches es elegante. Un buen SDK in-process no necesita ninguno de los dieciséis — porque la frontera que los justifica no existe. Cada cinturón que pusimos es un peaje pequeño que pagamos por la opcionalidad multi-provider.
El zoom out: in-process vs out-of-process
Las dos clases de bug que parchamos son específicas del modelo out-of-process, y son inevitables cuando integras un LLM-server externo en lugar de usar un SDK que vive en tu mismo heap.
| Dimensión | SDK in-process (Anthropic) | LLM-server out-of-process (OpenCode) |
|---|---|---|
| Modelo IPC | Mismo heap, mismo proceso | HTTP+SSE en :4096 |
| Lifecycle del LLM | Opaque, gestionado por el SDK | Explícito, tu código maneja spawn/kill |
| Hook surface | PreToolUse, PostToolUse, PreCompact... | Ninguno (solo permission auto-allow) |
| Archivado de transcripts | Sí, en hook PreCompact | No |
| Reporte de usage | Sí (input/output/cache hit por turno) | No estructurado |
| Latencia por evento | Llamada directa al iterator | HTTP roundtrip + JSON serialize |
| Memoria del runtime | ~50MB | ~200-400MB (server + MCP children) |
| CPU bajo carga equivalente | baseline | ~+30% (event loop de la capa Node) |
| Multi-provider LLM | No (un solo proveedor) | Sí (DeepSeek, OpenRouter, Groq...) |
OpenCode te da multi-provider. Eso es real y valioso — DeepSeek puede salir 10× más barato que Opus para muchas tareas. Pero el precio es:
- Dos clases de bug que no existen in-process — port leak por exec chain, iterator colgado por SSE no abortable. Defendibles, pero los pagas tú.
- Pérdida de hooks — no puedes archivar transcripts en compact, no puedes interceptar tool calls, no puedes observar al agente desde afuera.
- Pérdida de telemetría — el SSE de OpenCode no expone usage estructurado tipo Anthropic; tu dashboard de costos queda ciego.
- Single point of failure compartido — un cuelgue mid-stream del LLM degrada el runtime entero (todos los turnos esperan al timeout), no solo el turno actual.
El SPOF del shared runtime
Hay una decisión adicional que vale la pena nombrar: una sola instancia del LLM-server por agent-runner, compartida entre turnos. Razones legítimas: warm-up cero, cache de contexto entre turnos, menor memoria total. Pero implica que un cuelgue mid-stream degrada al runtime entero. Todos los mensajes encolados esperan al timeout (típicamente cinco minutos), y los que ya estaban marcados como "claimed" en la cola al matar el container se pierden si no tienes re-encolado automático.
La causa de los silencios largos en producción rara vez es el bug del provider en sí. Es la combinación de bug del provider + decisión de runtime compartido + cola at-most-once.
Cuándo elegir cada uno
OpenCode (y similares: aider, cline, sgpt en server-mode) son adapter shims, no harnesses equivalentes al SDK nativo. Son defendibles cuando:
- El ahorro del modelo alternativo (DeepSeek, etc.) supera el costo operacional del out-of-process.
- Toleras cuelgues ocasionales con recovery de minutos.
- No necesitas observabilidad fina de tokens y costos.
- No te duele perder archivado automático de transcripts.
Para producción crítica, SDK nativo del proveedor: Anthropic, OpenAI directo, Bedrock con su SDK. Out-of-process shims para experimentación, prototipos, contenido, o agentes secundarios donde un timeout de cinco minutos es ruido aceptable.
En NanoClaw quedamos con los dos providers conviviendo. El default para clientes pagando es Claude in-process. OpenCode + DeepSeek queda como segundo provider para staging y trabajos no críticos. Los dos parches viven juntos en container/agent-runner/src/providers/opencode.ts y resuelven los dos bugs: matar por nombre antes de cada spawn, y envolver cada await del iterator en Promise.race.
Lo que aprendimos
Tres cosas que generalizan más allá de OpenCode:
-
Audita el árbol de procesos antes de confiar en
proc.kill. Si tu CLI es un wrapper shell que execs en otra runtime, hay capas que JavaScript nunca conoce.ps -efantes de spawnear, y si hay capas, mata por nombre o por process group. -
Cualquier iterator de red sin AbortSignal es un cuelgue esperando. SSE, websockets, gRPC streaming, async iterators de SDKs random. Si no puedes cancelarlo desde afuera, envuélvelo en
Promise.racecon un timeout explícito. Asumir que el peer cierra limpio es ingenuo — y cuando no lo hace, tuawaitno vuelve. -
El modelo de proceso es parte de la API. "Mismo heap" vs "otro proceso conectado por TCP" no es un detalle de implementación — define qué bugs te van a tocar parchar. Cuando eliges un provider, no estás eligiendo solo un modelo: estás eligiendo una superficie de fallas.
El SDK nativo no es mejor en abstracto. Es más simple porque su modelo de proceso elimina una clase entera de fallas. Si decides ir out-of-process, hazlo a sabiendas: con un budget de tiempo para parches como los dieciséis de hoy, ~30% más de CPU bajo carga, y una expectativa realista de la observability que vas a perder.
Hoy nos costó tres horas y un mal rato. Mañana, con los parches en su lugar, OpenCode + DeepSeek vuelve a ser una segunda opción razonable para todo lo que no sea producción crítica. Esa es la conclusión honesta — ni "OpenCode es malo" ni "el SDK nativo es la única respuesta". Es: conoce la frontera que estás cruzando antes de cruzarla, y trae cinturones.
Prueba Formmy
Si construyes agentes para WhatsApp, web, o voz, Formmy te da el harness de producción sin que tengas que parchar provider por provider.
Prueba Formmy gratis — Agentes que no se quedan colgados a las 3 AM.
¿Tienes un harness propio y quieres contar lo que rompiste? Escríbenos.
