Skip to content

v0.28.0

Choose a tag to compare

@Kewton Kewton released this 28 Aug 07:13
· 36 commits to main since this release
fff17df

[0.28.0] - 2026-08-28

Highlight: opencode を「動かせるが観測できない」状態から引き上げた版。TUI のダイアログ・サイドバー・共有リンク・instance 別設定を CommandMate 側から扱えるようにし、検出器が ready と誤答してオーケストレーションが空振りする経路を 2 つ塞いだ(#2095 / #2112)。あわせて実機 UAT(Epic #2002)で Web Push が iOS にだけ 1 通も届いていなかったことを発見して修正し(CM_VAPID_SUBJECT の既定値が APNs に拒否されていた・#2124)、設定手順そのものが利用者向けに存在しなかった問題(#2123)と、未設定・不正・不達がすべて無音だった問題を 1 つの起動時セルフチェックで可視化した。

Added

  • test(quality): Epic #2055 の子 Issue 17 件について、受入条件を守るテストが空振りしていないことを変異注入で確認した (#2103): Epic #2055 の受入条件「各子 Issue の受入条件が機械可読なテスト(変異注入で赤になることを確認済み)で固定されている」は Epic を閉じた時点で 24 件中 7 件しか満たしていなかったので、残る 17 件(#2032 / #2034 / #2035 / #2036 / #2037 / #2038 / #2042 / #2043 / #2045 / #2046 / #2047 / #2048 / #2049 / #2051 / #2052 / #2053 / #2054)に構造を保つ変異(値の入れ替え・境界のずらし・条件の反転のみ。行やブロックの削除はしない)を 1 件 1 箇所ずつ当て、CI=true NODE_ENV=test npx vitest run の exit code で裁定した。結果は docs/design/epic-2055-mutation-sweep.md の表(当てた変異のファイル:行 / 対象テスト / 赤になったファイル名と件数)に全 17 件ぶん記録した。赤が変異のせいであることは、変異を git checkout -- で戻して md5 が事前ベースラインと一致することを確認したうえで同じコマンドを回し直し、緑になるところまで見て初めて言える — このマシンでは負荷起因の赤が実際に出ている(2026-08-27 実測: develop のフル test:unit で db-migration-path.test.ts の 2 件が Test timed out in 5000ms、orchestrate-monitor/monitor-exit-codes.test.ts の 1 件が stderr 空で赤。いずれも単独では緑)ので、対照を取らない赤は証拠にならない。18 本の変異すべてで対照は緑・復元は md5 一致・git status は空だった。空振りは 1 件見つかった: #2037 の第 1 受入条件「matrix の opencode 行が実測値になり証跡へのリンクがある」を守るテストが無く、src/lib/skills/compatibility-matrix.ts の opencode 行の discovery.outcome を verified → unsupported に入れ替えても(labelKey を対応させて既存の整合性不変条件を満たしたまま)tests/unit 全体が緑だった。既存の compatibility-matrix.test.ts は claude / codex の 2026-07-26 測定値だけを値で固定しており、#2037 が足した opencode 行に対して動いたのは unmeasuredAgents() からの消失だけ — これは verified でも unsupported でも等しく真なので測定値を守っていない。tests/unit/lib/skills/opencode-matrix-measurement-2037.test.ts(9 件)を追加して両軸の outcome / evidenceKind / labelKey / limitation / roots / 版 / 測定日 / 派生 support / 証跡リンクを既存 pin と同じ書き方で固定し、同じ変異で赤になることを確認した(既存テストは 1 行も弱めていない — assertion の削除・skip・期待値の実測合わせはしていない)。Issue 本文の前提との食い違いが 2 件: (1) #2053 は「不採用の裁定 pin なので変異注入が意味を持たないかもしれない」とされていたが、裁定は configScope: 'none' という 1 個の値として production に在り、per-instance へ入れ替えると opencode-config-scope-2053.test.ts が赤になる=意味を持つ。「意味を持たない」のは production の変更がゼロの #2052 だけである。(2) #2052 が置いた tests/fixtures/opencode-web-2052/** 10 ファイルはリポジトリのどのテストからも読まれていない(grep の一致は fixture 自身のみ)。証跡アーカイブとしては正しいが、tests/ 配下にあるため「テストが読んでいる」と誤読されうる。production の変更は 0 行(src/** と server.ts は本 Issue の scope.deny)。
  • feat(cli): commandmate interrupt で生成中のターンを CLI から中断できるようにした (#2101): Web 側の POST /api/worktrees/:id/interrupt(Issue #46、#2034 で opencode は POST /session/:id/abort が一次経路)に対応するサブコマンドが無く、stop はサーバ停止のため、暴走したターンを止めるには curl か tmux アタッチしかなかった。--instance <id> で送り先を選べ(--instance 省略時は「既定エージェント」ではなく稼働中の全セッションが対象。wait と同じ理由で --agent は持たない)、--json は success / message / interrupted[] をそのまま返す。終了コードは中断成功 0 / 稼働中セッション無し 30(InterruptExitCode.NO_ACTIVE_SESSIONS。ルートは「worktree が無い」も「稼働セッションが無い」も同じ 404 かつ機械可読な code を持たないため、CLI 側でメッセージを見て 99 と振り分ける)/ インスタンスを解決できない 2。隔離 HOME・私設 tmux socket・隔離 dev server で実測し、opencode 1.18.23 の生成中(sessionStatus=running / isGenerating=true)に撃って opencode-interrupt-aborted-via-api を通り(Esc×2 フォールバックではない)ready / isGenerating=false に戻ることを確認した。
  • feat(ui,hooks,report): opencode セッションの共有リンク発行 / 取り消しと export --sanitize の添付を CommandMate から使えるようにする (#2051): opencode 限定の「共有リンクを発行」ボタン(POST /session/:id/share)を確認ダイアログつきで composer 脇に追加し、DELETE による取り消しと発行済み URL の表示・コピーまでを OpencodeSessionControls に載せた。1.18.22 の実測で share: "disabled" の拒否は「コードを持たない HTTP 500」(本当の理由はサーバ側ログの Sharing is disabled in configuration にしか出ない)だと分かったため、撃ってから判別する経路は成立せず、新設した GET /api/worktrees/:id/opencode/share が GET /config を先に見てボタン自体を出し分ける。GET /config は share を設定したときだけキーを返すので「キー不在」は disabled と同一視せず、ゲートは === 'disabled' のみ。取り消しは実測でページ内容は消えるが HTTP ステータスは 200 のまま(Not Found は SPA 描画)で、しかも session.share は DELETE 後もサーバ再起動を跨いで残るため、ルートは当該 URL を「かつて公開した記録」として lastShareUrl の名前で返し DELETE の応答には含めない。opencode export --sanitize は日次レポートの ## opencode session transcripts (sanitized) 節と /create-pr Phase 4-2 に繋いだが、実測で --sanitize は利用者の発話もエージェントの応答もツール入出力も全て伏せ字にする=会話が 1 文字も残らない(残るのは ID・時刻・トークン数・コスト・model / agent 名・ツール名・projectID)ため、生 JSON ではなく「セッションの形」の要約表として添付し、添付前に auditOpencodeExportRedaction() が実測済みの全機微フィールドを検査して素のテキストが残る export は捨てる。節の生成は 1 日あたり 20 セッション・合計 90 秒で打ち切り、打ち切った分は件数を節に明記する(1 回 30 秒 × 20 件が全部タイムアウトすると、本文生成が終わったあとのレポートに 10 分積み増すため)。#2038 が locales/ 非 scope のため直書きしていた 3 ラベルも worktree.opencodeSession.* へ移設した。
  • feat(hooks,ui,session): opencode のイベント購読が生きているかを状態に載せ、ヘッダチップとロスター行に出す (#2054): Phase 4 が用意した AgentEventSource.liveness() / probeActivity() に本番の呼び出し元が 1 つも無かったため、opencode のポートが別プロセスに奪われて port_identity_changed が立っても、画面上はどこからも観測できなかった(degradeToScraper が理由を立てた次の行で購読を Map から削除しており、以後 getOpencodeLiveness は unknown を返していた)。落とした購読の最後の liveness を残すようにしたうえで、structuredEvents.source に kind(sse / hooks / scraper)・degradedReason・liveness・probedActivity を additive に追加し、worktree-status-helper の CliToolSessionStatus.eventSource として同じ畳み込み(describeAgentEventSource() 1 箇所)を publish、ヘッダピルの tooltip と AgentInstancesPane の警告行に描く。ハートビート断(30 秒=実測 10 秒周期の 3 拍落ち)は stale。購読型ソースを持たない claude / codex 等は kind 以外を publish しないので、チップも roster 行も #2054 以前とバイト単位で同一。probeActivity は attach 直後の 1 回だけ呼ばれ、ターンの途中で開いたストリームが最初のフレームまで無言な区間を埋める。実測は opencode 1.18.22・隔離 HOME・専用ポートで実施(docs/design/opencode-server-live-verification.md §25)。
  • docs(design): opencode 同居 Web UI を External Apps proxy 越しに開けるかを実測し、「不可」と原因を確定 (#2052): opencode 1.18.22 の --port つき TUI / opencode serve / opencode web はいずれも同一の Web UI(2884 B の SPA シェル+2.7 MB バンドル、HTML も JS も 3 者バイト一致)を配り、素の origin では起動してコンソールエラー 0 件、TUI と双方向に live 同期する(ホームの「最近のセッション」一覧だけは他所発のセッション追加を live 更新しない)。しかし /proxy/<prefix>/ 越しに開くと真っ白になる。原因は 1 点で、proxy が /proxy/<prefix> を剥がさない(buildUpstreamUrl は無加工転送。実測で upstream は receivedUrl:"/proxy/echo/hello?x=1" を受ける)一方、SPA の参照が絶対パスのみ(/assets/… の文字列リテラル 10 件は全て絶対、相対 ./assets/… は 0 件、BASE_URL 相当の間接も 0 件)で、opencode 側に basePath 設定が存在しない(GET /doc の ServerConfig は port/hostname/mdns/mdnsDomain/cors の 5 キーのみ)ため。Issue 本文とブリーフィングの「proxy は WebSocket を運べない」は誤りで、handleProxyUpgrade()(#671)が raw TCP pass-through で通すことを往復実測(陰性対照: websocketEnabled=false→403 / 未知 prefix→404)、SSE も 500 ms 刻みでバッファ無しに流れることを実測したので、WS も SSE も CORS も不可の原因ではない(UI が要る transport は GET /global/event の SSE 1 本で、new WebSocket は組込み PTY 専用)。あわせて OPENCODE_SERVER_PASSWORD はユーザ名 opencode 固定の HTTP Basic で、proxy が authorization を剥がすため proxy 越しには 401 になること、--cors 無しの許可 origin は loopback のみであることを実測した。記録は docs/design/opencode-server-live-verification.md §24、一次証拠は tests/fixtures/opencode-web-2052/。
  • feat(cli-tools,ui,tmux): 特殊キーの許可集合を ICLITool.navigationKeys() のツール別宣言にし、opencode 用クイックキー列を PC / モバイルに追加 (#2046): POST /api/worktrees/[id]/special-keys が全ツール共通の 1 本の語彙ではなくリクエストされたツール自身の宣言で検証するようになった(claude / codex / copilot / gemini / antigravity / vibe-local の集合は BaseCLITool の既定実装で従来と差分ゼロ、NAVIGATION_KEY_VALUES ⊆ ALLOWED_SPECIAL_KEYS 相当の #2032 不変条件はレジストリ全体に量化した形で観測可能)。opencode だけが leader C-x と chord letters と C-p / C-t を宣言し、OpencodeQuickKeys(PC の split footer とモバイル端末タブで共有)が tab / shift+tab エージェント切替・ctrl+p コマンドパレット・ctrl+t variant・leader chord 9 種・PgUp / PgDn / Home / End を送る(chord は 1 リクエスト 2 エントリの逐次送出。実測 leader_timeout 2000ms に対し本番の 100ms 間隔で 3/3 成立)。ctrl+x b(サイドバー)は実測の結果あえて公開していない — 既定 80 桁でも明示トグルは #2047 の 121 桁ゲートを無視してサイドバーを出し、その frame は ready/opencode_response_complete から running/unknown_frame に反転して assistant の返信としてサイドバー本文が保存され、Escape でも戻らない。初回ターン前は leader が letter を食わず composer に literal が入るため、ctrl+x u/r/c/g はセッション成立まで disabled にした。実測は opencode 1.18.22 を隔離 HOME と専用 tmux socket で駆動したもので、詳細は docs/design/opencode-server-live-verification.md §22。
  • feat(config,ui): opencode のペイン幅を CM_OPENCODE_PANE_WIDTH で設定可能にし、既定 80 の根拠を実測で確定 (#2047): lib/cli-tools/opencode.ts に 2 箇所ベタ書きされていた幅 80(新規作成の resize-window と再接続の reconcileExistingSession)を OPENCODE_PANE_WIDTH / resolveOpencodePaneWidth() に一本化し、両経路とも同じ設定から読むようにした(片方だけだと「新規は 200 / 再接続は 80」がサーバ再起動後にしか現れない形で残る)。あわせて opencode 1.18.22 を隔離 HOME・専用 tmux socket で実測し、121 桁以上でサイドバーが transcript と同じ行に描かれる(120 桁以下では出ない、1 桁刻みで両方向に再現)ことを確定。同一セッションを 80 / 120 / 200 桁で採った 13 フレーム × 3 幅の fixture で再検証したところ、120 桁は全フレームで verdict が 80 桁と一致した一方、200 桁では ①sliceOpenCodeTurn がサイドバーを assistant の返信として保存し ②中断ターンの ready が running に反転し ③セッションタイトルが OPENCODE_IDLE_COMPOSER_PATTERN を誤成立させたため、既定は 80 のままとし 120 を計測済みの安全上限として記録した。モバイル端末ビューは #2049 のポリシー宣言に mobileWrapMode を足し、opencode のフレームを再折返しせず実測桁数のまま横スクロールさせる(表示のみ)。
  • feat(ui,hooks,db): opencode の agent / model / variant を instance ごとに設定し、起動行と送信プロンプトに反映してヘッダに出す (#2048): instance 設定に agent / model / variant を追加し、候補を GET /config/providers(provider / model / model ごとの variants)と GET /agent(mode: primary かつ非 hidden の 2 件)から取得する(opencode 未起動時は自由入力)。agent / model は launch plan に --agent / --model として載せ、--variant は載せない — 1.18.22 の TUI に --variant は無く、渡すと usage を出して終了しペインが空になる(opencode run だけが持つフラグ、設計書 §20.3)。variant は POST /session/:id/prompt_async に載せて適用する。あわせて、agent を載せない prompt_async が --agent plan のペインでもそのターンを build で走らせていた #2035 以来の欠陥を、設定済み instance について解消した(§20.5)。表示側は message.updated.info.variant / Session.model.variant を effort ラッチへ記録し、mergeModelInfo が「エージェント自身の申告」を画面スクレイプと antigravity の id 推定より優先することで、ヘッダ / instance pill が agent · model · variant になる(opencode はペインのどこにも variant を出さないので、この経路が唯一の情報源)。
  • feat(ui,config): opencode の端末ビューに空行圧縮を適用し、picker / palette のパネル帯を保持する (#2049): Issue #1172 の表示専用圧縮(compactTuiLayoutPadding)は claude / codex 限定だったので opencode を追加したうえで、「桁を塗っていて背景色 SGR を持つ空行」はパネル本体=構造として畳まない規則(src/lib/terminal-display-normalize.ts)を新設した。opencode 1.18.22 の実キャプチャで測ると ctrl+p の command palette は 70 桁のスペースを ESC[48;2;20;20;20m で塗ったグリフの無い帯を 8 行持ち、素直に #1172 を適用すると 7 行しか残らずパネルの上端が消える(実測: 201 行 → #1172 は 57 行/帯 7 本、#2049 は 58 行/帯 8 本)。composer の ┃ と ╹▀▀▀ 区切り・承認ダイアログは ┃ を持つのでそもそも空行ではなく、どちらの規則でも欠けないことを実キャプチャで assert した。あわせて PC(TerminalSplitPaneContent.tsx)とモバイル(MobileTerminalTab.tsx)に手書きで 2 重宣言されていたツール列挙を src/config/terminal-display-compaction.ts に集約し、画面によって同じセッションの見え方が変わる形を塞いだ。
  • feat(canary,detection): 検出カナリアに opencode の 5 シナリオを追加し、--version probe で版ずれを検出する (#2050): npm run canary に --tool を入れ、scripts/canary/tool-profiles.ts が geometry / capture 行数 / 起動完了行 / 起動フラグ / 起動オーバーレイをツールごとに持つようにしたうえで、opencode の陽性分岐 A0/A/C/D/E に 1 本ずつ(idle composer / esc interrupt / Allow once Allow always Reject / /models picker / duration 付き完了マーカー)のシナリオを本番の 80x200 geometry で追加。各期待値は本番 verdict(evidence 含む)と、検出器が読む行とは別の行による構造的事実の 2 つを独立に assert する。preflight の <tool> --version を parseCliVersion で読んで DETECTOR_VERIFIED_AGAINST と突き合わせ、版ずれを常に報告する(既定は警告のみ、--strict-version で exit 5)。opencode 1.18.22 で 5 シナリオ緑 / --mutate で 5/5 赤 / OPENCODE_PERMISSION_PATTERN の 1 ラベルを壊すと実際に赤になることを実測し、OPENCODE_VERIFIED_AGAINST を 1.18.22(2026-08-26 / 80x200)へ更新。使い捨て HOME は opencode の auth.json を mode 600 で複製し、XDG 変数を子環境から落とし、実 ~/.config/opencode/* と opencode.db を各シナリオ前後で照合する。
  • feat(push,hooks): opencode の質問・セッションエラー・更新通知を push に繋ぐ (#2045): opencode は question.asked のあと session.idle を出さず(1.18.22 実測: 20 秒後も GET /session/status は busy)、このリポジトリには質問を自動応答する経路が無いため、待ちは人間が触るまで無期限に続く。waiting 系 push は status プローブが観測する waiting edge から上がるので「誰かが画面を見ていたら鳴る」形になっており、これでは間に合わない。そこで sources/opencode/push.ts を新設し、opencode ingest からのみ(notification/error の汎用フックにはしない)notifyPushSubscribers を呼ぶ。question.asked は kind:'prompt' + waitingSince = frame の receivedAt で送るので、後から status プローブが同じ待ちを報告しても shouldSendWaitingPush が重複と判定して 2 通目にならない。session.error は kind:'failure' + 新 reason agent-session-error(upstream-fault を再利用しない — 1.18.22 の error.name 8 種のうち上流由来は APIError / ProviderAuthError の 2 つだけで、残りはローカル失敗)で、interrupt である MessageAbortedError は通知しない。7 語に写像されない installation.update-available は subscription.ts の deliver() で mapping 前に読み、kind:'completion'(対応不要=情報バケツ)で接続あたり version ごとに 1 回だけ通知する — フレームは { version } だけで sessionID を持たない(GET /doc 実測)ため、写像できる単位が接続しかないため。claude / codex の発火回数が変わっていないことは実ルート POST /api/hooks/agent-event を通した統合テストで固定した。
  • feat(ui,hooks,verification): opencode の「このターンの変更ファイル」パネルと revert / unrevert を追加する (#2043): opencode 限定のパネルを composer 上に出し、ファイル名クリックで既存 DiffViewer を開き、確認ダイアログつきで POST /session/:id/{revert,unrevert} を撃てるようにした。Issue 本文の前提は実測で崩れており、1.18.22 の session.diff は「このターンの変更ファイル」ではなく**「revert がせき止めている変更」である(ファイル編集を伴う 2 ターンで観測した 8 通すべてが diff: []、session.idle と同一ミリ秒のフレームすら空。revert 直後に初めて非空になる)。そのためターンのファイルは GET /session/:id/diff?messageID=<user msg> から取り、session.diff は Session.revert と併せて「せき止め分」として保持する構成にした(structuredEvents.sessionDiff は session / sessionContext に次ぐ 3 つ目の additive キー)。revert は存在しない messageID でも 200 を返し、既に revert 中ならその既存 revert をそのまま返す**ため、revert.messageID が要求した id と一致するかで成否を判定している。work-evidence には opencode を名指しした run に限り第 2 証跡を additive に足した(git が何も見つけなかった分岐のみ、他ツールの判定は不変)。実測は docs/design/opencode-server-live-verification.md §16。
  • feat(report,cli-tools,db): opencode を日次レポートに参加させ、コスト台帳と CMATE.md の run オプションを追加 (#2044): SUMMARY_ALLOWED_TOOLS に opencode を末尾追加(既定は claude のまま)し、executor を opencode run --format json … に変えて extractOpencodeFinalText() が JSON イベント列の最後のアシスタントメッセージの text を本文にする(--format default の装飾除去には依存しない。ツール呼び出しを挟む実行では assistant message が 2 通になり、1 通目は「ツールを呼ぶと決めた」側なので messageID で束ねる)。migration v58 の agent_session_costs は #2040 が in-memory で持つ {cost, tokens} を session_id を PK に上書きで写し取る — opencode 1.18.22 実測で Session.cost はセッション累計であり、加算すると同じ累計を何度も足すため(各セッション累計の和が opencode stats --project "" の Total Cost $0.07 / Input 6 / Output 181 / Cache Read 8.4K / Cache Write 16.7K と一致することを確認済み)。日次レポートには worktree 別の「Agent session cost」節を生成後に決定的に追記する(AI には渡さない=buildSummaryPrompt は不変で claude / codex の生成結果は従来どおり、台帳が空の日は節ごと出ない)。CMATE.md の CLI Tool 列は opencode に限り --model / --agent / --variant / --continue / --title のフラグ列を受け付け(重複フラグ・値欠落・未知フラグは行スキップ、空白入り --title は引用符で括る)、job-executor.resolveScheduleExecuteOptions()(旧 resolveModelOption())が列の解釈を resolveScheduleCommandOptions() へ委譲することでスケジュール実行の argv まで届く(vibe-local の DB 由来モデルの解決と、claude / codex / gemini / copilot / antigravity / vibe-local の起動引数は不変)。
  • feat(hooks,ui): opencode の会話履歴を SSE / GET /session/:id/message から生成し、port 接続中は scrape を止める (#2041): opencode の返答をエージェント自身の Markdown 原文として chat_messages に保存する writer(lib/hooks/sources/opencode/transcript + history)を追加し、購読が live の間は response-checker の scrape 保存を停止して 1 ターン 1 行にした。1.18.22 実測(隔離 HOME・3 ターン・142 フレーム、設計書 §13)で Issue 本文の前提 2 つが崩れており、本文は message.part.updated の開始(text:"")と終了(全文)の 2 回にしか乗らず増分は 1.18.3 の fixture に無い message.part.delta 95 件に乗るため delta は 1 件も読まず part id の last-write-wins にして再送を構造的に冪等化し、1 ターンが assistant メッセージ 2 通になりうる(finish:"tool-calls" → "stop")ため turn key を messageID ではなく parentID にした。再接続は server.connected 1 件しか replay しない(実測)ので GET /session/:id/message で未保存分を補完し、/session/status が静かなサーバで {} を返す(実測)ため対象 session は turn-gate → #2038 の永続化 id の順で解決する。request_id に oc-turn:<parentID> を刻んで冪等キーと Markdown 描画マーカーを兼ねさせ、migration は追加していない(#2044 の v58 と衝突するため)。ConversationPairCard は当該行だけ既存の .assistant-md レンダラで描画し、scrape 行は従来どおり verbatim のまま(rehype-raw は使わない — 素の <T> が消えるため)。
  • feat(ui,hooks): opencode の agent / model / cost / コンテキスト使用率をヘッダに常時表示する (#2042): pane header に build · claude-sonnet-4.6 と $0.03 · 8.5K (1%) の 2 chip を、desktop header の instance pill には(幅予算を動かさないため)tooltip として描画する。使用率は structuredEvents.session.tokens の合計では出せない: Session.tokens はセッション累計(opencode stats が印字する値)で、opencode 自身のフッタが出す (1%) は直近 assistant メッセージ 1 通が母数であり、実測 2 ターンで 16,999 対 8,508 と乖離する(合計すると opencode が 1% と出す場面で 2% と表示し、ターンを重ねるほど開く)。そこで GET /session/:id/message?limit=4 と GET /config/providers を読む派生レコード structuredEvents.sessionContext を新設し、opencode 1.18.22 の bundle から転記した Math.round(tokens / limit.context * 100)(分母は limit.input ではない)で算出する。2 回の HTTP はポーリング経路の前に置かず、stale 判定を record.at にして 1 ターン 1 回に抑える。capture --json にも同じ値が出る。claude / codex は値が無いので描画は 1 バイトも変わらない。
  • feat(cli-tools,session): opencode のメッセージ送信を POST /session/:id/prompt_async 一次・キーストローク fallback にし、画像を実ファイルとして添付できるようにする (#2035): opencode 1.18.22 の実機計測で、/tui/append-prompt → /tui/submit-prompt は composer を経由するため 本文の先頭トークンが実在コマンドに前方一致するとコマンドパレットが submit を食い、200 true を返して 1 件もメッセージが作られない(/exit で再現)ことが分かったため、composer を経由しない prompt_async を採った。messageID を CommandMate が採番して GET /session/:id/message/:id で読み戻し、送った本文と text part が一致したときだけ送信成功と見なす(204 は「受理」であって「届いた」ではなく、file:// を欠いた file part は 204 の後にメッセージ全体が破棄されることを実測)。ポート未割当・購読が live でない・初回ターン前で session 未確定・POST 拒否・読み戻し不一致はすべて従来の sendMessageWithSubmitVerification に落ちるので、--port を知らない旧版や CM_AGENT_HOOKS_INJECT=0 の pane は #2035 前と同じ経路で送信できる。画像は supportsImage(): true + sendMessageWithImage() で file part として送られ(実モデルが画像を読んで回答するところまで確認)、サーバ経路が使えない pane では従来どおり [添付画像: <path>] に劣化する。
  • feat(slash-commands,skills): opencode のパレットを実行中セッションの GET /command から補い、Skill 発見を 1.18.22 で実測して記録 (#2036, #2037): opencode session のパレットが、その worktree の opencode server が持つ command registry(.opencode/commands/*.md と発見済み Skill、説明と引数ヒントつき)を catalog に足すようになった。取得は process cache 読み+背景更新で、パレット打鍵は loopback へ 1 度も await しない(#1913 §4 D2)。catalog はオフライン fallback として据え置き、live 側は catalog 既出の名前を上書きしない(上書きすると /init /review の訳文が英語直書きに戻るため)。あわせて opencode 1.18.22 を隔離 HOME で実測し(docs/design/opencode-server-live-verification.md §12)、(a) GET /command は markdown command と Skill だけを載せ TUI 組込み 16 個を載せないこと、(b) .agents/skills / .claude/skills(CommandMate の install root)を含む 6 root の Skill を opencode が発見し /<name> 送信で実行できるが opencode 自身のパレットには出ないことを確認して、compatibility-matrix.ts の opencode 行を unmeasured から実測値へ、attestation を 1.18.21 → 1.18.22 へ更新した(18 個の名前は不変。/compact は陽性対照 /status・陰性対照 /zzzznotacommand つきで再計測し、Enter が /review に化けることまで確認して除外を維持)。CommandMate のパレットが install 済み Skill を opencode session にも供給する(#1504 と同型)。
  • feat(cli,hooks): respond <id> <n> を構造化 decision に写像し、capture --json に session コスト / decision kind を additive 追加 (#2040): エージェントが decision ごとの id を publish する場合(capabilities.eventIdentity !== null=現状 opencode のみ)、commandmate respond <worktree> 3 が pane にキーを送らず、そのインスタンスが保持している decision へエージェント自身の API (承認なら POST /permission/:id/reply の once/always/reject、question なら POST /question/:id/reply の選択肢)で答えるようにした。回答するのは保持している decision がちょうど 1 件のときだけで、0 件は 404 decision_not_found、2 件以上は 409 multiple_pending_decisions(開いている id を返す)で止め、どちらも pane に何も送らない — 番号は「どれかの一覧の中の位置」であって呼び出し側はどの一覧かを言っていないので、最も古いものへ当てにいくと利用者が見てもいない承認に答えうる(answerStructuredDecision の「最古を採る」tie-break は keystroke に fallback できる経路のものであって、verdict を POST する経路のものではない)。送り先の判定はサーバが申告した capability を読む(ツール名の一覧を CLI に持たない)ため respond は送信前に current-output を 1 回読み、読めなければ従来経路に fail-open する。eventIdentity: null の claude / codex / gemini / copilot / antigravity と --default は現行どおり /prompt-response 経由のキー送出(--default は TUI のハイライト位置が wire に出ないため構造化経路が拒否する一方、keys ダイアログへの Enter は実際に答えになる)。あわせて capture --json の structuredEvents に、opencode の session.updated(購読済みストリーム上のフレームで、7 つのイベント語のどれにも写像されない)から読んだ session: { id, title, agent, model, provider, cost, tokens, at } と、保持中 dialog の pendingDecisions[].kind(permission / question)および question の questionOptions(エージェント payload の並び順=respond <n> が解決に使う番号)を追加した。追加はすべて additive で、既存キーの型・意味・有無は不変(.claude/skills/orchestrate-monitor のパーサ 27 suite / 449 test 緑)。sub-agent の session.updated(parentID を持つ)は無視し、記録は subscription の close で破棄する。
  • feat(session,cli-tools): opencode の sessionID を永続化し、再起動時に -s <id> で前セッションを復元する (#2038): killSession が /exit を送る前に、そのインスタンスが居たセッションを opencode 自身の GET /session/:id で照会して ~/.commandmate/opencode-sessions.json に記録し(Session.parentID を根まで辿るので sub-agent の turn を会話と取り違えない)、次の非 reuse 起動で opencode … -s <id> を launch plan の末尾に足す。記録された Session.directory が起動先の worktree と一致しない場合は使わない(ports.ts の worktreePath 照合と同じ守り。opencode のセッションはサーバではなく HOME の DB に属し、実測でディレクトリ A で起動したサーバが GET /session でディレクトリ B のセッションを返したため、これが無いと別リポジトリの会話を復元しうる)。UI には opencode 限定で「新規セッション」「セッション一覧(POST /tui/open-sessions)」「fork(POST /session/:id/fork → select-session)」を composer 脇に出し、commandmate instances は SESSION_ID / SESSION_TITLE 列と --json の sessionId / sessionTitle を末尾に追加する。claude / codex ほかの起動引数は 1 文字も変わらない(resume flag は OpenCodeTool.launchSession だけが合成する)。
  • feat(hooks,ui): opencode の question.asked を構造化回答(POST /question/:id/reply)で答えられるようにし、PromptPanel に選択肢ピッカーを出す (#2039): structured-decision-response に questionDecisionOptions() / resolveStructuredQuestionAnswer() / questionAnswerVerdict() を追加し、/respond の { decisionId, answer } 経路が kind==='question' を 選択肢番号(1 / 複数選択の 1,3)・選択肢ラベル・自由入力 から answers: string[][] に写像して decideOpencode → 既存の replyOpencodeQuestion() へ送るようにした。id の所属照合は #1932 の規約どおり resolve 済み (worktree, tool, instance) の listPending() 内に限り、別 instance / 別 worktree の question id は forbidden ではなく 404 decision_not_found(横断検索をしないので「そもそも見えない」)。permission と question の verdict 語彙は交差させず、2 択の question への 3 は Reject ではなく 400 answer_out_of_range(逆向きも同様で、answer verdict は toOpencodePermissionReply が wire 値を持たないため送達されない=双方向にフェイルクローズ)。PromptPanel は payload が decisionId を名指しし・1 問だけで・3 択 verdict を出していないときだけ #1726 の箇条書きを multiple_choice と同じラジオ+Submit のピッカーに差し替え、押された選択肢の番号を decisionId つきで送る。送達できたときは permission_replied を記録して prompt-waiting を解放する(question も承認と同じくセッションを塞ぐため)。
  • docs(remote): commandmate remote(QR ペアリング)の設計方針を確定 (#1937): docs/design/remote-qr-pairing-1937.md を追加し、Issue が「先に決めること」と指定した 2 点を実測の根拠つきで決定した。(1) 既存 QR ログイン(#383 の QrCodeGenerator)は deprecate して撤去する — 到達経路が /login の hidden md:block だけでスマホからは到達できず、docs/** と README.md に言及が 0 件(README が案内するスマホ手順は CM_BIND=0.0.0.0 + LAN IP)、利用には手入力でトークンを持っていることが前提、という実測から「守るべき利用者」を持たないと判断した。受け口の useFragmentLogin は #code= 対応へ転用して残す。(2) 端末別認証(remote devices / remote revoke)は Phase 1 に入れない — HTTP 認証を強制しているのは Edge Runtime の middleware.ts で better-sqlite3 も fs も使えず、かつ Next の Edge サンドボックスは buildEnvironmentVariablesFrom() が process.env のコピーを作って moduleContexts にキャッシュするため、起動後に発行した端末クレデンシャルを middleware に認識させる手段が無い(=端末テーブル追加では済まず認証アーキテクチャの作り替えになる)。Phase 1 の失効は remote stop と CM_AUTH_EXPIRE による一括失効に限定し、失うものを明記した。あわせて Provider 抽象(RemoteHandle に起動前スナップショットを持たせ stop() の入力を手形だけに絞る=全消しコマンドを型と lint で撃てなくする)、ペアリングコードのライフサイクル(平文トークンは env ではなく mode 0600 の一度限りのハンドオフファイルで渡す。src/lib/tmux/** が env: を渡さずエージェントの pane がサーバ env をそのまま継承する実測に基づく)、CM_BIND 既定 127.0.0.1 を壊さないための変更禁止リストと固定テスト、未解決論点 9 件、Phase 1 を 12 本に割った実装 Issue 分割案(並列可否つき)を記載した。

Changed

  • docs(cli): capture --json に Epic #2055 で additive に増えた 4 群のフィールドを CLI 利用ガイドへ追随させる (#2055): src/cli/types/api-responses.ts を触った 5 commit のうち 3 件(#2038 / #2042 / #2054)が docs/user-guide/cli-operations-guide.md を同一 PR で更新しておらず、Epic #2055 の横断受入条件「CLI JSON 契約の変更は additive のみ(両方を同一 PR で更新)」のドキュメント側が満たされていなかった(Epic close-out の突合で発見)。structuredEvents.sessionContext(コンテキスト使用率、#2042)・structuredEvents.sessionDiff(このターンが触ったファイルと revert 状態、#2043)・structuredEvents.source の健全性フィールド(kind / liveness / degradedReason / probedActivity、#2054)の 3 節を追加し、capture --json のサンプルにも反映した。契約自体は additive のままで、削除も既存値の変更も無い —— da582f97(Epic 着手前)と b110b100 の 2 サーバを同じ生きた claude / codex ペインに向けて payload を突き合わせ、削除フィールド 0・既存値の変化 0・追加 5(claude / codex ではすべて null、source.kind のみ "hooks")を実測している。OpencodeInstanceSession(#2038)は CLI コードから参照されておらず CLI 面に現れないため、ガイドには追加していない。
  • docs(hooks): opencode の Auto-Yes deny を OPENCODE_CONFIG_CONTENT で注入する案を実測のうえ不採用とし、configScope: 'none' を維持する裁定を記録 (#2053): opencode 1.18.22 の隔離 HOME で実測し、機構は実在し狙いどおり効くことを確認した(inline に permission の deny を注入すると permission.asked が 1 件 → 0 件、tool part は error、4.3 秒後に session.idle でターンが完走。§5.5 の「タイムアウト無しで無限に承認待ち」が消える)。それでも採らない: inline config は #1908 が測った 4 層の上に乗る 5 層目で、(1) 利用者の permission.bash: "ask"(文字列)に CommandMate がオブジェクトを注入すると層ごと置き換わり、無関係な echo が無ダイアログで走る=利用者の安全設定が黙って allow に降格し、(2) 解決後のルールは平坦な後勝ちリストなので利用者の完全一致 allow が CommandMate の広い glob deny に負ける。さらに壊れた JSON は TUI を exit 0 で落としポートを開かせない一方 serve は /global/health に healthy を返し続ける(#2054 の liveness が「生きている」と誤報する)。裁定は docs/design/agent-event-source-interface.md §3.4、実測は docs/design/opencode-server-live-verification.md §26、pin は tests/unit/hooks/sources/opencode-config-scope-2053.test.ts。
  • feat(cli-tools): opencode の中断を POST /session/:id/abort 一次・Esc×2 fallback にする (#2034): port が割り当たっていて subscription が live なときは、turn-gate が「このインスタンスのターン」と判定した primary session に対して abort API を送り、session.idle フレームの到着で完了を確認してから返す。未接続・非 live・session 不明・リクエスト拒否・2 秒以内に session.idle が来ない、のいずれも従来どおり Esc×2 に fallback する(CM_AGENT_HOOKS_INJECT=0 や --port 非対応版の opencode で中断できなくなることはない)。1.18.22 実測(隔離 HOME の opencode serve+LM Studio、生成中に abort)で 200 application/json の本文 true・session.error MessageAbortedError と session.idle が同一ミリ秒、その 23ms 後に 2 回目の session.idle(#1758 §5.3.2 の 1.18.3 では 19ms)を確認。すでに idle のセッションへの abort も 200 true を返すため応答は完了の証拠にならず、完了判定は必ずフレーム側で行う。

Fixed

  • fix(push,cli,docs): VAPID の既定 subject が APNs に拒否される問題を修正し、未設定・不正・不達を 1 つの検査で可視化 (#2123, #2124): CM_VAPID_SUBJECT の既定値を mailto:commandmate@localhost から https://github.com/Kewton/CommandMate に変更。VAPID の sub は送信元の連絡先で Apple(APNs)は妥当性を検証するため、実在しない localhost は 403 で拒否され iPhone / iPad にだけ通知が届かない(FCM は寛容なので Android だけで確認すると気づけない。Epic #2002 実機 UAT 実測 2026-08-27 / develop e8d0998: 同一の待機通知を 2 台へファンアウトし、既定 subject では Android 10 / Chrome 151 → FCM は届き iPad / iPadOS 18.7 → APNs は push-send-failed {"statusCode":403}、subject 変更後は 2 台とも受信を目視確認)。あわせて #2123 と #2124 に検査を 2 つ書かず 1 つにまとめた: src/lib/push/vapid.ts の inspectVapidConfig() が unconfigured / partial / invalid-subject / ok の 1 つの verdict を返し、formatVapidReportLines() の文面を server.ts の起動時セルフチェックと commandmate status が共有する(#2113 の localhost セルフチェックと同じ契約 —— await しない・fail-open・起動を止めない)。正常なら 1 行も出さない(陰性対照。行が出ること自体が信号で、健全な install で喋る診断は 1 週間で無視される)。status はこの shell の process.env ではなく getEffectiveEnv()=サーバが実際に起動した .env を読む(#1266 が CM_ALLOWED_IPS で踏んだのと同じ罠)。不正な subject は警告するが既定へ差し替えない —— 差し替えると警告が「サーバが実際に送る値」を説明しなくなる。鍵の生成手段が無かった件(#2123: commandmate init に VAPID は 0 件、scripts/ にヘルパ無し、利用者は node -e "require('web-push')..." を知っている必要があった)は commandmate init に組み込んだ: 同梱 web-push の generateVAPIDKeys() を遅延 import で呼び、3 変数を .env へ書く。既存の鍵ペアは --force でも必ず引き継ぐ —— 公開鍵は購読済みブラウザの PushSubscription に焼き込まれており、作り直すと購読済み端末が全部無言で切れるため。送信失敗が利用者に見えない件(#2124 対策案 3)は src/lib/push/delivery-health.ts を新設して端末ごとの到達状態を持ち、More 画面の通知設定に「この端末には通知が届いていません」(403 等。購読は残す)/「この端末の購読はプッシュサービス側で失効しました」(404 / 410)を出す。403 で購読を削除しない現行挙動は不変(設定ミスで購読を消してはいけない)で、変わったのは記録と表示だけ。状態は app_settings(migration 不要)に sha256(endpoint) をキーとして持ち、endpoint 自体は保存しない(bearer capability なので DB を開ける者に見せない)。410 で購読行が消えたあとも記録は残るので GET /api/push/subscriptions は「切られた端末」と「一度も購読していない端末」を区別して答えられる(それまでは応答がバイト単位で同一だった)。UI 側も serverSubscribed を持ち、ブラウザが PushSubscription を保持したままサーバに切られている端末に「この端末で通知が有効です」と表示し続けるのをやめた。ログは端末ごとの push-send-failed に consecutiveFailures を足し(1 回の 403 は瞬断・続く 403 は設定ミス)、ファンアウト末尾に push-fanout-complete {kind, delivered, failed} を 1 行出す(成功が完全に無言だったため「誰にも届かなかった」と「そもそも走らなかった」がログ上同一だった)。#2126(Android Chrome の suspicious 判定)は同じバナーに status を足すだけで乗る。設定手順は docs/user-guide/webapp-guide.md(ja / en 両方。それまで ja 側は「通知」の語が 0 件)に「スマホ通知」節として新設し、鍵生成 → 3 変数 → 再起動 → GET /api/push/vapid で configured: true 確認 → 端末で購読、まで一続きにした。実機 UAT で必要だったのに書かれていなかった前提も入れてある: HTTPS(secure context)が要ること(127.0.0.1 は例外だがスマホは別ホストなので該当しない)、iOS / iPadOS はホーム画面に追加してそこから起動しないと購読できないこと、Android Chrome は通常のタブでよいこと、アプリ内のボタンを押すまで OS の通知一覧にサイトが現れないこと(UAT で OS 設定を先に見て「許可済みのはず」と誤認した)、秘密鍵を commit しないこと。.env.example にも 3 変数をコメントつきで追加。検算: 15 件の構造保存変異(既定 subject の差し戻し / 裸ホスト判定の反転 / 正常時に喋らせる / removed フラグ反転 / 403 でも購読削除 / 成功時に記録を消さない / init で鍵を作り直す / .env に 3 変数を書かない / status を常に無言化 / route が delivery を落とす / バナーを描画しない / .env.example の subject 差し戻し / 鍵形状検査の緩和 / ファンアウト集計の入れ替え)をリポジトリ全体(unit 1175 files・integration 97 files)に対して 1 件ずつ注入し、15 件すべてが赤になることと md5 でのバイト同一復元を確認した。
  • fix(push): 検証ゲート不合格の通知がどのインスタンスの話か言わない問題を修正 (#2125): push-sender はタイトルを <worktree名> + (<agentName>) で組み立て、agentName が空なら接尾辞ごと落とす。commandmate verify <id> は --instance を取らないので gate-runner が instanceId: null を渡し、失敗カードは接尾辞なしで届いていた(オーケストレーター UAT 実測 2026-08-27 / develop e8d09989 / iPad iPadOS 18.7・APNs: タイトル uat/push-2002、本文 検証ゲート不合格:lint。本 PR では実機再測はしていない=未計測)。並列運用では 1 worktree に claude / claude-2 / codex が同居するので、鳴った先で対象を特定できない= Epic #2002 の「対応が必要なときだけ鳴らす」が対応に繋がらない。failure-push-notifier に表示専用の agentLabel を足し、検証失敗だけがそれを埋める: run がインスタンスを名指ししていれば従来どおり (codex-2)、名指しが無ければ #1925 の権威 resolveSessionTarget()(GET /api/worktrees/:id/resolve-target の HTTP 面と同じ関数)に worktree の既定送り先を訊いて (worktree: claude) と刻む。既定を素のインスタンス名として代入はしない —— それは「この agent がゲートを回した」という報告ではなく既定の解決結果なので、2 インスタンス居る worktree で既定側を名指しで犯人にしないための区別である。worktree 行が消えていて resolvedBy: 'fallback'(=resolve-session-target 自身の最終手段リテラル)に落ちた場合と DB が答えられない場合は (worktree: ?)=「言えなかった」で、黙って省略する経路は無くなった(worktree: の空白とコロンは isValidInstanceId の字母 [A-Za-z0-9_-] の外なので、この札がインスタンス id と読み違えられることはない)。解決結果は通知タイトルで止まり RunVerificationInput.instanceId には書き戻さない —— #2043 の opencode 第 2 証跡は「名指しの無いインスタンスは namesOpencodeInstance() に false を返す」ことに立脚しており、タイトルのために run 行へ解決済み id を書くと work-evidence の裁定が動くため(verification-failure-push-instance-2125.test.ts が run 行の instance_id が null のままであることを固定)。待機通知(kind: 'prompt')・上流障害・セッション起動失敗の表記は不変 —— waiting-push-notifier は従来どおり agentName にインスタンス id をそのまま載せるので <worktree名> (claude) のままで、#1790 の形は同一 worktree で両経路を同時に鳴らす回帰テストが固定している。
  • fix(skills): hooks-git.sh を standalone で source すると $TMPDIR に残った他人のマーカーで本物の WARN / ERROR が黙って消える問題を修正 (#2119): hooks-git.sh の診断は <worktree-id>.<cause> ごとに 1 回だけ出る設計で、「もう出した」の記録はファイル($MONITOR_HOOKS_STATE_DIR/warned-<key>)である —— monitor.sh がカウンタを $(...) の subshell で呼ぶ以上シェル変数では次のポーリングまで残らないので、ファイルであること自体は正しい。壊れていたのは置き場の同一性で、MONITOR_HOOKS_STATE_DIR も STATE_DIR も無い standalone は ${TMPDIR:-/tmp}/cm-monitor-hooks-$$ になり、$$ は再利用され(macOS の PID 空間は約 10 万で周回)誰も掃除しないため、堆積したディレクトリのキーは次の run が出そうとするキーそのものだった(実測 2026-08-27: $TMPDIR に 4129 ディレクトリ / 4214 マーカー)。再利用 PID を引いた run は自分が書いていない warned-… を見つけて mh_report_once() が黙って return 0 し、#1614 と #1728 が「絶対に消えない診断」にしたはずの 1 行 —— 「git が答えられなかった」と「worker が何もしていない」を区別する唯一の行 —— が消える。#2089(PR #2117)が直したのはテスト側だけで(useIsolatedHooksStateDir() が 1 テストにつき 1 つ置き場を切る)、本番側は .claude/skills/sync-map.json の sha256 pin が当時の scope 外だったため未着手のまま残っていた。本 Issue は置き場の決定を 3 分岐にする: MONITOR_HOOKS_STATE_DIR 指定ならその値(呼び出し側の所有物なので消さない)/STATE_DIR があれば monitor.sh の store に従来どおり相乗り(monitor.sh の EXIT trap が一緒に消す)/どちらも無ければ mktemp -d "${TMPDIR:-/tmp}/cm-monitor-hooks-XXXXXXXX"。mktemp -d は既存の名前を返さないので誤抑止は構造的に起きえない(mktemp が答えられないときだけ旧来の -$$ へ落ちる —— マーカーを諦めて毎ポール警告する方が悪い)。掃除は自分で作った時(MONITOR_HOOKS_STATE_DIR_OWNED=1)かつ trap -p EXIT が空の時だけ EXIT trap を張る: bash の EXIT trap は 1 本しかなく、source されたファイルが無条件に張るとオペレータ自身の後始末を黙って潰すので、空ディレクトリ 1 つと引き換えにはしない。rm -rf の対象は */cm-monitor-hooks-* に一致する名前に限定した。monitor.sh 配下は挙動不変 —— STATE_DIR があり trap cleanup EXIT も hooks を source する前に張られているので両方のガードが「自分のものではない」と答える(実 monitor.sh の 4 ポール run で警告 1 行・終了後に $TMPDIR が空、を新規テストで固定)。PID 衝突は確率でなく決定的に作って検算した: spawn した shell 自身に $TMPDIR/cm-monitor-hooks-$$/warned-<key> を書かせてから hooks を source すると $$ は旧フォールバックが次の行で計算する値そのものなので、再利用 PID の再現になる。同一サンドボックス同一コマンドでの対照 —— 修正前×堆積あり: stderr 空(欠陥)/修正前×クリーン: ERROR 1 行だが cm-monitor-hooks-<pid> が残る(litter の生産)/修正後×堆積あり: ERROR 1 行/修正後×クリーン: ERROR 1 行かつ何も残らない。既存の 4129 ディレクトリは消していない(消すと「直った」のか「証拠を消しただけ」なのか区別できなくなるうえ、使用中の store を消すと mh_report_once が再出力して warns once per worker 系を別の理由で赤にしうる)。新規の堆積は止まっており、full test:unit を 5 回回しても 4129 のまま増えない。SKILL.md に置き場と掃除規則の表を追記し、.claude/skills/sync-map.json は node scripts/skills-sync-map.mjs update で pin を再生成した(policy は port-required なので Kewton/commandmate-skills の skills/cmate-orchestrate-monitor へは逐語コピーではなく挙動を移植する必要があり、note にその旨を残してある)。
  • fix(test): hooks-git.sh の診断テストが「PID がぶつからなかった偶然」で緑になっていた問題を修正 (#2089): hooks-git.sh の WARN/ERROR は <worktree-id>.<cause> ごとに 1 回だけ出る設計で、「もう出した」記録はファイル($MONITOR_HOOKS_STATE_DIR/warned-<key>)である。monitor.sh はカウンタを $(...) の subshell で呼ぶのでシェル変数では残らず、ファイルであること自体は正しい。壊れていたのはフォールバックの同一性で、MONITOR_HOOKS_STATE_DIR も STATE_DIR も無いと $TMPDIR/cm-monitor-hooks-$$ になる — $$ は spawn した bash の PID、macOS の PID 空間は約 10 万で周回し、monitor.sh を経由しない source(= tests/unit/skills/orchestrate-monitor の全 suite)は誰も掃除しない。実測 2026-08-27 / develop 786e4765: $TMPDIR に cm-monitor-hooks-* が 4102 ディレクトリ / マーカー 4163 個堆積し、キーはすべてテスト fixture の id(myrepo-feature-x.status 459 個 / nope-nope.no-checkout 1376 個 / shared-name.ambiguous-basename 464 個 …)=次の run が出そうとするキーそのものだった。再利用 PID を引いた run は自分が書いていないマーカーを見つけ、mh_report_once() が黙って return 0 し、#1614 と #1728 が「絶対に消えない診断」にしたはずの行が消える。負荷でもタイムアウトでもない — 観測 3 件はすべて assertion diff で、Test timed out in … でも status: 141 でもなく、並列で頻発したのは PID の消費が速く再利用域へ早く回り込むためにすぎない。修正は useIsolatedHooksStateDir()(tests/helpers/hooks-git-diagnostics.ts)で 1 テストにつき 1 つの state dir を切り、hooks-git.sh を source する 3 suite(monitor-exit-codes / hooks-git-resolution / monitor-observability)が spawn env へ明示的に渡す。粒度は「呼び出しごと」ではなく「テストごと」 — マーカーは 1 回の run の中では生き残らねばならず(monitor.sh --max-polls 4 は今も警告を 1 回だけ出す)、一方でファイル単位では足りない(MONITOR_HOOKS_STATE_DIR を 1 つに固定して空で回すだけで prints the git failure a single time across a multi-poll run が落ちた=テストどうしが既に干渉していた)。失敗メッセージも分離し、stderr が空のときは expected '' to contain '…' ではなく「診断が 1 行も出ていない」と明言して「文言が変わった」ケースと区別する(文言自体を hooks-git-diagnostics.test.ts が pin)。副次効果として上記 3 suite が litter の生産者だったため $TMPDIR への堆積が止まる(フル run ごと約 15 ディレクトリ → 0、3 回連続実行で 4102 のまま)。hooks-git.sh 本体(standalone 経路)は未修正 — .claude/skills/sync-map.json が当該ディレクトリ配下の全ファイルの sha256 を pin しており(#1612 のクロスリポジトリ drift ゲート)、1 バイト変えると tests/unit/skills/sync-map.test.ts が赤になるが、その JSON は本タスクの scope 外だった。推奨する mktemp -d 化の具体案と移植手順は docs/design/hooks-git-marker-state-dir-2089.md §4 に記載。REAL_SHELL_CONCURRENT_FULL_RUNS(Issue 本文の依頼 2)は触っていない — この定数が支配するのは時間予算であり、それが効く失敗形(Test timed out / status: 141)は今回 1 件も観測されておらず、並列数を変えても PID 再利用の確率が動くだけで機構は消えないため。
  • fix(detection,cli,docs): opencode のダイアログが開いている間の ready を構造ゲートで止める (#2112): opencode のセッション一覧 (ctrl+x l) / エージェント一覧 (ctrl+x a) / タイムライン (ctrl+x g) が開いている間、detectSessionStatus が ready / opencode_response_complete を返し、commandmate wait がブロックされたペインを exit 0(完了) と裁定していた問題を修正(実測: #2046 が実 TUI のキーストロークで撮った commit 済み fixture tests/fixtures/opencode-live-2046/w80/dialog-*.txt を実 detector に通して再現)。オーバーレイは背後の完了マーカー ▣ Build · <model> · 2.8s を画面から消さないので、branch D が「ダイアログを開く前のターン」のマーカーを「いまの判定」として拾っていた。これは #1017 / #1494 の 60 秒エスケープハッチの迂回でもある — claude の /help は running / default(証拠なし側)に落ちてハッチが開くのに対し、opencode の 3 ダイアログは ready=肯定的証拠なので isUnclassifiedActive が永久に立たない。修正は完了判定の前に置くゲート(tools/opencode/detect.ts branch C2)で、判定は幾何で行う: opencode はダイアログを背景色で塗った矩形として描き、capture-pane -e はその背景を再送するので、矩形の左右端が行をまたいで一定であることと、そのタイトルバー行が esc ハッチを矩形の右端に接して持つことの 2 つで読む(src/lib/detection/opencode-modal-overlay.ts)。Sessions / Commands のような語の存在では判定しない — 会話本文が同じ語を書いただけで誤検出する #1883 が消した推論と同型だからで、OPENCODE_SELECTION_LIST_PATTERN の見出し allowlist も #1896 の narrow のまま広げていない(陰性対照 tests/fixtures/opencode-modal-overlay-2112/words-in-response.txt は、見出しを 5 つ足した allowlist なら発火し・構造ルールは発火せず・ready / opencode_response_complete のままであることを固定する)。行の形ではなく矩形を読むので、オーバーレイの左右に transcript が見えている 120 / 200 桁のフレームでも読める(opencode-live-2047/w{80,120,200}/command-palette.txt の 3 幅すべてで waiting)。ゲートが立つと waiting / opencode_modal_overlay(SELECTION_LIST_REASONS の 9 件目)になり、wait は完了ではなく exit 10 / 待機継続、UI は NavigationButtons、send は #1708 の isPromptWaiting でガードされる。ctrl+p コマンドパレット(従来 running / unknown_frame)も同じ reason に揃い、tests/unit/detection/tools/unclassified-frames.test.ts の別表から抜ける。claude / codex / copilot / gemini / antigravity / vibe-local の検出は不変(規則は opencode の detector からしか到達しない。横展開 8 フレームの判定をテストで固定)。未計測: ctrl+x t(themes)と ctrl+t(variant cycle)は fixture が無く、同じ矩形 chrome だと予想されるだけ — docs/design/opencode-modal-overlay-detection.md §5 に明記。
  • fix(server,cli): 案内している localhost に別プロセスが応答していても気づけない問題を修正 (#2113): listen 成功後に http://localhost:<port>/api/auth/status を自分で叩き、返ってきたのが自分でなければ起動ログと commandmate status の両方に警告を出す起動時セルフチェックを追加(src/lib/server/localhost-self-check.ts)。CommandMate は既定で IPv4 127.0.0.1 にだけ bind するのに README / cli-setup-guide / webapp-guide は http://localhost:3000 を案内しており、macOS の localhost は ::1 を先に解決するため、別プロセスが ::1:<port> を掴んでいるとブラウザは黙って別サーバに繋がる(実測 2026-08-27 / develop 54e122a9: 127.0.0.1:3000 は 200/14ms、localhost:3000 と [::1]:3000 は 10 秒でタイムアウト。占拠側も Next.js だったため ブラウザは別アプリから chunk を取りに行き、CommandMate 自身の error.chunkReload.title「Updating to the latest version」が出て調査が Service Worker の筋へ逸れた)。自分かどうかの判定は応答の中身ではなく自分の観測で行う — プローブは一度きりの nonce を x-cm-self-check ヘッダに載せ、サーバ側は prependListener('request') でその nonce を待つ。観測できれば self、応答は来たのに観測できなければ foreign(=警告)、接続不能・タイムアウトは unreachable で無言(::1 が空なのは正常=ブラウザも Node も 127.0.0.1 へフォールスルーするため、ここで警告すると健全な環境が毎回鳴る)。判定用の新エンドポイントは足さず、AUTH_EXCLUDED_PATHS にあり DB を触らない既存の /api/auth/status を叩くだけにしてある(判定が応答内容に依存しないので、auth 有効・IP ACL で 403・自己署名 HTTPS でも成立する)。全経路 fail-open で listen をブロックせず、await もせず、例外は投げない。status へは <configDir>/logs/self-check-<port>.json 経由で渡す — PID ファイルは CLI 親が子の bind 前に O_EXCL で書く #1632 の前方互換フォーマットで追記できず、CLI からの再プローブでは 「CommandMate が答えた」と「別サーバが答えた」を区別できないため。陳腐化ガードは PID ではなく startedAt (実機で判明: daemon.start() は npm run start を spawn するので PID ファイルは wrapper の PID を持ち、実際に bind する node dist/server/server.js の PID とは常に食い違う。実測 2026-08-27 port 3902: PID ファイル 58882 / listener 58937 で、PID 比較では警告が status に一切届かなかった)。あわせて案内 URL を実際の bind に合わせ、README / docs/user-guide/cli-setup-guide.md / docs/user-guide/webapp-guide.md と en/ja のミラー計 6 ファイルを http://127.0.0.1:3000 に統一し、なぜ localhost ではないかを併記した(wsl2-setup.md は Windows→WSL2 の localhost forwarding という別機構なので対象外)。デュアルスタック bind(Issue 本文の案 3)は CM_BIND の 3 値の意味論を 再定義する必要があるため入れていない。
  • fix(detection,ui,cli): opencode のサイドバーが出ているフレームを構造的に検出し復帰キーを利用者に示す (#2095): opencode のサイドバー(ctrl+x b / ctrl+p パレットの Show sidebar)はキャプチャの行を transcript と共有するため、ON の間はターンの終了マーカーが隠れ、終わったターンが running / unknown_frame のまま滞留する(#2046 が既定の 80 桁で実測)。#2046 はクイックキー列と special-keys route を塞いだが opencode 自身のパレット経由は塞げないので、「起きたと気づける」側を追加した。判定は単語ではなく幾何 —— 入力ボックスの下辺(╹▀▀▀…)から右端の桁を測り、ボックスに属する行がその右端より右に文字を持つことを第 2 列の signature とする(幅で分岐しない。明示トグルには #2047 の 121 桁閾値が無いため)。capture --json に paneObstruction: {id, matchedText, at} を publish し(upstreamFault と同じ形・同じ末尾 100 行を判定入力にする)、wait の unclassified メッセージと #1708 の履歴行に原因と ctrl+x b を足し、端末画面(PC / モバイル)に警告バーを出す。wait の exit code は 1 つも動かしていない: このフレームは既に isUnclassifiedActive で exit 10 / type: unclassified に落ちており、誤っていたのは verdict ではなく「理由が無いこと」だったため(--fail-on-pane-obstruction は入れない)。opencode 以外のツールはフレームを判定すらしない。
  • fix(ui): opencode のセッション操作と共有リンクの失敗が画面に出ないまま握り潰される問題を修正 (#2109): OpencodeSessionControls は new / list / fork / 共有の発行・取り消し・コピーの失敗をすべて console.error に流して return しており、押下フィードバックが pending スピナーだけだったため、409 で拒否されたときの見え方が「何も起きないボタン」と区別できなかった(コピー失敗に至っては console.error すら無い完全な握り潰しだった)。ルートは code: 'NO_OPENCODE_PORT'(ペインは動いているのに opencode サーバが無い=停止直後や CM_AGENT_HOOKS_INJECT=0 起動)と code: 'NO_OPENCODE_SESSION'(fork / 共有の対象セッションが特定できない)という機械可読な理由を返していたので、この 2 つに専用の文言(locales/{ja,en}/worktree.json の worktree.opencodeSession.error*)を与え、それ以外はルートの error 文字列をそのまま通す(サイドバーの sync ボタンと同じ規約)ようにした。NO_OPENCODE_SESSION は fork と共有で別文言にしてある(共有ボタンに「fork できるセッションがありません」と出さないため)。表示先は MessageInput が渡す既存の toast 面で、showToast を渡さない mount(WorktreeDetailRefactored.tsx の単一ペイン composer)では role="alert" のインラインチップにフォールバックする。成功時は従来どおり無言、opencode 以外のツールでは引き続き何も描画しない。opencode 1.18.23 の実機で NO_OPENCODE_PORT の 409 → 画面表示(ja / en)と、同条件で修正前は 5 秒間なにも出ないことを対照確認済み。
  • fix(server,opencode): CommandMate 再起動後、生きている opencode ペインへ購読を戻さず HTTP 経路だけが死んでいた問題を修正 (#2108): 起動シーケンス(initializeWorktrees() の直後、await しない)に reattachOpencodeEventStreams() を追加し、~/.commandmate/opencode-ports.json の各行のうち ICLITool.isRunning() がペインの生存を認めたものだけを並列に recoverOpencodePort → attachOpencodeEventStream へ通す。recoverOpencodePort はもともと復元に必要なこと(永続ファイル読み込み・パス照合・/global/health)を全部やっていたのに、その唯一の呼び出し連鎖が launchSession() の「セッションが既にある」分岐で、POST /send は isRunning が真なら startSession を通らない——つまり再起動後は誰も呼ばなかった。結果、tmux ペインも opencode サーバも生きていてポートも記録されているのに POST /api/worktrees/<id>/opencode/session が 409 NO_OPENCODE_PORT、instances/opencode が connected: false、opencode/session が live: false を返し続ける一方、current-output は isRunning: true のままなので画面上は正常に見えていた(opencode 1.18.23 実測、設計書 §28)。照合する worktreePath はファイルの記録値ではなくデータベースから引く——記録値をそのまま渡すと recoverOpencodePort のパス照合が恒真になり guard が無効化されるため。ポートが死んでいる/別プロセスに奪われている行は復元せず opencode-port-recovery-unhealthy / port_identity_changed の既存経路に落ち、ペインが無い行は health check を撃たれずに落ちる。他ツール(claude / codex / copilot / gemini / antigravity / vibe-local)の起動シーケンスは不変。
  • fix(ui): スマホの opencode クイックキー列を折りたたみ式にし、既定で閉じるようにした (#2106): #2046 が置いた 17 個の 44px キーは、実ブラウザ計測(tests/e2e/mobile-opencode-quick-keys-2106.spec.ts)で 7 行 378px を占め、TerminalDisplay の可視高を 390x730 で 40px、360x640 では 0px まで潰していた(Issue 本文の机上見積もりは列 ~265px / ターミナル ~140px で、実測はどちらも悪い)。OpencodeQuickKeys に collapsible prop を足し、モバイルのみ 44px トグル 1 行の disclosure に畳んで 既定は閉じる(開閉状態は useOpencodeQuickKeysDisclosure が useLocalStorageState 経由でデバイス単位に永続化)。これでターミナルの可視高は 390x730 で 40px→374px、360x640 で 0px→284px に回復し、17 キーは 1 タップで同じ集合・同じ順序・同じ送信挙動のまま到達できる。PC(TerminalSplitPaneContent)は collapsible を渡さないため描画は不変。
  • fix(hooks,session): opencode の question ダイアログの decisionId と選択肢を current-output に publish する (#2100): question は pendingDecisions[0].kind === "question" として検出されていたが id: null / questionOptions: null で届き、Web の PromptPanel が選択肢ボタンを描けなかった。原因は独立に 2 つ——(1) ingest が reportPermissionRequestPending()(id を持たない forecast 用の書き込み口)を呼んでいたため、parseOpencodeQuestion が読んだ que_… がその場で捨てられていた。(2) opencode は同じ question 呼び出しの tool part を message.part.updated(tool=question/status=running) として question.asked と同一ミリ秒で送るので、これが pre_tool_use/detail question に写り、applyAskUserQuestionTransition の「別の tool に移った=question は終わった」規則(Claude の AskUserQuestion だけを除外していた)に当たって、記録した選択肢を 1 ms 後に削除していた。forecast として開かれた record は 20 秒で失効するため、質問が画面に出たまま sessionStatus が waiting → ready に戻るという 3 つ目の欠陥も伴っていた(opencode は question 待ちの間 session.idle を 1 通も出さないので、失効後に開き直す契機が無い)。readPromptQuestionChoices() のゲートは 1 行も緩めていない——入力を出すようにしただけで、decisionOptions(approval の 3 verdict)は question には publish しない。CLI の respond <n> は不変。opencode 1.18.23 で実測(設計書 §27)。
  • fix(detection): opencode の permission ダイアログに数字を送ると Reject が Allow once に化ける問題を修正 (#2033): sendPromptAnswer が入力方式を CLI ツール名だけで選んでおり、直前のコメントが「codex/gemini/copilot/opencode は N + Enter をテキストとして受ける」と明言していた。opencode の permission は番号を持たない ←/→ のボタン列で、数字は飲まれ Enter はハイライト中(既定 Allow once)を確定するため(#1893 実測)、respond <id> 3(Reject)が承認に化ける。修正はツール名の追加ではなく、キー送出の前にその画面のダイアログ自身の answerMode(DialogVerdict.answerMode=各ツールが自分のフレームから実測して宣言した値、evaluateAutoYesDialogGate が Auto-Yes を裁くのと同じ測定)を detectDialog に問い、'numbered' でなければ数値回答を理由コード answer_mode_keys で送信前に拒否する。ダイアログ単位の判定なので copilot の numbered な permission は通り keys なピッカーは止まる(ツール単位では必ずどちらかを誤る)。/prompt-response は再検証で撮った同じフレームを渡し、拒否を 500 ではなく {success:false, reason:'answer_mode_keys'} で返す。ダイアログ規則を実測していないツール(gemini / antigravity / vibe-local)は非ゲートのまま。
  • fix(session,ui): 構造化承認の decisionId を promptData に載せ、PromptPanel の Allow once / Allow always / Reject を有効化 (#2031): buildStructuredPromptData が source / message / toolName / askUserQuestion / decisionOptions を copy して止まっており、#1932 が用意した受け側 readPromptDecisionId が読む decisionId を誰も書いていなかったため、opencode の permission に対して 3 択ボタンが常に非活性のままだった(Web からの唯一の回答手段が ←→+Enter の NavigationButtons)。decisionId を payload に載せたうえで、current-output-builder が decisionOptions の公開条件と decisionId の公開条件を 1 つの式から導出するようにし、「番号はあるが id が無い」=キーストローク経路(#1681 / #1725)に落ちる状態を構造的に作れなくした。あわせて permission.asked の対象(message.part.updated 相関で得た toolName)と Allow always が保存する patterns を件数・長さの上限つきで保持・表示する — これまで notification 経路は toolName: null を固定で渡しており、ブラウザは「ダイアログが開いている」ことだけ知らされて何に対してかを知らされていなかった。eventIdentity: null の claude / codex / gemini / copilot / antigravity は decisionId: null のままで描画は変更前と一致する。
  • fix(terminal): special-keys API が受け付ける BTab を tmux へ実際に送れるようにした (#2032): POST /api/worktrees/[id]/special-keys の許可語彙(NAVIGATION_KEY_VALUES)は Issue #473 から BTab(Shift-Tab / back-tab)を含んでいたのに、実送出側の ALLOWED_SPECIAL_KEYS には無く、バリデーションを通ったリクエストが sendSpecialKeys() の中で throw して 500 になっていた。ALLOWED_SPECIAL_KEYS に BTab を追加し、NAVIGATION_KEY_VALUES ⊆ ALLOWED_SPECIAL_KEYS(差集合が空。Space/BSpace/DC は送出側にのみ在るので集合一致ではなく片側包含)をテストで固定。あわせて isAllowedSpecialKey() に「トランスポートが実際に届けられるか」の判定を取り込み、語彙と送出側が将来乖離しても入力検証層で 400(呼び出し側の唯一の行動は「別のキーを送る」であり、リトライで直らないものを 500 と名乗らせない)で止めて transport に到達させないようにした。
  • fix(push): Epic #2002 の既定変更が既存購読者に届かず、失敗通知だけが黙って増えていた問題を修正 (#2056): NEW_SUBSCRIPTION_DEFAULTS.enabledCompletion = false は INSERT にしか効かず、KIND_COLUMN の failure → enabled_prompt は列の意味を in-place で広げたため、リリース前から購読していた端末は正常完了 ON のまま失敗 3 種(検証ゲート不合格・上流障害・セッション起動失敗)を上乗せで受け取り、しかもそれを知る手段が無かった。Epic の完了条件 3(完了は既定で通知しない)と 6(通知が意図せず止まらない)は既存行に対して両立しないので、「黙って倒す」でも「放置」でもなく同意で解く: v57 で additive なマーカー列 defaults_version(既定 0=#2000 以前に作られた行)だけを足し、印のついた端末にだけ設定画面へ一度きりの案内を出して「新しい既定を 1 タップで採用」か「この端末の設定を保つ」を選ばせ、どちらでも印を消す(PATCH /api/push/subscriptions の acknowledgeDefaultsNotice)。マイグレーション自体は fan-out クエリが読まない列を足すだけなので適用しても通知は 1 通も止まらず 1 通も増えない(v56→v57 のロールバック/再適用を挟んで全 kind の配信先集合が一致することをテストで固定)。
  • fix(push): 待機を抱えたままサーバが再起動すると、他端末に残った prompt カードが二度と置き換わらない問題を修正 (#2057): #2001 の「鳴ったカードの印」を in-memory の globalThis Map から app_settings(migration v27 の汎用 KV。マイグレーション追加なし、SQL は escalation-settings と同じ理由で lib/push/ 側)への書き戻し付き 2 層に変え、この プロセスに記憶が無いときは storage から読み戻すようにした。実測すると Issue 本文の前提は 1 点外れており、素の再起動では欠陥は出ない(ステータスプローブが待機を再観測 → observeWaitingEdge が開くエッジとして再通知 → 印が付き直す)。実際に落ちるのは再観測時の通知そのものがゲートされる場合で、Auto-Yes 下(#1999)がそれにあたり、印が付かないまま閉じるエッジが no-card を出して古いカードが残り続けていた。TTL は 24 時間(PROMPT_CARD_MAX_AGE_MS)— Issue が挙げた STRUCTURED_STATE_MAX_AGE_MS(30 分)は「30 分を超えるターンは普通」と provisional-turn 自身が書いているとおり待機の寿命ではなく、待機エピソードには時計による寿命が無い(閉じるエッジでしか消えない)ため、この repo が「待機がまだ開いていておかしくない長さ」として既に約束している MAX_ESCALATION_THRESHOLD_MINUTES = 1440 に合わせた。閉じるエッジ自体が観測されない場合(誰も worktree を開かないまま待機が解決した)は waiting-episode-state が in-memory であるかぎり届かないため、lib/session 側の判断として設計書 §6.2 に明文化し、現在の挙動をテストで固定した。
  • fix(assistant): Assistant Chat の未インストール起動失敗が push 通知を鳴らさない問題を修正 (#2022): POST /api/assistant/start が持っていた自前の isInstalled() 検査を、ツール自身の拒否(SessionStartUnavailableError)と唯一の通知窓口を通す形へ付け替えた。Issue 本文は「検査を削除すれば cliTool.startSession()(#2009 の seam)に到達する」としていたが実測では逆で、冒頭の 400 ガードが NON_INTERACTIVE_TOOLS(claude / codex / antigravity)以外を落とし、直後の同一述語の分岐が必ず早期 return するため、その startSession() はどの入力でも到達不能——削除すれば 503 ではなく status: 'ready' を返し、実際の失敗は後段 spawn の ENOENT として無音で現れていた。通知窓口は lib/cli-tools/start-availability.ts に移し(BaseCLITool.startSession もそこを呼ぶので notifySessionStartFailurePush の呼び出し行は 1 本のまま)、tmux セッションを作らない Assistant Chat も同じ窓口に載せた。通知はリポジトリ名を題に /chat を開く(worktree 行が主語でない初のケースなので SessionStartSubject で題と遷移先を渡す)。HTTP は 503 のまま・code: SESSION_START_UNAVAILABLE を追加し、本文はツール自身の文面(CLI tool 'claude' is not installed → Claude Code is not installed. Please install it first.)に変わる。non-interactive-runner の spawn ENOENT も同じ窓口へ接続した(ENOENT のみ、他の exec 失敗では鳴らさない)。