Repository navigation
v0.23.0
[0.23.0] - 2026-08-14
Highlight: Epic #1720 Phase 4 を完了し、機械判断の一次ソースを TUI スクレイピングから CLI 自身が申告する構造化イベントへ移す作業を全 6 ツール(claude / codex / copilot / gemini / antigravity / opencode)に広げた。#1759 で
AgentEventSource抽象を切り出して「1 ファイル + レジストリ 1 行でツールが 1 つ増える」形にし、#1760〜#1763 / #1779 で 5 ツール分を実装している。**スクレイパは 2 層目のフォールバックとして残るため、構造化イベントが無い環境の挙動は変わらない。**あわせて、この作業中に CI を 5 時間 31 分 57 秒沈黙させた/procへの recursive mkdir 無限ループ(Linux 限定・同期スピンのためtry/catchにもtestTimeoutにも到達せず OOM も残らない)を、製品コード側の 5 経路でも塞いだ(#1774)。
Added
-
feat(hooks): antigravity に承認裁定経路を張り、受け口の fail-closed な早期 return を塞いだ(Phase 4-4 残作業) (#1779)
- agy の
PreToolUseを登録し、専用の裁定コマンド(stdout が裁定)を/api/hooks/permission-requestに向けた。codex のPermissionRequest/ copilot のPreToolUseと同じ形で、中継スクリプトscripts/hooks/cmate-agent-event.shは 1 バイトも変更していない(curl … >/dev/nullで応答ボディを捨てるため裁定に使えない)。他 5 ツールの配送挙動は無変更 - 受け口の潜在欠陥を塞いだ。
permission-request/route.tsはgetAgentEventSource()を呼ぶ前にハードコードした{}を返す経路を 2 つ持っていた(worktree 未解決 / catch-all)。他ツールでは{}=「意見なし」だが agy では{}=拒否なので、PreToolUseを張った瞬間に「worktree が消えた」「想定外の例外」で agy のツール呼び出しが黙って拒否される。両経路をsource.encodeVerdict({kind:'abstain'})経由にし、他ツールの出力が{}のままであることをテストで固定した badRequest()の 4xx は 4xx のままにした(実測に基づく判断)。 裁定コマンドはcurl -fを使うので 4xx はボディごと捨てられ、コマンド自身の fallback に落ちる。400 が裁定として agy に届くことはないので、検証エラーを正直に返す現状のままでよい- 失敗経路の既定出力は
{"decision":"ask"}にした。Issue 本文の指定({"decision":"allow"})から意図的に変えている。 agy のdecisionはallow/deny/ask/force_askの 4 値で、askが「通常の承認フローへ」=abstain そのもの。設定ファイルは~/.gemini/config/hooks.jsonのマシン 1 本なので、allowフォールバックは「CommandMate を止めるとマシン上の全 agy セッションが全ツール呼び出しを無承認で実行する」を意味する(CommandMate が起動していないセッションを含む=失敗経路 5 がまさにそれ)。askなら「CommandMate が入っていない機械と同じ挙動」になり、Issue の狙い(agy を壊さない)を穴を開けずに満たす。Issue 本文の手動受入条件「Auto-Yes 無効時は従来どおりダイアログが出ること」もallowでは満たせない - サーバ不達 / タイムアウト / 非 2xx / JSON でない or
decisionの無いボディ /CM_PERMISSION_HOOK_URL未設定 の 5 経路すべてで{"decision":"ask"}を stdout に出す。curl -m 4を handler のtimeout: 5の内側に置き、agy に殺されて部分出力になる経路自体を作らない parsePermissionRequestを実装した(toolCall.name/toolCall.args)。()=>nullのままだとunknown-payloadで常に abstain=**「動いている Auto-Yes に承認するものが無い」と見分けがつかない**noDecisionを{kind:'blocks'}→{kind:'proceeds'}に変えた。encodeVerdictと対で読むこと — 安全なのは abstain をaskと綴っているからで、{}に戻すと宣言だけが嘘になる。blocksのままだと Auto-Yes 無効時の全ツール呼び出しでpermission-request-abstain-blocks-agentが warn され、起きていないことを報告し続けるcapabilities.supportedEventsにpre_tool_useは足していない(Issue 本文の実装範囲 4 からの逸脱)。copilot と同じ理由 — 登録先は permission receiver なのでpre_tool_useのNormalizedAgentEventは 1 件も生成されず、載せると「待っても来ない語」を約束することになる(copilot/source.tsが同じ判断を明記している)- 実測で覆した Issue 本文 / #1762 の前提 2 点(いずれも agy 1.1.12・隔離 HOME・専用 tmux socket
cmate-i1779・対話 TUI)- stdout が空(exit 0・無出力)は
{}とは別物で fail-open。 通常の承認ダイアログが出る。「中継のままPreToolUseを張るとマシン上の全ツール呼び出しが止まる」は成立しない(止まるのは{}を返したときで、{}は⚠ Tool call denied by pre-tool hook:を出す=#1757 P10 を再現)。中継を使わない判断自体は変わらない(裁定を返せないため) {"decision":"allow"}は対話 TUI の承認ダイアログを抑止しない。permissionOverrides:["command(*)"]を添えても、リダイレクトの無いls -aでも同じ。--print(headless)では効く(#1757 §5.4.4)。したがって対話セッションの Auto-Yes は従来どおり TUI 応答経路(#988)が担う — 本 Issue が追加したのは「abstain が安全になったこと」と「裁定を表現できるようになったこと」であって、ダイアログの消滅ではない。裁定は agy の文書どおりの綴りで正直に出している
- stdout が空(exit 0・無出力)は
- 実機検証: 生成した
hooks.jsonをそのまま隔離 HOME に置き、実 agy 1.1.12 で ①CommandMate 停止状態(ポート未 listen)→ 通常ダイアログ →1. Yesでコマンドが実際に実行された(最重要条件)/②受け口スタブが{"decision":"ask"}→ 通常ダイアログ、リクエストは?tool=antigravity&worktreeId=…&instanceId=…付きで到達し payload はtoolCall.name/toolCall.args/③スタブが 500 → 通常ダイアログ。ユーザーの実~/.gemini/は 3507 ファイルの sha256 マニフェストが前後一致(検証は複製 HOME で実施)。kill-serverは使っていない - 空振り緑の反証: ①失敗経路の既定出力を「無出力」に変える ②受け口の早期 return を
{}のハードコードに戻す ③encodeVerdictの abstain を{}に変える、の 3 変異を注入して赤になることを実測した(順に 9・2・11 テストが赤。すべて戻して 155/155 緑に復帰) AgentEventSourceI/F(types.ts)は 1 行も変更していない。NoDecisionBehaviorに「裁定しない=拒否される」を表す値が無い件は、本 Issue では表現する必要がなくなった(abstain をaskと綴るのでproceedsが実態と一致する)。ただし表現できない状態が消えたわけではない —encodeVerdictが{}を返す実装に戻ればnoDecisionは再び嘘になり、型はそれを防げない。申し送りとして残す- 既存テストの期待値を変えた 4 箇所とその理由(いずれも本 Issue が挙動を変えた点そのもの):
noDecisionをblocks→proceeds/isAbstainSafeをfalse→true(gemini との対比を「逆」→「理由の違う一致」に書き換え) / abstain の encode を{decision:'allow'}→{decision:'ask'}/buildAntigravityHookConfigのPreToolUseを「未登録であること」→「中継宛でないこと」(主張の芯=中継は裁定を返せない、は不変)。cli-tools/antigravity.test.tsの起動コマンド正規表現はCM_PERMISSION_HOOK_URLを含むよう更新(expect行ではない)
- agy の
-
feat(hooks): copilot に構造化イベントと承認裁定を横展開した(Phase 4-3) (#1761)
src/lib/hooks/sources/copilot/を追加し、registry.tsに 1 行登録した。copilot セッションのsessionStatus/waitの完了判定 / Auto-Yes が、TUI スクレイピングではなく copilot 自身の hooks から決まるようになる(スクレイパは #1723 の 2 層目として残る)- Issue 本文冒頭の「hooks が実在するか未確認。実在しなければ取り下げ」は事実と逆だった。 #1757 のスパイクが実在を確定させており、payload は 4 ツール中もっとも Claude Code に近い。取り下げていない
- 設定の書き先は
~/.copilot/settings.json。copilot help configはconfig.jsonに書けると言うが、copilot は起動時にhooksを settings.json へ移送し config.json を機械管理形式で書き戻すため、config.json に書くと次回起動で消える(#1757 P2) - ユーザー設定は置換せずマージする。 マーカー
cmate-copilot-agent-hooksを含む自分のエントリだけを差し替え、他のキーと他のハンドラ(grouped 形 / flat 形の両方)はそのまま保持する。パースできない設定ファイルには触らず、素のgh copilotで起動する(イベントを失うのは回復できるが、手編集された設定の上書きは回復できない) - 相関キーは起動コマンドの環境変数に載せた。 copilot に
--settings相当は無く設定はマシン全体で 1 本なので、Claude のように URL へ焼き込むと 2 番目のインスタンスが 1 番目の名前でイベントを送ることになる。CM_AGENT_WORKTREE_ID/CM_AGENT_INSTANCE_IDを起動コマンドのプレフィックスとして与え、hook が発火時に読む - グローバル設定なので、CommandMate が起動していない copilot では無効になるようにした。 全コマンドが
CM_AGENT_WORKTREE_ID未設定で無音終了する。これが無いとオペレータが自分の端末で起動した copilot のStopが cwd 解決で当たり、誰のエージェントも終わっていないのにcommandmate waitが返る - 裁定は
hookSpecificOutput.permissionDecision(Claude のdecision.behaviorではない)。copilot はdeny+理由文字列も解釈するため Claude 実装より広いが、permission-decision-serviceは deny を出さないので能力の記述にとどまる。{}は fail-safe(通常の承認フローに落ちる) decisionTimeoutSecondsは 10(Claude は 600)。Claude の感覚で組むと裁定が届かない。コマンド側はcurl -m 4で 10 秒の内側から打ち切る(エージェントの timeout に判定を任せると「遅れて届いた裁定が黙って捨てられる」ケースと区別できない)supportedEventsは 5 語。notificationは #1757 で発火実績ゼロ、pre_tool_useは承認ゲートそのもので permission receiver 宛=イベントストアに入らないため、どちらも約束しない(mapper は綴りを知っているので手設定の hook からは読める)- 本 Issue で新たに実測した 3 点(
docs/design/copilot-agent-hooks-injection.md): hook コマンドはシェル経由で実行される/copilot プロセスの環境変数が hook に継承される/PreToolUseの stdout が裁定として解釈され、permissionDecision:"allow"は--allow-all-tools無しでもプロンプトを出さずに実行する(#1757 は deny と{}のみ実測で allow は未計測だった) - 実機検証(copilot 1.0.79、受け口はスタブ。本番 port 3000 には送っていない): 相関 env ありで 6 イベント全件が
worktreeId/instanceId正で到達し裁定allowが効いた/相関 env なしで受信 0 件/CM_AGENT_INSTANCE_ID未設定は primary に落ちる/受け口停止でもセッションは正常完了(fail-open)/~/.copilot/は前後で sha256 一致。裁定の往復は ≈110ms(予算 ≈10s に対して約 90 倍の余裕)、受け口停止時は 133ms で{} - 空振り緑の反証: ①レジストリから copilot を外す ②イベント名の写像を 1 つ壊す(
Stop→session_start)③設定の書き先をconfig.jsonに変える ④beginAgentSession()の呼び出しを消す、の 4 変異を注入して赤になることを実測した(順に 8・4・4・4 テストが赤。すべて戻して 82/82 緑に復帰) AgentEventSourceI/F は 1 行も変更していない。if (tool === 'copilot')型の抜け道も追加していない
-
feat(hooks): opencode に server API / SSE 経由の構造化イベントと承認裁定を横展開した(Phase 4-5) (#1763)
- opencode セッションの機械判断(
sessionStatus/waitの完了判定 / プロンプト待ち / Auto-Yes)を、TUI スクレイピングから opencode server の構造化イベントに移した。scraper は 2 層目のフォールバックとして残り、構造化イベントが無い環境の挙動は 1 バイトも変わらない - Issue 本文の実装範囲 1・2 は実測で覆っている。 本文は「
opencode serveを別プロセスで立て tmux 内でopencode attach <url>する」「serve プロセスのライフサイクル管理(起動・ポート割当・停止・孤児回収)」を求めていたが、#1758 §5.1.2 が 素のopencodeTUI 自身が同じ HTTP サーバを内蔵していることを実測している(opencode --port <N>で起動した TUI に対し/global/health//event/GET /permission/POST /session/:id/permissions/:permissionIDがすべて serve と同一に応答)。したがって起動経路の変更は'opencode'→'opencode --port <N> --hostname 127.0.0.1'だけであり、killSession(/exit送出)・初期化待ちループ・reconcileExistingSessionは無変更、serve プロセスのライフサイクル管理と孤児回収は実装していない(サーバの寿命 = TUI プロセスの寿命なので、そもそも孤児になりえない)。Issue の「既知の罠」1 番目(/exitが TUI だけ閉じて serve が残る)も起きない - 新設
src/lib/hooks/sources/opencode/: 述語つき写像(mappers.ts)/完了の冪等化(turn-gate.ts)/SSE・REST クライアント(client.ts)/ポート割当と永続化(ports.ts)/globalThis経由の購読レジストリ(subscription.ts)/payload パーサ(payloads.ts)/状態反映(ingest.ts)/起動側の 3 点接続(runtime.ts)。AgentEventSourceI/F は 1 行も変えていない — #1759 のdefinePullEventSourceがそのまま使え、if (tool === 'opencode')の類は 1 箇所も足していない noDecision: { kind: 'blocks' }(実測)。 #1758 §5.5.3 は承認要求を 10 分 19 秒放置しても pending のままであることを計測しており、タイムアウトは存在しない。「応答しなければ TUI 承認に落ちる」というフォールバックは無く、TUI ダイアログと REST は先に答えた方が勝つレースである。したがって「判断できないときは黙る」は opencode でだけ安全側に倒れない ので、裁定を見送ったときはpermission-request-abstain-blocks-agentを必ず warn する(黙って落ちるとセッションが静かに止まり、考え込んでいる agent と区別がつかない)session.idleをwaitの完了判定に使えるようにしたが、数えてはいない。 承認待ちの 10 分間に当該セッションの idle は 1 件も出ない(§5.3.1)のでStopと同義だが、error / abort 経路では 1 ターンで 2 回発火し(19ms 差)、payload はsessionIDのみでターン識別子が無い(§5.3.2 / §5.3.3)。session.status(busy)を観測してから武装 → 最初のsession.idleだけ完了 → 以降は破棄する状態機械を置いた。session.status(idle)はsession.idleと同一ミリ秒の同じ signal なので写像しない- 実機検証で新たに判明:
message.updated(role: user)も 1 ターンに複数回、しかもsession.idleの後に再送される(1.18.3 実測、2026-08-13)。2 通は byte 単位で同一(timeはcreatedのみ)で payload では区別できない。user_prompt_submitには専用イベントが無くこのフレームが唯一の供給源なので、素直に 1:1 で写すと完了したターンの最新イベントがuser_prompt_submitになりstatus-mappingがrunningと読む →commandmate waitが 30 分の staleness bound まで返らない。同じ状態機械で message id 単位に初回だけ通すようにした(再接続では武装状態は捨てるがannounce 済み id は保持する — 新しいプロンプトは必ず新しい id なので保持しても取りこぼさず、再接続がターン終端と末尾再送のあいだに入る窓を塞げる)。#1758 のスパイクでも Issue 本文でも指摘されていなかった欠陥で、実機ドライブでのみ発見した - ポートは CommandMate 側で明示割当する(4200-4299)。
--port 0は「OS に空きを訊く」ではなく「まず 4096、埋まっていたら ephemeral」であり(§5.9.1)、実ポートを知る手段は stdout 1 行かlsofしかない(ポートファイルは書かれない、§5.9.2)。割当は~/.commandmate/opencode-ports.json(CM_OPENCODE_PORT_FILEで差替可)に記録し、CommandMate 再起動後の購読復帰は推測ではなく記録 +/global/health+ worktree パス照合で行う(ハッシュ再導出だけだと、衝突した 2 インスタンスが互いのサーバに繋がって別 worktree のイベントを取り違える) - 購読状態はすべて
globalThis経由(#1736 の前例: dev モードでバンドルが分かれ、書いた側と読む側が別マップになって無言で壊れた)。未知のtypeでは throw せず数えるだけにした —server.heartbeatは 10 秒ごとに届くのにサーバ自身の OpenAPI Event union に載っていない(§5.2.1 / D5) - 縮退は全経路 fail-open。 ポート枯渇・サーバ不達(
--portを知らない旧版 opencode)・SSE 断・CommandMate 再起動のいずれでも scraper に落ちて動き続ける。CM_AGENT_HOOKS_INJECT=0で起動が #1763 前の素のopencodeに戻る - 承認応答は
POST /permission/:requestID/reply({reply, message?})に固定した。per-session 版のPOST /session/:id/permissions/:permissionIDはボディのキーがresponseで拒否理由を渡せないが、こちらは渡した文字列がそのまま tool part のstate.errorに出る(§5.5.2)。question.askedは選択肢が構造化されて届くのでrecordAskUserQuestion()に流している(Claude では scraper に残さざるを得なかったAskUserQuestion相当) - 実機検証(opencode 1.18.3 / 隔離 HOME / 専用 tmux socket
cmate-p45-oc): 素の TUI に--portを付けて起動 →/global/health応答・lsofで TUI プロセス自身が listen することを確認/bashの外部ディレクトリ書き込みでpermission.asked→listPendingが callID 相関でツール名bashを解決 →POST /permission/:id/reply {"reply":"once"}→ ダイアログが消えてコマンドが実際に実行された(marker ファイルが生成された)/1 ターンにつきstopはちょうど 1 件、かつ最後のイベントがstop(上記の末尾再送修正後)/CommandMate 再起動相当(in-memory 状態ゼロの別プロセス)からopencode-port-recoveredで購読が復帰/TUI を kill すると liveness がlost(fetch failed)・probeActivityが null になり scraper 判定に落ちる/2 インスタンス(ポート 4296 / 4295)でイベントの取り違えゼロ(別 sessionID・各 1 ターン)/--port無しで起動した TUI は listener を持たないため health check で弾かれ従来どおり scraper のみで動く。ユーザーの~/.config/opencode/opencode.jsoncと~/.local/share/opencode/は sha256 一致・ディレクトリ構成 diff 空・mtime も検証開始より前のまま(auth.json は mode 600 で複製したのみ)。ユーザーのmcbd-*tmux セッションは全て健在(kill-server不使用) - 空振り緑の反証: ①レジストリから opencode 登録を外す ②
session.idle→stopの写像を壊す ③**noDecisionをproceedsに変える** ④idle の冪等化を外す ⑤beginAgentSession()の呼び出しを消す ⑥user_prompt_submitの再送抑止を外す、の 6 変異を注入して赤になることを実測した(順に 7・9・2・2・1・1 テストが赤。全変異を戻して 202 テスト全緑に復帰することも確認) - fix(test): develop 時点のテスト 2 箇所が
opencodeを「未登録ツールの例」に使っていたので直した。pull-source-contract.test.tsのfinallyとhooks-permission-request-source-1759.test.tsのafterEachがunregisterAgentEventSource('opencode')を呼んでおり、本 Issue が登録した本物のソースを削除するのに assert は通る(=緑のままレジストリが汚れる)。CI はfileParallelism: falseで全ファイルが 1 プロセスを共有するため、削除は以降の全ファイルに波及する(ローカルの並列実行では不可視)。前者は unregister ではなく元のソースを復元するようにし、後者の stub は恒久的にスコープ外のvibe-localへ移した。期待値(expect行)の意味は変えていない
- opencode セッションの機械判断(
-
feat(hooks): codex に構造化イベントと承認裁定を横展開した(Phase 4-2) (#1760)
- #1759 の
AgentEventSourceに codex 実装(src/lib/hooks/sources/codex/)を足し、レジストリに 1 行登録した。I/F の変更も codex 固有の抜け道(if (tool === 'codex')等)も無い。wait/capture --prompts/ Auto-Yes への反映は消費層がツール非依存なので自動的に付いてくる - 設定は
$CODEX_HOME/hooks.json1 本、内容は静的。相関キーは起動コマンド行の環境変数で渡す。 codex には--settings相当が無く 1 ファイルが全セッション共有になるため、worktreeId/instanceIdをファイルに焼き込めない。本 Issue で hook のcommandが POSIX シェルで実行され、hook プロセスが codex を起動したシェルの環境を継承することを実測したので、CM_AGENT_WORKTREE_ID/CM_AGENT_INSTANCE_ID/ 受け口 URL /CODEX_HOMEを起動行に置く方式にした。同一 worktree のcodexとcodex-2が実機で正しく振り分けられることを確認済み(cwdは両者同一なので他に手段が無い) CODEX_HOMEを起動行で pin する。 tmux セッションは tmux サーバの環境を継ぐため、CODEX_HOMEを設定して起動したサーバが書いた hooks.json を、起動された codex が読まない(~/.codexを見る)という無言の食い違いが起きる。実測して修正した(症状はエラー無しの「イベント 0 件」)- 登録は 5 イベント。
SessionStart/UserPromptSubmit/Stop/SessionEnd+PermissionRequest。Notificationは codex に存在しない(レビュー画面が列挙する 11 イベントに無い)。Pre/PostToolUseは消費側が無く 1 ツール呼び出しにつき 2 POST になるだけなので登録せず、capabilities.supportedEventsからも外した(待つ側が永久に待たないように)。SessionEndの timeout は 3 秒(codex がクランプし TUI に警告を常時表示するため) PermissionRequestだけインラインcurl。 応答ボディがそのまま裁定になるため、ボディを捨てる中継スクリプトは使えない。{}→ 通常の承認ダイアログ(fail-safe)、decision.behavior=allow→ ダイアログ無しで実行、受け口停止中も fail-open(セッションは止まらない)を実機で確認した- trust は CommandMate が与えない。 codex の hooks は人間が trust するまで完全に無言で skipされ、trust はユーザーの
~/.codex/config.tomlに書かれる。既定で--dangerously-bypass-hook-trustを使わないのは、このフラグが作業対象リポジトリ内の.codex/hooks.jsonまで無審査で実行させるため(悪意あるリポジトリを開いた瞬間に任意コマンドが走る)。既定は「設定は書く・trust は人間が codex 自身のレビュー画面で 1 回与える」。自動化向けにCM_CODEX_HOOK_TRUST=bypassを opt-in で用意した。作業前後で~/.codex/config.tomlは sha256 一致(notifyを含め非汚染) - 起動時の「Hooks need review」ダイアログを
cli-tools/codex.tsが処理する(3=trust せず継続)。getCodexActiveDialogはこの画面をnullに分類しisCodexPromptReadyも false を返すため、放置すると 30 回ポーリングし切ってダイアログのままsendMessageに渡る(実測)。hooks.json を置くだけで codex の起動が壊れるということなので、注入とセットで入れた - ユーザーの既存 hooks 設定は置換せずマージする。
$CODEX_HOME/hooks.jsonはユーザー自身の codex hooks が置ける唯一の場所でもある。# commandmate:agent-hooksマーカーで自分のハンドラだけを置換し、内容が一致するときはファイルを開かず(trust を無駄にしない)、JSON として読めないファイルは上書きせず注入を諦める - 世代フェンス(#1723 / S8)を
CodexTool.startSessionの生成パスに追加した。無いと前プロセスのuser_prompt_submitを新セッションのものと読んで起動直後にrunningを publish する(単体テストは緑のまま壊れる型) - 空振り緑の反証: ①レジストリから codex 登録を外す ②
Stopの写像を壊す ③noDecisionを実測値と違う値にする ④beginAgentSession()を消す、の 4 変異を注入して赤になることを実測し、戻して緑に復帰することも確認した CM_AGENT_HOOKS_INJECT=0で注入をスキップし、起動コマンドが #1760 以前と 1 バイト同一になることをテストで固定した- test:
tests/setup.tsにCODEX_HOMEの既定を temp dir に pin した。pin が無いと codex セッションを起動するテストが開発者の実~/.codex/hooks.jsonを書き換える(実際に起きたので追加した) - test: #1759 が「未登録ツールの例」として使っていた
'codex'を'vibe-local'に差し替えた(agent-event-source.test.ts/agent-session-lifecycle-1759.test.ts)。特に register→finallyunregister するケースは、本物の codex source を消しながら assert は通る=緑のまま registry が壊れる(CI はfileParallelism: falseで全ファイルが 1 プロセスを共有するため以降の全ファイルに波及する) - 設計と実測は
docs/design/codex-agent-event-source.md
- #1759 の
-
docs/test: codex / copilot / gemini / antigravity の hooks 実機挙動を検証し、実 payload を fixture として収集 (#1757)
- Epic #1720 Phase 4 の全下流 Issue(#1759 / #1760 / #1761 / #1762)が前提にする外部 CLI 4 種の hooks 挙動を実機で確定した。
src/の変更はゼロ(スパイク)。成果物はdocs/design/agent-hooks-phase4-live-verification.mdとtests/fixtures/hooks/{codex,copilot,gemini,antigravity}/*.json(24 payload) - Issue 本文の「copilot に hooks は無いかもしれない(無ければ #1761 は取り下げ)」という前提は誤りだった。
copilot --helpに hook の語が無いのは事実だが、copilot help configがhooks/disableAllHooksを、copilot plugin --helpが「Plugins extend Copilot CLI with additional skills, agents, hooks, …」を明記しており、実際に 6 イベントが発火した。payload は 4 ツール中もっとも Claude Code に近い。4 ツールすべてに hooks が実在したため、横展開 Issue の取り下げ提案は 1 件も無い - 最重要の安全性所見: antigravity (
agy) だけ no-decision が fail-CLOSED。 Claude / codex / copilot は hook が空応答を返すと通常の承認フローに戻る(fail-safe)が、agy はdecisionを欠いた{}を返すとrun_command/list_dir/search_webすべてを拒否する(hooks を外した対照実験で裏取り済み)。一方 timeout(ハンドラ無応答)は fail-open なので、「無応答」と「空応答」で挙動が真逆になる。Auto-Yes v2 が「判断できないときは黙る」実装だと agy ではエージェントが全停止する type:"http"は 4 ツールすべてで使えない(Claude だけの機能)。codex はhooks.jsonに http を 1 つ書いただけでunknown variant 'http', expected one of 'command', 'prompt', 'agent'となり hooks.json 全体が捨てられ全イベントが死ぬ。警告は stderr 1 行のみで TUI には出ない- codex の未 trust hooks は
codex execで完全に無言で skip される(stderr / stdout / ログいずれにも出力なし)。trust はユーザーの~/.codex/config.tomlに[hooks.state."<path>:<event>:0:0"] trusted_hashとして書き込まれるため、ユーザー設定を汚さずに hooks を有効化する経路は--dangerously-bypass-hook-trustかCODEX_HOME差し替えしかない - ライフサイクルの意味論が Claude と揃っていない: codex の
SessionStartは最初のターン送信時に出る(TUI 起動 08:05:15 → SessionStart 08:06:23)。copilot はUserPromptSubmitがSessionStartより先。codex は強制終了時にSessionEndを出さない。session_startを起動完了 signal にしてはいけない - timeout はいずれも fail-open だが既定値が 2 桁違う: codex 600s / copilot ≈10s(実測)/ agy 30s。裁定を返す受け口は copilot に合わせて 10 秒以内に応答する必要がある
AGENT_EVENT_TYPESの 7 語が 4 ツールすべてで揃うわけではない。共通なのはsession_start/pre_tool_use/post_tool_use/stopの 4 語だけ。notificationは codex / agy に存在せず、session_end/user_prompt_submitは agy に存在しない。gemini は語彙自体が別(BeforeTool/BeforeAgent/PreCompressほか)でツール名もリマップされる(Bash → run_shell_command)- agy の payload にはイベント名も cwd も無い(
hook_event_name相当のフィールドが存在せず、workspacePathsは空配列)。イベント種別と worktree ID は中継コマンドの引数に焼き込む以外に手段が無い。キーも 1 ツールだけ camelCase(protojson) scripts/hooks/cmate-agent-event.shは 4 ツールのどれにも使えなかったためスパイクはダンプサーバ直で回避した。--eventの allowlist が 5 語しかなく(pre_tool_use/post_tool_useでdie)、map_event_nameはPreToolUse/PostToolUseも gemini/agy の語も知らない。修正は #1759 の担当なので本 Issue では触っていない- ユーザーのグローバル CLI 設定は 1 バイトも変更していない:
~/.codex/config.toml(notifyの Computer Use 行を含む)/~/.gemini/settings.json/~/.copilot/{config,settings}.json/~/.gemini/antigravity-cli/settings.jsonはいずれも検証前後で diff 空・sha256 一致。ツールごとにCODEX_HOME/COPILOT_HOME/GEMINI_CLI_HOME/HOME差し替えで隔離し、tmux は専用 socket-L cmate-p4spikeのみを使用(kill-server不使用、ユーザーのmcbd-*セッション 11 本は全て健在) - 報告: 設定は無変更だが、3 ツールが検証中に自身の auto-update で版を上げた(gemini 0.42.0→0.55.1、copilot 1.0.77→1.0.79、agy 1.1.7→1.1.12。agy は
AGY_CLI_DISABLE_AUTO_UPDATE=1を付けていても更新された)。外部 CLI の版は実質固定できないため、下流はパーサを「未知フィールドは無視」で組むこと - 未計測(理由つき): gemini の項目 6(timeout)/ 7(承認裁定)と
BeforeTool/AfterTool/AfterAgent/AfterModel/Notificationの payload。この環境の Google アカウントがIneligibleTierError(This client is no longer supported for Gemini Code Assist for individuals)でモデル呼び出しに到達できず、ツール実行を伴うターンを 1 度も成立させられなかったため。「確認できなかった」であって「実在しない」ではない(hooks 機構自体は実在しSessionStartほか 5 イベントを実採取済み)。#1762 が gemini 分を進めるには有効なGEMINI_API_KEYが要る
- Epic #1720 Phase 4 の全下流 Issue(#1759 / #1760 / #1761 / #1762)が前提にする外部 CLI 4 種の hooks 挙動を実機で確定した。
-
docs/test: opencode の server API / SSE による構造化イベントと承認裁定を実機検証した(Phase 4-0b スパイク、コード変更なし) (#1758)
docs/design/opencode-server-live-verification.mdとtests/fixtures/hooks/opencode/*.json(19 件)を追加。opencode 1.18.3 を隔離 HOME・専用 tmux socket(cmate-p4spike-oc)で動かし、GET /eventの SSE 471 フレームとGET /doc(OpenAPI 3.1.0 / Event union 89 種)を一次証拠として検証項目 1〜10 を実測した- 項目 1 は Go。ただし Issue が前提にしていた
opencode serve+opencode attach構成は採らない — 素のopencodeTUI 自身が同じ HTTP サーバを内蔵していることを実測した。opencode --port 4791で起動した TUI に対して/global/health//event/GET /permission/POST /session/:id/permissions/:permissionIDのすべてが serve と同一に応答し、REST 承認でダイアログが消えてコマンドが実行された。これにより #1763 の起動経路変更はsrc/lib/cli-tools/opencode.ts:136の'opencode'→'opencode --port <N>'だけで足り、killSession(/exit送出)・初期化待ちループ・reconcileExistingSessionは変更不要。Issue の「既知の罠」1 番目は問題にならない。同時に「serve が居なければ scraper に落ちる」という縮退設計も意味が変わる(サーバは TUI と同一プロセスなので中間状態が存在しない。縮退が必要なのは--port無しで起動された既存セッションに対してのみ) - 項目 3(
session.idleの意味)はwaitの完了判定に使ってよい。 承認待ち 10 分 19 秒・質問待ち 40 秒のあいだ、当該セッションにsession.idleは 1 件も出ずsession.statusもbusyのままだった(GET /session/statusもbusy)。つまり Claude のStopと同義の「ターンが終わった」であり「セッションが暇」ではない。ただし error / abort 経路では 1 ターンで 2 回発火し(MessageAbortedError→ idle → 19ms 後に idle を実測)、payload はsessionIDのみでターン識別子が無いため、waitは「busy を観測してから武装 → 最初の idle で完了 → 以降を無視」+session.errorの併読が必須 - 項目 5(no-decision)は hooks と挙動が逆。 「応答しなければ通常の TUI 承認へ落ちる」というフォールバック段階は存在しない — TUI ダイアログは
permission.askedと同時に無条件で描かれ、REST と TUI は最初から並行しており先に答えた方が勝つレースである。そしてタイムアウトが無い(10 分 19 秒放置して自動裁定・タイムアウトイベントとも 0 件)。したがって Auto-Yes は fail-open しない: Claude は「黙る=安全(fail-open)」だったが opencode は「黙る=エージェントが無限停止」で、逆に誤って allow を返すと人間が読む前にダイアログが消える。#1763 は裁定を見送ったことを利用者に見せる必要がある - 項目 6 / 9: 1 サーバに複数 session を載せられる(2 session 同時 busy を実測)が、
GET /sessionは「このサーバの session」ではなく同一 HOME / project の全 session を返す(別ポートの serve が作った session まで見えた=opencode.db共有)ため instance 一覧として使ってはいけない。推奨は 1 インスタンス = 1 TUI プロセス = 1 ポート(当該ポートのsession.createdが一意に自分のものになる)。POST /session→-s <sessionID>による事前バインドも実測で成立。--port 0は「OS に空きを訊く」ではなく「まず 4096、埋まっていたら ephemeral」(1 本目 4096 / 2 本目 58153 を実測)で、実ポートを知る手段は stdout 1 行かlsofだけ(ポートファイルは書かれない)→ CommandMate 側で明示割当するのが唯一の安全策 - 項目 10: plugin は採用しない。 ローカル JS plugin は動作し
init/event/tool.execute.before/tool.execute.afterが発火したが、eventの語彙は SSE と完全に同一で情報が増えず、承認を裁定する plugin フックが存在しない("permission.ask"という文字列自体が 1.18.3 のバイナリに無く、hook 面はevent/tool.execute.*/command.execute.beforeのみ)。Phase 4-5 の中核が plugin では実装できない - Issue 本文・
--helpと実測の食い違いを 6 件記録(うち D1「serve/attach 不要」と D2「no-decision フォールバック不在」は設計の形を変える)。加えてserver.heartbeat(10 秒周期・死活監視の signal)がサーバ自身の OpenAPI Event union に載っていない、/api/eventは 1 ターンの先頭 3 件で無言に沈黙する(独立に 2 回再現)、GET /api/session/:id/event?after=<seq>は 1 バイトも返さない(durable replay による再接続は使えない)ため、購読は legacy/event一本に決めた question.askedが質問文・ヘッダ・選択肢を構造化して配り、POST /question/:id/replyで答えられることを実測した。Claude では scraper に残さざるを得なかったAskUserQuestion(#1708 / #1726)が opencode では完全に構造化イベントで扱える- 最重要成果物として §9 に Phase 4-1(#1759)への要求事項を、実測から導いた 8 つの制約(到着方向・裁定経路・fail 方向・7 語への非 1:1 写像・相関キー・接続健全性・再同期・未知 type)と具体的な型シグネチャ案(
AgentEventTransport/NoDecisionBehavior/Verdict/decide()/listPending()/liveness())として記載した。AgentEventSourceが push 型(hooks)だけでなく pull 型(SSE 購読 + REST 応答)も表現できる形でなければならないという制約の根拠がここにある - 非汚染の証拠:
~/.config/opencode/opencode.jsoncの before/after diff は空(sha2564e901f9e…不変)、~/.local/share/opencode/の構成 diff も空、opencode.db(58MB)とauth.jsonの mtime は検証開始前のまま。tmux -L cmate-p4spike-oc専用 socket のみ使用しkill-serverは未使用(ユーザーのmcbd-*10 本は健在)、本番サーバ(port 3000)へは 1 リクエストも送っていない、立てた serve は PID 指定で個別 kill(広域pkill未使用)。src/の変更は 0 件
-
slash-commands: カタログを claude docs へリコンサイルし、未反映の 3 コマンドを追加 (#1767)
/agents/import/list-agents(いずれも claude のみ)。code.claude.com/docs/en/commands.mdの実行(2026-08-13)で 3 件とも実在する行であることを確認済み。カタログ総数 159 → 162、claude 可視数 97 → 100(codex 53 / opencode 10 / antigravity 13 は変化なし)/agentsと/importは claude 専用の説明キーを持つ(Issue #1704 の tool-scoped key)。/agentsは opencode の「利用可能なエージェントを一覧・管理」、/importは codex の「Claude Code から設定…を取り込み」という別の意味の説明を共有していたため、そのままでは claude のパレットに他ツール向けの誤った説明が出る。slashCommands.descriptions.{agents,import}を tool 別オブジェクトへ分割し、既存ツールの文面は 1 文字も変えずにそれぞれのキーへ移した/schedule(#1488 のキュレーション判断)・/ultraplan(#1503 の幻コマンド)・/pr-comments/vim(上流で削除済み)・/cost/stats(/usageのエイリアス)は従来どおり追加していない
Changed
-
feat(hooks): gemini / antigravity に構造化イベントを横展開した(Phase 4-4) (#1762)
docs/design/agent-event-source-interface.md§4 の手順 1〜8 を 2 ツール分実行した。新設src/lib/hooks/sources/gemini/(tool-id/event-vocabulary/settings-generator/shared-config-tree/source)とsrc/lib/hooks/sources/antigravity/(tool-id/hooks-config/source)、registry.tsに 2 行、index.tsに re-export、cli-tools/{gemini,antigravity}.tsのstartSessionに世代フェンスと注入。AgentEventSourceI/F は変更していない/if (tool === '…')型の抜け道も入れていない/hook-event-vocabulary.tsの共有表に gemini・agy の綴りを足していない(判断根拠はdocs/design/agent-hooks-gemini-antigravity-integration.md)- 実機検証で Issue 本文・#1757 の記載と食い違った点(実測を正とした)
- gemini の hook
timeoutはミリ秒。 #1757 §8.2 R13 は「単位はすべて秒」としていたが誤り。他ツールと同じtimeout: 5を書いた実 v0.55.1 セッションでHook execution error: Hook timed out after 5msとなり、hook は登録され開示バナーにも出て起動もしているのに curl が socket を開く前に殺され、全イベントが無言で失われた。バンドルのDEFAULT_HOOK_TIMEOUT = 6e4(=60 秒)とメッセージHook timed out after ${timeout}msで裏取り。GEMINI_HOOK_TIMEOUT_MS = HOOK_TIMEOUT_SECONDS * 1000に修正し、ms/秒それぞれをテストで固定した(agy は doc どおり秒。実測でもtimeout: 5で 3 イベント配送成功) - gemini の項目 6/7(#1757 §5.3.6 で未計測)を出荷バイナリの実装読みで確定した。
DefaultHookOutput.isBlockingDecision()はdecision === 'block'|'deny'、isAskDecision()は=== 'ask'、shouldStopExecution()はcontinue === false=空応答にはどのフィールドも無いので no-decision は fail-OPEN。計測(「そのとき止まらなかった」)より強い根拠(「止められる分岐が無い」) - gemini の
Notificationの subtype はToolPermissionの 1 種のみ(NotificationTypeの唯一のメンバ)。意味は Claude のpermission_promptと同一 - agy の
Stopは--print(headless)モードでは発火しないケースを確認した(ツール承認が auto-deny で終わった実行では CommandMate の hook もユーザー自身の hook も動かなかった)。ツールを使わない普通のターンでは発火する
- gemini の hook
- gemini の Policy Engine と hooks の優先順位(Issue の必須確認事項)— Auto-Yes の意味は一意である。 scheduler の実装は
evaluateBeforeToolHook()→checkPolicy()→if (hookDecision === 'ask') decision = ASK_USERの順。hook にできるのは (a)deny/blockで即座にツールを落とす (b)continue:falseで停止させる (c)askでダイアログへ引き上げる の 3 つだけで、checkPolicyの裁定を「承認」に変える分岐は存在しない('allow'を hook 出力から読む箇所も無い)。したがって gemini の承認は Policy Engine(--approval-mode/--policy/-y)専任であり、CommandMate はencodeVerdict()で常に{}を返し、Policy Engine のフラグも一切渡さない(hooks を注入しても gemini セッションの権限は広がらない)。gemini の Auto-Yes は従来どおり TUI 経路のみで、hooks 側に第 2 の承認経路が生まれないため二重定義にならない - agy のグローバル設定 1 本を複数 worktree でどう扱うか(Issue の必須確認事項)— 相関を設定ファイルから追い出した。 agy が読むのは
~/.gemini/config/hooks.jsonただ 1 本(doc の<workspace>/.agents/hooks.jsonは読まれない)、payload にcwdもworkspacePathsも無く、hook の cwd は~/.gemini/config。よって設定ファイルには worktree も instance も書かず、起動コマンドのCM_HOOK_URL='…?tool=antigravity&worktreeId=…&instanceId=…'に相関を載せた(中継スクリプトはCM_HOOK_URLを自分で読むので hook コマンド内のシェル展開に依存しない)。1 本の設定で N worktree × M instance が同時に成立し、CommandMate 外で起動された agy は cwd が worktree に解決できず 202 で捨てられる(誤紐付けは起きない)。HOME差し替えは gemini の OAuth 資格情報ごと巻き添えにするため採らなかった - agy の
PreToolUseは張っていない。decision必須の fail-CLOSED({}で全ツール拒否=#1757 P10)で、中継スクリプトは stdout に何も書かないため、張った瞬間にマシン全体のツール呼び出しが止まる。中継に裁定を返す機能を足すのはscripts/hooks/**の変更=本 Issue のスコープ外なので、agy の承認裁定(Auto-Yes v2 経路)は本 Issue では実装していない(encodeVerdictは正しい wire 形を実装済みで、中継が対応した時点でPreToolUseを登録すれば有効になる)。PostToolUse/Stopは doc と実測の両方で空応答が安全 - 登録イベント: gemini =
SessionStart/BeforeAgent/AfterAgent/Notification/SessionEnd(BeforeTool/AfterToolは写像だけ持ち登録しない — 同期実行でツール呼び出しごとに 2 往復ブロックする一方、runningはBeforeAgentが既に立てている)。agy =SessionStart/PostToolUse(matcher*) /Stop(agy にはuser_prompt_submitが無いためPostToolUseが唯一の「実行中」signal) - 共有 config ツリーの非破壊:
~/.gemini/には gemini のsettings.jsonと OAuth 資格情報、agy のconfig/hooks.jsonとantigravity*が同居する。書き込みは常にマージ(gemini はhooks内の自分のエントリだけ差し替え、agy は名前つき hookcommandmateの 1 キーだけを占有)。新規作成時のみ file 0600 / dir 0700 で、既存ファイルの permission は変えない。両方向の非破壊を sha256 でテスト固定した(最悪ケースとして「worktree の.geminiが共有ツリーそのもの」でも検証) - 消費層の subtype 語彙が Claude のままだった点への対処:
status-mapping.ts/agent-event-state.tsはdetail === 'permission_prompt'というリテラルを直接比較する。#1759 が抽象化したのはイベント名だけなので、gemini のToolPermissionをそのまま publish するとイベントは正しく届いて記録もされるのに永久にwaitingにならない。ソース側(extractDetail)で翻訳した AgentEventSourceI/F で表現できなかったもの(#1759 への報告。抜け道は作っていない): (G1)AgentInstanceRefに worktree のパスが無く、configScope:'per-worktree'の gemini はprepareLaunchだけでは設定を書けない →injectGeminiHookSettings(worktreePath, target)を別 export しcli-tools/gemini.tsから呼ぶ(settingsPathは null)。(G2)NoDecisionBehaviorに「裁定しない=拒否される」が無い(proceeds/blocks/blocksUntilの 3 値)→ agy はblocksを選択(describeAbstain().safe === falseになる唯一の値で、拒否は待機より悪いので危険側に倒れる)。summary の文面は機構としては不正確。(G3) subtype 語彙が共通化されていない(上記)- 実機検証(本番サーバ port 3000 には 1 件も飛ばしていない。ローカルのダンプサーバ 3762 とツール専用 tmux socket
cmate-p44-gemのみ使用)- gemini:
session_start/user_prompt_submit/session_endの 3 種が実 v0.55.1 から受け口まで到達(クエリtool=gemini&worktreeId=wt-live&instanceId=gemini-2、body にcwd/sessionId/detail)。AfterAgentとNotificationはこのマシンのアカウントがIneligibleTierError(UNSUPPORTED_CLIENT)でモデル呼び出しに到達できず未計測(#1757 §5.3.6 と同じ制約。「確認できなかった」であって「実在しない」ではない — 綴りは CLI 自身のHookEventNameenum とhooks migrateの変換表で確定済み)。ユーザーのsecurity.authキーを含む既存 settings.json がマージ後も残ることを実ファイルで確認 - agy:
session_start/post_tool_use(×6) /stopが実 v1.1.12 から到達(クエリinstanceId=antigravity-2、cwdは予告どおり~/.gemini/config)。同じhooks.jsonに置いたユーザー自身の名前つき hook (user-probe) も同時に発火=併存を実機で確認。Stopに空応答を返してもエージェントは正常に停止した(fail-safe を実測) - fail-open: 受け口を落とした状態で両ツールを実行し、gemini は hook 失敗ログ 0 件・agy は通常どおり応答(どちらも停止しない)
- ユーザー設定の before/after:
~/.gemini/settings.json/~/.gemini/config/*.json/~/.gemini/antigravity-cli/settings.jsonの sha256 が全一致・diff 空。検証用に作成した~/.gemini/config/hooks.jsonは検証前が「ファイル無し」だったので削除して原状復帰済み
- gemini:
- 空振り緑の反証: 10 変異すべてが赤になることを実測し、戻して緑に復帰することを確認した(括弧内は赤になったテスト数)— registry から gemini を外す(7) / antigravity を外す(3) / gemini の
AfterAgent→stopを壊す(4) / agy の設定に焼く--event stopを壊す(1) / agy のnoDecisionをproceedsにする(2) / gemini の設定書き込みをマージ→置換にする(5) / agy の同(2) / gemini のbeginAgentSessionを消す(4) / agy の同(2) / gemini の timeout を ms→秒に戻す(1) - 品質ゲート実測(
cmd > log; echo $?):npm run lint0 /npx tsc --noEmit0 /CI=true npm run test:unit0(827 files・15175 tests)/CI=true npm run test:integration0(80 files・1147 passed・2 skipped)。並列モードのnpm run test:unitでは実行ごとに違う無関係ファイルが 5s timeout で落ちた(1 回目gate-runner-timestamps、2 回目hooks-claude-done-delegationほか 2 件。単独実行ではいずれも緑、CI モードでは全緑)=負荷起因で本変更とは無関係 - 既存テストの期待値は削除していない。
tests/unit/cli-tools/antigravity.test.tsのみ、startSessionが実~/.gemini/config/hooks.jsonを書かないよう HOME を一時ディレクトリに退避し、sendKeysの 4 件をCM_HOOK_URLプレフィックスつきの形に更新した(#989 の--modelクォート検証は維持)
-
refactor(hooks):
AgentEventSource抽象を抽出し、Claude 固有の継ぎ目をツール別実装に分離した(Phase 4-1) (#1759)- Epic #1720 の Phase 4-2〜4-5 が「1 ファイル足せば 1 ツール増える」形になるための土台。Claude の挙動は 1 バイトも変えていない(
buildAgentHookSettingsの出力・PermissionRequestの応答ボディ・注入される--settingsの内容はすべて develop と同一であることを実測で確認済み) - 新設
src/lib/hooks/sources/: I/F(types.ts)/述語つきマッパ(event-mapper.ts)/definePushHookSource・definePullEventSource(define-source.ts)/裁定スロット(pending-decisions.ts)/describeAbstain(abstain.ts)/globalThis経由のレジストリ(registry.ts)/未対応ツール用の互換ソース(legacy-relay.ts)/Claude 実装(claude/)。ツール追加手順はdocs/design/agent-event-source-interface.md scripts/hooks/cmate-agent-event.shの語彙不足を修正した(Phase 4 全体のハードブロッカー)。type:"http"は Claude 専用で他 4 ツールは使えない(#1757)ため、この中継が唯一の配送路である。にもかかわらず--eventは 5 語しか受けず、#1726 でAGENT_EVENT_TYPESに入ったpre_tool_use/post_tool_useを渡すと exit 2 していた。加えてmap_event_nameはPreToolUse/PostToolUseも gemini のBeforeTool/AfterTool/BeforeAgent/AfterAgentも知らなかった。7 語すべて + 5 ツールの native 名に対応させ、session id の抽出にconversationId(antigravity)/turn_id(codex)を追加し、--detailを新設した(bash 3.2 互換を維持)NoDecisionBehaviorを型として持たせた(実測 C3)。「裁定しない=安全」は成り立たない: Claude / codex / copilot は無応答で通常フローへ進むが、antigravity は空応答を「拒否」と解釈してツールを全停止し、opencode はタイムアウト無しで無限待ちする(10 分 19 秒の実測)。/api/hooks/permission-requestはabstainかつnoDecision.kind !== 'proceeds'のときpermission-request-abstain-blocks-agentを warn するようにした。どちらの失敗もセッションが無言で止まる(考え込んでいる agent と区別がつかない)ため、記録が唯一の手がかりになる- イベント写像を「名前の表」から「述語つきマッパ」に変えた(C4)。
Record<string, AgentEventType>では opencode を写せない —message.part.updatedはpart.state.statusによってpre_tool_useとpost_tool_useに割れ、user_prompt_submitには専用イベントが無く(message.updatedのinfo.role)、notificationは 3 つの別イベントの束である - 未知イベントで throw しないことを型と実装の両方で固定した(C8)。 opencode の
server.heartbeatはサーバ自身の/docに型定義が無いのに 10 秒ごとに届く。未知はnullを返して数える(getUnknownEventTally()) - 世代フェンス(S8)を共通ヘルパ化した。
beginAgentEventGenerationの呼び出しは全コードベースでclaude-session.tsの 1 箇所だけだった=他ツールにはフェンスが無い。src/lib/session/agent-session-lifecycle.tsのbeginAgentSession()に集約し、Claude の既存呼び出しをそれ経由に置き換えた。Phase 4-2 以降は各startSessionからこの 1 行を呼ぶ - S1/S2(イベント名の綴りと subtype 抽出)を
agent-event-types.tsからhooks/sources/hook-event-vocabulary.tsへ移した。agent-event-types.tsに残るのは 7 語だけになり、ツール非依存の共有モジュールに 1 ツールの綴りが同居する状態を解消した(次のツールが同じ表に追記される導線を断つのが目的) - I/F が pull 型(SSE 購読 + REST 応答)を表現できることを、#1758 §9.4 のチェックリスト 11 項目に照らして検証した(
tests/unit/hooks/sources/pull-source-contract.test.tsが opencode の実 SSE fixture でソースを実際に組み立てて確認する)。実際の opencode 対応は #1763 のままスコープ外 - 空振り緑の反証: レジストリから claude を外す/
Stop→stopの写像を 1 つ壊す/noDecisionを全ソースproceedsに固定する/describeAbstainを常に safe にする、の 4 変異を注入して赤になることを実測した(順に 11・26・2・6 テストが赤) - fix(test):
/proc配下を env var に代入する fixture が Linux CI を 5h31m ハングさせたため差し替えた(PR #1773 の postmortem)。CM_AGENT_HOOKS_DIR = '/proc/…'が製品コードのmkdirSync(dir,{recursive:true})に届き、procfs は存在し得ない子への mkdir に EPERM ではなく ENOENT を返すため Node の recursive 実装が「親が無い=作って再試行」と解釈して C++ 内で同期無限ループになる(コンテナ実測: 25 秒経っても返らず CPU 100.4%・メモリ 12.66MiB で平坦)。同期ループなのでイベントループが止まり vitest の testTimeout すら発火しない(OOM 痕跡も残らず、ログが無音になるだけ)。macOS では/procが存在せず即 throw → 緑になるので、原理的にローカル再現しない。fixture を「親が通常ファイルであるパス」に変更した(通常ファイルは子を持てないので全 OS で即 ENOTDIR。macOS/node 24 0ms・Linux/node 18 1ms)。テストの意図(fail-open の検証)とexpect行は不変 - 再発防止に
tests/unit/guards/no-procfs-env-fixtures.test.tsを追加した。tests/全体を走査し env var への/proc/sys/dev配下パスの代入(process.env.X = …/process.env['X'] = …/vi.stubEnv(…))を禁じる。#1760〜#1763 の 4 ワーカーが当該ファイルを雛形にするため、コメントではなく赤いテストで止める。文字列としての/proc参照は対象外(system-directories.test.ts/db-migration-path.test.ts/git-workflow.test.tsは実 fs に触れない)。/dev/null等の終端デバイスノードは完全一致で許可(GIT_CONFIG_GLOBAL=/dev/nullは既存の正当な idiom) - 既存テストの期待値(
expect行)は 1 つも削除・変更していない。tests/unit/scripts/cmate-agent-event.test.tsの 2 ケースだけ入力値を差し替えた(PreToolUseは #1726 以降「対応語の無いイベント」ではなくなったため、その役をPreCompactに交代させた。期待値は同一)
- Epic #1720 の Phase 4-2〜4-5 が「1 ファイル足せば 1 ツール増える」形になるための土台。Claude の挙動は 1 バイトも変えていない(
Fixed
- fix(security): 設定パスが
/proc/sys/dev配下でもサーバが停止しないようにした (#1774)/procへの recursive mkdir は失敗せず「返らない」。 procfs は存在し得ない子へのmkdirに EPERM ではなく ENOENT を返し、Node の recursive mkdir はそれを「親が無い」と解釈して親を作って再試行する。親(/proc)の作成は EEXIST で成功扱いになるので 1 階層でもループに入る。同期版はイベントループごと停止(try/catchは書いてあっても呼び出しが返らないので到達しない)、非同期版は promise が永久に settle せず libuv スレッドプールを 1 本恒久占有する(既定 4 本)。エラーログも OOM も残らない — PR #1773 のUnit Testsは fixture がCM_AGENT_HOOKS_DIRに/proc/…を入れたことでこの状態に入り、5 時間 31 分 57 秒、出力ゼロで走った(同期スピンなので vitest のtestTimeoutも発火できない)- Linux / コンテナ限定。 macOS は
/procが無いため即 throw して fail-open になり、ローカルでは構造的に再現しない - 共有ヘルパ 1 つを各 resolver に通す形にした(
src/config/safe-directory.tsのresolveSafeDirectory(candidate, fallback, source))。Issue 本文はCM_AGENT_HOOKS_DIR/CM_LOG_DIRの 2 経路と書いているが、develop3e8a6192時点の実測は 5 経路(Phase 4 の #1760 / #1761 / #1763 がCODEX_HOME/COPILOT_HOME/CM_OPENCODE_PORT_FILEを同型で追加していた)。「2 箇所に足す」ではなく resolver 側に集約したので、6 個目のツールは既存の resolver を呼ぶだけで守られる。gemini/shared-config-tree.writeJsonObjectFile()(~/.geminiツリーの共通 writer)は引数でパスを受け取り代替既定値が無いので、フォールバックではなく throw で拒否する(呼び出し元 2 つは既に throw を「hooks 無しで起動」として扱う=fail-open の形は不変) isSystemDirectory()をそのまま適用するのは誤りで、実測で退けた(Issue 本文の「提案する対処 1.」からの意図的な逸脱)。 同関数は/tmpと/varも弾くが、そこはハングしない普通の書き込み可能ディレクトリであり、os.tmpdir()は Linux で/tmp・macOS で/var/folders/…と両方その配下にある。適用するとtests/setup.ts:15のCODEX_HOME隔離とtests/helpers/agent-hooks-dir.tsが既定値に落ちてテストがユーザーの実~/.codexと~/.commandmate/hooksを書き換え、コンテナ運用のCM_LOG_DIR=/var/log/commandmateも黙って化ける。ハングを防げないうえに正常な構成を壊すので、VIRTUAL_FILESYSTEM_ROOTS(/proc/sys/dev)を名前付き部分集合として切り出し、判定機構(lexical/physical 両解決+境界一致)は既存のものを共有した。VIRTUAL_FILESYSTEM_ROOTS ⊂ SYSTEM_DIRECTORIESはテストで固定してある- throw しない。 ログディレクトリで throw するとロギング自体が死に、hooks は既に fail-open が設計方針。既定値へフォールバックして
logger.warnを出す。警告は(source, candidate)ごとに 1 回だけ(getLogDir()は全ログ書き込みの経路上にあるため、無条件だと 1 つの設定ミスがログ洪水になる) - テストは fs にも env にも触れない。
tests/unit/guards/no-procfs-env-fixtures.test.tsが env 代入を機械的に赤にするので、①述語と resolver は素の文字列引数で全分岐を固定 ②5 設定の配線は述語を mock した無害な sentinel パスで end-to-end に確認 ③引数を取る入口(HookSettingsOptions.directory/CodexHookOptions.codexHome/writeJsonObjectFile)だけ実/proc文字列で fail-open まで通す、の 3 段に分けた - 空振り緑の反証: 10 変異を 1 つずつ注入して全部赤になることを実測(経路ごとのガード除去 8 種=順に 2・3・2・2・3・2・2・3 テスト赤/
isVirtualFilesystemPathを常に false=32 赤/フォールバックを候補値そのままに変更=23 赤)。すべて戻して 136/136 緑に復帰 - 実機検証: ①実 Linux コンテナ(
node:24-bookworm)でガード無しのmkdirSync('/proc/x/y',{recursive:true})が 15 秒間 CPU 100.4% / メモリ平坦のまま返らないことを再現し、②同じコンテナで実コードをバンドルして走らせると 1ms で既定値へフォールバックし/proc/xは作られないことを確認(/tmp/var/log~/.codex相対パス/procfs/devicesは素通し)。③macOS 側は隔離環境(ポート 3774・隔離 DB、本番 3000 は無停止)でCM_LOG_DIR=/proc/definitely-not-writable/cmate-1774を与えてサーバが起動しGET /が 200、/api/worktrees/<id>/logsが 200 で<cwd>/data/logsにフォールバックし、6 回叩いても警告は 1 回であることを実測。~/.commandmate/~/.codex/~/.copilot/~/.gemini/は前後で sha256 マニフェスト一致(差分は develop 由来の既存テストagent-session-lifecycle-1759.test.tsがCM_AGENT_HOOKS_DIRを隔離せず書く 1 ファイルのみで、内容はCM_PORTから決まる=本変更とは無関係。同一環境の 2 回連続実行で同一ハッシュを実測して確認) grep -rn "mkdirSync(\|mkdir(" src/ | grep recursiveの全 25 件を 1 件ずつ判定し、安全だったものも根拠つきでdocs/design/virtual-filesystem-directory-guard.md§4 に記録した(DB 系 3 件はisSystemDirectory済み、skills 系 6 件は検証済み root 配下、file-operations3 件はcheckPathSafetyで worktree 内に限定、CLI 4 件とtmux/read-modeはhomedir()定数、ほか)。残存リスクとして「これらの安全はHOMEが正気であることに依存する」を明記