Repository navigation
v0.29.0
[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.1bind のままであることも併記)。あわせて 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_ERRORexit 1 / 非対話--yes無しはCONFIG_ERRORexit 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 のリスク・明示承認・CookieSecure)を 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-serveProvider は未実装の 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.error1 行で可視化した(既存の唯一のログはWorktreeSelectionContextのポーリング effect 内にあったが、WorktreesCacheProviderが常にexternalWorktreesを渡すため到達不能だった)。/loginへのリダイレクト(fetch が 307 を追って 200 の HTML になる)と非 JSON 応答は、fetchApiが持っていた判定をdetectAuthRedirect()/detectNonJsonBody()としてapi-clientから export し、両呼び出し口が同じ規則を共有する形で弾くようにした。