@cocorograph/hub-agent 0.7.28 → 0.7.30
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/claude-stream-bridge.mjs +101 -5
- package/src/main.mjs +103 -16
- package/src/tmux.mjs +9 -1
- package/src/usage.mjs +156 -2
package/package.json
CHANGED
|
@@ -568,18 +568,27 @@ class ClaudeStreamSession {
|
|
|
568
568
|
}
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
-
/** モデルが Opus 4.6+
|
|
571
|
+
/** モデルが effort / adaptive thinking 対応 (Opus 4.6+ / Fable 5) かどうか。
|
|
572
572
|
* budget 方式 (maxThinkingTokens) は Opus 4.7+ で廃止扱いのため、effort モデルでは
|
|
573
|
-
* effort + thinking:{type:'adaptive'} に切り替える。
|
|
573
|
+
* effort + thinking:{type:'adaptive'} に切り替える。
|
|
574
|
+
* ⚠️ frontend の isEffortModelByPattern (types/cockpit.ts) と同一パターンを維持する
|
|
575
|
+
* こと。旧実装は fable-5 を含まず、frontend が effort を送っても bridge が非 effort
|
|
576
|
+
* 分岐に入り「effort も budget も未適用」になる不整合があった (2026-07-02 修正)。 */
|
|
574
577
|
_isEffortModel() {
|
|
575
|
-
return
|
|
578
|
+
return (
|
|
579
|
+
typeof this.model === "string" && /claude-fable-5|claude-opus-4-[678]/.test(this.model)
|
|
580
|
+
)
|
|
576
581
|
}
|
|
577
582
|
|
|
578
583
|
/** 思考関連オプション (effort / adaptive thinking / 旧 budget) を options へ適用する。
|
|
579
584
|
* per-message / 常駐 query の両方から呼ぶ共通ロジック (分岐の二重定義を避ける)。 */
|
|
580
585
|
_applyThinkingOptions(options) {
|
|
581
|
-
|
|
582
|
-
|
|
586
|
+
// model 未指定 (= browser が agent/アカウント既定モデルへ委任) でも、browser が
|
|
587
|
+
// effort を明示送信してきた場合は effort 分岐に入れる。frontend は system/init の
|
|
588
|
+
// 実効モデル (runtimeModel) で capability 判定してから effort を送るため、ここでは
|
|
589
|
+
// その判断を信頼する (2026-07-02)。
|
|
590
|
+
if (this._isEffortModel() || (!this.model && this.effort)) {
|
|
591
|
+
// effort モデル: adaptive thinking を明示 ON にし、effort で深さを指定する。
|
|
583
592
|
// budget 方式 (maxThinkingTokens) は使わない (Opus 4.7+ で非対応)。
|
|
584
593
|
options.thinking = { type: "adaptive" }
|
|
585
594
|
if (this.effort) options.effort = this.effort
|
|
@@ -803,6 +812,26 @@ class ClaudeStreamSession {
|
|
|
803
812
|
} catch {
|
|
804
813
|
/* ignore */
|
|
805
814
|
}
|
|
815
|
+
// 停止即時化 (2026-07-02): onTurnSettled はチャット信号 (サイドバードット) 専用で、
|
|
816
|
+
// browser の SDK ストリーム UI (turnActive / interrupting) を解除するイベントでは
|
|
817
|
+
// ない。abort でターンが終わると result が届かず、UI は 120s 無音ウォッチドッグ
|
|
818
|
+
// まで「停止中…」のまま固着する。合成 result を通常のイベント経路 (claude.event)
|
|
819
|
+
// へ流し、reducer にターン終了を即時確定させる。subtype はユーザー中断 (abort) と
|
|
820
|
+
// 異常終了 (result 無しの自然終了) を区別する (frontend は前者を「中断しました」
|
|
821
|
+
// フッターで表示する)。uuid を持たせるのは重複排除 (isDuplicateEvent) が result
|
|
822
|
+
// 署名 (session_id + num_turns + duration_ms + cost) で判定するため — 合成 result
|
|
823
|
+
// はこれらが毎回同値になり、2 回目以降の中断で「重複」と誤判定され捨てられるのを防ぐ。
|
|
824
|
+
try {
|
|
825
|
+
this._emit({
|
|
826
|
+
type: "result",
|
|
827
|
+
subtype: aborted ? "aborted_by_user" : "turn_settled",
|
|
828
|
+
uuid: randomUUID(),
|
|
829
|
+
session_id: this.sessionId ?? undefined,
|
|
830
|
+
timestamp: new Date().toISOString(),
|
|
831
|
+
})
|
|
832
|
+
} catch {
|
|
833
|
+
/* ignore */
|
|
834
|
+
}
|
|
806
835
|
}
|
|
807
836
|
// graceful detach: browser が切れている間にターンが完走したら、ここで遅延
|
|
808
837
|
// クローズする。manager 側で sessions Map から撤去 + exit を emit する。
|
|
@@ -1559,6 +1588,73 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1559
1588
|
return this._mergeNeedsAuthCache(servers)
|
|
1560
1589
|
}
|
|
1561
1590
|
|
|
1591
|
+
/**
|
|
1592
|
+
* アカウント全体の 5h/7d rate-limit 使用率 + プラン種別を返す (usage.mjs
|
|
1593
|
+
* recordChatUsageSnapshot に渡す想定)。mcpStatus と同じ control-only query を共有する
|
|
1594
|
+
* (account-level = session/チャット非依存。statusLine が動かない純 SDK チャット運用でも
|
|
1595
|
+
* 取得できる)。
|
|
1596
|
+
*
|
|
1597
|
+
* SDK の `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` は unstable な
|
|
1598
|
+
* 実験的 API のため、失敗 (未実装 SDK バージョン / 一時的なエラー等) は null を返す。
|
|
1599
|
+
* 呼び出し側 (main.mjs `usage.request` ハンドラ) は null なら何もせず、既存の
|
|
1600
|
+
* statusLine / transcript 推定フォールバックに委ねる。
|
|
1601
|
+
*
|
|
1602
|
+
* @returns {Promise<import('@anthropic-ai/claude-agent-sdk').SDKControlGetUsageResponse|null>}
|
|
1603
|
+
*/
|
|
1604
|
+
async getAccountUsage() {
|
|
1605
|
+
try {
|
|
1606
|
+
const q = await this._ensureMcpControlQuery()
|
|
1607
|
+
this._armMcpControlIdle()
|
|
1608
|
+
if (typeof q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET !== "function") {
|
|
1609
|
+
return null
|
|
1610
|
+
}
|
|
1611
|
+
return await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
this.logger?.warn(
|
|
1614
|
+
{ err: err?.message },
|
|
1615
|
+
"getAccountUsage failed (SDK experimental usage API)",
|
|
1616
|
+
)
|
|
1617
|
+
return null
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* 指定 cwd で現在ライブな (browser 接続中、または detach 猶予内の) SDK チャット
|
|
1623
|
+
* session_id を返す。無ければ null。
|
|
1624
|
+
*
|
|
1625
|
+
* TUI モードには tuiReboundSessions による bound session 解決 (main.mjs
|
|
1626
|
+
* `turnActiveForCwd`/`jsonlContextForCwd` 呼び出し) があるが、チャット (SDK stream)
|
|
1627
|
+
* モードには同等の仕組みが無かった。同一 cwd の encode dir にはチャット本体の他に
|
|
1628
|
+
* サブエージェント / headless `claude -p` (記憶蒸留 Stop hook 等) / 過去セッションの
|
|
1629
|
+
* jsonl が堆積するため、bound 指定無しの mtime 最新フォールバックは cross-activity
|
|
1630
|
+
* 汚染 (無関係な jsonl を「そのチャット」と誤認) を起こし得る。本メソッドはチャット版
|
|
1631
|
+
* の bound session 解決として main.mjs (startStateLoop / tmux.list_sessions ハンドラ)
|
|
1632
|
+
* から使う。同一 cwd に複数ライブセッションがある場合は最終アクティビティが最も
|
|
1633
|
+
* 新しいものを採用する。
|
|
1634
|
+
*
|
|
1635
|
+
* @param {string} cwd
|
|
1636
|
+
* @returns {string|null}
|
|
1637
|
+
*/
|
|
1638
|
+
liveSessionIdForCwd(cwd) {
|
|
1639
|
+
if (!cwd) return null
|
|
1640
|
+
let best = null
|
|
1641
|
+
let bestAt = -1
|
|
1642
|
+
const seen = new Set()
|
|
1643
|
+
for (const session of this.sessions.values()) {
|
|
1644
|
+
if (seen.has(session) || session.cwd !== cwd || !session.sessionId) continue
|
|
1645
|
+
seen.add(session)
|
|
1646
|
+
let at = 0
|
|
1647
|
+
for (const t of session.lastActivityByStream.values()) {
|
|
1648
|
+
if (t > at) at = t
|
|
1649
|
+
}
|
|
1650
|
+
if (at > bestAt) {
|
|
1651
|
+
bestAt = at
|
|
1652
|
+
best = session.sessionId
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return best
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1562
1658
|
/**
|
|
1563
1659
|
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
1564
1660
|
* サーバーを `needs-auth` として補完する。
|
package/src/main.mjs
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
getUsage,
|
|
99
99
|
isFreshUnboundBind,
|
|
100
100
|
recordChatRateLimit,
|
|
101
|
+
recordChatUsageSnapshot,
|
|
101
102
|
turnActiveForCwd,
|
|
102
103
|
} from "./usage.mjs"
|
|
103
104
|
import {
|
|
@@ -271,12 +272,21 @@ async function loadClaudeSdk(logger) {
|
|
|
271
272
|
|
|
272
273
|
/**
|
|
273
274
|
* B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
|
|
274
|
-
* pty 出力データ (pty.data) と resize (pty.resize)
|
|
275
|
-
*
|
|
276
|
-
*
|
|
275
|
+
* - pty 出力データ (pty.data) と resize (pty.resize): 高頻度・順序保証不要。
|
|
276
|
+
* - claude.interrupt / codex.interrupt (2026-07-02): 停止指示。前段に重い dispatch
|
|
277
|
+
* (tmux.exec 等) が滞留していると停止が遅延するため直列キューをバイパスする。
|
|
278
|
+
* 中断は「現ターンを止める」冪等な制御で、入力系のような順序依存が無い (むしろ
|
|
279
|
+
* キュー内の後続入力より先に届くべき)。
|
|
280
|
+
* 入力系 (claude.input)・他の制御系 (tmux.exec / permission / cancel→paste 等) は
|
|
281
|
+
* WS 受信順 = pane 反映順を守るため false (= 直列キューに残す)。
|
|
277
282
|
*/
|
|
278
283
|
export function isFastPathMessage(type) {
|
|
279
|
-
return
|
|
284
|
+
return (
|
|
285
|
+
type === "pty.data" ||
|
|
286
|
+
type === "pty.resize" ||
|
|
287
|
+
type === "claude.interrupt" ||
|
|
288
|
+
type === "codex.interrupt"
|
|
289
|
+
)
|
|
280
290
|
}
|
|
281
291
|
|
|
282
292
|
/**
|
|
@@ -1334,6 +1344,61 @@ export function enrichTmuxSessionsForListResponse(
|
|
|
1334
1344
|
})
|
|
1335
1345
|
}
|
|
1336
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* state loop 内で「チャット信号 (getChatSignal) でペイン status/context_pct を上書きするか」
|
|
1349
|
+
* を決める純関数 (startStateLoop から抽出、単体テスト用)。
|
|
1350
|
+
*
|
|
1351
|
+
* バグ修正 (2026-07): 従来コードは書き換え「後」の status で processing ガードを判定して
|
|
1352
|
+
* いたため、チャット自身が processing を立てたときに自分自身の条件 (`status !== "processing"`)
|
|
1353
|
+
* を満たせなくなり、SDK チャット生成中はドーナツ (context_pct) が一切更新されなかった。
|
|
1354
|
+
* 本関数は判定を必ず「上書き前の pane 由来 status (paneStatus)」で行う。ペイン
|
|
1355
|
+
* (capture-pane) が実際に processing (= 本物の TUI が生成中) の場合のみチャット信号での
|
|
1356
|
+
* 上書きを止める。それ以外 (pane が idle 等 = SDK チャット専用ペイン含む) は常にチャット
|
|
1357
|
+
* 信号を優先する。
|
|
1358
|
+
*
|
|
1359
|
+
* @param {string} paneStatus capture-pane 由来の生 status (上書き前)
|
|
1360
|
+
* @param {number|null|undefined} paneContextPct capture-pane 由来の生 context_pct
|
|
1361
|
+
* @param {{status?:string|null, context_pct?:number|null}|null} chat getChatSignal() の戻り値
|
|
1362
|
+
* @returns {{status:string, contextPct:number|null|undefined}}
|
|
1363
|
+
*/
|
|
1364
|
+
export function applyChatOverlay(paneStatus, paneContextPct, chat) {
|
|
1365
|
+
let status = paneStatus
|
|
1366
|
+
let contextPct = paneContextPct
|
|
1367
|
+
if (chat) {
|
|
1368
|
+
const paneIsProcessing = paneStatus === "processing"
|
|
1369
|
+
if (chat.status && !paneIsProcessing) status = chat.status
|
|
1370
|
+
if (typeof chat.context_pct === "number" && !paneIsProcessing) {
|
|
1371
|
+
contextPct = chat.context_pct
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return { status, contextPct }
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
|
|
1379
|
+
*
|
|
1380
|
+
* TUI (tuiReboundSessions, tmux セッション名キー) を優先し、無ければ claudeBridge の
|
|
1381
|
+
* ライブ SDK チャットセッション (cwd キー) にフォールバックする。チャットモードには
|
|
1382
|
+
* tmux 上の bind 台帳が無いため、これが無いとチャット信号 stale (90s 無活動) 時の
|
|
1383
|
+
* フォールバックが cwd の encode dir 内の別アクティビティ jsonl (サブエージェント /
|
|
1384
|
+
* headless `claude -p` / 過去セッション等) を「最新」と取り違える cross-activity 汚染を
|
|
1385
|
+
* 起こし得る (TUI 版は tuiReboundSessions で 2026-06 に根治済み。本関数はチャット版の同根治)。
|
|
1386
|
+
*
|
|
1387
|
+
* @param {Map<string,string>|null|undefined} boundSessions ctx.tuiReboundSessions
|
|
1388
|
+
* @param {string} sessionName tmux セッション名
|
|
1389
|
+
* @param {string|null|undefined} cwd
|
|
1390
|
+
* @param {{liveSessionIdForCwd?: (cwd:string)=>string|null}|null|undefined} claudeBridge
|
|
1391
|
+
* @returns {string|null}
|
|
1392
|
+
*/
|
|
1393
|
+
export function resolveStateLoopBoundSessionId(boundSessions, sessionName, cwd, claudeBridge) {
|
|
1394
|
+
const tuiId = boundSessionId(boundSessions, sessionName)
|
|
1395
|
+
if (tuiId) return tuiId
|
|
1396
|
+
if (cwd && claudeBridge && typeof claudeBridge.liveSessionIdForCwd === "function") {
|
|
1397
|
+
return claudeBridge.liveSessionIdForCwd(cwd) || null
|
|
1398
|
+
}
|
|
1399
|
+
return null
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1337
1402
|
export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
|
|
1338
1403
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
1339
1404
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
@@ -1370,25 +1435,25 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1370
1435
|
await refreshContextWindow()
|
|
1371
1436
|
const states = await listSessionStates({ plugins, logger })
|
|
1372
1437
|
for (const s of states) {
|
|
1373
|
-
let status = s.status
|
|
1374
|
-
let contextPct = s.context_pct
|
|
1375
1438
|
// 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
|
|
1376
1439
|
// tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
|
|
1377
1440
|
const permissionMode = s.permission_mode ?? null
|
|
1378
1441
|
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
1379
1442
|
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
1380
1443
|
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
1444
|
+
// capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
|
|
1445
|
+
// TUI が生成中) のときのみチャット信号で上書きしない (判定は applyChatOverlay
|
|
1446
|
+
// 内部で必ず「上書き前の pane 由来 status (s.status)」を使う。2026-07 バグ修正:
|
|
1447
|
+
// 旧実装は書き換え後の status で判定していたため、SDK チャット生成中はドーナツが
|
|
1448
|
+
// 一切更新されなかった)。素のシェル (SDK チャットのペイン) は processing を
|
|
1449
|
+
// 返せないため、SDK チャットの補完は従来どおり効く。TUI 生成中に stale な SDK
|
|
1450
|
+
// status (waiting) が勝ってステータスドット/三点リーダーが消える事故の防止
|
|
1451
|
+
// (2026-06-12) は paneIsProcessing ガードのまま維持される。
|
|
1381
1452
|
const chat = s.cwd ? getChatSignal(s.cwd) : null
|
|
1453
|
+
const overlay = applyChatOverlay(s.status, s.context_pct, chat)
|
|
1454
|
+
let status = overlay.status
|
|
1455
|
+
let contextPct = overlay.contextPct
|
|
1382
1456
|
if (chat) {
|
|
1383
|
-
// capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
|
|
1384
|
-
// TUI が生成中) のときはチャット信号で上書きしない。素のシェル (SDK チャット
|
|
1385
|
-
// のペイン) は processing を返せないため、SDK チャットの補完は従来どおり効く。
|
|
1386
|
-
// TUI 生成中に stale な SDK status (waiting) が勝ってステータスドット/
|
|
1387
|
-
// 三点リーダーが消える事故の防止 (2026-06-12)。
|
|
1388
|
-
if (chat.status && status !== "processing") status = chat.status
|
|
1389
|
-
if (typeof chat.context_pct === "number" && status !== "processing") {
|
|
1390
|
-
contextPct = chat.context_pct
|
|
1391
|
-
}
|
|
1392
1457
|
// チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
|
|
1393
1458
|
// tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
|
|
1394
1459
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
@@ -1457,6 +1522,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1457
1522
|
// T2: tool_use 末尾 active 中の中断マーカー無しクラッシュ判定のため、tmux pane_current_command
|
|
1458
1523
|
// から claude プロセス生存を 3 値で返す paneClaudeAliveOrUnknown を probe として渡す。
|
|
1459
1524
|
// 'dead' (シェル前景)→active=false に倒す。'alive'/'unknown' は active 維持(偽陰性ゼロ)。
|
|
1525
|
+
// - チャット信号が stale (90s 無活動) でこの else-if に落ちるケース: SDK チャットは
|
|
1526
|
+
// tmux bind 台帳 (tuiReboundSessions) を持たないため、従来は boundSessionId が常に
|
|
1527
|
+
// null を返し mtime 最新 jsonl へフォールバックしていた (chat 版の cross-activity
|
|
1528
|
+
// 汚染, 2026-07 発覚)。resolveStateLoopBoundSessionId が claudeBridge のライブ
|
|
1529
|
+
// セッション id へフォールバックすることで TUI と同じ方式で根治する。
|
|
1460
1530
|
let turnActive = null
|
|
1461
1531
|
if (chat?.status === "processing") {
|
|
1462
1532
|
turnActive = true
|
|
@@ -1470,7 +1540,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1470
1540
|
} else if (s.cwd) {
|
|
1471
1541
|
turnActive = await turnActiveForCwd(
|
|
1472
1542
|
s.cwd,
|
|
1473
|
-
|
|
1543
|
+
resolveStateLoopBoundSessionId(boundSessions, s.session_name, s.cwd, claudeBridge),
|
|
1474
1544
|
s.session_name,
|
|
1475
1545
|
{ paneAliveProbe: paneClaudeAliveOrUnknown },
|
|
1476
1546
|
)
|
|
@@ -3302,10 +3372,13 @@ async function dispatch(msg, ctx) {
|
|
|
3302
3372
|
try {
|
|
3303
3373
|
// boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
|
|
3304
3374
|
// (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
|
|
3375
|
+
// claudeBridge: TUI bind が無い SDK チャット cwd 向けの同根治フォールバック
|
|
3376
|
+
// (2026-07 追加。tmux.mjs listSessions 参照)。
|
|
3305
3377
|
const sessions = await listTmuxSessions({
|
|
3306
3378
|
plugins: ctx.plugins,
|
|
3307
3379
|
logger: ctx.logger,
|
|
3308
3380
|
boundSessions: ctx.tuiReboundSessions,
|
|
3381
|
+
claudeBridge: ctx.claudeBridge,
|
|
3309
3382
|
})
|
|
3310
3383
|
// 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
|
|
3311
3384
|
// レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
|
|
@@ -3572,6 +3645,20 @@ async function dispatch(msg, ctx) {
|
|
|
3572
3645
|
}
|
|
3573
3646
|
case "usage.request": {
|
|
3574
3647
|
try {
|
|
3648
|
+
// 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
|
|
3649
|
+
// statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
|
|
3650
|
+
// ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
|
|
3651
|
+
// 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
|
|
3652
|
+
// API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
|
|
3653
|
+
// は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
|
|
3654
|
+
if (ctx.claudeBridge) {
|
|
3655
|
+
try {
|
|
3656
|
+
const acct = await ctx.claudeBridge.getAccountUsage()
|
|
3657
|
+
if (acct) recordChatUsageSnapshot(acct)
|
|
3658
|
+
} catch (err) {
|
|
3659
|
+
ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3575
3662
|
const [usage, sessions] = await Promise.all([
|
|
3576
3663
|
getUsage(),
|
|
3577
3664
|
getSessionUsages(),
|
package/src/tmux.mjs
CHANGED
|
@@ -643,7 +643,15 @@ export async function listSessions(opts = {}) {
|
|
|
643
643
|
// 4. それも無ければ pane scrape の正規表現フォールバック。
|
|
644
644
|
const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
|
|
645
645
|
// bound session があればその jsonl で context% を引く (cwd dir の別セッション jsonl の取り違え回避)。
|
|
646
|
-
|
|
646
|
+
// TUI bind (opts.boundSessions, tmux セッション名キー) を優先し、無ければ SDK チャット
|
|
647
|
+
// (claudeBridge, cwd キー) のライブセッションへフォールバックする。チャットモードには
|
|
648
|
+
// tmux 上の bind 台帳が無いため、これが無いと cold-start (session.state snapshot 未取得)
|
|
649
|
+
// の poll 応答で cwd の別アクティビティ jsonl を「最新」と取り違える cross-activity 汚染が
|
|
650
|
+
// 起き得る (2026-07 発覚。TUI 版は本行の boundSessions 解決で既に根治済みだった)。
|
|
651
|
+
const boundId =
|
|
652
|
+
boundSessionId(opts.boundSessions, s.name) ||
|
|
653
|
+
(cwd && opts.claudeBridge?.liveSessionIdForCwd?.(cwd)) ||
|
|
654
|
+
null
|
|
647
655
|
const jsonlInfo = cwd ? await jsonlContextForCwd(cwd, boundId) : null
|
|
648
656
|
const context_pct =
|
|
649
657
|
jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
|
package/src/usage.mjs
CHANGED
|
@@ -272,6 +272,125 @@ export function whenChatRateLimitsPersisted() {
|
|
|
272
272
|
return _persistInFlight
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// SDK control request (`usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET`)
|
|
277
|
+
// から取得した 5h/7d + plan のスナップショット (プロセス内共有 + ディスク永続化)。
|
|
278
|
+
//
|
|
279
|
+
// 背景: `recordChatRateLimit` (上記, SDK の `rate_limit_event`) は 2026-07 時点の
|
|
280
|
+
// SDK/Claude CLI で `utilization` フィールドが送られなくなり (実測確認済み)、事実上
|
|
281
|
+
// 常時 no-op 化した。加えて statusLine cache (`~/.hub/usage/latest.json`) 自体も
|
|
282
|
+
// `rate_limits` を出さなくなったため (readOfficial 参照)、5h/7d はターミナル・
|
|
283
|
+
// チャット両モードで estimate (transcript 集計の粗い近似) に落ちていた。
|
|
284
|
+
//
|
|
285
|
+
// SDK の control request `usage_EXPERIMENTAL_...()` は実測で正確な utilization/
|
|
286
|
+
// resets_at を返すことを確認済み (claude-agent-sdk 0.3.198)。account-level (session
|
|
287
|
+
// 非依存) なので、claude-stream-bridge.mjs の MCP control-only query を共有して
|
|
288
|
+
// 定期的に呼び出し (main.mjs `usage.request` ハンドラ)、ここに記録する。
|
|
289
|
+
//
|
|
290
|
+
// 永続化先は statusLine cache とは別ファイル (`~/.hub/usage/chat-usage.json`)。
|
|
291
|
+
// 同じ latest.json に混ぜると TUI の statusLine スクリプトが全文上書きで消してしまう
|
|
292
|
+
// (旧 chatRateLimits の設計的弱点)。専用ファイルなら誰にも上書きされない。
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function chatUsageCachePath() {
|
|
296
|
+
return configPath("HUB_CHAT_USAGE_CACHE", os.homedir(), ".hub", "usage", "chat-usage.json")
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @type {{five_hour:{percent:number,resetAtMs:number|null}|null, seven_day:{percent:number,resetAtMs:number|null}|null, plan:string|null, updatedAtMs:number}|null} */
|
|
300
|
+
let chatUsageSnapshot = null
|
|
301
|
+
let _chatUsagePersistInFlight = Promise.resolve()
|
|
302
|
+
|
|
303
|
+
/** SDKControlGetUsageResponse.rate_limits.{five_hour,seven_day} の 1 窓を正規化する。 */
|
|
304
|
+
function normalizeRateLimitWindow(w) {
|
|
305
|
+
if (!w || typeof w !== "object") return null
|
|
306
|
+
const percent = typeof w.utilization === "number" ? w.utilization : null
|
|
307
|
+
if (percent === null) return null
|
|
308
|
+
let resetAtMs = null
|
|
309
|
+
if (typeof w.resets_at === "string") {
|
|
310
|
+
const t = Date.parse(w.resets_at)
|
|
311
|
+
if (!Number.isNaN(t)) resetAtMs = t
|
|
312
|
+
} else if (typeof w.resets_at === "number") {
|
|
313
|
+
resetAtMs = w.resets_at < 1e12 ? w.resets_at * 1000 : w.resets_at
|
|
314
|
+
}
|
|
315
|
+
return { percent, resetAtMs }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* SDK control request `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()`
|
|
320
|
+
* の戻り値 (SDKControlGetUsageResponse) を取り込む。EXPERIMENTAL API なので形が
|
|
321
|
+
* 変わる可能性があり、想定外の入力は無視する (呼び出し側は try/catch で包む前提)。
|
|
322
|
+
*
|
|
323
|
+
* @param {{rate_limits_available?: boolean, rate_limits?: {five_hour?: object|null, seven_day?: object|null}|null, subscription_type?: string|null}|null} usageResponse
|
|
324
|
+
* @param {number} [now]
|
|
325
|
+
*/
|
|
326
|
+
export function recordChatUsageSnapshot(usageResponse, now = Date.now()) {
|
|
327
|
+
if (!usageResponse || typeof usageResponse !== "object") return
|
|
328
|
+
if (usageResponse.rate_limits_available === false) return
|
|
329
|
+
const rl = usageResponse.rate_limits
|
|
330
|
+
if (!rl || typeof rl !== "object") return
|
|
331
|
+
const five = normalizeRateLimitWindow(rl.five_hour)
|
|
332
|
+
const seven = normalizeRateLimitWindow(rl.seven_day)
|
|
333
|
+
if (!five && !seven) return
|
|
334
|
+
chatUsageSnapshot = {
|
|
335
|
+
five_hour: five,
|
|
336
|
+
seven_day: seven,
|
|
337
|
+
plan: usageResponse.subscription_type || chatUsageSnapshot?.plan || null,
|
|
338
|
+
updatedAtMs: now,
|
|
339
|
+
}
|
|
340
|
+
// P5(bug) と同じ直列化パターン (recordChatRateLimit 参照): 並行 persist の tmp 破壊/
|
|
341
|
+
// 中間状態読みを防ぐため、常に前回の persist 完了を待ってから次を実行する。
|
|
342
|
+
_chatUsagePersistInFlight = _chatUsagePersistInFlight.then(
|
|
343
|
+
() => persistChatUsageSnapshot(),
|
|
344
|
+
() => persistChatUsageSnapshot(),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function persistChatUsageSnapshot() {
|
|
349
|
+
const snapshot = chatUsageSnapshot
|
|
350
|
+
if (!snapshot) return
|
|
351
|
+
const p = chatUsageCachePath()
|
|
352
|
+
try {
|
|
353
|
+
await fs.mkdir(path.dirname(p), { recursive: true })
|
|
354
|
+
} catch {
|
|
355
|
+
/* ignore */
|
|
356
|
+
}
|
|
357
|
+
const tmp = `${p}.tmp.${randomUUID()}`
|
|
358
|
+
try {
|
|
359
|
+
await fs.writeFile(tmp, JSON.stringify(snapshot))
|
|
360
|
+
await fs.rename(tmp, p)
|
|
361
|
+
} catch {
|
|
362
|
+
try {
|
|
363
|
+
await fs.unlink(tmp)
|
|
364
|
+
} catch {
|
|
365
|
+
/* ignore */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 直近の chat usage snapshot 書き込みの完了を待つ。テスト用。 */
|
|
371
|
+
export function whenChatUsageSnapshotPersisted() {
|
|
372
|
+
return _chatUsagePersistInFlight
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** プロセス内メモリを優先し、無ければディスク永続化 (hub-agent 再起動直後等) を読む。 */
|
|
376
|
+
async function readChatUsageSnapshot() {
|
|
377
|
+
if (chatUsageSnapshot) return chatUsageSnapshot
|
|
378
|
+
const text = await readOrNull(chatUsageCachePath())
|
|
379
|
+
if (!text) return null
|
|
380
|
+
try {
|
|
381
|
+
const j = JSON.parse(text)
|
|
382
|
+
if (j && typeof j === "object" && typeof j.updatedAtMs === "number") return j
|
|
383
|
+
} catch {
|
|
384
|
+
/* ignore */
|
|
385
|
+
}
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** テスト用: chatUsageSnapshot をクリアする。 */
|
|
390
|
+
export function _resetChatUsageSnapshot() {
|
|
391
|
+
chatUsageSnapshot = null
|
|
392
|
+
}
|
|
393
|
+
|
|
275
394
|
async function readOfficial(now) {
|
|
276
395
|
const text = await readOrNull(statuslineCache())
|
|
277
396
|
if (!text) return null
|
|
@@ -284,8 +403,13 @@ async function readOfficial(now) {
|
|
|
284
403
|
const rl = j.rate_limits ?? j.rateLimits ?? {}
|
|
285
404
|
const five = rl.five_hour ?? rl.fiveHour ?? rl["5h"]
|
|
286
405
|
const seven = rl.seven_day ?? rl.sevenDay ?? rl["7d"]
|
|
287
|
-
if (!five && !seven) return null
|
|
288
406
|
const ctx = j.context_window?.used_percentage ?? j.contextWindow?.used_percentage ?? null
|
|
407
|
+
// 2026-07: Claude CLI 2.1.197+ の statusLine は rate_limits を出さなくなった
|
|
408
|
+
// (chatUsageSnapshot 由来の SDK control request がその代替本命ソースになる。
|
|
409
|
+
// 下の getUsage() 参照)。以前は rate_limits 欠落時に無関係な context_window まで
|
|
410
|
+
// 巻き込んで握りつぶしていた (ドーナツが常に jsonl 推定フォールバックへ落ちる副作用)。
|
|
411
|
+
// rate_limits と context の両方が無いときだけ official ソースとして無意味とみなす。
|
|
412
|
+
if (!five && !seven && ctx === null) return null
|
|
289
413
|
|
|
290
414
|
const bucketize = (b, defaultLimit) => {
|
|
291
415
|
if (!b) return { ...emptyBucket(defaultLimit), limit: defaultLimit }
|
|
@@ -310,7 +434,10 @@ async function readOfficial(now) {
|
|
|
310
434
|
return {
|
|
311
435
|
plan: j.plan || "official",
|
|
312
436
|
computedAt: now,
|
|
313
|
-
|
|
437
|
+
// 5h/7d の実値が伴わない (rate_limits 欠落・context のみ) ときは "official" を名乗らない。
|
|
438
|
+
// badge/表示は source を「5h/7d の信頼度」の意味で使っているため、chatUsageSnapshot 等の
|
|
439
|
+
// 上位レイヤーがまだ埋めていない時点では honest に "estimate" を返す。
|
|
440
|
+
source: five || seven ? "official" : "estimate",
|
|
314
441
|
context: typeof ctx === "number" ? ctx : null,
|
|
315
442
|
last5h: bucketize(five, 100),
|
|
316
443
|
last7d: bucketize(seven, 100),
|
|
@@ -1048,6 +1175,33 @@ export async function getUsage() {
|
|
|
1048
1175
|
result.source = "official"
|
|
1049
1176
|
}
|
|
1050
1177
|
}
|
|
1178
|
+
|
|
1179
|
+
// SDK control request (usage_EXPERIMENTAL...) 由来のスナップショット。上の
|
|
1180
|
+
// chatRateLimits (rate_limit_event) より本命の情報源 (utilization 欠落問題が無い、
|
|
1181
|
+
// 2026-07 時点で実測確認済み)。statusLine / rate_limit_event のどちらよりも新しければ
|
|
1182
|
+
// 上書きする。5h/7d が実際に埋まったときだけ source を official に昇格させる。
|
|
1183
|
+
const chatSnap = await readChatUsageSnapshot()
|
|
1184
|
+
if (chatSnap && chatSnap.updatedAtMs > 0) {
|
|
1185
|
+
const baseline = Math.max(cacheMtime ?? 0, chatRateLimits.updatedAtMs || 0)
|
|
1186
|
+
if (chatSnap.updatedAtMs > baseline) {
|
|
1187
|
+
if (chatSnap.five_hour) {
|
|
1188
|
+
result.last5h = {
|
|
1189
|
+
...result.last5h,
|
|
1190
|
+
percent: chatSnap.five_hour.percent,
|
|
1191
|
+
resetAtMs: chatSnap.five_hour.resetAtMs ?? result.last5h?.resetAtMs ?? null,
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (chatSnap.seven_day) {
|
|
1195
|
+
result.last7d = {
|
|
1196
|
+
...result.last7d,
|
|
1197
|
+
percent: chatSnap.seven_day.percent,
|
|
1198
|
+
resetAtMs: chatSnap.seven_day.resetAtMs ?? result.last7d?.resetAtMs ?? null,
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (chatSnap.plan) result.plan = chatSnap.plan
|
|
1202
|
+
if (chatSnap.five_hour || chatSnap.seven_day) result.source = "official"
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1051
1205
|
return result
|
|
1052
1206
|
}
|
|
1053
1207
|
|