@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.28",
3
+ "version": "0.7.30",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -568,18 +568,27 @@ class ClaudeStreamSession {
568
568
  }
569
569
  }
570
570
 
571
- /** モデルが Opus 4.6+ (effort / adaptive thinking 対応) かどうか。
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 typeof this.model === "string" && /claude-opus-4-[678]/.test(this.model)
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
- if (this._isEffortModel()) {
582
- // Opus: adaptive thinking を明示 ON にし、effort で深さを指定する。
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) のみ true。入力系 (claude.input)・制御系
275
- * (tmux.exec / permission / cancel→paste 等) WS 受信順 = pane 反映順を守るため false
276
- * (= 直列キューに残す)。1 件の tmux.exec ハングで pty 入出力まで止まるのを防ぐ。
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 type === "pty.data" || type === "pty.resize"
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
- boundSessionId(boundSessions, s.session_name),
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
- const boundId = boundSessionId(opts.boundSessions, s.name)
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
- source: "official",
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