@cocorograph/hub-agent 0.7.49 → 0.7.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.49",
3
+ "version": "0.7.52",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
Binary file
@@ -145,6 +145,50 @@ const CHAT_SHARED_ENABLED = process.env.HUB_AGENT_CHAT_SHARED === "1"
145
145
  const DEAD_TERMINAL_TTL_MS =
146
146
  Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
147
147
 
148
+ /**
149
+ * モデル能力レジストリ (2026-07-11): モデル名 → { supportsEffort, supportsXhighEffort }。
150
+ *
151
+ * 【なぜ SDK 由来にするか】旧実装は effort 対応可否を正規表現
152
+ * `/claude-fable-5|claude-opus-4-[678]/` でハードコードしていた。これは Hub のモデル
153
+ * マスタとも frontend の正規表現とも独立した第 3 の SoT で、新モデルが出るたびに
154
+ * 取りこぼす (2026-07-02 に fable-5 で実際に発生)。SDK の `supportedModels()` は
155
+ * `ModelInfo.supportsEffort` / `supportedEffortLevels` を権威的に返すので、それを
156
+ * 起動時に 1 回引いてここへ載せ、以降の effort 判定に使う。
157
+ *
158
+ * 空 (SDK 未取得 / 一覧に無いカスタムモデル) の場合は「browser が effort を明示送信
159
+ * してきたか」にフォールバックする (frontend は Hub マスタの supports_effort で判定して
160
+ * から送るため信頼できる)。詳細は ClaudeStreamSession._isEffortModel。
161
+ *
162
+ * @type {Map<string, { supportsEffort: boolean, supportsXhighEffort: boolean }>}
163
+ */
164
+ const _modelCapabilities = new Map()
165
+
166
+ /** SDK `supportedModels()` の戻り値 (ModelInfo[]) をレジストリへ載せる。 */
167
+ export function setModelCapabilities(models) {
168
+ _modelCapabilities.clear()
169
+ for (const m of models || []) {
170
+ const name = m?.value
171
+ if (typeof name !== "string" || !name) continue
172
+ const levels = Array.isArray(m.supportedEffortLevels) ? m.supportedEffortLevels : []
173
+ _modelCapabilities.set(name, {
174
+ supportsEffort: m.supportsEffort === true,
175
+ supportsXhighEffort: levels.includes("xhigh") || levels.includes("max"),
176
+ })
177
+ }
178
+ return _modelCapabilities.size
179
+ }
180
+
181
+ /** モデル名の能力を返す。未知 (未登録 / 一覧に無い) なら null。 */
182
+ export function getModelCapability(model) {
183
+ if (typeof model !== "string" || !model) return null
184
+ return _modelCapabilities.get(model) ?? null
185
+ }
186
+
187
+ /** テスト用: レジストリをクリアする。 */
188
+ export function _resetModelCapabilities() {
189
+ _modelCapabilities.clear()
190
+ }
191
+
148
192
  /** 文字列を SDK streaming input の SDKUserMessage に包む。
149
193
  * SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
150
194
  * 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
@@ -209,6 +253,24 @@ function extractPromptText(message) {
209
253
  return ""
210
254
  }
211
255
 
256
+ /**
257
+ * SDK-P0-3 (2026-07-10): SDK の権威的ターン終了シグナル。
258
+ * sdk.d.ts (SDKSessionStateChangedMessage) いわく
259
+ * "'idle' fires after heldBackResult flushes and the bg-agent do-while exits
260
+ * — authoritative turn-over signal."
261
+ * つまり result が流れるターンでは `result` → `idle` の順で届き、result が届かない
262
+ * ターン (held back のまま終わる等) でも idle は届く。frontend は SDK-P0-1 で既に
263
+ * idle をターン終端として扱っているが、agent 側 (resident query) は result しか見て
264
+ * おらず _busy が固着していた。その非対称を解消するための判定。
265
+ */
266
+ function isSessionIdleEvent(msg) {
267
+ return (
268
+ msg?.type === "system" &&
269
+ msg?.subtype === "session_state_changed" &&
270
+ msg?.state === "idle"
271
+ )
272
+ }
273
+
212
274
  /**
213
275
  * 1 stream に対応する Claude セッション (per-message query モデル)。
214
276
  */
@@ -227,6 +289,7 @@ class ClaudeStreamSession {
227
289
  logger,
228
290
  onEvent,
229
291
  onPermission,
292
+ onPermissionCancel,
230
293
  onExit,
231
294
  onError,
232
295
  onReap,
@@ -257,6 +320,8 @@ class ClaudeStreamSession {
257
320
  this.logger = logger
258
321
  this.onEvent = onEvent
259
322
  this.onPermission = onPermission
323
+ /** 未応答 permission を内部 deny した際に browser へカード撤去を伝えるコールバック。 */
324
+ this.onPermissionCancel = onPermissionCancel
260
325
  this.onExit = onExit
261
326
  this.onError = onError
262
327
  /** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
@@ -331,6 +396,18 @@ class ClaudeStreamSession {
331
396
  this._permissionResolvers = new Map()
332
397
  /** 現在ターン実行中か (多重 query 防止) */
333
398
  this._busy = false
399
+ /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (result / idle / abort の
400
+ * いずれか 1 回だけ _busy=false + drain させるための冪等ガード)。resident query は
401
+ * 1 ターンにつき `result` と `session_state_changed:idle` の**両方**が流れうるため、
402
+ * 素朴に両方で drain すると二重 drain (FIFO 崩れ) を起こす。ターン開始時
403
+ * (_beginResidentTurn) に false へ戻す。 */
404
+ this._turnSettled = false
405
+ /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
406
+ * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
407
+ * ガードに使う。generator のイベント順序は保証される (turn N の idle は turn N+1 の
408
+ * 最初のイベントより必ず先に届く) ため、`idle 到着時に現ターンがまだ何も出していない`
409
+ * = その idle は前ターンのもの、と判定できる。ターン開始時に false へ戻す。 */
410
+ this._turnProgressed = false
334
411
  /** detach 済みフラグ (新規ターン受付停止) */
335
412
  this._closed = false
336
413
  /** 実行中ターンの完走を待ってからクローズする予約フラグ (graceful detach 用)。
@@ -435,6 +512,20 @@ class ClaudeStreamSession {
435
512
  })
436
513
  }
437
514
 
515
+ /**
516
+ * SDK options.stderr: CLI サブプロセスの stderr を logger へ流す (2026-07-11 監査)。
517
+ *
518
+ * 未設定だと認証エラー・MCP 起動失敗・内部警告といったサブプロセスの一次情報が完全に
519
+ * 破棄され、「SDK チャットが無音で挙動がおかしい」系の障害調査で手掛かりが残らない。
520
+ * onError は generator が throw したときしか発火しないため、それとは別に stderr を拾う。
521
+ * デバッグ用途なので debug レベルで出す (通常運用のログを汚さない)。
522
+ */
523
+ _stderrHandler(data) {
524
+ const text = typeof data === "string" ? data.trim() : ""
525
+ if (!text) return
526
+ this.logger?.debug({ stream_id: this.stream_id, cwd: this.cwd, stderr: text }, "claude cli stderr")
527
+ }
528
+
438
529
  /** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
439
530
  async _canUseTool(toolName, input) {
440
531
  if (!this.onPermission) return { behavior: "allow", updatedInput: input }
@@ -458,6 +549,46 @@ class ClaudeStreamSession {
458
549
  })
459
550
  }
460
551
 
552
+ /**
553
+ * 未応答の permission をすべて内部 deny し、その request_id を browser へ通知する。
554
+ *
555
+ * 【なぜ通知が要るか】(2026-07-10 監査)
556
+ * ターン終了 / 中断 (abort) / セッション close の際、SDK の `canUseTool` Promise を
557
+ * 宙吊りにしないため deny で解決していた。しかし **browser には何も送っていなかった**
558
+ * ため、承認カードだけが画面に残り続ける (孤児カード)。ユーザーはそれをクリックする
559
+ * まで消せず、既に終わったターンのツールを承認/拒否したかのような UI になる。
560
+ *
561
+ * `claude.permission.cancel` は既に agent → backend → browser の型として存在する
562
+ * (TUI 由来カードの撤去に使われている) ので、SDK チャットの内部 deny でも同じ型で
563
+ * 撤去を伝える。request_id は browser 側で照合され、未知 id は no-op になる。
564
+ *
565
+ * @param {string} reason "turn ended" | "aborted" | "closed" 等
566
+ * @returns {string[]} deny した request_id 群
567
+ */
568
+ _denyAllPendingPermissions(reason) {
569
+ const cancelled = []
570
+ for (const [request_id, resolver] of this._permissionResolvers) {
571
+ cancelled.push(request_id)
572
+ try {
573
+ resolver.resolve({ behavior: "deny", message: reason })
574
+ } catch {
575
+ /* ignore */
576
+ }
577
+ }
578
+ this._permissionResolvers.clear()
579
+ if (cancelled.length && this.onPermissionCancel) {
580
+ try {
581
+ this.onPermissionCancel({ request_ids: cancelled, reason })
582
+ } catch (err) {
583
+ this.logger?.warn(
584
+ { err: err?.message, reason },
585
+ "onPermissionCancel callback threw",
586
+ )
587
+ }
588
+ }
589
+ return cancelled
590
+ }
591
+
461
592
  /** browser からの permission 応答を該当 request_id の Promise に渡す。 */
462
593
  resolvePermission(request_id, decision) {
463
594
  const r = this._permissionResolvers.get(request_id)
@@ -602,26 +733,28 @@ class ClaudeStreamSession {
602
733
  }
603
734
  }
604
735
 
605
- /** モデルが effort / adaptive thinking 対応 (Opus 4.6+ / Fable 5) かどうか。
606
- * budget 方式 (maxThinkingTokens) は Opus 4.7+ で廃止扱いのため、effort モデルでは
607
- * effort + thinking:{type:'adaptive'} に切り替える。
608
- * ⚠️ frontend の isEffortModelByPattern (types/cockpit.ts) と同一パターンを維持する
609
- * こと。旧実装は fable-5 を含まず、frontend が effort を送っても bridge が非 effort
610
- * 分岐に入り「effort も budget も未適用」になる不整合があった (2026-07-02 修正)。 */
736
+ /** モデルが effort / adaptive thinking 対応かどうか。
737
+ *
738
+ * 判定順 (2026-07-11、正規表現ハードコードを廃止):
739
+ * 1. SDK `supportedModels()` 由来の能力レジストリに this.model があればそれを使う
740
+ * (権威。新モデルも取りこぼさない)。
741
+ * 2. レジストリに無い (SDK 未取得 / 一覧に無いカスタムモデル / model 未指定) 場合は、
742
+ * **browser が effort を明示送信してきたか**にフォールバックする。frontend は
743
+ * Hub マスタの supports_effort で判定してから effort を送るため、「effort を
744
+ * 送ってきた ≒ そのモデルは effort 対応」が成り立つ。これにより正規表現による
745
+ * モデル名の当てずっぽうを排し、能力を引けない起動直後も安全側に倒れる。 */
611
746
  _isEffortModel() {
612
- return (
613
- typeof this.model === "string" && /claude-fable-5|claude-opus-4-[678]/.test(this.model)
614
- )
747
+ const cap = getModelCapability(this.model)
748
+ if (cap) return cap.supportsEffort
749
+ return !!this.effort
615
750
  }
616
751
 
617
752
  /** 思考関連オプション (effort / adaptive thinking / 旧 budget) を options へ適用する。
618
753
  * per-message / 常駐 query の両方から呼ぶ共通ロジック (分岐の二重定義を避ける)。 */
619
754
  _applyThinkingOptions(options) {
620
- // model 未指定 (= browser が agent/アカウント既定モデルへ委任) でも、browser が
621
- // effort を明示送信してきた場合は effort 分岐に入れる。frontend は system/init の
622
- // 実効モデル (runtimeModel) で capability 判定してから effort を送るため、ここでは
623
- // その判断を信頼する (2026-07-02)。
624
- if (this._isEffortModel() || (!this.model && this.effort)) {
755
+ // effort 対応判定は _isEffortModel に一元化 (レジストリ → browser effort フォールバック)。
756
+ // model 未指定 (= browser が既定モデルへ委任) でも effort 送信時は effort 分岐に入る。
757
+ if (this._isEffortModel()) {
625
758
  // effort モデル: adaptive thinking を明示 ON にし、effort で深さを指定する。
626
759
  // budget 方式 (maxThinkingTokens) は使わない (Opus 4.7+ で非対応)。
627
760
  options.thinking = { type: "adaptive" }
@@ -721,7 +854,7 @@ class ClaudeStreamSession {
721
854
  this._emitQueueState()
722
855
  return { accepted: true }
723
856
  }
724
- this._busy = true
857
+ this._beginResidentTurn()
725
858
  // 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
726
859
  // なら (再)起動する。_runResidentQuery は起動時 options.resume=this.sessionId で文脈を
727
860
  // 復元するため、途中死からの復活でも過去コンテキストは失われない。
@@ -794,6 +927,7 @@ class ClaudeStreamSession {
794
927
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
795
928
  includePartialMessages: true,
796
929
  abortController: this._abortController,
930
+ stderr: (data) => this._stderrHandler(data),
797
931
  }
798
932
  if (this.model) options.model = this.model
799
933
  if (this.permissionMode) options.permissionMode = this.permissionMode
@@ -858,15 +992,8 @@ class ClaudeStreamSession {
858
992
  }
859
993
  } finally {
860
994
  this._abortController = null
861
- // 未解決 permission は閉じる
862
- for (const [, resolver] of this._permissionResolvers) {
863
- try {
864
- resolver.resolve({ behavior: "deny", message: "turn ended" })
865
- } catch {
866
- /* ignore */
867
- }
868
- }
869
- this._permissionResolvers.clear()
995
+ // 未解決 permission は閉じ、browser の孤児カードも撤去する
996
+ this._denyAllPendingPermissions("turn ended")
870
997
  // このターンで jsonl に書かれた行は query stream で既に push 済みなので、
871
998
  // watcher の offset を末尾に飛ばして二重 push を防ぐ。busy=true のまま
872
999
  // skipToEnd を待ち、完了後に busy=false にすることで、その間に watcher poll が
@@ -1067,6 +1194,40 @@ class ClaudeStreamSession {
1067
1194
  }
1068
1195
  }
1069
1196
 
1197
+ /** SDK-P0-3 (2026-07-10): resident の「ターン開始」を 1 箇所に集約する。
1198
+ * _busy を立てるのと同時に、そのターンの終端フラグ (_turnSettled) と進行フラグ
1199
+ * (_turnProgressed) を必ずリセットする。ターン開始は 3 箇所ある
1200
+ * (sendMessage の直接 push / _drainResidentPending / _runResidentQuery finally の
1201
+ * 異常再起動 push) ので、リセット漏れを構造的に防ぐためのヘルパー。 */
1202
+ _beginResidentTurn() {
1203
+ this._busy = true
1204
+ this._turnSettled = false
1205
+ this._turnProgressed = false
1206
+ }
1207
+
1208
+ /** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
1209
+ * 同一ターンで 2 回以上呼ばれても最初の 1 回しか作用しない (_turnSettled ガード)。
1210
+ * これにより `result` → `idle` の順で両方届く通常ケースでも drain は 1 回だけになる。
1211
+ * @param {string} reason "result" | "idle" — ログ用
1212
+ * @param {(msg: string) => void} [denyPending] 未解決 permission を閉じるクロージャ
1213
+ */
1214
+ _settleResidentTurn(reason, denyPending) {
1215
+ if (this._turnSettled) return
1216
+ this._turnSettled = true
1217
+ denyPending?.("turn ended")
1218
+ this._busy = false
1219
+ // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1220
+ // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1221
+ // async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
1222
+ // _beginResidentTurn が同期実行されるので、以降の sendMessage は busy を正しく観測する)。
1223
+ this._drainResidentPending().catch((err) =>
1224
+ this.logger?.warn(
1225
+ { stream_id: this.stream_id, err: err?.message, reason },
1226
+ "drainResidentPending threw",
1227
+ ),
1228
+ )
1229
+ }
1230
+
1070
1231
  /** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
1071
1232
  * queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
1072
1233
  * ultracode (0.6.28): 次ターンの目標 ultracode 状態へ reconcile してから push する
@@ -1082,7 +1243,7 @@ class ClaudeStreamSession {
1082
1243
  // _busy=true のまま残り、以降の drain が全て弾かれて pending が永久に積み上がる
1083
1244
  // デッドロックになる。失敗時は取り出したメッセージを先頭へ戻し _busy を解除して、
1084
1245
  // 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
1085
- this._busy = true
1246
+ this._beginResidentTurn()
1086
1247
  try {
1087
1248
  // ultracode reconcile (0.6.28) も await で例外を投げうるので try の中に含める。
1088
1249
  await this._reconcileResidentUltracode(next.ultracode === true)
@@ -1100,8 +1261,10 @@ class ClaudeStreamSession {
1100
1261
 
1101
1262
  /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
1102
1263
  * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
1103
- * result で _busy=false にするが query は継続し次 input を待つ。close (inputQueue.close) で
1104
- * generator が終わり query が完走する。
1264
+ * 終端シグナル (result / session_state_changed:idle) で _busy=false にするが query は継続し
1265
+ * 次 input を待つ。close (inputQueue.close) で generator が終わり query が完走する。
1266
+ * SDK-P0-3 (2026-07-10): 終端を result だけに頼ると、SDK が idle でターンを終えたときに
1267
+ * _busy が固着し、以降の送信が pending へ永久滞留する (T06052)。
1105
1268
  * 改修4: resume セッションも対象。query 起動時 options.resume に過去 session_id を渡して
1106
1269
  * 文脈を引き継ぐ (init は初回 1 回のみ)。continue は使わない (自動継続の暴走回避)。初回 input は
1107
1270
  * sendMessage が積むユーザーの実メッセージなので「Continue from where you left off」は発生しない。
@@ -1113,6 +1276,7 @@ class ClaudeStreamSession {
1113
1276
  env: siblingEnvFor(this.cwd),
1114
1277
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
1115
1278
  includePartialMessages: true,
1279
+ stderr: (data) => this._stderrHandler(data),
1116
1280
  }
1117
1281
  if (this.model) options.model = this.model
1118
1282
  if (this.permissionMode) options.permissionMode = this.permissionMode
@@ -1129,16 +1293,7 @@ class ClaudeStreamSession {
1129
1293
  // 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
1130
1294
  // query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
1131
1295
  if (this.sessionId) options.resume = this.sessionId
1132
- const denyPending = (reason) => {
1133
- for (const [, resolver] of this._permissionResolvers) {
1134
- try {
1135
- resolver.resolve({ behavior: "deny", message: reason })
1136
- } catch {
1137
- /* ignore */
1138
- }
1139
- }
1140
- this._permissionResolvers.clear()
1141
- }
1296
+ const denyPending = (reason) => this._denyAllPendingPermissions(reason)
1142
1297
  try {
1143
1298
  const q = this.sdk.query({ prompt: this._inputQueue, options })
1144
1299
  this._residentQuery = q
@@ -1153,17 +1308,34 @@ class ClaudeStreamSession {
1153
1308
  if (msg?.type === "result") {
1154
1309
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1155
1310
  // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
1156
- denyPending("turn ended")
1157
- this._busy = false
1158
- // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1159
- // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1160
- // async。result ハンドラ (for await ループ内) からは fire-and-forget で呼ぶ。
1161
- this._drainResidentPending().catch((err) =>
1162
- this.logger?.warn(
1163
- { stream_id: this.stream_id, err: err?.message },
1164
- "drainResidentPending threw",
1165
- ),
1166
- )
1311
+ this._settleResidentTurn("result", denyPending)
1312
+ } else if (isSessionIdleEvent(msg)) {
1313
+ // SDK-P0-3 (2026-07-10): result が届かずに idle でターンが終わるケースの救済。
1314
+ // これを見ていなかったため _busy が true で固着し、以降の送信が
1315
+ // _pendingMessages に退避されたまま (drain 契機の result が永久に来ないので)
1316
+ // 永久滞留していた ("resident busy, message queued" が出続ける / リロードで消える)。
1317
+ //
1318
+ // ガード: 「前ターンの遅延 idle」で新ターンを誤終了させない。result で settle →
1319
+ // drain → 次ターン開始 (_busy=true) の直後に、同じターンの idle が続けて届く
1320
+ // (SDK は result → idle の順に流す) ため、素朴に _busy=false にすると新ターンを
1321
+ // 撃ち抜き二重 drain / FIFO 崩れを起こす。generator のイベント順序は保証されるので
1322
+ // 「現ターンがまだ 1 件もイベントを出していない (_turnProgressed=false)」なら
1323
+ // その idle は前ターンのものと確定できる。
1324
+ if (this._busy && this._turnProgressed) {
1325
+ this._settleResidentTurn("idle", denyPending)
1326
+ // per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
1327
+ // processing→waiting へ落とす。result 経由なら main.mjs が result を見て
1328
+ // waiting にするが、idle 単独ではその契機が無く 90s TTL まで固着するため。
1329
+ try {
1330
+ this.onTurnSettled?.()
1331
+ } catch {
1332
+ /* ignore */
1333
+ }
1334
+ }
1335
+ } else {
1336
+ // 現ターンが「進行した」証跡。init / assistant / stream_event / user、および
1337
+ // session_state_changed の running / requires_action もここに含む。
1338
+ this._turnProgressed = true
1167
1339
  }
1168
1340
  try {
1169
1341
  this._emit(msg)
@@ -1207,7 +1379,7 @@ class ClaudeStreamSession {
1207
1379
  // close() するだけで参照を持ち越さないので、ここで差し替えても整合する。
1208
1380
  this._inputQueue = new InputQueue()
1209
1381
  const next = this._pendingMessages.shift()
1210
- this._busy = true
1382
+ this._beginResidentTurn()
1211
1383
  this._residentStarted = true
1212
1384
  // ultracode (0.6.29): query を先に起動して _residentQuery を確定させてから
1213
1385
  // reconcile → push する (sendMessage の通常経路と同順)。push してから起動すると
@@ -1237,7 +1409,19 @@ class ClaudeStreamSession {
1237
1409
  } catch {
1238
1410
  /* ignore */
1239
1411
  }
1412
+ // SDK-P0-3 (2026-07-10): 中断も「このターンの終端」なので settled を立てる。
1413
+ // interrupt() 後に SDK が session_state_changed:idle を流しても、_turnSettled=true
1414
+ // (pending 無しで drain が早期 return するケース) か _turnProgressed=false
1415
+ // (drain が次ターンを開始したケース) のどちらかで必ず無視される。
1416
+ this._turnSettled = true
1240
1417
  this._busy = false
1418
+ // 孤児カード防止 (2026-07-10 監査): 中断したターンに紐づく未応答 permission は
1419
+ // もう誰も応答しない。deny で canUseTool の Promise を解放し、browser のカードも
1420
+ // 撤去する。これが無いと (a) 画面に承認カードが残り続け (b) _permissionResolvers に
1421
+ // 残った古い request_id が reattach 時の replayPendingPermissions で再送されて
1422
+ // 「終わったターンのカード」が蘇る。close() 経由の abort では下流の close() が
1423
+ // "closed" で再度呼ぶが、map は空なので no-op (二重通知しない)。
1424
+ this._denyAllPendingPermissions("aborted_by_user")
1241
1425
  // SDK-P0-2 (2026-07-07): resident 経路のターン終端イベント非対称の解消。
1242
1426
  // per-message の abort (_runPerMessage finally) は合成 result (aborted_by_user) を
1243
1427
  // frontend へ emit + onTurnSettled を呼んで UI (turnActive/interrupting) とサイドバー
@@ -1248,8 +1432,9 @@ class ClaudeStreamSession {
1248
1432
  // - 実行中ターンがあった (wasBusy) ときだけ emit する (未走行での空 abort に
1249
1433
  // 「中断しました」フッターを出さない)。
1250
1434
  // - close()/reap 経由の abort (_closed=true) では browser は既に離脱しているため抑止する。
1251
- // - drain タイミングは変更しない (既存の同期 drain を維持し、result+idle 二重 drain や
1252
- // 中断ターンの遅延 idle が新ターンを誤終了させる新種レースを持ち込まない)。
1435
+ // - drain タイミングは変更しない (既存の同期 drain を維持する)。result+idle の二重 drain と
1436
+ // 中断ターンの遅延 idle による新ターン誤終了は、SDK-P0-3 の _turnSettled /
1437
+ // _turnProgressed ガードで潰してある (上の _turnSettled=true もその一部)。
1253
1438
  // - 合成 result の uuid は重複排除 (isDuplicateEvent の result 署名判定) 回避のため必須。
1254
1439
  if (wasBusy && !this._closed) {
1255
1440
  try {
@@ -1343,14 +1528,7 @@ class ClaudeStreamSession {
1343
1528
  }
1344
1529
  this._watcher = null
1345
1530
  }
1346
- for (const [, resolver] of this._permissionResolvers) {
1347
- try {
1348
- resolver.resolve({ behavior: "deny", message: "closed" })
1349
- } catch {
1350
- /* ignore */
1351
- }
1352
- }
1353
- this._permissionResolvers.clear()
1531
+ this._denyAllPendingPermissions("closed")
1354
1532
  // マスク (2026-06-23): アイドル close (走行ターン無し = CLI が jsonl を掴んでいない) は
1355
1533
  // ここで即マスクする。走行中 close (abortTurn 経由) は CLI 終了を待つため run-loop の
1356
1534
  // finally 側でマスクする (上の _maskJsonlAtRest 冪等ガードで二重実行は防がれる)。
@@ -1558,6 +1736,16 @@ export class ClaudeStreamBridge extends EventEmitter {
1558
1736
  input,
1559
1737
  })
1560
1738
  },
1739
+ onPermissionCancel: ({ request_ids, reason }) => {
1740
+ // 内部 deny した承認カードを browser から撤去する (孤児カード防止)。
1741
+ this.emit("permissioncancel", {
1742
+ stream_id: session.stream_id,
1743
+ session_id: session.sessionId,
1744
+ cwd: session.cwd,
1745
+ request_ids,
1746
+ reason,
1747
+ })
1748
+ },
1561
1749
  onError: (err) => {
1562
1750
  this.emit("error", {
1563
1751
  stream_id: session.stream_id,
@@ -1756,6 +1944,39 @@ export class ClaudeStreamBridge extends EventEmitter {
1756
1944
  return this._mergeNeedsAuthCache(servers)
1757
1945
  }
1758
1946
 
1947
+ /**
1948
+ * SDK `supportedModels()` を control-only query 経由で 1 回引き、モデル能力レジストリ
1949
+ * (_modelCapabilities) を更新する。起動時に 1 回呼ぶ想定 (能力はセッション中不変なので
1950
+ * attach ごとに引き直さない)。
1951
+ *
1952
+ * `supportedModels()` は公開 Query インターフェース (sdk.d.ts) のメソッドだが、念のため
1953
+ * typeof ガードし、失敗 (未実装 SDK / 一時エラー) は握って false を返す。取得できなければ
1954
+ * レジストリは空のままで、effort 判定は「browser が effort を送ってきたか」へフォールバック
1955
+ * するため安全側に倒れる。
1956
+ *
1957
+ * @returns {Promise<number|false>} 載せたモデル数 / 失敗時 false
1958
+ */
1959
+ async refreshModelCapabilities() {
1960
+ try {
1961
+ const q = await this._ensureMcpControlQuery()
1962
+ this._armMcpControlIdle()
1963
+ if (typeof q.supportedModels !== "function") {
1964
+ this.logger?.warn({}, "SDK に supportedModels() が無い (能力レジストリ未更新)")
1965
+ return false
1966
+ }
1967
+ const models = await q.supportedModels()
1968
+ const n = setModelCapabilities(models)
1969
+ this.logger?.info({ count: n }, "model capabilities loaded from SDK")
1970
+ return n
1971
+ } catch (err) {
1972
+ this.logger?.warn(
1973
+ { err: err?.message },
1974
+ "refreshModelCapabilities failed (effort 判定は browser effort へフォールバック)",
1975
+ )
1976
+ return false
1977
+ }
1978
+ }
1979
+
1759
1980
  /**
1760
1981
  * アカウント全体の 5h/7d rate-limit 使用率 + プラン種別を返す (usage.mjs
1761
1982
  * recordChatUsageSnapshot に渡す想定)。mcpStatus と同じ control-only query を共有する
@@ -1948,10 +2169,36 @@ export class ClaudeStreamBridge extends EventEmitter {
1948
2169
  return { servers, targeted }
1949
2170
  }
1950
2171
 
2172
+ /**
2173
+ * SDK の control query が指定メソッドを実装しているか検査して返す。
2174
+ *
2175
+ * `mcpAuthenticate` / `mcpSubmitOAuthCallbackUrl` / `mcpClearAuth` は **公開
2176
+ * `Query` インターフェース (sdk.d.ts) に型が存在しない**内部 API で、ランタイム
2177
+ * (sdk.mjs) にのみ実装がある。SDK 側でリネーム/削除されても TypeScript は警告
2178
+ * できないため、`usage_EXPERIMENTAL_...` と同じく呼ぶ前に typeof で確かめる。
2179
+ *
2180
+ * 未実装なら `mcp_method_unsupported:<name>` を throw する。呼び出し側 (main.mjs の
2181
+ * `claude.mcp.*` ハンドラ) の try/catch がこれを掴んで `claude.mcp.response` の
2182
+ * error として browser へ返すので、`q.mcpAuthenticate is not a function` という
2183
+ * 診断不能なメッセージではなく「SDK がこの API を持っていない」と分かる形になる。
2184
+ */
2185
+ _requireMcpMethod(q, name) {
2186
+ if (!q || typeof q[name] !== "function") {
2187
+ const err = new Error(`mcp_method_unsupported:${name}`)
2188
+ this.logger?.warn(
2189
+ { method: name },
2190
+ "SDK control query に MCP メソッドが存在しない (SDK バージョン差異の可能性)",
2191
+ )
2192
+ throw err
2193
+ }
2194
+ return q
2195
+ }
2196
+
1951
2197
  /** 指定 MCP サーバーの OAuth 認証を開始する。認可 URL を含む応答を返す。 */
1952
2198
  async mcpAuthenticate(serverName, redirectUri) {
1953
2199
  const q = await this._ensureMcpControlQuery()
1954
2200
  this._armMcpControlIdle()
2201
+ this._requireMcpMethod(q, "mcpAuthenticate")
1955
2202
  return await q.mcpAuthenticate(serverName, redirectUri)
1956
2203
  }
1957
2204
 
@@ -1959,6 +2206,7 @@ export class ClaudeStreamBridge extends EventEmitter {
1959
2206
  async mcpSubmitOAuthCallback(serverName, callbackUrl) {
1960
2207
  const q = await this._ensureMcpControlQuery()
1961
2208
  this._armMcpControlIdle()
2209
+ this._requireMcpMethod(q, "mcpSubmitOAuthCallbackUrl")
1962
2210
  return await q.mcpSubmitOAuthCallbackUrl(serverName, callbackUrl)
1963
2211
  }
1964
2212
 
@@ -1966,6 +2214,7 @@ export class ClaudeStreamBridge extends EventEmitter {
1966
2214
  async mcpClearAuth(serverName) {
1967
2215
  const q = await this._ensureMcpControlQuery()
1968
2216
  this._armMcpControlIdle()
2217
+ this._requireMcpMethod(q, "mcpClearAuth")
1969
2218
  return await q.mcpClearAuth(serverName)
1970
2219
  }
1971
2220
 
package/src/main.mjs CHANGED
@@ -597,7 +597,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
597
597
  // assistant / result はターンの進行/完了 = permission 応答後に必ず流れるため、
598
598
  // ここで inputPending を落として「確認待ち」ドット/通知を自己解消させる。
599
599
  if (event?.type === "assistant") {
600
- const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
600
+ // 分母は result で学習したセッション固有の窓サイズ (無ければグローバル概算)。
601
+ // マシン全体で 1 個の statusLine 値を使うと、別モデルの並走で 5 倍ずれる。
602
+ const pct = resolveChatContextPct(cwd, "claude", event)
601
603
  try {
602
604
  // cliKind は chat-signals のキーの一部 (T05945)。同一 cwd で走る codex の信号と
603
605
  // 混ざらないよう、claude 由来のイベントは必ず "claude" バケットへ書く。
@@ -612,6 +614,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
612
614
  /* ignore */
613
615
  }
614
616
  } else if (event?.type === "result") {
617
+ // SDK の権威的な窓サイズ (result.modelUsage[model].contextWindow) をここで学習する。
618
+ resolveChatContextPct(cwd, "claude", event)
615
619
  try {
616
620
  recordChatActivity(cwd, "claude", {
617
621
  status: "waiting",
@@ -659,6 +663,36 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
659
663
  input,
660
664
  })
661
665
  })
666
+ claudeBridge.on(
667
+ "permissioncancel",
668
+ ({ stream_id, session_id, cwd, request_ids, reason }) => {
669
+ // SDK チャットの未応答 permission をターン終了/中断/close で内部 deny したとき、
670
+ // browser に残っている承認カードを撤去する (孤児カード防止, 2026-07-10 監査)。
671
+ // TUI 由来カードの撤去と同じ `claude.permission.cancel` 型を使う。
672
+ for (const request_id of request_ids || []) {
673
+ try {
674
+ client.send({
675
+ type: "claude.permission.cancel",
676
+ stream_id,
677
+ session_id,
678
+ cwd,
679
+ request_id,
680
+ reason,
681
+ })
682
+ } catch {
683
+ /* ignore */
684
+ }
685
+ }
686
+ if (cwd && request_ids?.length) {
687
+ // 確認待ちドットも落とす (次の assistant/result を待たずに自己解消させる)。
688
+ try {
689
+ recordChatActivity(cwd, "claude", { inputPending: false })
690
+ } catch {
691
+ /* ignore */
692
+ }
693
+ }
694
+ },
695
+ )
662
696
  claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
663
697
  client.send({ type: "claude.exit", stream_id, code, reason, session_id })
664
698
  })
@@ -1016,6 +1050,26 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1016
1050
  jsonlLiveWatchers.start()
1017
1051
  ctx.jsonlLiveWatchers = jsonlLiveWatchers
1018
1052
 
1053
+ // モデル能力レジストリ (2026-07-11): SDK supportedModels() を起動時に引き、effort
1054
+ // 対応可否の判定を正規表現ハードコードから権威データへ置き換える。能力はセッション中
1055
+ // 不変なので attach ごとには引き直さない。ただし control-only query は cold start 直後に
1056
+ // 失敗しうるため、起動フェーズ内で数回だけリトライする (成功したら打ち切り)。全滅しても
1057
+ // effort 判定は「browser effort 送信」へフォールバックするので致命ではない。
1058
+ // fire-and-forget で起動をブロックしない。
1059
+ if (claudeBridge) {
1060
+ ;(async () => {
1061
+ const delaysMs = [0, 3000, 15000]
1062
+ for (const d of delaysMs) {
1063
+ if (d) await new Promise((r) => setTimeout(r, d))
1064
+ const n = await claudeBridge.refreshModelCapabilities()
1065
+ if (n) return // 1 件でも載れば成功
1066
+ }
1067
+ logger.warn({}, "model capability を起動フェーズで取得できず (browser effort へフォールバック)")
1068
+ })().catch((err) =>
1069
+ logger.warn({ err: err?.message }, "model capability 取得ループが例外終了"),
1070
+ )
1071
+ }
1072
+
1019
1073
  // T04784 会話継続バインディングの冪等性ガード: tmux セッション名 → 最後に --resume で
1020
1074
  // 載せ替えた Claude session_id。TUI ビューが remount のたびに claude.tui.bind を送っても、
1021
1075
  // 同じ id への再 respawn (= claude 再起動) を抑止する。
@@ -1098,13 +1152,76 @@ export async function refreshContextWindow() {
1098
1152
  }
1099
1153
  }
1100
1154
 
1155
+ /**
1156
+ * セッション単位で学習した context 窓サイズ (トークン)。
1157
+ * キーは `${cliKind}\u0000${cwd}` (chat-signals と同じ粒度)。
1158
+ *
1159
+ * 【なぜセッション単位が要るか】(2026-07-10 監査)
1160
+ * 旧実装は分子 (SDK assistant.usage = セッション単位) に対し、分母を
1161
+ * resolvedContextWindow = statusLine cache (`~/.hub/usage/latest.json`) 由来の
1162
+ * **マシン全体で 1 個のグローバル値**にしていた。同ファイルは「最後に statusLine を
1163
+ * 書いた任意のセッション」の値なので、1M 窓の Opus と 200k 窓の Sonnet を同時に
1164
+ * 走らせると、分母が取り違わってドーナツが 5 倍ずれる (分子と分母のスケール不一致)。
1165
+ *
1166
+ * SDK は `result.modelUsage[model].contextWindow` として **そのターンで実際に使われた
1167
+ * モデルの窓サイズ**を毎ターン渡してくる。これが唯一の権威なので、result のたびに
1168
+ * ここへ学習し、以降の assistant イベントの分母に使う。
1169
+ */
1170
+ const contextWindowByChatKey = new Map()
1171
+
1172
+ /** 直近の assistant メッセージのモデル名。result の modelUsage から主対話モデルを選ぶ手掛かり。 */
1173
+ const lastAssistantModelByChatKey = new Map()
1174
+
1175
+ /** chat-signals と同じ (cwd, cliKind) 粒度のキーを作る。 */
1176
+ export function chatKey(cwd, cliKind) {
1177
+ return `${cliKind || "claude"}\u0000${cwd || ""}`
1178
+ }
1179
+
1180
+ /**
1181
+ * SDK `result` の `modelUsage` から、そのセッションの context 窓サイズを選ぶ。
1182
+ *
1183
+ * `modelUsage` は `Record<モデル名, ModelUsage>` で、1 ターンに複数モデルが乗りうる
1184
+ * (サブエージェントの haiku 等)。ドーナツが表すべきは **主対話モデル**の窓なので、
1185
+ * 1. 直近 assistant メッセージのモデル名に完全一致するもの
1186
+ * 2. 無ければ入力トークン (input + cache_read) が最大のもの = 会話本体を読んだモデル
1187
+ * の順に選ぶ。どれも窓サイズを持たなければ null (呼び出し側がグローバル値へフォールバック)。
1188
+ *
1189
+ * @param {Record<string, {contextWindow?:number, inputTokens?:number, cacheReadInputTokens?:number}>|null|undefined} modelUsage
1190
+ * @param {string|null|undefined} preferredModel 直近 assistant の model 名
1191
+ * @returns {number|null}
1192
+ */
1193
+ export function pickContextWindow(modelUsage, preferredModel) {
1194
+ if (!modelUsage || typeof modelUsage !== "object") return null
1195
+ const entries = Object.entries(modelUsage).filter(
1196
+ ([, v]) => v && typeof v.contextWindow === "number" && v.contextWindow > 0,
1197
+ )
1198
+ if (entries.length === 0) return null
1199
+ if (preferredModel) {
1200
+ const hit = entries.find(([name]) => name === preferredModel)
1201
+ if (hit) return hit[1].contextWindow
1202
+ }
1203
+ const inputOf = (v) => (v.inputTokens || 0) + (v.cacheReadInputTokens || 0)
1204
+ let best = entries[0]
1205
+ for (const e of entries) {
1206
+ if (inputOf(e[1]) > inputOf(best[1])) best = e
1207
+ }
1208
+ return best[1].contextWindow
1209
+ }
1210
+
1101
1211
  /**
1102
1212
  * SDK assistant メッセージの usage から context 使用率 (%) を概算する。
1103
1213
  * context = input + cache_read + cache_creation + output(直近応答)。
1104
1214
  * プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を合算する。
1105
- * 分母は resolvedContextWindow (実窓サイズ。1M ベータ等を自動反映)。
1215
+ *
1216
+ * 分母は `contextWindow` 引数 (= SDK result の modelUsage から学習したセッション固有の
1217
+ * 窓サイズ) を最優先し、未学習なら resolvedContextWindow (statusLine cache 由来の
1218
+ * グローバル概算) にフォールバックする。セッションの初回ターンだけは result 未着で
1219
+ * フォールバックになるが、初回はトークンが少なくドーナツもほぼ 0 のため実害は無い。
1220
+ *
1221
+ * @param {object|null} u assistant.message.usage
1222
+ * @param {number|null|undefined} contextWindow セッション固有の窓サイズ (省略時グローバル)
1106
1223
  */
1107
- export function contextPctFromUsage(u) {
1224
+ export function contextPctFromUsage(u, contextWindow) {
1108
1225
  if (!u) return null
1109
1226
  const tokens =
1110
1227
  (u.input_tokens || 0) +
@@ -1112,7 +1229,43 @@ export function contextPctFromUsage(u) {
1112
1229
  (u.cache_creation_input_tokens || 0) +
1113
1230
  (u.output_tokens || 0)
1114
1231
  if (tokens <= 0) return null
1115
- return Math.min(100, Math.round((tokens / resolvedContextWindow) * 1000) / 10)
1232
+ const win =
1233
+ typeof contextWindow === "number" && contextWindow > 0
1234
+ ? contextWindow
1235
+ : resolvedContextWindow
1236
+ return Math.min(100, Math.round((tokens / win) * 1000) / 10)
1237
+ }
1238
+
1239
+ /**
1240
+ * SDK チャットの assistant / result イベントから context% を解決し、
1241
+ * 併せて `result.modelUsage` の窓サイズを (cwd, cliKind) 単位で学習する。
1242
+ *
1243
+ * ハンドラ本体から切り出して単体テスト可能にしている (学習 → 次ターンで反映、
1244
+ * という状態遷移こそがこの機能の本体であり、純関数だけでは配線を検証できないため)。
1245
+ *
1246
+ * @returns {number|null} assistant のとき context% / それ以外は null
1247
+ */
1248
+ export function resolveChatContextPct(cwd, cliKind, event) {
1249
+ const key = chatKey(cwd, cliKind)
1250
+ if (event?.type === "assistant") {
1251
+ const model = event.message?.model || null
1252
+ if (model) lastAssistantModelByChatKey.set(key, model)
1253
+ return event.message?.usage
1254
+ ? contextPctFromUsage(event.message.usage, contextWindowByChatKey.get(key))
1255
+ : null
1256
+ }
1257
+ if (event?.type === "result") {
1258
+ // モデル切替やセッション回転が起きても次の result で必ず上書きされる (自己修復)。
1259
+ const win = pickContextWindow(event.modelUsage, lastAssistantModelByChatKey.get(key))
1260
+ if (win) contextWindowByChatKey.set(key, win)
1261
+ }
1262
+ return null
1263
+ }
1264
+
1265
+ /** テスト用: 学習済みの窓サイズ / モデル名をリセットする。 */
1266
+ export function _resetContextWindowLearning() {
1267
+ contextWindowByChatKey.clear()
1268
+ lastAssistantModelByChatKey.clear()
1116
1269
  }
1117
1270
 
1118
1271
  /**