@cocorograph/hub-agent 0.8.16 → 0.8.17

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.8.16",
3
+ "version": "0.8.17",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -26,8 +26,51 @@ import { createHash } from "node:crypto"
26
26
  import { execFile } from "node:child_process"
27
27
  import { promisify } from "node:util"
28
28
 
29
+ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
30
+ import { hasFileCredentials } from "./profiles.mjs"
31
+
29
32
  const execFileP = promisify(execFile)
30
33
 
34
+ /**
35
+ * 認証在否チェックの短期キャッシュ TTL (ms)。
36
+ *
37
+ * ログイン状態は分単位でしか変わらないのに、`usage.request` (Cockpit のウィンドウ数 ×
38
+ * 60 秒周期) が来るたびに `security` コマンドを 1〜3 回 spawn していた。config
39
+ * ディレクトリ単位で 30 秒だけ結果を共有し、同時要求は single-flight に畳む。
40
+ * プロファイル切替直後は呼び出し側が clearCredentialCheckCache() で明示的に捨てる。
41
+ */
42
+ export const CREDENTIAL_CHECK_TTL_MS = 30_000
43
+
44
+ /** @type {Map<string, AsyncTtlCache>} configDir -> Keychain 在否 */
45
+ const keychainCheckCache = new Map()
46
+ /** @type {Map<string, AsyncTtlCache>} configDir -> .credentials.json 在否 */
47
+ const fileCheckCache = new Map()
48
+
49
+ function cachedCheck(store, configDir, build) {
50
+ const key = String(configDir)
51
+ let cache = store.get(key)
52
+ if (!cache) {
53
+ cache = new AsyncTtlCache({ ttlMs: CREDENTIAL_CHECK_TTL_MS })
54
+ store.set(key, cache)
55
+ }
56
+ return cache.get(build)
57
+ }
58
+
59
+ /**
60
+ * 認証在否キャッシュを捨てる (プロファイル切替直後・テストの状態初期化用)。
61
+ * configDir 省略時は全件。
62
+ */
63
+ export function clearCredentialCheckCache(configDir) {
64
+ if (configDir === undefined) {
65
+ for (const cache of keychainCheckCache.values()) cache.clear()
66
+ for (const cache of fileCheckCache.values()) cache.clear()
67
+ return
68
+ }
69
+ const key = String(configDir)
70
+ keychainCheckCache.get(key)?.clear()
71
+ fileCheckCache.get(key)?.clear()
72
+ }
73
+
31
74
  /**
32
75
  * 既定 (プライマリ) プロファイルで CLAUDE_CONFIG_DIR が未設定のとき、Claude Code は
33
76
  * ハッシュ無しの素のサービス名 `Claude Code-credentials` に OAuth トークンを保存する
@@ -100,11 +143,30 @@ export async function existsGenericPassword(service, opts = {}) {
100
143
  * 含める (PLAIN_CREDENTIAL_SERVICE のコメント参照)。含めないと既定プロファイルが常に
101
144
  * 空振りして「要ログイン」が出っぱなしになる。
102
145
  *
146
+ * ⚠️ キャッシュは **opts 無しの呼び出しだけ**に効かせる。`opts.execFileP` 等で挙動を
147
+ * 差し替える呼び出し (テストの分岐検査) までキャッシュすると、実際の分岐を通らないまま
148
+ * 前回値を返してしまい検査が空振りする。
149
+ *
103
150
  * @param {string} configDir
104
151
  * @param {{execFileP?: Function}} [opts]
105
152
  * @returns {Promise<boolean>}
106
153
  */
107
154
  export async function profileHasKeychainCredentials(configDir, opts = {}) {
155
+ if (opts && Object.keys(opts).length > 0) {
156
+ return probeKeychainCredentials(configDir, opts)
157
+ }
158
+ return cachedCheck(keychainCheckCache, configDir, () =>
159
+ probeKeychainCredentials(configDir, {}),
160
+ )
161
+ }
162
+
163
+ /**
164
+ * Keychain 在否の実処理 (キャッシュを挟まない)。
165
+ * @param {string} configDir
166
+ * @param {{execFileP?: Function}} opts
167
+ * @returns {Promise<boolean>}
168
+ */
169
+ async function probeKeychainCredentials(configDir, opts) {
108
170
  const candidates = new Set([configDir])
109
171
  try {
110
172
  candidates.add(await fs.realpath(configDir))
@@ -139,3 +201,17 @@ export async function profileHasKeychainCredentials(configDir, opts = {}) {
139
201
  }
140
202
  return false
141
203
  }
204
+
205
+ /**
206
+ * Keychain の無いプラットフォーム向け在否判定 (profiles.mjs の hasFileCredentials) の
207
+ * キャッシュ版。TTL・クリア導線は Keychain 版と共通 (clearCredentialCheckCache)。
208
+ *
209
+ * ⚠️ 実処理は profiles.mjs 側が正本。ここは呼び出し頻度を落とすためのラッパで、
210
+ * 判定ロジックは持たない。
211
+ *
212
+ * @param {string} configDir
213
+ * @returns {Promise<boolean>}
214
+ */
215
+ export async function hasFileCredentialsCached(configDir) {
216
+ return cachedCheck(fileCheckCache, configDir, () => hasFileCredentials(configDir))
217
+ }
@@ -329,6 +329,7 @@ export class CodexStreamBridge extends EventEmitter {
329
329
  historyMaxItems,
330
330
  historyMaxBytes,
331
331
  listThreadsCacheTtlMs,
332
+ accountRateLimitsCacheTtlMs,
332
333
  } = {}) {
333
334
  super()
334
335
  this.codexCmd = codexCmd
@@ -369,6 +370,14 @@ export class CodexStreamBridge extends EventEmitter {
369
370
  // AsyncTtlCache が single-flight と、thread/start 中の世代切替をまとめて担う。
370
371
  this._listThreadsCacheTtlMs = listThreadsCacheTtlMs ?? 5_000
371
372
  this._listThreadsCache = new Map()
373
+ // アカウント利用制限 (5h/7d) は **アカウント全体の値** で、Cockpit のウィンドウ数だけ
374
+ // 別々に取りに行く必要がない。1 回の取得ごとに `codex app-server` を spawn →
375
+ // initialize → 停止しており、control レーンの実測占有 (2026-08-01: 92 秒中 73.3 秒) の
376
+ // 主因になっていた。profileId 単位で短期キャッシュし、TTL 内は app-server を
377
+ // **一切起動しない**。同時要求は AsyncTtlCache の single-flight で 1 本に畳む。
378
+ this._accountRateLimitsCacheTtlMs = accountRateLimitsCacheTtlMs ?? 45_000
379
+ /** @type {Map<string, AsyncTtlCache>} profileId -> cache */
380
+ this._accountRateLimitsCache = new Map()
372
381
  }
373
382
 
374
383
  /**
@@ -1037,6 +1046,25 @@ export class CodexStreamBridge extends EventEmitter {
1037
1046
  * Cockpit フッターを最新化できる。呼び出し側は認証済みプロファイルの場合だけ呼ぶ。
1038
1047
  */
1039
1048
  async readAccountRateLimits({ cwd, profileId = "default" } = {}) {
1049
+ let cache = this._accountRateLimitsCache.get(profileId)
1050
+ if (!cache) {
1051
+ cache = new AsyncTtlCache({ ttlMs: this._accountRateLimitsCacheTtlMs })
1052
+ this._accountRateLimitsCache.set(profileId, cache)
1053
+ }
1054
+ try {
1055
+ // ⚠️ 失敗を **キャッシュの外** で受けるのが要点。AsyncTtlCache は build 失敗を
1056
+ // キャッシュしないため、一時的な取得失敗が TTL 分だけ再試行を止めることがない。
1057
+ // 失敗時フォールバック (直近の成功値) は従来どおりここで効かせる。
1058
+ return await cache.get(() => this._fetchAccountRateLimits({ cwd, profileId }))
1059
+ } catch (err) {
1060
+ const cached = this._accountRateLimitsByProfile.get(profileId)
1061
+ if (cached) return cached
1062
+ throw err
1063
+ }
1064
+ }
1065
+
1066
+ /** app-server を 1 本 spawn して account/rateLimits/read を取る実処理 (キャッシュ miss 時のみ)。 */
1067
+ async _fetchAccountRateLimits({ cwd, profileId }) {
1040
1068
  const client = this._clientFactory({ cwd: cwd || process.cwd(), codexCmd: this.codexCmd, logger: this.logger })
1041
1069
  client.start()
1042
1070
  try {
@@ -1045,15 +1073,20 @@ export class CodexStreamBridge extends EventEmitter {
1045
1073
  const result = await client.request("account/rateLimits/read", {})
1046
1074
  this._accountRateLimitsByProfile.set(profileId, result)
1047
1075
  return result
1048
- } catch (err) {
1049
- const cached = this._accountRateLimitsByProfile.get(profileId)
1050
- if (cached) return cached
1051
- throw err
1052
1076
  } finally {
1053
1077
  client.stop()
1054
1078
  }
1055
1079
  }
1056
1080
 
1081
+ /** プロファイル切替等で「別アカウントの値を見せない」ために使う (省略時は全 profile)。 */
1082
+ clearAccountRateLimitsCache(profileId) {
1083
+ if (profileId === undefined) {
1084
+ for (const cache of this._accountRateLimitsCache.values()) cache.clear()
1085
+ return
1086
+ }
1087
+ this._accountRateLimitsCache.get(profileId)?.clear()
1088
+ }
1089
+
1057
1090
  _wireClient(session) {
1058
1091
  const { client } = session
1059
1092
  client.on("notification", (msg) => this._onNotification(session, msg))
package/src/main.mjs CHANGED
@@ -86,6 +86,8 @@ import {
86
86
  // 名前衝突を避けて namespace import する。
87
87
  import * as codexProfiles from "./codex-profiles.mjs"
88
88
  import {
89
+ clearCredentialCheckCache,
90
+ hasFileCredentialsCached,
89
91
  isKeychainPlatform,
90
92
  profileHasKeychainCredentials,
91
93
  } from "./claude-credentials.mjs"
@@ -3309,6 +3311,237 @@ export async function handleKillSession(msg, ctx) {
3309
3311
  }
3310
3312
  }
3311
3313
 
3314
+ /** usage.response の中身を共有する TTL (ms)。frontend の poll は既定 60 秒周期。 */
3315
+ export const USAGE_RESPONSE_TTL_MS = 4_000
3316
+
3317
+ /**
3318
+ * usage.response の **中身だけ** を共有する短期キャッシュ。
3319
+ *
3320
+ * usage.request が返すのはアカウント全体の 5h/7d 利用量で、どのウィンドウから聞いても
3321
+ * 同じ答えになる。にもかかわらず N 個のウィンドウが別々に取りに行き、1 回ごとに
3322
+ * `security` spawn ・SDK control RPC ・`codex app-server` の起動/停止を伴っていたため、
3323
+ * control レーンの実測占有 (2026-08-01: 92 秒中 73.3 秒) の主因になっていた。
3324
+ *
3325
+ * ⚠️ **request_id は絶対にここへ入れない**。キャッシュした payload に前の要求の
3326
+ * request_id が混ざると、別ウィンドウの要求に他人の応答が返る。送信時に各要求の
3327
+ * request_id を後から差し込む (handleUsageRequest 参照)。
3328
+ */
3329
+ export const usageResponseCache = new AsyncTtlCache({ ttlMs: USAGE_RESPONSE_TTL_MS })
3330
+
3331
+ /** 前回 build 以降に「キャッシュで返した」要求数 (= 畳めた重複の実測値)。 */
3332
+ let usageServedFromCacheSinceLastBuild = 0
3333
+
3334
+ /**
3335
+ * usage.response の中身 (usage / sessions / codexUsage / codexSessions) を組み立てる。
3336
+ * キャッシュ miss のときだけ実行される。
3337
+ *
3338
+ * 戻り値は `{ payload, timings }`。**payload はそのまま応答に展開する中身**、timings は
3339
+ * 内訳の所要時間で、ログにだけ使う (応答には出さない)。ログの発火は呼び出し側
3340
+ * (handleUsageRequest) が「実際に build したときだけ」行う。
3341
+ *
3342
+ * @returns {Promise<{payload: object, timings: object}>}
3343
+ */
3344
+ export async function buildUsagePayload(ctx) {
3345
+ const startedAt = Date.now()
3346
+ let credentialsMs = 0
3347
+ let accountUsageMs = 0
3348
+ let localUsageMs = 0
3349
+ let codexRateLimitsMs = 0
3350
+
3351
+ const credentialsStartedAt = Date.now()
3352
+ const activeClaudeProfile = await getActiveProfile()
3353
+ const claudeHasCredentials = isKeychainPlatform()
3354
+ ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
3355
+ : await hasFileCredentialsCached(activeClaudeProfile.configDir)
3356
+ credentialsMs = Date.now() - credentialsStartedAt
3357
+
3358
+ // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
3359
+ // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
3360
+ // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
3361
+ // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
3362
+ // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
3363
+ // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
3364
+ if (ctx.claudeBridge) {
3365
+ const accountUsageStartedAt = Date.now()
3366
+ try {
3367
+ // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
3368
+ // 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
3369
+ // control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
3370
+ // blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
3371
+ // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
3372
+ const acct = await withDeadline(
3373
+ ctx.claudeBridge.getAccountUsage(),
3374
+ ACCOUNT_USAGE_TIMEOUT_MS,
3375
+ () =>
3376
+ ctx.logger?.warn?.(
3377
+ { timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
3378
+ "account usage timed out (dispatch lane released)",
3379
+ ),
3380
+ )
3381
+ if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
3382
+ } catch (err) {
3383
+ ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
3384
+ }
3385
+ accountUsageMs = Date.now() - accountUsageStartedAt
3386
+ }
3387
+
3388
+ const localUsageStartedAt = Date.now()
3389
+ let [usage, sessions] = await Promise.all([
3390
+ getUsage({ profileId: activeClaudeProfile.id }),
3391
+ getSessionUsages(),
3392
+ ])
3393
+ localUsageMs = Date.now() - localUsageStartedAt
3394
+ // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
3395
+ if (!claudeHasCredentials) {
3396
+ usage = null
3397
+ sessions = []
3398
+ }
3399
+
3400
+ // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
3401
+ // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
3402
+ // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
3403
+ // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
3404
+ // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
3405
+ let codexUsage = null
3406
+ let codexSessions = []
3407
+ const activeCodexProfile = await codexProfiles.getActiveProfile()
3408
+ const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
3409
+ if (ctx.codexBridge && codexHasCredentials) {
3410
+ try {
3411
+ // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
3412
+ // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
3413
+ // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
3414
+ // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
3415
+ // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
3416
+ // producerSeq を context% の live source として通して両経路を収束させる。
3417
+ const cwds = new Set()
3418
+ for (const s of ctx.codexBridge.sessions.values()) {
3419
+ if (s.threadId && s.cwd) cwds.add(s.cwd)
3420
+ }
3421
+ const liveCodex = []
3422
+ for (const cwd of cwds) {
3423
+ const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
3424
+ if (authoritative?.threadId) {
3425
+ liveCodex.push({
3426
+ sessionId: authoritative.threadId,
3427
+ cwd: authoritative.cwd,
3428
+ threadId: authoritative.threadId,
3429
+ producerSeq: authoritative.seq,
3430
+ })
3431
+ }
3432
+ }
3433
+ if (liveCodex.length > 0) {
3434
+ // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
3435
+ // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
3436
+ codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
3437
+ producerSeq: liveCodex[0].producerSeq,
3438
+ })
3439
+ codexSessions = await codexSessionUsages(liveCodex)
3440
+ // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
3441
+ // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
3442
+ // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
3443
+ // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
3444
+ // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
3445
+ const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
3446
+ const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
3447
+ codexSessions = filterCodexSessionsByAuthority(
3448
+ codexSessions,
3449
+ producerByCwd,
3450
+ seqForCwd,
3451
+ )
3452
+ // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
3453
+ // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
3454
+ // (context だけ null 化。5h/7d は account-wide なので保持する)。
3455
+ if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
3456
+ codexUsage = { ...codexUsage, context: null }
3457
+ }
3458
+ }
3459
+ // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
3460
+ // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
3461
+ const codexRateLimitsStartedAt = Date.now()
3462
+ const rateLimits = await ctx.codexBridge.readAccountRateLimits({
3463
+ profileId: activeCodexProfile.id,
3464
+ })
3465
+ codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
3466
+ const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
3467
+ if (accountUsage) {
3468
+ codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
3469
+ recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
3470
+ }
3471
+ } catch (err) {
3472
+ ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
3473
+ }
3474
+ }
3475
+ // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
3476
+ // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
3477
+ if (codexHasCredentials && !codexUsage) {
3478
+ codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
3479
+ }
3480
+
3481
+ return {
3482
+ payload: { usage, sessions, codexUsage, codexSessions },
3483
+ timings: {
3484
+ credentials_ms: credentialsMs,
3485
+ account_usage_ms: accountUsageMs,
3486
+ local_usage_ms: localUsageMs,
3487
+ codex_ratelimits_ms: codexRateLimitsMs,
3488
+ total_ms: Date.now() - startedAt,
3489
+ },
3490
+ }
3491
+ }
3492
+
3493
+ /**
3494
+ * usage.request の処理。中身は TTL キャッシュ + single-flight で共有し、request_id
3495
+ * だけを要求ごとに差し込む。
3496
+ *
3497
+ * ⚠️ 送信オブジェクトは `...payload` を先に展開し **request_id を後から上書き** する。
3498
+ * 万一 payload 側に request_id が混入しても、他人の応答が返る事故にならないため。
3499
+ *
3500
+ * テストから直接呼べるよう export。中身の組み立ては deps.build で差し替え可能。
3501
+ */
3502
+ export async function handleUsageRequest(msg, ctx, deps = {}) {
3503
+ const build = deps.build || (() => buildUsagePayload(ctx))
3504
+ try {
3505
+ // build を実行したのが自分かどうか。single-flight で畳まれた caller の closure は
3506
+ // 呼ばれないため、そのまま「キャッシュで返した件数」の計測になる。
3507
+ let built = false
3508
+ const result = await usageResponseCache.get(() => {
3509
+ built = true
3510
+ return build()
3511
+ })
3512
+ if (built) {
3513
+ // 効果を数字で確認するための内訳。**build したときだけ** 出す (ヒット時は出さない。
3514
+ // ウィンドウ数 × poll で件数が多く、ログが埋まるため)。
3515
+ // served_from_cache_since_last_build が「畳めた重複」の実測値になる。
3516
+ ctx.logger?.info?.(
3517
+ {
3518
+ ...result.timings,
3519
+ served_from_cache_since_last_build: usageServedFromCacheSinceLastBuild,
3520
+ },
3521
+ "usage request timing",
3522
+ )
3523
+ usageServedFromCacheSinceLastBuild = 0
3524
+ } else {
3525
+ usageServedFromCacheSinceLastBuild += 1
3526
+ }
3527
+ ctx.client.send({
3528
+ type: "usage.response",
3529
+ ...result.payload,
3530
+ request_id: msg.request_id,
3531
+ })
3532
+ } catch (err) {
3533
+ ctx.client.send({
3534
+ type: "usage.response",
3535
+ usage: null,
3536
+ sessions: [],
3537
+ codexUsage: null,
3538
+ codexSessions: [],
3539
+ error: err.message,
3540
+ request_id: msg.request_id,
3541
+ })
3542
+ }
3543
+ }
3544
+
3312
3545
  async function dispatch(msg, ctx) {
3313
3546
  const t = msg?.type || ""
3314
3547
  try {
@@ -4357,6 +4590,10 @@ async function dispatch(msg, ctx) {
4357
4590
  // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
4358
4591
  const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
4359
4592
  tmuxSessionsSnapshotCache.clear()
4593
+ // アカウントが変わったので、旧アカウントの利用量・認証在否を TTL の残り時間だけ
4594
+ // 見せ続けないよう明示的に捨てる (直後の needs_login 判定も実測値になる)。
4595
+ usageResponseCache.clear()
4596
+ clearCredentialCheckCache()
4360
4597
  // 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
4361
4598
  const hasCreds = isKeychainPlatform()
4362
4599
  ? await profileHasKeychainCredentials(target.configDir)
@@ -4497,6 +4734,10 @@ async function dispatch(msg, ctx) {
4497
4734
  const before = ctx.codexBridge?.sessions?.size ?? 0
4498
4735
  ctx.codexBridge?.shutdown()
4499
4736
  tmuxSessionsSnapshotCache.clear()
4737
+ // Codex アカウントが変わったので、旧アカウントの 5h/7d を TTL の残り時間だけ
4738
+ // 見せ続けない (rate limits 自体は profileId 別キャッシュなので混ざらないが、
4739
+ // usage.response の中身は両アカウントを束ねているため明示的に捨てる)。
4740
+ usageResponseCache.clear()
4500
4741
  const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
4501
4742
  const needsLogin = !hasCreds
4502
4743
  const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
@@ -4963,145 +5204,7 @@ async function dispatch(msg, ctx) {
4963
5204
  return
4964
5205
  }
4965
5206
  case "usage.request": {
4966
- try {
4967
- const activeClaudeProfile = await getActiveProfile()
4968
- const claudeHasCredentials = isKeychainPlatform()
4969
- ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
4970
- : await hasFileCredentials(activeClaudeProfile.configDir)
4971
- // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
4972
- // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
4973
- // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
4974
- // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
4975
- // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
4976
- // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
4977
- if (ctx.claudeBridge) {
4978
- try {
4979
- // ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
4980
- // 返ってこないときに control レーン全体が止まる** (usage.request は 60 秒周期で
4981
- // control レーンに乗り、そこにセッション停止も並ぶ)。同種の head-of-line
4982
- // blocking で cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
4983
- // 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
4984
- const acct = await withDeadline(
4985
- ctx.claudeBridge.getAccountUsage(),
4986
- ACCOUNT_USAGE_TIMEOUT_MS,
4987
- () =>
4988
- ctx.logger?.warn?.(
4989
- { timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
4990
- "account usage timed out (dispatch lane released)",
4991
- ),
4992
- )
4993
- if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
4994
- } catch (err) {
4995
- ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
4996
- }
4997
- }
4998
- let [usage, sessions] = await Promise.all([
4999
- getUsage({ profileId: activeClaudeProfile.id }),
5000
- getSessionUsages(),
5001
- ])
5002
- // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
5003
- if (!claudeHasCredentials) {
5004
- usage = null
5005
- sessions = []
5006
- }
5007
-
5008
- // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
5009
- // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
5010
- // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
5011
- // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
5012
- // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
5013
- let codexUsage = null
5014
- let codexSessions = []
5015
- const activeCodexProfile = await codexProfiles.getActiveProfile()
5016
- const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
5017
- if (ctx.codexBridge && codexHasCredentials) {
5018
- try {
5019
- // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
5020
- // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
5021
- // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
5022
- // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
5023
- // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
5024
- // producerSeq を context% の live source として通して両経路を収束させる。
5025
- const cwds = new Set()
5026
- for (const s of ctx.codexBridge.sessions.values()) {
5027
- if (s.threadId && s.cwd) cwds.add(s.cwd)
5028
- }
5029
- const liveCodex = []
5030
- for (const cwd of cwds) {
5031
- const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
5032
- if (authoritative?.threadId) {
5033
- liveCodex.push({
5034
- sessionId: authoritative.threadId,
5035
- cwd: authoritative.cwd,
5036
- threadId: authoritative.threadId,
5037
- producerSeq: authoritative.seq,
5038
- })
5039
- }
5040
- }
5041
- if (liveCodex.length > 0) {
5042
- // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
5043
- // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
5044
- codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
5045
- producerSeq: liveCodex[0].producerSeq,
5046
- })
5047
- codexSessions = await codexSessionUsages(liveCodex)
5048
- // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
5049
- // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
5050
- // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
5051
- // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
5052
- // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
5053
- const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
5054
- const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
5055
- codexSessions = filterCodexSessionsByAuthority(
5056
- codexSessions,
5057
- producerByCwd,
5058
- seqForCwd,
5059
- )
5060
- // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
5061
- // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
5062
- // (context だけ null 化。5h/7d は account-wide なので保持する)。
5063
- if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
5064
- codexUsage = { ...codexUsage, context: null }
5065
- }
5066
- }
5067
- // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
5068
- // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
5069
- const accountUsage = codexUsageFromAccountRateLimits(
5070
- await ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
5071
- )
5072
- if (accountUsage) {
5073
- codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
5074
- recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
5075
- }
5076
- } catch (err) {
5077
- ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
5078
- }
5079
- }
5080
- // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
5081
- // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
5082
- if (codexHasCredentials && !codexUsage) {
5083
- codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
5084
- }
5085
-
5086
- ctx.client.send({
5087
- type: "usage.response",
5088
- request_id: msg.request_id,
5089
- usage,
5090
- sessions,
5091
- codexUsage,
5092
- codexSessions,
5093
- })
5094
- } catch (err) {
5095
- ctx.client.send({
5096
- type: "usage.response",
5097
- request_id: msg.request_id,
5098
- usage: null,
5099
- sessions: [],
5100
- codexUsage: null,
5101
- codexSessions: [],
5102
- error: err.message,
5103
- })
5104
- }
5207
+ await handleUsageRequest(msg, ctx)
5105
5208
  return
5106
5209
  }
5107
5210
  default: