Skip to content

v0.27.0

Choose a tag to compare

@Kewton Kewton released this 24 Aug 04:28
· 41 commits to main since this release
245e49e

[0.27.0] - 2026-08-24

Highlight: エージェントの状態検出と通知の 2 層を、実測で作り直したリリースです。wait がアイドルなペインで返らなくなる回帰(#2011)を、実フレーム 7 枚と実 exit code で裁定して修正しました(修正前 124 → 修正後 0、陽性対照つき)。スマホ通知は「何が起きたか」から「あなたが動く必要があるか」へ軸を切り替え、Auto-Yes 中のプロンプト通知を抑止(#1999)、失敗 3 種を通知に接続(#2000)、端末間の消し込み(#2001)を入れています。あわせてセキュリティ 2 件 — 平文 CM_AUTH_TOKEN が子プロセスへ渡っていた問題(#1996)と、EXCLUDED_PATTERNS の秘密ファイルが files ルートのパス直指定で読み書きできた問題(#2014、読み取り 2 面+書き込み 4 面)— を塞ぎました。検証ゲートは 7 → 11 本に増やし(#1994)、ws-server / cli-tools/manager のモジュール循環を切って import 時間を 1043ms → 228ms / 1025ms → 417ms に短縮しています(#1984、対照つき実測)。

Added

  • feat(env-manager): ワークツリーの .env を PC / スマホ両対応の専用 UI で表示・編集できるようにした (#1968): Activity Bar の env(PC)と Tools タブの 環境変数(スマホ)から開く EnvManagerPane を追加。Key-Value 形式と Raw テキスト形式の双方で閲覧・編集・保存でき、値は既定でマスク(固定 8 文字。長さを漏らさない)され 👁️ で 1 行ずつ解除する。.env.example / .env.sample があれば未定義キーを補完サジェストとして提示し、dotenv 構文・危険な制御文字・サイズをクライアントとサーバの両方で検証する。対象ファイル名はサーバ側 allowlist(.env / .env.<segment> / .env.<segment>.local、[A-Za-z0-9_-] 1〜32 文字)で決まり、加えて isPathSafe(字句)と resolveAndValidateRealPath(シンボリックリンク)でワークツリー外への到達を塞ぐ。値はログにもエラー本文にも出さない。一般ファイルツリーの EXCLUDED_PATTERNS は緩めておらず、2 つの面が交わらないことを統合テストで固定している。実機確認手順は docs/features/env-manager.md。
  • feat(push): 待機が解決したら他端末に残る通知を無音で置き換える (#2001): Issue の提案(無音 push + showNotification を呼ばず getNotifications({tag})→close() のみ)は userVisibleOnly: true 契約により iOS だけでなく Chrome / Firefox / Safari の 3 エンジンすべてで成立しないため、「無音の置き換え」に訂正した — 解決 push は古いカードと同じ tag で close() してから silent: true / renotify: false の「対応済み」カードを 1 枚出す(端末上の枚数は増えず、音もバイブも鳴らない)。送信は「実際に鳴ったカードがある」「同じ worktree の他インスタンスが待っていない」「要対応バケツの購読が 2 台以上」の 3 条件を満たしたときだけで、1 台運用では push が 1 通も増えず、Auto-Yes が答えた待機(#1999)は no-card で落ちるため削減が食い潰されない。消し込む種別は prompt のみ(failure は「解決」の信号を持たないので「対応済み」は誤報になる)。判定は resolution-push-sent / resolution-push-skipped に理由コード(no-card / still-waiting / single-device / cross-device-clear / push-unconfigured)つきで出る。根拠と棄却案は docs/design/cross-device-notification-dismissal.md、実機手順は docs/qa/2001-cross-device-dismissal-uat.md。
  • feat(verify): build 3 本と integration を宣言ゲートに足し、負荷で裁定が反転するゲートだけを cpu.heavy に載せる (#1994): CI 12 ジョブのうち宣言していたのは 7 本で、2026-08-22〜23 の 3 日間に build と integration だけが見る欠陥が 3 件 develop に入った(#1943 の route export / #1927 の integration 期待値 / #1933 の build:cli TS2307)。3 件とも wait --verify は exit 0 を返していた。.commandmate/verify.yaml に build-cli / build-server / build / integration を追加し、CI 12 ジョブ中 11 本をカバーする。mutex は「負荷が変えるのは実時間だけか、裁定そのものか」で分け、決定的な build 系 3 本は mutex なし+余裕のある timeoutSec、テストランナー内部に 5s 予算を持つ integration だけを cpu.heavy に載せた(実測: 5 ワーカー相当で mutex 無しは 1/6 PASS、cpu.heavy 保有下では 6/6 PASS)。build は typecheck より前に置く(tsconfig.json が .next/types を include するため、後ろに置くと route の rename/削除で成果物由来の TS2307 偽陽性が出る)。
  • feat(api,ui): /respond を decisionId で応答可能にし、所属照合を入れた (#1932): POST /api/worktrees/[id]/respond の messageId を optional 化し、{ decisionId, answer, cliTool?, instanceId? } でも応答できるようにした。opencode の permission は scraper が promptData を出さないため画面経由では答えられず(#1898-3 実測)、CLI は #1898 で「番号/ラベル → id をサーバ側で解決」できるようになっていたが、Web 側には入口が無かった。解決スコープは resolve 済みの (worktree, tool, instance) に閉じる(方針書 §10.3 / D3 決定 3): 受け取った decisionId はその scope の listPending() に実在することを確認してから適用し、無ければ 404 decision_not_found で何も送らない。別 instance / 別 worktree の id は「拒否する対象」ではなくそもそも見えないので、forbidden ではなく not_found を返す。decisionId は外部由来なので 256 文字・^[A-Za-z0-9._:-]+$ で検証し、違反は破棄(切り詰めない) — 切り詰めた id は前方一致する別の id と衝突し、不正リクエストが「別の承認への送達」に化けるため(§10 DR4-001)。ソースに到達できず所属照合ができないときは 502 decision_source_unreachable で、answerStructuredDecision と違いフェイルオープンしない(このルートにはキーストロークの退避路が無い)。あわせて既存 messageId 経路に message.worktreeId === :id の所属照合を追加した(#1932 S6b、従来は未照合で、他 worktree の message id を渡すと URL 側の worktree の pane に回答がタイプされていた)。UI 側は PromptPanelProps.decisionId を追加し、decisionId と decisionOptions の両方が揃ったときだけ縮退表示(#1725)に 3 つの verdict ボタン(1=Allow once / 2=Allow always / 3=Reject)を出す。decisionOptions だけでボタンを出してはいけない — id が無い番号はキーストローク経路に落ち、カーソル移動式ピッカーではハイライト行が選ばれる(#1681)。TerminalSplitPaneContent は payload が id を名指ししたときだけ /respond へ、それ以外は従来どおり /prompt-response へ POST する。
  • npm run check:detector-freshness(Issue #1929): 手元にインストールされた CLI と各検出器の実測ビルドを突き合わせて一覧表示する任意実行のチェック。--json / --strict(陳腐化があれば exit 1)に対応。CI の必須ゲートにはしない(結果が runner にどの CLI が入っているかに依存するため)。
  • 検出器の陳腐化警告 detector.staleness と npm run check:detector-freshness(Issue #1929): 各ツールモジュールの検出規則がどの CLI ビルドを実測して書かれたかを src/lib/detection/tools/verified-against.ts に集約し、DETECTOR_VERSION_PROBES(src/lib/detection/version-probes.ts)がインストール版を読んで比較する。インストール版のほうが新しいツールは、認証必須の capture --json(detector.staleness)と commandmate status に { installed, verifiedAgainst } として出る。probe はプロセス内 1 回だけ実行し in-flight を共有してキャッシュする。5 秒ポーリング経路(capture / current-output)では probe を await しない — キャッシュが温まるまで detector キーは応答に載らず、これは「まだ分からない」であって「陳腐化なし」ではない(方針書 §4 D2 / DR3-013)。
  • feat(guard): route.ts の export 形状を静的ガードで裁定に載せる (#1946): src/app 配下の App Router route entry(128 本)が Next.js の受け付けない名前を export していないかを検査する scripts/check-route-exports.mjs を追加し、.commandmate/verify.yaml のゲート route-exports と CI ジョブ Route export guard の両方から同じスクリプトを呼ぶようにした。PR #1943 の export const SERVER_CAPABILITIES は npm run build だけが見る欠陥("SERVER_CAPABILITIES" is not a valid Route export field)で、Next の route 型チェックは next build が生成する型ガードファイルの中にしか存在しないため、build ゲートを持たない wait --verify を exit 0 で素通りして develop に届いた。許可リストは Next 15.5.20 の実装(HTTP_METHODS と next-types-plugin の Base 型)から導出し、tests/unit/guards/route-export-allowlist.test.ts が node_modules/next から再導出して突き合わせるので、Next の更新でリストがずれたら unit が赤くなる。判定は文字列 grep ではなく依存なしのレキサ(コメント・文字列・テンプレートリテラル・正規表現リテラルを 1 トークンとして食い、深さ 0 の export だけを見る)で、export type / export interface は実行時 export ではないので落とさない。実測 0.3s / 128 entries。
  • feat(api,cli): 状態の additive 契約拡張 — statusEvidence / lastKnownStatus、CliToolSessionStatus の理由、ls の REASON 列、structuredEvents の turn フィールド、wait --help の相互参照 (#1926): 多エージェント状態アーキテクチャ(docs/design/multi-agent-state-architecture.md、Issue #1915)Phase 1 の契約だけの着地で、判定は 1 フレームも変わらない(SessionStatus の値域も isUnclassifiedActive の真偽も不変)。(1) CurrentOutputResponse(capture --json)に statusEvidence: 'positive'|'none' / lastKnownStatus / lastKnownStatusAt を additive 追加。statusEvidence === 'none' は既存の isUnclassifiedActive === true と同じ事実で、#1924 が導出元にした evidence を外に出したもの。lastKnownStatus は「最後に肯定的に確認できた状態」の server 側 latch(in-memory、TTL は STRUCTURED_STATE_MAX_AGE_MS と同値の 30 分、再起動でクリア、セッション停止で破棄)。(2) 第 2 の契約変更(DR3-005): GET /api/worktrees / [id] の sessionStatusByCli (CliToolSessionStatus)にも statusEvidence? / sessionStatusReason? / lastKnownStatus? / lastKnownStatusAt? を additive 追加。ヘッダチップ・BranchStatusIndicator・ls はここが受け皿で、current-output への追加だけでは §7 の 4 行が実装できない。キー省略規約は model と同じ(読めなかったときは値 null ではなくキー自体を出さない)で、複数インスタンスの集約では落とす。(3) commandmate ls の表に REASON 列が増えます(STATUS と DEFAULT の間)。証拠なしの行は no_recent_output (no evidence) のように表示。--json はサーバーの行そのままで、理由と証拠は sessionStatusByCli.<tool> の下に入る(トップレベルには足していない)。(4) structuredEvents に turnId / openedAt / closedAt / closedBy を additive 追加。Phase 1 は最新イベント 1 件からの暫定導出で、turnId はまだ安定したターン同一性ではない(ターン途中の pre_tool_use で打ち直される)。lastEventType / lastEventAt は残り、wait の adoptTurnStart(#1839 のゲート)は Phase 4 まで移行しない。(5) wait --help に unclassified dwell(60 秒・exit 10・--stall-timeout / --timeout との優先関係)の節を追加(規約 3)。既存フィールドの削除・意味変更はゼロで、#1926 以前のサーバーを見ている CLI はキーが無いだけ(undefined は 'positive' とは別の意味)。
  • refactor(hooks): AgentSourceCapabilities に宣言値 5 項目を追加し 6×5 表を pin、evidence と unclassified-frame の型を先行着地 (#1924): 多エージェント状態アーキテクチャ(docs/design/multi-agent-state-architecture.md、Issue #1915 / PR #1920)Phase 1 の型と宣言値だけの着地で、状態機械の挙動は 1 件も変わらない(SessionStatus の値域は 4 値のまま。unknown は追加しない)。(1) AgentSourceCapabilities に permissionHookPredictsDialog / sessionStartMayArriveLate / permissionReplyReleasesPrompt / eventIdentity('permission-id'|'tool-call-id'|'message-id'|null) / resync('none'|'session-status-poll') を追加し、6 ソース全部(claude / codex / gemini / copilot / opencode / antigravity)+ 未対応ツール用の互換ソースに値を書いた。すべてJSON 直列化可能な宣言値で関数は置かない(§4 D3 決定 1)。値は方針書 §4 D3 の 6×5 表が正で、claude 行が「未計測セルの既定値」=現行挙動、departure は copilot の sessionStartMayArriveLate:true(#1903 実測、copilot 1.0.80)と opencode の permissionReplyReleasesPrompt:true / eventIdentity:'permission-id' / resync:'session-status-poll'(#1898-#1900 実測、opencode 1.18.20)だけ。判定軸は supportedEvents ではない(DR2-014): pre_tool_use が届かないのは gemini だけでなく codex / copilot / antigravity も同じで、「permission hook を登録しているか、その非 allow がダイアログの予告になるか」が軸である。(2) tests/unit/hooks/sources/capabilities.test.ts が表を件数ではなく値の完全一致で pin する(D3 の受入指標を grep 0 件から差し替えた DR3-006。あの grep は着手前から 0 件=空虚な緑だった)。あわせて「ソースディレクトリの全数」「capability キーの全数」「宣言値が関数でないこと」も pin し、7 本目のソース・9 個目の capability・関数化のいずれも表を更新せずには入らない。(3) ScraperVerdict に evidence: 'positive' | 'none' を追加し、isUnclassifiedActive をこの値からの導出に縮退させた(§4 D1 決定 2)。生産側の式は不変なので分類は 1 フレームも動かない(default / no_recent_output の 2 経路が 'none')。input_prompt を 'none' へ倒すのはツール別の idle composer 肯定検出と fixture が揃った Phase 3 で、ツール単位に行う(DR2-002)。mergeStructuredStatus は全分岐で evidence === 'none' として書く — 2 つの式で 1 つの事実を書くと Phase 3 で片方だけ動くため。(4) AutoYesSuppressionReason に 'unclassified-frame' を追加(§4 D1 決定 4)。同時更新先 3 箇所(auto-yes-resolver.ts / src/cli/types/api-responses.ts / wait.ts の SUPPRESSION_CAUSE)で、server / CLI 双方向 pin(tests/unit/cli/config/cross-validation.test.ts)と Record 網羅が片方だけの更新を tsc で落とすことを変異注入で確認済み。記録は Phase 3 から(ツール別 detectDialog が汎用推定と食い違える位置が要る)。(5) capture --json の structuredEvents.source = { cliToolId, capabilities } を additive 追加(§7)。既存フィールドの型は変えていない。方針書 DR2-022 からの意図的な逸脱: 「ホットパスの current-output では名前だけ返し capabilities は詳細取得時に返す」とあるが、capture --json は GET /api/worktrees/:id/current-output の応答をそのまま印字する実装(src/cli/commands/capture.ts)なので詳細取得という別経路が存在しない。新設は本 Issue の範囲外(型と宣言値だけ)なので、~250 バイトの静的 JSON として無条件に載せた。
  • refactor(guard): 'claude' の解決フォールバックを AST ガードで baseline 固定する(実測 21 箇所 / 13 ファイル・うち解決フォールバック 10 件、減少のみ許可) (#1923): 方針書 §4 D5 決定 4(docs/design/multi-agent-state-architecture.md)の Phase 1 ガード。tests/unit/guards/no-claude-fallback.test.ts を追加し、src/app/api 配下の route.ts・src/cli/commands/**・src/lib/session/**(Claude 固有の claude-session.ts / claude-executor.ts は除く)を ESLint の esquery セレクタで AST 走査して、?? 'claude' / || 'claude' / 三項の分岐 / const D: CLIToolType = 'claude' / 呼び出し引数 / オブジェクト値 / return / 既定引数 の 8 綴りを検出する。baseline の各行に「解決フォールバック / 対象外(理由つき)/ 許可」の区分を機械可読に併記し、対象外行(Claude 固有 hook ルート・wait の表示既定・commander の option 既定)は進捗に数えない。許可は resolveSessionTarget の DEFAULT_SESSION_CLI_TOOL 1 件のみ。増加は赤・減少は緑。製品コードの挙動は変えていない(テスト 1 ファイルの追加のみ)。Issue 本文の「36 箇所 / 19 ファイル」は 'claude' の素の grep 値で case / === / 許可リスト配列 / コメントを含むため AST 実測とは一致しない(本 commit で測り直した)。Issue 本文後半の「i18n no-restricted-syntax を warn → error へ格上げ」は #1922 で着地済みのため再実装していない。
  • feat(guard): token discipline ガードに「トークン名の実在検査」を追加 (#1889): 従来のガードは生配色ユーティリティの不在しか見ておらず、実在しないセマンティックトークン名へ置換しても PASS した。Tailwind は解決できないクラスを黙って捨てるため、症状は「背景が消える/文字色が継承されて読めない」という視覚だけの silent failure で、クラス名が単なる文字列である lint・tsc・unit のいずれも検出できない(PR #1881 では実在を人間が globals.css の grep で手検証していた)。scripts/check-token-discipline.mjs(CI ジョブと verify.yaml ゲートが共有する #1882 の単一権威ソース)を拡張し、(bg|text|border|ring)-<rest> の <rest> が Tailwind 組み込みの非配色ユーティリティ / Tailwind 組み込み配色 / globals.css の --color-* のいずれにも当たらなければ hard-fail する。Tailwind に解決させず許可リストを採ったのは、CI の token-discipline ジョブが checkout + run: 1 本で npm install を行わない(= tailwindcss を import できない)ため。Issue が指摘した「--color-* に無い名前は全部エラー」の素朴な実装は 121 種中 79 種を誤検出するが、許可リストを入れた時点で残る偽陽性は実測 11 種(text-align 等の CSS プロパティ名 6 種+a text-entry context 等の英文コメント 5 種)まで落ち、コメント本文の除去と 「直後が : なら CSS 宣言」の判定で 0 種になる(develop 現状で exit 0)。動的クラス名(`bg-${tone}-subtle`)・arbitrary value・4 接頭辞以外の配色ユーティリティ・コメント本文は検出できないことをスクリプト冒頭と docs/design-system.md に明記。*Terminal* 例外・テストファイル除外・src/app/worktrees 除外は新検査にも同じく適用される。
  • chore(verify): 検証ゲートの CI 網羅ギャップを塞ぎ、静的ガードの実装を scripts/ に一本化 (#1882): wait --verify が全ゲート exit 0 を返した commit が CI の Token discipline で FAILURE になった(PR #1881)。.commandmate/verify.yaml の宣言ゲートが lint / typecheck / unit の 3 本だけで、CI 11 ジョブのうち 8 本を見ていなかったためで、/orchestrate がワーカーの完了を exit code で裁定する設計を部分的に無効化していた。ただし verify.yaml へ git grep や閾値をコピーしない:同じ検査の実装が 2 箇所に増えると片方だけ更新されて静かに乖離し、乖離は必ず「verify は緑・CI は赤」の向きに倒れる。そこで Token discipline と CLAUDE.md size check のインライン検査本体を scripts/check-token-discipline.mjs / scripts/check-claudemd-size.mjs へ切り出し(既に scripts/check-control-chars.mjs を呼ぶ形だった Control character check に合わせた)、ci-pr.yml の当該ジョブはそのスクリプトを呼ぶだけにしたうえで、verify.yaml に token-discipline / control-chars / claudemd-size の 3 ゲートを同じスクリプトを実行する形で追加した(実測 3 本合計 0.2 秒、裁定時間への影響はほぼゼロ)。Integration(2.1m)/ Legacy tmux / Security Audit / Build / E2E は所要と副作用のため追加していない。切り出しが挙動を変えていないことは旧インラインシェルを抽出して新旧を同一入力で突き合わせて証明済み(clean / PR #1881 の生 sky 配色再現 / *Terminal* / .test.・__tests__ / src/app/worktrees / CLAUDE.md の 34999・35000・35001 バイト境界で出力・exit code とも完全一致)。*Terminal* 例外(両テーマでダークを維持する意図的な常時ダーク島、#1079)を含む除外は新テストで固定し、除外を落とす変異注入で赤になることも確認した。実機では commandmate verify commandmate-issue-1882 が新 3 ゲートを実行し、生配色を 1 行入れると exit 20 を返す。
  • feat(ui): composer に残った未送信テキストを表示し、ワンクリックで実行・クリアできるようにする (#1879): Claude が推奨コマンドを composer に事前入力した状態(あるいは人間が打ちかけて離席した状態)は、read-only ターミナル越しでは目で読んで打ち直すしかなかった。Enter を送れる既存 UI(NavigationButtons / TerminalEscapeHatch)は「迷子の Enter が composer に届かないように」検出フラグでゲートされており、通常の入力プロンプトでは意図的に出ないためである。capture payload に composerText / composerState を追加し、中身が非空のときだけ「未送信の入力」バーを PC・モバイル両方に出す。[実行] は既存の special-keys に ['Enter'] を送る(新 API なし)。[クリア] は新設の POST /api/worktrees/[id]/clear-composer で、#1878 §5-1 の実測(行頭カーソルでは C-u が何も消さない/複数行は 1 回では消えない)を踏まえ C-e+C-u を読み戻し検証つきでループする。表示条件は isUnclassifiedActive / isSelectionListActive に一切依存せず、既存ゲートも不変(「中身が空なら Enter を送る導線は出ない」ことをテストで固定)。抽出は stripAnsi 前の生 capture の SGR を見るため、Claude Code v2.1 が空の composer に dim(ESC[2m)で描くゴースト/プレースホルダを実内容と取り違えない(ANSI 除去後は実残存と 1 バイトも変わらないため、fixture も ANSI 付きの実 capture)。claude 限定。

Changed

  • docs(orchestrate): 並列度の最適点を実測で置き換え、PR をゲートの前に出す手順と 完了検出が壊れたときの退避を足した (#2002): 6-1 の「同時 CI は 2〜3 本」は 「5〜6 本で約 2 倍」という 1 点の実測に基づいていたが、CI 実行 118 本(2026-08-21〜24)を同時実行ピーク別に集計すると 1 本=10.8 分 / 3 本=11.6 分 / 4 本=14.7 分 / 20 本=49.2 分で、3〜4 本までは +8〜36% とほぼ無償だった。あわせて逆方向の失敗を明記した — 同時 1 本で回した帯は CI 単体こそ最速(10.8 分)だが**スループットは 2.50 → 0.50 PR/h(5 分の 1)**まで落ち、1 本あたりの品質指標(1 マージあたりの CI 実行回数 3.7 → 2.0、PR 作成→マージ 最長 6 時間 → 11〜13 分)はすべて改善していたので、落ちたのは並列度だけだった。新設 6-1-1 は PR をローカルゲートより前に出す手順 — ローカルゲートと CI は同じテストを見ており、順に回すと 1 issue あたり約 22 分を直列に払う。ローカルゲートの 85〜90% は unit 単独(実測 545〜584 秒。gate-runner が CI:'true' を渡し fileParallelism:false になるため素の約 9 倍)なので、速い 3 本(lint/typecheck/build、計 40 秒前後)だけ先に通して PR を出せば 1 issue あたり約 10 分が消える。3-3 には wait --verify の退避手順を追加 — 完了検出はゲートの前に居るので検出層の欠陥が裁定を止める(#2011 の回帰で 3 ワーカーが Unclassified interactive frame … Waiting for human response... のまま 18 分空転した)。verify --gates は完了検出を経由せず、--gates を渡せば scope が選択されないので exit 99 にも落ちない。その場合 work-evidence と scope はオーケストレーターが手で照合する。
  • refactor(ws-server,cli-tools): ws-server と cli-tools/manager をモジュールスコープの循環から外す (#1984): import('@/lib/ws-server') 1043ms と import('@/lib/cli-tools/manager') 1025ms がほぼ同値だったのは偶然ではなく、両者が同じ 1 つの強連結成分だったため — どちらを import しても ws-server -> cli-tools/manager -> polling/response-poller -> polling/response-poller-core -> polling/response-checker -> ws-server (および -> realtime/terminal-broadcast -> ws-server)が丸ごとロードされ、その底にある session/cli-session -> tmux / child_process まで引かれていた。実測で ws-server を通る循環は 2 本、manager を通る循環は 8 本あり、manager 側 8 本はすべて manager -> response-poller を通るので、切ったのはその 1 辺と ws-server -> manager の計 2 辺である。(1) CLIToolManager.stopPollers() を await import('../polling/response-poller') 化した — この静的 import は stopResponsePolling(...) 1 行のためだけに在り、そこが循環の起点だった。代償として戻り値が void から Promise<void> になり、唯一の呼び出し元 api/worktrees/[id]/kill-session/route.ts に await を足した。足さないと poller 停止が直後の deleteSessionState() より後ろへずれる(型検査も lint も検出しない静かな挙動変化。npm run lint の設定に no-floating-promises は無く、既存の kill-session-cli-tool-gateway-1905.test.ts は順序を見ていないので await の有無で結果が変わらない)ため、実際の副作用の順序を tests/unit/api/kill-session-stop-pollers-order-1984.test.ts で固定した。(2) セッション名の規則を src/lib/cli-tools/session-name.ts に切り出し、ws-server は CLIToolManager ではなくそれを引くようにした。ws-server が manager を必要としていたのは getTool(id).getSessionName(...) の戻り値 1 つだけで、getSessionName() は BaseCLITool の 1 実装しかなく 7 つの具象ツールはどれも override していない(実測)。BaseCLITool.getSessionName() は新モジュールへ委譲するので規則の実装は 1 箇所のままで、7 ツール全部で tool.getSessionName(...) === resolveSessionName(tool.id, ...) が成り立つことを tests/unit/cli-tools/session-name-1984.test.ts が 84 通りで固定する(食い違えば ws-server が存在しない tmux セッションを購読しにいく)。実測(冷えた vitest プロセスで各 5 回、中央値): ws-server 1043ms → 228ms(-78%)、cli-tools/manager 1025ms → 417ms(-59%)。対照として本 Issue が触っていない polling/response-checker は 1035ms → 1040ms で不変(マシン全体が速くなったのではなく、切った 2 辺の効果であることの陽性対照)。循環そのものは tests/unit/guards/no-ws-server-manager-cycle-1984.test.ts が静的 import グラフで検査し、再 export(export { ... } from)を辺として数えること・合成グラフで検出器自身が循環を見つけること・本 Issue が触っていない response-checker <-> response-poller-core の循環を今も報告することを陽性対照として持つ(この 3 つが無いと「循環 0 件」はパーサが壊れている場合にも成立する。実際に最初の実装は再 export を数えず 0 件と報告した)。tests/unit/ws-server-cleanup.test.ts の #1977 由来の @/lib/cli-tools/manager stub は、manager が ws-server の依存グラフから消えたことで何も塞がなくなったので撤去した(#1977 が scope 外として見送った本番側の是正が本 Issue で着地したため)。ふるまいの変更は stopPollers() の非同期化のみで、セッション名・kill の手順・broadcast の内容はいずれも不変。
  • feat(push): 通知の分類を「出来事」から「要対応かどうか」へ切り替え、失敗 3 種を通知に接続した (#2000): 通知種別を 'prompt' | 'completion' から 'prompt' | 'completion' | 'failure' へ広げ、検証ゲート不合格 / 上流 API 障害 / セッション起動失敗を新しい kind: 'failure' として配信する。保存列は 2 本のまま(Issue の提案表自体が「要対応 / 参考情報」の 2 バケツで、プロンプト待ちも失敗も同じ「あなたが動く必要がある」側なので、enabled_prompt を要対応バケツとして意味を広げ、マイグレーションを伴う 3 列目を避けた。対応は KIND_COLUMN の exhaustive Record なので、種別を足すと型検査で分類を迫られる)。正常完了は新規購読のみ既定 OFF(NEW_SUBSCRIPTION_DEFAULTS.enabledCompletion = false)で、既存の購読行は 1 行も変えない —— 既定は DB の DEFAULT ではなく upsertPushSubscription() の INSERT が束縛するリテラルだったので、マイグレーションも既存行の UPDATE も不要だった(v41 の DEFAULT 1 はこの経路では一度も使われない。SQLite は ALTER COLUMN で DEFAULT を変えられずテーブル再構築が要るため宣言は据え置き、意図はコメントで残した)。同じ障害で鳴り続けないことは信号の形ごとに保証する: 上流障害は poll ごとに再評価される level なので push/failure-episode-state.ts で edge に変換し(episode 同一性は fault id、observeWaitingEdge と同型の単一書き手)、さらに instance ごと 30 分の cooldown を重ねる —— 529 storm は判定窓(末尾 100 行)から banner が出入りし、しかも overloaded と retrying の 2 署名を交互に踏むので、素の open/close edge では 1 インシデントで最大 10 回鳴る。検証失敗とセッション起動失敗は event 形(run は 1 回閉じ、start は 1 回 throw)なので push-sender の 30 秒 content 窓だけを guard とし、その content には excerpt ではなくインシデント署名(verification:<runId> 等)を渡す(retry ごとに attempt 番号が変わる文面は dedup キーとして機能しない)。検証ゲート不合格は契約タスク以外の run だけ通知する(/orchestrate の並列ワーカーは全 run が赤で鳴ると Epic #2002 の目的と正面衝突する)。判定材料は startVerification が解決した taskId であって payload.taskId ではない —— resolveTask() は taskId 未指定でも getVerifiableTask(worktreeId)(#1545)へ落ちるので、input.taskId を見る実装ではワーカーの run が素通りする(実測)。上流障害の観測点は current-output-builder(upstreamFault を計算する読み取り経路=ブラウザが見ているときだけ走る)ではなく polling/response-checker(サーバが自分で回す poller)で、判定窓は公開フィールドと同じ末尾 100 行に揃えた。失敗は #1999 の prompt-push-gate を通さない(Auto-Yes はダイアログに答えるだけで、赤いゲートも上流障害も起動失敗も直さないため、そこで黙らせると詰まったパイプラインだけが無音になる)。全判定は failure-push-raised / failure-push-suppressed に理由コード(contract-task / run-not-failed / upstream-cooldown / upstream-same-episode / push-unconfigured)つきで出るので、「鳴らない」と「壊れている」を運用者が切り分けられる(docs/design/discoverability-principle.md)。設定画面と通知本文は ja / en とも行動ベースに置き換えた(「応答待ち」→「対応が必要なとき」、「セッション完了」→「完了も知らせる(任意)」、失敗本文は 検証ゲート不合格: / Stalled by an upstream API fault: のように失敗を名指しするので成功と読み分けられる)。SessionStartTimeoutError は通知しない(#1637 の定義どおり「遅い起動であって失敗ではない」)。
  • docs(design): 方針書の識別子を実測に合わせて訂正し、棚卸しを再実行可能なガードにする (#1995): docs/design/multi-agent-state-architecture.md が名指す識別子を機械抽出して src/ / tests/ / scripts/ の実在と突き合わせ、実在しなかった 8 件を訂正した(readOpencodeEventStream→openOpencodeEventStream(#1900)/StatusVerdict→ScraperVerdict・ToolStatusVerdict(#1924/#1926/#1927)/getStatusCaptureLines の Phase 3 TODO は #1933 で着地済み/src/lib/__tests__/** は #1939 で tests/unit/lib/** へ移設済み/findOnPath→findExecutableOnPath/deliverVerdict→answerPendingDecision・AgentEventSource.encodeVerdict/CLITool→ICLITool)。併せて §4 D1 決定 4 の「Auto-Yes の接続先は response-checker.ts の detectPromptWithOptions」を実測に合わせ、キーを送るのは src/lib/auto-yes-poller.ts の detectAndRespondToPrompt である(response-checker.ts に送出コードは 1 行も無い)と書き直した。棚卸しは scripts/design-doc-identifiers.ts(任意の設計書に向けられる)と tests/unit/docs/design-doc-identifier-audit.test.ts として資産化し、未実装 20 件を理由つきで宣言したうえで「実在→不在」(改名・削除の着地)と「不在→実在」(予定の着地)の両遷移を固定する。ソースコードは 1 行も変更していない。
  • docs(orchestrate): 2-4-1 に CHANGELOG 断片の実例を丸ごと 1 本貼り、module-reference 断片には実在確認の出力を書き写させる (#1997): /orchestrate 2-4-1 はワーカーが書く dev-reports/changelog/issue-<N>.md の形式を 1 行の抽象仕様(- **<type>(<scope>): …** (#<N>): …)でしか示しておらず、2026-08-22〜23 の Phase 4 では 4 ワーカー中 3 つが形式から外した(#1930 / #1931 / #1933。規約どおりは #1932 のみ)。外れた断片は 6-4 の集計 grep -cE '^- \*\*' に数えられない。契約へ実例を丸ごと 1 本貼った #1994 の断片が初回から規約どおりだった対照に合わせ、develop の CHANGELOG.md にある実エントリ(#1975 の fix(cli) 行)を転記ブロックの中へ逐語で貼り、- ** で始める理由(集計 grep)・(#N) を要約の外に置く理由(機械抽出)・<type> が CLAUDE.md の規約語彙である理由(リリースノートの分類)・1 エントリ 1 行の理由を 1 行ずつ添えた。module-reference 断片側は実例では足りないと判断した — #1927 / #1932 の契約には既に実在確認の指示があったのに存在しない行への追記指示が 4 件 / 1 件出ており、形式ではなく事実の誤りで断片を読んでも分からないため — ので指示を「確認する」から「grep -n '^| \`'の出力(行番号つきの行キー)を断片に書き写す」へ変え、実在する行キーを使った実例を添えた(実在確認は転記ブロックには入っておらず、直近 2 本の契約はオーケストレーターが手で足していた)。ガードはtests/unit/tasks/orchestrate-changelog-fragment-example.test.ts— 実例行がCHANGELOG.mdに逐語で在ること・理由が 4 行あること・型語彙が CLAUDE.md の表に在ること・module-reference 実例の行キーがdocs/module-reference.md` に実在することを pin する(変異 6 種で赤を確認)。6-4 の手順は変更なし。
  • docs(verify): mutex 判定基準(9.6)と、ゲート順序が意味を持つ場合を仕様に書く (#1994): docs/design/verification-config.md に §9.6「どのゲートに mutex を付けるかの判定基準」を追加し、実測表と timeoutSec の算出根拠(N ワーカー × 各 mutex ゲートの遅い実測の合計)を記録した。§3.2 に「先行ゲートの副作用を後続ゲートが読むときは順序が意味を持つ」を追記。
  • refactor(hooks,session): 構造化層の状態導出を「最新イベント=verdict」から instance ごとの TurnRecord へ置換する (#1930): 構造化層の状態導出を「最新イベント=verdict」から instance ごとの TurnRecord へ置換した(Issue #1930、Epic #1921 Phase 4)
  • version probe が絶対パス解決を経るようになった(Issue #1929, DR4-010 (2) / §13.2 S17): DETECTOR_VERSION_PROBES の execFile probe は PATH を歩いて実行可能ファイルを絶対パスで特定してから起動し、解決できなければ子プロセスを 1 つも起動せずに probe をスキップする(detector.staleness にその行が出ない)。copilot は resolveCopilotExecutable() へ委譲し、gh copilot -- --version は使わない — 未インストール環境で CLI のダウンロードを起こすため(#1907 / #1979 の実測)。probe は sanitizeEnvForChildProcess() の env・timeout 5000ms・maxBuffer 64KiB で走る。
  • 状態検出をツール別モジュールへ分割し、「否定の不在 → ready」を廃止(Issue #1927 / Epic #1921 Phase 3)
  • docs(design): 多エージェント状態アーキテクチャ方針書の 3 記述を Phase 2 の実測で訂正する(copilot の version probe / copilot の idle 証拠 / UNKNOWN_FRAME の未着地) (#1979): 方針書 docs/design/multi-agent-state-architecture.md だけの変更で、ソースコードは 1 行も変えていない。Epic #1891 の実装 26 件と実機受入で、設計レビュー時点の推定で書かれていた 3 箇所が実測と食い違うことが判明し、いずれも Phase 3(#1927 / #1928 / #1929)が直接の実装対象だったため、訂正しないと「方針書どおりに実装して間違える」形になっていた。(1) §4 D2 の probe 表 / DR4-010 規約 (1) / §10.11 / §13.2 S17 の gh copilot -- --version を撤回した。copilot は gh の拡張ではなく gh 2.86.0 に組み込まれた preview コマンドで、未インストール環境ではリリースをダウンロードする(gh 自身の --help が明記。#1907 実測)。version を知るための probe が環境を変更するのは「ホットパスで副作用を持たない」という D2 の前提と両立しない。代替候補も実測で潰した — gh extension list は copilot を一切列挙せず(出力 0 行)、gh extension list --json は unknown flag。採った案は resolveCopilotExecutable()(src/lib/cli-tools/copilot-executable.ts、#1907 で着地、#1913 が VERSION_PROBES へ kind:'delegated' で接続済み)への委譲で、これは DR4-010 (1)「probe 対象と launch 対象を別物にしない」をより強く満たす(CopilotTool.isInstalled() / startSession() が同じ関数の同じ戻り値で起動先を決めるため原理的に食い違えない)。したがって copilot を例外扱いする必要は無く、probe 対象から外す必要も無い。使い捨ての PATH / HOME / XDG_DATA_HOME で 3 scenario を実測し、copilot も gh も無い環境・gh だけある環境のいずれでも子プロセスを 1 つも起動せず null を返すこと(gh は findOnPath のゲートに使うだけで実行しない)、~/.local/share/gh が作られないこと、ユーザーの ~/.config/gh が更新されないことを確認した。あわせて DR4-010 に規約 (5) probe は環境を変更してはならない を追加し、S17 の受入条件を「gh copilot -- --version を綴った実装が赤になる変異ケース」つきに書き換えた。(2) §4 D1 決定 1 の (2) にあった copilot の例(「● 応答行の直後に空の composer」)を実 TUI キャプチャで差し替えた。1.0.80 のどのフレームにもこの形は無い — composer ❯ は 200x1000 ペインの 999 行目に固定で、応答本文の約 930〜970 行下にあり(実測: turn-complete.txt は本文 65 行目、turn-running-thinking.txt は 28 行目)、生成中も同一の形で描かれる。この例を実装指針にすると生成中を ready と誤判定する規則ができる(#1885 の症状そのもの)。copilot の肯定的完了証拠はペイン最下行のステータスバー(idle: ← open sidebar · / commands · ? help · tab next tab / 生成中: ● Working esc interrupt)で、readCopilotStatusBar / COPILOT_IDLE_STATUS_PATTERN / COPILOT_WORKING_STATUS_PATTERN として #1885 で着地している。根拠は tests/unit/lib/detection/fixtures/copilot-live-1885/ の実キャプチャ 4 件(copilot 1.0.80 / 200x1000)。同じ誤りを再生産しないよう、§6.1 の移行マッピング表 (2) 行と §13.1 のチェックリストからも「composer が空」を全ツール共通規則とする書き方を外した。(3) STATUS_REASON.UNKNOWN_FRAME が未着地であることを明記した — grep -rn 'UNKNOWN_FRAME' src/ は 0 件(Phase 2 の scope 外。develop a175767a で実測)。§6.1 に現在の STATUS_REASON 値の全列挙とともに書き、§8 Phase 3 行と §13.1 に #1927 の作業として起こした。§8 Phase 3 行の「新規 Issue(Phase 0 完了時に分割)」も実在の #1927 / #1928 / #1929 に置き換えた。§15.5 に「Phase 2 の実装・実機受入で覆った実測」表(3 件・影響 Issue 併記)と、3 件に共通する失敗の形(実行体・実フレーム・実定数を見ずに設計上あるべき形を書いた)を追記した。
  • refactor(api,cli): tool/instance 解決を resolveSessionTarget に一本化し、CLI をサーバ委譲・版スキュー対応にする (#1925): 「この要求はどのエージェントのどのインスタンス宛てか」の実装が 4 つあり、しかも答えが食い違っていた(設計 §3 P4)。kill-session は明示 ?cliTool を roster より優先し矛盾を報告せず、CLI 側の写しにはプライマリインスタンスの段(instanceId がツール名、#868)が無かった。CLI ツールIDは tmux セッション名の一部なので、食い違いはそのまま「別のセッションを触る」ことを意味する。正本を src/lib/session/resolve-session-target.ts の 1 実装に寄せ(優先順位は roster > 明示指定 > primary anchor > worktree 既定 > 既定エージェント、instanceId 未指定時は roster を見ない)、GET /api/worktrees/:id/resolve-target(createRequestRateLimiter 適用)と GET /api/capabilities({ serverVersion, capabilities } の固定トークンのみ・AUTH_EXCLUDED_PATHS に入れない・Cache-Control: no-store)を新設した。CLI(send / respond / capture / auto-yes)は自前解決をやめてサーバへ委譲する。挙動変化 3 件: (a) kill-session の解決が明示優先から roster 優先に変わり、矛盾時は 400 instance_tool_conflict(従来は黙って明示側を採っていた)。あわせて「どれでも解決できない instance」への 400 が無くなり、send / capture と同じく worktree の既定エージェントへ落ちる。(b) 読み取り経路の矛盾は 400 ではなく警告つきで続行 — capture --instance X --agent Y が roster と食い違う場合、従来の exit 2 をやめて stderr に 1 行警告し roster 側を読む(監視スクリプトは capture の非 0 を「今回のポーリングを飛ばす」と解釈して無音で回り続けるため)。変更系(send / respond / auto-yes --enable / terminal)は従来どおり拒否する。(c) send --model が解決後のエージェントに対して検証される — --instance copilot-2 --model gpt-5-mini が --agent copilot の重複指定なしで通るようになった。あわせて POST /api/worktrees/:id/terminal が instanceId を受け付け、非プライマリのセッションへ送れるようになった(従来は常にプライマリ宛て)。版スキュー: npm i -g は稼働デーモンを再起動しないため、CLI は GET /api/capabilities をプロセス内 1 回プローブし(Accept: application/json + redirect: 'manual')、本物の 404(本文が空 or JSON)だけを旧サーバと解釈して従来の 2 段解決へ縮退する(resolvedBy: 'client-fallback' + stderr 警告 1 行)。401/403・3xx/HTML・500・通信エラーではフォールバックせず終了する — 互換経路は primary anchor 段を持たない劣化解決なので、認証未通過や中間装置の応答をそこに落とすと send / respond の着弾先が変わりうる。
  • fix(cmate): opencode の --model provider/model を CMATE.md スケジュールで実際に通し、到達不能だった ollama/ 前置分岐を整理する (#1914): claude-executor.buildCliArgs() の opencode run -m ollama/<model> 分岐は #379 以来一度も実行されたことがなかった — job-executor.resolveModelOption() が opencode に常に undefined を返し、もう一方の呼び出し元 daily-summary-generator も SUMMARY_ALLOWED_TOOLS(claude/codex/copilot/antigravity)で塞がれていたため。挙動変更: TOOLS_WITH_MODEL_SUPPORT に opencode を追加(1→2 件)し、resolveModelOption() の cliToolId === 'copilot' ハードコード(同じ Set の 2 つ目のコピー)を TOOLS_WITH_MODEL_SUPPORT.has() に置き換えて配線を 1 箇所に寄せた。これで CMATE.md に | … | opencode --model ollama/qwen3:8b | … | と書くと opencode run -m ollama/qwen3:8b <message> が起動する。値は provider/model のまま verbatim で渡す — ollama/ の前置は撤去した(opencode run --help の -m,--model が "in the format of provider/model"=1.18.21 で実測。前置は Ollama 以外のプロバイダを到達不能にし、provider を含む値を ollama/anthropic/… に二重化していた)。分岐を削除せず到達可能化したのは、消すと opencode --model x がパースも検証も通ったうえで無視されるためである。あわせて src/config/schedule-config.ts の getPermissionOptionsForTool() から default: return GEMINI_PERMISSIONS を廃止し、CLI_TOOL_IDS の 7 件すべてに case を置いて OPENCODE_PERMISSIONS / NO_PERMISSION_FLAGS を追加(返り値は全ツールで不変、DEFAULT_PERMISSIONS に 唯一欠けていた opencode: '' を補完)。
  • docs/fix(cmate,i18n): opencode / copilot 周辺のドキュメントと CMATE 既定を 6〜7 ツール前提へ是正する (#1914): agent-event-hooks.md(日英)の「自動注入は Claude のみ」を撤回し、registry に登録済みの 6 ツール(claude / copilot / gemini / antigravity / codex / opencode)のツール別表(設定ファイル・スコープ・相関キーの運び方・配送・裁定イベント・決定予算)と §0.8 の各ツール詳細を追加した — copilot がマシン共通の ~/.copilot/settings.json を merge すること・CM_AGENT_WORKTREE_ID 無しでは hook が不活性なこと・決定予算 10 秒・#1904 の原子的置換+ロックと config.json に hooks があれば書かずに素の起動へ落ちること・CM_AGENT_HOOKS_INJECT=0 の opt-out・opencode は --port + SSE で CommandMate が購読する側であることを明記。挙動変更: CMATE.md の Permission 列で opencode が claude の --permission-mode 値(acceptEdits 等)を受け付けていたのを止めた(cmate-parser は空へ落として warn、cmate-validator はエラー)。両者の default: は CLAUDE_PERMISSIONS ではなく「許可フラグ無し」を指すようになり、CLI_TOOL_IDS に増えたツールが Claude の語彙を継承しなくなる。i18n の status.claudeIsThinking(全ツールで「Claude is thinking...」)を status.agentIsThinking の {toolName} 補間へ置き換え、Assistant の CLI リファレンスを --instance 単独形(#1638)+ instances / verify / sync / send --contract へ更新。docs は他に module-reference(CLI_TOOL_IDS 7 件・CLI_TOOL_DISPLAY_NAMES.copilot='Copilot'・opencode/copilot の sendMessage は submit 検証つき送信・NavigationButtons は OpenCode 専用ではない)、architecture(6→7 ツール)、cmate-schedules-guide(opencode / antigravity 追加)、cli-operations-guide(report --tool antigravity)を是正した。
  • refactor(lint): src/lib/tmux/** の直接 import を ESLint の error で塞ぎ、実測 31 ファイルの allowlist(減少のみ)で固定する (#1922): route / CLI / poller / ws から tmux を直接叩く経路が 31 ファイル分あり、CLITool(と第 2 のゲートウェイ captureSessionOutput)を迂回しても何も鳴らなかった。.eslintrc.json に no-restricted-imports を severity error で入れ、既存 31 件を overrides の allowlist に置く(投入直後の npm run lint は exit 0 / 出力 0 行のまま。以後 allowlist は減ることしか許さない)。allowlist は「恒久除外 12(対応する ICLITool メソッドが無い)」と「段階解消 19(#1905 / #1906 が削る唯一の進捗指標)」の 2 エントリに分けて機械可読にし、tests/unit/guards/tmux-import-allowlist.test.ts がソート済みパス列挙を完全一致で pin する(npm run lint の対象は src だけで、allowlist の件数は ESLint 側では固定できないため)。設計方針書の 3 綴り(@/lib/tmux/** / **/lib/tmux/** / ./tmux/**)では足りないことを実測した: no-restricted-imports が使う ignore パッケージで直接測ると ../tmux/x・../../tmux/x・barrel の @/lib/tmux が素通りし、しかも ../tmux/** は仮定ではなく src/lib/cli-tools/*.ts が実際に使っている綴りである。**/tmux/** と **/tmux を足した 5 綴りで出荷し、@/config/tmux-pane-config / ./tmux-capture-cache に当たらないことも同時に確認した。動的取得はコアルールでは捕まらない(DR4-005)ため no-restricted-syntax に ImportExpression / require の 2 セレクタを追加し、同キーの既存 i18n セレクタを D5 決定 4 (1) に従って warn → error へ格上げした(1 ルール 1 severity の制約。格上げ後も lint は 0 件)。動的側は allowlist を持たない。再エクスポート経由の穴(allowlist 済みモジュールが tmux シンボルを再 export すると、その先は完全に無検出)は、src/lib/session/index.ts の export * from './claude-session' を明示的な名前付き re-export へ置き換え、かつ「allowlist 済みモジュールが tmux パスから re-export しない」ことをガードテストで pin して塞いだ。NavigationKey は src/lib/tmux/tmux.ts から src/types/terminal-keys.ts へ移し(tmux.ts が再 export するので server 側の import 元は不変)、NavigationButtons.tsx / TerminalEscapeHatch.tsx の型のみ import を解消した — allowTypeImports で恒久的に正当化する案は D4 の決定どおり採っていない。陽性対照はすべて実際に撃った: allowlist を外すと ESLint が報告するファイルがちょうど 31 件で allowlist と完全一致すること、非 allowlist ファイルで静的 5 形(別名 / 相対 / barrel / 深い相対 / export * from)と動的 3 形(import() 2 種 / require())がすべて error になること、@/config/tmux-pane-config などが当たらないことを、ガードテスト内で ESLint の Node API を実行して固定した。さらに overrides.files の [id] は minimatch のキャラクタクラスであり、素で書くと .../i/route.ts にしか当たらず該当 5 ファイルが投入直後に error になることを実測したので \[id\] とエスケープし、これも pin した。ガードが空振りでないことは変異注入 9 種(allowlist の増加 / 減少 / エスケープ剥がし / 綴りを方針書の 3 つへ後退 / severity 降格 / セレクタ削除 / export * 復活 / src への動的 import 追加 / allowlist 済みモジュールからの tmux re-export)で全件赤になることを確認済み。棚卸しは方針書 §16 付録 A(develop 90b67eb9)から中身が 4 件入れ替わっていた(#1879 / #1890 で増えた clear-composer/route.ts・composer-clear.ts の 2 件を段階解消に追加、NavigationKey 移設で client の型のみ 2 件を削除)。総数 31・区分 12/19 は結果として不変で、差分は付録 A に追記した。

Documentation

  • docs(cli-types): statusEvidence の docstring を #2011 後の定義に直し、isUnclassifiedActive との違いを明記 (#2015): src/cli/types/api-responses.ts の CurrentOutputResponse.statusEvidence は「isUnclassifiedActive が運ぶのと同じ事実」と書いていたが、#2011(PR #2016)が両者を分離したためこの記述は誤りになった。'positive'/'none' は「その判定に積極的な裏付けがあるか」(検出器がツールごとに produce し §4 D1 ロールアウトで広がる)、isUnclassifiedActive は「そもそもどのルールもこのフレームを読めたか」(src/lib/session/status-evidence.ts の isUnclassifiedFrame = running かつ no_recent_output/unknown_frame/default)という別の問いであり、答えは双方向に食い違う — アイドル composer をどのツール固有ルールも保証しなければ 'none' かつ classified(wait は完了する)、読めないペインでエージェントが Stop を報告すれば 'positive' かつ unclassified(wait は待ち続ける)。isUnclassifiedActive 側にも相互参照を追加した。docstring は実行できないため「戻すと赤になる」変異が作れない代わりに、docstring が名指しする reason トークンを STATUS_REASON に、union 幅をサーバの StatusEvidence に固定する guard を tests/unit/cli/types/status-evidence-contract-2015.test.ts に追加した(UNCLASSIFIED_FRAME_REASONS が #2011 で module-private のため、集合そのものとの突き合わせは未達。その旨をテスト先頭に明記)。

Fixed

  • fix(push): セッション起動失敗の通知を 7 ツールすべてに広げ、発火判定を 1 箇所に集約 (#2009): #2000 が接続した「セッション起動失敗」の通知は、SessionStartFailedError を投げる claude-session.ts の 1 行にぶら下がっており、実測でその型を投げるのはリポジトリ全体でそこ 1 箇所だけだったため Claude 以外の 6 エージェントは無音で落ちていた。発火点を 7 実装に書き写すのではなく、全ツールが継承する BaseCLITool.startSession() へ引き上げた — 各ツールは launchSession()(protected)を実装するだけになり、通知は構造的に付いてくる。あわせて 7 ツールすべての「未インストール」検知を素の Error から SessionStartUnavailableError(code=SESSION_START_UNAVAILABLE)へ型付けし、failure-push-notifier の唯一の分類器が「未インストール(→ session-start-unavailable、本文はツール名+辞書の『インストールされていません』)」「起動後の終端エラー(→ session-start-failed、#2000 と同一の文面・signature)」「起動が遅いだけ(→ 通知せず session-still-starting を info ログに出す。#1637 の『セッションもプロセスも生きていて修理は要らない』)」「分類不能(→ 鳴らすがツール名しか本文に載せない。素の Error の文面は tmux/CLI の生出力を含みうるため)」を出し分ける。POST /api/worktrees/:id/send が持っていた重複したインストール検査は削除した — それが先に走るせいで各ツール自身の拒否(=通知が繋がる唯一の seam)が到達不能になっていたため。503 は SESSION_START_UNAVAILABLE のマッピングで維持し、本文はツール自身の文面になる(copilot のインストールヒント #1907 が捨てられなくなる)。#2000 の Claude 経路の payload は不変(title / body / dedup signature をテストで固定)。
  • test(infra): 実シェル予算が守る「同時フル test:unit の本数」を宣言し、その条件で測り直した (#1985): #1950 の 30s ガードは「同時フル実行 1 本」の条件でしか測られておらず、条件が書かれていなかったため #1977 の 2 本同時実行で「設計どおり作動したガード」が欠陥として読まれた。tests/helpers/real-shell-budget.ts に 2 本まで(mutex: cpu.heavy 由来 1 本+手で回す 1 本)を根拠つきで宣言し、N=1..4 のスイープ(28 コア / 標本 1864〜7456 / peak load 57.3〜163.8)を REAL_SHELL_LOAD_SWEEP として同梱。ガードは「宣言+超過 1 本 = N=3 の最遅 25873ms × 1.8 倍」から 30000 → 60000(宣言 N=2 の p99.9 9854ms に対し 6.09 倍)、vitest 予算は 3 倍関係を保って 90000 → 180000。機構(vitest globalSetup でロックを取る)は cpu.heavy 保有者である unit ゲートが自分を待つため採らず運用規約とした。実測は再現可能な scripts/measure-real-shell-budget.mjs と docs/qa/1985-real-shell-budget-concurrency.md に残し、tests/unit/guards/real-shell-test-budget.test.ts が宣言・スイープ・散文の一致とサイズ決定規則を固定する。
  • fix(detection): アイドルな Claude ペインで脱出ハッチが常時表示され wait が完了しない回帰を修正 (#2011): #1927 が isUnclassifiedActive を statusEvidence === 'none' から導出したため、「フレームを分類できなかった」という旗が「アイドルだと積極的に証明できなかった」という別の主張に置き換わり、実測でアイドル 8 ペイン中 7 ペインが該当していた。旗を isUnclassifiedFrame(status, reason)(running × default/unknown_frame/no_recent_output のみ)として証拠から切り離し、mergeStructuredStatus は旗を scraper のまま素通しする。あわせて hook の turn-close(closedBy: 'stop' かつ closedAt > openedAt)を statusEvidence: 'positive' として認め、reason: hook_stop と statusEvidence: none が同時に並ぶ自己矛盾を解消。IDLE_EVIDENCE_DEFAULT_MODE.claude は observe へ戻し、unclassified_frames の実測を経てから再度倒す。実フレーム fixture(Claude Code 2.1.241 / 200x1000)7 枚で検証し、commandmate wait は同じペインで exit 124 → exit 0 になった。
  • fix(push): Auto-Yes が自動応答するプロンプトでスマホを鳴らさない (#1999): prompt push の生産者 2 つ(polling/response-checker.ts のポーラ経路と push/waiting-push-notifier.ts の待機エッジ経路)に、push/prompt-push-gate.ts の単一の述語を通す。抑止するのは「Auto-Yes が有効で、かつこの待機についてポリシーが回答を差し控えていない」場合だけ — Auto-Yes 無効/未設定はもちろん、期限切れ・stop_pattern_matched・consecutive_errors も従来どおり通知する(getAutoYesState() が期限切れを disableAutoYes(…,'expired',…) に畳み、disableAutoYes() がどの停止理由でも enabled:false を書くので、enabled の 1 読みで Issue 本文の 4 行すべてを覆い、列挙し忘れた停止理由は通知側に倒れる)。ポリシー差し控え(#1684)は getLastPolicySuppression().at >= waitingSince、すなわち記録が当該 episode の開始以降に書かれたときだけこの待機の理由とみなす(記録は消えないので、この鮮度判定が無いと過去の差し控え 1 件でそのセッションが永久に鳴りっぱなしになる)。エスカレーション再通知(#1790、既定 10 分)は抑止しない — runEscalationTick は getWaitingEpisode を読み直してAuto-Yes が解決した待機を落としてから発火するので、しきい値に届いた待機は「2 秒ごとに再評価され続けて 10 分間解決されなかった」もの。差し控え記録は理由の説明にならない(resolver が答えを持たないプロンプト型は suppressedBy: null で何も記録しない)ため、再通知まで黙らせると Auto-Yes が停止したパイプラインの恒久ミュートになる。判定は shouldSendWaitingPush() が「送ると決めた時点」で episode を記録するので notifyPushSubscribers を呼ぶ前に置く(後で捨てると dedup が 1 枠を消費し、後続の再通知が prev.since === since で落ちる)。述語が生産者側に居るのは NotificationEvent が instanceId しか持たず cliToolId を持たないため(alias インスタンス claude-2 からツールを復元できず、Auto-Yes の複合キーを組めない)。抑止は logger.info('prompt-push-suppressed') に worktree / instance / waitingSince / 理由コードつきで出す(鳴らないことと壊れていることを運用者が区別できるようにする、docs/design/discoverability-principle.md)。Auto-Yes 無効時の挙動は不変(payload まで固定するテストつき)。クライアント・Service Worker の変更なし
  • fix(security): 子プロセスから剥がす集合を「資格情報+起動元エージェントの身元」に定義し直し、平文 CM_AUTH_TOKEN の漏れを塞いだ (#1996): sanitizeEnvForChildProcess() の適用範囲を実測で定義し直した。(1) CM_AUTH_TOKEN が渡っていた — SENSITIVE_ENV_KEYS に在るのはハッシュ側の CM_AUTH_TOKEN_HASH だけで平文名が無く、env に置いて子プロセスを起こすと実際に読み出せた({"tok":"PROBE-1996-PLAINTEXT","hash":null})。session/claude-executor / assistant/non-interactive-runner / lib/slash-command-catalog / cli-tools/copilot-executable / detection/version-probes の 5 経路から third-party CLI へ渡り得る状態で、うち slash-command-catalog の docblock は既に「probe は auth token を third-party CLI に渡さない」と書いていた(実態が追いついた形)。tmux はこの関数を通らない(src/lib/tmux/*.ts に env: 上書きなし)ので pane 内の $CM_AUTH_TOKEN は従来どおり展開される。(2) #1942 の CM_HOOK_ prefix は launch line を覆えていなかった — 7 ソースの prepareLaunch を実際に組んで env を読むと相関変数は 6 個で、CM_HOOK_ で始まるのはうち 2 個。残る CM_AGENT_TOOL / CM_AGENT_WORKTREE_ID / CM_AGENT_INSTANCE_ID / CM_PERMISSION_HOOK_URL は #1942 と同じ誤帰属を 2 経路で独立に起こす(scripts/hooks/cmate-agent-event.sh は CM_AGENT_TOOL を env から読み CM_HOOK_URL 不在時は ${CM_PORT:-3000} へフォールバックするので、prefix だけ剥がすと宛先が既定=たいてい稼働中のサーバに戻り帰属だけ他インスタンスのまま着弾する/生成される antigravity の PreToolUse は [ -z "${CM_PERMISSION_HOOK_URL:-}" ] を「CommandMate 起動か」の判定に使っており、machine-global singleton の ~/.gemini/config/hooks.json 下では別インスタンスのサーバに許可を尋ねてその答えに従う)。AGENT_CORRELATION_ENV_KEYS(6 個)を追加し、prefix は名前空間の内側では今も列挙より強いので残す。prefix を CM_AGENT_ へ広げないのは CM_AGENT_HOOKS_INJECT / CM_AGENT_HOOKS_DIR が運用者スイッチで「ここに運用者の価値は無い」が成り立たないため、4 個を CM_HOOK_* へ改名しないのは CM_AGENT_TOOL が同梱 relay の公開 I/F(#1549 の手書き hook が依存)でありディスク上の machine-global 設定が旧名を持つため。剥がさないもの=per-tool の設定リダイレクト CODEX_HOME(実測 1 個。#1933 のローカル allowlist に在った COPILOT_HOME / XDG_CONFIG_HOME は読み出し専用で plan.env に入らない)。drift ガードは実測が引き継ぎ、tests/unit/lib/agent-launch-plan-secrets-1933.test.ts が 7 ソースの実測 env と宣言の両方向の完全一致を、tests/unit/security/child-process-agent-env-1996.test.ts が 2 つのコピーの一致と実子プロセスが読めないこと(陽性対照つき)を見る。lib/security → lib/hooks の import は循環を敷くので作らず、tests/unit/guards/security-no-hooks-import.test.ts が import / export … from / await import() / require() の 4 綴りを固定する(ESLint 8 の no-restricted-imports は後ろ 2 つを見ない)。
  • fix(verify): ゲートに稼働サーバの NODE_ENV を継承させない (#1994): ゲートは CommandMate サーバの子プロセスとして起動するため、commandmate start --dev / npm run dev の NODE_ENV=development を継承していた。実測で npm run build(Next.js 15)は NODE_ENV 未設定なら exit 0、NODE_ENV=development では /404 /500 /offline の prerender が <Html> should not be imported outside of pages/_document で落ちて exit 1 になる。build ゲートを宣言した状態では、サーバの起動方法だけで全ワーカーの裁定が赤になっていた。GitHub Actions は NODE_ENV を設定しないので、既存の CI=true 正規化と同じ理由で NODE_ENV を落とす。
  • fix(build): npm run build に NODE_ENV=production を明示する (#1994): 上と同じ欠陥を、どのランナー・どのシェルから起動しても踏まないようにするため、スクリプト自身が必要な NODE_ENV を宣言する(test / start が既に取っている形)。next build は NODE_ENV 未設定なら production を自ら設定するので、通常経路の挙動は変わらない。
  • refactor(cli-tools): メッセージ本文が Escape / C-c / Enter だとキーとして着弾していた問題を修正し、ツール固有の挙動を ICLITool の宣言に閉じた (#1933): tmux send-keys は位置引数をキーテーブルで先に解決するが、grep -n "'-l'" src/lib/tmux/*.ts は develop b982fb88 で 0 件で、sendMessageWithSubmitVerification はユーザ本文を sendKeys(sessionName, message, false) で打鍵していた。tmux 3.5a・専用 socket・raw pty 上の cat で実測した結果、本文 Escape は ESC(1b)、Enter は CR(0d)、C-c は SIGINT として着弾し、さらに - 始まりの本文は getopt に食われて rc 0 のまま 1 バイトも送られない(send-keys -t X '-l')ことが分かった。KeySequence({kind:'key',name} | {kind:'literal',text} の判別 union、src/types/cli-tool-contracts.ts)と src/lib/tmux/key-sequence.ts を新設し、literal は必ず send-keys -l -- を通す。本文打鍵は sendKeys(..., { literal: true }) 経由になった。あわせて ICLITool に describeComposer() / gracefulExitSequence() / captureSpec() を追加し、BaseCLITool に claude 相当の既定実装を置いた(既存 7 ツールの挙動は不変)。graceful exit の後置条件は verifyGracefulExit() が graceful_exit_timeout / port_orphaned の理由コードで判定し、OpenCodeTool.killSession は 割当 port の /global/health が応答し続けている間は force kill する(pane が消えても opencode の HTTP サーバが生き残ると、その port を受け取った次のインスタンスが旧サーバに購読を張り、別 worktree にイベントが記録される)。getStatusCaptureLines は captureSpec() に集約され、session/worktree-status-helper.ts が pane 高さ 2 個のために CLI ツール実装 2 本を import する必要はなくなった。
  • fix(hooks): opencode の全 fetch を redirect: 'manual' と content-type 検証で loopback に固定し、SSE フレームと replay の上限を pin する (#1931): opencode の SSE / REST 経路が リダイレクトに追随しなくなった(Issue #1931)。client.ts の全 fetch(requestJson / probeOpencodeHealth / openOpencodeEventStream)が redirect: 'manual' を通るようになり、port を奪ったプロセスが 3xx を返しても CommandMate は loopback を離れない。合わせて content-type を検証する(JSON 経路は application/json、/event は text/event-stream)— opencode 1.18.21 は未知のルートに 200 text/html(Web UI の SPA シェル)を返すため、「ソケットが受け付けた」は「そのルートが在る」ではない。SSE の 1 フレームには上限(MAX_OPENCODE_SSE_FRAME_CHARS = 256 Ki 文字)が入り、超過フレームは捨てて件数をログに出す。
  • Auto-Yes をツール別のダイアログ肯定検出に限定(Issue #1928、方針書 §4 D1 決定 4):
  • fix(test): 非ファミリの遅い unit テスト 5 本を、予算ではなく遅さの原因から直す (#1977): #1950 が実シェル起動テスト 69 ファイルを予算で救った外側に、負荷で 5000ms の既定予算を脅かすテストが残っていた。5 本すべての所要を冷えた worker で import 単位まで実測したところ、遅さは jsdom でも waitFor でも vi.resetModules() でもなく(reset 後の再 import は実測 11ms → 8ms。vitest は transform 済みコードを保持するので払うのは初回だけ)、(A) 本番コードの実 setTimeout スリープ と (B) it() の中で初回に払うモジュールグラフの import の 2 種類しかなかった。tests/unit/lib/tmux-capture-invalidation.test.ts は @/config/cli-tool-timing-config の待ち時間を *_MS だけ 0 にする部分 mock(opencode killSession 2103ms→4ms、9 テスト合計 5.71s→0.535s)、tests/unit/config/eslint-i18n-module-scope-literal.test.ts は .eslintrc.json から実セレクタを読み出して plugin を読まない最小構成で lint(初回 lint 787ms→16ms、ルール消滅・overrides 無効化を検知するガードを 2 本追加)、tests/unit/components/app-version-display.test.tsx はこのファイルが触らない 8 ペインを mock(グラフ 1731ms→約 850ms)、tests/unit/api/hooks-claude-done-delegation.test.ts は @/lib/ws-server(route import 987ms のうち 924ms)を broadcastMessage だけの stub に、tests/unit/ws-server-cleanup.test.ts は @/lib/cli-tools/manager を stub(import('@/lib/ws-server') 1160ms→372ms)。あわせて it() の中の初回 await import() を collection 時の静的 import に移した。負荷(load avg 24〜63)でのフル実行実測で 1 テストあたりの最大所要は 2.10s→0.52s / 4.60s→0.10s / 2.53s→0.04s / 2.11s→0.11s / 3.33s→0.01s。グローバル testTimeout も個別 testTimeout も一切上げていない。変異注入 13 件(.eslintrc.json のセレクタ破壊 / rule 改名 / overrides 無効化、invalidateCache・clearAllCache・applyAgentStopEvent・cleanupRooms export の削除、ws-server export 改名、APP_VERSION_DISPLAY の env 読み取り除去、Info ボタンの aria-label 破壊、mobile info タブの配線切断、manager の module スコープ利用)がすべて赤になることを確認済み。あわせて実測で判明した ws-server → cli-tools/manager → polling/response-poller → polling/response-checker → ws-server のモジュールスコープ循環(底は @/lib/session/cli-session 996ms)は、循環を切れる辺がいずれも本 Issue の scope 外の同期 API 非同期化を伴うため別 Issue 送りとし、根拠を dev-reports/issue-1977-findings.md に記録した。
  • fix(cli): send 直後の wait が「まだ始まっていない」を完了と読む問題を修正 (#1975): wait が sessionStatus==='ready' を完了と判定する直前に、「このインスタンスに最後に渡されたプロンプト」と「エージェント自身が最後に報告したターン終了(lastStopEventAt)」を突き合わせるゲートを追加。send 直後は最新の構造化イベントが直前ターンの stop のままなので #1839 の adoptTurnStart() が何も採用せず、turnStartedAt === null が「決着済み」と読まれてアイドル composer をそのまま完了にしていた(隔離サーバ実測 2026-08-22 / copilot 1.0.80: send→wait 5 回中 3 回が約 0.3 秒・basis=scraper_ready・成果物ゼロで exit 0)。ゲートは GET /api/worktrees/:id/messages?limit=1&unit=pairs を --instance(無指定ならサーバが解決した cliToolId)でスコープして読む。hook を出さないツールは挙動不変 — structuredEvents.source.capabilities.supportedEvents(#1924 の宣言値)が stop とターン開始語の両方を宣言しているソースだけがこのゲートに入り、legacy-relay(supportedEvents: [])と #1924 以前のサーバは従来経路のまま台帳も引かない。保留は PENDING_PROMPT_HOLD_MS=60 秒で打ち切り(hooks は全経路 fail-open なので Stop の取りこぼしで wait が返らなくなってはいけない)、--timeout / --stall-timeout はそれより短ければ従来どおり優先される。完了行の basis= は、エージェントが最新プロンプトの終了を報告していれば hook_stop になる(scraper_ready は「画面しか言っていない」という文書どおりの意味に戻る)。
  • fix(security,test): CM_HOOK_* を子プロセス env から除去し、COPILOT_HOME の隔離既定を置く (#1942): 設計方針書 §13.2 S8 の後半(#1904 / PR #1941 が scope 外として残した積み残し)。sanitizeEnvForChildProcess() が SENSITIVE_ENV_KEYS に加えて CM_HOOK_ 名前空間を落とすようになり、CommandMate 自身が起動された pane から継承した CM_HOOK_URL / CM_HOOK_PORT が claude -p(Assistant Chat)・slash command プローブ・copilot --version へ漏れなくなった(漏れると別サーバへ他インスタンスの相関キー付きでイベントが飛ぶ。エラーは出ない)。あわせて tests/setup.ts に COPILOT_HOME の既定を追加 — ~/.copilot/settings.json は configScope: 'global-singleton' でマシンに 1 本しかなく、既定の無いテストが 1 本でも到達すると開発者の実設定を merge・backup・別ポートへ書き換える。既定は worker pid 配下に置き、並列 checkout 同士が同じ .cmate.lock を奪い合わないようにした。
  • fix(test): src/ に同居していた unit テスト 13 ファイル / 267 テストを tests/unit/ へ移設し、裁定の外にあった赤 19 件を裁く (#1939): npm run test:unit は vitest run tests/unit、test:integration は vitest run tests/integration、Playwright の testDir は tests/e2e なので、src/**/__tests__/ にあった 13 ファイルは CI でも wait --verify でも一度も実行されていなかった。実測で 5 ファイル 19 テストが赤。19 件はすべてテストの陳腐化で、実装バグは 1 件も無かった(本番ソースの変更は 0 行)。内訳は i18n 移行 #1276(15 件)/Clipboard フォールバック追加 #438(1 件)/worktrees.memo → description の migration v13 リネーム(2 件)/Gemini の REPL 化 #368(1 件)。ついでに migration v10 のデータ移送は初めて実データで検証した(従来のテストは全 migration 実行後に構造だけ見ており、移送ループを一度も通していなかった)。再発防止として tests/unit/guards/test-file-placement.test.ts を追加し、どのゲートも走らせないディレクトリにテストファイルが置かれることを禁止する。
  • test(infra,cmate-verify,orchestrate-monitor): 負荷で断続的に落ちる unit テストと、タイムアウトが 141 に化ける表示を直す (#1950): tests/unit のうち spawnSync / execFileSync / execSync / /bin/sh で実プロセスを起動する 69 ファイルが、開発機のフル npm run test:unit でだけ Test timed out in 5000ms / expected { status: 141 } を断続的に出し、無関係な PR の wait --verify を exit 20 にしていた。原因は 1 つで、vitest 既定の 5000ms がこのファミリの実測 p99(4525ms)とほぼ同値だったこと。status: 141 は別の不具合ではなく、spawnSync({ timeout }) が切れたときに node が SIGTERM 送出と stdio クローズを同じ手順で行い、SIGPIPE を trap するスクリプトが 128+13 で終わる姿だった(error.code === 'ETIMEDOUT' を誰も見ていなかったため exit code の不一致に見えていた)。tests/helpers/real-shell-budget.ts を追加し、テスト本文の内容からファミリを判定して tests/setup.ts が予算(90s)を与え、各サブプロセス呼び出しの hang ガード(30s)をその内側に置いて、hang はガードが名指しで報告するようにした。あわせて (1) gate-runner-timestamps / gate-runner の固定閾値 assert を実測値基準に置き換え(CI で expected 1104 to be less than 800 が出ていたもの)、(2) cmate-verify の fixture suite が待ち時間 0 を固定値で断定していた 2 箇所を「waited= フィールドが自分の場所に出ていること」の検査に変え(date +%s に秒未満が無いため負荷で 1 と読める。#228 が duration 側だけ直して wait 側を残していた)、(3) monitor.sh に first-signal-wins ガードを入れて、外から timeout で殺されたときに 141 ではなく実際に送られた signal の 143 を返すようにした。ファミリの直列化は計測のうえ不採用(開発者が素で回すフル test:unit が 66s → 201s、3 倍)。スイート所要への影響は無し(素の並列実行で 66s → 62s、CI=true で fileParallelism が切れる wait --verify の unit ゲートで 583.6s → 559.7s)。
  • fix(detection,api): OSC 8 リンクの未除去 / logs route の 4 ツール直書き / opencode 生成中の isGenerating / copilot・opencode の model 読取り (#1912): 独立した小粒 4 件。(1) stripAnsi が OSC 8 ハイパーリンクを 1 バイトも落としていなかった — ANSI_PATTERN の OSC 分岐は BEL 終端 \x1b\][^\x07]*\x07 しか知らず、claude / codex / copilot が実際に吐くのは ST 終端(ESC \)なので、ESC]8;id=md-…;https://…ESC\ Learn More ESC]8;;ESC\ がそのまま保存応答・ターミナル表示・検出入力に漏れていた(リポジトリの実 TUI キャプチャ 30 本に残っていた)。\x1b\][^\x07\x1b]*(?:\x07|\x1b\\) に置換。ペイロードから ESC を外したことで「後方の BEL まで飲み込んでリンクラベルごと消す」旧挙動も同時に塞がり、終端の無い OSC は触らない。src/lib/clipboard-utils.ts に同じパターンの手写しがもう 1 本あるが scope 外のため未修正。(2) GET /api/worktrees/:id/logs/:filename が 4 ツール直書きだった — log-manager は CLI_TOOL_IDS(7 件)で書き listLogs も全件返すので、copilot / opencode / vibe-local のログは一覧に出るのに開くと 404。CLI_TOOL_IDS を回すよう変更(パス検証は不変)。(3) opencode 生成中に isGenerating / thinking 表示が出ない — current-output-builder の thinking が reason === THINKING_INDICATOR の単項比較で、opencode 分岐 A(esc interrupt)が返す opencode_processing_indicator を取りこぼしていた。status-detector に GENERATING_REASONS / isGeneratingStatus() を追加して導出を 1 箇所に寄せた(default は入れない=エージェントが名乗った証拠ではない)。OPENCODE_PROCESSING_INDICATOR の正規表現自体は触っていない(#1894 と衝突しない)。(4) model-info-extractor に copilot / opencode を追加。copilot はステータスバーの右端セル <model>[ · <Effort>] を最下部 1 行だけ読む(フレーム走査だと 930 行上の「バーの語彙を引用したプロンプト」を拾って 03:00 を model として latch する。#1885 の位置判定と同じ理由)。Issue 本文の綴り <model> (effort) は誤りで、括弧形は transcript の ● Model changed from … to gpt-5-mini (medium) for this session の方。後者はバーが picker / ダイアログに隠れたときのフォールバックと、バーが同一 model を effort 無しで名乗るときの effort 供給に使う。opencode は ▣ <Agent> · <model>[ · <duration>] のステップマーカーだけを読む — Issue 本文が挙げるフッタ Build · <model> <provider> は意図的に読まない: model と provider は SGR の色だけで分かれており stripAnsi 後は 1 語のブロブ、provider 語彙はユーザー設定次第(実 picker で OpenCode Zen / GitHub Copilot / LMStudio / Ollama Cloud)で、テキストだけの正しい分割が存在しない。opencode はペインに effort を一切描かないので effort は常に null。全件、実 TUI fixture(#1879 / #1883 / #1885 / #1890 / #1893 / #1895 / #1896)だけで pin し、各ガードは変異注入で赤になることを確認済み。
  • fix(session,api,cli-tools): copilot 宛 send の迂回を潰して改行を保持し、opencode の submit 検証を実際に効かせ、terminal route に prompt guard を入れる (#1906): 送信経路の 3 点。(1) copilot 迂回: send-user-message.ts と POST /api/worktrees/:id/terminal はどちらも cliToolId === 'copilot' を特別扱いし、content.replace(/\n+/g,' ') → 生 sendKeys → 200ms → 単独 Enter を撃っていた。CopilotTool.sendMessage(waitForPrompt / SELECTION_LIST_COMMANDS / submit 検証)は画像添付フォールバック以外から到達不能で、#1886 の folder-trust 肯定検出も #1895 の picker 11 件も production では一度も効いていない。両方の迂回を削除し ICLITool.sendMessage に一本化した。改行の平坦化は撤廃(copilot 1.0.80・私設 tmux ソケットで実測: send-keys にリテラル改行を渡すと composer は ❯ line one / line two / line three と複数行のまま、分離した Enter がその全体を submit し、transcript の echo も改行を保つ。4 行の本文を実機で送って ACK が返ることまで確認、所要 338ms)。#559 の懸念は実測で位置づけ直した: composer は生成中も列 0 に ❯ を描くので waitForPrompt は初回 poll で返る(0ms)。15 秒使い切るのは composer が消えている=ダイアログが開いているときだけで、そのとき送ると本文はダイアログの単一行入力に入り 4 行が ping oneping twoping three… に潰れる(実測)。これは迂回すれば 0ms で同じ結果になる話なので、防いでいるのは下記 (3) の guard である。(2) opencode の submit 検証が空振り: classifySubmit の入力行検出は >❯› マーカー頼みで、opencode はマーカーを一切描かない。よって opencode の送信は毎回「入力行が無い=submitted」に落ち、#1471 の「飲まれた Enter を検出して撃ち直す/確認できなければ throw」が一度も走っていなかった。マーカーを描く 6 ツールに限定し(INPUT_LINE_MARKER_TOOLS)、opencode は #1911 の OPENCODE_GUTTER_ROW_PATTERN / OPENCODE_COMPOSER_BOTTOM_BORDER を再利用した構造読み(findOpenCodeComposerRows: 下端ボーダーの上のガター連続、その最終行=Build · <model> は常にクロームなので除外)に切り替え、Ask anything...(#1883)は空バッファの肯定証拠として扱う。読み戻し窓もツール別にした: opencode は初ターン前に入力ボックスを 200 行ペインの中央(〜100 行目)に描くので、末尾 12 行では composer が 1 行も入らない。opencode だけ OPENCODE_PANE_HEIGHT(200=ペイン全体)を読む。(3) terminal route(Review 画面入力)に prompt guard: sendUserMessage は #1708/#1737 以来ダイアログが開いていれば送信を拒否するが、この route は拒否せず、ダイアログ表示中の送信がダイアログの入力行に着弾していた。isPromptWaiting を通し、待機中は 409 + code: PROMPT_WAITING(send route と同じ契約)を返す。Issue 本文からの意図的な逸脱 2 件: (a) 本文は terminal route を sendUserMessage に通すよう提案しているが、唯一の呼び出し元 src/hooks/useSendMessage.ts が送信直後に POST /api/worktrees/:id/messages で自分で永続化しているため、通すと Review 画面の全メッセージが History に二重登録される。欠けていたのは guard なので guard だけを取った。(b) 本文の項目 3 前半「instanceId を無視」は #1925 で解決済みなので再実装していない。tmux import allowlist は 段階解消 18 → 16(総数 30 → 28、[id] エスケープ 6 → 5): terminal/route.ts と send-user-message.ts が両方とも tmux を直接叩かなくなった(hasSession → ICLITool.isRunning)。OPENCODE_PANE_HEIGHT は @/config/tmux-pane-config へ移設(opencode.ts が re-export するので import 元は不変)— submit-verified-sender が ./opencode から取ると循環 import になり、opencode の child_process 利用が sender の全消費者に染み出して codex のテスト 1 本が実際に落ちた。fixture は opencode 1.18.21・80x200 の実キャプチャ(tests/unit/lib/detection/fixtures/opencode-live-1906/)+ #1883/#1893 の既存フレーム再利用。空振りでないことは変異注入 8 種で確認済み(opencode をマーカー読みへ差し戻し / 読み戻し窓を 12 行へ / モデル行をバッファ扱い / Ask anything... の空判定除去 / 改行平坦化の復活 / terminal route の guard 除去 / terminal route の copilot 迂回復活 / allowlist への再追加)。
  • fix(cli-tools,detection): opencode の中断を Esc 二度押しにし、esc again to interrupt の 5 秒間を running として読む (#1894): opencode 1.18 は Esc 1 回では中断せず、フッタが esc interrupt → esc again to interrupt に変わるだけで、その表示が生きている 5 秒以内の 2 回目だけがターンを中断する(実測 1.18.21・私設 tmux socket・80x200: ラベルは 0.31〜4.71 秒で 5.07 秒に復帰。Esc 1 回では 3 回中 3 回とも生成が続き · 11.3s / · 16.3s / · 19.0s で自然完了)。つまり BaseCLITool.interrupt() を継承していた OpenCodeTool は GUI の中断ボタンでも POST /interrupt でも opencode を一度も止められていなかった。OpenCodeTool.interrupt() を override し Escape → OPENCODE_INTERRUPT_SECOND_ESCAPE_DELAY_MS(300ms)→ Escape を送る(実機で本メソッドを直接駆動し、317ms・生成が文の途中で停止・▣ Build · … · interrupted を確認)。併せて OPENCODE_PROCESSING_INDICATOR を /esc (?:again to )?interrupt/ に拡張し、この 5 秒間も肯定的証拠つきの running/opencode_processing_indicator にする。Issue 本文の「ready/opencode_response_complete に化ける」は 1.18.21 では再現せず、実測は証拠なし(statusEvidence: 'none')— フレームが新しい間は running/default、lastOutputTimestamp が 5 秒古くなると ready/no_recent_output の偽完了(wait 偽完了・send guard 素通り・sidebar idle は同じ)。OPENCODE_SKIP_PATTERNS が同じ定数を共有するため、この行は保存される応答からも落ちるようになる。中断で終わったターンは · interrupted を残し duration を持たないので完了マーカーとしては読まない(#1893 の方針を踏襲)
  • fix(session,cli): capture <id>(非 JSON)が alternate-screen ツールで 1 ターン後に空文字になる問題を修正 (#1910): buildCurrentOutput は content を「ポーラーがまだ保存していない分」として session_states.last_captured_line でスライスするが、この行数がカーソルとして使えるのは alt-screen でない ∧ capture window が未飽和 のときだけである。claude / opencode / copilot は tmux の scrollback を持たず capture-pane が常に pane 高さ(copilot・claude 1000 行、opencode 200 行)を返すため、ポーラーが 1 ターン目に保存した lastCapturedLine(= pane 高さ)以降を切り出すと content が空文字になり、commandmate capture <id> の出力が 1 バイト(空行)だけになっていた。#1670 が入れた既存ガードは capture window(10000 行)の飽和しか見ておらず、1000 行の pane では永久に発火しない。判定を capturedLineCountIsCursor(cliToolId, captureWindowSaturated)(src/lib/cli-tools/types.ts、#1268 と #1670 の 2 つの pin 機構を 1 箇所に集約)に置き換え、カーソルが死んでいる場合はフレーム全体を返す。scrollback ツール(codex / gemini / vibe-local / antigravity)の差分挙動は不変で、lineCount / lastCapturedLine / fullOutput / realtimeSnippet の意味も変えていない(additive)。副次的に、wait の stall 検知が alt-screen ツールで常に「無変化」と読んでいた状態も解消する。
  • fix(hooks): 開いているターンの途中に届いた session_start で構造化判定を失わないようにする (#1903): copilot 1.0.80 は初回ターンで UserPromptSubmit → 12〜15 秒後 → SessionStart の順に hook を発火する(2 回実測。捕捉済み payload の SessionStart は既に送信済みのプロンプト本文を initial_prompt に載せている)。「最新イベント=verdict」モデルでは agentEventToSessionStatus('session_start') が null なので、この到着でターンの running / hook_prompt_submit が消え、生成中の copilot フレームを ready / input_prompt と読む scraper(#1885)に判定が落ちていた。この窓で始めた commandmate wait は Completed (basis=scraper_ready) で即 exit 0 する(PreToolUse は 13 秒後、Stop は 30 秒後)。recordAgentEvent に第 5 引数 RecordAgentEventOptions.sessionStartMayArriveLate(#1924 の宣言値。省略=false=従来挙動)を追加し、宣言している source では 開いているターン中の session_start を記録しない(lastAgentEvent を置換せず世代も切らない)。ツール ID では分岐しない(#1901 の permissionHookPredictsDialog・#1899 の eventIdentity と同型で、宣言値を反転すると挙動も反転する)。ターンが開いているかの判定は getStructuredSessionState をイベント自身の時刻で引くので、世代フェンス(#1723)と 30 分の齢上限をそのまま継承する — stop 後・セッション最初・齢超過の session_start は従来どおり記録され世代を切る(/clear は session_end→session_start なので影響を受けない)。本物の再起動(pane 内での手動再起動)を握り潰さない担保は 3 本: session_id が開いているターンと食い違えば記録する(片方でも null なら同一とみなす=手書き #1549 hook を壊さないため)、保持しても齢上限は UserPromptSubmit から測るので 30 分で scraper に戻る、CommandMate 自身が張り直すセッションは beginAgentEventGeneration で全消去される。保持したフレームでも model の latch だけは行う(SessionStart は claude が model を載せる唯一のイベント、#1783)。recordAgentEvent の戻り値を void から AgentEventRecordOutcome に変更(additive)し、receiver は保持を agent-event-held として 1 行ログに出す(無言で落とさない)。回帰は tests/unit/session/late-session-start-1903.test.ts(route 経由 6 件+状態機械 6 件+capability 反転 2 件)
  • fix(detection): copilot の応答クリーニングから picker chrome をフレーム単位で外す(#1895 の追随) (#1895): COPILOT_SKIP_PATTERNS は応答クリーニング(tui-accumulator / response-cleaner)にも使われており、旧 Search \w+... / Select Model は picker chrome を 1 行も落とさない一方で copilot 自身の応答文を削っていた(ASCII の Search models... は実フレーム全体で 1 箇所、それも応答本文にしか出ない。chrome は U+2026 の Search models…)。クリーニングの取りこぼしは行パターンでは直せない(Recommended models / GPT-5.6 Luna 328K Medium は散文と区別できない)ため、extractCopilotContentLines が picker フレームなら [] を返すようにした。poller は生ペイン全体を毎 tick 投入するので、/model を開いて読んでいる間モデル一覧 ~50 行が 1 poll ごとに保存応答へ追記されていた。tests/unit/lib/tui-accumulator-copilot.test.ts が pin していた ASCII 綴りは製品が出さない綴りだったので実測の綴りへ差し替えた。
  • fix(detection): copilot 1.0.80 の picker を最下部フッタで肯定検出し、応答文の語だけで waiting になるのを止める (#1895): COPILOT_SELECTION_LIST_PATTERN(Search \w+... / Select Model / Enter to select)は 1.0.80 が開く picker 11 種の実フレームに 1 件も当たらず(/model の検索欄は U+2026 の Search models…、Select Model という語は無く、全フッタが小文字)、/model は running/default へ落ちて NavigationButtons が出ず、wait は人が picker を閉じるまで返らなかった。同じパターンが逆方向にも誤爆しており、応答文に「Select Model」「Search models...」があるだけで完了済みターンが waiting/copilot_selection_list になっていた(normalizeTuiFrameForDetection が空行を 1 行に畳むため、旧 30 行窓に transcript が入る)。判定を位置に置き換える: picker は copilot が下部クロームの代わりに描くものなので、isCopilotSelectionFrame はペイン最下部(最下 3 非空行)の key-hint フッタだけを読み、transcript は読まない。readCopilotStatusBar(#1885)が状態を言えるフレームは無条件で除外するので、running 判定との順序はヘルパ内で解決する。箱の中の行(│ … │)は読み飛ばすため、同じ小文字フッタを持つ folder-trust / permission ダイアログは従来どおり prompt_detected / hasActivePrompt:true に留まる。/permissions は「フッタは picker・本体は 2 択の番号メニュー」なので既存の optionsCount <= 3 分岐で PromptPanel 側へ出る。あわせて SELECTION_LIST_COMMANDS を実測 11 件(model/agent/theme/permissions/skills/mcp/settings/statusline/subagents/resume/session)へ広げ、sendModelCommand からは picker 待ち 5 秒と C-m を撤去した(引数付き /model <id> は picker を開かず即時切替するのが実測。未知の id も有効 id 一覧を出すだけで picker は出ない)。両方向を copilot 1.0.80 の実キャプチャ tests/unit/lib/detection/fixtures/copilot-picker-1895/(200x1000)で pin。なお CopilotTool.sendMessage 経路自体は send-user-message.ts が迂回しているため現状は到達不能(#1906 の着地後に効く)。
  • fix(detection): opencode の返答本文の番号リストが応答待ちプロンプトに化け、Auto-Yes が 1 をユーザー発話として送っていた問題を修正 (#1896): opencode の返答が 1. / 2. / 3. で終わり最後に質問文があると detectSessionStatus が waiting/prompt_detected/hasActivePrompt:true、detectPrompt が multiple_choice(3 択)を返し、resolveAutoAnswer が "1" を解決していた。opencode では数字は composer に入りユーザー発話として送信される(send は guard で拒否、wait --on-prompt agent は exit 10、sidebar は橙のまま)。実測では生成中フレーム(フッタが esc interrupt)でも同じ偽プロンプトが返り、さらに**#1893 の permission ダイアログが開いているフレーム**ではtranscript に残った古い番号リストが priority 1 で勝ち、1 に続く Enter がハイライト中の Allow once を確定させうる状態だった。対策は buildDetectPromptOptions('opencode') の hasNumberedDialogs: false(新設の DetectPromptOptions 宣言)で、detectMultipleChoicePrompt が opencode のフレームでは走査前に打ち切る。opencode 1.18.21 の対話面はボタン列(←/→)とピッカー(↑/↓)の2 つだけでどちらも数字を受け取らない、という実測に基づく宣言であり、設計方針書 §4 D1 決定 4「Auto-Yes は汎用な番号リスト推定だけでは撃たない」の実装位置である。両ダイアログの肯定検出(OPENCODE_PERMISSION_PATTERN / OPENCODE_SELECTION_LIST_PATTERN)は据え置きなので、wait の exit 10 とNavigationButtons はこれまでどおり出る。あわせて OPENCODE_SELECTION_LIST_PATTERN をピッカー枠の痕跡つき(ヘッダ行末の右寄せ esc 必須)に狭め、応答本文が Select model to continue: と書いただけで opencode_selection_list に固着する問題も直した。fixture は opencode 1.18.21・80x200 の実 TUI キャプチャ 7 枚(tests/unit/lib/detection/fixtures/opencode-live-1896/)。
  • fix(api,cli-tools): kill-session route が cliTool.killSession() を迂回して tmux を直接 kill していたのを是正し、copilot / opencode の終了シーケンスを実測に合わせる (#1905): GUI の停止ボタンと commandmate instances <id> remove --kill が使う POST /api/worktrees/:id/kill-session は、cliTool.getSessionName() で得た名前に lib/tmux の killSession() を直接当てており、ICLITool.killSession() を一度も通っていなかった(設計 §4 D4 の迂回)。そのためツール固有の終了処理が全て飛んでいた — 報告された症状は opencode で、pane だけが消えて SSE 購読が閉じず port も返らないため opencode-subscription-disconnected … reason:"fetch failed" が 30 秒間隔で 13 回続き、同じ port で起動し直した opencode に二度と opencode-subscription-opened が出なかった。CopilotTool.killSession に至ってはどこからも到達不能(もう一方の呼び出し元である Assistant session route は copilot を許可していない)で、以下の不具合が無症状のまま残っていた。挙動変更 3 件: (a) kill が各ツールの graceful exit を通るようになり、稼働中セッション 1 本あたり 0.5〜3 秒かかる(従来は tmux kill 即時)。複数ターゲットは従来どおり逐次処理する。(b) 1 ターゲットの kill 失敗が残りのターゲットを巻き添えにしなくなり(従来は 500 で中断)、失敗分は 200 応答の failedSessions に載る。失敗したターゲットの poller と session state はあえて残す(生きている pane の状態を消さないため)。全ターゲットが失敗したときだけ 500 を返す — 生存している pane のメッセージを archive して isRunning:false を broadcast するのは、この Issue が問題にしている「完了した kill と飛ばされた kill を呼び出し側が区別できない」形そのものだから。(c) .eslintrc.json の tmux import allowlist の段階解消区分が 19 → 18(総数 31 → 30)。#1922 で入れた allowlist を実際に減らす最初の PR で、tests/unit/guards/tmux-import-allowlist.test.ts の完全一致 pin も同時に更新した。ツール側は実測で直した(私設 tmux ソケット・200x50): opencode 1.18.21 — send-keys '/exit' C-m の一括送出は終了しない(/ がコマンドパレットを開き同一コマンドの C-m をパレットが食う。/exit を composer に残したまま 10.8 秒後も稼働、2/2)。本文と Enter を分離すると 0.445 / 0.456 / 0.458 秒で終了(n=3)。copilot 1.0.80 — Issue 本文の「素の exit はチャット送信になる」はこの版では成立しない(exit / /exit の一括・分離、C-c ×2、C-d の 6 綴りすべてが終了する)。実際の欠陥は待ち時間で、終了所要は 11 サンプルで 1.006〜2.193 秒=全サンプルが TUI_EXIT_WAIT_MS(500ms) 超=tmux kill が必ず終了処理の途中に着弾していた。COPILOT_EXIT_WAIT_MS(3000ms) を新設し、送出も /exit の本文/Enter 分離に揃えた。回帰は「route が cliTool.killSession を実際に呼ぶ」「route 自身は tmux を kill しない」「opencode の releaseOpencodeEventStream が route 経由で走る」をtests/unit/api/kill-session-cli-tool-gateway-1905.test.ts で呼び出しの実測として pin し、変異注入 5 種(route の tmux 直叩き復活 / allowlist 復活 / opencode の一括送出復活 / copilot の素 exit 一括送出復活 / COPILOT_EXIT_WAIT_MS の 500ms 降格)で全件赤になることを確認済み。
  • fix(api,cli): auto-yes の既定エージェントが claude 固定で、worktree 既定 copilot / opencode のダイアログが自動応答されなかった (#1909): 挙動変更。POST /api/worktrees/:id/auto-yes は解決結果を?? 'claude' で締めており、「リクエストがエージェントを名指ししていない」を「claude」に潰していた。commandmate auto-yes <id> --enable(および send --auto-yes)が worktree 既定 copilot の worktree で claude の poller を起動し、2 秒ごとに Claude Code session ... does not exist を WARN しながら copilot の許可ダイアログは無応答のまま残る(実測)。send / wait / capture は worktree 既定を使うため、auto-yes だけが非対称だった。解決を #1925 の resolveSessionTarget に一本化し、既定は worktree 既定(roster > 明示指定 > primary anchor > worktree 既定)に変わる(方針書 §4 D5 決定 2 / 挙動変化 3 件の (a))。GET 側も同時に修正(DR3-010): 後方互換のトップレベル状態が getAutoYesState(id, 'claude') のままだと、copilot の poller が動いているのに UI と状態読み出しは claude の(誰も書かない)状態を返す。?cliToolId= 単体クエリも roster 優先で解決するようになった。auto-yes state は in-memory Map なのでデータ移行は不要。副作用のある経路なので、roster と矛盾する cliToolId は POST では 400 instance_tool_conflict(DR3-015。GET は 200 + conflict フィールド)。応答に cliToolId / instanceId / resolvedBy を additive で載せ、CLI は Auto-yes enabled for <id> (copilot). の形でどのエージェントを武装したかを表示する(旧サーバは従来のメッセージにフォールバック)。src/app/api/**/route.ts の claude フォールバック綴りは 8 → 7 件に減った。Issue 本文の項目 2(send --model の検証順序)は #1925 で解決済みのため再実装していない。#1898 との合流: 同じ POST が足した保留 permission の再裁定(recheckPendingDecisions)は、ここで解決した cliToolId / instanceId の対を受け取る——既定 copilot の worktree では copilot の保留分を読み直す。CLI は「どのエージェントを武装したか」と「その場で何件裁定したか」を 2 行で報告する。
  • fix(hooks): copilot / antigravity の permission hook をダイアログ予告として読むのをやめる (#1901): copilot は PreToolUse を 全ツール呼び出し(Read / Grep / Bash …)で発火し大半をダイアログ無しに即実行する(実測: Read の 0〜1 秒後に PostToolUse)のに、permission-decision-service の reportPendingDialog() は #1725 が入れた Claude の意味論「非 allow = これからダイアログが出る」を全ソースに適用していた。結果、Auto-Yes OFF の copilot ではツール呼び出しのたびに waiting / hook_permission_request が最大 20 秒(STRUCTURED_PROMPT_PROVISIONAL_MAX_AGE_MS)立ち、commandmate wait --on-prompt agent が build / test 中のポーリングで偽 exit 10("the agent reported it via PermissionRequest (no decision) for Read")、send は blockedBy:'structured' で拒否、WS / Push 通知がツール呼び出しごとに飛んでいた。antigravity も同じ配線(antigravity/hooks-config.ts)。予告を #1924 が宣言済みの AgentSourceCapabilities.permissionHookPredictsDialog で条件化し(設計方針書 §4 D3 決定 1 / §6.2)、ツール ID で分岐せず registry から宣言値を読む — #1898 の permissionReplyReleasesPrompt と同じ書き方。予告するのは claude / codex のみで、copilot / antigravity / gemini / opencode + 未対応ツール用の互換ソースは予告しない。ダイアログを見落とすようにはならない: copilot は承認をペイン下端に箱で描きステータスバーも composer も消えるので detectSessionStatus が waiting / prompt_detected / hasActivePrompt:true を返す(#1885 / #1886 の実フレーム copilot-live-1885/permission-dialog.txt で確認)。opencode は notification(permission_prompt) フレーム(観測であって予告ではない)が従来どおり記録するので何も失わない。失うのは遅延だけで、scraper は 5 秒の capture キャッシュ越しに読むため実ダイアログの報告が最大 1 ポーリング遅くなる。裁定(allow / no-decision)と応答ボディは 1 バイトも変えていない。Issue 本文からの意図的な逸脱: 本文は allow 監査行(recordAllowedPermission)も copilot / antigravity でスキップするよう提案しているが、allow されたリクエストはダイアログを描かないのでこの行が「CommandMate が無人でコマンドを承認した」唯一の記録であり、capability はダイアログ予告についての宣言なので監査行は残した(方針書 §6.2 も reportPendingDialog のみを条件化対象に挙げている)。
  • fix(test): #1899 の SSE 購読テストを #1900 の health-before-trust 契約に合わせる (#1963): #1899 と #1900 は単独ではどちらも緑だったが、統合された develop で npx tsc --noEmit が exit 2、opencode-event-dedup-1899.test.ts の stop 系 2 件が赤になった。型エラーは #1900 の readOpencodeEventStream → openOpencodeEventStream(async 化)と openOpencodeSubscription の 第 4 引数 port: number → OpencodeSubscriptionOptions への変更を追随していなかっただけだが、stop が 1 本も適用されない方は別原因で、#1900 が /event を開く前に /global/health を必須にした(§4 D3 DR4-004 の health-before-trust)のに対しテストが probeOpencodeHealth を mock しておらず、実 fetch が閉じたポート 4242 に出て refused → health-unreachable で stream が一度も開かれていなかった。probeOpencodeHealth(healthy)と fetchOpencodeSessionStatuses({})を mock に追加し、購読を opencodeAgentEventSource.subscribe 経由=宣言された capabilities.resync が効く本番配線に戻し、beforeEach に「stream が 1 本開いた」ことの明示的な前提アサートを置いた(health が落ちたら 3 画面下の 「stop 0 件」ではなくその場で原因を名指しして落ちる)。#1899 の不変条件(連続 2 ターン=stop 2 本、abort の二重 idle=stop 1 本)はそのまま維持。プロダクションコードの変更は無し(テストのみ)。
  • fix(hooks): opencode の受信 dedup をフレーム固有 id で行い、ターン終端を 3 秒窓の対象外にする (#1899): SSE 経路の ingest は全フレームを isDuplicateAgentEvent(鍵は (worktree, tool, instance, event, detail, sessionID)/窓 3 秒)に通しており、opencode が publish する id が 1 つも鍵に入っていないため、3 秒以内の「別々の事実」が 1 通の再送として捨てられていた。実測: permission.asked per_1 の 1 秒後に届く permission.asked per_2 が裁定も記録もされず(opencode は noDecision: blocks なので承認待ちのまま無限に止まる。10m19s 実測)、2.5 秒間隔の stop は 2 本目が消えて最新イベントが user_prompt_submit のまま running に貼り付き(commandmate wait が 30 分の staleness bound まで返らない)、1.5 秒間隔の question.asked と 0.5 秒間隔の pre_tool_use も同様。ツール名では分岐せず AgentSourceCapabilities.eventIdentity(#1924 の宣言値。opencode だけが 'permission-id')を読む classifyAgentEventDelivery を agent-event-state.ts に置き、規則を 3 つにした — ①フレームに id があれば時間窓なしでその id を鍵にする(再同期が live stream の数分後に同じフレームを replay しても 1 通と数える。3 秒窓が狙っていて実際には届いていなかったケース)②id が無く語が stop / session_end なら抑止しない(session.idle は {sessionID} だけで 2 ターンを区別できない。この経路で turn 数を数えているのは turn-gate で、abort の二重 idle は ingest まで届かない)③それ以外は従来どおり 3 秒窓。push 型 hook(claude / codex / gemini / copilot / antigravity)は eventIdentity: null なので③のみ=挙動不変(#1722 の二重 Stop を分けているのは窓だけなので、ここを緩めない)。id の抽出は capability の宣言に対応する source 側メソッド AgentEventSource.eventIdentityOf(payload) を新設して置いた(spec の extractEventIdentity?、既定 null)— opencode 1 ツールの中だけで permission.asked=properties.id / permission.replied=properties.requestID(同じ値・別綴り、#1898)/ tool part=part.callID / prompt=info.id / session.idle=無し の 5 通りがあり、capability だけでは読めない。identity 鍵には (event, detail) を必ず含める — id 単独の鍵は ask と reply が同値なので #1898 の解除フレームを重複として捨てる。id は readEventIdentity() が長さ 128 と文字種 allowlist で検証し、外れた値は切り詰めずに破棄して時間窓へ戻す(切り詰めると prefix が同じ別 id と衝突して無関係なフレームが重複に見える)。dedup 集合は instance ごとに 512 件で bound(平坦な 1 本だと騒がしい pane が静かな pane の id を追い出す)、世代交代(beginAgentEventGeneration)と discardAgentEventState で破棄する。回帰は Issue 実測の 4 ケース+再同期 replay+#1898 の ask/reply 同値+pre/post 同 callID を pin し、stop は subscription 経由(turn-gate 込み)で「連続 2 ターンは 2 本」と「abort の二重 idle は 1 本」の両方を固定した。空振りでないことは変異注入 8 種で確認済み(capability を null に反転 / extractEventIdentity 未配線 / LIFECYCLE_AGENT_EVENT_TYPES から stop 除去 / identity 鍵から (event, detail) 除去 / ingest を旧 3 秒窓へ差し戻し / dedup 集合を server 横断の平坦 1 本へ / turn-gate の already-completed 除去 / 新 Map をモジュールスコープへ)。
  • fix(polling): opencode の応答保存にターン境界を入れる(user echo / フッタ混入・前ターン再保存・accumulator 未使用) (#1911): opencode は alternate screen で描画するので 1 回の capture に会話の末尾とペイン下端の chrome が同居するが、「現在のターンがどこからどこまでか」を誰も持っておらず、報告された 3 件は同じ欠落を 3 方向から見たものだった。(1) 抽出の起点が後ろから 2 本目の ▣ Build 行=前ターンのマーカーだったため、送ったメッセージの echo(┃ <本文>)が常に応答へ混入し、セッション最初のターンでは 2 本目が無く line 0 まで落ちてペイン全体が応答になっていた。下端も無制限で、composer の Build · <model> <provider> 行と 3 行に折返した cwd フッタ(<cwd> 6.4K (1%) · $ctrl+p)が保存されていた。cwd 折返し行に署名は無いので、境界は findOpenCodeChromeStart / findOpenCodeUserEchoEnd(resolveOpenCodeTurnRegion)で構造的に決める — claude の findClaudeChromeStart(#1289)の opencode 版。(2) isOpenCodeComplete が完了マーカーを最新の user echo より下に要求するようになった。#1893 の時間必須化では終わった前ターンのマーカー(duration 付き)を落とせず、送信直後の最初のポーリングで前ターンの応答が新ターンの応答として保存され、stopPolling まで走って本物の応答が永久に記録されなかった。(3) Layer-2 accumulator は opencode でも書かれていたのに読まれておらず、ペインより長いターンは頭が落ちて保存されていた。投入元を生ペインから現ターン領域(sliceOpenCodeTurn)へ変え、echo が画面外に出たとき(turnHeadTruncated)だけ読む — copilot の無条件 accumulated || response にはしない(opencode は + Thought: … · 12ms→· 579ms と行をその場で書き換えるため overlap 検出が外れ、ペインに全部載っている応答では重複を生む)。実フレーム(1.18.21 / 80x200)で extractResponse + cleaner を pin し、削らなくなったものと今も削れるものの両方を固定、8 種の変異注入で赤を確認した
  • fix(detection): opencode の permission ダイアログを肯定検出し、完了マーカーの時間を必須化する (#1893): opencode 1.18 の permission ダイアログ(△ Permission required + 番号なしの横ボタン列 Allow once Allow always Reject)を検出層が一切見ておらず、直前に描かれる時間なしの途中マーカー ▣ Build · <model> が OPENCODE_RESPONSE_COMPLETE に一致するため、人間の判断待ちで停止しているセッションが ready / opencode_response_complete として publish されていた(commandmate wait が偽完了、sidebar は idle、send guard も通り、isOpenCodeComplete はダイアログ本文を「応答」として保存してポーリングを止めていた)。(1) OPENCODE_PERMISSION_PATTERN(箱の gutter 直後のボタン列に当てる肯定的検出)を追加し、opencode 分岐の先頭(esc interrupt 分岐 A・完了分岐 D より前)で waiting / opencode_permission_prompt / hasActivePrompt:false を返し SELECTION_LIST_REASONS に入れる。(2) 完了判定を OPENCODE_TURN_COMPLETE_PATTERN(· <duration> 必須)に置き換え、isOpenCodeComplete はさらにダイアログ表示中を完了扱いしない。3 択の options は意図的に合成していない — 実測(1.18.21・80x200)でボタン列は ←/→ + Enter でしか動かず数字キーは無反応(3 送出前後でボタン行がバイト同一)で、sendPromptAnswer は opencode への数値回答をテキスト+Enter として送るため、respond <id> 3(Reject のつもり)がハイライト中の Allow once を確定=承認に化ける。wait は isSelectionListActive 経由で従来どおり exit 10、UI は NavigationButtons(ボタン列が実際に受け取るキー)。副作用として、Reject 後の中断ターンは ready を名乗らず証拠なしへ倒れる(方針書 D1 準拠。wait は既存 unclassified 経路で拾う)。fixture は実 TUI キャプチャ tests/unit/lib/detection/fixtures/opencode-live-1893/(opencode 1.18.21 / 80x200、bash と edit の両ダイアログ)。
  • fix(cli-tools): opencode 起動が worktree に opencode.json を無断生成し、固定 15 秒 sleep で初回 send をブロックしていた (#1908): ensureOpencodeConfig は Ollama(:11434)か LM Studio(:1234)が応答すると worktree ルートに provider 設定(約 4KB)を書いており、報告環境では 6 リポジトリ(CommandMate 自身の checkout を含む)に ?? opencode.json が残っていた。生成は既定で行わないようにし、CM_OPENCODE_LOCAL_PROVIDER_CONFIG による opt-in(worktree = 従来の生成先 / global = $XDG_CONFIG_HOME/opencode/opencode.json にマシン 1 本)に変更した。opt-in 時も、OPENCODE_CONFIG が設定されている・worktree に opencode.json / opencode.jsonc / .opencode/opencode.json(c) がある・グローバル設定がある、のいずれかなら書かない(1.18.21 の opencode debug config 実測: これら 4 種はすべて読まれ、provider は全層でマージされ、worktree ルートの opencode.json はキー衝突時に $OPENCODE_CONFIG とグローバル設定の両方に勝つ — 生成は「足すだけ」ではなく利用者の選択を上書きしうる)。既存の生成物は削除も更新もしない(そのまま読まれるので provider 一覧は失われない)。あわせて OPENCODE_INIT_WAIT_MS = 15000 の盲目 sleep を、#1883 の OPENCODE_IDLE_COMPOSER_PATTERN(入力箱 gutter 直後のプレースホルダ=肯定的証拠)と Connect a provider オーバーレイを 500ms × 60 回(30 秒窓)ポーリングする waitForReady に置き換えた(#1907 の copilot と同型)。実測(1.18.21・私設 tmux ソケット・使い捨て HOME・80x200): composer 描画は 2.9〜3.6 秒、並列 6 エージェント負荷下では 24.1 秒。HTTP サーバは composer より 1.3〜1.8 秒先に /global/health に応答するため、attachOpencodeEventStream の前提は固定 15 秒より強くなる(負荷下の 22.8 秒ケースでは旧実装は 15 秒時点で probe に失敗し構造化イベントを失っていた)
  • fix(api): current-output が ?instance= の CLI ツールを解決せず、wait --instance <opencode|copilot|codex> が稼働中のセッションに対して NOT_STARTED(exit 21)を返していた (#1884): GET /api/worktrees/:id/current-output は cliToolId を ?cliTool か worktree 既定からしか決めておらず、?instance= を式に一切入れていなかった。既定が claude の worktree で ?instance=opencode を渡すと getTool('claude').isRunning(id, 'opencode') = 実在しないセッション名 mcbd-claude-<id>-opencode を引き、opencode が生成中でも isRunning:false / sessionStatusReason:'session_not_running' を返す。commandmate wait には送り先を訂正する --agent が無い(#1638)ため、これは 1 回目のポーリングで exit 21(Not started: … has no running claude session for instance opencode)になり、orchestrate / wait を使う自動化が opencode・copilot・worktree 既定と異なる codex の instance を一切扱えなかった。解決を #1925 の唯一の権威 resolveSessionTarget() に置換した(設計 §4 D5 決定 3)ので、precedence は roster > 明示指定 > primary anchor(#868)> worktree 既定 の 1 実装に揃う。読み取り経路なので roster と ?cliTool の矛盾は 400 にせず、roster 優先で 200 を返し conflict を payload に載せる(DR3-015。ここで 400 を返すと orchestrate-monitor の capture ループが毎ポール skip して無音で回り続ける)。payload に resolvedBy / conflict を additive 追加し(capture --json にそのまま出る)、wait の Not started: 行末に (resolvedBy=…) を付けた(stderr のみ・--json 契約は不変)。?cliTool の値そのものを使う経路・instance 未指定の経路の挙動は変わらない。
  • fix(api): スラッシュコマンド route が CLI 版 probe を await しており、応答時間が「そのマシンに何が入っているか」に依存していた (#1913 追補): PR #1947 で CI の Integration Tests が 2 回落ちた(api-worktree-slash-commands.test.ts の should return merged command groups for valid worktree が Test timed out in 5000ms。gh run rerun --failed では 11/11 pass)。原因はカタログが 163→240 件に増えたことではない。 実測すると 240 件の build+merge+filter は 0.07ms/回(keyOf の dedup は Map なので O(n))で、同ファイルの他 9 テストは 2〜4ms で通っている。当該テストだけが遅いのは、そこが route の本体を最初に通す=getCatalogStaleness() が実際に子プロセスを 5 本起動する唯一のテストだからで、その 1 回だけで 341ms(getCatalogStaleness() 単体 346ms=テスト所要のほぼ全部)。probe 3 本時代は 79ms、#1913 が足した opencode --version(260ms)と copilot(287ms)で 322ms になっていた。つまり所要は「ランナーに何の CLI が入っているか」で変わり、上限は 322ms ではなく probe 1 本が固まったときの VERSION_PROBE_TIMEOUT_MS(5s)である。直し方は 3 つ: (1) route は getCatalogStalenessSnapshot()(同期。キャッシュを読み、無ければ背景で probe を開始して {} を返す)を使い、子プロセスを応答パスから外す(方針書 §4 D2 / DR3-013 (a)(b)(c))。{} は「まだ不明」であって「陳腐化なし」ではなく、バナーは次回パレットを開いたときに出る。(2) copilot の probe を gh copilot -- --version から resolveCopilotExecutable() 委譲に変えた。#1907 が実測したとおり gh copilot は PATH に copilot が無いと CLI をダウンロードする — 版バナーのための probe がソフトウェアをインストールするのは論外で、しかも #1907 で起動実体は PATH の copilot に変わっており「起動する実行体を probe する」(DR4-010 (1)) も満たさなくなっていた。委譲先は起動と同じ解決を使い、絶対パス解決・sanitize 済み env・maxBuffer も既に備えている。(3) api-worktree-slash-commands.test.ts に child_process モックを足し、この suite が実 CLI を起動しないようにした(隣の user-catalog suite は当初からそうしている)。当該テストは 341ms → 17ms。ガードは変異注入 4 種で赤を確認(route が再び await する / snapshot が背景 probe を開始しない / in-flight 共有をやめる / copilot を再び gh copilot で撃つ)。とくに 1 本目は「固まった CLI」を模したモックに対してアサーションで落ちるのではなくテストが返ってこなくなる — CI が踏んだ失敗そのものを決定的に再現している。
  • fix(catalog): copilot / opencode のスラッシュコマンドカタログを実機の 1.0.80 / 1.18.21 へリコンサイルし、幻 2 件の削除・欠落 30 件の追加・ja 未翻訳の解消を行う (#1913): copilot の組み込みコマンドだけが src/lib/slash-commands.ts に英語直書きの配列として置かれており、descriptionKey を持たないため ja ユーザーには copilot のパレットだけが英語だった。68 件すべてを src/config/slash-commands-catalog.json へ移し(getCopilotBuiltinCommands() はカタログの copilot スコープを返すだけになる。route の注入は同一 keyOf で自己 dedup するので挙動は不変)、en/ja の辞書に対を入れた。採用集合は実機で採り直した: copilot help commands(1.0.80、67 行)と私設 tmux ソケットで開いた実パレットは食い違う — /undo は help に無いのにパレットに在り、/footer /rewind は help に在るのにスクロール一覧に出ず完全入力でだけ出る。両者の和集合を採用し、どちらにも無い /streamer-mode だけを幻として落とした。opencode は 1.18.21 のパレットを端まで走査して 18 件を確定し、欠落していた debug diff init mcps move review skills status variants を追加、パレットに存在しない /compact を落として frequentlyUsed.opencode からも外した(/status が枠を埋める)。幻 2 件は src/config/slash-commands-exclusions.json に kind: phantom として記録したので catalog:refresh が再提案しない。#1503 の /undo 名前ごと禁止は copilot に限って実在するコマンドを隠していたので、v0.21.2 が /vim に対して行ったのと同じ形で codex スコープへ狭めた。i18n は Issue が挙げた /exit(slashCommands.descriptions.exit が「OpenCode TUI を終了」1 本で、claude / codex の /exit もそこへ解決していた)だけでなく、同じ形の衝突が 11 名にあることが分かったので exit login logout feedback skills init agent plugin memory app debug を <name>.<tool> へ分割した(分割はキーの追加ではなく全請求者の書き換えである — JSON は同じキーを文字列とオブジェクトの両方にできないため、flat キーに残ったエントリは undefined に解決して空欄になる。これを固定するガードを追加した)。あわせて opencode の /connect が「言語サーバーに接続」と説明されていたのを実機の Connect provider に直した。VERSION_PROBES に opencode(opencode --version)と copilot(gh copilot -- --version)を追加し、verifiedAgainst に copilot: 1.0.80 / opencode: 1.18.21 を記録した(陳腐化が検知されるようになる)。copilot の probe は起動実体と同じ実行体である必要があるので裸の copilot は撃たない(COPILOT_LAUNCH_COMMAND = 'gh copilot'。設計方針書 §4 D2 / DR4-010 (1))。probe は sanitizeEnvForChildProcess() の env と明示 maxBuffer で起動する(DR4-010 (3)(4))。Issue が挙げた SELECTION_LIST_COMMANDS の拡張は、実測に基づいて見送った: copilot 1.0.80 で picker を開くのは 11 コマンドだが、COPILOT_SELECTION_LIST_PATTERN はそのうち 1 つもマッチしない(/model は Search models… を U+2026 で描くので Search\s+\w+\.\.\. に当たらず、picker のフッターは enter to select と小文字なので Enter to (?:select|confirm) にも当たらない)。いま名前を足しても waitForSelectionList が 5 秒空振りする分だけ遅くなるだけなので、パターン修正(検出層 / #1885・#1886)が入ってから広げるべきものとして、実測結果をコード上のコメントに残した。ガードが空振りでないことは変異注入 13 種(幻の再投入 2 / 欠落の再現 1 / flat キーへの差し戻し 1 / opencode 文言の claude への流用 1 / /undo の codex 復活 1 / verifiedAgainst 改竄 1 / frequentlyUsed への幻復帰 1 / 裸 copilot probe 1 / env・maxBuffer・opencode probe の削除 3 / 英語直書きの復活 1)で全件赤になることを確認済み。
  • fix(cli-tools): copilot の isInstalled が gh 組込みヘルプで偽陽性になり、未インストール環境では pane 内で黙ってダウンロードが始まっていた (#1907): CopilotTool.isInstalled() の第 2 段 gh copilot --help は copilot が 1 バイトも無いマシンでも exit 0 を返す(実測: gh 2.86.0 / gh extension list 空 / 約 0.02 秒)。copilot はもはや gh 拡張ではなく gh 組込みの preview コマンドで、そのヘルプ自身が「PATH にあればそれを実行し、無ければ ~/.local/share/gh/copilot へダウンロードする」と書いている — つまり旧チェックが証明していたのは gh が入っていることだけだった。実害はバッジの誤りに留まらない: startSession が gh copilot を pane へ送るとそこでダウンロードが始まり、waitForReady が 30 秒空振りしたあと copilot-prompt-detection-timeout を出して**「起動成功」として続行していた。判定を肯定的証拠 1 本**(方針書 §4 D1)に置き換える: 新設の resolveCopilotExecutable()(src/lib/cli-tools/copilot-executable.ts)が PATH の copilot → gh がダウンロードした $XDG_DATA_HOME/gh/copilot の順に実行可能ファイルを探し、絶対パスへ解決してから --version を実行して、exit 0 かつ版文字列が取れたときだけ { path, version, source } を返す(裸のコマンド名を execFile しない・sanitizeEnvForChildProcess() / timeout / maxBuffer を明示 = DR4-010 の規約)。exit 0 だけで真にしないのが要点で、それが旧実装を無力にしていた当のものである。起動も同じ 1 回の解決から決める(isInstalled() に訊いてから別のコマンドを打つ、という食い違いが本件の構造): PATH 実体があればそれを起動し、gh 管理コピーのときだけ gh copilot にフォールバックする(gh は PATH を優先するので、PATH に無いこの分岐では gh はまさに probe したファイルを実行する。実在を確認済みなのでダウンロードも起きない)。command は 'gh' → 'copilot'。案内文は COPILOT_INSTALL_HINT(brew install copilot-cli / npm i -g @github/copilot)に差し替え、廃止された gh extension install github/gh-copilot(別製品)を案内しない。commandmate init の依存表にも copilot / opencode を追加した(copilot は command: 'copilot'。gh 経由では実在を測れない)。あわせて COPILOT_INIT_WAIT_MS = 4000 の盲目 sleep を廃止した — 実測では banner が ~1.3s、folder-trust ダイアログが ~2.5s、composer はダイアログ応答後なので、4 秒は信頼フォルダには長すぎ未信頼フォルダには無意味だった。単に消すと新しい偽陽性が生まれる(sleep は起動直後のフレーム=シェルのプロンプトを最初のポーリングから隠していただけで、COPILOT_PROMPT_PATTERN の ^[>❯] は starship / pure / agnoster のプロンプトにそのまま当たる)ため、readiness を isComposerDrawn()=全幅ルールに挟まれた ❯ 行という copilot 自身の構造へ狭めた上で #1886 の waitForReady に乗せている(二重実装なし。#1886 の 2 つの契約 — 箱入りダイアログは ready に読めない / stripBoxDrawing を噛ませない — はどちらも維持)。fixture は私設 tmux ソケット・本番 geometry(200x1000)で 250ms 間隔の実キャプチャから起こした(tests/fixtures/copilot-launch-boot-1080.ts)。ガードは変異注入 7 本(readiness を ^[>❯] へ戻す/起動を gh copilot 固定へ戻す/4 秒 sleep を復活/版文字列要求の削除/gh 不在ガードの削除/絶対パスでなく裸の語を probe/PATH と gh 管理コピーの順序反転)ですべて赤になることを確認済み。#1886 の folder-trust 実装は変更していない。
  • fix(detection): copilot 1.0.80 の生成中フレームが全て ready/input_prompt になり、wait が生成中の agent を Completed と裁定していた (#1885): COPILOT_THINKING_PATTERN(#547)は copilot の 1.0.79 より前の語彙(braille スピナー・(Esc to cancel・Reasoning ■■■・... Thinking・Generating・Processing)に書かれており、1.0.80 はそのどれも描かない(実測: 生成中の実フレーム 44 枚に対して 0 件一致)。一方 ❯ composer は生成中も描かれ続けるため、全フレームが detectSessionStatus の step 3 に落ちて ready/input_prompt として publish されていた。実害は sidebar の見た目ではなく wait で、完了条件 sessionStatus === 'ready' && isUnclassifiedActive !== true を初回ポーリングで満たし、生成開始 2 秒の worker が basis=scraper_ready で Completed になる。1.0.80 は turn の状態をペイン最下行のステータスバーだけに描く(実測: 生成中は ◉ Working · 1.5 KiB esc interrupt、待機中は ← open sidebar · / commands · ? help · tab next tab。スピナー字形は ● ◉ ◎ ○ を巡回し、バイト数は出力が出てから付く)。そこで COPILOT_WORKING_STATUS_PATTERN / COPILOT_IDLE_STATUS_PATTERN と readCopilotStatusBar() を新設し、窓ではなく最下行 1 行だけを読むようにした —— copilot 自身が応答本文に ● Working esc interrupt と印字した実フレームがあり、15 行窓で見ると完了済みセッションが永久に running に固着する(#1900 の形)。待機側は D1(方針書 §4 D1 決定 1 の 2)に従い idle ステータスバーという肯定的証拠に載せ替え、copilot を step 3 の汎用 promptPattern 判定から除外した(除外しないと idle バーのアンカーが観測不能になる)。permission ダイアログは箱がペイン下部を占有してステータスバー自体が消えるため、running 判定は当たらず従来どおり waiting のまま。実 TUI フレーム 7 枚(200x1000)を tests/unit/lib/detection/fixtures/copilot-live-1885/ に fixture 化し、detectSessionStatus と mergeStructuredStatus 適用後(wait が実際に読む値)の両方を pin した。
  • fix(detection): opencode の idle composer Ask anything... が hasActivePrompt: true と判定され、send が全拒否・sidebar が永続 waiting になっていた (#1883): status-detector.ts の opencode 分岐 E は Ask anything... に一致したフレームを reason: 'prompt_detected' / hasActivePrompt: true で返していた。これは resolvePromptWaiting が「人間が先に答えないといけない」と読む信号なので、被害は表示だけに留まらない —— 起動しただけの opencode に対する commandmate send … --instance opencode が blockedBy: 'scraper' で恒久的に拒否され(exit 2、本文は未達)、ユーザーは最初の 1 通すら送れない。案内される commandmate respond は実在しないダイアログを指すので出口も無く、ls / sidebar は waiting に固着する。Ask anything... は claude の ❯・codex の › と同じ入力欄であって質問ではない。修正は input_prompt / hasActivePrompt: false(=他ツールの idle 入力行と同格)に揃えたうえで、その ready を肯定的証拠の上に置いた(設計方針書 §4 D1 / §6.1 行(2)): opencode は入力バッファが空のときにだけプレースホルダを描き、1 文字打つと行ごと置き換わる(実測。composer-residual.txt は同じ行が ┃ echo PREFILLED になっている)ので、入力箱の gutter(┃)の直後にあるプレースホルダ行は「composer が空」の肯定的証拠になる。新設 OPENCODE_IDLE_COMPOSER_PATTERN はその gutter を要求し(stripBoxDrawing の前に当てる。空白は [^\S\n] = 水平方向のみ — \s は m フラグ下で改行をまたぎ、別の行の gutter と phrase を組にして composer の無いフレームに一致した)、応答本文に出た同じ phrase(phrase-in-response.txt で実測)や送信済みメッセージの transcript エコーは一致しない。あわせて opencode を汎用の step 3(promptPattern の 15 行窓)から除外した: opencode の promptPattern は phrase そのものなので、除外しないと E が退けたフレームがそのまま同じ verdict で再入場し、gutter アンカーが観測不能になる(実測。変異注入で確認)。証拠が無いフレームは D1 どおり heuristics に落ちる。検証は実 TUI キャプチャで行い、detectSessionStatus と、status-detector を経由せず detectPrompt(stripBoxDrawing(stripAnsi(...))) を直接呼ぶ Auto-Yes 経路(response-checker.ts)の両方を pin した(fixture: tests/unit/lib/detection/fixtures/opencode-live-1883/、opencode 1.18.20 / 私設 socket / 本番 geometry 80x200 の 5 フレーム)。非空虚性は 4 種の変異注入で確認済み(hasActivePrompt を true に戻す / gutter アンカーを素の phrase に緩める / step 3 の除外を外す / 空白を \s に戻す —— いずれも赤)。Issue 本文との相違 2 点: (a) 「L660 を false にすべき」だけでは D1 を満たさない(上記の 2 点が要る)。(b) 「branch D が直近 15 行に無いと応答完了後も E が勝って再発しうる」は実測で否定された —— 1.18.20 は初回応答後にプレースホルダを捨てるため E は再発せず、D の窓は最後の非空コンテンツ行で終わる=完了直後は ▣ Build 行自身が末尾なので常に D が勝つ。OPENCODE_PROMPT_PATTERN / OPENCODE_SKIP_PATTERNS / response 抽出系は不変(それらは verdict ではなく行の削除に使うため、phrase がどこに出ようと拾ってよい)。
  • fix(hooks): copilot の hook 設定がマシン共通ファイルであることに由来する 3 つの脆さを塞ぐ (#1904): ~/.copilot/settings.json はマシンに 1 本で、書いたのは「最後に copilot セッションを起動したサーバ」である。そこから 3 件。① config.json の hooks が settings.json を上書きする(copilot 1.0.80 実測: 両方にマーカー hook を置くと CONFIG-* だけが発火し、直後の settings.json は CONFIG-* 6 件 / SETTINGS-* 0 件)。copilot help config は今も hooks を config.json に書くよう案内しているので、ドキュメントに従ったユーザーは CommandMate の hooks を無音で失う。書き込み前に config.json を読み(実ファイルは先頭 2 行が // コメントで JSON.parse が 0 文字目で落ちるため、コメントを除去してから解析する)、hooks があれば settings.json を書かずに素の gh copilot で起動し copilot-hook-config-json-shadows-settings を warn に出す。copilot 自身の移送でキーは消えるので、この拒否は 1 回の起動で自然に解ける。② ポートと relay の絶対パスが最後に書いたサーバに固定される(実測: port 3011 の開発サーバが起動した結果、マシン上の全 copilot セッションの宛先が 3011 になった。checkout を消すと relay も失われる)。ポートだけを CM_HOOK_PORT で起動 env に載せ、hook 冒頭の case "$CM_HOOK_PORT" in ''|*[!0-9]*) … exit 0;; esac で数値検証する。scheme / host / relay の絶対パスは literal のままとし(curlArgumentPreamble は宛先を見ずに Authorization: Bearer を付けるため、宛先が定数であることがトークン漏洩の防波堤。env で運ぶ relay パスは「hook のたびに実行するプログラム」の委譲になる)、relay は [ -x '<path>' ] を発火時に見て無ければ inline curl へ落ちる。${VAR:-既定} の綴りは使わない(未設定時に黙って別の宛先へ落ちるため、未設定なら発火しない)。③ 4xx のボディが裁定として copilot に渡っていた(out=$(curl …) に -f が無く {"error":"cwd rejected: …"} が verdict になる)。-fsS にして非 2xx は {} に倒し、併せて失敗を無音にしない — permission_request_failed rc=22 / agent_event_post_failed rc=<n> を stderr に 1 行出す(観測側の || true も廃止)。あわせて settings.json の書き込みを temp + rename の原子的置換にし、~/.copilot/.cmate.lock を O_EXCL で取り(取れなければ書かずに hooks なしで起動、10 秒より古いロックは落ちたプロセスの残骸として奪う)、書き換える場合のみ直前の内容を 1 世代 settings.json.cmate-backup に残す。#1904 で内容がサーバ非依存になったため、再起動しても普通はバイト列が変わらず、その場合は書かずに返す。検証は生成した shell を実 /bin/sh で実ループバックサーバに撃って行い(4xx がボディを漏らさない・非数値 port で 1 リクエストも出ない・relay が消えても inline へ落ちる 等)、ガードは変異注入 10 件すべてで赤になることを確認済み。設計根拠は docs/design/multi-agent-state-architecture.md §10.8 / §10.9(受入条件 S7 / S8 / S16)、実測記録は docs/design/copilot-agent-hooks-injection.md §7。
  • fix(cli-tools): copilot 初回起動の folder-trust ダイアログを waitForReady が検出できず、起動が毎回 30 秒ストールしていた (#1886): copilot 1.0.80 は未 trust の git リポジトリで最初に「Confirm folder trust」を出す(実測: git でない素のディレクトリでは出ない)。ダイアログは箱の中に描かれ、選択行は │ ❯ 1. Yes という綴りになるため、readiness 判定の COPILOT_PROMPT_PATTERN(/^[>❯]\s|^\?\s+/m)はフレームのどこにも当たらない — 同時に composer 行そのものが消えるので、30 回のポーリングが丸ごと空振りしてから copilot-prompt-detection-timeout を出して続行していた。waitForReady にダイアログの肯定検出を足し、セッション限りの trust(1. Yes)を Enter 無しで 1 度だけ送って、そのあと従来どおり composer の実在で ready を判定する(「ダイアログが無い=ready」には倒さない。固定 sleep も足していない — 既存の 1 秒ポーリングに乗せている)。答えてよい選択肢を綴りで照合するのが本修正の要点で、option 2(Yes, and remember this folder for future sessions)はマシン共通の ~/.copilot/config.json に trustedFolders を書くため、copilot が並びを変えたら検出ごと落として**#1886 以前のストールに戻す**(=勝手に永続化しない)方向に倒してある。1 が永続化しないことは実機で確認済み(私設 tmux ソケット・使い捨ての git リポジトリで再現し、応答前後で ~/.copilot/config.json の sha256 がバイト一致)。対になる waitForPrompt(sendMessage 側)も同じ検出で塞いだ: startSession は既存セッションで早期 return するので、CommandMate の外で起動された pane はダイアログ上のままここに到達し、この関数は timeout でログを出して送信を続行する — 本文はダイアログへ打ち込まれ、そこでは本文中の数字が選択キーなので「2 を含むメッセージ」が Yes, and remember を選んでしまう。readiness 判定に stripBoxDrawing を噛ませる「修正」は入れてはいけないことも実測して固定した(│ ❯ 1. Yes が ❯ 1. Yes になり、ダイアログが ready として読める)。fixture は実機キャプチャ(200x1000)から起こし、ガードは変異注入 8 本(検出分岐の削除/box 剥がし/アンカー 1 本化/選択肢照合の削除/one-shot 解除/キャッシュ無効化の削除/waitForPrompt 分岐の削除/trailing Enter)で全て赤になることを確認している。状態検出(detectSessionStatus)は無変更 — このダイアログは既に waiting / multiple_choice / 3 択として正しく読めており(実測)、send guard・wait・Auto-Yes は以前から保護されていた。本 Issue の実害は起動遅延だけである。
  • fix(hooks): copilot の Edit は tool_input が文字列(apply-patch envelope)のため unknown-payload になり hooks Auto-Yes がファイル編集を裁定できない (#1902): copilot 1.0.80 の Edit は PreToolUse の tool_input を object ではなく文字列("*** Begin Patch\n*** Add File: note4.txt\n+yo\n*** End Patch\n")で送る。parseCopilotPermissionRequest が plain object を必須にしていたため null → unknown-payload → no-decision となり、Auto-Yes ON でも編集系だけ毎回ダイアログが描かれ、scraper poller の 2〜4 秒遅延(#1891 S 群で止まる条件つき)に依存していた(Read / Bash は object なので正常に裁定されており、症状は「Auto-Yes は効くが編集だけ効かない」に見えていた)。新設の src/lib/hooks/tool-input-normalization.ts が文字列 tool_input を { patch }(envelope 以外は { text })へ正規化し、allow を返せるようにした。denyPatterns の照合対象は envelope の action ヘッダ行(*** Add File: … / *** Update File: … / *** Delete File: … / *** Move to: …)であり、hunk 本文には当てない — PRIMARY_TOOL_INPUT_KEYS が Write.content / Edit.new_string を除外しているのと同じ規則で、本文照合は copilot の Edit を claude の Edit / Write より厳しくし、シェルスクリプトを書いただけで無人実行が誰も見ていないダイアログで止まる。ヘッダ行は動詞とパスの両方を含むので Delete File でも \.env でも契約は書ける。ヘッダが 1 件も取れない/64 件を超える場合は envelope 全体へフォールバック(over-match = ダイアログで済む安全側)。正規化は無言で行わない(方針書 §7 の discoverability): 理由コード付きの記録を capture --json / wait が読む structuredEvents.toolInputNormalization ({reason:'string-tool-input-as-patch'|'string-tool-input-as-text', key, receivedType, toolName, at})として additive に露出する。露出のみで判定は読まない。claude / codex / gemini / opencode / antigravity の裁定経路は 1 バイトも変わらない(正規化はソースが payload に載せたときだけ働き、object payload では null)。回帰テストは Issue 本文の生 payload をそのまま使い、文字列 Edit と object の Read / Bash を同一ケースで pin する
  • fix(verify): 並列ワーカーの verify が同時にフル test:unit を走らせ、diff と無関係の赤で exit 20 を返していた (#1917): /orchestrate はワーカーの完了を wait --verify の exit code だけで裁定する(#1544 / #1882)。その裁定がマシンの負荷で反転していた —— 2 ワーカーの verify が同時に npm run test:unit に到達した回だけ、サブプロセスの exit code を検査する monitor-exit-codes.test.ts が exit 130 を取り逃して落ち、その diff が触れてもいないテストで exit 20(不合格)になった(実測: 単独 486.5s / 553.4s は緑、同時実行の 640.5s だけが赤。単独再実行は 16/16 緑)。同一セッション中に再発しており、並列オーケストレーションでは構造的に繰り返す。二次被害のほうが重い: 「exit 20 は負荷かもしれない」と運用者が学習すると本物の不合格まで疑われ、ゲートが形骸化する。修正は設定 1 行である —— マシン全体のロック機構は #1771 で実装済み(src/lib/verification/machine-lock.ts / verify-run.sh / mutex: キー)で、どのゲートも宣言していなかったことだけが欠けていた。.commandmate/verify.yaml の unit ゲートに mutex: cpu.heavy を宣言し、実装には一切手を入れていない。名前が unit でも test-unit でもないのは仕様 9.2 の命名規約による: mutex 名はゲート ID ではなく資源の名前であり、ここで奪い合っているのは固定ポートでも DB でもなく「このマシンで重いスイートを走らせる枠」= CPU と実時間なので、cpu.heavy と名指しておけば別リポジトリの同じくらい重いスイートが同じ名前を宣言するだけで同じ枠を共有できる(unit では自分自身としか排他できない)。安いゲートには付けない: 静的ガード 3 本(各 0.1s)は失敗を秒で返すために在るので(#1882)他 worktree の 500s の後ろに並ばせない。lint / typecheck も実測に基づいて見送った —— 2 worktree 同時実行で lint 5.4s / 5.2s、typecheck 10.8s / 10.6s といずれも緑のままで、負荷起因の赤を出した実績が無い一方、mutex を付けると「ロックが空かないまま timeoutSec に達した」= SKIP reason=mutex-wait = exit 99(裁定不能、仕様 9.4) という存在しなかった経路を安いゲートに持ち込むことになる。実機検証は隔離した DB・ロックルート・ポートの CommandMate サーバに 2 つの使い捨て linked worktree を登録し、commandmate verify を同時に起動して行った(記録: docs/qa/1917-parallel-unit-mutex.md)。宣言が消えても他に赤くなるものが無いため、不変条件は tests/unit/guards/verify-heavy-gate-mutex.test.ts が固定する(unit が cpu.heavy を宣言する / 名前がゲート ID と一致しない / 静的ガードと lint・typecheck が直列化されない / mutex を宣言するゲートはちょうど 1 本)。
  • fix(guard): token discipline のパレット列挙が Tailwind 既定パレットの 11/26 ファミリーしか見ておらず、生配色が素通りしていた (#1892): TOKEN_DISCIPLINE_PATTERN は #1082(gray/slate)と #1116(chromatic 9 色)でその時コードに在った色を手で選んだ 11 ファミリー列挙で、neutral / zinc / stone / pink / rose / fuchsia / indigo / cyan / teal / emerald / lime は検査対象ですらなかった。結果、移行済みディレクトリ内・*Terminal* 除外後・テスト除外後で 4 箇所の生配色が残ったまま ガードが exit 0(「生配色は無い」) を返していた(TreeNode の text-pink-500 ×2 / VerificationPane の bg-neutral-900・text-neutral-100 / gitPaneShared の text-teal-600)。手で選んだ列挙は「誰も足そうと思わなかった色」について構造的に無言で、その無言はクリーンなツリーと区別できない。パレット一覧を TAILWIND_PALETTE_FAMILY_NAMES 1 箇所に集約し、不在検査の正規表現も実在検査(#1889)の組み込み配色判定も同じ配列から生成するようにしたうえで、Tailwind 既定パレットの全 26 ファミリーへ拡張した。列挙漏れが再発しない根拠は「気をつける」ではなく機械で担保する: unit テストが node_modules/tailwindcss/theme.css の --color-<family>-<step> 実宣言を読み、この配列と集合として一致することを検査する = Tailwind を上げてファミリーが増えればその時点でテストが赤になる(スクリプト自身が tailwindcss を import しないのは、CI の token-discipline ジョブが npm install を行わないため。#1889 と同じ制約)。実際この突き合わせで Tailwind 4.3 が追加した mauve / olive / mist / taupe が発覚し、拡張後の列挙に含めている。さらに変異注入(列挙を旧 11 ファミリーへ戻す)で新テスト 19 本が赤になることを確認した際に、Issue が挙げていない 5 件目が出た: border-t-cyan-500(FileTreeView のスピナー)は (bg|text|border|ring)-<family>-<step> の形に当たらず、実在検査は「Tailwind 組み込み配色」と判定しているのに不在検査は見ていない=同じクラスについて 2 つの検査が食い違っていた。パターンに側面・オフセット segment(border-t-* / ring-offset-*)を足して塞ぎ、border-t-accent-500(cyan-500 と同一 RGB)へ置換した。置換は 5 箇所: ファイル種別アイコンの pink → text-accent-500、untracked の teal ペア → text-accent-600 dark:text-accent-400、スピナー → border-t-accent-500、そして VerificationPane のゲートログ <pre> → 新設のダーク島トークン bg-terminal-surface / text-terminal-foreground。この <pre> は常時ダークのまま維持する判断で(CLI ゲートの生ログを流すターミナル出力面。#1075 分類 (a)、同じ画面に TerminalDisplay が並ぶ)、ガードの *Terminal* 除外へリネームで逃がす案は採らなかった — 「常時ダークである」という設計上の性質をファイル名の綴りに預ける形であり、綴りを外れた瞬間に静かに壊れる(まさに VerificationPane がそれだった)。--terminal-* は @layer base の :root に 1 度だけ宣言し .dark に対を置かないので、「テーマに追従しない」がトークンの定義そのものになる。値は TerminalDisplay が実際に描いている gray-900 / gray-300 に合わせ(旧 neutral-900 / neutral-100 から意図的に変更。コントラスト 11.6:1)、同じ「ターミナルのダーク」がコンポーネントごとに散るのを止めた。判断と不採用案は docs/design-system.md に記録。回帰テストは develop に残っていた行を逐語で(実パスに)植えて CLI を実行し exit 1 を固定し、トークンへ置換した同じツリーで exit 0 になることまで検査する。*Terminal* 例外・.test./.spec./__tests__ 除外・src/app/worktrees 除外・#1889 の実在検査はいずれも不変(既存テストは緑のまま)、CI と verify.yaml が同じスクリプトを呼ぶ #1882 の構造も不変。
  • fix(hooks): opencode の SSE 購読が、再接続で stop を失い・別 session の idle を instance の完了として publish し・0.0.0.0 で listen 中のポートを空きと判定し・health 1 回失敗で恒久劣化していた (#1900): 5 点とも「エラーは 1 件も出ないまま状態だけが嘘になる」形の欠陥。(1) 再接続は gate.reset() でターンの武装を捨てるだけだったので、watchdog(30 秒無音)や TCP 断がターン中に起きると、その後の session.idle が never-armed で落ち running/hook_post_tool_use のまま最大 30 分固着した。方針書 §4 D3 の resync capability(opencode は 'session-status-poll')を読み、再接続時に GET /session/status をsession 単位で読み直して busy を再武装し、切断前に武装していた session が 明示的に idle と答えたときだけ session.idle を合成する(closedBy: 'resync_idle' 相当)。応答に載っていない session は「不在=idle」とみなさない。capabilities.resync を 'none' に倒すと全部止まる(宣言値が switch であって tool 名ではない)。(2) turn gate に primary session の概念を入れた。何も busy でないときに busy になった session だけが instance のターンを名乗れ、info.parentID を宣言した session(sub-agent。opencode 1.18.21 の GET /doc の Session schema で確認)は名乗れない。primary が busy の間、他 session の idle は foreign-session として落とす — mergeStructuredStatus は構造化 ready が scraper の running を上書きするので、sub-agent の完了が wait をターン途中で exit 0 にしていた。primary が busy でないときは規則を解くので、primary の推定を外しても「完了を取りこぼし続ける」にはならず、最悪でも scraper が閉じるまで遅れるだけになる。(3) isPortFree が 127.0.0.1 しか bind せず、macOS/BSD では Node が付ける SO_REUSEADDR により 0.0.0.0 占有中でも loopback bind が成功していた(Darwin 25.6.0 で実測)。4200-4299 は Angular 既定の 4200 を含むため、これは割当失敗ではなく他プロセスの localhost 乗っ取りになる。0.0.0.0 / :: も probe し、EADDRINUSE/EACCES だけを veto として扱う(IPv6 の無いホストで全ポートが埋まって見えないように)。(4) attach の /global/health が 1 回きりで、失敗するとそのセッションは永久に scraper のみだった(稼働中の pane を再 attach する経路が無いため)。0/0.5/1/2/4 秒の 5 回に増やし、OPENCODE_SERVER_PASSWORD による 401 のように答えたうえで拒否したケースは即座に打ち切って status をログに出す。(5) resyncPending が /event を開く前に走っており、その隙間に上がった permission はどちらにも現れず次の再接続までブロックしていた。stream を開いてから resync する順序に変え、併せて §4 D3 DR4-004(§13.2 S5)の health-before-trust を実装した — 再接続ごとに /global/health の version を照合し、不一致なら stream も status 応答も信じずに port_identity_changed で scraper へ降格する(loopback の別プロセスが stop 1 本で wait を完了に化けさせられるため)。backoff は close() 専用の signal で待つようにし、既に abort 済みの signal で addEventListener('abort') が発火しない罠と listener の蓄積も塞いだ。
  • fix(detection): /send の送信前 composer クリアが codex に効かず、残存があると本文が連結され続けていた (#1890): #1880 は extractComposerText が claude 以外を unsupported_tool に短絡することを前提に非 claude をクリア経路の手前で return する設計だったため、codex は「壊れないが直りもしない」状態のまま残っていた(#1880 実機検証ケース7 で連結が再現)。同じ短絡により #1879 の未送信入力バーも POST /api/worktrees/[id]/clear-composer も codex では機能していなかった。extractComposerText に codex の入力箱を実測ベースで教え(findCodexInputBox)、COMPOSER_CLEAR_SUPPORTED_TOOLS に codex を追加して 3 つを同時に有効化した。codex は箱を描かないため claude の「終端セパレータ→開始セパレータ」探索は空振りする=フレーム末尾の空行区切りブロックを下から最大4つ辿って ›(U+203A) 始まりのブロックを composer とする構造探索に切り替え、codex が › を使う他の 2 箇所は属性で弾く(送信済みメッセージの transcript エコー=グリフが dim、承認/model picker/hooks review の選択行=本文が bold)。誤検知のコストは #1879 当時の「バーが出ない」ではなく**「送信のたびにクリアが暴発し最終的に送信自体を拒否する」に上がっているため、判別できない形はすべて no_composer に倒す fail-closed 設計とし、placeholder(Ask Codex to do anything ほか 2 種)・承認ダイアログ・model picker・transcript エコーを ANSI 保持の実 capture fixture でテスト固定した(tests/unit/lib/detection/fixtures/codex-live-1890/、codex-cli 0.148.0 / 200x1000)。cursor_x による判別は採らない**ことを実測で確定:codex では残存があっても Home を押していれば空 composer と同じ 2 を返す。gemini/copilot/opencode/vibe-local/antigravity は従来どおり unsupported_tool のまま(まず codex 1 つで確定させる)。claude の挙動は不変(実機で #1880 のケース1・ケース3 と dim ゴーストの非発火を再確認)。
  • fix(send): /send が composer の残存文字列と本文を連結する(本文が黙って改変・消失し、成功と報告される) (#1880): sendMessageWithSubmitVerification は本文を TUI の現在のカーソル位置に素のキーストロークで挿入しており、composer に残存があると連結された 1 本のプロンプトが実行されていた。#1878 の実測で被害は 4 形(内容改変/スラッシュコマンド降格/カーソル行頭による順序反転/残存が / 始まりのとき Unknown command で本文が完全消失)、いずれも exit 0 / Message sent. / sessionStatus: ready を返すため呼び出し側(CLI・wait・orchestrate ワーカー)から正常送信と区別できなかった。本文打鍵の直前に #1879 の clearComposer()(C-e+C-u を読み戻し検証つきでループ)を挟み、上限内に空にできなければ打鍵せずに throw する(黙って握り潰さない)。破棄した内容は clearComposer() に追加した discardedText(最初の読み戻し値。remainingText は最終読み戻しなのでクリア成功時は常に空で監査に使えない)としてログに残す。claude 以外は従来どおり:extractComposerText が claude 以外を unsupported_tool に短絡する=cleared が常に false になるため、素直に「クリアできなければ失敗」とすると codex/gemini/copilot/opencode/vibe-local/antigravity が全滅する。ツール判定でクリア経路に入る前に return し、読み戻し capture もキー送出も発生させない(実測 codex 321ms は残存なし claude 330ms と同等)。no_composer(オーバーレイ表示中などで入力欄が画面に無い)も失敗と断定しない。実機(Claude Code v2.1.238 / Codex v0.148.0、200x1000 ペイン)で #1878 のケース1〜4・複数行残存・dim ゴースト・codex 退行なしを確認済み。
  • fix(polling): copilot の応答を History に保存し、バナー / ステータスバー / reasoning を保存しない (#1897): copilot instance に send した応答が 1 件も保存されず、代わりに起動バナー 2 件と Working esc interrupt GPT-5.6 Terra が assistant メッセージとして保存され、以後ポーラーが停止していた。原因は 4 つ。(1) extractResponse の完了判定が hasPrompt && !isThinking で、copilot の ❯ composer は生成中も描かれ、COPILOT_THINKING_PATTERN は 1.0.80 の描画に 1 件も一致しない(#1885 実測 44 枚 0 件)ため、生成開始 1 回目のポーリングで「完了」になっていた。checkForResponse は full-screen TUI では保存後に stopPolling するので、本物の応答は二度と探されない。判定を readCopilotStatusBar(...) === 'idle'(ペイン最下行 1 行の肯定的な idle 証拠。設計方針書 §4 D1 決定 1 の 2)に置換し、末尾窓 thinking ゲートを copilot で外した。(2) 起動バナー自体が「composer あり・idle バー」の完成フレームなので、最初のユーザー発言より前に応答として保存されていた。prompt echo が 1 行も無いフレームに限り COPILOT_BOOT_BANNER_ANCHORS を見て未完了に落とす。(3) copilot は claude と同型の下端 chrome(cwd 行 / ルール 2 本 / composer / ステータスバー)を持つが誰も切っていなかった。findCopilotChromeStart を新設し extractResponse と TUI accumulator の両方で切る(copilot 経路で保存本文を作るのは accumulator 側)。位置で切るのが要点で、copilot はステータスバーの語彙を応答本文として印字しうる(status-vocabulary-in-response.txt)ため語彙規則は同じ応答を消す。reasoning ブロック(⌄ Thought for 41s + │ …)も落とす — normalizeCopilotLine が U+2500..U+257F を先に消すので既存の [╭╮╰╯│] 規則は accumulator 経路で死んでいた。(4) cleanCopilotResponse が正当な散文を削っていた: COPILOT_TOOL_ACTION_PATTERN の約 110 語の英単語列挙が ● Check the config に当たり、しかもブロック開始だったので返答の残り全部が消えていた(1.0.80 の ● はエージェント発言のマーカーで、ツール行は $ <Tool>)。列挙を実ツール名に絞って単行 skip 化し、ブロックは空行で閉じるようにし、COPILOT_COMMAND_OUTPUT_PATTERN から find/go/make/cat/ls/cd/echo/node/python/ruby を外し、COPILOT_THINKING_PATTERN から素の Generating|Processing を外した(skip pattern 兼 liveness だったため「行が消える+ターンが永久未完了」の二重被害だった)。折り返した prompt echo の続き行も読み飛ばす。#1885 の実フレーム 7 枚で extractResponse + accumulator + cleanCopilotResponse を pin し、削るべきものが今も削れることを同じ suite で対に pin した(変異注入 14 件すべて赤を確認)。
  • fix(hooks): opencode の permission を「裁定してから記録」に直し、permission.replied を release として読み、後付け Auto-Yes と respond から裁定できるようにする (#1898): 隔離サーバで実測した 3 件をまとめて塞ぐ。(1) 承認後も waiting 固着 — ingest.ts が裁定の前にイベントを記録しており、recordAgentEvent が notification(permission_prompt) で prompt-waiting を開くため、Auto-Yes が同じ tick で once を送達した承認でも「人間が塞がれている」状態が開いた。解除は post_tool_use/stop しか無いので、sleep 8; pwd では 11:06:02.959 reply:once delivered:true の直後から 8 秒間 capture --json が waiting / hook_permission_prompt / isPromptWaiting:true を返し、その間 wait --on-prompt agent は exit 10、send は guard で拒否だった。順序を裁定 → 記録に入れ替え、AgentEventRecord に additive な promptSettled / decisionId を足して「この記録はダイアログを残さない」を状態機械へ渡す(記録してから裁定する順序に戻さないこと。窓が偶然閉じているだけの実装になる)。(2) permission.replied が未マップ — 6 ツールで唯一の「ダイアログが消えた」という肯定的証拠なのに 7 語のどれにも写していなかったため、端末で人間が答えた場合も同じ固着が起きていた。notification + 共有 detail permission_replied(agent-event-types.ts)へ写し、AgentSourceCapabilities.permissionReplyReleasesPrompt(#1924 の宣言値)を読んで解除する。agentEventToSessionStatus は null を返すのでstatus は決めない(scraper の判定は不変)。解除は properties.requestID(id ではない=実測)で照合し、別の承認への reply が別のダイアログの記録を消さないようにした。(3) 後付け Auto-Yes が裁定しない — 保留 permission の再取得が再接続時にしか走らず、ダイアログ待ちの状態で auto-yes --enable しても 30 秒以上無反応だった。recheckPendingDecisions() を新設し POST /api/worktrees/:id/auto-yes(enabled:true)から同期で呼ぶ(方針書 §4 D3 決定 3 の契機 (2))。対象は AgentSourceCapabilities.resync !== 'none' のソースのみ= hook 系 5 ツールは即 return する(hook の pending は route が握っている最中の要求で、再裁定は二重応答になる)。採用件数は 50 件で頭打ちにし超過は件数を露出(DR4-009)。応答に additive な pendingDecisions{examined,delivered,skipped} を載せ、commandmate auto-yes --enable が「Re-judged N pending approval(s)」を出す。(4) respond 不能 — /prompt-response は pane を再キャプチャして detectPrompt を回すため、opencode の承認ダイアログは常に prompt_no_longer_active(exit 99)で、端末で直接キーを押すしか無かった。AgentSourceCapabilities.eventIdentity が id を宣言するソースでは、pane を触る前に GET /permission の保留分を読み respond <id> 1|2|3/"Allow once"/once|always|reject/yes(=最も狭い allow の once)を verdict に解決して POST /permission/:id/reply で送る。キーは 1 つも送らないので #1681 の「非数値 respond が Enter に化ける」罠には当たらない。--default は拒否する(ワイヤ上に既定 verdict が無く、当て推量で承認することになる)。decisionId はクライアントから受け取らない — 解決済み target の保留分から選ぶので、別 instance / 別 worktree の id は表現すらできない(DR4-003 / S6 を構造で満たす)。あわせて wait の exit 10 出力に options(1=Allow once / 2=Allow always / 3=Reject) を載せた(promptData.decisionOptions として additive に公開。options は空のまま=画面の番号を打つ経路には渡らない)。capture --json には structuredEvents.permissionDecision(#1902 と同型の露出のみ・理由コードつき)を additive 追加し、無人で下された allow が「どこにも見えない」状態を無くした。空振り緑でないことは変異注入 7 種で確認(記録→裁定の順序へ戻す/permission.replied の写像を外す/decisionId 照合を外す/resync ゲートを外す/eventIdentity ゲートを外す/current-output の capability ゲートを外す/wait の decisionOptions フォールバックを外す)。いずれも該当テストが赤になる。回帰は実機 SSE 列(permission.asked → reply → permission.replied → tool part running/completed → session.idle)を購読層ごと流して各時点の getStructuredPromptWaiting を pin し、session.idle の後に届く message.updated(role:user) の再送が turn-gate に吸収されて何も再開しないことも同じ列で固定した。Issue 本文からの意図的な逸脱: 期待列の message.updated(assistant) は採取済み fixture に無く、info.role === 'user' 以外の message.updated はどの語にも写らないため、同区間で実際に採取されている tool part フレームで代替した。

Security

  • fix(security): EXCLUDED_PATTERNS に載る秘密ファイルを files ルートのパス直指定でも読み書きできないようにした (#2014): EXCLUDED_PATTERNS(src/lib/file-tree.ts)はツリーの一覧から隠すだけで、/api/worktrees/:id/files/:path はそれを見ていなかった。実測(develop @6696c4bb)では GET /files/.env が 200 で生の値を返し、?download=1 の生バイト分岐・server.pem / private.key / .git/config(remote URL にトークンが載る)も同様に通り、さらに PATCH {action:'rename'} で .env を leaked.md に改名してから読む 2 リクエストの迂回、PUT /files/.env.yml(.yml は編集可能拡張子)による秘密ファイルの上書き、POST /files/.env の作成、DELETE /files/.env の削除、POST /upload/... .env.yml(拡張子 allowlist だけでは止まらない)まで通っていた。EXCLUDED_PATTERNS を丸ごと 403 にすると node_modules の読み取りまで巻き添えになるため、新設した src/lib/security/sensitive-file-guard.ts で全パターンを 2 層に分類した — 拒否層(.env / .env.* 系・*.pem / *.key・.git=中身が資格情報そのもの)と非表示のみの層(node_modules / .DS_Store / Thumbs.db=機密ではなく量とノイズのための除外で、読み取り挙動は従来どおり)。拒否は GET だけでなく getWorktreeAndValidatePath に置いて GET/PUT/POST/DELETE/PATCH 全メソッドと upload ルートに効かせ(改名迂回があるため読み取りだけ塞いでも無意味)、PATCH の改名先・移動先も同じ規則で塞いで「API が作れるのに管理できないパス」を作らない。照合はツリー側と同じマッチャを共有しつつ拒否層だけ大文字小文字を無視する(macOS/Windows は .ENV で .env が開くことを実測。ALLOW list である Env Manager が case-sensitive なのとは逆方向が安全)。2 層が EXCLUDED_PATTERNS を過不足なく分割していることは単体テストが固定し、パターンを追加すると分類するまで赤になる。#1968 の Env Manager は専用 allowlist と 3 層パス検証を通る別経路で、従来どおり .env を提供する(統合テストで両面を同時に固定)。