AI

コーディングエージェントのhooksを共通のプロトコルに寄せる

  • Claude Code
  • macOS
  • Codex CLI
  • OpenCode
  • Grok Build

社内で試験的に開発中のターミナルアプリでは、コーディングエージェントやコマンドを動かしているタブに「実行中」「承認待ち」「完了」「失敗」を識別できるように調整を行っています。 CLI が自分から送ってくるシグナルだけで組みたいので、Claude Code・Codex CLI・OpenCode・Grok Build の 4 つについて、ターミナルに実際に流れてくるバイト列を PTY で記録し、hooks で何が取れるかを突き合わせました。

結果は、ターミナルに流れてくるシグナルだけで 4 状態が揃う CLI は 1 つもなく、揃うのは CLI ごとの hooks を共通のプロトコルへ変換したときだけでした。この記事の内容は 2026 年 9 月時点の各 CLI のバージョンで確認した範囲です。

画面を読まずに状態を知りたい

開発中のターミナルアプリは libghostty を組み込んでいて、タブごとにコマンドやエージェントの状態を表示します。既存のツールの中には、画面下部のテキストを定期的に読んで「承認を求めるプロンプトが出ているか」を判定するものがあり、非常に便利です。 弊社では目的が AI エージェントのオーケストレーターではないため、最初からこれを採らないと決めました。

代わりに使えるのは、CLI が自分から端末に書くエスケープシーケンスです。Ghostty はこれを runtime action として上位に渡してくれるので、ターミナルアプリではコールバックを受けるだけで済みます。関係するのは 3 種類です。

  • OSC 9;4
    • ConEmu 由来の進捗表示用のシーケンスで、ESC ] 9;4;<state>;<value> の state が 0 で「消す」、1 で「値を設定」、3 で「不定」を意味する
    • Ghostty は値が読めない 9;4;1;-1 を、値なしの進捗として上位に渡す
  • OSC 777 と OSC 9
    • デスクトップ通知用のシーケンスで、タイトルと本文の文字列を運ぶ
  • OSC 133
    • shell integration 用のシーケンスで、プロンプトとコマンドの開始・終了の位置を示す

これらのみで足りるかどうかを、CLI ごとに測りました。

測り方

各 CLI を /usr/bin/script の下で起動して PTY に流れる全バイトを記録し、expect からツール呼び出しを起こさない最小のプロンプト(「Reply with exactly OK and nothing else」)を 1 turn だけ送りました。記録から ESC ] で始まるシーケンスを種類ごとに数えています。端末からフォーカスが外れたことを条件にする通知は、script 配下では focus イベントが来ないので、プロンプト送信の 1.5 秒後に focus-out イベント(ESC [ O)を PTY に書き込んで再現しました。

設定ディレクトリは CLI ごとに使い捨ての場所へ向け(CLAUDE_CONFIG_DIRCODEX_HOMEGROK_HOME、OpenCode は XDG_CONFIG_HOME)、手元の hooks やプラグインが混ざらないようにしました。Grok Build は Claude Code や Cursor の hooks を既定で拾いに行くので、GROK_CLAUDE_HOOKS_ENABLED=false などの環境変数で止めています。

この測り方で注意すべきことが 3 つあります。

  • ログインが必要なエージェントは、隔離先の設定ディレクトリを指定した状態で事前にログインしておくこと
  • 承認待ちは、ツール呼び出しを起こすプロンプトを送らないと発生しない
    • OpenCode の「Permission needs input」と、承認待ちを Ctrl-C で中断したときの「Session done」は、以前の計測の記録によります
  • turn の長さで回数が変わる場合があること
    • Grok Build の 9;4;1;-1 は 66 秒の turn で 14 回でしたが、2 秒で終わる turn では 1 回でした
    • 中断時の 9;4;0;0 も 66 秒の turn を手動で止めたときの記録で、隔離環境で観測した 9;4;0;0 は turn 終了と TUI 終了の 2 回です

ターミナルに流れてくるもの

CLIバージョンturn 中の OSC 9;4完了時の通知承認待ち
Claude Code2.1.263Ghostty の runtime action で INDETERMINATEREMOVE を交互に受ける(以前の計測)focused では未観測。公式には Ghostty・Kitty・iTerm2 で離席時にデスクトップ通知を送る(未計測)承認待ちと turn 終了は同じ REMOVE で、区別できない
Codex CLI0.153.4出さない設定時のみ素の OSC 9(本文は応答テキスト)なし
OpenCode1.15.10出さないattention を有効にし、かつ端末からフォーカスが外れているときだけ OSC 777「Session done」同条件で OSC 777「Permission needs input」(以前の計測)
Grok Build1.0.139;4;1;-1 を繰り返し、終了と中断で 9;4;0;0OSC 777(unfocused 条件なので focused では出ない)なし

busy だけなら Claude Code と Grok Build は OSC 9;4 で取れます。Grok Build は 2 秒で終わる turn で 9;4;1;-1 が 1 回、66 秒かかった turn(手動で中断)では 14 回でした。Codex と OpenCode は turn 中に何も出しません。

OpenCode の OSC 777 は条件が 2 つ重なったときだけ出ます。attention を有効にしたうえで、端末からフォーカスが外れていることです。focus-out を送らなかった実行では attention が有効でも 0 回、attention が既定の無効のままだと focus-out を送っても 0 回でした。Grok Build の OSC 777 も既定の unfocused 条件では focused のままの実行で 0 回、condition = "always" にすると「Turn complete in 2.2s.」が 1 回出ました。

「完了」と「承認待ち」は、どの CLI でも人間向けの通知文でしか届きません。しかも OpenCode では、承認待ちを Ctrl-C で中断しても「Session done」が出ました。通知文から完了と失敗を推測すると、中断を完了と数えることになります。Claude Code は承認待ちと turn 終了で同じ REMOVE を出すので、OSC 9;4 だけでは「止まっている理由」が分かりません。

Claude Code の turn 中は INDETERMINATE と REMOVE が交互に来る。承認を挟まない turn は最後に REMOVE で止まり、承認を挟む turn は承認待ちに入ったところでも REMOVE で止まって、承認後に再開し、最後にまた REMOVE で止まる。承認待ちと turn 終了の REMOVE は同じシグナルなので、止まった理由はシグナルからは分からない

OSC 133 は使えません。shell integration が示すのはシェルから起動したコマンドの開始と終了で、起動したままの TUI は turn が終わってもプロセスが終わらないからです。

hooks で取れる状態と、取れない状態

4 つとも、turn の開始・終了・承認待ちといったタイミングで自前のコードを呼べる仕組みを持っています。Claude Code・Codex・Grok Build はコマンド hooks、OpenCode はプラグインのイベントです。そこから端末にエスケープシーケンスを書けば、CLI がターミナルに出さない状態も届けられます。ただし、どのタイミングで呼ばれるかは CLI ごとに違います。

4 つの CLI について、ターミナルに流れるシグナルだけで取れる状態と、hooks を足して初めて取れる状態を並べた表。実行中は Claude Code と Grok Build だけが OSC 9;4 で出す。承認待ち・完了・失敗はターミナルのシグナルだけではどの CLI も取れず、hooks で補って初めて取れる。失敗は Codex の hooks では取れない

Claude Code の hooksterminalSequence という戻り値で、hook の出力としてエスケープシーケンスを端末に書けます。StopFailure では hook の出力と終了コードが無視されますが、terminalSequence だけは有効です。NotificationStopStopFailure の 3 つに hook を張り、この経路で書けば対応できます。

Codex CLI には terminalSequence 相当がなく、hook の標準出力は Codex が制御用に読みます。代わりに hook から /dev/tty へ直接書くと、PTY にそのまま届くことを計測で確かめました。ただしドキュメントに書かれた仕様ではなく観測した挙動なので、TTY がない場合は何もしない作りにしています。取れないのは失敗です。Stop のペイロードに完了・中断・失敗の区別がなく、StopFailure 相当のイベントもありません。中断は、最初の計測時にはドキュメントになかった Interrupt がその後に公式のイベント一覧へ載ったので取れるようになります。

OpenCode は端末ではなくプラグインのイベントが豊富で、busyretryidlesession.errorpermission.asked を push で受けられます。成功する turn は busy が 3 回来て idle、プロバイダー障害では busyretry が交互に来るところまで観測しました。落とし穴は 2 つで、イベントにはセッションの親子関係が載らないので subagent の idle を本体の完了と取り違えないよう session.created を覚えておくこと、session.error の後にも idle が来ることがあるので失敗を完了で上書きしないことです。

Grok Build は 4 つの中でイベントの種類が最も揃っていて、StopStopFailureStopCancelled が別々に来ます。承認待ちは Notificationpermission_promptagent_needs_input で取ります。注意点は互換機能で、Grok は Claude Code 向けの hooks を既定で拾ってしまうため、Claude Code の hooks を入れていると二重に送信されてしまう場合があります。

1 つのプロトコルに寄せる

CLI ごとに違うイベントを、アプリでは 1 種類のパケットに変換しています。OSC 777 の本文に JSON を載せ、発信元と状態と表示方法を明示する形です。主要なフィールドだけ抜き出すと次のようになります。

{
  "protocol": "dirigo.activity",
  "version": 2,
  "source": "codex",
  "operation": "set",
  "state": "waiting",
  "presentation": "markOnly",
  "message": "Waiting for approval"
}

presentation を分けたのは、Codex や OpenCode が自分で出す素の通知と二重にならないようにするためです。Codex・OpenCode・Grok Build ではパケットは markOnly にしてタブのマークだけを動かし、macOS の通知バナーは CLI 本来の通知に任せます。

発信元の識別は source フィールドだけで行い、通知のタイトルや本文の文字列からは推測しません。生の OSC 9 や OSC 777 は、どの CLI が出しても人間向けの通知として扱います。プラグインの導入は各 CLI の公式の手順(Claude Code の claude plugin marketplace addCodex の codex plugin marketplace add、OpenCode のグローバルプラグインディレクトリ、grok plugin install)でユーザー自身に入れてもらうのが良さそうです。

あくまで現時点での挙動の検証である

上の表と図は各 CLI の特定のバージョンで測ったもので、hooks のイベント一覧はその後も変わり続けています。Codex は最初の計測から 3 週間のうちに Interrupt が公式のイベント一覧に載りました。次にバージョンが上がったときは、同じ手順で PTY を取り直す前提です。

Codex の失敗状態は、hooks では今も取れません。完全な completed / interrupted / failed は Codex の app server にしかなく、それを使うとターミナルではなく Codex のクライアントになります。この線は越えないことにしているので、Codex のタブは失敗しても「完了」のマークが付きます。