Canopy は Claude Code の VS Code 拡張機能を、手を加えずに、VS Code なしで動かす。3.0 からは、Mac 上のセッションはすべてバックグラウンドのデーモンが持つ。Mac のアプリも、別の Mac も、iPhone も、そこに attach するクライアントになった。
押さえる考えは 2 つ。1 つ目、Canopy は Claude Code のプロトコルを再実装しない。拡張機能の extension.js をそのまま Node で読み込み、vscode モジュールだけを小さな偽物に差し替える。画面は拡張機能自身の React webview を WKWebView で表示する。
2 つ目、セッションを持つプロセスと、見ているウィンドウは別物。shim を動かし続けるのは canopyd で、どのマシンのどのウィンドウもクライアントにすぎない。
Canopy --daemon1 つのバイナリが 2 役をこなす。CanopyMain は NSApplication に触る前に引数を見る。--daemon ならデーモン、--unregister-daemon なら LaunchAgent の登録解除、それ以外は SwiftUI アプリを起動する。
サイドバー、最大 6 ペインの分割表示、ランチャー、設定、MacroPad。この Mac のセッションの shim は持たない。OpenSession.isDaemonHosted なペインは MirrorPaneView で attach する。別の Mac のセッションを開くペインと同じ経路。
launchd が LaunchAgent として起動する。ログインセッションの中で動くので、CLI の OAuth トークンを login keychain から読める。NSApplication を作らず RunLoop.main で回るので、LaunchServices からはアプリの 2 つ目のインスタンスに見えない。
Resources/vscode-shim/index.js が require("vscode") を横取りし、extension.js を activate する。拡張機能が使う API を 10 個ほどの小さなモジュールで用意する。未実装のメンバーへのアクセスは Proxy がログに残すので、黙って失敗しない。
拡張機能が -p --input-format stream-json --output-format stream-json --verbose --include-partial-messages で起動する。SSH remote では、代わりに ssh-claude-wrapper.sh を経由する。
この Mac のペインで入力して Return を押したときに起きること。別の Mac やスマホのペインも同じ道を通る。違うのは Unix socket が Tailscale 上の TCP になることだけ。
acquireVsCodeApi().postMessage を呼ぶ。Canopy のスタブがそれを webkit.messageHandlers への post に変える。ShimProcess に渡る。window.js が webview メッセージとして拡張機能に届ける。stream_event 行として出し、続けて assistant と result を出す。io_message に包んで自分の webview に post する。クライアントはデーモンに 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。 |
| session | attach 中のペインごとに 1 本 | attach | webview 自身の NDJSON を双方向に。attach 時の transcript の replay、要求に応じた webview の asset、ファイル転送、open_url や notify などサーバからクライアントへの指示。 |
ControlProtocol.version は 1。合わなければ理由付きの hello_error を返して切る。attach 行のフラグで追加機能を選ぶ。status、usage、images、restart、compress: "br"(4 KB 以上の行を Brotli で Z <n> <m> フレームにする)。~/Library/Application Support/Canopy/daemon-<bundle id>.sock。Debug のデーモン(sh.saqoo.Canopy.debug)が Release の socket を奪わないため。103 バイトを超えたらユーザーごとの一時ディレクトリに置く。ペインを閉じることと、セッションを止めることは別の操作。古いセッションを開く、止まったセッションに戻る、再起動後にペインを復元する。この 3 つは 1 本の attach 経路でまかなう。
shim が動いていて、少なくとも 1 つのクライアントが見ている。keep-alive がプロンプトキャッシュを温めるのはこの状態のセッションだけ。
Cmd+W で閉じるのはペインで、セッションではない。デーモンの中で走り続け、全クライアントの Open 一覧に残る。
Stop Session か reaper で止まる。reaper の条件は、クライアントなし、作業中でも質問中でもなく、許可待ちも背景タスクもない状態が 15 分。行は Recents に戻る。
新しいビルドがディスクに来ても、失う作業がなくなるか、Restart now が押されるまで待つ。クライアントは attach し直し、セッションは transcript から再開する。
open 付きの attach が来たら、デーモンはその場で resume する。だから「保存して終了」は shim の状態を持ち越さない。保存したセッションに attach し直せば resume される。| 状態 | 持ち主 | 補足 |
|---|---|---|
| 走っているセッション、shim、活動状態 | デーモン | canopyd の中の SessionStore.openSessions |
| Recents、フォルダ、タイトル | デーモン(Mac ごと) | クライアントが list_sessions / list_folders で聞く。relay には載せない |
| セッションに効く設定 | デーモン | 既定の permission mode、keep-alive、recap、worktree の seed、アカウント、モデルプロバイダ |
| レート制限 | デーモン | アカウントごとに集めてクライアントに push |
| ペインの並びと幅、ウィンドウ、フィルタ、MacroPad | クライアント | 各 Mac の GUI が自分のレイアウトを持つ |
| マシン一覧、presence、開いているセッションの roster | relay | スマホ向けのイベント(発言の文面を含む)と通知の本文も relay を通る。webview のストリームと transcript の replay は通らない |
SMAppService.agent、plist はバンドル内)を確かめ、なければ登録する。Debug は CANOPY_REGISTER_DAEMON=1 のときだけ登録する。DaemonSupervisor が socket を確かめる。登録済みなのに応答がなければ launchctl kickstart して socket が開くのを待つ。未登録(Debug)や承認待ちのとき、または launchd が起こせなかったときは、GUI が --daemon を普通の Process として自分で起動する。daemon_restarting を送り、shim を止めて exit 1 する。launchd が新しいビルドを起動する。SessionStore は GUI のモデルでもある。開いている行、Recents、ペイン、フォーカス中のペインを持つ。各ペインは PaneSlot で、幅は重みとして持つ。WeightedPaneLayout(SwiftUI の独自 Layout)が詳細カラムを重みで分け、親から提案された幅をそのまま返す。これがウィンドウが勝手に広がり続けるのを止めている。
サイドバーの Open ブロックはペインの並びの地図になっている。強調された行を上から読むと、ペインを左から読んだ順になる。サイドバーのドットと MacroPad の LED は、どちらも SessionActivity という 1 つの分類を読む。
各ペインの webview はセッションごとの entry ファイルを読み込み、Canopy の注入スクリプトを受け取る。acquireVsCodeApi のスタブ、テーマ CSS、画像プレビュー、スクロール位置の保持、入力欄へのフォーカス、recap の行。
サイドバーは relay から他の Mac のセッションを並べる。開くと、その Mac の canopyd に Tailscale 経由で .mirror ペインが attach する。transcript もファイルも CLI もその Mac に残る。
relay で Mac を見つけ、Tailscale 越しに同じ control / session プロトコルを話す。通知は relay の push で届く。
デーモンが動かない Linux、WSL、Windows 向け。shim はこの Mac で動き、拡張機能の claudeProcessWrapper が ssh-claude-wrapper.sh を指して、ホスト上の claude を実行する。transcript は描かれず、@ メンションはローカルのファイルを読む。
クラウドのセッションは Keychain の OAuth トークンで /v1/sessions から一覧し、短命の shim(RemoteSessionsBridge)でローカルのセッションにテレポートする。
注記がなければ Sources/Canopy/ の下。全体像のどの部分に属するかで分けた。
CanopyMain GUI かデーモンかを選ぶCanopyDaemon run loop と終了処理DaemonSupervisor、DaemonRegistrationDaemonPaths、DaemonConfigDaemonUpgrade、DaemonUpgradeCenterSessionReaper、DaemonReaperControlProtocol、ControlSession、ControlClientMirrorServer、MirrorClient、MirrorWireMirrorSink、MirrorAccess、MirrorEndpointMirrorStatusFrame、MirrorFileTransferNDJSONLineAssemblerShimProcess shim とトラッカーSessionStore、OpenSessionKeepAlive*、Recap*、SessionTitle*ClaudeSessionHistory JSONL の読み取りSharedRateLimitData、ClaudeAccountCanopyApp、Sidebar、DetailWeightedPaneLayout、PaneSlot、PaneDividerMirrorPaneView attach するすべてのペインLauncherView、StatusBarViewMacroPad/ USB キーパッドRoster/RosterPublisherRoster/RemoteRosterWatcherRoster/RosterNotifier、RosterReplyPhoneReplyQueueResources/vscode-shim/index.js 入口window.js webview のブリッジworkspace.js、context.jscjk-emphasis*.js ストリームの修復Resources/ssh-claude-wrapper.sh