Repository navigation
v0.20.0
[0.20.0] - 2026-08-04
Highlight: worktree ID を「一度採番したら動かない値」に作り替えたリリース。ブランチ由来だった ID をディレクトリ由来へ変え(#1644)、既存行も migration v54 で一括で振り直し、旧 ID は alias として API・ページ・CLI の 3 面で解決し続ける(#1645)。この移行中に同一 git リポジトリを 2 つの scan root として登録していると sync のたびに ID が 8 hex ずつ伸び、稼働中セッションが UI から消える回帰(#1659)を本番で踏んだため、churn を塞ぎ(migration v55 で伸びた ID を圧縮)、原因となる構成に気付ける警告(#1662)と、リポジトリを非破壊で走査対象から外す GUI(#1658)を追加した。あわせて Auto-Yes が Claude in Chrome の許可ダイアログに応答しない問題(#1676)など、実運用で踏んだ不具合を 12 件修正している。
DB マイグレーションあり:
CURRENT_SCHEMA_VERSION52 → 55。次回起動時に自動で実行される。worktree 行・チャット履歴・タスク・検証実行は 1 行も削除しない(v54/v55 の ID 振り直しはrenameWorktreeIdPreservingChildrenで子データを引き連れる)。削除されるのは、この同じリリースで導入された内部の旧 ID → 新 ID リダイレクト表(worktree_aliases)の中間行だけである — churn でa→a-1234abcd→a-1234abcd5678ef90と伸びた梯子の途中段は、圧縮後には到達経路として不要になるため落とす。利用者から見える旧 URL(振り直し前の実 ID)は alias として残るので/worktrees/<旧ID>は 308 で解決し続ける。
Added
-
同一 git リポジトリを指す scan root が複数登録されていることに気付けるようになった (#1662):
CommandAgentとCommandAgent-developのように同一 git リポジトリの 2 つの worktreeが両方 scan root として登録されていると、git worktree listはどちらから叩いても同じパス集合を返すため、sync のたびに同じ worktree が 2 回 upsert されworktrees.repository_pathが実行ごとに入れ替わる。これが #1659 の ID churn(sync ごとに worktree ID が 8 hex 伸び、稼働中セッションが UI から消える)の前提条件だった。#1660 で churn 自体は塞いだが、「なぜ 2 つ登録されているのか」に利用者が気付ける導線は無いままだったのでそれを作った。判定はgit rev-parse --git-common-dirの実パス比較(src/lib/git/git-common-dir.ts)。正規化は 2 段とも必須で、出力は linked worktree では絶対・main checkout では相対(.git)(git 2.49 で実測)、さらに macOS の/tmp→/private/tmpは字句比較で取り逃がす(#1659 の worktree 群がまさにそこ)。登録時:POST /api/repositories/validate-pathがduplicateScanRootsを返し、入力中に警告行が出る。送信時は確認ダイアログを挟む。ブロックはしない — 実際に登録するPOST /api/repositories/scanは 1 行も変えておらず、validも変えないのでサーバ側に拒否する経路が無い(同一リポジトリの複数 worktree を独立管理したい正当なユースケースを締め出さないため)。デバウンス前に急いで送信しても警告が出るよう送信直前に照合し直すが、400ms の締め切りを付けてある(検出は助言であり、応答しないエンドポイントが "Scan & Add" を固まらせてよい理由が無い。溢れた分は一覧のバッジ側が後から拾う)。登録済み:GET /api/repositoriesの各行にduplicateOf(同じリポジトリである他の scan root のパス)が付き、Repositories 画面の Name セルにバッジが出る。バッジはボタンで、押すとその行の #1658 Scan トグルにフォーカスが移る — 対処手段を自分で探させる警告は無視されるため。判定対象は enabled な行だけ(無効化済みの root は走査されないので二重走査を作らず、数えると「誤検知しない」条件を自分で破る)。副次的に、片方を Scan トグルで外すと残った行のバッジが消えるので remedy が効いたことが画面で分かる。is_env_managedでは区別しない(#1659 の実害はまさに env 由来側で出た。env 由来の root でも Scan トグルは効くことをコードで確認済み)。git が答えられないパス(非 git リポジトリ・削除済み・timeout)は判定をスキップするだけで登録フローを阻害しない。テストは実 git リポジトリを砂箱に作って走らせ(execFileモックでは「git が実際に何を出力するか」という主張を検証できない)、UI は Playwright の実ブラウザでも往復を実測した。空振りでないことは変異 12 種を注入して全て赤になることで確認済み。判断と根拠はdocs/design/duplicate-scan-root-warning.md -
リポジトリを「走査対象から外す」だけの非破壊な無効化が GUI から行えるようになった (#1658): Repositories 画面のトグルは
visible(サイドバー表示)しか変えず、enabled(走査対象)を落とす GUI が存在しなかった。そのためenabled = 0へ至る唯一の経路がDELETE /api/repositories= 除外 + purge(配下 worktree の tmux セッションを kill し、worktree 行を削除 → chat history / memos / todos / timers / schedules / execution logs / tasks / verification_runs も道連れ)で、「片方の scan root の走査を止めたいだけ」の利用者に「履歴を捨てて稼働セッションを殺す」以外の道が無かった(#1659 の ID churn に遭った利用者が実際にこれで詰まった)。一覧の "Status" 列を Scan トグルに変え、PUT /api/repositories/[id]がenabledを受けるようにした。無効化はUPDATE repositories SET enabled = 0の 1 文だけで、worktree 行も子データも稼働中セッションも一切触らない(PUT ルートはsession-cleanupを import すらしない。テストが「呼ばれないこと」を固定)。enabledとvisibleは #690 の分離を維持して直交のままにしたので、無効化しても worktree はサイドバーに残る — 確認ダイアログがそれを明示し、消したい場合は Visibility トグルへ誘導する。再有効化は呼び出し元ゼロだったPUT /api/repositories/restoreに配線し、フラグを戻すと同時に再 scan して worktree をその場で戻す。All / Disabledフィルタで無効化中の一覧と復元が画面内で完結する。prune は誘発しない:syncWorktreesToDBの per-repo prune は scan に現れたリポジトリのグループしか回らず、pruneStaleRepositoryWorktreesはディレクトリが消えたときだけ削除するため、無効化は削除条件を満たさない(#1659 の発端そのもの=同一 git repo を指す 2 つの scan root の片方を無効化するケースもテストで固定)。MAX_DISABLED_REPOSITORIES(SEC-SF-004)は新経路にも効かせ、超過時は 409。破壊的なDELETEは挙動を変えず、GUI にも露出させていない(画面には非破壊の操作しか無いので取り違えようがない)。UI は jsdom だけでなく Playwright の実ブラウザでも往復を実測した(portal + focus trap + 退出アニメーション付きの実Modal、実辞書、実 fetch。tests/e2e/repository-scan-toggle.spec.ts)。判断と根拠、およびserver.tsの起動時 purge に残っていた危険(WORKTREE_REPOS列挙のリポジトリを無効化するとサーバ再起動時に worktree 行が削除されセッションが kill される。本 Issue の scope 外のため未修正 → #1666 で解消) はdocs/design/repository-disable-gui.mdに記録した -
/worktrees/<旧ID>が本物の HTTP 308 を返すようになった(#1644 からの宿題) (#1645): #1644 はページを layout のpermanentRedirectで救済したが、実測では HTTP 301 ではなく HTTP 200 +<meta http-equiv="refresh">だった。これは App Router の仕様で、layout の第 1 文に無条件permanentRedirectを置いても同じ 200 になる(真の 3xx を返すのは Route Handler / Server Action / middleware だけ)。ブラウザは meta refresh に従うので着地はするが、ステータスコードが買うもの(ブックマークの自動書き換え、非ブラウザクライアントの追従)は得られず、サブパスも失われていた(layout は自分より下のパスを知らないので/worktrees/<旧ID>/terminalが worktree 詳細に着地する)。旧 ID が実際に飛んでくるのは既存行を振り直す本 Issue からなので、ここで解決した。middleware.tsは edge runtime で alias 解決に要る SQLite を引けないため不可で、server.tsが Next へ渡す前に傍受する形にした(src/lib/git/worktree-redirect.ts)。サブパス(/terminal//files/...)とクエリ(?_rsc=を含む)は byte-for-byte 保持するので、ターミナル画面のブックマークはターミナル画面に着地する。301 ではなく 308 —/worktrees/<id>は今はページルートだが Server Action はページ自身の URL へ POST するため、301 が歴史的に許す GET 化は避けたい。併せてCache-Control: no-store— 「旧 ID → この worktree」は永続的だが永遠ではなく、worktree を削除すると alias は CASCADE で消え、後から同じ basename のディレクトリがその ID を live として採番されうる(解決では live が alias に優先)。恒久リダイレクトをキャッシュしたクライアントは二度と問い合わせず永久に間違った worktree へ着地するので、ステータスは「移動した」と言い続けつつその答えのキャッシュだけを断る。server.tsへは**await import()で遅延読込**(top-level 静的 import はtsx server.ts下で Next の AsyncLocalStorage bootstrap を壊し、最初のリクエストで落ちる前科がある)。ステータスコードは単体テストでは主張できない(next/navigationをモックしたテストは 200 でも緑になる。#1644 が実際に踏んだ)ので、隔離環境(ポート 3199 / 専用 DB / スクラッチ repo)の実サーバにcurlを当てて dev・production の両方で 308 を実測し、docs/design/worktree-id-migration-uat.mdに記録した。同ドキュメントには Phase 3/4 の実機 UAT も記録している — 旧 ID 名で本物のclaudeを起動した状態で migration を当て、ペイン PID 94761 が前後で同一(プロセスは殺されていない)・スクロールバック保持・旧名は即座に解決不能・一時セッション残留 0・孤児行 0 を確認。ブランチ切替→sync では ID 不変・セッション継続(同一 PID)・Auto-Yes はexpiresAtまで同一のまま継続・履歴/タスク/検証履歴/roster すべて引き継ぎを確認した -
既存 worktree の ID をパス由来へ一括で振り直した(migration v54) (#1645, Phase 4): #1644 は ID が勝手に動かないようにしただけで、既に DB にある行には触れていない — それらは今も
sanitize(repoName)-sanitize(branch)を持っており、単に凍結されたブランチ由来 ID である。#1621 が挙げた破断が本当に消えるのは、保存されている ID がディレクトリ由来になった後。migration v54 が全行を採番し直し、旧 ID をworktree_aliasesに残す。採番規則の肝は「他の行が空ける ID を絶対に再利用しない」こと: 退役した ID は消えるのではなく alias になり、解決では live worktree が alias に優先する。だから worktree B に「A が退役させたx」を渡すと、A のxを指すブックマークが黙って B に着地する。taken 集合には全 worktree の現 ID と既存 alias 全部を入れ(自分自身の ID だけ除外=そのまま維持できる)、ぶつかった候補は同一 basename の衝突と同じくbasename-<sha256(path)[0..8]>に落とす。適用はold → cmate-mig54-<n> → newの 2 段。renameWorktreeIdPreservingChildrenの衝突分岐は「destination に source をマージして source 行を DELETE する」破壊的経路なので、そこへ入る余地を構造的に消す(採番規則のおかげで今は到達しないが、規則の側だけに安全性を預けない)。stage 2 が書くtemp → newの alias 行は最後に削除する(残すと ID を永久に占有する=採番器は alias を taken 扱いするため)。子行はgetWorktreeChildTablesの列挙(FK 宣言ではなくworktree_id列)で追従するのでtasks(#1548) /verification_runs(#1544) / alias 自身も付いてくる。skill_operationsだけは付いてこない:BEFORE UPDATE ... RAISE(ABORT)の追記専用台帳(#1234)で、書き換えようとすると migration ごと abort する。監査行は「その時点の identity」を記録するものなので旧 ID のままが正で、alias があるので解決もできる。tmux セッションとインメモリ状態は migration からは触れないため、起動時にserver.tsがreconcileWorktreeSessionsFromAliases(db)(Phase 3)を動的 importで呼んで追従させる — alias 表が「動いた ID の一覧」そのものなので、何も無ければtmux list-sessions1 回で終わる冪等な pass になる。down()も alias から逆算して用意した(ロールバック可能)。実装都合として、ID 採番の純粋部分をsrc/lib/git/worktree-id.tsへ、主キー移送をsrc/lib/db/migrations/worktree-id-rename.tsへ切り出した — migration が@/lib/git/worktrees(@/lib/db経由で循環)や@/lib/db/worktree-db(suite 中のvi.mockをrunMigrationsが被る。v52 が実測で踏んだ罠)を import できないため。公開 API と import パスは不変。空振り検証は変異注入 4 件で確認済み(子テーブル列挙を FK メタデータのみに戻す→1件赤 / 採番の reserve を外す→3件赤 / temp alias を残す→3件赤 / alias 記録を止める→5件赤) -
worktree ID が動いたとき、稼働中のセッションと実行時状態が追従するようにした (#1645, Phase 3):
reconcileWorktreeSessions(db, oldId, newId)(バッチ形(db, renames[])もあり)を新設した。migrateWorktreeIdPreservingChildrenは DB の行を移すが、ID から導出されているだけで保存されていないものは触れない — tmux セッション名mcbd-{cli}-{worktreeId}[-{suffix}](cli-tools/base.tsの導出値。稼働中のエージェントがプロセスは生きたまま UI から消え、同じディレクトリに 2 体目を起動できてしまう #1621 (a))と、Auto-Yes / レスポンスポーラー / control-mode attach / WS ルームの 4 つのインメモリキー(#1621 (f))。このうち 1 つでも落とすと「セッションは生きているのに指示が届かない」という同型の壊れ方をするので、すべて無効化ではなくキー移送にした。2 段階リネーム: 一括振り直しでは ある worktree の新名が別 worktree の旧名になりうる(設計の例=commandmate-mainは現行mycodebranchdesk-mainの新名)ため、A→B / B→A のスワップには正しい単発順序が存在しない。全セッションを一時名(cmate-renaming-*。mcbd-接頭辞を避けるので読むモードの#{m:mcbd-*}ガードに拾われず、tmux lsを見た人間にも一過性だと分かる)へ退避してから本名へ入れる。インメモリの移送側も同様に「全件 detach → 全件 write」の 2 相で、スワップが自己衝突しない。対象列挙は roster 駆動(agent_instances× CLI ツール登録 + 全ツールの primary instance)で、生存判定はセッション名の完全一致、tmux 操作はexactTarget()—mcbd-claude-<wt>はmcbd-claude-<wt>-2の接頭辞なので前方一致は別インスタンスを巻き込む(#1156)。roster は新旧両方の ID で引く(DB 移行の前後どちらから呼ばれても効くようにするため)。移送の内訳: Auto-Yes は state を deadline / stopPattern ごと移し、動いていたポーラーだけ新 ID で再起動する(poller の timer chain が worktreeId をクロージャに掴んでいるため、map の付け替えだけでは旧セッションをポーリングし続ける)。レスポンスポーラーはactivePollers/pollingStartTimes(turn の実開始時刻を維持するので MAX_POLLING_DURATION の 30 分が移行でリセットされない)/prompt・response の dedup ハッシュを持ち越し、新 ID で再スケジュールする。control-mode はTmuxControlRegistryのキーを差し替えるだけ(rename-sessionは attach を切らない=子プロセス・パイプ・スクロールバックがそのまま生き残るので、張り直すより re-key のほうが安全。entry がイベントハンドラと idle timer に読ませる名前を entry 自身に持たせ、クロージャが旧名を掴んだままにならないようにした)。WS はルームと各クライアントの購読集合、さらにターミナル購読がキャッシュしている sessionName を移す(後者を落とすとterminal_inputが消えたセッション名へ飛ぶ)+購読者へworktree_renamedを通知する。capture キャッシュはrenameSessionが新旧両名を破棄する。起動時はreconcileWorktreeSessionsFromAliases(db)がworktree_aliasesを「旧 ID の一覧」として使い、旧名で残っているセッションだけを拾う(何も無ければtmux list-sessions1 回で終わる冪等な no-op)。移送しなかったもの 1 件(scope 制約): TUI accumulator(opencode / copilot の途中バッファ)は新キーで空初期化する。src/lib/tui-accumulator.tsにバッファを書き戻す API が無く、同ファイルは本 Issue のscope.allow外のため。移行境界を跨いだ 1 応答が途中で切れうるが、次の turn からは正常に蓄積する。空振り検証は変異注入 7 件で確認済み(一時名を経由しない単発リネーム → 6 件赤 / Auto-Yes state を移送せず破棄 → 4 件赤 / dedup を移送せず clear → 1 件赤 / WS ルームを移送せず破棄 → 2 件赤 / roster 列挙を前方一致 sweep に置換 → 1 件赤 / Auto-Yes ポーラーを再起動しない → 1 件赤 / ターミナル購読の sessionName を据え置き → 1 件赤) -
読むモードの「非対応 tmux では no-op」を、実際に古い tmux を動かして実証した (#1641): #1623 は受入条件「
display-popup非対応 tmux(<3.2)で no-op となり、案B が代替として動作すること」を未検証のままクローズしていた(手元が 3.5a しか無かったため)。単体テストは capability プローブの戻り値をモックして両分岐を通していただけで、モックは「プローブが tmux に正しい質問をしているか」を何も保証しない。docker で tmux 2.8 / 3.1c / 3.3a(対照)を用意し、出荷しているsrc/lib/tmux/{read-mode,tmux,transcript-squeeze}.tsを esbuild で束ねてそのまま実行して裏を取った(書き写した再実装は使っていない)。結果は 2.8 / 3.1c でoutcome=unsupported-tmux・prefix+gは未バインド・同一サーバの他セッションも既定ソケットも無傷、3.3a ではinstalled。案B(capturePane+squeezeTranscript)は 3 版すべてで 1003 → 52 行・マーカー検出と同一の結果で、tmux バージョン非依存であることが実測で確認できた。実測で分かった新事実 2 件: (1) プローブが正解する理由はバージョンで違う — 3.1c は「引数は受けるが未知の名前には無出力で exit 0」、2.8 / 3.0a は「list-commandsがそもそもコマンド引数を取らない」ため実在するcapture-paneにも同じ usage エラーを返す。したがって出力判定は両方を正しく捌く唯一の形である一方、supportsDisplayPopupの形を他コマンドの capability 判定へ流用してはいけない(3.1 未満の全 tmux で偽陰性)。(2) 衝突検査に使うlist-keysのキー引数も 3.1 からで、3.0 以下ではreadExistingBindingが常に「キーは空き」を返す劣化状態になる。これが無害なのは capability プローブが先に short-circuit していて 3.0 以下がそこへ到達しないからで、ガードの順序が load-bearing だと判明したため単体テストで固定した(「非対応 tmux ではlist-keysを一度も撃たない」)。ハーネスはscripts/verify-legacy-tmux-readmode.sh+scripts/legacy-tmux-probe/。ホスト側ドライバは tmux を 1 行も呼ばない(2026-08-02 に「隔離したつもりのkill-serverで稼働中の全mcbd-*セッションを消した」事故があるため、構造として不可能にした)。コンテナ側は既定ソケットに囮セッションを置き、ソケット引数を取らない本番コードを$TMUXで-L私設サーバへ向けたうえで、転送が効いていなければ実行を拒否する。CI ジョブlegacy-tmux-readmode(container: node:18-bullseye= tmux 3.1c)を追加し、ベースイメージが将来 3.2 以上になったらジョブ自身を失敗させる(何も検証していない緑を防ぐ)。空振り検証はCM1641_INVERT_EXPECT=1で全行の期待値を反転させ 3 行とも正しく落ちることを確認、単体テストは 2 変異(プローブを exit-code 型へ戻す / 衝突検査を capability プローブより前へ移す)で赤を確認済み -
bash 参照実装
verify-run.shをoptions.requireCommitに対応させた (#1639): 同じ.commandmate/verify.yamlを読む 2 つのランナー(製品エンジンと、CommandMate サーバも Node も無い環境のための Phase 0 参照実装)のうち、bash 側だけがoptions.requireCommit(#1628)を知らなかった。Issue 本文は「宣言を無視して commit 無しでも合格する」としているが、実測では合格していない — awk パーサの options キーが閉じた集合だったためline 7: unknown options key: requireCommitで exit 2 の設定エラーになり、設定を書いたリポジトリでは bash 版が一切走らなかった(黙って無視はしていないが、宣言を書くと使えなくなるという別の壊れ方をしていた)。awk パーサにキーを追加し、true/false検証と、commits=0を FAIL(RESULT not_started/ exit 21)にする判定を実装した。ゲート行にはrequireCommit=trueが付き(false のときは付かないので既定の出力は不変)、commits=0 uncommitted=3は「作業が在る」とも読めて FAIL の理由が行から読み取れない唯一のケースなので、理由行を stderr に出す。2 実装の drift は conformance テストで固定した(tests/unit/skills/cmate-verify/require-commit-conformance.test.ts)— 同一の git サンドボックスに両ランナーを当て、7 セルの行列(キー省略 /false/true× 未 commit のみ / commit 済み / commit+追加変更 / 作業ゼロ)で work-evidence と run の判定が一致することを見る。既知の差分も同じファイルに明示的に pin した: (1) TS は.commandmate/tasks/を作業証跡から除外する(#1580)が bash 版は除外しないため、契約ファイルだけが変更された worktree で判定が食い違う(bash が PASS / TS が not_started。bash が緩い向きで requireCommit と同種の欠陥だが、work-evidence が「何を数えるか」の変更なので follow-up 扱い)、(2) 未追跡ディレクトリを TS は-uallでファイル単位・bash は 1 エントリで数える(数字だけが違い判定は一致する)。bash 版は実行契約を読まない(スタンドアロンランナーであり、シェルから起動したランはどの委任にも紐付かない)ことを、スクリプト冒頭・SKILL.md・仕様書に明記した — 両方のランナーで効かせたい要求は、2 実装が共に読む唯一のファイルである verify.yaml に書く。skill 実体は.claude/skills/.agents/skillsの両ルートに byte-identical で置き(Claude は前者、Codex / Antigravity は後者しか読まない)、sync-map.jsonの pin も更新済み。Kewton/commandmate-skillsへの移植は未了(pin を更新したので対応表からは移植済みと区別が付かない状態になっている点を sync-map.json の note に明記した)。空振り検証は変異注入で確認済み(判定分岐の除去 → bash suite 5 件 + conformance 1 件が赤 / awk が再びキーを拒否 → 15 件赤 /requireCommit=trueを無条件出力 → 1 件赤 / TS 側の判定除去 → conformance 1 件赤) -
実行契約に
success.requireCommitを追加し、契約が宣言した commit 義務を機械が検査するようにした (#1642):send --contractの前文は以前から「作業完了後は必ず commit すること(未 commit の作業は未完了とみなされる)」と固定文言で宣言していたが、work-evidence はcommits=0, uncommitted=1でpassedを返していた — 契約が宣言したルールを機械が一度も検査していない(#1628 D-4)。Epic #1585 の受入実測では、Codex ワーカーが未コミットのままwait --verifyで exit 0 /RESULT passedを受け取っている。#1628 で入れたverify.yamlのoptions.requireCommitはリポジトリ単位のスイッチなので、「ワーカー委任には commit を要求したい」と「手元の対話的 verify では要求されたくない」が両立しない(work-evidence が落ちると後続ゲートは全てskippedになり、未コミット状態では lint / typecheck / unit の結果が一切返らなくなる)。契約側に置くことで委任単位で効くようにした。優先順位は OR(PM 決定) —options.requireCommitとsuccess.requireCommitのどちらか一方が true なら trueで、契約が verify.yaml を緩めることはできない。「契約が勝つ」にすると、リポジトリが立てた要求を個々の委任契約が黙って外せることになり同じ穴が委任単位で再発するため、締める方向にしか効かない合成を採った。裁定の理由行はどちらの宣言が要求したかを名指しする。既定は false(requireWorkEvidenceは省略時 true だが、requireCommitを true にすると既存の全契約の判定が変わるため)。前文と work-evidence のラベルは固定文言をやめ、実際に効く規則へ追従させた — 宣言と裁定が食い違わないことが本 Issue の目的なので、false のときは「commit の有無そのものは検査されない」と正直に書く。requireCommit: trueかつrequireWorkEvidence: falseは契約エラーにした(裁定するゲートを契約のゲート集合から外す組み合わせで、受理すると同じ「宣言だけあって見る機械が無い」状態が別の形で復活する。requireScopeClean× 空scope.allowと同型の規則)。未接続の契約(#1620 のfindDetachedContract)からはフラグを読まない — そのランはその契約についてのランではないため(未接続であること自体は従来どおり scope のskippedとして集計に残り run はerror)。空振り検証として 4 変異(OR を「契約が勝つ」へ/前文を固定文言へ戻す/gate-runner が契約を無視する/requireWorkEvidence: falseとの併用を許す)を注入し、いずれも赤になることを確認済み -
npx 自己更新時に、残置したグローバル導入を警告 (#1633): サーバは npx 経由の自己更新(
update --relaunch-npx)で更新され続ける一方、npm install -gで入れた CLI は据え置かれるため、PATH 上のcommandmateだけが何ヶ月も古いまま乖離しうる(実際に 0.18.0 サーバ + 0.2.4 グローバル CLI という環境が生まれ、#1632 が誰にも気付かれない原因になった)。npx 自己更新経路の冒頭でnpm root -gからグローバル導入を検出し、その版・パス・npm install -g commandmate@latest/npm uninstall -g commandmateの案内をコンソールと~/.commandmate/update.logの両方に記録する。警告は助言に徹する: グローバル導入が無ければ何も出さず、npm 不在・npm root -gの失敗・package.json が読めない等の検出失敗は警告をスキップするだけで update 本体は一切阻害しない(package.json が読めない場合は版をunknown versionとして警告自体は出す)
Changed
-
旧 worktree ID を alias として解決し続けるようにした(API・ページ・CLI の 3 面) (#1644, Phase 2): worktree ID は採番された瞬間に DB の外へ漏れる — 開いているタブ、スマホ、PWA ショートカット、ブックマーク、誰かのスクリプトの
commandmate send <id>。Phase 1 で ID は動かなくなったが、旧方式で書かれた既存行は #1645 が振り直すため、その移行を跨いで旧 ID が引き続き答える必要がある。migration v53 でworktree_aliases(old_id PK, worktree_id → worktrees(id) ON DELETE CASCADE, created_at)を追加し、alias の記録を rename 操作そのものの性質にした —migrateWorktreeIdPreservingChildrenが記録するので、sync も #1645 の一括移行も将来のディレクトリ移動も、呼び出し側が覚えていなくても旧 URL が生き残る。解決規則は live worktree が alias に優先(退役名を後から実在の worktree が取った場合は実在側が正)で、A→B→C は A→C の 1 hop に畳む(chain を歩かないので循環しえない)。deriveWorktreeIdの taken 集合にも alias ID を入れ、新規 worktree が alias の転送先を覆い隠さないようにした。「片面だけ直す」失敗を避けるため、route 境界で ID を写す:src/app/api/worktrees/[id]/**の全 route(69 ファイル・89 箇所)がparams展開直後にcanonicalWorktreeId()を通す。route は worktree の存在確認を 1 回するだけで、その後は生の URL セグメントを子テーブル参照・tmux セッション名・poller / Auto-Yes キー・WS broadcast に使い回しているため、存在確認だけ写すと 404 が「履歴が空で返る」「旧名で 2 体目のエージェントを起動する」に化ける(この half-fix を変異注入で赤にして固定した)。canonicalWorktreeIdは全経路を try で包んだ total 関数で、不正形式・未知 ID・DB 失敗はすべて要求 ID をそのまま返す(route 単体テストが path-validator や@/lib/dbを部分モックするため、throw すると alias と無関係な route が 500 になる。実測でfiles-downloadが 500 化したため修正)。CLI(send/wait/capture/respond/auto-yes/instances/verify)は全て HTTP 経由なので、この 1 箇所で旧 ID を受理するようになる。ls --idの前方一致は従来どおり現在の ID に対して行う(旧 ID は完全一致でのみ解決)。Issue 本文との食い違い 1 件(実測): ページ/worktrees/<旧ID>は 301 にならない。隔離 DB の実サーバ(tsx server.ts, NODE_ENV=production)で計測したところ、permanentRedirectは HTTP 200 +<meta id="__next-page-redirect" http-equiv="refresh">を返す。これは App Router の仕様で、layout の第 1 文に無条件permanentRedirectを置いても(loading.tsxの有無に依らず)同じ 200 になる — 真の 3xx を返すのは Route Handler / Server Action / middleware だけである。ブラウザは meta refresh に従うので「開いたままのタブ・スマホ・PWA・ブックマークが現行 worktree に着地する」という目的は達成されるが、ステータスコードが買うもの(ブックマークの自動書き換え、非ブラウザクライアントの追従)は得られない。真の 3xx にはmiddleware.tsかserver.tsが要り、どちらも本 Issue の scope 外(かつ middleware は edge runtime で SQLite 不可)なので、旧 ID が実際に飛んでくる瞬間を所有する #1645 の follow-up とする。この食い違いはモックした単体テストが緑のまま実サーバが 200 を返していたので、テストは「どの URL へ転送するか」だけを主張し、ステータスは実測値として doc に固定した。 空振り検証は変異注入で確認済み(canonicalWorktreeIdの alias 解決除去 → 5 件赤 / rename の alias 記録除去 → 3 件赤 / taken 集合から alias 除去 → 1 件赤 / alias を live より優先 → 4 件赤 / half-fix(存在確認だけ写す)→ 1 件赤) -
worktree ID をブランチ由来からディレクトリ由来へ変え、一度採番したら動かない値にした (#1644, Phase 1): ID は
sanitize(repoName)-sanitize(branch)で導出されていたため、git worktree を使わず同一ディレクトリでブランチを切り替えると同じディレクトリの ID が別物になっていた(detached HEAD ではコミットのたびに変わる)。#1151 で「履歴が CASCADE 削除される」データ損失は解消済みだが、ID に紐づく DB の外側(tmux セッション名mcbd-{cli}-{worktreeId}、開いているタブ・ブックマーク、ポーラー / Auto-Yes のキー)は追従しないままだった。deriveWorktreeId(resolvedPath, takenIds)を新設し(sanitize(basename(path))、衝突時のみ-{sha256(path).slice(0,8)}、桁を伸ばす決定論的な再試行つき)、generateWorktreeIdは@deprecatedにした。効いているのは導出規則ではなく「再導出しない」こと —syncWorktreesToDBがパスで既存行を引き、ヒットすれば既存 ID をそのまま維持し、無い場合にだけ採番する。したがって ID はブランチ切替でも detached HEAD の前進でも、将来この導出規則を変えても動かない。worktrees.nameの意味は「表示用のブランチ名」に固定し、毎 sync で実ブランチへ更新する(branchカラムは #1003 のまま)。Issue 本文との食い違い 1 件: 本文は「scanWorktreesは ID を確定させない({path, branch, name, repositoryPath, repositoryName}を返す)」としているが、scanWorktreesの戻り値はPOST /api/repositories/scan/restoreとsyncWorktreesAndCleanup(src/lib/session-cleanup.ts)がWorktree[]前提で受けており、いずれも本 Issue の scope 外のため型を落とせない。idは「暫定値(provisional)」として残し、確定はsyncWorktreesToDBが行う形にした(優先順位=既存パスの ID > 呼び出し側の提案 ID(未使用の場合のみ)>deriveWorktreeId)。ID は全リポジトリ横断のグローバル主キーなので、衝突判定の taken 集合もgetAllWorktreeIdsで横断的に取る(/a/mainと/b/mainの同名ディレクトリが両方mainを主張すると 2 件目がUNIQUE(path)で落ちる)。本 Issue のマージ時点で既存 worktree の ID は変わらない(新規登録分のみ新方式。既存行の一括移行は #1645)。空振り検証は変異注入で確認済み(既存パスの ID 維持を外す → 5 件赤 / taken 集合をリポジトリ内に狭める → 1 件赤 /nameのブランチ追従を外す → 1 件赤) -
送り先エージェントの指定を
--instance単独形へ統一(ドキュメント・ヘルプ文言) (#1638):--agentを受け付けるのはsend/respond/capture/auto-yesだけで、waitはunknown optionで exit 1 になる。この非対称が実害になるのは**「sendにだけエージェントを書いてwaitには書かない」ときで、waitは worktree の既定エージェントを見るため、Codex 用に切った worktree で黙って Claude Code の完了を待つ(#1629 の実測)。--agentは廃止せずエイリアスとして存置し、位置づけだけを「roster に無いインスタンスのアドホック起動の補助」へ降格させた — 削除は出荷済み CLI・既存スクリプト・埋め込みドキュメントを壊す代償に見合わず、加えてsend --registerは roster 外のID(例codex-3)から CLI ツールを推論できないため--agentでしか指定できない(削除は不可能であって、単に非推奨なのではない)。判定ロジック(#1629 の優先順位: roster が正本、矛盾は exit 2)は一切変更していない。整合させた 4 面は (1)send/respond/capture/auto-yesの.option()説明文(src/cli/config/agent-target-options.tsを単一ソース化し、存置の根拠を doc コメントに記載)、(2)wait --instanceの説明文(「waitに--agentは無い」を明記)、(3)commandmate docsの埋め込み文書src/cli/docs/agent-operations.ts、(4)docs/user-guide/cli-operations-guide.mdとCLAUDE.mdの例。Issue 本文との食い違い 1 件: 「--agent codex --instance codex-2形が残存」はdocs/user-guide/cli-operations-guide.mdにも 6 箇所残っていた(#1629 で対応済みなのは「マルチセッション使用例」節のみ)。副次的に、並列 worktree のサンプルが誤りだったことが判明したので直している — 1 回のwaitに指定できる--instanceは 1 つで引数の全 worktree に適用されるため、send --agent codexした WT2 を含むwait "$WT1" "$WT2"は WT2 の既定エージェントを見ており、Issue の症状そのものをサンプルが教えていた(インスタンスの異なる worktree はwaitを分ける)。空振り検証として 3 変異(send の--agent説明文を旧文言へ戻す/waitに--agentを生やす/埋め込み文書に--agent codex --instance codex-2を戻す)を注入し、いずれも赤になることを確認済み。wait --agentの新設は #1629 の結論どおり不採用。第2弾で英語版ドキュメントまで揃えた:docs/en/user-guide/cli-operations-guide.mdは要約版で--instanceに一度も言及しておらず(489 行 対 日本語 1220 行、マルチセッション節そのものが無い)、--agent形だけを教えている状態だったため、逐語訳ではなく「推奨形と事実」(推奨形・--agentの位置づけ・waitに--agentが無いこと・--registerに必須である理由)を英語版の粒度で追加した。日英のworkflow-examples.mdの契約送信例(send --agent claude→ 素のwait)も--instance claude形へ統一。あわせて日英ともドキュメントの markdown を検査する退行ガードを追加した(wait --agentの用例が無いこと・--agentの用例がinstances addか--registerに限られること・各 locale の cli-operations-guide が推奨形と非対称を明記していることを固定)。第3弾でREADME.mdを揃え、退行ガードの走査を列挙から自動発見へ切り替えた: 走査対象が「docs は自動発見・root は列挙」という非対称な形だったため、README.md が 2 回続けて取りこぼされていた(1 回目は scope 外、2 回目は列挙漏れ)。root markdown とdocs/**を丸ごと自動発見し、記録文書(CHANGELOG.md/docs/**/design//docs/**/internal/)のみを名前で除外する形に変更(記録を後から書き換えさせるガードは有害なため。実際docs/design/1623-tmux-reading-mode.mdはcapture --pane [--agent X] [--instance Y]を当時の決定として正しく記録している)。さらに用例の抽出がテーブルセル内のインラインコードを読めていなかった問題も修正した — README は全用例をテーブルセルに書いており**、行頭アンカーの走査では「用例ゼロ」と読まれて素通りする。これが README.md が 2 回とも無検査だった構造的な理由である。空振り検証として 2 変異(README のテーブルセルにwait --agentを復活/インラインコード抽出を外す)を注入し、いずれも赤を確認(後者は前者の offender を他のガードが検出できないことも同時に示している)。走査対象は 69 ファイル(docs/ja/README.mdを含む)
Fixed
-
Auto-Yes が Claude in Chrome の許可ダイアログに応答しない問題を修正 (#1676):
Claude in Chrome wants to run JavaScript on localhost:8787+❯ 1. Allow / 2. … / 3. Deny (esc)型の許可ダイアログをdetectMultipleChoicePrompt()の Layer 5(SEC-001 質問行バリデーション)が「プロンプトではない」と判定していた。ヘッダが疑問文でなく宣言文で、?もQUESTION_KEYWORD_PATTERNのキーワードも含まないため(実測 8 操作中 6 つが MISS。type/selectだけ偶然キーワードに一致して通るため「効きが悪い」体感になる)。検出器 1 箇所の沈黙が共有パイプラインの 5 経路(Auto-Yes / PromptPanel / Web Push・Message History /commandmate waitexit 10 /commandmate respondの送信前再検証)へ同時に波及していた。修正は Layer 5 の適用条件に!hasDefaultIndicatorを追加した 1 行: ❯ の実在を Pass 2 で確認できたフレームは質問行バリデーションを免除する(FP 防御はカーソル実在チェックで足りており、codex / gemini はrequireDefaultIndicator: trueで最初から同じ意味論。claude だけが #193 の ❯ 欠落対応の副作用で非対称だった)。❯ が無いフレームは従来どおり SEC-001a/b を通るため #193 の capture artifact 耐性は不変。同型の FN だった和文命令形 AskUserQuestion(「実装方針を選んでください」=?もキーワードも無い)も同時に解消。FP ガード 3 種(散文の番号付きリスト / #1495 model オーバーレイ / composer ❯ バリア)とdetectSessionStatus経由のhasActivePromptを回帰テストで固定し、fake-agent.test.tsの mutation test は新セマンティクス(質問行と ❯ の両方を消して初めて waiting を失う)へ更新した -
ターミナル出力が 1MB を超えると末尾(最新)が捨てられ、画面に古い側だけが残る問題を修正 (#1674): 稼働中の codex セッション(
mcbd-codex-commandagent-develop)で、composer 行も Codex の最終メッセージ(Goal blocked (/goal resume))も描画されず、画面の最終行が JSON 差分の途中で切れて+2;205;214;2という文字列で終わっていた。原因はsanitizeTerminalOutput()のvalidated.slice(0, MAX_TERMINAL_OUTPUT_LENGTH)。ターミナルは下から読むものなので意味があるのは末尾(いま何が起きているか)だが、この切り詰めは先頭を残して末尾を捨てていた。実測 capture は 1,182,902 文字で上限 1,048,576 を 134,326 文字超えており、その 134,326 文字がまるごと最新側だった。加えて切断が文字数ちょうどで行われるため ANSI エスケープの内部に落ち、ansi-to-htmlがシーケンスのパラメータバイトをそのまま文字として出力していた(画面末尾の+2;205;214;2の正体。実 capture の切断点は…43m+\x1b[38;2;205;214;2|44m \x1b[38;2;147;15で、Issue 本文の実測値とバイト単位で一致することを確認済み)。直したのは 3 点。 (1)truncateTerminalOutput()を新設し末尾を残して先頭を捨てる(同リポジトリのtruncateMessage()(src/lib/response-cleaner.ts)が同じ理由で先行実装している形に揃えた。marker つき・サロゲートペアガードつき)。(2) 切断を行境界に合わせた — ANSI エスケープは改行をまたがないので、残す末尾を改行の直後から始めればシーケンス内部で切れることは原理的に無い。行境界が 64KB 以内に見つからない病的な入力(1 行が巨大)にはエスケープ境界を認識するフォールバックを置いた。(3) 切り捨てを画面に出す — 無言で消えると「出力がそこで終わっている」と読め、実際に本件は「Codex が途中で止まった」と誤診されたので、TERMINAL_TRUNCATION_MARKER([... 古い出力を省略しました / older output truncated ...])を先頭に出す。対になる経路も点検した:sanitizeTerminalOutput()の呼び出し元はTerminalDisplay.tsxの 4 箇所(初期描画 / coalesce / append チャンク / replace)で、append チャンク経路だけ入力が全体でなく差分である。差分が上限を超えると末尾保持はその差分の先頭を落とし、既描画チャンクと新チャンクの間に虫食いを作る(旧実装の先頭保持でも同じ位置に穴が空いたが、末尾保持では穴が marker で可視化されるぶん誤読しやすい)。そこで上限超えの append は replace へフォールバックさせ、描画中の DOM が常に出力の連続した suffix であることを保つようにした。terminal-display-normalizer.ts(#1172 の compaction)は sanitize の前段で縮めるだけなので変更不要。上限値 1,048,576 は据え置き(本件は向きと切り方の欠陥であって容量不足ではない)。定数はsrc/config/terminal-output-config.tsへ集約した。顕在化した理由は #1624(v0.19.0): それ以前はhistory-limitが pane 生成後に設定されて実際には効かず pane は tmux 既定の 2000 行しか保持していなかったため、capture は約 240KB 止まりで 1MB 上限に一度も届いていなかった(#1624 は正しい修正であり、同じ pane に 3 つの上限問題を同時に露出させた。保存側 #1670・検知側 #1671・描画側が本件)。テストは実測値と同じ 1,182,902 文字・切断点が SGR シーケンス内部に落ちる fixture で「末尾が描画され先頭が落ちること」「切断が行境界であること(残った部分が入力の純粋な suffix で、その直前が改行であること)」「シーケンスのパラメータバイトが HTML に漏れないこと」「marker が出ること」「上限以下は 1 バイトも変わらないこと」「上限+1 文字」「行境界の無い入力」「サロゲートペア」を固定し、TerminalDisplay側で「上限超え append が replace へ落ちること」「通常の append は従来どおり追記されること」を固定した。空振りでないことは変異注入 3 件で確認済み(切る向きを先頭保持へ戻す → 8 件赤/行境界合わせを外して文字数ちょうどで切る → 4 件赤/append のフォールバックを殺す → 1 件赤、しかも失敗メッセージがFIRST-FRAME-LINEの直後に marker が来る虫食いそのものを示す)。修正後の実 capture(1,182,902 文字)を通して marker・Goal blocked (/goal resume)・漏れなしの 3 点を実機データで確認している -
tmux の scrollback が capture 窓(10000 行)を超えると、inline レンダリング系ツールの応答が永久に保存されなくなる問題を修正 (#1670): 2026-08-04、稼働中の codex セッション(
mcbd-codex-commandagent-develop、history_size10908)で Message History が 10:02:22 JST で止まった。エージェントは 10:44 まで動き続けターミナルには全部出ていたが履歴には 1 件も入らず、logs/server.logにはalready-saved-up-to-lineが繰り返し出ていた。原因はこの app の capture が「末尾 N 行のスライディング窓」であること。captureSessionOutput()は tmux に-S -CACHE_MAX_CAPTURE_LINESを要求しsliceOutput()が末尾 N 行へ切り詰めるので、返る行数はmin(pane 行数, 窓幅)。pane が窓より小さいうちは行数が伸びるのでsession_states.last_captured_lineは「どこまで読んだか」のカーソルとして機能するが、pane が窓を超えた瞬間に行数は窓幅へ張り付き、以後は窓が滑るだけになる。カーソルはもう追い越せずresult.lineCount <= lastCapturedLineが恒久的に真になって毎回応答が捨てられる。バッファリセット検知(bufferShrank/sessionRestarted)は縮んだときしか働かないため自力復帰の経路が無かった。これは #1268 が alternate-screen 系(claude / opencode / copilot)に対して直したのと同型の欠陥で、あちらは pane 高で飽和しusesAlternateScreenというツール属性で除外できたのに対し、こちらは scrollback を実際に持つツール(codex / gemini / vibe-local / antigravity)が窓を超えた時点から同じ状態になるため、除外は属性ではなく実行時条件でなければならない。直したのは 4 点。 (1)isCaptureWindowSaturated(capturedLineCount, windowLines)をtmux-capture-cache.tsに置き、extractResponse()が trailing blank を落とす前の raw 行数で判定してExtractionResult.captureWindowSaturatedに載せる(切り詰めはsliceOutput()で起きるので、判定できるのは未トリムの行数だけ)。(2)checkForResponse()の重複判定をlineCountIsCursor = !usesAlternateScreen(cliToolId) && !result.captureWindowSaturatedに変更(3 つの行数ゲートと race 再チェックが同時に効かなくなる)。(3) 内容ハッシュ dedup の分岐をusesAlternateScreenから!lineCountIsCursorへ広げた — カーソルを止めるだけでは再保存を抑えるものが無くなり、完了済みの同じ応答を 2 秒ごとに追記してバグより悪くなる。(4)resolveExtractionStartIndex()に branch 2a''(飽和時は直近の user echo をアンカーにし、未検出なら窓全体)を追加。ゲートだけ直しても codex は branch 2b でlastCapturedLineから切り出すため、長い応答の末尾数行だけが保存される。対になる経路も点検した:current-output-builder.tsのlines.slice(lastCapturedLine)も同じ前提に立っており、飽和時にcontentが 1〜2 行へ潰れてcommandmate capture <id>(data.contentを出力)が実質空を返していたので、飽和時はカーソルを無視して全行返すようにした(窓は前へしか滑らないので 0 が唯一安全なクランプ、結果は superset で新規出力を落とさない)。checkForResponse()のcaptureSessionOutput(..., 10000, ...)というリテラルも定数へ寄せた(要求幅と判定基準が黙って乖離しうる)。CACHE_MAX_CAPTURE_LINESは引き上げていない — 飽和の原因は「バッファが伸びなくなること」自体なので、20000 に上げても pane 側がTMUX_HISTORY_LIMIT = 20000で飽和した時点で同じことが起きる。窓幅を引数で受ける形にしたうえで、小さい窓・CACHE_MAX_CAPTURE_LINES・TMUX_HISTORY_LIMITの 3 つで同じ再発と同じ回復をテストで固定した。顕在化した理由は #1624(v0.19.0): それ以前はhistory-limitが pane 生成後に設定されて実際には効かず、pane は tmux 既定の 2000 行しか保持していなかった。#1624 が生成前設定に直して 20000 行が本当に確保されたことで、長寿命の inline セッションが初めて 10000 行の窓を越えられるようになった(#1624 は正しい修正であり、上限の不整合を露出させただけ)。テストは実checkForResponse()を駆動し、4 ツールそれぞれの実 pane 形(livecapture-paneから採取)で「窓が張り付いたまま 3 ターン連続で保存されること」「手で DB を直さずに張り付いたlast_captured_line(実測値 9999 を含む)から復帰すること」「同一ターン内の再 poll では重複保存しないこと」「次ターンの同一内容は保存すること」「窓の下では行数カーソルが従来どおり効くこと(updateSessionStateが呼ばれないことで、拒否したのが行数ゲートであって空応答パスでないことまで特定)」を固定した。空振りでないことは変異注入 4 件で確認済み(飽和判定を殺す → 19 件赤/lineCountIsCursorを旧式へ戻す → 12 件赤/内容 dedup の分岐をusesAlternateScreenへ戻す → 2 件赤/branch 2a'' を殺す → 7 件赤)。#1268 の回帰テスト(response-checker-alternate-screen.test.ts)は 1 行も変えずに実行し緑。Issue 本文との食い違い 1 件: 本文は「response-checker.ts:512のガードが恒久的に真になる」を単独原因として挙げているが、実測ではゲートを外すだけでは直らない — codex は branch 2b でlastCapturedLineを開始位置に使うため、飽和状態では応答の末尾断片しか取れない(変異注入で赤になることを確認)。ゲートと抽出アンカーは両方必要で、加えて内容 dedup を広げないと再保存の暴走を招く。本件と #1671(完了判定が末尾 20 行の• Ranを活動中と誤認)は同じセッションで併発しており、本修正だけでは #1671 は残る -
codex の完了判定が末尾 20 行に残る
• Ranを活動中と誤認し、短い最終メッセージで終わったターンの応答が保存されなかった問題を修正 (#1671):mcbd-codex-commandagent-developは 10:44 にターンを終えて idle になっていたのに、checkForResponse()が毎回!result.isCompleteで返り続け、その応答が Message History に永久に載らなかった。原因はextractResponse()のisThinkingがCODEX_THINKING_PATTERNを末尾 20 行固定の窓に当てていたこと。codex は inline 描画(alternate_on=0)なので出力したステップ行は pane の scrollback に残り続け、過去形の記録である• Ran <cmd>が窓から出て行かない。結果、hasPrompt = true/isThinking = true→isPromptBasedComplete = falseが固定する。最終メッセージが 20 行を超えたターンでは記録が窓の外へ押し出されて保存されるので、応答が保存されるかどうかが最終メッセージの長さに依存していた。Issue 本文の根本原因の帰属は実測で 2 点訂正した。 (1) 本文は「実行中は• RunningなのでRunningは活動中の指標として正しく、Ranだけを alternation から外せばよい」としていたが、• Running <cmd>も同じく scrollback に残る記録である(報告された pane の 11,000 行に• Ran396 件・• Running11 件、いずれも終了済みステップ)。Ranを落とすだけでは、背景コマンドの• Running記録の直後に短い最終メッセージで終わったターンで同じ欠陥が再現する。(2) 実行中に codex が出す活動表示は• Runningではなく、composer の直上に固定されるライブ行• Working (13s • esc to interrupt) · …である。そこでRanの削除ではなく、活動の判定そのものを「残留する記録」から「ライブ行」へ移した:isCodexTurnActive()(src/lib/detection/cli-patterns.ts)が (a) 窓内のesc to interrupt、(b) composer 直上THINKING_TAIL_LINE_COUNT行内の活動マーカー、の 2 系統で判定する。(a) は使い捨て codex-cli 0.146.0 セッションの生成中フレーム 21 枚すべてに存在し、11,000 行の idle capture には0 件(in-place で再描画され、ターン終了と同時に消える。Claude のCLAUDE_INTERRUPT_HINT_PATTERNと同じ性質)。(b) はesc to interruptの文言が変わった codex ビルド向けの版非依存の保険で、記録行が届かないよう狭く取ってある。composer が画面に無いフレーム(オーバーレイ表示中・再描画中)では従来どおり窓全体のマーカー照合へフォールバックし、「まだ活動中」側に倒す。CODEX_THINKING_PATTERN自体は変えていない —detectThinking()経由の status-detector / auto-yes-poller / submit-verified-sender と codex のskipPatternsは無変更(対になる経路も点検した: status-detector は同じ pane に対して既にreadyと答えており、ポーラーだけが「思考中」と答え続けていた。両者の答えが一致するようになった)。#1670 の担当範囲(lineCountIsCursor周りの dedup ガード・response-extractor.ts)には触れていない。テストは使い捨てセッションの実 TUI capture をそのまま fixture 化して固定した(tests/unit/lib/detection/fixtures/codex-live-1671/。稼働中のワーカーセッションは composer に入力途中のテキストが残るため使えない): 実行中フレーム・短い最終メッセージで終わったフレーム・報告された本番 pane の末尾の 3 枚で、実checkForResponse()を通した保存の成否、ポーリング継続時に同一内容を 1 回しか保存しないこと、実行中を完了と誤認しないことを固定している。fixture が空振りでないことは前提ガード(記録行が本当に窓内に残っているか等)で守り、変異注入 4 件で全て赤になることを確認済み(呼び出し元を元の窓照合へ戻す → 4 件赤 / ライブ行の判定を殺す → 1 件赤 / composer 直上の窓を 20 行へ広げる → 5 件赤 / composer 不在時のフォールバックを殺す → 1 件赤) -
起動時の除外リポジトリ purge が、非破壊で無効化したはずの worktree を再起動のたびに消していた問題を修正 (#1666): #1658 が入れた「走査対象から外すだけ」の無効化は
UPDATE repositories SET enabled = 0の 1 文で、worktree 行にも子データにも稼働セッションにも触らない。ところがserver.tsのinitializeWorktrees()(起動時に無条件で走る)がexcludedPathsを回してcleanupMultipleWorktrees()で tmux セッションを kill しdeleteWorktreesByIds()で行を削除していたため、非破壊なのは次にサーバを再起動するまでだった。露出するのはWORKTREE_REPOSに列挙されたリポジトリだけ — DB 登録のみのリポジトリは無効化するとdbEnabledPathsから外れてallPathsにもexcludedPathsにも入らないが、env 由来のものはallPathsに残って絞り込みで落ちexcludedPathsに入る。つまり同じトグルが、リポジトリをどこで設定したかによって破壊的だったりそうでなかったりし、被害はクリックから 1 再起動ぶん離れて現れる(#1658/#1659 の発端=CommandAgentとCommandAgent-developを両方 scan root に登録した形がまさに後者)。この purge は #202「除外したリポジトリの worktree をサイドバーから消す」の実装で、当時はenabled = 0に至る唯一の経路が purge 済みのDELETE /api/repositoriesだったため実質 no-op のガードだった。監査ログ([excluded] <path>)は残し、purge ループだけを落とした。 #202 の要件は捨てていない — 「見せない」はvisible(#690 が概念分離として導入。サイドバーの絞り込みはsrc/lib/sidebar-utils.tsでvisibleのみを見る)、「走査しない」はenabledという分離に沿って、実装手段を行削除からvisibleへ移した。除外されたリポジトリは scan されないのでsyncWorktreesToDBが行を作り直すこともなく、行削除は表示フィルタを履歴の破棄で実装したうえ再有効化で取り消せない。#1658 の確認ダイアログが既に「無効化しても worktree はサイドバーに残る/消したいなら Visibility トグル」と約束しているので導線も揃っている。対になる経路も点検した:excludedPathsを受け取る呼び出し元はserver.tsとPOST /api/repositories/syncの 2 つだけで、後者はfilteredPathsしか分解代入しておらず元から破壊しない。起動時に走るもう 1 つの削除経路(syncWorktreesToDBの per-repo prune)は scan に現れたrepositoryPathのグループしか回らないので無効化リポジトリには届かず、pruneStaleRepositoryWorktreesは sync ルート専用で起動時には走らない。DELETE /api/repositories(除外 + purge)は 1 行も変えていない。テストはserver.tsを import して実initializeWorktrees()を走らせる(tests/unit/lib/startup-excluded-repository-purge.test.ts)— 既存のserver-startup-exclusion-filter.test.tsは同じ primitives をテスト側で手組みしており、purge ループがそのすぐ下にあっても緑のままだったため。stub したのはプロセス境界(Next / HTTP サーバ / tmux トランスポート /await import()される 4 つの fail-open reconciler)だけで、再起動 1 回でも 3 回でも worktree 行・chat_messages・tasks・verification_runsが 1 行も減らないこと、cleanupMultipleWorktrees・killWorktreeSession・tmux のkillSessionに届かないこと、監査ログが出続けること、無効化パスが scan に渡らないこと、起動がenabledを戻さないこと、DB 登録のみの経路も無傷なこと、そして実際に消えた worktree の prune は従来どおり効くことを固定した。空振りでないことは変異注入 4 件で確認済み(git show HEAD:server.tsで purge を忠実に復元 → 3 件赤 / 監査ログを落とす → 1 件赤 /scanMultipleRepositories(filteredPaths)をallPathsへ戻す → 1 件赤 / per-repo prune を無効化 → 1 件赤)。Issue 本文と設計書 §4 の記述は実測で 2 点訂正した。 (1) 「cleanupMultipleWorktreesは他でも使われている」はリポジトリ全体では真だがserver.ts内では偽で、この import も削除が必要だった。(2) 「lint が未使用 import を弾く」は偽 —npm run lintはeslint src --ext …でserver.tsを対象にせず、tsconfig.jsonにnoUnusedLocalsも無い。実際に未使用 import を足して両ゲートを回しどちらも exit 0 であることを確認したので、5 つの import は目視で落とした。npm run test:integrationも exit 0 で実測(72 files / 1067 passed)。判断と根拠はdocs/design/repository-disable-gui.md§6 に記録した -
砂箱の後始末が
ENOTEMPTYで落ち、無関係な PR の CI をランダムに赤くしていた問題を修正 (#1663): 落ちていたのはアサーションではなくafterEachのrmSync(dir, { recursive: true, force: true })で、PR #1660 では触ってもいないgate-runner.test.tsがrmdir '/tmp/gate-runner-UwYY3F/.git/objects'で落ち、再実行だけで緑になった。再帰削除は「ディレクトリを読む→見えた分を消す→自身をrmdir」なので、読んだ後に書き戻しが起きると最後のrmdirがENOTEMPTYを漏らす(実 git を spawn するテストは git が.git/objectsへ書くのと競合し、I/O の遅い CI ほど窓が広い)。Node のmaxRetries/retryDelayは必要だが不足で、あのリトライは同じパスへrmdirを再発行するだけで走査をやり直さないため、走査済みディレクトリが中身つきで再生成されると全リトライが同じ理由で落ちる(変異注入で実証)。共通ヘルパtests/helpers/temp-dir.tsのremoveTempDir()が再帰削除そのものを最大 4 回やり直し(各回が木を読み直す)、内側のmaxRetriesは一過性の EBUSY/EPERM 用に併用する。後始末の失敗は throw しない(終わったテストの assertion は残骸で無効にならず、afterEachから投げること自体が直したい flake そのもの)代わりに、残骸パスを stderr([temp-dir] left behind sandbox:)とgetLeakedTempDirs()に出して黙って消し残さない。tests 配下の{ recursive: true, force: true }削除 135 箇所 / 69 ファイルを機械的に置換した(Issue 記載の「68 箇所」は実測と食い違う)。再現テストは実 git に依存せず、worker_threads の実書き込みを走査に衝突させる(衝突しなかったラウンドは何も証明しないので破棄し、木を大きくして再試行する) -
起動時 reconcile が「予測できなかったセッション」を数えずに成功として報告していた問題を修正 (#1661): 2026-08-03 の本番適用で
reconcile:complete {"renames":51,"renamedSessions":20,"skipped":0,"errors":0}と報告されながら、稼働中の 2 セッションが旧名のまま UI から消えた(プロセスは生存)。skipped: 0は「取りこぼし 0」ではなく、列挙されなかったものは数えられないというだけだった。対象名をagent_instances× CLI ツール登録から予測して完全一致で照合する設計上、予測が再現しない名前は構造的に見えない。「実在するセッション名から逆引きする」経路を足した。 生存セッション名をmcbd-<cli>-<id>[-<suffix>]として読み、<id>を DB が知っている ID の完全一致集合(worktrees∪worktree_aliases.old_id∪ その pass の pair)に長い ID から突き合わせる。前方一致には戻していない(#1156)—<wt>-2自体が登録済み worktree ならmcbd-claude-<wt>-2はその worktree に解決して<wt>のリネームに巻き込まれず、登録が無ければ<wt>の 2 番目のインスタンスとしてmcbd-claude-<new>-2へ追従する(mcbd-claude-<new>に乗ることは両方の読みで起こらない)。これが効くのは roster が実態を語らない場合で、本番 DB では 70 worktree 中 45 件がagent_instancesを 1 行も持たない。あわせて報告を分離した:planSources.predicted(予測して実在を確認した数)/planSources.discovered(逆引きでしか見つからなかった数)/unaccountedSessions(実在するがどの既知 ID にも紐付かないmcbd-*セッション名の一覧)。最後の 1 つは 0 でなければ WARN として名前ごと出す(server.tsの起動ログにも出る)。Issue 本文の原因記述は実測で 2 点訂正した。 (1) 「roster が空なので何も探さない」は誤り —collectInstanceTargetsは全 CLI ツールの primary instance を無条件に追加しており(同ファイル、既存テストcovers the primary instance of every CLI tool without any roster rowが固定)、mcbd-claude-<oldId>は roster が空でも予測される。roster の空白が効くのはsuffix 付きインスタンスだけである。(2) 2 セッションが取り残された時点は起動時 pass ではなく sync 経路だった — 本番 DB のschema_versionは v54 が 21:52:58、v55(#1658 の修正)が 23:48:59 で、その間の約 2 時間は #1658 未修正のビルドが動いており、upsertWorktree→migrateWorktreeIdPreservingChildrenが sync のたびに当該 5 worktree の ID を伸ばしていた。この経路は alias を記録するがセッション追従を一度も呼ばない(reconcileWorktreeSessions*の呼び出しはserver.ts:294の 1 箇所のみ)。21:52 の pass 自体は、当時の pair(commandagent-develop-develop→commandagent-develop)に対してセッションが既に移行先の名前だったため動くものが無かっただけで、報告は正しかった。呼び出し点は増やしていない(判断): ID が起動時以外に動くのはupsertWorktreeの中で、これは better-sqlite3 の同期トランザクション(db.transaction())であり、tmux サブプロセスを起こす非同期の reconcile を await できない。正しい形は commit 後フックを持つ sync 側(src/lib/git/worktrees.ts)だが本 Issue のscope.allow外のため、呼び出し元を持たない配線だけを足すことは避けた。次回起動時に回復可能であることは alias 記録によって既に担保されており、本修正はその回復漏れ(roster 空白)を塞ぎ、回復できないものを WARN として可視化する。冪等性は維持している — 現行 ID を既に持つセッションは pair の移行先に解決し、移行先は source ではないので 2 回目の pass は何も計画しない(テストで固定)。検証は本番 DB を read-only で開いて実コードを走らせ(tmux は全面フェイク・リネームは 1 件も発行していない)、今日の収束済み状態では renames 0 / unaccounted 0、未記録世代のセッションを混ぜると WARN 2 件、roster に無い suffix 付きインスタンスを混ぜるとdiscovered: 1で追従することを確認した。空振りでないことは変異注入 4 件で確認済み(逆引きプランを捨てる → 4 件赤 / unaccounted の記録を止める → 2 件赤 / 最長 ID 一致を最短一致にする → 3 件赤 / 既知 ID 集合を無視して素の前方一致にする → 7 件赤)。npm run test:integrationも exit 0 で実測(72 files / 1067 passed) -
同一 git リポジトリを 2 つの scan root として登録すると worktree ID が sync ごとに 8 hex 伸び続ける回帰を修正 (#1658): #1645(PR #1657)適用直後から、あるリポジトリの 5 worktree だけ ID が伸び続け、81 文字に達したところで ID から導出される tmux セッション名が実セッション名と乖離し UI から稼働セッションが消えた。原因は
syncWorktreesToDBが既存 ID の引き当てをgetWorktreesByRepository=**repository_pathスコープで行っていたこと。同一性キーはpath(worktrees.pathはNOT NULL UNIQUE)で、repository_pathは「どの scan root が最後に upsert したか」しか表さない列である。CommandAgentとCommandAgent-developは同一リポジトリの 2 worktree で両方が scan root に登録されており、git worktree listはどちらから叩いても同じ 5 パスを返す(実測)。1 回の sync が同じパスを 2 グループ分処理するため列が ping-pong し、後から回ったグループでは引き当てが 0 件 → 提案 ID も taken →deriveWorktreeIdが走り、自分の現 ID と自分の alias に衝突して digest の桁を 1 段ずつ伸ばす(foo→foo-2f4530fe→foo-2f4530fe1cf1f9f8→ …)。本番の alias 生成時刻(再起動直後 64 秒で 6 回・約 1 秒差で 2 件ずつ)と完全に一致する。3 点を直した。 (1) パスによる引き当てを横断化(getAllWorktreePathIds)。同一 run 内で採番した ID も即マップへ載せるので、DB にまだ行が無い新規パスが 2 つ目の scan root から再訪されても再採番されない(初回 sync から発生していた)。(2) 再導出が自分の履歴に衝突しない(deriveIdIgnoringOwnHistory)。横断引き当てにより通常この分岐へは落ちないが、落ちても「その行の現 ID+その行を指す alias」を taken から除くので収束する(従来は単調増加)。(3) migration v55 が伸びた ID を basename 由来へ畳み直す。畳めるのは「自分の履歴を除いて再導出した結果が現 ID より短い」行だけで、他の worktree の ID / alias は絶対に取らない(v54 と同じ規則)。退役する現 ID は alias 化するので churn 中に配られた URL も解決し続け、梯子の中間段(isDerivedWorktreeIdが真かつ着地 ID より長い alias)は削除する — どれも同じ worktree を指し数十秒で置き換わった上、残すと採番器がその ID を永久に占有するため。commandagent-develop-develop/commandagent-develop-detached-1c64d87fのような本物の旧名は形が違うので残る。prune の生存判定も scan 全体のパス集合に広げた — どれか 1 つの scan root が報告していれば on-disk であり、片方の scan から消えただけの行を消して次のグループが新規行として作り直す(=ID も履歴も失う)経路を塞ぐ。削除対象の行集合は従来どおり repo スコープ**なので他リポジトリには及ばない。検証は隔離 DB(CM_DB_PATHを worktree 配下へ差し替え、本番data/db.sqliteは未オープン)で本番同型の状態を再現して実施 — churn 6 周で 61〜77 文字・alias 31 件まで育てた DB に v55 を当て、5 行すべてが basename へ復帰・alias 31 → 6 件(移行前 ID 5 件+本物の旧名 1 件、中間段 25 件を削除)・旧 ID すべて解決可能・chat 履歴追従・その後 3 回 sync しても 1 文字も動かないことを確認した。空振りでないことは変異注入で確認済み — 引き当てを repo スコープへ戻すと再現テスト 6 件、prune の生存判定を repo スコープへ戻すと 1 件、v55 の自分-alias 除外を外すと 7 件、梯子判定を長さだけにすると 3 件が赤 -
bash 参照実装
verify-run.shが実行契約を作業証跡として数えていた問題を修正 (#1651): 製品エンジンは.commandmate/tasks/(実行契約)を work-evidence の両方のカウンタから除外している(#1580)が、bash 参照実装には除外が無かった。オーケストレーターが契約ファイルを 1 件置いただけの worktree でbash: RESULT passed (exit 0)/TS: RESULT not_started (exit 21)と判定が食い違い、bash が緩い向き=「エージェントは 1 行も書いていないのに作業が在ると報告する」経路が開いていた(#1544 以降ずっと塞いできた「見ていないものを合格として報告する」欠陥で、#1639 の requireCommit が閉じたものと同じクラス)。契約ファイルはオーケストレーターの証跡であってエージェントの証跡ではないため、両カウンタから除外するのが正である。commit 側はgit rev-list --count <base>..HEAD -- ':(top)' ':(exclude,top).commandmate/tasks/'(:(top)は除外だけの pathspec にしないためと、両パターンを cwd ではなくリポジトリルートに固定するため。契約だけを載せた setup commit は 1 コミット分の作業として数えない)、未コミット側はgit status --porcelain -z --untracked-files=allを NUL 区切りのエントリ単位で解析し、エントリ内のいずれかのパスが契約ファイルでなければ作業として数える(契約を実作業へ rename した場合・その逆向きも拾う)。-z/-uallは必須で、人間向けフォーマットは空白を含むパスを C クォートし、rename を->で連結し、既定の untracked モードは新規の.commandmate/tasks/を?? .commandmate/の 1 エントリに畳む — いずれもパスでないものを判定に渡す。解析は bash 3.2(macOS 既定)で動くwhile IFS= read -r -d ''とR/Cの 2 パス読みで書いた。pathspec を付けた副作用として、ファイルを 1 つも変更しない commit(--allow-empty)も数えなくなる(git の履歴単純化。製品実装も #1580 以降そう振る舞っており、bash 側の fixture がこれに依存していたので実ファイルの commit に直した)。両カウンタが 0 かつ変更自体は存在する場合は、除外が効いたことを stderr に 1 行出す(FAIL commits=0 uncommitted=0をゲートのバグと読ませないため。stdout の機械可読契約は不変)。conformance テストの pin は「一致する」側へ書き換えた — 契約除外の pin を削除して MATRIX へ 5 行(契約のみ / 契約だけの setup commit / 契約+実作業 / 契約を実作業へ rename / requireCommit との合成)として移し、もう 1 件の既知差分(未追跡ディレクトリを TS は-uallでファイル単位・bash は 1 エントリ)は-uallの移植で自然に解消したので同じく畳んだ。差分が「両方 > 0 のまま数字だけズレる」形は verdict の比較では見えないため、同テストはcommits=N uncommitted=Nを数値として突き合わせる block を持つ。bash suite にも 32 件のアサーションを足し(MIN_ASSERTIONSは 150 → 200)、空振りでないことは変異注入で確認した(commit 側 pathspec 除去 → bash 6 件+conformance 3 件赤 / 未コミット側除外の除去 → bash 9 件+conformance 2 件赤 /-uall除去 → 10 件赤 / rename の 2 パス目を見ない → 1 件赤 /-zをやめて人間向けフォーマットを行単位で読む → 21 件赤)。skill 実体は.claude/skills/.agents/skillsの両ルートへ byte-identical に置き、sync-map.jsonの pin も更新済み。Kewton/commandmate-skillsへの移植は未了(pin を更新したので対応表からは移植済みと区別が付かなくなる点を、verify-run.sh/run-tests.sh/SKILL.mdの note に「counterpart は 0.2.0 のまま=未移植」と明記した。移植と version bump は PM が別途行う) -
新規 worktree への初回
sendが「原因不明のサーバエラー」で落ちていた問題を修正 (#1637): 症状はError: Server error. Check server logs for details.(exit 99)だけで、tmux セッションもclaudeプロセスも 1 回目の時点で既に生きていた。起動に失敗したのではなく、15 秒以内に prompt を観測できなかっただけである(本番ログにClaude initialization timeout (15000ms)が 6 件)。3 点を直した。(1) コールドスタートの実測とタイムアウト値: 専用 tmux socket(-L)と本番と同じ検出述語(Yes, I trust this folder/CLAUDE_PROMPT_PATTERN)で計測したところ、idle・trust 済みリポジトリで 1443 / 1470 ms、idle・新規ディレクトリ(trust ダイアログあり)で 1885 / 1896 / 1902 ms、6 本同時起動で 2845 / 3450 / 3488 / 4206 / 4215 / 4850 ms だった。健全なコールドスタートは idle 約 2 秒・6 並列で約 5 秒なので、15 秒は並列時に対して 4 倍未満の余裕しかなく、オーケストレーション中(複数エージェント+テスト+ビルド)はここを使い切る。Issue の再現手順(30 秒後の再送は成功)が実際の ready 時間を 15 秒超〜45 秒程度と示しているのでCLAUDE_INIT_TIMEOUTを 60 秒にした(codex が既に持つ約 33 秒の窓と同じオーダー)。健全系の待ち時間は増えない(prompt を観測した瞬間に抜ける)。加えて、待つ時間を延ばした分の副作用を消すため、init ループでもisSessionHealthy()と同じ終端エラーパターン検査を行い、成功しえない起動は 60 秒を待たず即座に失敗させる。既存セッションへの送信は別の予算(CLAUDE_SEND_PROMPT_WAIT_TIMEOUT= 10 秒)で従来どおり。タイムアウト時にセッションを kill しない点も維持している(再送が安価なのはこのため)。(2) CLI に原因が伝わらない(本 Issue の中心):src/cli/utils/api-client.tsが 5xx を一律Server error. Check server logs for details.に潰していた。原因は常にレスポンス本文のerrorに入っており(このリポジトリの全ルートが{ error }を返す)、クライアントはcodeを読むために本文を既にパースしていた —errorを使っていなかっただけである。handleApiError(error, status, payload)に本文を渡し、5xx はServer error: <サーバの理由>を出すようにした(本文が無ければ従来の文言のまま)。4xx は CLI 自身のより具体的な文言(Check the worktree ID等)を据え置く。あわせてサーバ側も、起動中のセッションを 500 ではなく 503 +code: SESSION_STARTINGで返し、「再送すれば直る」こととcommandmate capture <id>で様子が見られることを本文に書く。文面は tool 名・tmux セッション名・数値だけで組み立てているのでパスも生出力も含まず、SEC-SF-002(詳細はサーバログ、クライアントには generic)の例外として安全に通せる。(3)--contract失敗時の孤児タスク:send --contractは送信前にタスク行を作るため、送信が失敗すると誰も作業していない行が残る。PM 判断(行は消さず、送信失敗時に終端させ、後続の scope 解決の対象から外す)に従い、状態機械でpending+send_failed→cancelled(初回送信が一度も届いていない=judge すべき作業が無い)、running+send_failed→failed(作業は在るので #1620 どおり再 judge 可能)と行き先を分けた。行とtask_events(send_failed/ from=pending)は残るので監査証跡は失われない。failedのままにできない理由:VERIFIABLE_TASK_STATUSESは #1620 で意図的にfailedを含んでおり(赤いゲートの後の再実行が契約を引けるように)、getVerifiableTaskは最後に更新された行を返す。#1623 ではコールドスタートで失敗したcbb7fe71と、再送で成功しsucceededになった88280de0が並存し、succeededは解決対象外・孤児は対象内だったため、後のwait --verifyが孤児の古い scope スナップショットに当たって現行契約が許可しているパスを違反として exit 20 を返していた。cancelledはACTIVE_TASK_STATUSESにもVERIFIABLE_TASK_STATUSESにも属さないので、この経路が閉じる。対になる経路の点検結果: 初期化タイムアウトでstartSession()が throw するのは claude だけだった。codex / gemini / copilot / antigravity のwaitForReady()はタイムアウトを log してそのまま return し(codex の窓は 3s + 30×1s ≒ 33s)、opencode / vibe-local は固定 sleep のみで readiness を待たない。失敗は後段のsendMessage()側のwaitForPrompt()が throw して 500 の本文に載るため、(2) の修正でこれらのツールでも原因が CLI に出るようになる。よってツール側のコード変更は行っていない。空振り検証として 5 変異(孤児をfailedへ戻す/5xx の潰しを戻す/タイムアウトを generic 文言へ戻す/ルートを 500 へ戻す/予算を 15s へ戻す)を注入し、いずれも赤になることを確認済み -
稼働中のサーバを旧 CLI が "Stopped (no PID file)" と誤報する前方互換の欠落を修正 (#1632): #1354 で
~/.commandmate/.commandmate.pidの中身を bare integer から JSON state へ変えた際、パスは据え置いたまま形式だけを変えたため、PATH に残った旧 CLI(readPid()がparseInt(content, 10))は先頭の{で NaN → 「ファイル無し」と解釈していた(#1354 の後方互換は「新 CLI が旧形式を読む」片方向のみだった)。writeState()を 1 行目 bare PID / 2 行目 JSON のハイブリッド形式に変更し、parseIntが先頭の数字で停止する性質を使って前方互換を回復した。readState()も同時にハイブリッド対応させている(片側だけ直すと JSON.parse が失敗して{pid}のみに縮退し、versionが落ちて #1354 の CLI↔サーバ版不一致警告が黙って無効化される)。旧 3 形式(bare int / JSON 単体 / ハイブリッド)の読み取りと O_EXCL アトミック書き込み・#1358 のプロセス同一性検証は不変で、旧 CLI 相当のparseIntロジックで PID が取れることを固定 fixture のテストで担保した -
ロガーのキー名マスクが機密でないフィールドまで
[REDACTED]にしていた問題を修正 (#1640):SENSITIVE_KEY_PATTERNにkeyが単体で入っていたため、名前にkeyを含むだけのフィールドが軒並みマスクされていた。実害の代表例が読むモードの起動ログread-mode:ready {"key":"[REDACTED]"}(#1623)で、実際の値はg— そのログを読む理由そのもの(どのキーがバインドされたか)が伏せられていた。同ファイルの値パターン側は当初から(token|secret|api_key|apikey|auth)とkey単体を除いており、キー名パターンだけが広かった。緩める変更なので、先にsrc/全体(865 ファイル / ロガー呼び出し 619 箇所 / 構造化フィールド名 134 種)を TypeScript AST で洗い出してから変更している。緩んだのは実測でkey(read-mode.ts/clone-manager.ts)とcompositeKey(auto-yes-poller.ts)の 2 つだけで、新パターンで新たに露出する機密フィールドは 0 件だった(現時点のsrc/にはtoken/password等の名前を持つログフィールドがそもそも 1 つも無い)。新パターンはpassword|secret|token|auth+ 資格情報を運ぶ*key複合語(api/private/access/session/signing/encryption/ssh/gpg/deploy、-/_区切り可)で、apiKey/API_KEY/x-api-key/privateKey/accessKey/sshKey等は従来どおりマスクされる。publicは意図的に含めていない(VAPID 公開鍵のように公開が前提の値まで伏せてしまうため)。authは部分一致のまま残した —(?!or)でauthorを避ける案はauthorizationまで外してしまうので採らない。Issue 本文が誤マスクの実例として挙げたkeyName/bindKey/idempotencyKey/sortKey/publicKeyは、実測ではいずれもロガーへ渡っていない(idempotencyKeyは skill install API の DB 監査カラム、sortKeyは UI state、publicKeyは VAPID API のレスポンス)ため、実際に巻き込まれていたのは上記 2 フィールドである。動的キーが混入しうる経路も点検済みで、ロガーへ非リテラルを渡すのはlogSecurityEvent1 箇所(呼び出し 4 箇所の実フィールドはtargetPath/resolvedTarget/resolvedLinkTarget/currentPath/resolvedAncestor/value)、スプレッド 2 箇所は閉じた型(ProxyLogEntry/SkillPlanSweepResult)で、いずれも該当なし。値パターン側は不変なので、{ key: 'token=tok_live_123' }のように良性のキー名に埋まった秘密は引き続き値側で落ちることも回帰に固定した。空振りでないことは変異注入で確認済み — 旧パターン(key単体)へ戻すと 11 件、*key複合語を落とすと 15 件が赤