Repository navigation
v0.24.0
[0.24.0] - 2026-08-16
Highlight: エージェントの入力待ちを見逃さないための経路を一通り揃えたリリース。WS 即時配信・要対応バッジ・クロス画面 Toast に加え、タブタイトル / favicon / App Badge / 通知音でブラウザ外へ、さらに waiting エッジ駆動の push 通知でデバイス外へ伝わるようになった(方針 A / D / E)。あわせて稼働中のモデルと reasoning effort を hooks の構造化イベントと tmux capture の両経路から取得し、UI と CLI (
instances/capture --json) に露出した。External Apps のプロキシは末尾スラッシュとクエリ文字列を生バイトのまま転送するようになり、Next.js static export のアプリが CommandMate 経由で開けなかった問題(/proxy/<app>/try/と/assets/が 404)が解消している。
Fixed
- fix(proxy): クエリ文字列を生バイトのまま上流へ転送する (#1804)
- #1802 でクエリは転送されるようになったが、バイト完全一致ではなかった。
?q=a%20b&n=1は上流に?q=a+b&n=1として、?bareは?bare=として届いていた。
application/x-www-form-urlencodedの意味論では+と%20は同じ空白にデコードされるため
大半のアプリでは実害が無いが、クエリのバイト列に対して署名を検証する上流
(HMAC 署名つき URL / presigned URL / OAuth 1.0a)では署名検証に失敗する - 原因は Next.js が route handler に渡す前に
request.urlを再構成していること。
%20→+と?bare→?bare=はURLSearchParamsで再シリアライズした時の署名であり、
App Router の route handler の内側からは触れない層で起きる - 修正は「Next.js を迂回する」のではなく「生 URL を一段手前で退避する」方式。
server.tsのrequestHandlerは Next.js に渡す前に生のreq.url(Node の request target)を
持っているので、これをx-cm-raw-urlヘッダへ退避し、
src/app/proxy/[...path]/route.tsが読み戻す。ヘッダが無い場合(next dev単体・unit test)は
従来どおりrequest.urlにフォールバックするため #1802 の挙動を維持する /proxy/*をserver.tsで横取りする案(起票時の方針)は採用しなかった。
/proxy/...は現在src/middleware.tsの認証と IP 制限を通っており、
AUTH_EXCLUDED_PATHSは完全一致判定なので除外されていない。Next.js を迂回すると
External Apps から認証と IP 制限が丸ごと外れる(Cloudflare Tunnel 配下では外部公開に直結)。
next.config.jsのheaders()(CSP 等)も失われる。この性質を
tests/unit/proxy/proxy-auth-guard.test.tsで固定した- 偽装防止:
server.tsはx-cm-raw-urlを無条件にdeleteしてから
/proxy/の時だけsetする。クライアントが付けてきた値は経路を問わず必ず捨てられる。
内部用ヘッダなのでfilterHeaders()で剥がしており上流へは転送されない server.ts側は inline 4 行、import は 1 つも追加していない(#1428 の再発防止)。
top-level に内部モジュールの静的 import を足すとtsx server.ts下で Next の
AsyncLocalStorage bootstrap が壊れ、最初のリクエストでクラッシュする。
この故障は unit / integration / build / lint をすべてすり抜け E2E だけで落ちる- 実機計測(隔離 DB・ポート 3805・エコー上流 3806、生 TCP でバイト単位送信):
?q=a%20b&n=1/?bare/?q=a%2Bb/?q=a%26b/?sig=aGVsbG8%3D/
?q=%E6%97%A5%E6%9C%AC/?q=a+b/?empty=/?a=1&a=2がすべてバイト一致で上流着。
#1802 のパス保存(/try/と/tryの区別・a%2Fb・a%20b・多バイト・素の+・深い階層)と
HEAD / POST / PUT / PATCH / DELETE に回帰なし。認証有効時は未認証/proxy/...が 307(/login)、
不正 Bearer が 401、正規 Cookie が 200 でクエリもバイト一致 - 既知の制限(実測により起票時の想定と乖離):
?単独(/search/?)は依然として落ちる。
ただし原因はnew URL()ではなく **Node のfetch()(undici)**である。
生文字列を最初の?で分割する実装により?はbuildUpstreamUrl()まで保持され、
new URL(...).hrefも?を保つが、undici は request target をpathname + searchで組み立て、
present-but-empty なクエリではsearchが''になるため送信時に落ちる
(fetch('http://127.0.0.1:3806/a/?')の上流受信が/a/であることを実測)。
解消には proxy の transport をhttp.requestに置き換える必要があり、
undici の gzip 透過処理(content-encoding剥がし)も自前で作り直すことになるため、
空クエリを表す区切り文字 1 バイトの代償としては割に合わないと判断した
- #1802 でクエリは転送されるようになったが、バイト完全一致ではなかった。
- fix(proxy): 上流転送で末尾スラッシュとクエリ文字列を保持する (#1802)
- External Apps のプロキシがルート画面(
/proxy/<app>/)だけ 200 で、配下(/proxy/<app>/try/,
/proxy/<app>/assets/)が 404 になっていた。 Next.js static export のようにディレクトリ URL に
末尾/を要求する上流では、/try/と/tryは別の URLであり、上流は後者を 404 にする - 原因は
src/app/proxy/[...path]/route.tsの'/proxy/' + pathSegments.join('/')。
Next.js の catch-all params は「区切り文字を落とした percent-decode 済みの配列」なので、
配列から join で URL を再構築する方式では構造的に 3 つの情報が失われる:
①末尾スラッシュ ②クエリ文字列(buildUpstreamUrl()の JSDoc は "including query string" と
書いてあるのに呼び出し側が一度も search を渡していなかった)③percent-encode
(%20が素の空白、%2Fが区切りの/に化ける) - 修正は
new URL(request.url)のpathname+searchをそのまま連結して転送する形に変えた。
pathSegmentsはpathPrefixの検索用途(DB のpathPrefixは decode 済みの素の文字列)にのみ残した - 保存されるのはパスであり、クエリは route handler 到達前に Next.js が正規化する(実機計測)。
エコー上流を立てて実サーバ経由で計測した結果は以下のとおり:- パス: バイト列がそのまま保存される —
/try/は/try/、a%20b/a%2Fb/
%E6%97%A5%E6%9C%AC/ 素の+すべて無変換で上流へ届く - クエリ: バイト完全一致は達成できない —
?q=a%20b&n=1は?q=a+b&n=1に、
?bareは?bare=になる(URLSearchParamsで再シリアライズした時の署名)。
%2B/%26/%3D/ percent-encode 済みマルチバイト値は保存される。
これは App Router の route handler からは触れない層での正規化であり、
本 Issue のコード(new URL()もfetch()も無改変であることを node で切り分け済み)が
原因ではなく、本 Issue の範囲では解消できない。既知の制限として、
?単独の空クエリも WHATWG URL の仕様上searchが空文字になるため落ちる(/x?→/x)
- パス: バイト列がそのまま保存される —
- ログには query string を載せない(Issue #395 の方針)。 クエリにトークンが載りうるため、
転送用 path(pathname + search)とログ用 path(pathnameのみ)を分離した。
/proxy/単体での 400、WebSocket フォールバックの 426 は挙動を変えていない
(next.config.jsのskipTrailingSlashRedirect: true(#671)と、
src/lib/ws-server.tsがrequest.urlを生のまま渡す upgrade 経路には手を入れていない)
- External Apps のプロキシがルート画面(
- fix(session): 並列開発で宙に浮いた 2 つの配線を繋いだ(
reasoningEffortが永久に null/詳細ヘッダに指示待ち表現が無い) (#1785, #1787, #1784)- どちらも「実装されていなかった」のではなく「着地済みの実装同士が繋がれていなかった」。
Phase 2(#1784・保持層)と Phase 3(#1785・露出)、および #1787(waiting 視認性)が同時並行で開発され、
それぞれのテストは緑のまま着地したため、境界の穴を CI が一度も検出できなかった。
同じ穴を再発させないための記録として、原因を「片方の Issue のバグ」ではなく並列開発の配線漏れとして残す commandmate capture <id> --json | jq '.reasoningEffort'とcommandmate instancesのEFFORT列が、
effort を実際に検出できているセッションでも永久にnull/ 空欄だった。
#1785 が「#1784 未着地でも動くように」置いたresolveReasoningEffort()seam(return null固定)を
誰も差し替えなかったのが原因。#1785 のテストは #1784 の着地で赤にならないよう
値ではなくスキーマ(null許容)で書かれていたので、両 Issue のテストが緑のまま穴が残った- 修正は
getResolvedAgentModelInfo()1 回の呼び出しに集約した(getLastKnownAgentEffort()ではなく)。
effort 値そのものはどちらでも同じ(後者は前者の.effortを返す薄いラッパ)だが、
modelとreasoningEffortを別々の reader から読むと不整合な payload を publish しうる —
modelを hooks 専用 latch(getLastKnownAgentModel)から読んだままだと、
最初のSessionStarthook が届く前にバナーを scrape 済みの claude セッションで
model が null なのに effort だけ載る行(buildModelByInstanceが "unreachable through the API" と
明記している形)がEFFORT列に出る。1 回の解決に統一することで、antigravity の
「effort は model id 末尾から導出」規則も自動的に効く - オーケストレーターの前提と実測が食い違った点(実測を正とした): ①同ファイル内の
modelは
「既に保持層から解決されている」とされていたが、実際はgetLastKnownAgentModel(hooks 専用 latch)で、
worktree-status-helper(Web UI 側)が使うgetResolvedAgentModelInfoと別の答えを返していた。
上記のとおり両者を後者に統一した。これは #1783 が「サーバ再起動後の claude の穴は Phase 2 が埋める」と
明記した設計の未完了部分でもある(値は厳密に上位互換 — hooks があれば同値、無いときだけ capture が穴を埋める)。
②commandmate instancesのEFFORT列は「保持層と無関係に空欄」ではなく、
instancesはworktree-status-helperではなく current-output エンドポイントを instance ごとに叩くので、
同じ 1 箇所の修正で両 CLI 面が同時に直る(Web UI の pill ツールチップは #1784 時点で既に正しく出ていた) - 未稼働セッション(
running=no)がnullを返す規則は変えていない。 保持層は意図的に期限切れしない
(8 時間走るターンは最後まで同じモデル・同じ effort)ので、停止済み行が前プロセスの値を名乗らないよう
落とすのはサーバー側だけ。effort も model と同じ扱いにした - #1787 受入条件 4 が部分未達だった —
awaitingInstruction(エージェントが「ターンを終えた」と自己申告した状態)の
セカンダリ表現は、サイドバー行(BranchListItem)とWorktreeCardには出ていたが、
worktree 詳細ヘッダ(DesktopHeader)に無かった。同ファイルが #1787 の契約 scope 外だったため。
ブランチ一覧では緑バッジが出ているのに、そのブランチを開くと消える状態だった - サイドバーと同一の表現・同一トークン・同一 i18n キー(
worktree.awaitingInstruction.badge/.label)を
再利用した(新しいデザインを発明しない/重複キーを作らない)。success(緑)系で、
waiting のwarning(amber)系とは決して混同されないことが唯一の必須要件 - バッジは pill ごとではなく行に 1 つ(worktree 単位)。サイドバー行が既にその粒度で出しており
(branch.awaitingInstructionはderiveWorktreeWaitingDetailによる全インスタンスの畳み込み)、
かつこの行はMAX_HEADER_AGENT_PILLSで幅を配給しているため、pill ごとに文字列を足すと
稼働中のインスタンスが「+N」に押し出される(#1783 が model をツールチップに留めたのと同じ理由)。
「+N」トリガーの後ろに置いて pill の幅予算を一切消費せず、既存の表示は 1 バイトも削っていない - 空振り緑の反証(変異注入で実測): ①
buildCurrentOutputのreasoningEffortをnullに戻す →
新規current-output-effort-wiring-1784の 9 件中 7 件が赤(残り 2 件はnullを期待する規則そのもの)。
②詳細ヘッダのawaitingInstruction描画を外す →WorktreeDetailSubComponentsの 4 件が赤。
変異は復元しgit statusで確認済み
- どちらも「実装されていなかった」のではなく「着地済みの実装同士が繋がれていなかった」。
Changed
- feat(push): 入力待ち通知を waiting エッジ駆動にし、種別の細分化とエスカレーションを追加した(入力待ち可視化 方針E) (#1790)
- 通知トリガーを #1786 の waiting エッジ(
onWaitingTransition)に載せた。src/lib/push/waiting-push-notifier.tsは
#1788 のwaiting-broadcast.tsと同じく購読するだけで、検出器・pane capture・独自の「前回 waiting だったか」を持たない。
これにより poller 非稼働・同一ターン 2 個目のプロンプト・MAX_POLLING_DURATION超過・
selection list / pager / 構造化のみの待ちが通知されるようになった - poller 内の発火は残した(Issue 本文の推奨から逸脱。実測に基づく判断)。
本文は「エッジ経路へ一本化し poller 内は削除」を推奨していたが、observeWaitingEdgeの呼び出し元は
worktree-status-helper1 箇所= worktree 一覧 / 詳細 API の probe のみで、サーバ側の周期スキャンは存在しない
(grep -rn observeWaitingEdge srcで確認)。つまりエッジはクライアントが画面を開いているときにしか観測されないため、
一本化すると「アプリを閉じて離席している」=スマホ通知が最も要る状況で無通知になる。
よって両経路を残し、response-checkerが prompt 検出時にobserveWaitingEdge(waiting:true)で
同じ episode を開くことで二重送信を構造的に潰した(順序も意図的で、prompt の質問文を持っている
poller 側を先に送り、後続のエッジ経路は dedup で黙る) - dedup を episode 化した。
shouldSendWaitingPush()(key = worktreeId + instanceId +waitingSince)が
「1 つの待ちにつき 1 通」を保証する。content hash 30 秒は同一ターン 2 個目の同文プロンプトを落としつつ、
長い待ちには同じ質問を再送し得るという逆向きの誤りを両方持っていた。completion 経路は現行維持 - 応答保存時に
observeWaitingEdge(waiting:false)で episode を閉じる。これが無いと poller が開いた待ちが
ブラウザの probe が来るまで開きっぱなしになり、以降のプロンプトが全部その古い episode に畳まれて無音になる - 通知文を
waitingKindで出し分ける。prompt→「応答待ちです」/menu・unclassified→「端末の確認が必要です」/
エスカレーション→「まだ応答待ちです(N分経過)」。両言語をlocales/{en,ja}/notifications.jsonに追加した。
payload のwaitingKindは待ちのときだけ付くので、#1125 の completion payload は 1 バイトも変わらない。
tagも据え置き=再通知は最初の通知を置換する(同じ待ちで通知カードが 2 枚積まれない) - エスカレーション(再通知)を追加した。既定 10 分・1 episode 1 回・
NotificationsSettingsで変更/オフ可能。
相乗りできる既存の周期処理が無かった(global-session-pollerは assistant chat 専用、resource-cleanupは別責務)ため
60 秒 interval を新設したが、待ちがある間だけ張る(pending 0 でclearInterval、unref()済み)。
生存判定は自前の pending ではなくgetWaitingEpisode()= #1786 のストアが権威なので、
別画面で答えられた待ちにも閉じるエッジを取りこぼした待ちにも再通知しない - 設定はインストール単位(
app_settingsv27 に相乗り、migration なし)。#1788 の in-app トグルと違い
localStorage を使えない — 判定するのは request も browser も無い background timer だから。
読みは全経路 total(行なし/壊れた JSON/範囲外/DB 不通 → 既定値)で、正規化はフィールド独立
(不正な threshold がenabledを巻き添えにしない) - VAPID 未設定環境では待ちを記録すらしない。
isPushConfigured()が偽なら timer も DB 参照も発生しない
(「静かなだけ」ではなく完全に不活性)。既存 prompt トグルはそのまま新経路に効く(kindは'prompt'のまま)。
awaitingInstructionは仕様どおりスコープ外 - Issue 本文との食い違い(実測を正とした)は 1 点のみ: 本文の「エッジ = poller 非依存」は正しいが、
クライアント非依存ではない(observeWaitingEdgeの呼び出し元は一覧 / 詳細 API の probe だけ)。
これが「poller 内は削除」を採らなかった理由。本文の file:line はすべて実測と一致していた
(response-checker.ts:612=kind:'prompt'/:723=kind:'completion'の 2 箇所のみ、
notification-dedup.ts:23=30 秒窓、response-poller-core.ts:29=MAX_POLLING_DURATION = 30 分、
public/sw.jsの push / notificationclick、NotificationEventの kind 2 種) - Auto-Yes との競合に猶予は入れなかった。本文は「数秒後にまだ waiting なら送る」を検討可としていたが、
現行でも poller は Auto-Yes の有無に関係なくプロンプト検出時点で通知している
(tests/integration/auto-yes-policy-escalation.test.tsの「escalation does not depend on the policy」が固定している)ため、
通知量は本 Issue で増えない。一方で猶予を入れるとすべての正当なプロンプト通知が数秒遅れる - 既存テストの期待値変更は 1 件:
tests/unit/i18n/notifications-push-keys.test.tsの
「pre-i18n の日本語文言を byte-for-byte 保持」をtoEqual→toMatchObjectに緩めた。
#1308 の 4 文言は引き続き byte 単位で固定しており、網羅性は同ファイル内の
「pushのキー集合=PUSH_KEYSと完全一致」テスト(新キー 4 件を追加済み)が担保する - 空振り緑の反証(変異注入で実測):
①startWaitingPushNotifierの購読を no-op listener に潰す → 14 件が赤
(notifies on an edge nothing but the status probe observed/
notifies for a wait no prompt detector could classify/
notifies a second instance of the same worktree independently/
notifies again once the wait ended and a new one began/
renders prompt|menu|unclassified in both locales/
re-notifies once, and only once, past the threshold/
says "check the terminal" when that is what the wait needs/
does not re-notify a wait that has been answered/
does not re-notify when the closing edge was never seen but the wait is over/
sends nothing when the reminder is switched off/honours a threshold the user shortened/
is driven by an interval that exists only while something is waiting)
②shouldSendWaitingPushを常時 true に潰す → 6 件が赤
(opens the episode the status probe would have opened/
sends nothing extra when the status probe then reports the same wait/
closes the episode on a reply, so the next prompt notifies again/
does not double-send when the poller and the edge both report it/
suppresses a repeat of the same episode however often it is reported/
keys the guard on the episode, not on the content)
③ エスカレーションの閾値判定を常に不成立にする → 4 件が赤
(re-notifies once, and only once, past the threshold/
says "check the terminal" when that is what the wait needs/
honours a threshold the user shortened/
is driven by an interval that exists only while something is waiting)
変異は 3 件とも復元し、grep -rn MUTATION srcが空・全ゲート緑に復帰することを確認した
- 通知トリガーを #1786 の waiting エッジ(
Added
-
feat(pwa): 入力待ちをタブタイトル・favicon・App Badge・通知音でブラウザ外に伝える(入力待ち可視化 方針D) (#1789)
- 件数は #1788 の
useAttentionCountをそのまま使う(新設なし・二重実装なし)。 タブタイトル/favicon/App Badge/
通知音の 4 面すべてが同じ値を読むので、サイドバーのバッジや/review?filter=approvalの一覧と食い違いようがない。
カウント規則(waiting な worktree を 1 件と数える = instance が 3 つ待機でも 1)も #1788 のまま変更していない - タブタイトル: N > 0 で
(N) <現行タイトル>、0 で原状復帰。変換は strip→prepend なので冪等で、
同じ effect が 2 回走っても(1) (1) Fooにならない。Next のページ遷移は title を上書きする(<title>要素ごと
差し替わることもある)ので順序に賭けずdocument.headの MutationObserver で観測して再適用する - favicon: canvas 合成を data URL で差し替え(SW の cache-first
/favicon.ico・/icons/とも、
ブラウザ固有の favicon キャッシュとも交差しない)。hrefだけ差し替え、sizes/typeは Next が出したまま。
0 件と unmount で原状復帰する。数字は潰さず描く判断とした(バッジ円がタイル辺の約 68% を占め、16px 縮小でも
1 グリフは読める)。桁溢れは9+で、その状態のときだけ amber → red に変える - App Badge:
navigator.setAppBadge/clearAppBadgeを feature detect のうえ呼ぶ。未対応・reject・throw を
すべて黙殺し、ログも出さない(未対応ブラウザで status 変化ごとに warning が出るコンソールは読まれなくなる) - 通知音(既定 OFF・オプトイン): #1788 の WS waiting エッジで 1 episode 1 回。音源は Web Audio の合成 2 音で
外部リソース一切なし。autoplay 制約は初回ジェスチャ(pointerdown/touchstart/keydown)での
unlockWaitingSound()で解錠し、鳴らせなければ黙って諦める(リトライループもトーストも console 出力もなし)。
トーストと違って既定 OFF なのは、音は端末の外へ出る(会議中・共有オフィス)ため — 頼まれていない音はタブごと
ミュートされる最短路で、それは #1788 のトーストまで道連れにする。バイブ(任意項目)は実装していない - タブ非表示中の更新停止は許容仕様として明記した(
useWorktreesCacheの visibilitychange による poll 停止。
#1788 の WS が生きている間は push で追随するので、通常はタブが裏でも追いつく) - Issue 本文の現状記述はコードで裏取り済み(
document.titleの動的変更なし/setAppBadge・new Audio・
AudioContext・navigator.vibrateのヒット 0/public/sw.js:161のbadgeは通知アイコンで本件と別物 —
いずれも本文どおり)。本文と実装の差は 1 点:<link rel="icon">はソースに literal では存在せず、
App Router の file convention(src/app/icon.png/icon1.png/icon2.png)から複数生成される。
そのため「既存 link の href を替える」実装は全件に対して行い、1 件も無い文書では link を生成して復帰時に除去する - 空タイトルでの無限書き戻しを実装中に発見して塞いだ:
document.titleの getter は空白を strip/collapse するため
"(2) "を書くと"(2)"で読み戻る。差分検知が永久に「変わった」と言い続け、title 監視が回り続ける。
プレフィクスは空タイトル時に末尾スペースを出さず、strip 正規表現も(?:\s+|$)で"(2)"単体を剥がす - 空振り緑の反証(変異注入で実測): ①
formatTitleWithBadgeのプレフィクス付与を潰す →attention-badge-1789/
useAttentionBadge-1789の 10 件が赤(prefixes one, several, and more than nine/is idempotent: applying it twice cannot produce "(1) (1) Foo"/replaces a stale badge rather than stacking on it/emits no trailing space when there is no title to prefix/prefixes the count, and drops the prefix again at zero/replaces the prefix on a count change rather than stacking one/stays single-prefixed when the effect runs again for the same count/
re-applies after a navigation rewrites the title/settles on a page with no title at all, instead of re-writing forever/restores the plain title on unmount)。②restoreFaviconsの復帰を潰す → 6 件が赤
(restores every original href/never records a data URL as the original, however many times it re-applies/
creates a link when the document has none, and removes it again/restores the authored icon at zero/
restores the authored icon on unmount/survives a count change without ever losing the authored href)。
③音のトグル判定を常に true にする → 2 件が赤(stays silent while the toggle is off (the default)/
stays silent when the toggle is explicitly off)。3 変異とも復元して全緑に復帰(git statusで確認)
- 件数は #1788 の
-
feat(cli): モデル / reasoning effort を
instancesとcapture --jsonに露出した(モデル/effort 可視化 Phase 3) (#1785)CurrentOutputResponse(current-output-builder)にmodel/reasoningEffort(ともにstring | null) を追加した。
値は Phase 1(#1783)の保持層そのままで、CLI 側でのモデル名の解釈・整形はしない —
capture --json | jq '.model'の値を/statusやagy modelsの表示とそのまま突き合わせられるようにするためcommandmate instances <id>の表にMODEL/EFFORT列、--jsonにmodel/reasoningEffortを追加した。
列は末尾に追加なので、INSTANCE_ID〜AUTO_YESを列位置で読んでいるスクリプトはそのまま動く- 未稼働セッションは
null/ 空欄。 保持層は意図的に期限切れしない(8時間走るターンは最後まで同じモデル)ので、
そのまま返すとRUNNING noの行が前プロセスのモデルを名乗る。落とすのはサーバー側だけで、
CLI に同じ規則をもう 1 つ置かない(2 箇所に持つと食い違う) - 既存フィールドは追加のみで一切変えていない。
capture --jsonのcontent/realtimeSnippet/
sessionStatus/sessionStatusReasonは orchestrate-monitor 等の既存消費者が依存しており、
テキスト出力(既定)も byte 単位で不変(回帰テストで固定) reasoningEffortは現状すべてnull。 effort はどのエージェントの hooks payload にも無く、
抽出は Phase 2(#1784)の担当。resolveReasoningEffort()という named seam を置いてあるので、
#1784 着地時はこの関数の本体を差し替えるだけで済み、payload 形状・CLI・テスト期待値は動かない
(テストは値ではなく null 許容のスキーマ検証で書いてある)- 空振り緑の反証(変異注入で実測):
buildCurrentOutputのmodel解決をnullに潰すと
current-output-model-1785の 3 件(publishes the model the agent reported about itself/
publishes it verbatim/publishes null for a stopped session even after a model was latched)が赤になる。
変異は復元して全緑に復帰
-
feat(detection): tmux capture から model / reasoning effort を抽出して補完する(モデル/effort 可視化 Phase 2) (#1784)
- reasoning effort はどのツールの hooks payload にも存在しない(
tests/fixtures/hooks/全件で確認)。
唯一の情報源は TUI が自分で描く chrome なので、src/lib/detection/model-info-extractor.tsを新設して
extractModelInfo(cliToolId, captureText) → {model, effort}で読む。Phase 1(#1783)が埋められなかった
サーバ再起動後の claude の model(claude はSessionStartにしか model を載せない)も同じ経路で埋まる - 既存の detection パターンは 1 バイトも変更していない。
CODEX_STATUS_BAR_PATTERNは codex の
running/idle 判定の境界(#1150)、CLAUDE_MODEL_OVERLAY_FOOTER_PATTERNは Auto-Yes が/modelピッカーを
誤確定してユーザーのグローバル既定モデルを書き換えるのを防ぐガード(#1495)。前者はフッタ行の特定にだけ
read-only で再利用し、値の読み出しは新規パターンで行う - 実測で確定させた形式(2026-08-15、隔離 socket
tmux -L cm1784probe+ 200x60 の捨てセッション。
fixture はtests/fixtures/model-info-captures.ts):
codex 0.147.0gpt-5.6-sol xhigh · ~/…(legacygpt-5.4 high · 21% left · ~/…と effort 無しの
o4-mini 50% left · /pathも同一パターンで解ける — 最初の·より前を切ってから読む形にしたため。
位置で読むと legacy の50%が effort として出てしまう)/
claude 2.1.232▝▜█████▛▘ Opus 5 (1M context) with xhigh effort · Claude Max/
agy 1.1.13? for shortcuts … Gemini 3.7 Flash · hig - Issue 本文と食い違った実測を 2 件、実測を正として実装した:
①agy のステータスバーは右寄せの最終 1 桁が欠ける(highがhigとして届く。200 桁でも 120 桁でも再現=
capture ではなく agy 1.1.13 のレンダラ側)。Issue 本文のGemini 3.5 Flash · mediumはそのままでは取れないので、
1 文字欠けを一意に復元できるときだけ復元する(hig→high/lo→low/mediu→medium)。
復元できない末尾トークンはその行ごと捨てる — さもないと effort 無しモデルの名前が 1 文字削れて出る。
②Issue 本文はgpt-oss-120b-mediumを「サフィックス無し」に分類しつつ規則を-low|-medium|-high$と書いており
自己矛盾している。書かれた規則どおりmediumと読む(UI に出る model は hooks の id そのままなので実害なし) - マージ規則: model は hooks > capture(食い違えば hooks。agy は
Gemini 3.7 Flashと表示するが
id はgemini-3.7-flash-high)、effort は codex/claude が capture、antigravity は modelName 末尾サフィックス導出 >
capture。保持は hooks 由来(__agentEventLastModel)と capture 由来(__agentCapturedModelInfo)を
別 Map に持つ(同一 Map に混ぜると scrape した表示名が id を後書きで潰し、復元手段が無くなる) CliToolSessionStatus.reasoningEffort?を追加しsessionStatusByInstance経由で API へ。未知のときはキー自体を
出さない(#1783 が model に決めた規約と同じ。toEqualで全体比較している既存スイートを壊さないため)。
UI は #1783 のモデル表示に· <effort>を追記する形で、effort 不明なら表示は #1783 と 1 バイトも変わらない。
DesktopHeader の status pill は幅配給の都合でツールチップのみ(#1783 の制約を維持)- この機能のための tmux capture は 1 回も増やしていない — ステータス検出ポーラが既に取った capture テキストに
相乗りする(captureSessionOutputの呼び出し回数をテストで固定) - 空振り緑の反証(変異注入で実測): ①
resolveEffortTokenが常に null を返すよう変異=effort 抽出を無効化 →
41 テストが赤(extractor 27・retention 9・join 5。UI スイートは緑のまま=表示ロジックは抽出に依らないことの裏取り)。
②ステータス検出ポーラからrecordCapturedModelInfo(...extractModelInfo(...))の呼び出しを外す → join の 6 テストが赤
(「antigravity は modelName 導出」だけは緑のまま=capture 非依存の経路であることの裏取り)。
2 変異とも復元しgit statusで確認、全緑に復帰
- reasoning effort はどのツールの hooks payload にも存在しない(
-
feat(realtime): 入力待ちを WS 配信・要対応バッジ・クロス画面 Toast で即時に知らせる (#1788)
- WS 配信は #1786 の
onWaitingTransitionを購読するだけ(src/lib/realtime/waiting-broadcast.ts)。
検出器を新設していないので、hooks 由来(構造化イベントだけが見えるダイアログ)でもスクレイパー由来でも
同じように飛び、response poller には依存しない。発火はエッジのみ(21 回の poll で 1 フレーム) SessionStatusEventは追加のみ(isWaitingForResponse?/waitingKind?/waitingSince?)。
ただしisRunningは optional 化した。observeWaitingEdgeはセッションが消えた probe でも
waiting:falseで呼ばれるため waiting フレームはセッション存在を答えられず、trueを送れば
kill 済みセッションが sidebar で蘇りfalseを送れば生存中を殺す。既存 2 消費者
(useTerminalPanePolling/useSplitMessages)はisRunning !== falseで守られているので不在は安全- Issue 本文の
broadcastToWorktreeは実在しない(実測)。ws-server の room ブロードキャストは
broadcast/ 内部handleBroadcast。後者をsetupWebSocketから注入し、closeWebSocketで解除する
(import 循環回避+テストで実サーバ不要)。room は認証済み subscribe でしか入れないので認可は迂回しない - バッジ件数は
src/hooks/useAttentionCount.tsの単一セレクタ(selectAttentionCount/
selectAttentionWorktrees/useAttentionCount)。方針D (#1789) のタブタイトル・favicon・App Badge は
ここを import すること。 カウント規則は「waiting な worktree を 1 件(instance が 3 つ待機でも 1)」=
この数字は/review?filter=approvalへのリンクで、その一覧の述語と同一だから - PC はサイドバーヘッダのピル、モバイルは
GlobalMobileNavの Review タブのバブル(0 件で非表示、
99 超は99+、件数>0 のときタップ先が approval フィルタ)。Home の Waiting 統計もリンク化し、
同じセレクタで数えるようにした(旧ローカル集計はisSessionRunningも要求しており approval 一覧より
1 件少なく出得たので意図的な挙動変更) - クロス画面 Toast(
WaitingToastListener、AppProviders に単一マウント): 表示中の worktree では出さない/
waitingSinceを dedup キーに 1 episode 1 回/トグルで無効化可。Toast 本文は実<button>にした
(hover 依存の表現はタッチ端末で恒久的に不可視になるため) - アプリ内通知トグルは push カードの外・上に置いた。
NotificationsSettingsの本体は Push API 非対応 /
iOS 未インストール / VAPID 未設定で早期 return する — アプリ内 Toast が唯一の通知になるのは
まさにその環境なので、その内側に置くと最も必要な場所で設定が消える - ポーリングは廃止していない(間隔も不変)。WS 未接続クライアントが従来どおり poll で waiting を知る
回帰テストつき - 空振り緑の反証(変異注入で実測・全て復元済み): ①エッジ購読を無効化 →
waiting-broadcast-1788が
7 件赤(broadcasts the extended frame when a wait beginsほか)②episode dedup を無効化 →
fires once per episode however many frames repeat itが赤(expected [...] to have a length of 1 but got 3)
③兄弟 instance の再計算を無効化 →keeps the worktree waiting while a SIBLING instance still isが赤
- WS 配信は #1786 の
-
feat(verification): 実行契約が Issue 固有のゲート定義を運べるようにした(#1756 案 B) (#1791)
- 契約に
verify.gateDefinitions([{id, command, timeoutSec}]) を追加した。verify.gatesの意味は変えていない
(宣言済みゲートからの選択)。gates省略時は「verify.yaml の全ゲート + この契約の定義全部」 .commandmate/verify.yamlは読むだけで 1 バイトも書かない。 orchestrator が Issue 固有ゲートを渡すのに
verify.yaml を書き換えるしかなかったのが問題だった — verify.yaml は work-evidence の変更集合に残るので
(除外は.commandmate/tasks/だけ)、追記を置いただけの worktree が「作業済み」に見えてexit 21が意味を失う。
契約は既にtasks.contract_jsonへ snapshot 済みで変更集合からも除外済みなので、新しい改竄面を作らずに済む。
work-evidence / scope の除外規則(CONTRACT_DIR_PREFIX)は変更していない- 形と検証は verify.yaml の
gates[]と同一実装。 verify-config の gate エントリ検証を
validateGateEntriesとして切り出して両方から呼ぶので、id パターン・予約 id 禁止・重複禁止・
timeout の整数と範囲が「同じ制約」であることがコメントではなく事実になる - 送信時に fail-closed で拒否(exit 2): 予約 id(
work-evidence/scope/env-clean)との衝突、
verify.yaml の既存 gate id との衝突、gatesがどこにも無い id を名指し、verify.yaml が無いのに定義がある。
id 衝突を黙って上書きにしないのは、リポジトリ自身が宣言した合格の定義を委任単位で差し替えられ、
しかもレポート上は同じ id なので差し替えたことが読み取れないため(override が要るなら別 Issue で明示構文を設計する) - 裁定の出所を記録する(migration v56:
verification_gate_results.source):builtin/verify.yaml/contract。
verify --json/verify show(src=<source>)/verify history --jsonから読め、
verifyとwait --verifyの GATE 行はcontractのときだけ末尾に[contract]を付ける。
どの裁定がリポジトリの合格定義でどれが Issue 固有かが report から読めないと、この分離は
「静かな 2 つ目の verify.yaml」になる。 v56 以前の行はnullで backfill しない(履歴は書き換えない) - 実行順は verify.yaml の宣言順 → 契約の宣言順。マージ元はこのランが結び付いた task の契約だけで、
未接続の契約(findDetachedContract)からは読まない。id が verify.yaml と衝突する契約が届いた場合は
マージせず run をerrorにする(送信時に弾いているので旧ビルド由来のみ) gatesを書いたのに定義したゲートを選ばないのは契約エラーにした(Issue 本文にない追加規則)。
契約が唯一の宣言元なので、選ばれなければ永久に走らない=「チェックを足したつもりで足していない」契約になる- 空振り緑の反証(変異注入で実測): ①契約ゲートをマージから外す → 失敗するはずの Issue ゲートを持つ run が
passedを返し(実測PROBE run.status=passed repro=absent)6 テストが赤 ②verify.yaml との id 衝突検証を外す →
送信が 201 で通り(expected 201 to be 400)2 テストが赤 ③予約 id 検証を外す → 10 テストが赤
(契約側 6・verify.yaml 側 4=共有バリデータが両方を守っていることの裏取り)。3 変異とも復元して全緑に復帰
- 契約に
-
feat(session): 入力待ちの構造化合成と
waitingKind/waitingSince/awaitingInstructionを一覧 API に露出(入力待ち可視化 方針A・基盤) (#1786)- 一覧 API(
/api/worktrees・/api/worktrees/[id])が構造化イベントを見るようになった。 これまで per-instance の状態はdetectSessionStatus()(スクレイパー)だけで決めており、hooks が正確に知っている待ち(#1725 のprompt_waiting)はサイドバー / Home / Sessions / Review / CommandPalette のどのドットにも出ていなかった。checkCliToolStatusでpeekPromptWaiting()を通しisWaitingForResponseを OR で広げる - 副作用の無い read-only 変種(
peekPromptWaiting)を新設して一覧側はそれを使う。resolvePromptWaitingは corroborate/clear の副作用を持つが、一覧側は (a)STATUS_DETECTION_CAPTURE_LINESの狭い窓で検出するため「プロンプトは無い」の証拠が解除規則の想定より弱い、(b) 同じ記録をblocksSendが読むので read エンドポイントの誤解除が send ガードを黙って解除する(#1708 の穴の再来)、(c) 開いているタブの数で状態機械の結果が変わる、の 3 点から書き手にしない。corroborate を張る側(buildCurrentOutput/ send ガード)は無変更で、blocksSendの意味・挙動も変えていない isWaitingForResponse = resolution.waiting(Issue 本文の指定)は採らず OR にした。 コードで裏取りした食い違い:resolution.waitingはhasActivePrompt || 構造化だが、一覧のフラグはsessionStatusToActivityFlags(status)=status === 'waiting'で厳密に広い(selection list と codex pager はhasActivePrompt: falseのwaiting)。そのまま代入するとサイドバーの選択リストがオレンジから緑に落ちる回帰になり、本 Issue の非機能要件(ドットを悪化させない)に反する。OR は広げるだけで狭めないCliToolSessionStatusにwaitingKind('prompt'|'menu'|'unclassified'|null)/waitingSince(epoch ms)/awaitingInstructionを追加し、sessionStatusByCli/sessionStatusByInstance経由で露出。per-CLI 集約は kind=優先度(prompt>menu>unclassified)・since=最小値(最長の待ち)・awaiting=OR。クライアント型(types/models.ts)はSessionWaitingDetailとして同期し全フィールド optional(既存 fixture・古いサーバの応答がそのまま型検査を通る)。UI は変更していない(方針 B/C の担当)deriveWaitingKind()を純関数(session/waiting-kind.ts)として切り出した。menuの判定式はcurrent-output-builderのisSelectionListActiveと同一で、SELECTION_LIST_REASONSの membership を二重に持たない- 待ちエッジの観測点を 1 箇所に閉じた(
session/waiting-episode-state.ts)。observeWaitingEdge()が false→true / true→false を検出し、onWaitingTransition()が**#1788(WS 配信)と #1790(push 発火)の差し込み口**になる(ポーリング回数ではなく交差 1 回につき 1 発火。購読解除関数を返す)。sinceはエピソード中不変で、構造化 episode のatを優先する(エージェントは描画と同時に post、scraper は 5 秒キャッシュ越しなので必ず遅れる)。listener の例外は握り潰す(一覧 API の hot path で通知 sink の故障がステータス読みを落としてはならない)。#1736 の規約どおりglobalThis保持で、module-identity テストにケースを追加した notification(idle_prompt)を第 3 の状態にした。agent-event-stateにawaiting_instructionを併設し、user_prompt_submit/session_start/session_end/ 世代交代まで保持する。SessionStatusの 4 値は変更していない —idle_promptはreadyのままで、ready=「送信できる」の意味を全消費者に再判断させないため boolean を足す形にした。stopでは立てない(中間ターンの終了は指示待ちではない)。この状態にだけ齢の上限を掛けていない(解除が composer 入力=UserPromptSubmitとセッション終了という取りこぼしようのないイベントで、6 時間 idle のエージェントは実際にまだ指示待ちだから)- 追加の tmux capture は発行しない(構造化状態は in-memory 参照。エッジ記録は try/catch の外で 1 回だけ呼び、未起動・capture 失敗も「待っていない」として開いていたエピソードを閉じる)
- 空振り緑の反証: 構造化合成(
|| peek.waiting)を無効化する変異を注入し、4 テストが赤になることを実測(waits when the scraper says \ready` and the agent reported an open dialogほか)。変異は戻してgit status` で確認済み
- 一覧 API(
-
feat(hooks): エージェントの実モデル名を構造化イベントから取得し UI に表示する(モデル/effort 可視化 Phase 1/3) (#1783)
- モデル名は最初から payload に載っていて、正規化層で捨てられていた。
NormalizedAgentEventにmodel: string | nullを追加し、worktree 詳細画面の分割ペインタイトル・ヘッダの status pill・ロスター(PC / モバイル)に表示する。effort の取得(#1784)と CLI 露出(#1785)は本 Issue のスコープ外 - 抽出は
define-source.tsのbuildNormalizedEventに一元化し、ツール差は spec の宣言に閉じた。 フラットキー用のmodelFields?: readonly string[](claude / codex =model、antigravity =modelName)と、フラットでは届かない形のためのextractModel?: (payload) => string | null(opencode)の併用。片方だけにしなかった理由は、conversationIdFieldsが既に同じ 2 段構え(宣言 + 逃がし弁)で、model だけ別の作法にすると「どちらを見ればいいか」がツールごとに変わるから。両方あるときは関数が勝つ - Issue 本文の記述と食い違った点(実測を正とした): opencode の model キーは 1 つではなく 2 つある。 本文は
model.modelIDだけを挙げていたが、捕捉済み fixture ではmessage.updatedがproperties.info.model.modelID、session.created/session.deletedはproperties.info.model.id。modelIDだけを読む実装はsession_start— 購読を張った直後に必ず来る唯一のフレーム — で model を取り逃す。両方読む(modelID→idの順)。providerIDは連結しない(他ツールは素のモデル名を返すので、opencode だけgithub-copilot/claude-sonnet-4.6になる) lastAgentEventはレコードごと置換されるので、model 専用の Map を別に持った。 claude はSessionStartにしか model を載せない(fixture 13 件中 1 件)ため、最新イベントから読むと最初のプロンプト送信で表示が消える。getLastKnownAgentModel()は最後に観測した非 null 値を latch し、null のイベントは既知の値を消さない。/clear(session_end→session_start)では保持し、破棄するのは世代交代(beginAgentEventGeneration=別プロセス)とdiscardAgentEventStateのときだけ- 齢による失効(
STRUCTURED_STATE_MAX_AGE_MS)は付けていない。 あの 30 分は「Stopを取りこぼした status がrunningに貼り付く」を防ぐためのもので、model はそういう種類の主張ではない(8 時間走るターンも最初と最後で同じモデル)。失効させると一番役に立つ長時間セッションで表示が消える - モデル未報告時は
sessionStatusByInstanceにキー自体を出さない。model: nullを常設するとworktree-status-helper-status-mapping.test.tsのtoEqual比較が落ちるうえ、不在が言うこと以上を言わない。UI 側もnull のときは何も描画しない — gemini / copilot は payload に model を持たず、hooks 未設定のセッションも同様なので、「不明」バッジは全行に出る恒久的なノイズになる - DesktopHeader はツールチップのみにした(表示面の判断)。 あの行は
MAX_HEADER_AGENT_PILLSで幅を配給していて余剰は「+N」に畳まれるため、pill ごとに 2 本目の文字列を足すと稼働中のインスタンスがモデル名のために overflow に押し出される。model は既存の hover /aria-labelに載せ、見えている pill のテキストは 1 バイトも変えていない。分割ペインのタイトルバー(最優先の表示面)とロスター行は実テキストで表示する - 保持は in-memory のみで DB(
session_states)には永続化しない。サーバ再起動後、codex / antigravity は次イベントで復活し、claude は次のSessionStartまで不明になる。この穴は Phase 2(#1784)の capture フォールバックが埋める設計 - gemini / copilot は対象外(model: null のまま)。 copilot は payload に model キーが無く、gemini の
BeforeModel(唯一 model を持つ payload)はGEMINI_HOOK_EVENT_NAMESに無く unknown-event として捨てられる。語彙追加が要るので別 Issue CLAUDE_MODEL_OVERLAY_FOOTER_PATTERN(#1495 の/modelピッカー誤確定ガード)には触れていない- 空振り緑の反証: 6 変異を注入して赤になることを実測(ベースラインは 4 ファイル 63 テスト緑)。①
buildNormalizedEventの model 抽出をnull固定 → 20 赤 ②recordAgentEventの latch を外す → 12 赤 ③worktree-status-helperで model を載せない → 2 赤 ④TerminalSplitPaneの null ガードを外す → 3 赤(「何も描画しない」側の 3 ケースだけが落ちる) ⑤opencode のmodel.idフォールバックを削る → 2 赤(session.created/session.deleted。Issue 本文どおりmodelIDだけを読む実装が落ちる) ⑥DesktopHeader の tooltip を model 抜きに戻す → 2 赤。すべて戻して 63/63 緑・git statusで復元を確認
- モデル名は最初から payload に載っていて、正規化層で捨てられていた。