Blog
Dentro del protocolo: streaming de terminal en tiempo real de Mac a iPhone
Cómo construimos un protocolo basado en WebSocket que transmite la salida del terminal tmux al iPhone en tiempo real, con actualizaciones diferenciales, renderizado ANSI y objetivos agresivos de latencia.
Sigue las novedades de producto e ingeniería de esta categoría.
Ver categoríaEl streaming de terminal es el núcleo de Tactic Remote. Cada funcionalidad — aprobaciones, notificaciones, monitoreo de sesiones — depende de que el iPhone muestre una representación precisa y responsiva de lo que sucede en el terminal de tu Mac. Lograr esto correctamente requirió resolver varios problemas no obvios a nivel de protocolo.
Este artículo explica cómo funciona nuestro protocolo de streaming, desde la captura de tmux hasta el renderizado en iPhone, y las decisiones de ingeniería que tomamos en el camino.
El pipeline
Los datos del terminal fluyen a través de cinco etapas:
- Captura de tmux — La app Mac companion lee el contenido del terminal desde los paneles de tmux.
- Cálculo de diferencias — Comparamos la captura actual con el último estado transmitido.
- Serialización — Las regiones modificadas se empaquetan en un frame binario compacto.
- Transporte WebSocket — Los frames se envían al iPhone a través de una conexión WebSocket persistente.
- Renderizado ANSI — El cliente iPhone interpreta las secuencias de escape y renderiza texto con estilo.
Cada etapa tiene su propio presupuesto de latencia. Nuestro objetivo es una entrega extremo a extremo en menos de 100ms en red local, dejando margen significativo en cada etapa.
Captura de panel tmux
Tactic Remote gestiona las sesiones de Claude Code a través de tmux. Esta fue una decisión arquitectónica temprana que rinde dividendos para el streaming: tmux mantiene un buffer de terminal virtual que podemos leer programáticamente sin interceptar el flujo PTY real.
Usamos tmux capture-pane con los flags -p y -e para extraer el contenido del panel con las secuencias de escape ANSI preservadas. Las capturas se ejecutan en un intervalo de sondeo de 50ms durante el streaming activo — lo suficientemente rápido para sentirse en tiempo real, lo suficientemente infrecuente para evitar un overhead de CPU significativo.
Un detalle: capture-pane devuelve el contenido visible del panel más el historial de desplazamiento. Capturamos solo el viewport visible por defecto y transmitimos el historial bajo demanda cuando el usuario desplaza hacia arriba en el iPhone. Esto mantiene el payload por frame pequeño para el caso común.
El proceso de captura añade aproximadamente 3-8ms por frame en un Mac moderno, medido en hardware desde M1 hasta M4.
Actualizaciones diferenciales
Enviar el buffer completo del terminal en cada frame sería un desperdicio. Un panel de terminal típico de 80x24 contiene aproximadamente 1,920 caracteres. Con secuencias ANSI, un frame completo puede alcanzar 8-12 KB. A 20 frames por segundo, eso son 160-240 KB/s de ancho de banda — aceptable en Wi-Fi local pero problemático para conexiones celulares a través de Cloudflare Tunnel.
En su lugar, calculamos diferencias a nivel de línea entre frames consecutivos. La mayoría de las actualizaciones del terminal afectan solo unas pocas líneas — aparece una nueva línea de log, una barra de progreso avanza, un cursor se mueve. Nuestro algoritmo de diferencias identifica las líneas cambiadas por comparación de hash y transmite solo el contenido modificado.
En la práctica, las actualizaciones diferenciales reducen el tamaño promedio del frame en un 85-92%. Un frame diferencial típico durante la salida activa de Claude Code es de 400-800 bytes. Durante períodos inactivos (esperando entrada del usuario o respuestas de la API), no se envían frames — solo transmitimos cuando el contenido cambia.
| Métrica | Frame completo | Diferencial |
|---|---|---|
| Tamaño promedio de frame | 8-12 KB | 400-800 bytes |
| Ancho de banda a 20 fps | 160-240 KB/s | 8-16 KB/s |
| Ancho de banda en reposo | 160-240 KB/s | 0 KB/s |
El formato de transmisión
Los frames diferenciales se serializan en un formato binario compacto en lugar de JSON. Cada frame contiene:
- Un encabezado de 4 bytes: versión del protocolo (1 byte), tipo de frame (1 byte) y longitud del payload (2 bytes).
- Un número de secuencia para ordenamiento y detección de pérdidas.
- Un array de registros de actualización de líneas, cada uno conteniendo el índice de línea, el nuevo contenido (UTF-8 con secuencias ANSI embebidas) y un checksum CRC32 para verificación de integridad.
Elegimos un formato binario personalizado sobre Protocol Buffers o MessagePack después de que los benchmarks mostraran que el overhead de serialización para mensajes pequeños y frecuentes estaba dominado por los costos de inicialización de la biblioteca. Nuestro serializador manual añade menos de 1ms por frame.
Transporte WebSocket
La conexión de streaming usa un único WebSocket persistente. Evaluamos Server-Sent Events (SSE) y TCP crudo, pero WebSocket ofreció la mejor combinación de comunicación bidireccional (necesaria para solicitudes de desplazamiento y control de flujo), amplia compatibilidad de red y framing incorporado.
La gestión de conexiones maneja tres escenarios:
Red local — El iPhone se conecta directamente al servidor local del Mac companion. El tiempo de ida y vuelta es típicamente de 2-8ms. La latencia total de streaming (de captura a renderizado) promedia 40-60ms.
Cloudflare Tunnel — El tráfico se enruta a través de la red de borde de Cloudflare. El tiempo de ida y vuelta aumenta a 30-80ms dependiendo de la geografía. La latencia total de streaming promedia 120-200ms. Aún lo suficientemente responsivo para monitoreo, aunque los usuarios notan la diferencia durante salidas con desplazamiento rápido.
Transiciones de red — Cuando el iPhone cambia entre Wi-Fi y celular (o entre redes), la conexión WebSocket se interrumpe. Nuestra lógica de reconexión restablece la conexión en 1-2 segundos y solicita una resincronización de frame completo para restaurar el estado. Durante la reconexión, un indicador visual en el iPhone muestra el estado de la conexión para que los usuarios sepan por qué la salida se ha pausado.
Manejo de secuencias de escape ANSI
La salida del terminal de Claude Code es rica en secuencias de escape ANSI — colores para resaltado de sintaxis, negrita para énfasis, posicionamiento del cursor para indicadores de progreso. Preservar estas secuencias con precisión en el iPhone es esencial para la legibilidad.
Nuestro renderizador iOS maneja las secuencias SGR (Select Graphic Rendition) más comunes: 16 colores estándar, paleta de 256 colores, color verdadero de 24 bits, negrita, cursiva, subrayado e inverso. Intencionalmente no soportamos secuencias de movimiento de cursor (CSI H, CSI A/B/C/D) en la vista renderizada — estas se consumen durante la captura de tmux y se reflejan en el posicionamiento de líneas en lugar de transmitirse como códigos de escape crudos.
Un desafío específico del móvil: el modo oscuro. Los esquemas de color de terminal diseñados para fondos oscuros se renderizan mal cuando iOS está en modo claro (y viceversa). Detectamos el modo de apariencia actual del iPhone y aplicamos un mapeo de colores que preserva las relaciones de contraste. Esto no es un tema — es un ajuste de luminancia que mantiene la intención de color original mientras asegura la legibilidad.
Presupuesto de rendimiento
Rastreamos el rendimiento de streaming en tres percentiles:
| Métrica | P50 | P95 | P99 |
|---|---|---|---|
| Latencia de captura | 4ms | 7ms | 12ms |
| Cálculo de diferencias | 1ms | 3ms | 5ms |
| Serialización | <1ms | 1ms | 2ms |
| Red (local) | 3ms | 8ms | 15ms |
| Red (tunnel) | 45ms | 90ms | 150ms |
| Renderizado en iPhone | 6ms | 12ms | 18ms |
| Total (local) | 15ms | 31ms | 52ms |
| Total (tunnel) | 57ms | 113ms | 187ms |
Estos números provienen de telemetría de producción de nuestra base de usuarios beta. La latencia P50 local de 15ms significa que para la mayoría de las interacciones, la pantalla del iPhone se actualiza más rápido de lo que el ojo humano puede percibir el retraso.
Lo que aprendimos
Construir un protocolo de streaming en tiempo real para salida de terminal nos enseñó varias lecciones:
El sondeo supera al evento-dirigido para este caso de uso. Inicialmente intentamos engancharnos a los eventos internos de actualización de tmux, pero encontramos que el sondeo de 50ms era más simple, más confiable y tenía un costo de rendimiento despreciable.
Las diferencias a nivel de línea son suficientes. Prototipamos diferencias a nivel de carácter, pero la complejidad adicional produjo solo una reducción adicional del 5-8% en ancho de banda, no justificando la carga de mantenimiento.
El framing binario importa a alta frecuencia. Para mensajes enviados 20 veces por segundo, cada byte de overhead en el formato de serialización se amplifica. La verbosidad de JSON tenía un costo medible.
La UX de reconexión es tan importante como el rendimiento de conexión. Los usuarios toleran una reconexión de 2 segundos si entienden qué está pasando. Perciben una reconexión de 500ms como rota si no hay retroalimentación visual.
Para detalles de implementación, consulta nuestra documentación de Arquitectura. Si estás construyendo algo similar, estaremos encantados de discutir nuestro enfoque — contáctanos a través de la página de Soporte.
Prueba Tactic Remote
Controla tus agentes de programación desde el móvil
Conéctate a Claude Code, Codex y otros agentes en tu Mac, Windows o Linux. Consulta el progreso y envía la siguiente instrucción desde el iPhone o iPad.