Skip to content

v0.29.0

Choose a tag to compare

@Kewton Kewton released this 30 Aug 00:21
· 40 commits to main since this release
98b55f5

[0.29.0] - 2026-08-30

Highlight: スマホから CommandMate を使うための接続設定を、PC で 1 コマンド・スマホで QR を読むだけに畳み込む commandmate remote を追加した(#1937 Phase 1)。これまでは LAN 内 IP・CM_BIND・HTTPS・VPN/Tunnel・認証トークンの手入力を利用者が自分で組む必要があり、CommandMate の価値を体験する前に離脱しうる導入障壁になっていた。QR に長期トークンを埋めず、一度限り・既定 10 分で失効するペアリングコードを使う(旧 QR ログイン #383 は発行側を撤去。受理は 1 リリース残す)。秘匿値は env に置かず mode 0600 のハンドオフファイルへ置く — src/lib/tmux/** は pane にサーバの環境変数をそのまま継承させるため、env に置くと CommandMate が動かしているエージェント自身が長期トークンを読めてしまうからである。CM_BIND の既定 127.0.0.1 は変わらず、remote は外への口を 1 つ増やすだけで、stop は CommandMate が作ったものだけを片付ける。実機 UAT(3 OS × 2 Provider)で Tailscale 経路は全項目合格し、Cloudflare 経路は公開 URL が remote の終了と同時に死ぬ不具合(#2146)を実測で発見・修正して再確認した。あわせてサイドバーの hover-freeze がデータ到着前の空配列を凍結する不具合(#2058 / #2059)と、GET /api/worktrees の一覧と tmux 状態の分離(#2060、既定 346ms → ?includeStatus=0 で 39ms)を入れている。

Added

  • feat(remote): Tailscale Serve Provider を実装し、利用者の既存 serve 設定を消さない teardown を実測で固定 (#1937): src/lib/remote/tailscale.ts の stub(detect() が常に available:false)を実装に差し替え、TAILSCALE_NOT_IMPLEMENTED_REASON の export を削除した。detect() は PreflightChecker.checkDependency() と同じ spawnSync('tailscale', ['version'], {timeout:5000})(shell を挟まない=MF-SEC-1)で available を、tailscale status --json の BackendState と Self.DNSName で ready を別々に答える。start() は起動前に tailscale serve status --json のスナップショットを採って RemoteHandle.preexisting に載せ、serve --bg --yes --https=443 --set-path / http://127.0.0.1:<port> で公開する(upstream は常に 127.0.0.1。0.0.0.0 / localhost を禁止する argv の assert つき、設計 §9.2)。stop() は planStop() が返す「owned に在り preexisting に無い」キーだけを serve --https=<port> --set-path <path> --yes off で除去する。設計方針書 §6.4 は「想定」だったため実機で U-2 を採取した(2026-08-29 / Tailscale 1.102.3 / macOS、記録は dev-reports/issue/1937/u2-tailscale-serve.md): (1) off は serve --help に 1 件も無いが実在し、--set-path を伴うときだけ単一ハンドラ削除になる。伴わない serve --https=443 off と、パスを位置引数で渡す serve --https=443 / off は、どちらも当該ポートの全ハンドラを消して serve status --json を {} に戻した(exit 0・無警告)。しかも前者は serve 成功時に Tailscale 自身が案内する文字列である。(2) 既に使われているパスへ serve すると無警告で上書きされ元の upstream は復元不能なので、start() は対象キーがスナップショットに在れば起動を拒否する。(3) get-config / set-config は service スコープで node の serve ハンドラを対象に取れず、復元経路として成立しない。これを受けて tests/unit/config/remote-destructive-command-guard.test.ts の禁止語を 4 → 8 に拡張し(serve clear / serve drain / set-config --all / --set-path を伴わない off を追加)、全語に陽性対照を付けたうえで「陽性対照の数 == ルールの数」も assert した。R1 が否定対照として祝福していた ['serve','--https=443','/','off'] は実測で全消しコマンドだったため陽性対照へ移した。
  • docs(user-guide): スマホからのアクセス導線を commandmate remote 先行に組み替え、U-8 の OS 対応可否を実測で確定 (#1937): docs/user-guide/webapp-guide.md の「モバイルからのアクセス」を方法1 = commandmate remote(QR ペアリング)/ 方法2 = 同一 LAN 直結の順に組み替え、README.md の FAQ も remote の 1 行手順を先に置いた。CM_BIND=0.0.0.0 の手順は残したうえで「認証も暗号化も無いまま LAN に開くため、同じ Wi-Fi にいる誰でも認証を求められずリポジトリ・ターミナル・エージェントを操作できる」というリスクと、使用後に 127.0.0.1 へ戻す手順を明記(remote は CM_BIND を読みも書きもせず、サーバは 127.0.0.1 bind のままであることも併記)。あわせて U-8 の OS 対応可否表を webapp-guide に置き、「実測済み」「未実施」「未検証」を凡例で区別した——macOS (Darwin arm64) と Linux (Debian 12 aarch64 / docker) は Provider 検出・承認ゲート・status / stop を公開 Tunnel を作らない範囲で実測し(cloudflare-quick: ready / tailscale-serve は未実装 stub のため全 OS で DEPENDENCY_ERROR exit 1 / 非対話 --yes 無しは CONFIG_ERROR exit 2 で provider.start() の手前で停止、両 OS で exit code もメッセージも一致)、Linux はコンテナ実測でベアメタルとはネットワーク構成が異なりうる旨を注記、WSL2 は検証環境が無いため「未検証」と明記して既知のリスク(localhost 転送の構成差により Tunnel のアップストリーム 127.0.0.1 が WSL2 内部を指すか Windows 側を指すかが構成依存)を残した。公開 Tunnel の実疎通は全 OS とも R12(実機 UAT)送りとし、本作業では公開 Tunnel を 1 本も作っていない(実測の手順・生ログ・副作用ゼロの証跡は dev-reports/issue/1937/u8-os-matrix.md)。Web Push 節の HTTPS 調達手段にも commandmate remote を最上位で追加し、U-6 の決定(Tunnel 経由でもログイン Cookie に Secure が付かないのは、立てると http://127.0.0.1:3000 のローカル利用で Cookie が拒まれて壊れるための正しい挙動)を利用者向けに明記した。コードは 1 行も変更していない。
  • docs(cli): commandmate remote のコマンドリファレンスを CLI 操作ガイドと CLAUDE.md に追加 (#1937): docs/user-guide/cli-operations-guide.md に commandmate remote 節を新設し、remote(既定 up)/ remote status / remote stop の3サブコマンドと全フラグ(--provider / --expires / --pairing-expires / -p, --port / --yes / --json)、終了コード表(0 SUCCESS / 1 DEPENDENCY_ERROR / 2 CONFIG_ERROR / 3 START_FAILED / 4 STOP_FAILED / 99 UNEXPECTED_ERROR。remote は新しいコードを追加していないため「全終了コード一覧」の 1 / 3 / 4 の行にも remote を追記した)、remote status の出力例を記載した。ドキュメント側で明示したのは、実装が意図的にしないことが読者にとっての要点だから: Provider は cloudflare-quick のみ実装済みで tailscale-serve は未実装のスタブ(--provider tailscale は exit 1)であること、公開 Tunnel は常に明示承認が要り非対話環境では --yes が無ければ拒否されること、Tailscale が使えないことは公開 Tunnel へ自動で切り替える理由にならないこと、remote status は URL は出すがペアリングコードもセッショントークンも出さないこと(コードを示すのは up の一度だけで、up --json の pairingUrl はコードを含むためログに残さないよう注記した)、remote stop は状態ファイルが読めないとき Provider を推測して片付けにいかず SUCCESS で終わること、--expires 切れで閉じるのは外部への口だけでサーバは落とさないこと、渡す環境変数は3つだけで CM_BIND は読みも書きもしないこと、Auto-Yes 有効化フラグが存在しないこと。あわせて U-6 の決定(Tunnel 構成では外側が HTTPS でオリジンは平文 HTTP のため認証 Cookie に Secure が付かない。Secure を立てると 127.0.0.1 への HTTP アクセスで Cookie が拒まれローカル利用が壊れるのでこれは正しい挙動であり、コードは変更しない)を明記した。CLAUDE.md へは既存の ls / send などと同じ粒度でコマンドとフラグの列挙だけを足した(8行、19,971 bytes で 35,000 bytes 制限内)。節見出しを H2 ではなく H3 にしたのは tests/unit/docs/ja-en-heading-parity.test.ts が ja/en の ## 個数一致を固定しており、英語版 docs/en/user-guide/cli-operations-guide.md が本作業の scope 外のため(同ファイル内の ### commandmate interrupt と同じ前例に倣った)。英語版への remote 節の追加は未対応で、別途必要。
  • docs(security): commandmate remote のセキュリティ面(Quick Tunnel のリスク・明示承認・Cookie Secure)を docs に明記 (#1937): docs/security-guide.md に Threat Model > Provider Tunnel と Recommended Authentication Methods > Option 4: commandmate remote を追加し、Option 1〜3 が「利用者が自分で組む」方式であるのに対し remote は「CommandMate が設定を代行し、自分が作ったと記録したものだけを片付ける」方式であることを位置づけた。Cloudflare Quick Tunnel については ランダムな公開 URL がインターネットに出る/URL は起動ごとに変わる/長期・本番利用には使わない/CommandMate のトークン認証は常に有効/明示承認なしには作らない(非対話では --yes が無ければ CONFIG_ERROR=exit 2) の 5 点を明記。U-6(Tunnel 構成でセッション Cookie に Secure が付かないこと)は「正しい挙動」として理由まで記載した — secure は CM_HTTPS_CERT の有無で決まり、Tunnel は外側が HTTPS でオリジンは平文 HTTP なので付かない。無条件に立てると http://localhost:3000 でブラウザが Cookie を拒否してローカル利用が壊れる一方、外側は既に HTTPS で網線上の盗聴リスクは下がっており、HttpOnly / SameSite=Strict は経路によらず常に付く。あわせてペアリングコードの性質(一度限り・既定 10 分・Crockford Base32 26 文字=128 bit・平文は非永続で ~/.commandmate/remote-pairing.json は mode 0600 かつ消費=ファイルの不在)、起動時 env が CM_AUTH_TOKEN_HASH / CM_AUTH_EXPIRE / CM_REMOTE_PAIRING_FILE の 3 つだけで 3 つ目は秘匿値ではなくパスであること(src/lib/tmux/** が pane にサーバ環境をそのまま継承させるため)、CM_BIND を読みも書きもせず既定 127.0.0.1 が変わらないこと、--auto-yes 系フラグを作っていないことが Auto-Yes 無効の構造的保証であること、期限切れで閉じるのは外部への口だけでサーバは落とさないこと、remote stop は状態が読めないとき Provider を推測して片付けないことを記載し、Security Checklist に remote 用 8 項目を追加した。tailscale-serve Provider は未実装の stub である旨を明示し、既存の Option 3: Tailscale(利用者自身が Tailscale を使う話。今日も有効)と混同しないよう注記を置いた。docs/TRUST_AND_SAFETY.md の「外部アクセス時の依存(任意)」には cloudflared への依存・何が外部に出るのか・片付けは CommandMate が作ったものだけ、の 3 小節を日本語で追記。コードは 1 行も変更していない(U-6 は現状維持+docs 明記が決定事項)。
  • feat(cli): commandmate remote でサーバを公開しスマホと QR ペアリングできるようにした (#1937): remote(既定 up)/ remote status / remote stop を追加。up は 依存検出 → 既存サーバ確認 → トークンとペアリングコードの生成 → runStart({daemon:true}) → waitForServer() → Provider.start() → 状態ファイル書き出し → QR 表示 の順で走り、start を再実装せず既存の runStart()(#1195 で exit しない核として切り出し済み)を合成する。Provider の選択と公開 Tunnel の承認は Provider registry ではなくこのコマンドの責務で(設計 §6.2)、--provider を指定した Provider が使えないときも 1 つも ready でないときも DEPENDENCY_ERROR(1) で止まり、Tailscale が駄目でも自動で公開 Tunnel へ切り替えない。Quick Tunnel は起動前に必ず明示承認を取り、非対話で --yes が無ければ CONFIG_ERROR(2)(承認文言は REVERSE_PROXY_WARNING と同じ src/cli/config/security-messages.ts に定数として置いた)。既にサーバが auth 有効で動いている場合は、そのトークンの平文を CommandMate が保持していないためペアリングできず CONFIG_ERROR で中断する(設計 U-4)。--expires(既定 8h)は CM_AUTH_EXPIRE でサーバ側のトークン期限を固定しつつ 外部への口だけを閉じる — 期限切れで commandmate stop 相当を走らせると PC のローカル利用まで巻き添えで死ぬため、サーバは落とさない(期限判定は remote status 実行時に行う)。remote stop は状態ファイル(~/.commandmate/remote.json、mode 0600)が読めないとき Provider を推測して片付けにいかず「片付けるものが分からない」と言って SUCCESS で終わる(Tailscale Serve の利用者設定を消すと復元手段が無いため)。起動時に渡す環境変数は CM_AUTH_TOKEN_HASH / CM_AUTH_EXPIRE / CM_REMOTE_PAIRING_FILE の 3 つだけで、CM_BIND も Auto-Yes 有効化キーも含まないことを完全一致テストで両方向に固定した(agent-launch-plan-secrets-1933.test.ts と同じ形)。3 つ目は秘匿値ではなくパスで、平文の長期トークンは mode 0600 のハンドオフファイル側にある — src/lib/tmux/** は pane にサーバの環境変数をそのまま継承させるので、env に置けば CommandMate が動かしている Claude / Codex 自身がそれを読めてしまう(設計 §7.2)。Provider は同じリリースの R2 で Cloudflare Quick Tunnel が実装され、cloudflared が入った環境では実際に Quick Tunnel 経路が動く(隔離実測: registry が cloudflare-quick を available:true / version:2025.4.0 / ready:true で返す)。R3 Tailscale は U-2 の実機実測が取れないため stub のままで、tailscale しか無い環境の commandmate remote は DEPENDENCY_ERROR で「使える Provider が無い」と報告する。
  • feat(remote): Cloudflare Quick Tunnel Provider を実装し U-3(URL 取得経路)を実測で確定 (#1937): src/lib/remote/cloudflare.ts の stub を実装に差し替えた(設計 §6.4)。detect() は PreflightChecker.checkDependency() と同じ spawnSync('cloudflared', ['--version'], {timeout:5000}) で判定する(shell を挟まない=MF-SEC-1)。Quick Tunnel はアカウントもログインも要らないので ready は available と常に一致する。start() は cloudflared tunnel --url http://127.0.0.1:<port> --no-autoupdate --metrics 127.0.0.1:<空きポート> --pidfile <state dir>/cloudflared.pid を spawn し、preexisting: null(Quick Tunnel は永続設定を作らない)と owned.pid を持つ RemoteHandle を返す。stop() は owned.pid への SIGTERM だけを行い、プロセスも spawn しない。URL 取得は U-3 を実機で実測して確定した(2026-08-29 / cloudflared 2025.4.0、記録は dev-reports/issue/1937/u3-quicktunnel-url.md): --metrics のサーバに /quicktunnel は実在し、200 / text/plain / {"hostname":"<name>.trycloudflare.com"} を返す(scheme を含まない裸のホスト名なので https:// は実装側で前置する)。ただし実測でバナーが metrics サーバより約 1 秒先に出ることが分かったため、素直に毎周回で両方を見ると第 2 候補が常に先に当たり /quicktunnel がデッドコードになる。そこで METRICS_PREFERENCE_WINDOW(既定 5 秒)の優先窓を置き、その間は第 1 候補だけを見るようにした。第 2 候補の stderr パーサは文言をアンカーにしない — 実測では URL がバナー文言と別の行に出るうえ、同じ stderr に https://www.cloudflare.com/website-terms/ などの無関係な URL が混ざるため、URL の形(https://<label>.trycloudflare.com)に一致させ、その無関係な URL を陰性対照としてテストに入れた。SIGTERM 後に公開 URL が HTTP 530 になって失効することも実測済み。--metrics に 127.0.0.1: を明示するのは help が「仮想環境下では既定が全インタフェースに bind し得る」と言っているためで、アップストリームと metrics がいずれも 127.0.0.1 であること(0.0.0.0 / localhost の禁止)は start() に渡る argv の assert で固定した(設計 §9.2、陽性対照つき)。破壊的変更: CLOUDFLARE_NOT_IMPLEMENTED_REASON の export を削除した(Cloudflare Provider は実装されたので、未実装と言う定数は嘘になる)。TAILSCALE_NOT_IMPLEMENTED_REASON は R3 が着地するまで残る。src/lib/remote/types.ts と tailscale.ts は未変更。
  • feat(auth): commandmate remote の 1 度限りペアリングコードと /login の #code= 受け口を追加 (#1937): 新設 POST /api/remote/pair が、CM_REMOTE_PAIRING_FILE が指す mode 0600 のハンドオフファイル(~/.commandmate/remote-pairing.json)を毎リクエスト読み、コードのハッシュを timingSafeEqual で照合してからcookie を作る前に unlinkSync し、認証 cookie を張る(ボディにトークンは載せない)。消費済みフラグはファイルの不在そのもので、2 回目・TTL(既定 10 分)超過・ファイル破損はいずれも 410 Gone、コード不一致は 401、remote 未起動(env 無し)は 404、固定キー 5 回/15 分のレート制限超過は 429 を返す。秘匿値を env に置かないのは実測に基づく決定で、src/lib/tmux/** は子プロセスに env: を渡さず pane がサーバの環境変数をそのまま継承するため、env に置けば CommandMate が動かしている Claude / Codex / OpenCode 自身が長期トークンを読めてしまう(設計方針書 docs/design/remote-qr-pairing-1937.md §7.2)。表示コードは generateToken() の先頭 128 bit を Crockford Base32 で 26 文字にしたもので、平文はどこにも保存しない。受け口は新規ルートを作らず既存 useFragmentLogin を一般化し、#code= は /api/remote/pair、#token= は従来どおり /api/auth/login へ振り分ける(AUTH_EXCLUDED_PATHS に増えるのは /api/remote/pair の 1 本だけで、配列の完全一致テストで固定)。#token= の受理は 1 リリース残すが deprecation 警告を 1 行出す(トークン本体はログに出さない)。撤去は Phase 2。
  • feat(remote): commandmate remote の Provider 抽象・全消し禁止ガード・ターミナル QR レンダラを追加 (#1937): Phase 1 の R1・R4・R8。src/lib/remote/types.ts に RemoteProvider / RemoteHandle / ProviderDetection / StopOutcome を置き、reset() / cleanupAll() を interface に持たせず stop() の入力を RemoteHandle だけにすることで「Provider の設定を全部読んで消す」が型の上で書けないようにした(Tailscale Serve の設定は tailscaled が持つ永続状態で、利用者が既に自分のサービスを Serve していることがあり、1 つでも消すと回復手段が無い)。planStop() が owned に在り preexisting に無いものだけを revert 対象にし、両方に在るものは StopOutcome.skipped に積んで人間に見せる(黙って skip するのと黙って消すのは外から区別できないため)。provider-registry.ts は detect() を回して Tailscale → Cloudflare の順で候補を全部返すだけで、選択も承認プロンプトもしない(「Tailscale が駄目でも自動で公開 Tunnel へ切り替えない」は選択の規則であって Provider の性質ではなく、非対話判定と --yes を知っているオーケストレータ側に置くべきものだから)— registry の export 一覧と import 先を完全一致で固定しているので、将来 selectProvider() を足すにはそのアサーションを消す必要がある。Provider 実装は available:false を返す stub 2 本(R2/R3 で中身だけ差し替える)。tests/unit/config/remote-destructive-command-guard.test.ts が src/lib/remote/** から tailscale serve reset / cloudflared tunnel cleanup 相当を禁止し、同じテスト内で禁止語を合成してチェッカに食わせ、検出されることを assert する陽性対照つき(検査が 0 件なのと検査が壊れているのは同じ緑になるため)。実ファイルへ禁止語を注入する変異でも赤になることを確認(tailscale.ts:62 で検出、復元後に緑)。ターミナル QR は U-1 を 3 軸で実測して qr.js@0.0.0 の直接依存化に決定(react-qr-code の固定依存として既に lock に載っており増分は package-lock.json 1 行・0 バイト/qrcode は +5,424 KB・+31 パッケージ)、独立実装 qrcode@1.5.4 とモジュール行列 16/16 一致を確認したうえで、package facade が opt.errorCorrectLevel || ErrorCorrectLevel.H で ECC レベル M(数値 0)を黙って H に化けさせる実測不具合を避けるため qr.js/lib/QRCode を直接読む。renderQrToTerminal() は上半ブロックで 2 モジュール行を 1 行に畳み、115 文字の Quick Tunnel URL が level M で 45 モジュール=53 桁(80 桁端末に収まる)。折り返された QR は読めないのに出力できているように見えるため、幅に収まらないときは何も出さず fits:false を返し(formatQrForTerminal() は null)、収まらない場合は ECC を 1 段下げて再試行する一方で quiet zone 4 モジュールは決して削らない。判定根拠は dev-reports/issue/1937/u1-qr-dependency.md。

Changed

  • feat(auth): 長期トークンを QR に埋める /login の QR 生成 UI を撤去 (#1937): #383 の QrCodeGenerator(src/components/auth/QrCodeGenerator.tsx)と同コンポーネント専用の 15 ケース(tests/unit/components/QrCodeGenerator.test.tsx)を削除し、src/app/login/page.tsx から dynamic() import と hidden md:block ブロックを外した。この UI が作っていたのは https://…/login#token=<長期認証トークン> という URL で、Issue #1937 のセキュリティ要件が禁じている当のものである。撤去の根拠は実測(設計 §2.1): 到達経路は /login の hidden md:block だけでスマホの画面幅では表示されず、CM_AUTH_TOKEN_HASH 未設定の既定構成では useAuthEnabled() が false になって /login に留まれず、docs/** と README.md の言及は 0 件(README が案内するスマホ手順は CM_BIND=0.0.0.0 + LAN IP であって QR ではない)。#token= の受理側 useFragmentLogin は無変更で残す(deprecation 警告つき、設計 §2.2)——発行側は即座に消えるが、既に手元に QR を持っている人のために受理は 1 リリース残し、撤去は Phase 2 とする。i18n は locales/{en,ja}/auth.json から QrCodeGenerator 専用の 10 キー(sectionTitle / urlLabel / urlPlaceholder / tokenLabel / tokenPlaceholder / securityNotice / showQrButton / hideQrButton / qrSecurityWarning / httpsWarning)だけを削除し、login/page.tsx が useFragmentLogin のエラー表示に使う 3 キー(autoLoginError / tokenExpiredOrInvalid / rateLimited)と R6 の login.pairing.* は残した。i18n キーの消しすぎは ESLint も tsc も CI も捕まえない(src/i18n.ts に onError / getMessageFallback が無いので欠損キーは raw キー文字列として描画され、tests/setup.ts の next-intl モックはキー文字列自身を返すので存在しないキーでもコンポーネントテストは通る)ため、実辞書を読む tests/unit/i18n/login-auth-keys-1937r7.test.ts と /login のレンダリングを見る tests/unit/app/login/page-qr-removal-1937r7.test.tsx を追加し、残す 6 キーが実コードパスから引かれること・削除した 10 キーが DOM に出ないことの両方を固定した。react-qr-code は package.json に残す(src/cli/utils/qr-terminal.ts がその pin 依存 qr.js/lib/QRCode を読むため)。
  • refactor(api): GET /api/worktrees の一覧(DB)と状態(tmux)を分離し、内訳を構造化ログで可視化 (#2060): 1 リクエストにつき 1 レコードの list:timing(debug/1000ms 超は list:slow を warn にも)を追加し、総時間・DB 部分・tmux 部分・listSessions() 単体・probe fan-out と、外からは見えない probe / capture / health-check の実発行数を並べて出すようにした。併せて additive な opt-OUT ?includeStatus=0 を追加し、これを付けたときだけ tmux 判定を丸ごと省いて DB 行だけを返す(状態キーは false で埋めず省略し、トップレベルに statusIncluded: false を付ける — false の行は「何も動いていない」と区別できず、サイドバーが自信を持って idle を描いてしまうため)。無指定は従来どおり状態込みで、レスポンス形は 1 バイトも変えていない(既存 7 消費側は無変更)。この開発機の実データで実測(worktree 69・tmux セッション 28・repository 21、本番 DB 461MiB のオンライン backup 複製 + 実 tmux): 既定 346ms(DB 37ms / tmux 309ms、うち listSessions() は 7ms で残り 302ms が probe)に対し ?includeStatus=0 は 39ms(−89%、本文 231,887B → 71,178B で −69%)。Issue 本文の「全 worktree × 7 ツールの capture-pane を fan-out」は実測と食い違っており、probe は 494 本組まれるが listSessions() が先に走るため capture の発行は 27 本で、コストは worktree 数ではなく稼働セッション数に比例する(idle な 48 worktree のコストは実質ゼロ)。ただし「tmux 側が重い」という主旨自体は正しく、総時間の 87〜89% が tmux 側だった。UI(useWorktreesCache)の初回 includeStatus=0 化は効果が大きい(初回ペイント 346ms → 39ms)が、当該ファイルが #2058 / #2059 と同時編集中のため本 PR では実装せず、注意点つきで dev-reports/issue/2060/measurement.md に申し送った。

Fixed

  • fix(sidebar): ホバー中のブランチ一覧が空のまま固まる問題と、読み込み中・取得失敗が「ブランチがありません」と表示される問題を修正 (#2058, #2059): サイドバーの hover-freeze(カーソルがリスト内にある間だけ並び順を凍結する仕組み)が空配列も凍結対象にしていたため、初回 fetch 中や fetch 失敗中にカーソルがリスト上にあると expiresAt: Infinity で [] が固定され、届いたブランチが次のポーリング(30 秒/WS 接続時は 60 秒)まで一切描画されなかった。凍結の入口で空リストを弾き、凍結中に空→非空へ変わったスナップショットは破棄して「初回データ到着」を並び替えとして扱わないようにし、さらに mouseleave 1 秒後の解除で useState のバージョンカウンタを bump して再レンダーを起こすようにした(従来は ref を無言で null にするだけで、解除が画面に出るのも次のポーリング待ちだった。カウンタは解除時のみ bump し、凍結の発火時には従来どおり再レンダーを起こさないので、36b173bd が消した「凍結発火とポーリング反映が同一コミットに乗って一瞬ちらつく」現象は再発しない)。useSyncExternalStore ではなく useState カウンタを選んだのは、凍結状態がコンポーネントローカルな ref に書き手 1 つで閉じており、購読すべき外部ストアも tearing も存在しないため(バージョン bump がそのまま購読そのもの)。あわせて #2059 として WorktreeSelectionContext が reducer に持ちながら公開していなかった isLoading を error とともに公開し(外部ソース使用時は WorktreesCacheProvider から externalIsLoading / externalError / externalRefresh を受け取る。従来 refreshWorktrees は useExternal で早期 return するため Sync ボタンごと無反応だった)、isFirstLoad = isLoading && worktrees.length === 0(src/app/page.tsx と同形)でスケルトン、取得失敗かつ 0 件で「ブランチを取得できませんでした+再試行」を出し、「ブランチがありません」を本当に 0 件のときだけに狭めた。useWorktreesCache の初回取得失敗には 2s → 5s → 10s の打ち切りつきリトライ(キャッシュが空のときのみ。データがあるときの失敗は「古い一覧」であって空画面ではないので通常ポーリングに委ねる)を入れ、失敗を console.error 1 行で可視化した(既存の唯一のログは WorktreeSelectionContext のポーリング effect 内にあったが、WorktreesCacheProvider が常に externalWorktrees を渡すため到達不能だった)。/login へのリダイレクト(fetch が 307 を追って 200 の HTML になる)と非 JSON 応答は、fetchApi が持っていた判定を detectAuthRedirect() / detectNonJsonBody() として api-client から export し、両呼び出し口が同じ規則を共有する形で弾くようにした。