@cocorograph/hub-agent 0.7.50 → 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.50",
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
  * 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
@@ -245,6 +289,7 @@ class ClaudeStreamSession {
245
289
  logger,
246
290
  onEvent,
247
291
  onPermission,
292
+ onPermissionCancel,
248
293
  onExit,
249
294
  onError,
250
295
  onReap,
@@ -275,6 +320,8 @@ class ClaudeStreamSession {
275
320
  this.logger = logger
276
321
  this.onEvent = onEvent
277
322
  this.onPermission = onPermission
323
+ /** 未応答 permission を内部 deny した際に browser へカード撤去を伝えるコールバック。 */
324
+ this.onPermissionCancel = onPermissionCancel
278
325
  this.onExit = onExit
279
326
  this.onError = onError
280
327
  /** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
@@ -465,6 +512,20 @@ class ClaudeStreamSession {
465
512
  })
466
513
  }
467
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
+
468
529
  /** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
469
530
  async _canUseTool(toolName, input) {
470
531
  if (!this.onPermission) return { behavior: "allow", updatedInput: input }
@@ -488,6 +549,46 @@ class ClaudeStreamSession {
488
549
  })
489
550
  }
490
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
+
491
592
  /** browser からの permission 応答を該当 request_id の Promise に渡す。 */
492
593
  resolvePermission(request_id, decision) {
493
594
  const r = this._permissionResolvers.get(request_id)
@@ -632,26 +733,28 @@ class ClaudeStreamSession {
632
733
  }
633
734
  }
634
735
 
635
- /** モデルが effort / adaptive thinking 対応 (Opus 4.6+ / Fable 5) かどうか。
636
- * budget 方式 (maxThinkingTokens) は Opus 4.7+ で廃止扱いのため、effort モデルでは
637
- * effort + thinking:{type:'adaptive'} に切り替える。
638
- * ⚠️ frontend の isEffortModelByPattern (types/cockpit.ts) と同一パターンを維持する
639
- * こと。旧実装は fable-5 を含まず、frontend が effort を送っても bridge が非 effort
640
- * 分岐に入り「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
+ * モデル名の当てずっぽうを排し、能力を引けない起動直後も安全側に倒れる。 */
641
746
  _isEffortModel() {
642
- return (
643
- typeof this.model === "string" && /claude-fable-5|claude-opus-4-[678]/.test(this.model)
644
- )
747
+ const cap = getModelCapability(this.model)
748
+ if (cap) return cap.supportsEffort
749
+ return !!this.effort
645
750
  }
646
751
 
647
752
  /** 思考関連オプション (effort / adaptive thinking / 旧 budget) を options へ適用する。
648
753
  * per-message / 常駐 query の両方から呼ぶ共通ロジック (分岐の二重定義を避ける)。 */
649
754
  _applyThinkingOptions(options) {
650
- // model 未指定 (= browser が agent/アカウント既定モデルへ委任) でも、browser が
651
- // effort を明示送信してきた場合は effort 分岐に入れる。frontend は system/init の
652
- // 実効モデル (runtimeModel) で capability 判定してから effort を送るため、ここでは
653
- // その判断を信頼する (2026-07-02)。
654
- if (this._isEffortModel() || (!this.model && this.effort)) {
755
+ // effort 対応判定は _isEffortModel に一元化 (レジストリ → browser effort フォールバック)。
756
+ // model 未指定 (= browser が既定モデルへ委任) でも effort 送信時は effort 分岐に入る。
757
+ if (this._isEffortModel()) {
655
758
  // effort モデル: adaptive thinking を明示 ON にし、effort で深さを指定する。
656
759
  // budget 方式 (maxThinkingTokens) は使わない (Opus 4.7+ で非対応)。
657
760
  options.thinking = { type: "adaptive" }
@@ -824,6 +927,7 @@ class ClaudeStreamSession {
824
927
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
825
928
  includePartialMessages: true,
826
929
  abortController: this._abortController,
930
+ stderr: (data) => this._stderrHandler(data),
827
931
  }
828
932
  if (this.model) options.model = this.model
829
933
  if (this.permissionMode) options.permissionMode = this.permissionMode
@@ -888,15 +992,8 @@ class ClaudeStreamSession {
888
992
  }
889
993
  } finally {
890
994
  this._abortController = null
891
- // 未解決 permission は閉じる
892
- for (const [, resolver] of this._permissionResolvers) {
893
- try {
894
- resolver.resolve({ behavior: "deny", message: "turn ended" })
895
- } catch {
896
- /* ignore */
897
- }
898
- }
899
- this._permissionResolvers.clear()
995
+ // 未解決 permission は閉じ、browser の孤児カードも撤去する
996
+ this._denyAllPendingPermissions("turn ended")
900
997
  // このターンで jsonl に書かれた行は query stream で既に push 済みなので、
901
998
  // watcher の offset を末尾に飛ばして二重 push を防ぐ。busy=true のまま
902
999
  // skipToEnd を待ち、完了後に busy=false にすることで、その間に watcher poll が
@@ -1179,6 +1276,7 @@ class ClaudeStreamSession {
1179
1276
  env: siblingEnvFor(this.cwd),
1180
1277
  canUseTool: (toolName, input) => this._canUseTool(toolName, input),
1181
1278
  includePartialMessages: true,
1279
+ stderr: (data) => this._stderrHandler(data),
1182
1280
  }
1183
1281
  if (this.model) options.model = this.model
1184
1282
  if (this.permissionMode) options.permissionMode = this.permissionMode
@@ -1195,16 +1293,7 @@ class ClaudeStreamSession {
1195
1293
  // 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
1196
1294
  // query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
1197
1295
  if (this.sessionId) options.resume = this.sessionId
1198
- const denyPending = (reason) => {
1199
- for (const [, resolver] of this._permissionResolvers) {
1200
- try {
1201
- resolver.resolve({ behavior: "deny", message: reason })
1202
- } catch {
1203
- /* ignore */
1204
- }
1205
- }
1206
- this._permissionResolvers.clear()
1207
- }
1296
+ const denyPending = (reason) => this._denyAllPendingPermissions(reason)
1208
1297
  try {
1209
1298
  const q = this.sdk.query({ prompt: this._inputQueue, options })
1210
1299
  this._residentQuery = q
@@ -1326,6 +1415,13 @@ class ClaudeStreamSession {
1326
1415
  // (drain が次ターンを開始したケース) のどちらかで必ず無視される。
1327
1416
  this._turnSettled = true
1328
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")
1329
1425
  // SDK-P0-2 (2026-07-07): resident 経路のターン終端イベント非対称の解消。
1330
1426
  // per-message の abort (_runPerMessage finally) は合成 result (aborted_by_user) を
1331
1427
  // frontend へ emit + onTurnSettled を呼んで UI (turnActive/interrupting) とサイドバー
@@ -1432,14 +1528,7 @@ class ClaudeStreamSession {
1432
1528
  }
1433
1529
  this._watcher = null
1434
1530
  }
1435
- for (const [, resolver] of this._permissionResolvers) {
1436
- try {
1437
- resolver.resolve({ behavior: "deny", message: "closed" })
1438
- } catch {
1439
- /* ignore */
1440
- }
1441
- }
1442
- this._permissionResolvers.clear()
1531
+ this._denyAllPendingPermissions("closed")
1443
1532
  // マスク (2026-06-23): アイドル close (走行ターン無し = CLI が jsonl を掴んでいない) は
1444
1533
  // ここで即マスクする。走行中 close (abortTurn 経由) は CLI 終了を待つため run-loop の
1445
1534
  // finally 側でマスクする (上の _maskJsonlAtRest 冪等ガードで二重実行は防がれる)。
@@ -1647,6 +1736,16 @@ export class ClaudeStreamBridge extends EventEmitter {
1647
1736
  input,
1648
1737
  })
1649
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
+ },
1650
1749
  onError: (err) => {
1651
1750
  this.emit("error", {
1652
1751
  stream_id: session.stream_id,
@@ -1845,6 +1944,39 @@ export class ClaudeStreamBridge extends EventEmitter {
1845
1944
  return this._mergeNeedsAuthCache(servers)
1846
1945
  }
1847
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
+
1848
1980
  /**
1849
1981
  * アカウント全体の 5h/7d rate-limit 使用率 + プラン種別を返す (usage.mjs
1850
1982
  * recordChatUsageSnapshot に渡す想定)。mcpStatus と同じ control-only query を共有する
@@ -2037,10 +2169,36 @@ export class ClaudeStreamBridge extends EventEmitter {
2037
2169
  return { servers, targeted }
2038
2170
  }
2039
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
+
2040
2197
  /** 指定 MCP サーバーの OAuth 認証を開始する。認可 URL を含む応答を返す。 */
2041
2198
  async mcpAuthenticate(serverName, redirectUri) {
2042
2199
  const q = await this._ensureMcpControlQuery()
2043
2200
  this._armMcpControlIdle()
2201
+ this._requireMcpMethod(q, "mcpAuthenticate")
2044
2202
  return await q.mcpAuthenticate(serverName, redirectUri)
2045
2203
  }
2046
2204
 
@@ -2048,6 +2206,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2048
2206
  async mcpSubmitOAuthCallback(serverName, callbackUrl) {
2049
2207
  const q = await this._ensureMcpControlQuery()
2050
2208
  this._armMcpControlIdle()
2209
+ this._requireMcpMethod(q, "mcpSubmitOAuthCallbackUrl")
2051
2210
  return await q.mcpSubmitOAuthCallbackUrl(serverName, callbackUrl)
2052
2211
  }
2053
2212
 
@@ -2055,6 +2214,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2055
2214
  async mcpClearAuth(serverName) {
2056
2215
  const q = await this._ensureMcpControlQuery()
2057
2216
  this._armMcpControlIdle()
2217
+ this._requireMcpMethod(q, "mcpClearAuth")
2058
2218
  return await q.mcpClearAuth(serverName)
2059
2219
  }
2060
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
  /**