@cocorograph/hub-agent 0.8.15 → 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.15",
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))
@@ -23,10 +23,44 @@ const LIFECYCLE_TYPES = new Set([
23
23
 
24
24
  const MAX_STREAM_LANE_KEYS = 4096
25
25
 
26
+ /**
27
+ * dispatch の所要時間ログを出す閾値 (ms)。
28
+ *
29
+ * ⚠️ これは計測のために足したもので、性能を変えるものではない。
30
+ * 「セッションの停止に 10 秒経っても応答が返らない」の中身が **一切観測できなかった**
31
+ * ため入れた (2026-07-31)。kill 実体は実測 0.00 秒・レーン内の tmux 処理も 0.1 秒未満
32
+ * なので、遅延の候補は「レーンの順番待ち」か「そもそも届いていない」に絞られるが、
33
+ * どちらなのかを判別する材料がログに 1 行も無かった。
34
+ *
35
+ * 全 dispatch を出すとログが膨れるので、閾値超えのものだけ出す。
36
+ */
37
+ const SLOW_DISPATCH_MS = 1000
38
+
39
+ /**
40
+ * 閾値に関係なく必ず所要時間を出す type。
41
+ *
42
+ * 停止 (kill) は低頻度なのでログ量の心配が無く、かつ「遅い / 返ってこない」の
43
+ * 調査対象そのものなので、速かった場合も含めて毎回記録する
44
+ * (速いことの記録が無いと「詰まっていない」を示せない)。
45
+ */
46
+ const ALWAYS_TIMED_TYPES = new Set(["tmux.kill_session"])
47
+
26
48
  export function isConnectionLifecycleMessage(type) {
27
49
  return LIFECYCLE_TYPES.has(type)
28
50
  }
29
51
 
52
+ /**
53
+ * 所要時間ログを出すべきか。テストから直接検査できるよう export する。
54
+ *
55
+ * @param {string} type メッセージ種別
56
+ * @param {number} waitMs レーンで順番待ちしていた時間
57
+ * @param {number} runMs ハンドラ本体の実行時間
58
+ */
59
+ export function shouldLogDispatchTiming(type, waitMs, runMs) {
60
+ if (ALWAYS_TIMED_TYPES.has(type)) return true
61
+ return waitMs >= SLOW_DISPATCH_MS || runMs >= SLOW_DISPATCH_MS
62
+ }
63
+
30
64
  function chatMode(type) {
31
65
  if (typeof type !== "string") return null
32
66
  if (type === "claude.attach" || type.startsWith("claude.")) return "claude"
@@ -179,6 +213,8 @@ export class DispatchScheduler {
179
213
  enqueue(msg) {
180
214
  const type = msg?.type
181
215
  const sequence = ++this.sequence
216
+ // 順番待ち時間の起点。bypass 経路は待たないので測らない。
217
+ const enqueuedAt = Date.now()
182
218
  if (this.shouldBypass(type) || isConnectionLifecycleMessage(type)) {
183
219
  return this._runNow(msg)
184
220
  }
@@ -217,7 +253,21 @@ export class DispatchScheduler {
217
253
  )
218
254
  return
219
255
  }
220
- await this.run(msg)
256
+ // レーンの順番待ち時間と、ハンドラ本体の実行時間を分けて測る。
257
+ // この 2 つを分けないと「遅いのは順番待ちか処理そのものか」が判別できない。
258
+ const startedAt = Date.now()
259
+ const waitMs = startedAt - enqueuedAt
260
+ try {
261
+ await this.run(msg)
262
+ } finally {
263
+ const runMs = Date.now() - startedAt
264
+ if (shouldLogDispatchTiming(type, waitMs, runMs)) {
265
+ this.logger?.info?.(
266
+ { type, lane, wait_ms: waitMs, run_ms: runMs },
267
+ "dispatch timing",
268
+ )
269
+ }
270
+ }
221
271
  })
222
272
  .catch((err) => {
223
273
  this.logger?.error?.(
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"
@@ -295,6 +297,36 @@ async function loadClaudeSdk(logger) {
295
297
  }
296
298
  }
297
299
 
300
+ /** アカウント利用量取得の時間制限 (ms)。超えたらレーンを解放して次へ進む。 */
301
+ export const ACCOUNT_USAGE_TIMEOUT_MS = 8_000
302
+
303
+ /**
304
+ * `promise` に時間制限を設ける。制限を超えたら `null` で解決し `onTimeout` を呼ぶ。
305
+ *
306
+ * ⚠️ **元の promise はキャンセルできない**ので、裏では走り続ける。ここでの目的は
307
+ * 「返ってこない処理に dispatch レーンを占有させ続けない」ことであり、処理そのものを
308
+ * 止めることではない。時間切れを黙って握りつぶさず呼び出し側でログに残すこと
309
+ * (時間切れの発生回数と頻度が、詰まりの実測値そのものになる)。
310
+ *
311
+ * テストから直接呼べるよう export する。
312
+ */
313
+ export function withDeadline(promise, timeoutMs, onTimeout) {
314
+ let timer
315
+ return Promise.race([
316
+ Promise.resolve(promise).finally(() => clearTimeout(timer)),
317
+ new Promise((resolve) => {
318
+ timer = setTimeout(() => {
319
+ try {
320
+ onTimeout?.()
321
+ } catch {
322
+ /* ログ失敗で本筋を止めない */
323
+ }
324
+ resolve(null)
325
+ }, timeoutMs)
326
+ }),
327
+ ])
328
+ }
329
+
298
330
  /**
299
331
  * B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
300
332
  * - pty 出力データ (pty.data) と resize (pty.resize): 高頻度・順序保証不要。
@@ -3242,13 +3274,33 @@ export async function handleKillSession(msg, ctx) {
3242
3274
  })
3243
3275
  return
3244
3276
  }
3277
+ // ⚠️ kill の受信・完了は必ずログに残す (2026-07-31 追加)。
3278
+ // 「停止に 10 秒経っても応答が返らない」の調査で、agent 側に kill の痕跡が
3279
+ // 1 行も無く、遅いのか・届いていないのか・返事が捨てられたのかを**判別できなかった**。
3280
+ // kill は低頻度なのでログ量の懸念は無い。速かった場合も記録する
3281
+ // (速いことの記録が無いと「agent は詰まっていない」を示せない)。
3282
+ ctx.logger?.info?.({ names, request_id: msg.request_id }, "tmux kill_session received")
3283
+ const killStartedAt = Date.now()
3245
3284
  const r = await killFn(names)
3246
- ctx.client.send({
3285
+ const killMs = Date.now() - killStartedAt
3286
+ const sent = ctx.client.send({
3247
3287
  type: "tmux.kill_session.result",
3248
3288
  request_id: msg.request_id,
3249
3289
  killed: r.killed,
3250
3290
  failed: r.failed,
3251
3291
  })
3292
+ // 送信できたか自体を残す。ws が閉じていると result は黙って捨てられるため、
3293
+ // 「kill は成功したのに要求元へ返っていない」を後から特定できるようにする。
3294
+ ctx.logger?.info?.(
3295
+ {
3296
+ request_id: msg.request_id,
3297
+ killed: r.killed.length,
3298
+ failed: r.failed.length,
3299
+ kill_ms: killMs,
3300
+ result_sent: sent === true,
3301
+ },
3302
+ "tmux kill_session done",
3303
+ )
3252
3304
  for (const name of r.killed) {
3253
3305
  try {
3254
3306
  ctx.client.send({ type: "session.gone", session_name: name })
@@ -3259,6 +3311,237 @@ export async function handleKillSession(msg, ctx) {
3259
3311
  }
3260
3312
  }
3261
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
+
3262
3545
  async function dispatch(msg, ctx) {
3263
3546
  const t = msg?.type || ""
3264
3547
  try {
@@ -4307,6 +4590,10 @@ async function dispatch(msg, ctx) {
4307
4590
  // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
4308
4591
  const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
4309
4592
  tmuxSessionsSnapshotCache.clear()
4593
+ // アカウントが変わったので、旧アカウントの利用量・認証在否を TTL の残り時間だけ
4594
+ // 見せ続けないよう明示的に捨てる (直後の needs_login 判定も実測値になる)。
4595
+ usageResponseCache.clear()
4596
+ clearCredentialCheckCache()
4310
4597
  // 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
4311
4598
  const hasCreds = isKeychainPlatform()
4312
4599
  ? await profileHasKeychainCredentials(target.configDir)
@@ -4447,6 +4734,10 @@ async function dispatch(msg, ctx) {
4447
4734
  const before = ctx.codexBridge?.sessions?.size ?? 0
4448
4735
  ctx.codexBridge?.shutdown()
4449
4736
  tmuxSessionsSnapshotCache.clear()
4737
+ // Codex アカウントが変わったので、旧アカウントの 5h/7d を TTL の残り時間だけ
4738
+ // 見せ続けない (rate limits 自体は profileId 別キャッシュなので混ざらないが、
4739
+ // usage.response の中身は両アカウントを束ねているため明示的に捨てる)。
4740
+ usageResponseCache.clear()
4450
4741
  const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
4451
4742
  const needsLogin = !hasCreds
4452
4743
  const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
@@ -4913,132 +5204,7 @@ async function dispatch(msg, ctx) {
4913
5204
  return
4914
5205
  }
4915
5206
  case "usage.request": {
4916
- try {
4917
- const activeClaudeProfile = await getActiveProfile()
4918
- const claudeHasCredentials = isKeychainPlatform()
4919
- ? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
4920
- : await hasFileCredentials(activeClaudeProfile.configDir)
4921
- // 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
4922
- // statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
4923
- // ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
4924
- // 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
4925
- // API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
4926
- // は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
4927
- if (ctx.claudeBridge) {
4928
- try {
4929
- const acct = await ctx.claudeBridge.getAccountUsage()
4930
- if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
4931
- } catch (err) {
4932
- ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
4933
- }
4934
- }
4935
- let [usage, sessions] = await Promise.all([
4936
- getUsage({ profileId: activeClaudeProfile.id }),
4937
- getSessionUsages(),
4938
- ])
4939
- // 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
4940
- if (!claudeHasCredentials) {
4941
- usage = null
4942
- sessions = []
4943
- }
4944
-
4945
- // Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
4946
- // 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
4947
- // の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
4948
- // 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
4949
- // として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
4950
- let codexUsage = null
4951
- let codexSessions = []
4952
- const activeCodexProfile = await codexProfiles.getActiveProfile()
4953
- const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
4954
- if (ctx.codexBridge && codexHasCredentials) {
4955
- try {
4956
- // T06407 増分2 (axis2): per-cwd の context% は **cwd ごとに権威 producer 1 件**へ
4957
- // 集約する。旧実装は全 live session を codexSessions へ流していたため、同一 cwd に
4958
- // 複数 thread があると frontend の usageByCwd (updatedAtMs 同値で first-wins) が
4959
- // 任意のスレッドを拾い、state loop (権威 producer) と別 thread を選んでドーナツが
4960
- // 前後した。liveSessionForCwd の同一規則で cwd → 権威 producer(session) を一意化し、
4961
- // producerSeq を context% の live source として通して両経路を収束させる。
4962
- const cwds = new Set()
4963
- for (const s of ctx.codexBridge.sessions.values()) {
4964
- if (s.threadId && s.cwd) cwds.add(s.cwd)
4965
- }
4966
- const liveCodex = []
4967
- for (const cwd of cwds) {
4968
- const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
4969
- if (authoritative?.threadId) {
4970
- liveCodex.push({
4971
- sessionId: authoritative.threadId,
4972
- cwd: authoritative.cwd,
4973
- threadId: authoritative.threadId,
4974
- producerSeq: authoritative.seq,
4975
- })
4976
- }
4977
- }
4978
- if (liveCodex.length > 0) {
4979
- // 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
4980
- // なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
4981
- codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
4982
- producerSeq: liveCodex[0].producerSeq,
4983
- })
4984
- codexSessions = await codexSessionUsages(liveCodex)
4985
- // ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
4986
- // 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
4987
- // 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
4988
- // codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
4989
- // live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
4990
- const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
4991
- const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
4992
- codexSessions = filterCodexSessionsByAuthority(
4993
- codexSessions,
4994
- producerByCwd,
4995
- seqForCwd,
4996
- )
4997
- // codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
4998
- // (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
4999
- // (context だけ null 化。5h/7d は account-wide なので保持する)。
5000
- if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
5001
- codexUsage = { ...codexUsage, context: null }
5002
- }
5003
- }
5004
- // ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
5005
- // rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
5006
- const accountUsage = codexUsageFromAccountRateLimits(
5007
- await ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
5008
- )
5009
- if (accountUsage) {
5010
- codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
5011
- recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
5012
- }
5013
- } catch (err) {
5014
- ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
5015
- }
5016
- }
5017
- // Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
5018
- // 表示する。未ログインならここへ到達せず、表示も完全に隠す。
5019
- if (codexHasCredentials && !codexUsage) {
5020
- codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
5021
- }
5022
-
5023
- ctx.client.send({
5024
- type: "usage.response",
5025
- request_id: msg.request_id,
5026
- usage,
5027
- sessions,
5028
- codexUsage,
5029
- codexSessions,
5030
- })
5031
- } catch (err) {
5032
- ctx.client.send({
5033
- type: "usage.response",
5034
- request_id: msg.request_id,
5035
- usage: null,
5036
- sessions: [],
5037
- codexUsage: null,
5038
- codexSessions: [],
5039
- error: err.message,
5040
- })
5041
- }
5207
+ await handleUsageRequest(msg, ctx)
5042
5208
  return
5043
5209
  }
5044
5210
  default: