sh.saqoo.Canopy · architecture

Canopy のしくみ

Canopy は Claude Code の VS Code 拡張機能を、手を加えずに、VS Code なしで動かす。3.0 からは、Mac 上のセッションはすべてバックグラウンドのデーモンが持つ。Mac のアプリも、別の Mac も、iPhone も、そこに attach するクライアントになった。

バージョン 3.1.0更新 2026-10-02macOS 15+ · Swift 6 · Node 18+
Overview

全体像

押さえる考えは 2 つ。1 つ目、Canopy は Claude Code のプロトコルを再実装しない。拡張機能の extension.js をそのまま Node で読み込み、vscode モジュールだけを小さな偽物に差し替える。画面は拡張機能自身の React webview を WKWebView で表示する。

2 つ目、セッションを持つプロセスと、見ているウィンドウは別物。shim を動かし続けるのは canopyd で、どのマシンのどのウィンドウもクライアントにすぎない。

Canopy のプロセス構成図 3 つのクライアント(この Mac のアプリ、別の Mac のアプリ、iPhone)が canopyd に接続する。canopyd はセッションごとに ShimProcess を持ち、それが Node の vscode-shim を動かし、shim が extension.js を読み込み、extension.js が claude CLI を起動する。canopyd は Cloudflare の relay に roster を送る。 CLIENT · この MAC Canopy.app サイドバー、ペイン、MacroPad ペインごとに WKWebView CLIENT · 別の MAC Canopy.app この Mac のセッションを mirror ペインで開く CLIENT · IPHONE Canopy Mobile 同じ webview、同じ プロトコル(別リポジトリ) CLOUDFLARE Relay マシン一覧、roster、 イベント、push Unix socket TCP · Tailscale TCP · Tailscale roster WS LAUNCHAGENT · MAC ごとに 1 つ · NSAPPLICATION なし canopyd Canopy.app/Contents/MacOS/Canopy --daemon MirrorServer session 接続:attach、replay、 asset、ファイル転送 ControlSession 一覧、起動、停止、改名、subscribe、 restart_now RosterPublisher roster とセッションのイベントを relay に送る SessionStore セッションの registry:開いている セッション、Recents、タイトル、 アカウント、レート制限 ShimProcess × N 走っているセッションごとに 1 つ。 Node の子プロセスを持ち、出力を attach 中の全クライアントへ配る コーディネータ群 keep-alive、recap、タイトル生成、 背景タスクの照合、reaper、 アップグレード確認 stdin / stdout NDJSON 各 SHIM の子プロセス node vscode-shim require("vscode") を偽装: webview、workspace、secrets extension.js Claude Code 拡張機能を 配布されたまま読み込む spawn claude CLI stream-json の入出力。 SSH 経由のときはラッパー越し
この MacCanopy.appペインごとに WKWebView
別の MacCanopy.appmirror ペイン
iPhoneCanopy Mobile同じ接続
Unix socket / TCP · Tailscale
LaunchAgent · Mac ごとに 1 つcanopydCanopy --daemon
MirrorServerattach、replay、asset
ControlSession一覧、起動、停止
SessionStoreセッションの registry
ShimProcess × N全クライアントへ配る
RosterPublisher → Relayroster、イベント、通知。webview のストリームは通らない
stdin / stdout NDJSON
node vscode-shimrequire("vscode") を偽装
↓
extension.js配布されたまま読み込む
↓ spawn
claude CLIstream-json の入出力
青い矢印は Canopy 自身の NDJSON。琥珀色はプロセスの境界。relay が運ぶのは、マシンとセッションの一覧、スマホ向けのイベント(発言やツール行の文面を含む)、通知。webview のストリームと transcript の replay は relay を通らず、クライアントとデーモンの間を Unix socket か Tailscale で直接流れる。
Runtime

プロセス

1 つのバイナリが 2 役をこなす。CanopyMain は NSApplication に触る前に引数を見る。--daemon ならデーモン、--unregister-daemon なら LaunchAgent の登録解除、それ以外は SwiftUI アプリを起動する。

GUI · Canopy.app

ウィンドウ

サイドバー、最大 6 ペインの分割表示、ランチャー、設定、MacroPad。この Mac のセッションの shim は持たない。OpenSession.isDaemonHosted なペインは MirrorPaneView で attach する。別の Mac のセッションを開くペインと同じ経路。

デーモン · canopyd

セッションの持ち主

launchd が LaunchAgent として起動する。ログインセッションの中で動くので、CLI の OAuth トークンを login keychain から読める。NSApplication を作らず RunLoop.main で回るので、LaunchServices からはアプリの 2 つ目のインスタンスに見えない。

子プロセス · node

vscode-shim

Resources/vscode-shim/index.js が require("vscode") を横取りし、extension.js を activate する。拡張機能が使う API を 10 個ほどの小さなモジュールで用意する。未実装のメンバーへのアクセスは Proxy がログに残すので、黙って失敗しない。

孫プロセス · claude

CLI

拡張機能が -p --input-format stream-json --output-format stream-json --verbose --include-partial-messages で起動する。SSH remote では、代わりに ssh-claude-wrapper.sh を経由する。

Data flow

メッセージの流れ

この Mac のペインで入力して Return を押したときに起きること。別の Mac やスマホのペインも同じ道を通る。違うのは Unix socket が Tailscale 上の TCP になることだけ。

行き:プロンプト

  1. WKWebView拡張機能の React UI が acquireVsCodeApi().postMessage を呼ぶ。Canopy のスタブがそれを webkit.messageHandlers への post に変える。
  2. GUI · RemoteMirrorBridgeペインの session 接続で、1 行の NDJSON としてデーモンの socket に送る。
  3. canopyd · MirrorServer接続は 1 つのセッションに attach 済み。行はそのセッションの ShimProcess に渡る。
  4. ShimProcess → nodeshim の stdin に書く。window.js が webview メッセージとして拡張機能に届ける。
  5. extension.js → CLI拡張機能が stream-json のユーザーターンを CLI の stdin に書く。

帰り:応答

  1. CLIAnthropic の SSE イベントを stream_event 行として出し、続けて assistant と result を出す。
  2. extension.jsそれぞれを io_message に包んで自分の webview に post する。
  3. node shimstdout に書く。日本語の太字の修復(CJK emphasis repair)はここで、Node を出る前にかかる。
  4. ShimProcess自分のトラッカー(ステータスバー、活動状態、背景タスク、レート制限)のために 1 回読み、attach 中の全クライアントへ送る。
  5. WKWebViewwebview が描く。途中の変換は CJK の太字の修復だけ。拡張機能の UI が自分のメッセージを自分で描く。
Protocol

接続

クライアントはデーモンに 2 種類の接続を張る。どちらも改行区切りの JSON。

接続本数最初の行運ぶもの
controlクライアントごと、デーモンごとに 1 本hello {token, protocolVersion}id 付きのリクエストと、それぞれ 1 つの response:list_sessions、list_folders、browse_dir、mkdir、open_session、stop_session、rename_session、restart_session、switch_account、list_accounts、request_recap、mirror_status、restart_now など。subscribe の後は session_state と upgrade_state の push。
sessionattach 中のペインごとに 1 本attachwebview 自身の NDJSON を双方向に。attach 時の transcript の replay、要求に応じた webview の asset、ファイル転送、open_url や notify などサーバからクライアントへの指示。
Lifecycle

セッションのライフサイクル

ペインを閉じることと、セッションを止めることは別の操作。古いセッションを開く、止まったセッションに戻る、再起動後にペインを復元する。この 3 つは 1 本の attach 経路でまかなう。

実行中・attach あり

shim が動いていて、少なくとも 1 つのクライアントが見ている。keep-alive がプロンプトキャッシュを温めるのはこの状態のセッションだけ。

実行中・attach なし

Cmd+W で閉じるのはペインで、セッションではない。デーモンの中で走り続け、全クライアントの Open 一覧に残る。

停止

Stop Session か reaper で止まる。reaper の条件は、クライアントなし、作業中でも質問中でもなく、許可待ちも背景タスクもない状態が 15 分。行は Recents に戻る。

デーモン再起動

新しいビルドがディスクに来ても、失う作業がなくなるか、Restart now が押されるまで待つ。クライアントは attach し直し、セッションは transcript から再開する。

attach は resume を兼ねる。デーモンが動かしていないセッションに open 付きの attach が来たら、デーモンはその場で resume する。だから「保存して終了」は shim の状態を持ち越さない。保存したセッションに attach し直せば resume される。
Ownership

状態の持ち主

状態持ち主補足
走っているセッション、shim、活動状態デーモンcanopyd の中の SessionStore.openSessions
Recents、フォルダ、タイトルデーモン(Mac ごと)クライアントが list_sessions / list_folders で聞く。relay には載せない
セッションに効く設定デーモン既定の permission mode、keep-alive、recap、worktree の seed、アカウント、モデルプロバイダ
レート制限デーモンアカウントごとに集めてクライアントに push
ペインの並びと幅、ウィンドウ、フィルタ、MacroPadクライアント各 Mac の GUI が自分のレイアウトを持つ
マシン一覧、presence、開いているセッションの rosterrelayスマホ向けのイベント(発言の文面を含む)と通知の本文も relay を通る。webview のストリームと transcript の replay は通らない
Operations

デーモンの運用

登録と起動

  • Release の GUI は起動のたびに LaunchAgent(SMAppService.agent、plist はバンドル内)を確かめ、なければ登録する。Debug は CANOPY_REGISTER_DAEMON=1 のときだけ登録する。
  • ペインが attach する前に DaemonSupervisor が socket を確かめる。登録済みなのに応答がなければ launchctl kickstart して socket が開くのを待つ。未登録(Debug)や承認待ちのとき、または launchd が起こせなかったときは、GUI が --daemon を普通の Process として自分で起動する。
  • デーモンはアプリと同じ identity なので、フルディスクアクセスの許可は両方に効く。

アップデート

  • Sparkle がアプリを差し替える。デーモンはディスク上の新しいビルドに気づき、再起動を止めているものを数える。たとえば走っているターン、答え待ちの質問、背景タスク、未送信のプロンプト、自分で attach し直せないクライアント。
  • 止めるものがなくなったら、デーモンは daemon_restarting を送り、shim を止めて exit 1 する。launchd が新しいビルドを起動する。
  • サイドバーの下端に、待っている更新と、それを止めているセッションと、Restart now ボタンが出る。古い Claude Code 拡張機能のまま動いているセッションも並び、個別に Restart できる。
Client

Mac クライアント

SessionStore は GUI のモデルでもある。開いている行、Recents、ペイン、フォーカス中のペインを持つ。各ペインは PaneSlot で、幅は重みとして持つ。WeightedPaneLayout(SwiftUI の独自 Layout)が詳細カラムを重みで分け、親から提案された幅をそのまま返す。これがウィンドウが勝手に広がり続けるのを止めている。

サイドバーの Open ブロックはペインの並びの地図になっている。強調された行を上から読むと、ペインを左から読んだ順になる。サイドバーのドットと MacroPad の LED は、どちらも SessionActivity という 1 つの分類を読む。

各ペインの webview はセッションごとの entry ファイルを読み込み、Canopy の注入スクリプトを受け取る。acquireVsCodeApi のスタブ、テーマ CSS、画像プレビュー、スクロール位置の保持、入力欄へのフォーカス、recap の行。

Reach

ほかの経路

Mac から Mac

別の Mac のデーモン

サイドバーは relay から他の Mac のセッションを並べる。開くと、その Mac の canopyd に Tailscale 経由で .mirror ペインが attach する。transcript もファイルも CLI もその Mac に残る。

スマホ

Canopy Mobile

relay で Mac を見つけ、Tailscale 越しに同じ control / session プロトコルを話す。通知は relay の push で届く。

Mac 以外のホスト

SSH remote

デーモンが動かない Linux、WSL、Windows 向け。shim はこの Mac で動き、拡張機能の claudeProcessWrapper が ssh-claude-wrapper.sh を指して、ホスト上の claude を実行する。transcript は描かれず、@ メンションはローカルのファイルを読む。

クラウド

Claude Code on the Web

クラウドのセッションは Keychain の OAuth トークンで /v1/sessions から一覧し、短命の shim(RemoteSessionsBridge)でローカルのセッションにテレポートする。

Code

ソースマップ

注記がなければ Sources/Canopy/ の下。全体像のどの部分に属するかで分けた。

入口とデーモン

  • CanopyMain GUI かデーモンかを選ぶ
  • CanopyDaemon run loop と終了処理
  • DaemonSupervisor、DaemonRegistration
  • DaemonPaths、DaemonConfig
  • DaemonUpgrade、DaemonUpgradeCenter
  • SessionReaper、DaemonReaper

プロトコル

  • ControlProtocol、ControlSession、ControlClient
  • MirrorServer、MirrorClient、MirrorWire
  • MirrorSink、MirrorAccess、MirrorEndpoint
  • MirrorStatusFrame、MirrorFileTransfer
  • NDJSONLineAssembler

セッション

  • ShimProcess shim とトラッカー
  • SessionStore、OpenSession
  • KeepAlive*、Recap*、SessionTitle*
  • ClaudeSessionHistory JSONL の読み取り
  • SharedRateLimitData、ClaudeAccount

Mac クライアントの UI

  • CanopyApp、Sidebar、Detail
  • WeightedPaneLayout、PaneSlot、PaneDivider
  • MirrorPaneView attach するすべてのペイン
  • LauncherView、StatusBarView
  • MacroPad/ USB キーパッド

relay とスマホ

  • Roster/RosterPublisher
  • Roster/RemoteRosterWatcher
  • Roster/RosterNotifier、RosterReply
  • PhoneReplyQueue

Node 側

  • Resources/vscode-shim/index.js 入口
  • window.js webview のブリッジ
  • workspace.js、context.js
  • cjk-emphasis*.js ストリームの修復
  • Resources/ssh-claude-wrapper.sh