Zurück zur Übersicht

Blog

Einblick in das Protokoll: Echtzeit-Terminal-Streaming vom Mac zum iPhone

Wie wir ein WebSocket-basiertes Protokoll gebaut haben, das tmux-Terminal-Ausgaben in Echtzeit auf das iPhone streamt — mit differentiellen Updates, ANSI-Rendering und ambitionierten Latenzzielen.

Veröffentlicht Tags: engineering / protocol / performance / terminal
Blog

Weitere Produkt- und Engineering-Updates aus dieser Kategorie.

Kategorie öffnen

Terminal-Streaming ist der Kern von Tactic Remote. Jede Funktion — Genehmigungen, Benachrichtigungen, Sitzungsüberwachung — hängt davon ab, dass das iPhone eine genaue, reaktionsschnelle Darstellung dessen zeigt, was im Terminal Ihres Mac passiert. Dies korrekt umzusetzen erforderte die Lösung mehrerer nicht offensichtlicher Probleme auf Protokollebene.

Dieser Beitrag beschreibt die Funktionsweise unseres Streaming-Protokolls, von der tmux-Erfassung bis zum iPhone-Rendering, und die technischen Kompromisse, die wir dabei eingegangen sind.

Die Pipeline

Terminal-Daten durchlaufen fünf Stufen:

  1. tmux-Erfassung — Die Mac-Companion-App liest Terminal-Inhalte aus tmux-Panes.
  2. Diff-Berechnung — Wir vergleichen die aktuelle Erfassung mit dem zuletzt übertragenen Zustand.
  3. Serialisierung — Geänderte Bereiche werden in einen kompakten binären Frame gepackt.
  4. WebSocket-Transport — Frames werden über eine persistente WebSocket-Verbindung an das iPhone gesendet.
  5. ANSI-Rendering — Der iPhone-Client interpretiert Escape-Sequenzen und rendert formatierten Text.

Jede Stufe hat ihr eigenes Latenzbudget. Unser Ziel ist eine Ende-zu-Ende-Zustellung in unter 100ms im lokalen Netzwerk, mit ausreichend Spielraum in jeder Stufe.

tmux-Pane-Erfassung

Tactic Remote verwaltet Claude-Code-Sitzungen über tmux. Dies war eine frühe Architekturentscheidung, die sich für das Streaming auszahlt: tmux pflegt einen virtuellen Terminal-Buffer, den wir programmatisch lesen können, ohne den tatsächlichen PTY-Stream abzufangen.

Wir verwenden tmux capture-pane mit den -p- und -e-Flags, um Pane-Inhalte mit erhaltenen ANSI-Escape-Sequenzen zu extrahieren. Erfassungen laufen während des aktiven Streamings in einem 50ms-Polling-Intervall — schnell genug, um sich wie Echtzeit anzufühlen, selten genug, um nennenswerten CPU-Overhead zu vermeiden.

Eine Feinheit: capture-pane gibt den sichtbaren Pane-Inhalt plus Scrollback zurück. Standardmäßig erfassen wir nur den sichtbaren Viewport und streamen Scrollback on-demand, wenn der Benutzer auf dem iPhone nach oben scrollt. Dies hält den Payload pro Frame für den Normalfall klein.

Der Erfassungsprozess fügt auf einem modernen Mac etwa 3-8ms pro Frame hinzu, gemessen über M1- bis M4-Hardware.

Differentielle Updates

Den vollständigen Terminal-Buffer bei jedem Frame zu senden wäre verschwenderisch. Ein typischer 80x24-Terminal-Pane enthält etwa 1.920 Zeichen. Mit ANSI-Sequenzen kann ein vollständiger Frame 8-12 KB erreichen. Bei 20 Frames pro Sekunde sind das 160-240 KB/s Bandbreite — akzeptabel im lokalen Wi-Fi, aber problematisch für Mobilfunkverbindungen über Cloudflare Tunnel.

Stattdessen berechnen wir zeilenweise Diffs zwischen aufeinanderfolgenden Frames. Die meisten Terminal-Updates betreffen nur wenige Zeilen — eine neue Log-Zeile erscheint, ein Fortschrittsbalken rückt vor, ein Cursor bewegt sich. Unser Diff-Algorithmus identifiziert geänderte Zeilen durch Hash-Vergleich und überträgt nur den geänderten Inhalt.

In der Praxis reduzieren differentielle Updates die durchschnittliche Frame-Größe um 85-92%. Ein typischer Diff-Frame während aktiver Claude-Code-Ausgabe beträgt 400-800 Bytes. In Leerlaufphasen (Warten auf Benutzereingabe oder API-Antworten) werden überhaupt keine Frames gesendet — wir übertragen nur bei Inhaltsänderungen.

MetrikVoller FrameDifferentiell
Durchschnittliche Frame-Größe8-12 KB400-800 Bytes
Bandbreite bei 20 fps160-240 KB/s8-16 KB/s
Bandbreite im Leerlauf160-240 KB/s0 KB/s

Das Übertragungsformat

Diff-Frames werden in ein kompaktes Binärformat statt JSON serialisiert. Jeder Frame enthält:

  • Einen 4-Byte-Header: Protokollversion (1 Byte), Frame-Typ (1 Byte) und Payload-Länge (2 Bytes).
  • Eine Sequenznummer für Reihenfolge und Verlusterkennung.
  • Ein Array von Zeilen-Update-Datensätzen, jeweils mit Zeilenindex, neuem Inhalt (UTF-8 mit eingebetteten ANSI-Sequenzen) und einer CRC32-Prüfsumme zur Integritätsverifikation.

Wir wählten ein eigenes Binärformat gegenüber Protocol Buffers oder MessagePack, nachdem Benchmarks zeigten, dass der Serialisierungs-Overhead für kleine, häufige Nachrichten von den Bibliotheks-Initialisierungskosten dominiert wurde. Unser handgeschriebener Serializer fügt unter 1ms pro Frame hinzu.

WebSocket-Transport

Die Streaming-Verbindung nutzt einen einzelnen persistenten WebSocket. Wir evaluierten Server-Sent Events (SSE) und rohes TCP, aber WebSocket bot die beste Kombination aus bidirektionaler Kommunikation (benötigt für Scroll-Anfragen und Flusskontrolle), breiter Netzwerkkompatibilität und eingebautem Framing.

Das Verbindungsmanagement behandelt drei Szenarien:

Lokales Netzwerk — Das iPhone verbindet sich direkt mit dem lokalen Server des Mac Companion. Die Roundtrip-Zeit beträgt typischerweise 2-8ms. Die Gesamtstreaming-Latenz (Erfassung bis Rendering) liegt durchschnittlich bei 40-60ms.

Cloudflare Tunnel — Der Datenverkehr wird über Cloudflares Edge-Netzwerk geleitet. Die Roundtrip-Zeit erhöht sich je nach Geografie auf 30-80ms. Die Gesamtstreaming-Latenz liegt durchschnittlich bei 120-200ms. Noch reaktionsschnell genug für Monitoring, obwohl Benutzer den Unterschied bei schnell scrollender Ausgabe bemerken.

Netzwerkwechsel — Wenn das iPhone zwischen Wi-Fi und Mobilfunk wechselt (oder zwischen Netzwerken), bricht die WebSocket-Verbindung ab. Unsere Reconnection-Logik stellt die Verbindung innerhalb von 1-2 Sekunden wieder her und fordert eine Vollframe-Resynchronisation zur Zustandswiederherstellung an. Während der Wiederverbindung zeigt ein visueller Indikator auf dem iPhone den Verbindungsstatus an, damit Benutzer wissen, warum die Ausgabe pausiert hat.

ANSI-Escape-Sequenz-Verarbeitung

Die Terminal-Ausgabe von Claude Code enthält reichlich ANSI-Escape-Sequenzen — Farben für Syntax-Highlighting, Fett für Hervorhebungen, Cursor-Positionierung für Fortschrittsanzeigen. Diese Sequenzen auf dem iPhone genau zu reproduzieren ist für die Lesbarkeit essenziell.

Unser iOS-Renderer verarbeitet die gängigsten SGR-Sequenzen (Select Graphic Rendition): 16 Standardfarben, 256-Farben-Palette, 24-Bit-True-Color, Fett, Kursiv, Unterstrichen und Invertiert. Cursor-Bewegungssequenzen (CSI H, CSI A/B/C/D) unterstützen wir in der gerenderten Ansicht bewusst nicht — diese werden während der tmux-Erfassung verarbeitet und in der Zeilenpositionierung reflektiert, statt als rohe Escape-Codes übertragen zu werden.

Eine mobilspezifische Herausforderung: Dark Mode. Terminal-Farbschemata, die für dunkle Hintergründe entworfen wurden, rendern schlecht, wenn iOS im Light Mode ist (und umgekehrt). Wir erkennen den aktuellen Darstellungsmodus des iPhone und wenden eine Farbzuordnung an, die Kontrastverhältnisse bewahrt. Das ist kein Theme — es ist eine Luminanz-Anpassung, die die ursprüngliche Farbintention beibehält und gleichzeitig Lesbarkeit sicherstellt.

Performance-Budget

Wir verfolgen die Streaming-Performance über drei Perzentile:

MetrikP50P95P99
Erfassungslatenz4ms7ms12ms
Diff-Berechnung1ms3ms5ms
Serialisierung<1ms1ms2ms
Netzwerk (lokal)3ms8ms15ms
Netzwerk (Tunnel)45ms90ms150ms
Rendering auf iPhone6ms12ms18ms
Gesamt (lokal)15ms31ms52ms
Gesamt (Tunnel)57ms113ms187ms

Diese Zahlen stammen aus Produktions-Telemetrie unserer Beta-Nutzerbasis. Die P50-Lokallatenz von 15ms bedeutet, dass bei den meisten Interaktionen das iPhone-Display schneller aktualisiert wird, als das menschliche Auge die Verzögerung wahrnehmen kann.

Was wir gelernt haben

Der Bau eines Echtzeit-Streaming-Protokolls für Terminal-Ausgaben hat uns mehrere Lektionen gelehrt:

Polling schlägt Event-Driven für diesen Anwendungsfall. Wir versuchten zunächst, uns in tmux' interne Update-Events einzuklinken, stellten aber fest, dass 50ms-Polling einfacher, zuverlässiger und mit vernachlässigbaren Performance-Kosten verbunden war.

Zeilenweise Diffs sind gut genug. Wir prototypisierten zeichenweises Diffing, aber die zusätzliche Komplexität brachte nur eine weitere Bandbreitenreduktion von 5-8% — den Wartungsaufwand nicht wert.

Binäres Framing zählt bei hoher Frequenz. Bei Nachrichten, die 20-mal pro Sekunde gesendet werden, wird jedes Byte Overhead im Serialisierungsformat verstärkt. JSONs Ausführlichkeit war messbar kostspielig.

Reconnection-UX ist genauso wichtig wie Connection-Performance. Benutzer tolerieren eine 2-Sekunden-Wiederverbindung, wenn sie verstehen, was passiert. Sie nehmen eine 500ms-Wiederverbindung als defekt wahr, wenn es kein visuelles Feedback gibt.

Für Implementierungsdetails siehe unsere Architektur-Dokumentation. Wenn Sie etwas Ähnliches bauen, diskutieren wir gerne unseren Ansatz — kontaktieren Sie uns über die Support-Seite.

Tactic Remote ausprobieren

Coding-Agenten vom Smartphone aus steuern

Verbinde dich mit Claude Code, Codex und anderen Agenten auf deinem Mac-, Windows- oder Linux-Rechner. Prüfe den Fortschritt und sende die nächste Anweisung vom iPhone oder iPad.