@cocorograph/hub-agent 0.7.50 → 0.7.53
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 +1 -1
- package/src/chat-signals.mjs +0 -0
- package/src/claude-stream-bridge.mjs +215 -41
- package/src/main.mjs +195 -5
- package/src/model-capability-report.mjs +118 -0
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
Binary file
|
|
@@ -145,6 +145,64 @@ 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
|
+
/** レジストリを Hub 報告用 [{model_name, supports_effort, supports_xhigh_effort}] へ変換する。
|
|
188
|
+
* 第2段 (2026-07-11): backend の apply_capability_report が期待する形。 */
|
|
189
|
+
export function getReportModels() {
|
|
190
|
+
const out = []
|
|
191
|
+
for (const [name, cap] of _modelCapabilities) {
|
|
192
|
+
out.push({
|
|
193
|
+
model_name: name,
|
|
194
|
+
supports_effort: cap.supportsEffort === true,
|
|
195
|
+
supports_xhigh_effort: cap.supportsXhighEffort === true,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** テスト用: レジストリをクリアする。 */
|
|
202
|
+
export function _resetModelCapabilities() {
|
|
203
|
+
_modelCapabilities.clear()
|
|
204
|
+
}
|
|
205
|
+
|
|
148
206
|
/** 文字列を SDK streaming input の SDKUserMessage に包む。
|
|
149
207
|
* SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
|
|
150
208
|
* 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
|
|
@@ -245,6 +303,7 @@ class ClaudeStreamSession {
|
|
|
245
303
|
logger,
|
|
246
304
|
onEvent,
|
|
247
305
|
onPermission,
|
|
306
|
+
onPermissionCancel,
|
|
248
307
|
onExit,
|
|
249
308
|
onError,
|
|
250
309
|
onReap,
|
|
@@ -275,6 +334,8 @@ class ClaudeStreamSession {
|
|
|
275
334
|
this.logger = logger
|
|
276
335
|
this.onEvent = onEvent
|
|
277
336
|
this.onPermission = onPermission
|
|
337
|
+
/** 未応答 permission を内部 deny した際に browser へカード撤去を伝えるコールバック。 */
|
|
338
|
+
this.onPermissionCancel = onPermissionCancel
|
|
278
339
|
this.onExit = onExit
|
|
279
340
|
this.onError = onError
|
|
280
341
|
/** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
|
|
@@ -465,6 +526,20 @@ class ClaudeStreamSession {
|
|
|
465
526
|
})
|
|
466
527
|
}
|
|
467
528
|
|
|
529
|
+
/**
|
|
530
|
+
* SDK options.stderr: CLI サブプロセスの stderr を logger へ流す (2026-07-11 監査)。
|
|
531
|
+
*
|
|
532
|
+
* 未設定だと認証エラー・MCP 起動失敗・内部警告といったサブプロセスの一次情報が完全に
|
|
533
|
+
* 破棄され、「SDK チャットが無音で挙動がおかしい」系の障害調査で手掛かりが残らない。
|
|
534
|
+
* onError は generator が throw したときしか発火しないため、それとは別に stderr を拾う。
|
|
535
|
+
* デバッグ用途なので debug レベルで出す (通常運用のログを汚さない)。
|
|
536
|
+
*/
|
|
537
|
+
_stderrHandler(data) {
|
|
538
|
+
const text = typeof data === "string" ? data.trim() : ""
|
|
539
|
+
if (!text) return
|
|
540
|
+
this.logger?.debug({ stream_id: this.stream_id, cwd: this.cwd, stderr: text }, "claude cli stderr")
|
|
541
|
+
}
|
|
542
|
+
|
|
468
543
|
/** SDK options.canUseTool: tool 起動を許可するかを browser に問い合わせる。 */
|
|
469
544
|
async _canUseTool(toolName, input) {
|
|
470
545
|
if (!this.onPermission) return { behavior: "allow", updatedInput: input }
|
|
@@ -488,6 +563,46 @@ class ClaudeStreamSession {
|
|
|
488
563
|
})
|
|
489
564
|
}
|
|
490
565
|
|
|
566
|
+
/**
|
|
567
|
+
* 未応答の permission をすべて内部 deny し、その request_id を browser へ通知する。
|
|
568
|
+
*
|
|
569
|
+
* 【なぜ通知が要るか】(2026-07-10 監査)
|
|
570
|
+
* ターン終了 / 中断 (abort) / セッション close の際、SDK の `canUseTool` Promise を
|
|
571
|
+
* 宙吊りにしないため deny で解決していた。しかし **browser には何も送っていなかった**
|
|
572
|
+
* ため、承認カードだけが画面に残り続ける (孤児カード)。ユーザーはそれをクリックする
|
|
573
|
+
* まで消せず、既に終わったターンのツールを承認/拒否したかのような UI になる。
|
|
574
|
+
*
|
|
575
|
+
* `claude.permission.cancel` は既に agent → backend → browser の型として存在する
|
|
576
|
+
* (TUI 由来カードの撤去に使われている) ので、SDK チャットの内部 deny でも同じ型で
|
|
577
|
+
* 撤去を伝える。request_id は browser 側で照合され、未知 id は no-op になる。
|
|
578
|
+
*
|
|
579
|
+
* @param {string} reason "turn ended" | "aborted" | "closed" 等
|
|
580
|
+
* @returns {string[]} deny した request_id 群
|
|
581
|
+
*/
|
|
582
|
+
_denyAllPendingPermissions(reason) {
|
|
583
|
+
const cancelled = []
|
|
584
|
+
for (const [request_id, resolver] of this._permissionResolvers) {
|
|
585
|
+
cancelled.push(request_id)
|
|
586
|
+
try {
|
|
587
|
+
resolver.resolve({ behavior: "deny", message: reason })
|
|
588
|
+
} catch {
|
|
589
|
+
/* ignore */
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
this._permissionResolvers.clear()
|
|
593
|
+
if (cancelled.length && this.onPermissionCancel) {
|
|
594
|
+
try {
|
|
595
|
+
this.onPermissionCancel({ request_ids: cancelled, reason })
|
|
596
|
+
} catch (err) {
|
|
597
|
+
this.logger?.warn(
|
|
598
|
+
{ err: err?.message, reason },
|
|
599
|
+
"onPermissionCancel callback threw",
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return cancelled
|
|
604
|
+
}
|
|
605
|
+
|
|
491
606
|
/** browser からの permission 応答を該当 request_id の Promise に渡す。 */
|
|
492
607
|
resolvePermission(request_id, decision) {
|
|
493
608
|
const r = this._permissionResolvers.get(request_id)
|
|
@@ -632,26 +747,28 @@ class ClaudeStreamSession {
|
|
|
632
747
|
}
|
|
633
748
|
}
|
|
634
749
|
|
|
635
|
-
/** モデルが effort / adaptive thinking
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
750
|
+
/** モデルが effort / adaptive thinking 対応かどうか。
|
|
751
|
+
*
|
|
752
|
+
* 判定順 (2026-07-11、正規表現ハードコードを廃止):
|
|
753
|
+
* 1. SDK `supportedModels()` 由来の能力レジストリに this.model があればそれを使う
|
|
754
|
+
* (権威。新モデルも取りこぼさない)。
|
|
755
|
+
* 2. レジストリに無い (SDK 未取得 / 一覧に無いカスタムモデル / model 未指定) 場合は、
|
|
756
|
+
* **browser が effort を明示送信してきたか**にフォールバックする。frontend は
|
|
757
|
+
* Hub マスタの supports_effort で判定してから effort を送るため、「effort を
|
|
758
|
+
* 送ってきた ≒ そのモデルは effort 対応」が成り立つ。これにより正規表現による
|
|
759
|
+
* モデル名の当てずっぽうを排し、能力を引けない起動直後も安全側に倒れる。 */
|
|
641
760
|
_isEffortModel() {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
761
|
+
const cap = getModelCapability(this.model)
|
|
762
|
+
if (cap) return cap.supportsEffort
|
|
763
|
+
return !!this.effort
|
|
645
764
|
}
|
|
646
765
|
|
|
647
766
|
/** 思考関連オプション (effort / adaptive thinking / 旧 budget) を options へ適用する。
|
|
648
767
|
* per-message / 常駐 query の両方から呼ぶ共通ロジック (分岐の二重定義を避ける)。 */
|
|
649
768
|
_applyThinkingOptions(options) {
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
// その判断を信頼する (2026-07-02)。
|
|
654
|
-
if (this._isEffortModel() || (!this.model && this.effort)) {
|
|
769
|
+
// effort 対応判定は _isEffortModel に一元化 (レジストリ → browser effort フォールバック)。
|
|
770
|
+
// model 未指定 (= browser が既定モデルへ委任) でも effort 送信時は effort 分岐に入る。
|
|
771
|
+
if (this._isEffortModel()) {
|
|
655
772
|
// effort モデル: adaptive thinking を明示 ON にし、effort で深さを指定する。
|
|
656
773
|
// budget 方式 (maxThinkingTokens) は使わない (Opus 4.7+ で非対応)。
|
|
657
774
|
options.thinking = { type: "adaptive" }
|
|
@@ -824,6 +941,7 @@ class ClaudeStreamSession {
|
|
|
824
941
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
825
942
|
includePartialMessages: true,
|
|
826
943
|
abortController: this._abortController,
|
|
944
|
+
stderr: (data) => this._stderrHandler(data),
|
|
827
945
|
}
|
|
828
946
|
if (this.model) options.model = this.model
|
|
829
947
|
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
@@ -888,15 +1006,8 @@ class ClaudeStreamSession {
|
|
|
888
1006
|
}
|
|
889
1007
|
} finally {
|
|
890
1008
|
this._abortController = null
|
|
891
|
-
// 未解決 permission
|
|
892
|
-
|
|
893
|
-
try {
|
|
894
|
-
resolver.resolve({ behavior: "deny", message: "turn ended" })
|
|
895
|
-
} catch {
|
|
896
|
-
/* ignore */
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
this._permissionResolvers.clear()
|
|
1009
|
+
// 未解決 permission は閉じ、browser の孤児カードも撤去する
|
|
1010
|
+
this._denyAllPendingPermissions("turn ended")
|
|
900
1011
|
// このターンで jsonl に書かれた行は query stream で既に push 済みなので、
|
|
901
1012
|
// watcher の offset を末尾に飛ばして二重 push を防ぐ。busy=true のまま
|
|
902
1013
|
// skipToEnd を待ち、完了後に busy=false にすることで、その間に watcher poll が
|
|
@@ -1179,6 +1290,7 @@ class ClaudeStreamSession {
|
|
|
1179
1290
|
env: siblingEnvFor(this.cwd),
|
|
1180
1291
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
1181
1292
|
includePartialMessages: true,
|
|
1293
|
+
stderr: (data) => this._stderrHandler(data),
|
|
1182
1294
|
}
|
|
1183
1295
|
if (this.model) options.model = this.model
|
|
1184
1296
|
if (this.permissionMode) options.permissionMode = this.permissionMode
|
|
@@ -1195,16 +1307,7 @@ class ClaudeStreamSession {
|
|
|
1195
1307
|
// 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
|
|
1196
1308
|
// query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
|
|
1197
1309
|
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
|
-
}
|
|
1310
|
+
const denyPending = (reason) => this._denyAllPendingPermissions(reason)
|
|
1208
1311
|
try {
|
|
1209
1312
|
const q = this.sdk.query({ prompt: this._inputQueue, options })
|
|
1210
1313
|
this._residentQuery = q
|
|
@@ -1326,6 +1429,13 @@ class ClaudeStreamSession {
|
|
|
1326
1429
|
// (drain が次ターンを開始したケース) のどちらかで必ず無視される。
|
|
1327
1430
|
this._turnSettled = true
|
|
1328
1431
|
this._busy = false
|
|
1432
|
+
// 孤児カード防止 (2026-07-10 監査): 中断したターンに紐づく未応答 permission は
|
|
1433
|
+
// もう誰も応答しない。deny で canUseTool の Promise を解放し、browser のカードも
|
|
1434
|
+
// 撤去する。これが無いと (a) 画面に承認カードが残り続け (b) _permissionResolvers に
|
|
1435
|
+
// 残った古い request_id が reattach 時の replayPendingPermissions で再送されて
|
|
1436
|
+
// 「終わったターンのカード」が蘇る。close() 経由の abort では下流の close() が
|
|
1437
|
+
// "closed" で再度呼ぶが、map は空なので no-op (二重通知しない)。
|
|
1438
|
+
this._denyAllPendingPermissions("aborted_by_user")
|
|
1329
1439
|
// SDK-P0-2 (2026-07-07): resident 経路のターン終端イベント非対称の解消。
|
|
1330
1440
|
// per-message の abort (_runPerMessage finally) は合成 result (aborted_by_user) を
|
|
1331
1441
|
// frontend へ emit + onTurnSettled を呼んで UI (turnActive/interrupting) とサイドバー
|
|
@@ -1432,14 +1542,7 @@ class ClaudeStreamSession {
|
|
|
1432
1542
|
}
|
|
1433
1543
|
this._watcher = null
|
|
1434
1544
|
}
|
|
1435
|
-
|
|
1436
|
-
try {
|
|
1437
|
-
resolver.resolve({ behavior: "deny", message: "closed" })
|
|
1438
|
-
} catch {
|
|
1439
|
-
/* ignore */
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
this._permissionResolvers.clear()
|
|
1545
|
+
this._denyAllPendingPermissions("closed")
|
|
1443
1546
|
// マスク (2026-06-23): アイドル close (走行ターン無し = CLI が jsonl を掴んでいない) は
|
|
1444
1547
|
// ここで即マスクする。走行中 close (abortTurn 経由) は CLI 終了を待つため run-loop の
|
|
1445
1548
|
// finally 側でマスクする (上の _maskJsonlAtRest 冪等ガードで二重実行は防がれる)。
|
|
@@ -1647,6 +1750,16 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1647
1750
|
input,
|
|
1648
1751
|
})
|
|
1649
1752
|
},
|
|
1753
|
+
onPermissionCancel: ({ request_ids, reason }) => {
|
|
1754
|
+
// 内部 deny した承認カードを browser から撤去する (孤児カード防止)。
|
|
1755
|
+
this.emit("permissioncancel", {
|
|
1756
|
+
stream_id: session.stream_id,
|
|
1757
|
+
session_id: session.sessionId,
|
|
1758
|
+
cwd: session.cwd,
|
|
1759
|
+
request_ids,
|
|
1760
|
+
reason,
|
|
1761
|
+
})
|
|
1762
|
+
},
|
|
1650
1763
|
onError: (err) => {
|
|
1651
1764
|
this.emit("error", {
|
|
1652
1765
|
stream_id: session.stream_id,
|
|
@@ -1845,6 +1958,39 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1845
1958
|
return this._mergeNeedsAuthCache(servers)
|
|
1846
1959
|
}
|
|
1847
1960
|
|
|
1961
|
+
/**
|
|
1962
|
+
* SDK `supportedModels()` を control-only query 経由で 1 回引き、モデル能力レジストリ
|
|
1963
|
+
* (_modelCapabilities) を更新する。起動時に 1 回呼ぶ想定 (能力はセッション中不変なので
|
|
1964
|
+
* attach ごとに引き直さない)。
|
|
1965
|
+
*
|
|
1966
|
+
* `supportedModels()` は公開 Query インターフェース (sdk.d.ts) のメソッドだが、念のため
|
|
1967
|
+
* typeof ガードし、失敗 (未実装 SDK / 一時エラー) は握って false を返す。取得できなければ
|
|
1968
|
+
* レジストリは空のままで、effort 判定は「browser が effort を送ってきたか」へフォールバック
|
|
1969
|
+
* するため安全側に倒れる。
|
|
1970
|
+
*
|
|
1971
|
+
* @returns {Promise<number|false>} 載せたモデル数 / 失敗時 false
|
|
1972
|
+
*/
|
|
1973
|
+
async refreshModelCapabilities() {
|
|
1974
|
+
try {
|
|
1975
|
+
const q = await this._ensureMcpControlQuery()
|
|
1976
|
+
this._armMcpControlIdle()
|
|
1977
|
+
if (typeof q.supportedModels !== "function") {
|
|
1978
|
+
this.logger?.warn({}, "SDK に supportedModels() が無い (能力レジストリ未更新)")
|
|
1979
|
+
return false
|
|
1980
|
+
}
|
|
1981
|
+
const models = await q.supportedModels()
|
|
1982
|
+
const n = setModelCapabilities(models)
|
|
1983
|
+
this.logger?.info({ count: n }, "model capabilities loaded from SDK")
|
|
1984
|
+
return n
|
|
1985
|
+
} catch (err) {
|
|
1986
|
+
this.logger?.warn(
|
|
1987
|
+
{ err: err?.message },
|
|
1988
|
+
"refreshModelCapabilities failed (effort 判定は browser effort へフォールバック)",
|
|
1989
|
+
)
|
|
1990
|
+
return false
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1848
1994
|
/**
|
|
1849
1995
|
* アカウント全体の 5h/7d rate-limit 使用率 + プラン種別を返す (usage.mjs
|
|
1850
1996
|
* recordChatUsageSnapshot に渡す想定)。mcpStatus と同じ control-only query を共有する
|
|
@@ -2037,10 +2183,36 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2037
2183
|
return { servers, targeted }
|
|
2038
2184
|
}
|
|
2039
2185
|
|
|
2186
|
+
/**
|
|
2187
|
+
* SDK の control query が指定メソッドを実装しているか検査して返す。
|
|
2188
|
+
*
|
|
2189
|
+
* `mcpAuthenticate` / `mcpSubmitOAuthCallbackUrl` / `mcpClearAuth` は **公開
|
|
2190
|
+
* `Query` インターフェース (sdk.d.ts) に型が存在しない**内部 API で、ランタイム
|
|
2191
|
+
* (sdk.mjs) にのみ実装がある。SDK 側でリネーム/削除されても TypeScript は警告
|
|
2192
|
+
* できないため、`usage_EXPERIMENTAL_...` と同じく呼ぶ前に typeof で確かめる。
|
|
2193
|
+
*
|
|
2194
|
+
* 未実装なら `mcp_method_unsupported:<name>` を throw する。呼び出し側 (main.mjs の
|
|
2195
|
+
* `claude.mcp.*` ハンドラ) の try/catch がこれを掴んで `claude.mcp.response` の
|
|
2196
|
+
* error として browser へ返すので、`q.mcpAuthenticate is not a function` という
|
|
2197
|
+
* 診断不能なメッセージではなく「SDK がこの API を持っていない」と分かる形になる。
|
|
2198
|
+
*/
|
|
2199
|
+
_requireMcpMethod(q, name) {
|
|
2200
|
+
if (!q || typeof q[name] !== "function") {
|
|
2201
|
+
const err = new Error(`mcp_method_unsupported:${name}`)
|
|
2202
|
+
this.logger?.warn(
|
|
2203
|
+
{ method: name },
|
|
2204
|
+
"SDK control query に MCP メソッドが存在しない (SDK バージョン差異の可能性)",
|
|
2205
|
+
)
|
|
2206
|
+
throw err
|
|
2207
|
+
}
|
|
2208
|
+
return q
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2040
2211
|
/** 指定 MCP サーバーの OAuth 認証を開始する。認可 URL を含む応答を返す。 */
|
|
2041
2212
|
async mcpAuthenticate(serverName, redirectUri) {
|
|
2042
2213
|
const q = await this._ensureMcpControlQuery()
|
|
2043
2214
|
this._armMcpControlIdle()
|
|
2215
|
+
this._requireMcpMethod(q, "mcpAuthenticate")
|
|
2044
2216
|
return await q.mcpAuthenticate(serverName, redirectUri)
|
|
2045
2217
|
}
|
|
2046
2218
|
|
|
@@ -2048,6 +2220,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2048
2220
|
async mcpSubmitOAuthCallback(serverName, callbackUrl) {
|
|
2049
2221
|
const q = await this._ensureMcpControlQuery()
|
|
2050
2222
|
this._armMcpControlIdle()
|
|
2223
|
+
this._requireMcpMethod(q, "mcpSubmitOAuthCallbackUrl")
|
|
2051
2224
|
return await q.mcpSubmitOAuthCallbackUrl(serverName, callbackUrl)
|
|
2052
2225
|
}
|
|
2053
2226
|
|
|
@@ -2055,6 +2228,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2055
2228
|
async mcpClearAuth(serverName) {
|
|
2056
2229
|
const q = await this._ensureMcpControlQuery()
|
|
2057
2230
|
this._armMcpControlIdle()
|
|
2231
|
+
this._requireMcpMethod(q, "mcpClearAuth")
|
|
2058
2232
|
return await q.mcpClearAuth(serverName)
|
|
2059
2233
|
}
|
|
2060
2234
|
|
package/src/main.mjs
CHANGED
|
@@ -24,7 +24,12 @@ import { extractLastAssistantText, pickContextText } from "./extract-paragraph.m
|
|
|
24
24
|
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
25
25
|
import { WsClient } from "./ws-client.mjs"
|
|
26
26
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
27
|
-
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
27
|
+
import { ClaudeStreamBridge, getReportModels } from "./claude-stream-bridge.mjs"
|
|
28
|
+
import {
|
|
29
|
+
buildCapabilityReport,
|
|
30
|
+
readCodexReport,
|
|
31
|
+
readSdkVersion,
|
|
32
|
+
} from "./model-capability-report.mjs"
|
|
28
33
|
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
29
34
|
import { UploadManager } from "./claude-upload.mjs"
|
|
30
35
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
@@ -597,7 +602,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
597
602
|
// assistant / result はターンの進行/完了 = permission 応答後に必ず流れるため、
|
|
598
603
|
// ここで inputPending を落として「確認待ち」ドット/通知を自己解消させる。
|
|
599
604
|
if (event?.type === "assistant") {
|
|
600
|
-
|
|
605
|
+
// 分母は result で学習したセッション固有の窓サイズ (無ければグローバル概算)。
|
|
606
|
+
// マシン全体で 1 個の statusLine 値を使うと、別モデルの並走で 5 倍ずれる。
|
|
607
|
+
const pct = resolveChatContextPct(cwd, "claude", event)
|
|
601
608
|
try {
|
|
602
609
|
// cliKind は chat-signals のキーの一部 (T05945)。同一 cwd で走る codex の信号と
|
|
603
610
|
// 混ざらないよう、claude 由来のイベントは必ず "claude" バケットへ書く。
|
|
@@ -612,6 +619,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
612
619
|
/* ignore */
|
|
613
620
|
}
|
|
614
621
|
} else if (event?.type === "result") {
|
|
622
|
+
// SDK の権威的な窓サイズ (result.modelUsage[model].contextWindow) をここで学習する。
|
|
623
|
+
resolveChatContextPct(cwd, "claude", event)
|
|
615
624
|
try {
|
|
616
625
|
recordChatActivity(cwd, "claude", {
|
|
617
626
|
status: "waiting",
|
|
@@ -659,6 +668,36 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
659
668
|
input,
|
|
660
669
|
})
|
|
661
670
|
})
|
|
671
|
+
claudeBridge.on(
|
|
672
|
+
"permissioncancel",
|
|
673
|
+
({ stream_id, session_id, cwd, request_ids, reason }) => {
|
|
674
|
+
// SDK チャットの未応答 permission をターン終了/中断/close で内部 deny したとき、
|
|
675
|
+
// browser に残っている承認カードを撤去する (孤児カード防止, 2026-07-10 監査)。
|
|
676
|
+
// TUI 由来カードの撤去と同じ `claude.permission.cancel` 型を使う。
|
|
677
|
+
for (const request_id of request_ids || []) {
|
|
678
|
+
try {
|
|
679
|
+
client.send({
|
|
680
|
+
type: "claude.permission.cancel",
|
|
681
|
+
stream_id,
|
|
682
|
+
session_id,
|
|
683
|
+
cwd,
|
|
684
|
+
request_id,
|
|
685
|
+
reason,
|
|
686
|
+
})
|
|
687
|
+
} catch {
|
|
688
|
+
/* ignore */
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (cwd && request_ids?.length) {
|
|
692
|
+
// 確認待ちドットも落とす (次の assistant/result を待たずに自己解消させる)。
|
|
693
|
+
try {
|
|
694
|
+
recordChatActivity(cwd, "claude", { inputPending: false })
|
|
695
|
+
} catch {
|
|
696
|
+
/* ignore */
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
)
|
|
662
701
|
claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
|
|
663
702
|
client.send({ type: "claude.exit", stream_id, code, reason, session_id })
|
|
664
703
|
})
|
|
@@ -1016,6 +1055,58 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1016
1055
|
jsonlLiveWatchers.start()
|
|
1017
1056
|
ctx.jsonlLiveWatchers = jsonlLiveWatchers
|
|
1018
1057
|
|
|
1058
|
+
// モデル能力レジストリ (2026-07-11): SDK supportedModels() を起動時に引き、effort
|
|
1059
|
+
// 対応可否の判定を正規表現ハードコードから権威データへ置き換える。能力はセッション中
|
|
1060
|
+
// 不変なので attach ごとには引き直さない。ただし control-only query は cold start 直後に
|
|
1061
|
+
// 失敗しうるため、起動フェーズ内で数回だけリトライする (成功したら打ち切り)。全滅しても
|
|
1062
|
+
// effort 判定は「browser effort 送信」へフォールバックするので致命ではない。
|
|
1063
|
+
// fire-and-forget で起動をブロックしない。
|
|
1064
|
+
if (claudeBridge) {
|
|
1065
|
+
;(async () => {
|
|
1066
|
+
const delaysMs = [0, 3000, 15000]
|
|
1067
|
+
let claudeOk = false
|
|
1068
|
+
for (const d of delaysMs) {
|
|
1069
|
+
if (d) await new Promise((r) => setTimeout(r, d))
|
|
1070
|
+
const n = await claudeBridge.refreshModelCapabilities()
|
|
1071
|
+
if (n) {
|
|
1072
|
+
claudeOk = true
|
|
1073
|
+
break // 1 件でも載れば成功
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (!claudeOk) {
|
|
1077
|
+
logger.warn({}, "model capability を起動フェーズで取得できず (browser effort へフォールバック)")
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// 第2段 (2026-07-11): 取得した能力を Hub へ報告する。backend が is_staff ゲート+
|
|
1081
|
+
// バージョン単調ラチェットでモデルマスタの能力列へ反映する(詳細は
|
|
1082
|
+
// apps/ai_models/capability_sync.py)。claude はレジストリ由来、codex は codex CLI が
|
|
1083
|
+
// model/list をキャッシュした ~/.codex/models_cache.json を読む(案A、ゼロ spawn)。
|
|
1084
|
+
// 送信は fire-and-forget。backend が古い版/非staffなら黙って無視するので害はない。
|
|
1085
|
+
try {
|
|
1086
|
+
const codex = await readCodexReport().catch(() => null)
|
|
1087
|
+
const report = buildCapabilityReport({
|
|
1088
|
+
claudeModels: claudeOk ? getReportModels() : [],
|
|
1089
|
+
sdkVersion: readSdkVersion(),
|
|
1090
|
+
codex,
|
|
1091
|
+
})
|
|
1092
|
+
if (report) {
|
|
1093
|
+
client.send(report)
|
|
1094
|
+
logger.info(
|
|
1095
|
+
{
|
|
1096
|
+
claude: report.claude?.models?.length ?? 0,
|
|
1097
|
+
codex: report.codex?.models?.length ?? 0,
|
|
1098
|
+
},
|
|
1099
|
+
"model capability report を Hub へ送信",
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
logger.warn({ err: err?.message }, "model capability report 送信に失敗")
|
|
1104
|
+
}
|
|
1105
|
+
})().catch((err) =>
|
|
1106
|
+
logger.warn({ err: err?.message }, "model capability 取得ループが例外終了"),
|
|
1107
|
+
)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1019
1110
|
// T04784 会話継続バインディングの冪等性ガード: tmux セッション名 → 最後に --resume で
|
|
1020
1111
|
// 載せ替えた Claude session_id。TUI ビューが remount のたびに claude.tui.bind を送っても、
|
|
1021
1112
|
// 同じ id への再 respawn (= claude 再起動) を抑止する。
|
|
@@ -1098,13 +1189,76 @@ export async function refreshContextWindow() {
|
|
|
1098
1189
|
}
|
|
1099
1190
|
}
|
|
1100
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* セッション単位で学習した context 窓サイズ (トークン)。
|
|
1194
|
+
* キーは `${cliKind}\u0000${cwd}` (chat-signals と同じ粒度)。
|
|
1195
|
+
*
|
|
1196
|
+
* 【なぜセッション単位が要るか】(2026-07-10 監査)
|
|
1197
|
+
* 旧実装は分子 (SDK assistant.usage = セッション単位) に対し、分母を
|
|
1198
|
+
* resolvedContextWindow = statusLine cache (`~/.hub/usage/latest.json`) 由来の
|
|
1199
|
+
* **マシン全体で 1 個のグローバル値**にしていた。同ファイルは「最後に statusLine を
|
|
1200
|
+
* 書いた任意のセッション」の値なので、1M 窓の Opus と 200k 窓の Sonnet を同時に
|
|
1201
|
+
* 走らせると、分母が取り違わってドーナツが 5 倍ずれる (分子と分母のスケール不一致)。
|
|
1202
|
+
*
|
|
1203
|
+
* SDK は `result.modelUsage[model].contextWindow` として **そのターンで実際に使われた
|
|
1204
|
+
* モデルの窓サイズ**を毎ターン渡してくる。これが唯一の権威なので、result のたびに
|
|
1205
|
+
* ここへ学習し、以降の assistant イベントの分母に使う。
|
|
1206
|
+
*/
|
|
1207
|
+
const contextWindowByChatKey = new Map()
|
|
1208
|
+
|
|
1209
|
+
/** 直近の assistant メッセージのモデル名。result の modelUsage から主対話モデルを選ぶ手掛かり。 */
|
|
1210
|
+
const lastAssistantModelByChatKey = new Map()
|
|
1211
|
+
|
|
1212
|
+
/** chat-signals と同じ (cwd, cliKind) 粒度のキーを作る。 */
|
|
1213
|
+
export function chatKey(cwd, cliKind) {
|
|
1214
|
+
return `${cliKind || "claude"}\u0000${cwd || ""}`
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* SDK `result` の `modelUsage` から、そのセッションの context 窓サイズを選ぶ。
|
|
1219
|
+
*
|
|
1220
|
+
* `modelUsage` は `Record<モデル名, ModelUsage>` で、1 ターンに複数モデルが乗りうる
|
|
1221
|
+
* (サブエージェントの haiku 等)。ドーナツが表すべきは **主対話モデル**の窓なので、
|
|
1222
|
+
* 1. 直近 assistant メッセージのモデル名に完全一致するもの
|
|
1223
|
+
* 2. 無ければ入力トークン (input + cache_read) が最大のもの = 会話本体を読んだモデル
|
|
1224
|
+
* の順に選ぶ。どれも窓サイズを持たなければ null (呼び出し側がグローバル値へフォールバック)。
|
|
1225
|
+
*
|
|
1226
|
+
* @param {Record<string, {contextWindow?:number, inputTokens?:number, cacheReadInputTokens?:number}>|null|undefined} modelUsage
|
|
1227
|
+
* @param {string|null|undefined} preferredModel 直近 assistant の model 名
|
|
1228
|
+
* @returns {number|null}
|
|
1229
|
+
*/
|
|
1230
|
+
export function pickContextWindow(modelUsage, preferredModel) {
|
|
1231
|
+
if (!modelUsage || typeof modelUsage !== "object") return null
|
|
1232
|
+
const entries = Object.entries(modelUsage).filter(
|
|
1233
|
+
([, v]) => v && typeof v.contextWindow === "number" && v.contextWindow > 0,
|
|
1234
|
+
)
|
|
1235
|
+
if (entries.length === 0) return null
|
|
1236
|
+
if (preferredModel) {
|
|
1237
|
+
const hit = entries.find(([name]) => name === preferredModel)
|
|
1238
|
+
if (hit) return hit[1].contextWindow
|
|
1239
|
+
}
|
|
1240
|
+
const inputOf = (v) => (v.inputTokens || 0) + (v.cacheReadInputTokens || 0)
|
|
1241
|
+
let best = entries[0]
|
|
1242
|
+
for (const e of entries) {
|
|
1243
|
+
if (inputOf(e[1]) > inputOf(best[1])) best = e
|
|
1244
|
+
}
|
|
1245
|
+
return best[1].contextWindow
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1101
1248
|
/**
|
|
1102
1249
|
* SDK assistant メッセージの usage から context 使用率 (%) を概算する。
|
|
1103
1250
|
* context = input + cache_read + cache_creation + output(直近応答)。
|
|
1104
1251
|
* プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を合算する。
|
|
1105
|
-
*
|
|
1252
|
+
*
|
|
1253
|
+
* 分母は `contextWindow` 引数 (= SDK result の modelUsage から学習したセッション固有の
|
|
1254
|
+
* 窓サイズ) を最優先し、未学習なら resolvedContextWindow (statusLine cache 由来の
|
|
1255
|
+
* グローバル概算) にフォールバックする。セッションの初回ターンだけは result 未着で
|
|
1256
|
+
* フォールバックになるが、初回はトークンが少なくドーナツもほぼ 0 のため実害は無い。
|
|
1257
|
+
*
|
|
1258
|
+
* @param {object|null} u assistant.message.usage
|
|
1259
|
+
* @param {number|null|undefined} contextWindow セッション固有の窓サイズ (省略時グローバル)
|
|
1106
1260
|
*/
|
|
1107
|
-
export function contextPctFromUsage(u) {
|
|
1261
|
+
export function contextPctFromUsage(u, contextWindow) {
|
|
1108
1262
|
if (!u) return null
|
|
1109
1263
|
const tokens =
|
|
1110
1264
|
(u.input_tokens || 0) +
|
|
@@ -1112,7 +1266,43 @@ export function contextPctFromUsage(u) {
|
|
|
1112
1266
|
(u.cache_creation_input_tokens || 0) +
|
|
1113
1267
|
(u.output_tokens || 0)
|
|
1114
1268
|
if (tokens <= 0) return null
|
|
1115
|
-
|
|
1269
|
+
const win =
|
|
1270
|
+
typeof contextWindow === "number" && contextWindow > 0
|
|
1271
|
+
? contextWindow
|
|
1272
|
+
: resolvedContextWindow
|
|
1273
|
+
return Math.min(100, Math.round((tokens / win) * 1000) / 10)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* SDK チャットの assistant / result イベントから context% を解決し、
|
|
1278
|
+
* 併せて `result.modelUsage` の窓サイズを (cwd, cliKind) 単位で学習する。
|
|
1279
|
+
*
|
|
1280
|
+
* ハンドラ本体から切り出して単体テスト可能にしている (学習 → 次ターンで反映、
|
|
1281
|
+
* という状態遷移こそがこの機能の本体であり、純関数だけでは配線を検証できないため)。
|
|
1282
|
+
*
|
|
1283
|
+
* @returns {number|null} assistant のとき context% / それ以外は null
|
|
1284
|
+
*/
|
|
1285
|
+
export function resolveChatContextPct(cwd, cliKind, event) {
|
|
1286
|
+
const key = chatKey(cwd, cliKind)
|
|
1287
|
+
if (event?.type === "assistant") {
|
|
1288
|
+
const model = event.message?.model || null
|
|
1289
|
+
if (model) lastAssistantModelByChatKey.set(key, model)
|
|
1290
|
+
return event.message?.usage
|
|
1291
|
+
? contextPctFromUsage(event.message.usage, contextWindowByChatKey.get(key))
|
|
1292
|
+
: null
|
|
1293
|
+
}
|
|
1294
|
+
if (event?.type === "result") {
|
|
1295
|
+
// モデル切替やセッション回転が起きても次の result で必ず上書きされる (自己修復)。
|
|
1296
|
+
const win = pickContextWindow(event.modelUsage, lastAssistantModelByChatKey.get(key))
|
|
1297
|
+
if (win) contextWindowByChatKey.set(key, win)
|
|
1298
|
+
}
|
|
1299
|
+
return null
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** テスト用: 学習済みの窓サイズ / モデル名をリセットする。 */
|
|
1303
|
+
export function _resetContextWindowLearning() {
|
|
1304
|
+
contextWindowByChatKey.clear()
|
|
1305
|
+
lastAssistantModelByChatKey.clear()
|
|
1116
1306
|
}
|
|
1117
1307
|
|
|
1118
1308
|
/**
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* モデル能力報告の組み立て(第2段, 2026-07-11)。
|
|
3
|
+
*
|
|
4
|
+
* hub-agent が上流(Claude Agent SDK / codex)から取得したモデル能力を、Hub backend の
|
|
5
|
+
* `model.capabilities.report` メッセージ形に整形する。backend は
|
|
6
|
+
* apply_capability_report() でバージョン単調ラチェットにより AIModelConfig の能力列へ反映する。
|
|
7
|
+
*
|
|
8
|
+
* - Claude: SDK `supportedModels()` 由来の能力レジストリ(getReportModels)+ SDK バージョン。
|
|
9
|
+
* - Codex: codex CLI 自身が `model/list` 応答をキャッシュした `~/.codex/models_cache.json` を
|
|
10
|
+
* 読む(案A)。起動ごとに codex app-server を spawn せずに済み、ラチェットに必要な
|
|
11
|
+
* client_version / fetched_at / etag / supported_reasoning_levels を全て持つ。
|
|
12
|
+
* ※ runtime の判定に cache を使うわけではない(それは第1段で直した context% の轍)。
|
|
13
|
+
* ここは「一度きりの報告」の一次データとして codex 自身の materialized view を読むだけ。
|
|
14
|
+
*/
|
|
15
|
+
import { promises as fs, readFileSync } from "node:fs"
|
|
16
|
+
import os from "node:os"
|
|
17
|
+
import path from "node:path"
|
|
18
|
+
import { createRequire } from "node:module"
|
|
19
|
+
|
|
20
|
+
/** インストール済み @anthropic-ai/claude-agent-sdk の version を読む。失敗時 null。
|
|
21
|
+
*
|
|
22
|
+
* SDK は `exports` フィールドで `./package.json` サブパスをブロックするため、
|
|
23
|
+
* `require("@anthropic-ai/claude-agent-sdk/package.json")` は
|
|
24
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED で失敗する。代わりにメインエントリを解決して
|
|
25
|
+
* そのパッケージルート(= dirname)の package.json を fs で直読みする。 */
|
|
26
|
+
export function readSdkVersion() {
|
|
27
|
+
try {
|
|
28
|
+
const require = createRequire(import.meta.url)
|
|
29
|
+
// "." export → 例: .../@anthropic-ai/claude-agent-sdk/sdk.mjs(パッケージルート直下)
|
|
30
|
+
const entry = require.resolve("@anthropic-ai/claude-agent-sdk")
|
|
31
|
+
// sdk.mjs はパッケージルートにあるため dirname がパッケージルート。
|
|
32
|
+
const pkgPath = path.join(path.dirname(entry), "package.json")
|
|
33
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
34
|
+
return typeof pkg?.version === "string" && pkg.version ? pkg.version : null
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function codexModelsCachePath() {
|
|
41
|
+
const dir = process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
|
|
42
|
+
return process.env.HUB_CODEX_MODELS_CACHE || path.join(dir, "models_cache.json")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* codex の models_cache.json を読み、報告ブロックへ整形する。失敗/未存在は null。
|
|
47
|
+
*
|
|
48
|
+
* `supported_reasoning_levels`([{effort, description}, ...])から effort 対応を導出:
|
|
49
|
+
* - supports_effort = low/medium/high 等が 1 つでもあれば true(reasoning あり)
|
|
50
|
+
* - supports_xhigh_effort = effort に "xhigh" を含むか
|
|
51
|
+
* `visibility === "hide"` のモデルはピッカー非表示だが、能力自体は正しいので報告に含める
|
|
52
|
+
* (backend は「マスタに存在する行」しか触らないため、hide モデルが紛れても実害なし)。
|
|
53
|
+
*/
|
|
54
|
+
export async function readCodexReport(cachePath = codexModelsCachePath()) {
|
|
55
|
+
let text
|
|
56
|
+
try {
|
|
57
|
+
text = await fs.readFile(cachePath, "utf8")
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
let j
|
|
62
|
+
try {
|
|
63
|
+
j = JSON.parse(text)
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const version = j?.client_version
|
|
68
|
+
if (typeof version !== "string" || !version) return null
|
|
69
|
+
const rawModels = Array.isArray(j?.models) ? j.models : []
|
|
70
|
+
const models = []
|
|
71
|
+
for (const m of rawModels) {
|
|
72
|
+
const name = m?.slug
|
|
73
|
+
if (typeof name !== "string" || !name) continue
|
|
74
|
+
const levels = Array.isArray(m.supported_reasoning_levels)
|
|
75
|
+
? m.supported_reasoning_levels
|
|
76
|
+
: []
|
|
77
|
+
const efforts = levels
|
|
78
|
+
.map((x) => (x && typeof x.effort === "string" ? x.effort : null))
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
models.push({
|
|
81
|
+
model_name: name,
|
|
82
|
+
supports_effort: efforts.length > 0,
|
|
83
|
+
supports_xhigh_effort: efforts.includes("xhigh") || efforts.includes("max"),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
version,
|
|
88
|
+
fetched_at: typeof j?.fetched_at === "string" ? j.fetched_at : undefined,
|
|
89
|
+
etag: typeof j?.etag === "string" ? j.etag : undefined,
|
|
90
|
+
models,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 報告メッセージを組み立てる。claude / codex は取得できたものだけ含める。
|
|
96
|
+
* どちらも空なら null を返す(送る意味がない)。
|
|
97
|
+
*
|
|
98
|
+
* @param {object} args
|
|
99
|
+
* @param {Array} args.claudeModels getReportModels() の戻り値(能力レジストリ由来)
|
|
100
|
+
* @param {string|null} args.sdkVersion readSdkVersion()
|
|
101
|
+
* @param {object|null} args.codex readCodexReport() の戻り値
|
|
102
|
+
*/
|
|
103
|
+
export function buildCapabilityReport({ claudeModels, sdkVersion, codex }) {
|
|
104
|
+
const msg = { type: "model.capabilities.report" }
|
|
105
|
+
if (sdkVersion && Array.isArray(claudeModels) && claudeModels.length > 0) {
|
|
106
|
+
msg.claude = { version: sdkVersion, models: claudeModels }
|
|
107
|
+
}
|
|
108
|
+
if (codex && codex.version && Array.isArray(codex.models) && codex.models.length > 0) {
|
|
109
|
+
msg.codex = {
|
|
110
|
+
version: codex.version,
|
|
111
|
+
models: codex.models,
|
|
112
|
+
}
|
|
113
|
+
if (codex.fetched_at) msg.codex.fetched_at = codex.fetched_at
|
|
114
|
+
if (codex.etag) msg.codex.etag = codex.etag
|
|
115
|
+
}
|
|
116
|
+
if (!msg.claude && !msg.codex) return null
|
|
117
|
+
return msg
|
|
118
|
+
}
|