コンテンツにスキップ

Paneflow のエージェント状態:画面ルールから OSC 7501 へ

Paneflow 0.17.7 以降、サイドバーはエージェントのターンの状態をただ一か所から取得します。エージェントが自分のターミナルに書き込むステータスです。権限の確認で止まったエージェントは、次のような形のシーケンスを書き込みます(例は仕様から引用しています)。

ESC ] 7501 ; state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQs... ESC \

エージェントを動かすツールは外から監視するしかない、というのが一般的な前提です。スピナーを照合し、権限のプロンプトを見つけ、入力欄が戻ったことに気づく。私はそれを何度も作っては削除しました。今の立場は、ターンの状態は実際にそれを知っている唯一のプロセスであるエージェントのものであり、ターミナルの役割は推測せずにその宣言を運ぶことだ、というものです。

ここで説明するのは Paneflow 0.19.0 の動作です。対象は OSC 7501 を出力するエージェントで、v2.1.295 以降の Claude Code と、10 月に openai/codex#52725 と #54114 でサポートをマージした Codex です。何も宣言しないエージェントには、意図的にターンの状態を与えません。

この記事では、Claude Code の 1 ターンをエージェントからサイドバーまで追い、続いて全体の構成、それ以前の 6 つの手法、コスト、そして最も強い反論を取り上げます。

OSC 7501 とは

プロトコルに関する Mitchell の記事を読んだ方は、この節を飛ばしてください。

Mitchell Hashimoto は 2026 年 10 月 6 日、Program Status Protocol を "a completely generic, terminal-native specification"(完全に汎用的な、ターミナルネイティブの仕様)として公開しました。Superlogical と Ghostty での仕事から生まれたものです(本人の記事)。プログラムは、コロンで区切った key=value の組を付けて OSC 7501 を書き込みます。

  • state: idle、working、blocked、done、error のいずれか。アクションとして clear もあります。
  • kind(blocked のときのみ): permission、question、auth。
  • progress、app、title、msg(base64、最大 2,048 バイト)。

プログラムは OSC 7501 ; ? でサポートの有無を問い合わせます。ハートビートはありません。シェルのプロンプトが戻る(OSC 133 A)か、プログラムが終了すると、ターミナルは working と blocked を破棄します。done と error は、ターミナルが非表示にすると決めるまで残ります。たとえば次のキー入力までです。

Paneflow にとって重要な性質が二つあります。状態は、それを知っている唯一のプロセスから来ること。そして寿命のルールにより、ターミナルが状態をタイムアウトさせる必要がないことです。

Claude Code の 1 ターン、エージェントからサイドバーまで

関わる三つのプロセスを通して、1 つのターンを追ってみます。PTY を所有するセッションホスト、状態を集約するワーカー、そしてデスクトップのウィンドウです。

Paneflow は Ghostty として応答します。 ホストはすべてのシェルを TERM_PROGRAM=ghostty で起動し(spawn_env.rs)、そのターミナルエンジンは libghostty-vt です。Claude Code がターミナルにプロトコルのサポートを問い合わせると、エンジンは次のように応答します。

ESC ] 7501 ; ? : states=idle,working,done,blocked,error : kinds=permission,question,auth ESC \

ホストはその応答が PTY へ送られるのを見て、セッションを program_status_capable としてマークします。応答するのはホストだけです。デスクトップも描画のために libghostty を組み込んでいますが、OSC 7501 のコールバックは登録しないので、エージェントが応答を二つ受け取ることはありません。desktop_terminals_neither_answer_nor_report_program_status というテストがこれを保証しています。

プロンプトを送信すると、Claude Code が state=working を書き込みます。 これらのバイトは、すでにグリッドを管理している同じ libghostty のパーサーを通るので、Paneflow が二度スキャンすることはありません。libghostty は解析したステータスをコールバックに渡し、ホストのイベントループがそれをレコードに変換します。

ghostty::BackendEvent::ProgramStatus(report) => {
    self.program_status.apply(report);
    self.program_status_capable = true;
}

ホストのスレッドが 500 ms ごとにセッションを巡回し、新しいものがないセッションは飛ばし、現在の宣言が変わったときだけセッションのマニフェストに書き込みます。ワーカーはプッシュストリームでホストに追従します。

ワーカーが集約します。 paneflow-serve は、ホストとすべてのクライアント(デスクトップ、paneflow CLI、MCP ブリッジ)の間にあります。まずセッションで既知のエージェントが動いていることを確認し、次に宣言を対応付けます。状態機械はこれですべてです(state.rs)。

fn declared_verdict(&self) -> (Status, ActivitySource) {
    let Some(declared) = self.agent_declaration() else {
        return (Status::Idle, ActivitySource::None);
    };
    let running = self.lifecycle.is_running();
    let status = match declared.state.as_str() {
        DECLARED_ERROR => Status::Errored,
        DECLARED_WORKING if running => Status::Busy,
        DECLARED_BLOCKED if running => Status::Attention,
        _ => Status::Idle,
    };
    (status, ActivitySource::Declared)
}

タイマー、フック、ベルのいずれもこれに関与しません。終了したプロセスが実行中や待機中として扱われることはなく、error は終了後も残ります。集約した状態が変わるたびに state_seq カウンターが進み、paneflow wait --idle と paneflow send --submit はこれを追います。

Claude Code が権限を求めます。 msg に質問を入れて state=blocked:kind=permission を書き込みます。ワーカーはセッションを Attention に移し、Attention への遷移なので、そのメッセージを含む "Needs input" 通知を出すと決めます。デスクトップが決めるのは、そのペインがすでに目の前にあるかどうかだけです。行には Need you が表示され、サイドバーの Agents セクションの先頭に移動します。

ターンは state=done で終わります。 ワーカーは "Finished" 通知を発行し、セッションを未読にします。タブを開くまで、行には Done が表示されます。ホストはペインに入力するまで done のレコードを保持します。paneflow send や MCP の write_pane ツールで送ったテキストは、入力として数えません。

全体の構成

各プロセスの役割は一つです。ホストは解析してレコードを保持し、それには上限があります。セッションあたり 256 レコードで、保留中のステータスイベントが 256 KiB のバーストを超えると、部分的な状態を残さずにすべてを消去します。ワーカーは集約して通知を決め、再起動時にはマニフェストからすべてのセッションを再構築します。デスクトップはプッシュストリームで受け取ったものを、ウェイクアップをまとめながら描画します。

エージェントが宣言する内容と、表示される内容です。

宣言サイドバー
workingスピナー
blockedNeed you と、エージェントのメッセージを含む通知
errorError と通知
done確認するまで Done、および通知
idle何も表示しない
3 秒経っても宣言なしNo status

フックが今も担うこと

Claude Code と Codex には今も Paneflow のフックがあり、それぞれ 9 つのイベントを扱います。フックが運ぶのは、OSC 7501 が運ばないものです。会話を再開可能にするプロバイダーのセッション ID とトランスクリプトのパス、タブの名前になるプロンプト、通知を埋める最後の結果、サブエージェントの開始と停止。フックが状態を動かすことはなく、a_hook_event_carries_metadata_but_never_moves_the_declared_state というテストがそれを固定しています。

落とし穴が一つあります。Claude Code はこれらのフックを同期的に実行します("async": false、timeout: 5)。レポーターは接続に 350 ms、リクエストに 350 ms を割り当て、その後 stdout に何も出さずに終了コード 0 で終了します。Paneflow が動いていない場合、最悪でイベントあたり約 700 ms かかります。

これより前の 6 つの手法

この機能の以前のバージョンは、どれも状態を推測していました。順に挙げます。

  1. ウィンドウタイトル。 最初は、検出器が OSC タイトルに点字のスピナーが出ないかを監視していました。4 月 7 日に、ラッパースクリプトからのフックに置き換えて削除しました(107098da1)。
  2. PTY のアクティビティ。 6 月には、フックのないエージェント向けにヒューリスティックを入れました。出力のバーストでエージェントを working に昇格させ、3 秒静かになると降格させ、キー入力のエコーは除外していました。1 週間後、"produced false spinners (a dev server streaming under a bare-shell thread, TUI redraws)"(誤ったスピナーを生んだ)という理由で削除しました(4200e8d49)。失敗です。
  3. 非アクティブタイマー。 300 秒の無音で Stalled 状態が発火しました。9 月に削除しました。"A long turn is a long turn, not a fault."(長いターンは長いターンであって、障害ではない)
  4. フックのないソース。 組織は disableAllHooks や allowManagedHooksOnly で Claude Code のフックを無効にできます。8 月に OSC 9;4 の進捗、OSC 777 の通知、Claude のセッションレジストリファイルを追加し、フックとの間で優先順位を付けて 20 秒で引き継ぐようにしました(0f3da66ea)。
  5. 永続的なリデューサー。 9 月には、セッションがラッチされるとフックが正となり、画面の変化で再設定される 5 分間のリースで Stop が失われた場合の影響に上限を設け、ビューポートのスキャンでメニューを探しました。10 月にはターミナルのベルが注意を促すシグナルとして加わりました。
  6. 宣言的な画面ルール。 10 月 2 日、各エージェントに優先順位付きの正規表現ルールを並べた screen.toml を用意し、署名付きのリモートカタログとキャプチャした画面のコーパスも整えました(c1d48e580)。これは 0.17.6 でリリースされました。

手法 5 の設計メモには、私がパッチを当て続けた症状が並んでいます。Stop が失われるとペインは永遠に思考中のままになり、Claude Code は Stop がユーザーの割り込みでは発火しないと文書化しているので、ターンの途中で Escape を押すとスピナーが回り続けました。Claude Code の番号付きメニューはフックをまったく発火しないので、選択を待っているセッションが実行中と表示されました。どの層も、その下の層を補正するために存在していました。

10 月 7 日、OSC 7501 の API を得るために libghostty の固定バージョンを更新しました。3 日後、328a8fe12 で宣言をターンの状態の唯一のソースにしました。119 ファイル、追加 1,020 行、削除 12,417 行です。画面ルール、リモートカタログ、キャプチャコマンド、ベルによる注意、Escape の処理はすべてなくなりました。

決め手は三つでした。Claude Code と Codex が自分の状態を宣言するようになり、エージェント自身の宣言は、外から行えるどんな推測にも勝ること。推測した状態の誤りに、パッチを当て続けていたこと。そしてルールには、エージェントのリリースごとに保守コストがかかったこと。コーパス、カタログの署名、フィクスチャです。

とはいえ、ルールはフィクスチャ上では間違っていませんでした。削除したコーパスのベースラインは、エージェントごとに 4 から 7 件のキャプチャで、すべてのエージェントで精度 1.0 を記録していました。その数字が測っていたのは手元のキャプチャであって、次のエージェントのリリースではありません。

代わりの選択肢は、何も宣言しないエージェント向けのフォールバックとしてルールを残すことでした。0.17.7 にフォールバックはありません。"Paneflow offers no replacement: the status comes back once the agent itself emits OSC 7501."(代替手段はなく、エージェント自身が OSC 7501 を出力すればステータスが戻る)

識別は照合し、状態は照合しない

Paneflow がエージェント固有の処理をするのは一か所だけです。ペインでどのエージェントが動いているかを知ることです。runtimes/ カタログは 13 のエージェントを記述しており、ホストはペインのフォアグラウンドジョブをこれと照合します。

Linux では /proc/<pid>/task/<tid>/children をたどり、cmdline を読みます。macOS では proc_listpids(PROC_PGRP_ONLY) と KERN_PROCARGS2 を使います。プロセスグループのない Windows では、セッションリーダーの子孫を最大 64 個たどり、それぞれのコマンドラインをプロセスの PEB から読みます。node、bun、python のラッパーはスクリプトのパスから解決するので、node.exe の下で動く npm でインストールした Claude Code も認識されます。一方、-e、-c、-m がエージェントを指すことはありません。カーネルの開始時刻を引数の読み取りの前後で読むので、再利用された pid が誤ったエージェントに割り当てられることはありません。

ヒント: fx というエイリアスは JSON ビューアーの名前でもあります。Paneflow が fx をエージェントとみなすのは、ペインのタイトルが fx v で始まっている間だけです。

照合を誤ると、ペインで動いているエージェントの表示が間違うことはあります。しかし、そのエージェントが何をしているかを捏造することはできません。

コスト

Windows 11 Pro、Ryzen 7 7800X3D、RTX 4070 Ti SUPER、0.18.0 のリリースビルド(b57da648)で、1 つのペインで OSC 7501 により working を宣言するフィクスチャプログラムを 62 秒間実行しました(ベンチマークの概要)。

プロセスCPU(1 コア)
ホスト0.00 %
ワーカー0.05 %
デスクトップ4.80 %、うちメインスレッド 3.66 %

デスクトップは 1 秒あたり 11.13 回のルートレンダリングを行い、それがスピナーの分です。サイドバーのすべてのスピナーは共有の 90 ms アニメーションクロック 1 つで動き、テストは working のエージェントが 1 つでも 8 つでも、サイドバーのウェイクアップを毎秒 10 から 12 回に固定しています。reduce_motion では、クロックを使わずに静止したスピナーを描画します。

レイテンシーには別の修正を入れました。ホストはすでにシェル統合から OSC 133 A を読んでおり、10 月 31 日からは、プロンプトの開始が次の 500 ms の巡回を待たずにスキャンを起こします。エージェントの終了から解放までの時間は、5 サイクルで 434 から 491 ms だったものが 6 から 9 ms になりました(96c525144)。

重要: CPU を理由に画面ルールを否定する論拠は、見かけほど強くありません。私のルールは "20 rules on 200x60 evaluate in 24 us p95" という速度で動いていました。削除したのは保守と誤った状態であって、ホットループではありません。OSC 7501 自体のコストを切り出したベンチマークはありません。ターミナルがいずれにせよ行う解析に乗っているからです。

何も言わないエージェントはどうなるのか

それが最も強い反論で、正当なものです。Claude Code と Codex は宣言します。カタログにあるほかのエージェントは、OSC 7501 を出力するようになって初めてターンの状態を得られ、それまでは何も得られません。サポートの問い合わせも宣言もないまま 3 秒経つと、行は idle のふりをせず、控えめな No status バッジを表示します。Paneflow が把握していれば、ツールチップに報告に対応した最初のバージョン(Claude Code 2.1.295)を示します。

こうしたエージェントでは、paneflow send --submit は no_signal を返し、paneflow wait --idle はターンの終わりで戻る代わりに、出力が静かになるのを待ちます。

Claude Code のターミナルのドキュメントから、いくつか注意点を挙げます。tmux の中では、Claude Code は使っているターミナルではなく tmux にサポートを問い合わせます。バックグラウンドのセッションはステータスを報告しません。CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 を設定すると報告がオフになります。Codex については、二つの PR を最初に含むリリースがどれなのか、まだわかりません。

Herdr はこのトレードオフの逆側を取り、現時点でより多くのエージェントの状態を表示します。Claude Code と Codex については、その連携ドキュメントによれば、状態は "comes from Herdr's screen manifest detection"(Herdr の画面マニフェスト検出から来る)もので、フックはセッションの識別情報を報告します。Pi、OpenCode ほか数種のエージェントでは、それらのフックが報告する状態を使います。その Claude 用マニフェストは丁寧な仕事です。画面領域に対する優先順位付きのルールで、Claude Code のスピナーのグリフをバージョンごとに追うコメントが付いています。Mitchell は、このファイルの履歴が "shows ten changes in three months for just Claude Code"(Claude Code だけで 3 か月に 10 回の変更)と指摘し、これは Herdr への批判ではないと付け加えました。私も同意します。それは私が払わないと決めたコストであり、No status がその代償です。

仕様を書いた Mitchell Hashimoto、Paneflow にステータスを渡す libghostty-vt パーサーを作った Ghostty プロジェクト、そして対応をリリースした Claude Code と Codex のチームに感謝します。

人間を待つことがある CLI を保守しているなら、OSC 7501 を出力してください。Paneflow のペインで次のコマンドを実行すると、ターミナルがそれをどう扱うかを確認できます。ヘッダーに 10 秒間 blocked: permission · Apply? が表示されます。

printf '\033]7501;state=blocked:kind=permission:app=deploy:msg=QXBwbHk/\033\\'; sleep 10

このワークフローを続ける

すべての記事

次のエージェントを Paneflow で起動しよう。