Volver a la lista

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.

Publicado Etiquetas: engineering / protocol / performance / terminal
Blog

Sigue las novedades de producto e ingeniería de esta categoría.

Ver categoría

El 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:

  1. Captura de tmux — La app Mac companion lee el contenido del terminal desde los paneles de tmux.
  2. Cálculo de diferencias — Comparamos la captura actual con el último estado transmitido.
  3. Serialización — Las regiones modificadas se empaquetan en un frame binario compacto.
  4. Transporte WebSocket — Los frames se envían al iPhone a través de una conexión WebSocket persistente.
  5. 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étricaFrame completoDiferencial
Tamaño promedio de frame8-12 KB400-800 bytes
Ancho de banda a 20 fps160-240 KB/s8-16 KB/s
Ancho de banda en reposo160-240 KB/s0 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étricaP50P95P99
Latencia de captura4ms7ms12ms
Cálculo de diferencias1ms3ms5ms
Serialización<1ms1ms2ms
Red (local)3ms8ms15ms
Red (tunnel)45ms90ms150ms
Renderizado en iPhone6ms12ms18ms
Total (local)15ms31ms52ms
Total (tunnel)57ms113ms187ms

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.