Repository navigation
v0.27.0
[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:cliTS2307)。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()に実在することを確認してから適用し、無ければ 404decision_not_foundで何も送らない。別 instance / 別 worktree の id は「拒否する対象」ではなくそもそも見えないので、forbiddenではなくnot_foundを返す。decisionIdは外部由来なので 256 文字・^[A-Za-z0-9._:-]+$で検証し、違反は破棄(切り詰めない) — 切り詰めた id は前方一致する別の id と衝突し、不正リクエストが「別の承認への送達」に化けるため(§10 DR4-001)。ソースに到達できず所属照合ができないときは 502decision_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_TOOL1 件のみ。増加は赤・減少は緑。製品コードの挙動は変えていない(テスト 1 ファイルの追加のみ)。Issue 本文の「36 箇所 / 19 ファイル」は'claude'の素の grep 値でcase/===/ 許可リスト配列 / コメントを含むため AST 実測とは一致しない(本 commit で測り直した)。Issue 本文後半の「i18nno-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-server1043ms → 228ms(-78%)、cli-tools/manager1025ms → 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/managerstub は、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):
/orchestrate2-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のexecFileprobe はPATHを歩いて実行可能ファイルを絶対パスで特定してから起動し、解決できなければ子プロセスを 1 つも起動せずに probe をスキップする(detector.stalenessにその行が出ない)。copilot はresolveCopilotExecutable()へ委譲し、gh copilot -- --versionは使わない — 未インストール環境で CLI のダウンロードを起こすため(#1907 / #1979 の実測)。probe はsanitizeEnvForChildProcess()の env・timeout5000ms・maxBuffer64KiB で走る。 - 状態検出をツール別モジュールへ分割し、「否定の不在 → 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 外。developa175767aで実測)。§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 優先に変わり、矛盾時は 400instance_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_IDS7 件・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を severityerrorで入れ、既存 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(develop90b67eb9)から中身が 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。機構(vitestglobalSetupでロックを取る)は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は developb982fb88で 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・cleanupRoomsexport の削除、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-session996ms)は、循環を切れる辺がいずれも本 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→wait5 回中 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-sessionroute が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 では 400instance_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 件を確定し、欠落していたdebugdiffinitmcpsmovereviewskillsstatusvariantsを追加、パレットに存在しない/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 名にあることが分かったのでexitloginlogoutfeedbackskillsinitagentpluginmemoryappdebugを<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>' ]を発火時に見て無ければ inlinecurlへ落ちる。${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_NAMES1 箇所に集約し、不在検査の正規表現も実在検査(#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 のresynccapability(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のSessionschema で確認)は名乗れない。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 の別プロセスがstop1 本で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+ 共有 detailpermission_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を提供する(統合テストで両面を同時に固定)。