@cocorograph/hub-agent 0.7.94 → 0.7.95

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.94",
3
+ "version": "0.7.95",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -333,6 +333,7 @@ export class CodexStreamBridge extends EventEmitter {
333
333
  }
334
334
  /** @type {Map<string, object>} stream_id -> session record */
335
335
  this.sessions = new Map()
336
+ this._accountRateLimitsByProfile = new Map()
336
337
  }
337
338
 
338
339
  /**
@@ -787,6 +788,27 @@ export class CodexStreamBridge extends EventEmitter {
787
788
  }
788
789
  }
789
790
 
791
+ /**
792
+ * アカウント単位の利用制限を取得する。thread を起動しないため、全セッション停止中でも
793
+ * Cockpit フッターを最新化できる。呼び出し側は認証済みプロファイルの場合だけ呼ぶ。
794
+ */
795
+ async readAccountRateLimits({ cwd, profileId = "default" } = {}) {
796
+ const client = this._clientFactory({ cwd: cwd || process.cwd(), codexCmd: this.codexCmd, logger: this.logger })
797
+ client.start()
798
+ try {
799
+ await client.request("initialize", { clientInfo: CLIENT_INFO })
800
+ const result = await client.request("account/rateLimits/read", {})
801
+ this._accountRateLimitsByProfile.set(profileId, result)
802
+ return result
803
+ } catch (err) {
804
+ const cached = this._accountRateLimitsByProfile.get(profileId)
805
+ if (cached) return cached
806
+ throw err
807
+ } finally {
808
+ client.stop()
809
+ }
810
+ }
811
+
790
812
  _wireClient(session) {
791
813
  const { client } = session
792
814
  client.on("notification", (msg) => this._onNotification(session, msg))
package/src/main.mjs CHANGED
@@ -118,6 +118,7 @@ import { SiblingInjectBridge } from "./sibling-inject.mjs"
118
118
  import {
119
119
  boundSessionId,
120
120
  codexContextPercentFromTokenUsage,
121
+ codexUsageFromAccountRateLimits,
121
122
  codexLiveContextForProducer,
122
123
  codexSessionUsages,
123
124
  codexTurnActiveForThread,
@@ -130,6 +131,8 @@ import {
130
131
  jsonlContextForCwd,
131
132
  recordChatRateLimit,
132
133
  recordChatUsageSnapshot,
134
+ readAccountUsageSnapshot,
135
+ recordAccountUsageSnapshot,
133
136
  recordCodexLiveTokenUsage,
134
137
  turnActiveForCwd,
135
138
  } from "./usage.mjs"
@@ -5632,6 +5635,10 @@ async function dispatch(msg, ctx) {
5632
5635
  }
5633
5636
  case "usage.request": {
5634
5637
  try {
5638
+ const activeClaudeProfile = await getActiveProfile()
5639
+ const claudeHasCredentials = isKeychainPlatform()
5640
+ ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
5641
+ : await hasFileCredentials(activeClaudeProfile.configDir)
5635
5642
  // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
5636
5643
  // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
5637
5644
  // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
@@ -5641,15 +5648,20 @@ async function dispatch(msg, ctx) {
5641
5648
  if (ctx.claudeBridge) {
5642
5649
  try {
5643
5650
  const acct = await ctx.claudeBridge.getAccountUsage()
5644
- if (acct) recordChatUsageSnapshot(acct)
5651
+ if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
5645
5652
  } catch (err) {
5646
5653
  ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
5647
5654
  }
5648
5655
  }
5649
- const [usage, sessions] = await Promise.all([
5650
- getUsage(),
5656
+ let [usage, sessions] = await Promise.all([
5657
+ getUsage({ profileId: activeClaudeProfile.id }),
5651
5658
  getSessionUsages(),
5652
5659
  ])
5660
+ // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
5661
+ if (!claudeHasCredentials) {
5662
+ usage = null
5663
+ sessions = []
5664
+ }
5653
5665
 
5654
5666
  // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
5655
5667
  // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
@@ -5658,7 +5670,9 @@ async function dispatch(msg, ctx) {
5658
5670
  // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
5659
5671
  let codexUsage = null
5660
5672
  let codexSessions = []
5661
- if (ctx.codexBridge && ctx.codexBridge.sessions.size > 0) {
5673
+ const activeCodexProfile = await codexProfiles.getActiveProfile()
5674
+ const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
5675
+ if (ctx.codexBridge && codexHasCredentials) {
5662
5676
  try {
5663
5677
  // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
5664
5678
  // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
@@ -5708,10 +5722,24 @@ async function dispatch(msg, ctx) {
5708
5722
  codexUsage = { ...codexUsage, context: null }
5709
5723
  }
5710
5724
  }
5725
+ // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
5726
+ // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
5727
+ const accountUsage = codexUsageFromAccountRateLimits(
5728
+ await ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
5729
+ )
5730
+ if (accountUsage) {
5731
+ codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
5732
+ recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
5733
+ }
5711
5734
  } catch (err) {
5712
5735
  ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
5713
5736
  }
5714
5737
  }
5738
+ // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
5739
+ // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
5740
+ if (codexHasCredentials && !codexUsage) {
5741
+ codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
5742
+ }
5715
5743
 
5716
5744
  ctx.client.send({
5717
5745
  type: "usage.response",
package/src/usage.mjs CHANGED
@@ -292,12 +292,26 @@ export function whenChatRateLimitsPersisted() {
292
292
  // (旧 chatRateLimits の設計的弱点)。専用ファイルなら誰にも上書きされない。
293
293
  // ---------------------------------------------------------------------------
294
294
 
295
- function chatUsageCachePath() {
296
- return configPath("HUB_CHAT_USAGE_CACHE", os.homedir(), ".hub", "usage", "chat-usage.json")
295
+ function profileCacheKey(profileId = "default") {
296
+ // Profile id は UI から作られるが、キャッシュパスに使う前に必ず無害な文字列へ
297
+ // 正規化する。プロフィール間の値が混ざるより、読めない cache を無視する方が安全。
298
+ const value = String(profileId || "default")
299
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "default"
300
+ }
301
+
302
+ function chatUsageCachePath(profileId = "default") {
303
+ const configured = process.env.HUB_CHAT_USAGE_CACHE
304
+ if (configured) {
305
+ // 既存の環境変数は default profile の互換パスとして維持する。追加 profile は
306
+ // 同じディレクトリ内に別ファイルで置き、最後に利用した別 profile を読まない。
307
+ if (profileId === "default") return configured
308
+ return path.join(path.dirname(configured), `chat-usage-${profileCacheKey(profileId)}.json`)
309
+ }
310
+ return path.join(os.homedir(), ".hub", "usage", "chat-usage", `${profileCacheKey(profileId)}.json`)
297
311
  }
298
312
 
299
313
  /** @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
314
+ let chatUsageSnapshots = new Map()
301
315
  let _chatUsagePersistInFlight = Promise.resolve()
302
316
 
303
317
  /** SDKControlGetUsageResponse.rate_limits.{five_hour,seven_day} の 1 窓を正規化する。 */
@@ -322,8 +336,9 @@ function normalizeRateLimitWindow(w) {
322
336
  *
323
337
  * @param {{rate_limits_available?: boolean, rate_limits?: {five_hour?: object|null, seven_day?: object|null}|null, subscription_type?: string|null}|null} usageResponse
324
338
  * @param {number} [now]
339
+ * @param {string} [profileId]
325
340
  */
326
- export function recordChatUsageSnapshot(usageResponse, now = Date.now()) {
341
+ export function recordChatUsageSnapshot(usageResponse, now = Date.now(), profileId = "default") {
327
342
  if (!usageResponse || typeof usageResponse !== "object") return
328
343
  if (usageResponse.rate_limits_available === false) return
329
344
  const rl = usageResponse.rate_limits
@@ -331,24 +346,25 @@ export function recordChatUsageSnapshot(usageResponse, now = Date.now()) {
331
346
  const five = normalizeRateLimitWindow(rl.five_hour)
332
347
  const seven = normalizeRateLimitWindow(rl.seven_day)
333
348
  if (!five && !seven) return
334
- chatUsageSnapshot = {
349
+ const previous = chatUsageSnapshots.get(profileId)
350
+ chatUsageSnapshots.set(profileId, {
335
351
  five_hour: five,
336
352
  seven_day: seven,
337
- plan: usageResponse.subscription_type || chatUsageSnapshot?.plan || null,
353
+ plan: usageResponse.subscription_type || previous?.plan || null,
338
354
  updatedAtMs: now,
339
- }
355
+ })
340
356
  // P5(bug) と同じ直列化パターン (recordChatRateLimit 参照): 並行 persist の tmp 破壊/
341
357
  // 中間状態読みを防ぐため、常に前回の persist 完了を待ってから次を実行する。
342
358
  _chatUsagePersistInFlight = _chatUsagePersistInFlight.then(
343
- () => persistChatUsageSnapshot(),
344
- () => persistChatUsageSnapshot(),
359
+ () => persistChatUsageSnapshot(profileId),
360
+ () => persistChatUsageSnapshot(profileId),
345
361
  )
346
362
  }
347
363
 
348
- async function persistChatUsageSnapshot() {
349
- const snapshot = chatUsageSnapshot
364
+ async function persistChatUsageSnapshot(profileId) {
365
+ const snapshot = chatUsageSnapshots.get(profileId)
350
366
  if (!snapshot) return
351
- const p = chatUsageCachePath()
367
+ const p = chatUsageCachePath(profileId)
352
368
  try {
353
369
  await fs.mkdir(path.dirname(p), { recursive: true })
354
370
  } catch {
@@ -373,13 +389,17 @@ export function whenChatUsageSnapshotPersisted() {
373
389
  }
374
390
 
375
391
  /** プロセス内メモリを優先し、無ければディスク永続化 (hub-agent 再起動直後等) を読む。 */
376
- async function readChatUsageSnapshot() {
377
- if (chatUsageSnapshot) return chatUsageSnapshot
378
- const text = await readOrNull(chatUsageCachePath())
392
+ async function readChatUsageSnapshot(profileId = "default") {
393
+ const inMemory = chatUsageSnapshots.get(profileId)
394
+ if (inMemory) return inMemory
395
+ const text = await readOrNull(chatUsageCachePath(profileId))
379
396
  if (!text) return null
380
397
  try {
381
398
  const j = JSON.parse(text)
382
- if (j && typeof j === "object" && typeof j.updatedAtMs === "number") return j
399
+ if (j && typeof j === "object" && typeof j.updatedAtMs === "number") {
400
+ chatUsageSnapshots.set(profileId, j)
401
+ return j
402
+ }
383
403
  } catch {
384
404
  /* ignore */
385
405
  }
@@ -388,7 +408,7 @@ async function readChatUsageSnapshot() {
388
408
 
389
409
  /** テスト用: chatUsageSnapshot をクリアする。 */
390
410
  export function _resetChatUsageSnapshot() {
391
- chatUsageSnapshot = null
411
+ chatUsageSnapshots = new Map()
392
412
  }
393
413
 
394
414
  async function readOfficial(now) {
@@ -1129,7 +1149,7 @@ export function _resetTurnActiveMemo() {
1129
1149
  * context%: statusLine 値が最新 jsonl より新しければ official を優先。
1130
1150
  * 古い / 無い (= チャットモードで statusLine 未更新) 場合は jsonl 推定で上書き。
1131
1151
  */
1132
- export async function getUsage() {
1152
+ export async function getUsage({ profileId = "default" } = {}) {
1133
1153
  const now = Date.now()
1134
1154
  const official = await readOfficial(now)
1135
1155
  const result = official || (await readEstimate(now))
@@ -1184,28 +1204,30 @@ export async function getUsage() {
1184
1204
  // chatRateLimits (rate_limit_event) より本命の情報源 (utilization 欠落問題が無い、
1185
1205
  // 2026-07 時点で実測確認済み)。statusLine / rate_limit_event のどちらよりも新しければ
1186
1206
  // 上書きする。5h/7d が実際に埋まったときだけ source を official に昇格させる。
1187
- const chatSnap = await readChatUsageSnapshot()
1207
+ const chatSnap = await readChatUsageSnapshot(profileId)
1188
1208
  if (chatSnap && chatSnap.updatedAtMs > 0) {
1189
- const baseline = Math.max(cacheMtime ?? 0, chatRateLimits.updatedAtMs || 0)
1190
- // `>=` (上と同じ理由): ファイル mtime とプロセス内タイムスタンプがタイになり得る。
1191
- // chatSnap は最も直接的な情報源 (SDK control request 実値) なのでタイ時は優先する。
1192
- if (chatSnap.updatedAtMs >= baseline) {
1193
- if (chatSnap.five_hour) {
1194
- result.last5h = {
1195
- ...result.last5h,
1196
- percent: chatSnap.five_hour.percent,
1197
- resetAtMs: chatSnap.five_hour.resetAtMs ?? result.last5h?.resetAtMs ?? null,
1198
- }
1209
+ // statusLine/latest.json と rate_limit_event は profile を持たない旧経路。別 profile
1210
+ // の更新時刻がより新しくても、profile 専用 SDK snapshot を負かしてはならない。
1211
+ if (chatSnap.five_hour) {
1212
+ result.last5h = {
1213
+ ...result.last5h,
1214
+ percent: chatSnap.five_hour.percent,
1215
+ resetAtMs: chatSnap.five_hour.resetAtMs ?? result.last5h?.resetAtMs ?? null,
1199
1216
  }
1200
- if (chatSnap.seven_day) {
1201
- result.last7d = {
1202
- ...result.last7d,
1203
- percent: chatSnap.seven_day.percent,
1204
- resetAtMs: chatSnap.seven_day.resetAtMs ?? result.last7d?.resetAtMs ?? null,
1205
- }
1217
+ }
1218
+ if (chatSnap.seven_day) {
1219
+ result.last7d = {
1220
+ ...result.last7d,
1221
+ percent: chatSnap.seven_day.percent,
1222
+ resetAtMs: chatSnap.seven_day.resetAtMs ?? result.last7d?.resetAtMs ?? null,
1206
1223
  }
1207
- if (chatSnap.plan) result.plan = chatSnap.plan
1208
- if (chatSnap.five_hour || chatSnap.seven_day) result.source = "official"
1224
+ }
1225
+ if (chatSnap.plan) result.plan = chatSnap.plan
1226
+ if (chatSnap.five_hour || chatSnap.seven_day) {
1227
+ result.source = "official"
1228
+ // スナップショットを再表示しただけでは「今取得した」ことにしない。フッターの
1229
+ // 最終更新時刻は実アカウント値を得た時刻を示す。
1230
+ result.computedAt = chatSnap.updatedAtMs
1209
1231
  }
1210
1232
  }
1211
1233
  return result
@@ -1594,6 +1616,99 @@ export function classifyCodexRateLimitWindows(snapshot) {
1594
1616
  return result
1595
1617
  }
1596
1618
 
1619
+ /** App Server のアカウント利用制限レスポンスを Cockpit wire 形式へ正規化する。 */
1620
+ export function codexUsageFromAccountRateLimits(response) {
1621
+ const rateLimits = response?.rateLimits
1622
+ if (!rateLimits || typeof rateLimits !== "object") return null
1623
+ const { last5h, last7d } = classifyCodexRateLimitWindows(rateLimits)
1624
+ return {
1625
+ plan: rateLimits.planType || "codex",
1626
+ computedAt: Date.now(),
1627
+ source: "official",
1628
+ context: null,
1629
+ last5h,
1630
+ last7d,
1631
+ }
1632
+ }
1633
+
1634
+ // Codex app-server の account/rateLimits/read はセッション不要だが、一時的な CLI 障害や
1635
+ // hub-agent 再起動直後には読めないことがある。最後に確認できた値を provider + profile
1636
+ // ごとに保存し、別アカウントの残量を表示しないようにする。
1637
+ function accountUsageCachePath(provider, profileId = "default") {
1638
+ const root = process.env.HUB_ACCOUNT_USAGE_CACHE_DIR || path.join(os.homedir(), ".hub", "usage", "accounts")
1639
+ return path.join(root, profileCacheKey(provider), `${profileCacheKey(profileId)}.json`)
1640
+ }
1641
+
1642
+ const accountUsageSnapshots = new Map()
1643
+ let _accountUsagePersistInFlight = Promise.resolve()
1644
+
1645
+ function accountUsageSnapshotKey(provider, profileId) {
1646
+ return `${profileCacheKey(provider)}:${profileCacheKey(profileId)}`
1647
+ }
1648
+
1649
+ function isUsageSnapshot(value) {
1650
+ return (
1651
+ value &&
1652
+ typeof value === "object" &&
1653
+ typeof value.computedAt === "number" &&
1654
+ typeof value.source === "string" &&
1655
+ value.last5h &&
1656
+ value.last7d
1657
+ )
1658
+ }
1659
+
1660
+ /** 最後に取得できたアカウント利用量を profile 単位で永続化する。 */
1661
+ export function recordAccountUsageSnapshot(provider, profileId, usage) {
1662
+ if (!isUsageSnapshot(usage)) return
1663
+ const key = accountUsageSnapshotKey(provider, profileId)
1664
+ accountUsageSnapshots.set(key, usage)
1665
+ _accountUsagePersistInFlight = _accountUsagePersistInFlight.then(
1666
+ () => persistAccountUsageSnapshot(provider, profileId, usage),
1667
+ () => persistAccountUsageSnapshot(provider, profileId, usage),
1668
+ )
1669
+ }
1670
+
1671
+ async function persistAccountUsageSnapshot(provider, profileId, usage) {
1672
+ const target = accountUsageCachePath(provider, profileId)
1673
+ try {
1674
+ await fs.mkdir(path.dirname(target), { recursive: true })
1675
+ const tmp = `${target}.tmp.${randomUUID()}`
1676
+ try {
1677
+ await fs.writeFile(tmp, JSON.stringify(usage))
1678
+ await fs.rename(tmp, target)
1679
+ } catch {
1680
+ await fs.unlink(tmp).catch(() => {})
1681
+ }
1682
+ } catch {
1683
+ // 表示用キャッシュの書込み失敗は利用セッションを妨げない。
1684
+ }
1685
+ }
1686
+
1687
+ /** メモリ優先、hub-agent 再起動後は provider/profile 専用のディスク値を返す。 */
1688
+ export async function readAccountUsageSnapshot(provider, profileId = "default") {
1689
+ const key = accountUsageSnapshotKey(provider, profileId)
1690
+ const inMemory = accountUsageSnapshots.get(key)
1691
+ if (inMemory) return inMemory
1692
+ const text = await readOrNull(accountUsageCachePath(provider, profileId))
1693
+ if (!text) return null
1694
+ try {
1695
+ const parsed = JSON.parse(text)
1696
+ if (!isUsageSnapshot(parsed)) return null
1697
+ accountUsageSnapshots.set(key, parsed)
1698
+ return parsed
1699
+ } catch {
1700
+ return null
1701
+ }
1702
+ }
1703
+
1704
+ export function whenAccountUsageSnapshotPersisted() {
1705
+ return _accountUsagePersistInFlight
1706
+ }
1707
+
1708
+ export function _resetAccountUsageSnapshots() {
1709
+ accountUsageSnapshots.clear()
1710
+ }
1711
+
1597
1712
  // ---------------------------------------------------------------------------
1598
1713
  // Codex context%(ドーナツ)の live source(T06407 増分2, 症状⑥ ドーナツ前後動の根治)
1599
1714
  //