0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

承認パネルが握り潰される原因を138件のダンプから特定した。many-ai-cli v0.7.0 の6つの変更

0
Last updated at Posted at 2026-08-15

ヒーロー(記事トップ)

2026-08-15 に v0.7.0 を出しました。CHANGELOG に並んだ項目は 27 個で、そのうち 14 個が Fixed です。新機能より修正のほうが倍あるリリースになりました。

v0.7.0 の全体像(インフォグラフィック)

この記事では、その 27 個のうち 6 つを実装の中身まで掘って書きます。残りは最後にまとめて 1 節にしました。

many-ai-cli について

自作で many-ai-cli という AI ツール向けの Web ダッシュボードを作っています。複数の AI コーディング CLI を並列で走らせ、承認をブラウザ 1 タブに集約。スマホからでも。

同じ悩みを持っている方は、下記の 2 行で入ります。

pnpm add -g many-ai-cli
many-ai-cli setup

bun install -g many-ai-cli でも npm install -g many-ai-cli でも入ります。Windows は winget、macOS は Homebrew の cask もあります。どの入れ方でも setup は 1 回だけ手で叩く必要があります(npm パッケージに postinstall は置いていません)。この記事に出てくるデスクトップのショートカットは、インストール時ではなく setup が作ります。

この話には前があります。前回は、対応する AI CLI を増やすかどうかを 20 製品調べて全部見送った、という判断の記録でした。

前回の記事: 対応 AI CLI を増やす前に、自分のツールの利用者数を測った方がいい

あのとき「provider を増やすより先に、今あるものの品質を上げるほうが効く」と書きました。v0.7.0 は、そう書いた 2 日後から始めた作業の結果です。承認の検出まわりに手を入れた項目が多いのはそのためです。

承認が減るほど、このツールの役目は前に出る

本題の前に、立ち位置を書いておきます。v0.7.0 の作業が承認の検出と完了通知に偏っている理由でもあります。

2026-08-14 から、Claude Code の Pro / Max / Team プランで自動承認(auto mode)が既定になりました。 専用の分類器がこれから実行するコマンドを評価し、取り消しのきかない操作、破壊的な操作、利用者の環境の外に影響する操作だけを止める作りです(ITmedia NEWS、2026-08-09: https://news.yahoo.co.jp/articles/5223b59033cf1bdeadaf1af82e168c1bed16eae1 )。

同じ記事に、Anthropic が挙げた背景の数字が出ています。利用者は権限確認の画面の 97% を承認していた一方で、Claude が出した作業計画には 39% の拒否を出していた。承認画面が形骸化していた、という話です。危険なコマンドの検出率も、人間による確認が 13.6% だったのに対して auto mode は 89% と報告されています。

こう聞くと「承認ボタンを押す道具は要らなくなるのでは」と思われそうですが、使っている実感は逆でした。

承認で止まらなくなると、セッションは長く黙って走ります。そして最後に 1 回だけ止まる。 1 本だけ動かしているなら気づきます。3 本 4 本を別々のリポジトリで走らせていると、その 1 回を見逃す。結局、定期的に全部のウィンドウを開いて回ることになります。見に行った 5 回のうち 4 回は「まだ動いている」で、その 4 回が一番つまらない。

しかも承認モデルは CLI ごとに違い、足並みを揃えて自動化されているわけでもありません。複数のベンダーを並べて使う限り、状態を 1 か所に集める場所は要ります。

このツールは承認ボタンを代わりに押す道具ではなく、止まったことを検出して知らせる道具です。 承認が減るほど、後者の比重が上がる。だから v0.7.0 は「承認を検出できていなかった」不具合と「終わったのに通知が飛んでいなかった」不具合を先に直しました。auto mode の既定化が 8/14 で、このリリースが 8/15 なのはただの偶然ですが、向きは合っていたと思っています。

1. デスクトップのアイコン 2 個問題を、トレイ常駐で畳んだ

many-ai-cli setup は、これまでデスクトップにショートカットを 2 個置いていました。Hub を起動する「Many AI Hub Start」と、止める「Many AI Hub Stop」です。

これが最後まで気に入っていませんでした。起動と停止という 1 つの状態の裏表を、アイコン 2 個という空間の分割で表現しているからです。使う側からすると、今 Hub が動いているかどうかはアイコンを見ても分かりません。分からないまま Start を押すと、既に動いていた場合の挙動を考えないといけない。

窓(デスクトップアプリ)にしてしまえば解決する話ではあります。実際 2026-08-14 に Tauri での Windows アプリ化と Microsoft Store 提出を検討しました。結論は 1 行も書かないまま全件見送りで、理由は「UI は既定ブラウザのタブで開く形のほうが自分の使い方に合っている」からです。複数のブラウザタブを行き来しながら使うので、Hub がそのタブの 1 つであること自体が利点になっている。独立した窓は画面の枠を 1 つ占有して、他のウィンドウと場所を取り合います。

そこで残った実課題、つまり「アイコンが 2 個ある」だけを、窓を作らずに畳むことにしました。

tray は serve の中ではなく、別プロセスに置く

many-ai-cli tray は小さな常駐プロセスを起動します。メニューは 3 つだけです。

  • Hub を開く(動いていなければ先に起動してから開く)
  • Hub を停止
  • トレイを終了

setup が作るショートカットは「Many AI Hub」1 個になり、これが tray を指します。

実装で最初に決めたのは、tray を serve の中に同居させないことでした。同居させると、Hub を停止した瞬間にトレイアイコンも消えます。そうすると「止めた後にもう一度開く」導線が無くなり、結局デスクトップのアイコンに戻ることになる。tray が別プロセスであれば、Hub の生死と無関係に居座れます。停止した後もトレイは残り、次に開きたくなったらそこから起動できる。

もう 1 つは cgo を持ち込まないことです。Go でトレイアイコンを出すライブラリはいくつかありますが、多くは cgo を要求します。cgo を有効にすると、リリースのクロスコンパイルが一気に面倒になります。このプロジェクトは全リリースターゲットを CGO_ENABLED=0 で作っているので、そこを崩したくない。

なので Win32 API を golang.org/x/sys/windows 経由で直接叩いています。Shell_NotifyIcon でアイコンを登録し、非表示のメッセージ専用ウィンドウを作ってウィンドウプロシージャでクリックを拾い、TrackPopupMenu でメニューを出す。書く量は増えますが、依存が増えないぶん後が楽です。

tray を別プロセスに置いた構成図

上の図のとおり、tray プロセスと Hub プロセスは親子ではなく並列に置いてあります。tray は Hub の起動と停止を外から指示するだけで、Hub が落ちても tray は生き残る。UI が出る先はこれまでどおり既定ブラウザのタブで、OS のネイティブ通知は増やしていません。

既存のアイコンは消さない

setup を再実行しても、既にある「Start」「Stop」のショートカットは削除しません。見つけたことだけ報告して、消すかどうかは利用者に任せます。

ここは少し悩みました。移行のことだけ考えるなら、古いものを消して新しいものに置き換えるのが親切です。ただ、デスクトップは利用者の持ち物です。自分が置いたものを自分で消すのはともかく、「これは自分が置いたものだから」という判定を自動で下して勝手に消すのは、判定が外れたときの被害が大きい。同名のショートカットを自分で作り直している人もいるかもしれない。

setup が触るのは自分が今から作るものだけ、というルールにしました。

対象は Windows だけです。macOS と Linux は従来どおり Start / Stop の 2 つ(.command / .desktop)が置かれます。トレイの実装が OS ごとに完全に別物になるので、まず自分が毎日使っている環境から入れました。

トレイを入れても、OS のネイティブ通知は入れていない

トレイに常駐するなら、ついでにトースト通知も出せます。実際そういう作りのツールは多い。

入れませんでした。作者本人が、あの右下から出てくる通知を好きではないためです。

承認待ちの知らせは、既に 3 つの経路で成立しています。ブラウザのタブタイトルが点滅する、favicon に件数のバッジが出る、設定で音を鳴らす。タブを見れば分かる状態が既にあるところに、OS レベルの割り込みを足す理由が見つかりませんでした。

「常駐プロセスができたから通知も出せる」は、できることの話であって、要るかどうかの話ではない。ここは分けて考えるようにしています。

前提: 承認をどうやって検出しているか

次の節から承認の検出まわりが続くので、先に仕組みを書いておきます。

wrap 対象の AI CLI は、どれも自前の TUI を持っています。API を生やしてくれているわけではないので、「今この CLI は承認待ちか」を外から知る手段は基本的にありません。PTY の出力を読むしかない。

素直にやるなら、出力の文字列を正規表現で引っかける方法(パターンスクレイピング)になります。実際そこから始めました。ただ、これは CLI 側の文言が変わるたびに壊れます。しかも壊れたことに気づけない。承認が出ていないのか、検出できていないのかが区別できないからです。

なので今は、AI 側に決まった形式で出力してもらう方式が主です。各 CLI の指示ファイル(Claude なら CLAUDE.md、Codex なら AGENTS.md)へ承認マーカーの仕様を冪等に注入し、質問と選択肢をその形式で囲んで出してもらう。マーカーは開始と終了の 2 つで、その内側は罫線と表を禁止しています。注入したブロックは、そのファイルを参照しているアクティブセッションが 0 になったら削除します。

問題は、出してもらった後です。PTY から流れてくるのは「画面に描くための命令の列」であって、テキストではありません。カーソル移動、行の消去、部分的な再描画が混ざります。TUI は入力欄の枠を毎フレーム描き直しますし、スクロールのたびに全体を描き直します。

そのため Hub 側に VT エミュレータのミラーを持っています。PTY の出力を食わせて、実端末と同じ画面バッファを再現し、その結果からマーカーブロックを取り出す。ここが今回のバグの舞台です。

取り出したブロックは、さらに分類にかけます。開始マーカーがブロック内に 2 個ある、選択肢の番号が重複している、罫線が混ざっている、といった形を「壊れている」と判定します。壊れていたら承認パネルを出さず、代わりに「承認を保留しました」というバナーを出す。壊れた選択肢を並べて誤送信するくらいなら出さないほうがいい、という設計です。

考え方としては正しいと今も思っています。ただ、判定が正しくても、判定に食わせるミラーが間違っていたら意味がありません。 それが 2 節の話です。

2. 承認パネルが握り潰されていた。真因は VT ミラーに 2 つあった

ここが v0.7.0 の主題です。

保留バナーの頻度が、高すぎました。

バナーが出ると、利用者はターミナルに戻って自分で答えることになります。それはこのツールを使う意味が消えている状態です。しかも「AI が承認を求めているのに Hub には出ていない」ので、気づくのが遅れます。承認待ちで止まったまま放置されたセッションが何本かありました。

138 件のダンプを再生して 132 件を再現した

v0.6.0 の頃に、マーカーが壊れて見えたときの生バイト列を保存する仕組みを入れてありました。2026-08-06 から 08-13 のあいだに 138 件たまっていました。

まず、この 138 件を「記録したときと同じミラーサイズ」で再生する検証コードを書きました。同じサイズで再生しないと意味がありません。壊れ方が端末の桁数に依存する可能性があるからです。

結果は 132 件が再現しました。つまりほとんどが「たまたま通信が乱れた」類のものではなく、決まった入力から決まって壊れる、再現性のあるバグでした。ここで原因が 2 つに割れました。

真因その 1: 全角文字でカーソルが 1 桁しか進んでいなかった

VT ミラーの writeRune が、どのルーンでも col を 1 だけ増やしていました。

日本語の行が来ると、実際の端末はカーソルが 2 桁進むのに、ミラーは 1 桁しか進みません。1 行ぶん日本語が流れると、ミラーのカーソルは実端末の半分の位置にいます。

何が起きるか。CLI の入力欄(composer)は自分の枠線を毎フレーム描き直します。その枠線は行の右端に来ます。ミラーのカーソルが左に寄っているせいで、本来なら文字で上書きされるはずの右端の枠線が生き残る。生き残った枠線が、選択肢のラベルの中に紛れ込む。

マーカーブロックの中に罫線があったら壊れているとみなす、という判定を入れてあったので、これが引っかかります。承認フォーマットは罫線と表を禁止しているので、ブロックの中の罫線はモデルが書いたものではありえない。だから枠線が重なった証拠になる、という理屈です。理屈は合っていて、罫線が本当に重なっていた。重ねていたのは自分のミラーでした。

ミラー側を直しました。East Asian Wide と Fullwidth、それに Emoji_Presentation を持つルーンは 2 桁として数え、結合文字と異体字セレクタは 0 桁として数えます。全角文字の 2 桁目に置く継続用のセンチネルは、Lines() の出力からもスクロールバックの押し出しからも取り除きます。

全角幅のズレで枠線が生き残る仕組み

上の図が、1 桁ぶんのズレが積み重なって右端の枠線を残す流れです。日本語を含む行が長いほどズレが大きくなるので、日本語で長めの質問をしたときほど承認が出なくなる、という嫌な性質がありました。

真因その 2: 古い再描画と今の質問が融合していた

もう 1 つは抽出側です。

TUI はスクロール中も再描画します。同じマーカーブロックの描きかけの世代が、スクロールバックに残ります。記録された 74 件の marker_leak のうち 72 件で、古い開始マーカーがスクロールバックに残っていました。

extractApprovalMarkerBlock は「いちばん新しいブロックを取る」つもりの実装でした。ところが非貪欲な正規表現を使っていたせいで、開始位置は 最初の開始マーカーになります。結果として、古い世代の途中から新しい世代の終わりまでを 1 つのブロックとして切り出す。そのブロックの中には開始マーカーがもう 1 個入っています。

ブロックの中に開始マーカーがある形は、分類側が「壊れている」と弾く形そのものです。自分で作った壊れ方を、自分で検出して捨てていました。

抽出を「最後の終了マーカーと、その手前にある最後の開始マーカー」を取る形に変えました。

この 2 つで、138 件のうち 93 件が普通にパネルとして描けるようになりました。残る 45 件(box_rule 32 件、marker_leak 10 件、duplicate_option 3 件)はどちらの原因でも説明がつかないので、引き続き抑止されたままです。

追加で、枠線だけの行は無視する

上の 2 件を直した後、記録済みダンプ 107 件(2026-08-08 から 08-14)をもう一度再生しました。残っていた box_rule 36 件のうち 13 件は、罫線と空白しかない行が原因でした。

これは「枠線が文字を上書きした」のではなく、枠線そのものがブロックの途中に落ちてきただけです。何も潰していない。罫線だけの行を脇に置くと、13 件すべてが構造的に無傷でした。裏づけとして、問題の 16 行のうち 15 行はちょうど vt_cols の幅ぴったりで、どれもブロックの先頭でも末尾でもありませんでした。

罫線だけで構成された行はスキップし、選択肢のラベルを上書きしている罫線はこれまでどおり弾く、という形に変えました。同じ 107 件で 12 件が追加でパネルになり、v0.6.0 のミラー修正より後に記録されたぶんでは box_rule が 16 件から 8 件へ半減しました。

3. 「もう答えた」を覚える場所が 3 つあって、互いに食い違っていた

承認パネルには、もう 1 つ別系統の不具合がありました。答えたはずの質問がまた出てくる。逆に、エージェントが本当にもう一度聞いてきた質問が二度と出てこない。

原因はブラウザ側です。「この承認はもう回答済みか」を判定する state が 3 本ありました。

かつての state 同一性の定義 失効の仕方
approvalConsumedSig 選択肢の署名 5 秒から 10 秒のタイマー
answeredMarkerSigs マーカーブロック全文のハッシュ 失効しない(セッション恒久)
approvalQuestionKey 質問文のハッシュ 手動 dismiss のあいだだけ

3 つとも「同じ質問とは何か」の定義が違います。この食い違いそのものが症状でした。

タイマー方式は、TUI の再描画が続いている最中に失効します。失効した後にもう一度同じブロックを見つけるので、回答済みの質問が画面に戻ってくる。

ブロック全文ハッシュ方式は逆に、失効しません。TUI が 1 文字でも違う形で描き直せば別ハッシュになるので取りこぼす一方、まったく同じ形で再描画されたら永久に埋めます。エージェントが意図的に同じ質問をもう一度出した場合も埋まる。

3 本とも撤去して、1 本に寄せました。candidateKeysourceEpoch の組です。

  • candidateKey は、provider、承認の種別、正規化した質問文、選択肢の番号と送信文字列から作ります。ラベル、空白、罫線、折り返しは含めません。 含めると、再描画のたびに別の候補として扱われてしまう
  • sourceEpoch は、live prompt の境界でだけ進みます。replay と reflow では進めません。 進めてしまうと、画面を復元しただけで新しい承認に見える

この形なら、再描画でラベルの位置がずれても同じ候補に落ちます。世代が進めば同じ質問文でも新しい候補として表示します。意図的に繰り返された質問がちゃんと出るのは仕様です。

判定ロジックは state.ts から approval-answered.ts に切り出しました。DOM を触らないので、回帰テストを 14 本書けています。

sourceEpoch を replay と reflow で進めない、というのが分かりにくいところなので補足します。

Hub はセッションの過去出力を保持していて、ブラウザがリロードしたときやセッションを切り替えたときに、それを流し直して画面を復元します(replay)。ウィンドウの幅を変えたときは、保持している内容を新しい桁数で組み直します(reflow)。

どちらも「AI が新しく何かを言った」わけではありません。ここで世代を進めてしまうと、ブラウザをリロードしただけで、さっき答えた承認が新品の顔をして戻ってきます。 ウィンドウを広げただけでも同じことが起きる。

逆に、live prompt の境界、つまり AI が実際に新しいプロンプトを出した瞬間だけは進めます。ここを進めないと、AI が意図的に同じ質問をもう一度出したときに二度と表示されません。

「画面の変化」と「AI の発話」を区別する、というのがこの 1 本の役割です。以前の 3 本はどれもこの区別を持っていませんでした。タイマー方式は時間で、ハッシュ方式は文字列で区別しようとしていて、どちらも画面の都合に振り回されます。

3 本の state を 1 本に統合した図

上の図のとおり、3 本それぞれが別の「同じ質問」の定義を持っていて、その隙間から漏れていました。ここは今後も、承認の誤表示を踏んだからといって抑止をもう 1 本足さない、という方針にしてあります。まず既存の 1 本で説明できるかを確かめる。説明できるなら直すのは candidateKey の作り方か sourceEpoch の進み方であって、新しい state ではありません。

同じ系統でもう 1 つ、セッションを切り替えたときに前のセッションの承認パネルが残る不具合も直しました。#action-bar は共有の DOM ノードで、どのセッションが描いたかを dataset.approvalSessionId に持っています。ところがセッション切り替え時に誰もこれを消していませんでした。切り替えた先も承認待ちだった場合、再描画チェックが「バーは見えていて中身も入っている」と判断して描き直さない。結果として、前のセッションの質問が現在のセッションの質問の顔をして残ります。そこで答えると、選択は別のセッションに飛びます。

所有者(owner)を判定材料に加えて、切り替え時に他人のパネルを消してから検出を走らせる形にしました。捨てるのは DOM だけです。前のセッションの承認状態と Hub 側のヒントは残します。その質問はまだ未回答なので、そちらに戻ったときにまた出てこないと困る。

4. 完了通知が既定 OFF のままだった。ただし通知先がある人だけ ON にする

README には「セッションが止まった瞬間に知らせます」と書いてあります。実際、承認待ちの検出とタスク完了の検出は入っています。

ただ、タスク完了サマリー通知は 2 段の opt-in の裏にありました。初期設定のままでは 1 回も飛びません。README の説明と実物が食い違っている状態です。

user_prefs.done_summary_notify.enabled を 3 値にしました。

  • 利用者がトグルを触ったことがあれば、その選択が勝ちます(ON でも OFF でも)
  • 触ったことがなければ、届け先がある人だけ ON にします。Web Push の購読があるか、notify.backends が 1 つ以上あるか
  • どちらも無ければ 何も変わりません。通知は増えません。送る先が無いので

完了通知の 3 値判定

上の図が判定の流れです。「既定を ON にする」という変更は、普通なら通知が増える方向の変更です。ここでは「送り先を用意した人には届き、用意していない人には何も起きない」という形にしました。設定を触ったことがない人の環境を、こちらの都合で騒がしくしたくない。

設定画面のトグルは、保存された値ではなく 実効値 を出すようにしました。ここを直さないと、通知が飛んでいるのに画面には OFF と表示される、という状態が起きます。

あわせて、このトグル自体のバグも直しました。PUT /api/user-prefs はオブジェクト全体を置き換える API です。ところがこのトグルは自分のフィールドだけを送っていました。つまりクリックするたびに、テンプレート、セッションの並び順、cwd の履歴、お気に入りが全部空に戻っていた。

現在の設定を読んで、自分の 1 フィールドだけをマージして、全体を送り返す形に直しました。トークン残量バーのトグルが先に同じ実装をしていたので、それに揃えた形です。

5. テンプレートの並び順が、選ぶたびに崩れていた

プロンプトテンプレートは、入力欄の上のパレットから選んで使います。

これまでは「選ばれた回数」で並んでいました。よく使うものが上に来る、という発想です。実際に使うと、手で並べ替えたつもりの順序が、次にテンプレートを 1 つ選んだ瞬間に崩れます。自動整列と手動整列が同じ場所で戦っている状態でした。

ここは機能を足す前に、自動整列と手動整列を同じリストに同居させないという整理をしました。

頻度ソートは、それ単体なら合理的です。よく使うものが上に来るのは便利。壊れるのは、そこに手で並べ替える手段を足したときです。利用者が並びを決めた直後に、システムが別の根拠で並びを決め直す。どちらが勝つかは「次にどっちの操作をしたか」で決まるので、利用者から見ると順序がランダムに変わるリストになります。

両立させる案はありました。手動で動かしたテンプレートだけ頻度ソートの対象外にする、といった形です。やめました。同じリストの中に「動くもの」と「動かないもの」が混ざると、どの行がどっちなのかが見た目から分かりません。分からない状態で並べ替えると、結果が予想と違う。

自動をやめて、手動だけにしました。保存された順そのままに並べます。新しく足したものは末尾に付きます。テンプレートごとの frequency カウンタは、クライアントからもサーバの構造体からもユーザー設定のミラーからも消しました。テンプレートを選んでもサーバへは何も書きません。config.yaml に残っている既存の frequency は無視され、次の保存で落ちます。

そのうえで、手で並べ替えられるようにしました。各行の左端にグリップがあり、掴んで動かすと順序が変わります。順序は user_prefs.templates としてサーバに保存されるので、別のブラウザでも別の端末でも同じ並びになります。

実装は HTML5 の drag-and-drop ではなく pointer events です。指でもペンでも動かせる。リストの上端や下端の近くで掴んだまま止めると自動でスクロールします。グリップにフォーカスを当てた状態で を押すと 1 つずつ動くので、キーボードだけでも支援技術からでも並べ替えられます。

検索ボックスに文字が入っているあいだは並べ替えを無効にしました。フィルタで隠れている行との相対位置が決まらないからです。掴んだ行を「見えている 3 行目」に落としたとき、それが全体の何番目なのかを一意に決められない。決められないものを推測で決めると、想定と違う結果になったときに利用者が原因を追えません。

もう 1 つ、テンプレートを選んだときの挙動を切り替えるトグルを足しました。パレットに「選んだら即送信」というスイッチがあります。

  • OFF(既定・従来の挙動): 選んだテンプレートを入力欄に入れる。編集してから送れる
  • ON: そのまま通常の送信経路で送る

スイッチの下に、今どちらのモードかを 1 行で書いてあります。設定は user_prefs.template_send.immediate としてサーバ側に持つので、ブラウザや端末をまたいでも付いてきます。

テンプレートの並び順と即送信トグル

上の図が、頻度ソートをやめて保存順にした結果と、選択時の 2 つのモードです。定型の指示を何度も打つ場面(「テストを流して」「diff を見せて」など)では、即送信のほうがクリック 1 回ぶん短くなります。

6. セッションが殺されると、利用者のリポジトリにゴミが残る

これが今回いちばん反省した項目です。

opencode を wrap するとき、Hub はセッションの作業ディレクトリに opencode.json を書きます。中身は権限の指定で、対話セッションなら permission: ask、オーケストレーションの子セッションなら permission: allow です。セッションが終わるときに元のファイルへ戻します。

同じ構図がもう 1 つあります。copilot / cursor-agent / grok のセッションでは、プロジェクトの AGENTS.md に承認ルールのブロックを追記します。これも Hub の正常終了時に削除します。

どちらも、プロセスが kill されたら後始末が走りません。

置き去りは自己修復せず、恒久化する

問題はここからです。次のセッションが同じディレクトリで起動すると、残っていたファイルを「オリジナル」として読みます。そして終了時にそれを書き戻す。一度置き去りになった内容が、次の実行で「正しい元の状態」に昇格します。

AGENTS.md のほうも同じです。注入先のファイル一覧はメモリ上にしかなかったので、Hub を kill するとリストごと消えます。消えたら誰も消しに行きません。そのまま git add -A すれば公開リポジトリに commit されます。

実際、両方の経路で commit されるところまで進みました。

置き去りが恒久化するループと、v0.7.0 で入れた 2 つの経路

上の図の左側が、後始末が飛んだ後に置き去りが「正解」に昇格していくループです。右側が v0.7.0 で入れた 2 つの経路、つまり次回起動時の回収と、既に commit されたものの検出です。

直し方は「後始末の到達率を上げる」ではない

最初に考えたのは、defer を増やす、シグナルを捕まえる、graceful shutdown を厚くする、といった方向です。全部やっても kill は防げません。到達率を 99% にしたところで、残り 1% が恒久化するなら同じことです。

方針を変えました。次回起動時に置き去りを回収する。

opencode.json は、排他ロックにロールバック用の情報を持たせました。「ファイルが何だったか」と「このセッションが何を書いたか」の 2 つです。古いロックを引き継いだセッションは、ファイルを触る前にまず置き去りを元へ戻します。死んだセッションより後にファイルが変わっていたら、他の誰かが触った後なので何もしません。

AGENTS.md は、注入先のリストを ~/.many-ai-cli/approval-rule-targets.json に永続化しました。Hub は起動時、最初のセッションが繋がる前に、残っているブロックを消します。再接続してきたセッションには通常の経路でもう一度注入されます。掃除できなかったファイルは次回のためにリストへ残します。リストのパースに失敗したら、報告だけして何もしません。壊れたリストを根拠に利用者のファイルを触るのは危ない。

回収より前に commit されたものは、回収経路に乗らない

ここが対になっています。次回起動時に回収する仕組みを入れても、回収より前に commit されてしまったものには届きません。 git が追跡しているファイルには .gitignore も効きません。

そこで many-ai-cli doctor に置き去りの検査を足しました。リポジトリの中で走らせると、各成果物を「git が追跡している」「作業ツリーにだけある」「無い」に分類します。きれいなリポジトリでは何も出力しません。

  • 追跡されているものが本題なので、取り消すための git rm --cached を具体的に出します
  • commit された "permission": {"*": "allow"} は警告ではなく 失敗 として報告します。そのリポジトリを clone した人全員の承認プロンプトが無効になるからです
  • 追跡状態はディスクではなく git のインデックスから読みます。動作中の opencode セッションが自分の opencode.json を書き換えている最中でも、それを置き去りと誤認しない。生きたロックの保持者がいれば作業ツリー側の報告も抑えます
  • AGENTS.md のブロックを探すときは、旧名の any-ai-cli:approval-rules を検索語にしています。旧名は現行マーカーの部分文字列なので、これで新旧どちらの世代の置き去りにも当たります。現行名だけで grep すると、古い置き去りが 0 件に見える
  • git が無い環境や、そこがリポジトリでない場合は、エラーを出さずスキップします

もし v0.7.0 より前にこのツールで opencode セッションや Hub セッションを動かしていたら、手元のリポジトリで many-ai-cli doctor を一度走らせてみてください。何も出なければ何もありません。

設計原則と検出手段は、対で持つ

この 2 つを別々の項目として書きましたが、自分の中では 1 つです。

利用者のファイルへ書く機能を作るときは、次回起動時の回収まで含めて設計する。 これが原則のほうです。セッション中だけ書き換えて終了時に戻す作りは、後始末が defer や graceful shutdown に載っている限り kill で飛びます。飛んだ結果が自己修復せず恒久化するなら、それは設計の穴です。

ただ、原則を決めた時点で既に外に出てしまったものには、原則は届きません。原則を決めた日から前は、検出でしか拾えない。

片方だけ直しても利用者には届きません。回収経路だけ入れて満足すると、既に commit されている人のリポジトリはそのままです。検出だけ入れると、これから同じことが起き続けます。だから対にしました。

ついでに書くと、生成物のファイル名は .gitignore にも入れてあります。ただし gitignore は既に追跡されているファイルには効きません。 一度 commit されたものは git rm --cached でしか外れない。ここも「入れたから大丈夫」と思いやすいところです。

doctor にはもう 1 つ足した

セッションログが実際に書けているかの検査です。log.session_enabled が ON のとき、最新の .log をファイルハンドル経由で開いて、何バイト入っているかを報告します。「ON にしたのに空」と「まだ何も書かれていない」を区別するためです。

サイズをディレクトリエントリではなくハンドルから読むのは意図的です。Windows では、別のプロセスが書き込み中のファイルのサイズと更新時刻がディレクトリ一覧に反映されるまで遅延があります。そのあいだ、エクスプローラーも dirGet-ChildItem も 0 バイトと表示し、タイムスタンプは作成時刻で止まって見えます。

2026-08-14 に、この見え方をログのバグだと診断しかけました。ログは無傷でした。なので両方の値が食い違ったときは、両方を出したうえで「これは異常ではない」と明記する形にしています。

残り 20 項目

ここまでで 7 項目です。残り 20 項目を、まとめて挙げます。

v0.7.0 の残り 20 項目サマリ

上の図が残りの一覧です。承認と画面の見え方、セッションの生死、OpenCode まわり、依存とリリース、の 4 系統に分かれます。

設定ファイルの中にしか無かった機能を、UI に出した

  • 通常セッションを専用の git worktree で動かす機能は前からありました。ただし有効化するには config.yamluser_prefs.spawn.worktree_auto を手で書き換えるしかなく、実質誰も見つけられない状態でした。spawn パネルに「このセッションを専用の git worktree で動かす」チェックボックスを足しました。チェックすると、node_modules.env のような未追跡ファイルは新しいチェックアウトへ持ち込まれない、という注意が出ます。選択は spawn.defaults.isolate_worktree に覚えます。このチェックボックスは隔離を 足す方向にしか効きません。外したままなら送信するフィールド自体を省くので、config.yamlworktree_auto: true を書いている人の既定はそのまま生きます

承認とセッションの表示

  • 承認バーの背景が半透明で、下のターミナル出力が文字に透けていました。オーバーレイになったときに、通常フローだった頃の rgba(245,158,11,0.04) をそのまま持ち越したのが原因です。不透明な color-mix(in srgb, var(--warn) 5%, var(--bg-elev)) に変えました。ここを半透明のまま調整するのは筋が悪い。オーバーレイにした目的が「質問が読めること」だからです
  • spawn の失敗が alert(t('spawn_failed') + await res.text()) だったので、{"ok":false,"error":…,"detail":…} が生で出ていました。detail だけを出す形にし、worktree 隔離を git リポジトリでないディレクトリに掛けたときのエラーには文章の説明を用意しました

セッションの生死

  • セッションカードの × が効かないことがありました。× は Hub からセッションを外して wrapper の WebSocket を閉じます。wrapper 側は、その素の EOF だけを見て「止めろと言われた」のか「回線が切れた」のかを推測していました。再接続から 10 秒の postReattachGuard の中では推測が「切れた」に倒れるので、2 秒後に再接続してカードが戻ってくる。もう一度 × を押すと新しいガード窓の中に入るので、いつまでも止まりません。Codex のセッションでよく起きていました。PTY 出力のキューが詰まる(pty_output_queue_full)と自分から切断して再接続するためで、2026-08-13 には 8 分間に 3 回そうなったセッションがありました。Hub がソケットを閉じる前に session_dismissed フレームを明示的に送り、wrapper はそれを受け取って PTY を終了する形にしました。推測ではなく意図を送る。ガードを短くする案は却下しました。2026-07-20 に直した「再接続直後の素の EOF で wrap 先の CLI が死ぬ」不具合が戻るからです。古い wrapper は知らないフレームを無視して従来どおり動きます
  • .git-worktrees/ が gitignore されていませんでした。通常セッションの worktree 隔離はここにチェックアウトを作るのに、.gitignore はオーケストレーション用の .many-ai-cli/ しか書いていなかった

OpenCode まわり

  • ピッカーから選んだスラッシュコマンドが末尾スペース付きで送られ、OpenCode がコマンドではなくプロンプトとして実行していました。候補を確定すると command + " " を挿入します。引数を続けて打てるようにするためです。ところが OpenCode の TUI は、行が / で始まりカーソルまでにスペースが無いあいだしかコマンド一覧を開きません(opencode 1.18.18 のバイナリで確認)。スペースが付いたままだと一覧は開かず、Enter は通常送信に落ち、/models がただの文章としてモデルに届きます。単一行のスラッシュコマンドからは末尾スペースを落とすようにしました。wrap 対象 6 種のどれも末尾スペースに意味を持たせていません。あわせて /models を、選択と同時に送信するコマンドに加えました。Claude の /model と挙動が揃います
  • スラッシュコマンドの一覧に、抜けていた 12 個を足しました。opencode 1.18.18 のバイナリと突き合わせて、/agents /debug /diff /mcps /move /org /skills /slash /status /variants /warp /workspaces が TUI 組み込みなのにピッカーに無いことが分かりました。何も消していません。 TUI 組み込みでない 9 個(/compact /export /init /redo /share /undo /unshare /details /thinking)はバイナリの中に session.* コマンドとして別経路で登録されているので、そのまま残してあります

依存とリリース

  • ソースからのビルドに Go 1.25.13 が必要になりました。go.modgo ディレクティブをパッチ 1 つ上げて、このコードが到達する標準ライブラリの脆弱性 5 件の修正を取り込んでいます(net/url の GO-2026-6218、crypto/tls の GO-2026-6090、net/http の GO-2026-6089 と GO-2026-5026、encoding/asn1 の GO-2026-5972)。リリース済みバイナリは CI がビルドしていて同じファイルを読むので影響ありません。GOTOOLCHAIN=auto のままなら新しいツールチェーンが自動で取得されます
  • リリースワークフローで、サードパーティの Action を変更不能なコミット SHA に固定しました。npm の資格情報は publish のステップだけに限定し、他のステップには「使えるかどうか」の真偽値しか渡しません
  • 管理下の Whisper リリースが、検証済みの Windows VC++ ランタイムを同梱するようになりました。Windows のリリース準備ジョブが Microsoft 署名の DLL を取得し、検証済みのペイロードが無ければ GoReleaser のジョブが fail closed で止まります

撤去したもの

  • 承認マーカーが壊れて見えたときに生の PTY バイト列を保存する仕組みを外しました。この記事の 2 節がまさにその出力から特定した話で、3 つの原因を見つけて直せたので役目は終わりです。残しておくと、入力由来のバイト列をディスクへ書き続けることになる。残りの診断価値と釣り合いません。4 つ目の原因が出てきたら、期限を切り直して入れ直します

このプロジェクトでは、調査用の観測コードを入れるときに instrumentation.json へ同じコミットで登録するルールにしてあります。gate(何で有効化されるか)と due(いつまでか)が必須で、期限を過ぎると検査がブロックします。「原因が確定したら消す」とコメントに書くだけでは消えないことを、3 回続けて出荷直前に学んだためです。

  • Agent Chat の prime が、末尾ページのデコードを確定する前にカーソルを進めなくなりました。選択の期限が切れたとき、Hub は安全な再試行カーソルを保持するので、既存の末尾レコードが飛ばされません

あわせて読みたい

many-ai-cli はこんなときに刺さります

  • Claude Code、Codex、Copilot、Cursor、Grok、opencode を同時に走らせていて、どれが止まったか分からなくなる人
  • auto mode を使っていて承認では止まらないぶん、いつ終わったのかが分からなくなった人
  • 複数のリポジトリを定期的に見に行って、そのたびにウィンドウを開くのが面倒な人
  • 承認待ちを捌くためにターミナルを行き来していて、その往復を減らしたい人
  • 離席中もスマホから承認だけ返しておきたい人
  • v0.7.0 からは、Windows でトレイアイコン 1 個から Hub の起動と停止ができます

いずれかに心当たりがあれば、pnpm add -g many-ai-climany-ai-cli setup の 2 行で 1 分で試せます。設定ファイルを書く必要はありません。

Star をいただけると開発の励みになります。使ってみて「ここが不便」があれば、Issue でも X の DM でも大歓迎です。

おわりに

このリリースで一番時間を使ったのは、コードを書く前の再生検証でした。138 件のダンプを記録どおりのサイズで再生するコードを先に書いて、そこで初めて「132 件は再現する」と言えた。再現しない前提のまま直していたら、たぶん枠線の判定を緩める方向に手を入れて、別の誤検出を作っていたと思います。

置き去りの件も同じで、後始末の到達率を上げる方向に頭が行きかけました。そっちに進んでいたら、今も自分のリポジトリに opencode.json が commit され続けていたはずです。

kill は防げない、を出発点に置く。これは今後も忘れないでおきたいところです。


※ ヘッダー画像とインフォグラフィックは AI(画像生成)で作成しています。

書いた人: ishizakahiroshi
群馬の北部で、保護猫2匹と暮らす、在宅エンジニア(何でも屋)
https://ishizakahiroshi.com/
https://github.com/ishizakahiroshi
X(業務委託・各種相談はこちら):
https://x.com/ishizakahiroshi

バックエンド・インフラ・AI連携まわりで、業務委託のご相談を受け付けています。フルリモートです。スポットや週2〜3時間からでも歓迎で、いろんな案件に携われたらうれしいです。こんな相談、歓迎です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?