ブログ
プロトコルの内側:Mac から iPhone へのリアルタイムターミナルストリーミング
WebSocket ベースのプロトコルを構築し、tmux ターミナル出力を差分更新、ANSI レンダリング、厳格なレイテンシ目標とともにリアルタイムで iPhone にストリーミングする方法。
このカテゴリのプロダクト/開発アップデートを確認できます。
カテゴリを見るターミナルストリーミングは Tactic Remote の中核です。すべての機能 — 承認、通知、セッション監視 — は、iPhone が Mac のターミナルで起きていることを正確かつ応答性よく表示できることに依存しています。これを正しく実装するには、プロトコルレベルでいくつかの自明ではない問題を解決する必要がありました。
この記事では、tmux キャプチャから iPhone レンダリングまで、ストリーミングプロトコルの動作と、その過程で行ったエンジニアリング上のトレードオフを解説します。
パイプライン
ターミナルデータは 5 つのステージを流れます:
- tmux キャプチャ — Mac companion アプリが tmux ペインからターミナル内容を読み取ります。
- 差分計算 — 現在のキャプチャを最後に送信した状態と比較します。
- シリアライゼーション — 変更された領域をコンパクトなバイナリフレームにパックします。
- WebSocket トランスポート — 永続的な WebSocket 接続を通じてフレームを iPhone に送信します。
- ANSI レンダリング — iPhone クライアントがエスケープシーケンスを解釈し、スタイル付きテキストをレンダリングします。
各ステージにはそれぞれのレイテンシバジェットがあります。目標はローカルネットワーク上でのエンドツーエンド配信を 100ms 以内にすることで、各ステージに十分な余裕を残します。
tmux ペインキャプチャ
Tactic Remote は tmux を通じて Claude Code セッションを管理します。これはストリーミングに大きな恩恵をもたらす初期のアーキテクチャ上の決定でした。tmux は仮想ターミナルバッファを保持しており、実際の PTY ストリームをインターセプトすることなくプログラム的に読み取れます。
-p と -e フラグ付きの tmux capture-pane を使用して、ANSI エスケープシーケンスを保持したペイン内容を抽出します。アクティブなストリーミング中は 50ms のポーリング間隔でキャプチャを実行します — リアルタイムと感じるのに十分な速度で、かつ CPU オーバーヘッドが無視できる程度の頻度です。
微妙な点が一つあります。capture-pane は可視ペインの内容に加えてスクロールバックも返します。デフォルトでは可視ビューポートのみをキャプチャし、ユーザーが iPhone で上にスクロールした場合にオンデマンドでスクロールバックをストリーミングします。これにより、通常のケースではフレームあたりのペイロードを小さく保てます。
キャプチャ処理はモダンな Mac で 1 フレームあたり約 3-8ms を追加します(M1 から M4 のハードウェアで計測)。
差分更新
毎フレームで完全なターミナルバッファを送信するのは無駄です。典型的な 80x24 のターミナルペインには約 1,920 文字が含まれます。ANSI シーケンスを含めると、フルフレームは 8-12 KB に達します。20 フレーム/秒では 160-240 KB/s の帯域幅になります。ローカル Wi-Fi では許容範囲ですが、Cloudflare Tunnel 経由のモバイル回線接続では問題になります。
代わりに、連続するフレーム間の行レベル差分を計算します。ほとんどのターミナル更新は数行にしか影響しません — 新しいログ行が出現、プログレスバーが進行、カーソルが移動。差分アルゴリズムはハッシュ比較で変更された行を特定し、変更されたコンテンツのみを送信します。
実際には、差分更新により平均フレームサイズが 85-92% 削減されます。Claude Code がアクティブに出力している間の典型的な差分フレームは 400-800 バイトです。アイドル期間(ユーザー入力や API レスポンスを待っている間)はフレームが一切送信されません — コンテンツが変更された場合にのみ送信します。
| 指標 | フルフレーム | 差分 |
|---|---|---|
| 平均フレームサイズ | 8-12 KB | 400-800 バイト |
| 20 fps 時の帯域幅 | 160-240 KB/s | 8-16 KB/s |
| アイドル時の帯域幅 | 160-240 KB/s | 0 KB/s |
ワイヤフォーマット
差分フレームは JSON ではなくコンパクトなバイナリフォーマットにシリアライズされます。各フレームには以下が含まれます:
- 4 バイトヘッダー:プロトコルバージョン(1 バイト)、フレームタイプ(1 バイト)、ペイロード長(2 バイト)。
- 順序付けと損失検出のためのシーケンス番号。
- 行更新レコードの配列。各レコードには行インデックス、新しいコンテンツ(ANSI シーケンスが埋め込まれた UTF-8)、整合性検証のための CRC32 チェックサムが含まれます。
Protocol Buffers や MessagePack よりもカスタムバイナリフォーマットを選択しました。ベンチマークの結果、小さく頻繁なメッセージのシリアライゼーションオーバーヘッドはライブラリの初期化コストが支配的であることがわかったためです。手書きのシリアライザは 1 フレームあたり 1ms 未満の追加です。
WebSocket トランスポート
ストリーミング接続は単一の永続 WebSocket を使用します。Server-Sent Events (SSE) と生の TCP を評価しましたが、WebSocket が双方向通信(スクロールリクエストとフロー制御に必要)、幅広いネットワーク互換性、組み込みフレーミングの最適な組み合わせを提供しました。
接続管理は 3 つのシナリオを処理します:
ローカルネットワーク — iPhone は Mac companion のローカルサーバーに直接接続します。ラウンドトリップタイムは通常 2-8ms です。合計ストリーミングレイテンシ(キャプチャからレンダリングまで)は平均 40-60ms です。
Cloudflare Tunnel — トラフィックは Cloudflare のエッジネットワークを経由します。ラウンドトリップタイムは地理的条件により 30-80ms に増加します。合計ストリーミングレイテンシは平均 120-200ms です。モニタリングには十分なレスポンシブさですが、高速スクロール出力時にはユーザーが違いに気づきます。
ネットワーク遷移 — iPhone が Wi-Fi とモバイル回線の間(またはネットワーク間)で切り替わると、WebSocket 接続が切断されます。再接続ロジックは 1-2 秒以内に接続を再確立し、状態を復元するためにフルフレームの再同期を要求します。再接続中は、iPhone 上のビジュアルインジケーターが接続状態を表示し、出力が一時停止している理由をユーザーに知らせます。
ANSI エスケープシーケンスの処理
Claude Code のターミナル出力は ANSI エスケープシーケンスに富んでいます — シンタックスハイライトの色、強調のための太字、プログレスインジケーターのカーソル位置指定。これらのシーケンスを iPhone 上で正確に保持することは、可読性にとって不可欠です。
iOS レンダラーは最も一般的な SGR(Select Graphic Rendition)シーケンスを処理します:16 標準色、256 色パレット、24 ビットトゥルーカラー、太字、イタリック、下線、反転。レンダリングビューではカーソル移動シーケンス(CSI H、CSI A/B/C/D)を意図的にサポートしていません — これらは tmux キャプチャ時に消費され、生のエスケープコードとしてではなく行の位置指定に反映されます。
モバイル特有の課題が一つあります:ダークモード。暗い背景用に設計されたターミナルカラースキームは、iOS がライトモードの場合にうまくレンダリングされません(逆も同様)。iPhone の現在のアピアランスモードを検出し、コントラスト比を維持するカラーマッピングを適用します。これはテーマではなく、元の色の意図を保持しつつ可読性を確保する輝度調整です。
パフォーマンスバジェット
3 つのパーセンタイルでストリーミングパフォーマンスを追跡しています:
| 指標 | P50 | P95 | P99 |
|---|---|---|---|
| キャプチャレイテンシ | 4ms | 7ms | 12ms |
| 差分計算 | 1ms | 3ms | 5ms |
| シリアライゼーション | <1ms | 1ms | 2ms |
| ネットワーク(ローカル) | 3ms | 8ms | 15ms |
| ネットワーク(トンネル) | 45ms | 90ms | 150ms |
| iPhone でのレンダリング | 6ms | 12ms | 18ms |
| 合計(ローカル) | 15ms | 31ms | 52ms |
| 合計(トンネル) | 57ms | 113ms | 187ms |
これらの数値はベータユーザーベースの本番テレメトリから得られたものです。P50 ローカルレイテンシが 15ms ということは、ほとんどのインタラクションで iPhone ディスプレイの更新が人間の目で遅延を感知できないほど高速であることを意味します。
学んだこと
ターミナル出力のリアルタイムストリーミングプロトコルを構築する中で、いくつかの教訓を得ました:
このユースケースではポーリングがイベント駆動に勝る。 当初は tmux の内部更新イベントにフックしようとしましたが、50ms ポーリングの方がシンプルで信頼性が高く、パフォーマンスコストも無視できるレベルでした。
行レベルの差分で十分。 文字レベルの差分もプロトタイプしましたが、追加の複雑さに対して帯域幅の削減はわずか 5-8% で、メンテナンスの負担に見合いませんでした。
高頻度ではバイナリフレーミングが重要。 1 秒間に 20 回送信されるメッセージでは、シリアライゼーションフォーマットの各バイトのオーバーヘッドが増幅されます。JSON の冗長性は測定可能なコストでした。
再接続の UX は接続パフォーマンスと同じくらい重要。 ユーザーは何が起きているかを理解できれば 2 秒の再接続を許容します。しかし視覚的フィードバックがなければ、500ms の再接続でも故障と感じます。
実装の詳細については、アーキテクチャドキュメントをご覧ください。同様のものを構築している場合は、サポートページからお気軽にご連絡ください。
Tactic Remote を試す
スマートフォンからコーディングエージェントを操作
Mac、Windows、Linux 上の Claude Code や Codex などに接続。iPhone や iPad で進捗を確認し、次の指示を送れます。