@cocorograph/hub-agent 0.7.83 → 0.7.85

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.83",
3
+ "version": "0.7.85",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -65,22 +65,24 @@ const CHAT_RESIDENT_ENABLED = process.env.HUB_AGENT_CHAT_RESIDENT !== "0"
65
65
  * 1 セッション=1 query で system/init を初回 1 回のみにする。VS Code 拡張 / Claude Web
66
66
  * と同じ挙動)。query 起動時に options.resume で過去文脈を引き継ぐ。
67
67
  *
68
- * ⚠️ デフォルト無効化 (2026-06-02): 「眠っていたセッションを起こすと
69
- * 『Continue from where you left off.』→『No response requested.』や /clear の
70
- * local-command-caveat が合成ユーザーバブルとして表示され、処理中になり、実メッセージが
71
- * キュー待ちになる」回帰が確認されたため既定 OFF に戻す。
68
+ * ✅ 既定 ON へ復帰 (2026-07-21, T06118)。旧既定 OFF の理由だった「resume + 空 InputQueue」
69
+ * 窓 (下記) を **push 先行起動** (_startResidentTurnWithInput) で塞いだため、resumed セッション
70
+ * も常駐化して安全になった。既定 ON にする狙いは **背景エージェントの信頼性回復**:
71
+ * per-message (resume=1query) だとターン結果が返ると query プロセスが畳まれ、run_in_background
72
+ * で起動した子エージェントが親ターンより長生きして「previous Claude Code process exited」で
73
+ * 孤児化・停止していた (実害)。常駐化すれば query プロセスがターンをまたいで生存する。
72
74
  *
73
- * 根本原因: resident 経路 (sendMessage) は **空の InputQueue のまま query を起動してから
74
- * push** する設計のため、「resume + 入力がまだ空」の窓で SDK が中断/未完了セッションの
75
- * 自動継続ターン (Continue from where you left off) を差し込む。per-message 経路は
76
- * InputQueue に push→即 close してから起動するためこの窓が無く、自動継続は起きない
77
- * (0.6.4 で暴走を潰した設計)。よって resumed セッションは per-message 経路に戻す。
75
+ * 旧回帰の根本原因 (履歴): resident 経路 (sendMessage) が **空の InputQueue のまま query を
76
+ * 起動してから push** していたため、「resume + 入力がまだ空」の窓で SDK が中断/未完了
77
+ * セッションの自動継続ターン (Continue from where you left off) を差し込んでいた。per-message
78
+ * 経路は InputQueue に push→即 close してから起動するためこの窓が無い (0.6.4 で暴走を潰した
79
+ * 設計)。今回 resident の 3 起動サイトも「push してから起動」に揃え、初回ターンの ultracode は
80
+ * 起動時 options.settings で焼き込む (applyFlagSettings のための空窓も作らない)。
78
81
  *
79
- * env HUB_AGENT_CHAT_RESIDENT_RESUME="1" で個別に再有効化できる (空キュー窓を塞ぐ
80
- * 根治を入れたら既定 ON へ戻す候補)。新規セッションの常駐化は CHAT_RESIDENT_ENABLED 側で
81
- * 独立制御 (新規は resume しないため自動継続の窓が無く、既定 ON のまま安全)。 */
82
+ * env HUB_AGENT_CHAT_RESIDENT_RESUME="0" で個別に無効化 (従来の per-message へロールバック)
83
+ * できる。新規セッションの常駐化は CHAT_RESIDENT_ENABLED 側で独立制御。 */
82
84
  const CHAT_RESIDENT_RESUME_ENABLED =
83
- process.env.HUB_AGENT_CHAT_RESIDENT_RESUME === "1"
85
+ process.env.HUB_AGENT_CHAT_RESIDENT_RESUME !== "0"
84
86
 
85
87
  /** MCP control 専用 query のアイドル破棄時間 (2026-06-03)。MCP サーバーは Claude Code
86
88
  * 設定 (アカウント単位) で全セッション共有のため、チャットセッションとは独立した
@@ -435,6 +437,20 @@ class ClaudeStreamSession {
435
437
  * false に戻す。 */
436
438
  this._ultracodeCurrent = false
437
439
 
440
+ /** 空 InputQueue 窓封鎖 (T06118): fresh start する resume 常駐 query の「初回ターンの
441
+ * ultracode 目標」。_startResidentTurnWithInput が push 先行で積むメッセージと一緒に
442
+ * セットし、_runResidentQuery が起動時 options.settings へ焼き込んで消費する
443
+ * (起動前 applyFlagSettings のための空 InputQueue 窓を作らないため)。 */
444
+ this._pendingInitialUltracode = false
445
+
446
+ /** ABA/FIFO 直列化 (T06118 codex-review major): 起動済み query 経路の sendMessage が
447
+ * reconcile (applyFlagSettings) を await している間 true。この間に旧 query の generator が
448
+ * 自然終了しても、_runResidentQuery の finally は pending を drain して新 query を先行起動
449
+ * してはならない (in-flight のこの送信が後着 pending に追い越されて FIFO が逆転するため)。
450
+ * finally は query を畳むだけに留め、await 復帰後の送信が自分のメッセージを push 先行で
451
+ * fresh-start する。pending はその turn settle 後に FIFO で drain される。 */
452
+ this._residentSendInFlight = false
453
+
438
454
  /** @type {Map<string, {resolve: (decision: object) => void, tool_name: string, input: object}>}
439
455
  * permission 応答待ち。tool_name / input も保持し、reattach 時に未応答分を
440
456
  * onPermission で再送 (replay) して「裏で動いていたセッションを開いたら確認待ち
@@ -915,15 +931,6 @@ class ClaudeStreamSession {
915
931
 
916
932
  // 改修2+4: 常駐query対象セッション。
917
933
  if (this._residentEligible) {
918
- // B6: 死亡後の (再)起動経路。既に query を起動した実績があり (_residentStarted)、かつ
919
- // 現在 query が無い (_residentQuery=null = 異常終了済み) 場合、既存 _inputQueue は前 query で
920
- // consume し切った (generator return 済み) インスタンス。同じものを次の sdk.query に渡すと
921
- // _q/_wake の残留が新 generator と競合し得るため、push 前に必ず作り直す。
922
- if (this._residentStarted && !this._residentQuery) {
923
- this._inputQueue = new InputQueue()
924
- } else if (!this._inputQueue) {
925
- this._inputQueue = new InputQueue()
926
- }
927
934
  // 改修4 (A): ターンのシリアライズ。実行中ターン (busy) は InputQueue へ即 push せず
928
935
  // pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
929
936
  // 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
@@ -937,31 +944,29 @@ class ClaudeStreamSession {
937
944
  this._emitQueueState()
938
945
  return { accepted: true }
939
946
  }
947
+ if (this._closed) return { accepted: false, reason: "queue_dead" }
940
948
  this._beginResidentTurn()
941
- // 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
942
- // なら (再)起動する。_runResidentQuery は起動時 options.resume=this.sessionId で文脈を
943
- // 復元するため、途中死からの復活でも過去コンテキストは失われない。
944
- // ultracode のとき: query を先に起動 (空 InputQueue なので入力待ちでブロック) し、
945
- // applyFlagSettings を await してから push することで、設定適用前にターンが
946
- // 消費されるレースを防ぐ。
947
- if (!this._residentQuery) {
948
- this._residentStarted = true
949
- this._startResidentQuery()
950
- }
951
- await this._reconcileResidentUltracode(ultracode)
952
- // A-2 修正 (2026-07-05): 上の await 中に常駐 query の generator が自然終了している
953
- // ことがある (_residentQueryReturned が finally で true になる)。その場合 this._residentQuery
954
- // はまだ非 null (finally 未実行) に見えても、実体は既に誰も consume しない返却済み
955
- // generator であり、そのまま push すると消失する。fresh-start と同じ手順 (InputQueue の
956
- // 再生成 + 再起動) を踏んでから push することで、既存の resident 復活ガード
957
- // (B6 / 異常終了時の自動再起動) に合流させる。
958
- if (this._residentQueryReturned || !this._residentQuery) {
959
- this._inputQueue = new InputQueue()
960
- this._residentStarted = true
961
- this._startResidentQuery()
949
+ // 空 InputQueue 窓封鎖 (T06118): fresh start。常駐 query が未起動 (初回) / 異常終了済み
950
+ // (_residentQuery=null = 死亡ガード B6) / generator 返却済み (_residentQueryReturned) の
951
+ // いずれも「新しい query を起動する」ケース。従来は空の InputQueue で起動してから push
952
+ // していたため「resume + 空入力」の窓で SDK が自動継続ターン (Continue from where you
953
+ // left off) を差し込む回帰があった。実メッセージを積んでから起動する push 先行
954
+ // (_startResidentTurnWithInput、per-message と同じ) に統一してこの窓を無くす。
955
+ // 初回ターンの ultracode は起動時 options.settings に焼き込むため、起動前 reconcile
956
+ // (applyFlagSettings) のための空窓も不要。
957
+ if (!this._residentQuery || this._residentQueryReturned) {
958
+ this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
959
+ return { accepted: true }
962
960
  }
963
- if (this._closed) return { accepted: false, reason: "queue_dead" }
964
- this._inputQueue.push(toSDKUserMessage(prompt))
961
+ // 起動済みの常駐 query へ次ターンを流す。init 済みで空窓が無いため、ultracode は走行中
962
+ // query への applyFlagSettings (reconcile) で適用してから push する。reconcile await 中の
963
+ // 旧 query 終了 × 後着 pending の ABA/FIFO 逆転は _reconcileThenDeliver が直列化する
964
+ // (drain 経路 _drainResidentPending と共通)。
965
+ const outcome = await this._reconcileThenDeliver(
966
+ toSDKUserMessage(prompt),
967
+ ultracode,
968
+ )
969
+ if (outcome === "closed") return { accepted: false, reason: "queue_dead" }
965
970
  return { accepted: true }
966
971
  }
967
972
 
@@ -1244,6 +1249,24 @@ class ClaudeStreamSession {
1244
1249
  }
1245
1250
  }
1246
1251
 
1252
+ /** 空 InputQueue 窓封鎖 (T06118): fresh start する resume 常駐 query を「入力を先に積んで
1253
+ * から起動」する (push 先行)。fresh な InputQueue を作り、実ユーザーメッセージを push して
1254
+ * から query を起動するため、「resume + 空入力」の窓で SDK が自動継続ターン (Continue from
1255
+ * where you left off) を差し込む余地が無い (per-message の push→close→起動と同じ原理)。
1256
+ * 初回ターンの ultracode は options.settings 経由で焼き込む (_runResidentQuery が
1257
+ * _pendingInitialUltracode を読む) ため、起動前 applyFlagSettings のための空窓も作らない。
1258
+ * ⚠️ _inputQueue は必ず作り直す (B6): 前 query で consume し切った (generator return 済み)
1259
+ * インスタンスを次の sdk.query に渡すと _q/_wake の残留が新 generator と競合し得るため。
1260
+ * @param {object} sdkUserMessage toSDKUserMessage() 済みの初回ユーザーメッセージ
1261
+ * @param {boolean} initialUltracode 初回ターンの ultracode 目標 */
1262
+ _startResidentTurnWithInput(sdkUserMessage, initialUltracode) {
1263
+ this._inputQueue = new InputQueue()
1264
+ this._inputQueue.push(sdkUserMessage)
1265
+ this._pendingInitialUltracode = initialUltracode === true
1266
+ this._residentStarted = true
1267
+ this._startResidentQuery()
1268
+ }
1269
+
1247
1270
  /** 改修4 (A): 常駐 query を resume 付きで (再)起動する。_runResidentQuery 起動時に
1248
1271
  * options.resume=this.sessionId で過去文脈を復元するため、途中死からの復活でも安全。 */
1249
1272
  _startResidentQuery() {
@@ -1282,6 +1305,45 @@ class ClaudeStreamSession {
1282
1305
  }
1283
1306
  }
1284
1307
 
1308
+ /** ABA/FIFO 直列化の共通デリバリ (T06118 codex-review major×2)。起動済みの走行中 query へ
1309
+ * 「ultracode を reconcile してから 1 メッセージを届ける」処理を、直接送信経路 (sendMessage) と
1310
+ * drain 経路 (_drainResidentPending) の両方から使う共通化ポイント。reconcile は control request
1311
+ * の往復で await するため、その窓の間に旧 query の generator が自然終了しうる (finally が走る)。
1312
+ * 所有権ガード (_residentSendInFlight) で await 中の finally に pending drain を見送らせ、await
1313
+ * 前後の query ハンドル比較 (ABA 検出) で交代/終了を捕捉して push 先行 fresh-start へ切り替える。
1314
+ *
1315
+ * 前提: 呼び出し側が既に _beginResidentTurn() 済みで、呼び出し時点の _residentQuery が走行中
1316
+ * (非 null・未返却) であること。
1317
+ * @param {object} sdkMsg toSDKUserMessage() 済みメッセージ
1318
+ * @param {boolean} ultracode このターンの ultracode 目標
1319
+ * @returns {Promise<"pushed"|"restarted"|"closed">} 届け方の結果
1320
+ */
1321
+ async _reconcileThenDeliver(sdkMsg, ultracode) {
1322
+ const activeQuery = this._residentQuery
1323
+ this._residentSendInFlight = true
1324
+ try {
1325
+ await this._reconcileResidentUltracode(ultracode)
1326
+ } finally {
1327
+ this._residentSendInFlight = false
1328
+ }
1329
+ if (this._closed) return "closed"
1330
+ if (
1331
+ this._residentQueryReturned ||
1332
+ !this._residentQuery ||
1333
+ this._residentQuery !== activeQuery
1334
+ ) {
1335
+ // ABA 検出: await 中に query が終了/交代した。finally は _residentSendInFlight を見て
1336
+ // pending drain を見送っているので、自分のメッセージを push 先行で fresh-start する
1337
+ // (pending はこの turn settle 後に _drainResidentPending が FIFO で流す)。await 中の
1338
+ // finally で _busy が false へ落ちているため、_beginResidentTurn で張り直してから起動する。
1339
+ this._beginResidentTurn()
1340
+ this._startResidentTurnWithInput(sdkMsg, ultracode)
1341
+ return "restarted"
1342
+ }
1343
+ this._inputQueue.push(sdkMsg)
1344
+ return "pushed"
1345
+ }
1346
+
1285
1347
  /** SDK-P0-3 (2026-07-10): resident の「ターン開始」を 1 箇所に集約する。
1286
1348
  * _busy を立てるのと同時に、そのターンの終端フラグ (_turnSettled) と進行フラグ
1287
1349
  * (_turnProgressed) を必ずリセットする。ターン開始は 3 箇所ある
@@ -1368,10 +1430,18 @@ class ClaudeStreamSession {
1368
1430
  // 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
1369
1431
  this._beginResidentTurn()
1370
1432
  try {
1371
- // ultracode reconcile (0.6.28) も await で例外を投げうるので try の中に含める。
1372
- await this._reconcileResidentUltracode(next.ultracode === true)
1373
- this._inputQueue.push(toSDKUserMessage(next.text))
1374
- this._emitQueueState([next.text])
1433
+ // ABA/FIFO 直列化 (T06118 codex-review major×2): drain も reconcile を await するため、
1434
+ // その窓で旧 query が終了して finally が「その先の pending」を新 query へ先行投入すると、
1435
+ // 今 drain 中の next が追い越されて FIFO が逆転する。直接送信経路と同じ _reconcileThenDeliver
1436
+ // を通し、_residentSendInFlight による finally の drain 見送り + ABA 検出で next を先に届ける。
1437
+ const outcome = await this._reconcileThenDeliver(
1438
+ toSDKUserMessage(next.text),
1439
+ next.ultracode === true,
1440
+ )
1441
+ // codex-review minor: session が close 中 (outcome==="closed") は next を配送していない。
1442
+ // started 通知 (queue_state に [next.text] を載せる) を出すと未配送メッセージが「開始済み」に
1443
+ // 見えるため抑止する。closed は終端状態なので pending へ戻さず破棄でよい (session 撤去中)。
1444
+ if (outcome !== "closed") this._emitQueueState([next.text])
1375
1445
  } catch (err) {
1376
1446
  this._busy = false
1377
1447
  this._pendingMessages.unshift(next)
@@ -1421,6 +1491,20 @@ class ClaudeStreamSession {
1421
1491
  // 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
1422
1492
  // query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
1423
1493
  if (this.sessionId) options.resume = this.sessionId
1494
+ // 空 InputQueue 窓封鎖 (T06118): fresh start の初回ターンが ultracode を要求していれば、
1495
+ // per-message と同じく起動時 options.settings に焼き込む。こうすることで「query を空 queue
1496
+ // で先に起動 → applyFlagSettings を await → push」という空窓経路を踏まずに済む。初回だけ
1497
+ // 焼き込み、_ultracodeCurrent を true に揃えて次ターンの reconcile が正しく差分判定できる
1498
+ // ようにする。読み取り後は消費 (次の (再)起動へ持ち越さない)。
1499
+ if (this._pendingInitialUltracode === true) {
1500
+ options.settings = {
1501
+ ...(options.settings || {}),
1502
+ ultracode: true,
1503
+ enableWorkflows: true,
1504
+ }
1505
+ this._ultracodeCurrent = true
1506
+ }
1507
+ this._pendingInitialUltracode = false
1424
1508
  const denyPending = (reason) => this._denyAllPendingPermissions(reason)
1425
1509
  try {
1426
1510
  const q = this.sdk.query({ prompt: this._inputQueue, options })
@@ -1514,27 +1598,31 @@ class ClaudeStreamSession {
1514
1598
  } finally {
1515
1599
  this.onReap?.()
1516
1600
  }
1517
- } else if (!this._closed && this._pendingMessages.length > 0) {
1601
+ } else if (
1602
+ !this._closed &&
1603
+ this._pendingMessages.length > 0 &&
1604
+ !this._residentSendInFlight
1605
+ ) {
1518
1606
  // 改修4 (A): 異常終了 (close 以外) で pending が残っていれば resume 付きで再起動し
1519
1607
  // 取りこぼしを防ぐ。_runResidentQuery が options.resume=this.sessionId で文脈を復元する。
1520
- // B6: 既存 _inputQueue の generator は既に return 済み (この query で消費し切った)。
1521
- // 同じインスタンスを次の sdk.query に再利用すると _q/_wake の残留状態が新しい
1522
- // generator と競合し得るため、再起動前に必ず作り直す。close() は this._inputQueue を
1523
- // close() するだけで参照を持ち越さないので、ここで差し替えても整合する。
1524
- this._inputQueue = new InputQueue()
1608
+ // 空 InputQueue 窓封鎖 (T06118): ここも fresh start なので push 先行で起動する
1609
+ // (_startResidentTurnWithInput が fresh な InputQueue を作り、実メッセージを積んでから
1610
+ // 起動する)。従来は「空 queue で起動 → reconcile await → push」で resume + 空入力の
1611
+ // 自動継続窓があった。異常再起動対象メッセージの ultracode は options.settings 経由で
1612
+ // 焼き込むため、フラグ取りこぼし (0.6.28 由来) も起きない。
1613
+ //
1614
+ // ABA/FIFO 直列化 (T06118 codex-review major): _residentSendInFlight の間 (起動済み
1615
+ // query 経路の sendMessage が reconcile を await 中) は、ここで pending を drain して
1616
+ // 新 query を先行起動してはならない。in-flight の送信が後着 pending に追い越されて FIFO が
1617
+ // 逆転するため。この場合は query を畳むだけに留め (上で _residentQuery=null 済み)、
1618
+ // await 復帰後の送信が自分のメッセージを push 先行で fresh-start する。pending はその
1619
+ // turn settle 後に _drainResidentPending が FIFO で流す。
1525
1620
  const next = this._pendingMessages.shift()
1526
1621
  this._beginResidentTurn()
1527
- this._residentStarted = true
1528
- // ultracode (0.6.29): query を先に起動して _residentQuery を確定させてから
1529
- // reconcile → push する (sendMessage の通常経路と同順)。push してから起動すると
1530
- // applyFlagSettings を発行する先 (_residentQuery) がまだ無く、異常再起動の対象
1531
- // メッセージだけ ultracode フラグが落ちる (0.6.28 由来の取りこぼし) ため、ここで揃える。
1532
- // _startResidentQuery → _runResidentQuery は最初の await (for await) より前に
1533
- // 同期で _residentQuery と _ultracodeCurrent=false を確定するので、直後の reconcile が
1534
- // 正しく差分判定できる。_reconcileResidentUltracode は内部で例外を握るため throw しない。
1535
- this._startResidentQuery()
1536
- await this._reconcileResidentUltracode(next.ultracode === true)
1537
- this._inputQueue.push(toSDKUserMessage(next.text))
1622
+ this._startResidentTurnWithInput(
1623
+ toSDKUserMessage(next.text),
1624
+ next.ultracode === true,
1625
+ )
1538
1626
  this._emitQueueState([next.text])
1539
1627
  }
1540
1628
  // マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
package/src/usage.mjs CHANGED
@@ -1483,6 +1483,117 @@ export function _resetCodexTokenCountMemo() {
1483
1483
  _codexTokenCountMemo.clear()
1484
1484
  }
1485
1485
 
1486
+ /**
1487
+ * Codex rate limit の窓分類定数 (T06407)。5h 窓 = 300 分、7d 窓 = 10080 分。
1488
+ * ⚠️ 位置 (primary/secondary) は 5h/7d を保証しない。実測 (0.144.x / pro プラン) では
1489
+ * primary が windowDurationMins=10080 (=7日)・secondary=null を返す。よって窓の分類は
1490
+ * **duration で行う** (症状⑦「7d が進まない」の根治)。閾値は 5h と 7d の中点 (~5190 分)。
1491
+ */
1492
+ const CODEX_5H_WINDOW_MINS = 300
1493
+ const CODEX_7D_WINDOW_MINS = 10080
1494
+ /** 分類スロットの正準窓長。窓は「最も近い正準窓」のスロットへ割り当てる(中点固定分割だと
1495
+ * 将来 1h+5h のような同帯 2 窓で実 5h を取りこぼす。codex-review minor 対応)。 */
1496
+ const CODEX_RATE_LIMIT_SLOTS = [
1497
+ { key: "last5h", canonicalMins: CODEX_5H_WINDOW_MINS },
1498
+ { key: "last7d", canonicalMins: CODEX_7D_WINDOW_MINS },
1499
+ ]
1500
+
1501
+ /** RateLimitWindow (App Server = camelCase / rollout jsonl = snake_case の両対応) から
1502
+ * used% を読む。値が無ければ null (= その窓は「未提供」)。 */
1503
+ function codexWindowUsedPercent(w) {
1504
+ if (!w) return null
1505
+ if (typeof w.usedPercent === "number") return w.usedPercent
1506
+ if (typeof w.used_percent === "number") return w.used_percent
1507
+ return null
1508
+ }
1509
+
1510
+ /** RateLimitWindow の窓長 (分)。camelCase/snake_case 両対応。不明なら null。 */
1511
+ function codexWindowMins(w) {
1512
+ if (!w) return null
1513
+ if (typeof w.windowDurationMins === "number") return w.windowDurationMins
1514
+ if (typeof w.window_minutes === "number") return w.window_minutes
1515
+ return null
1516
+ }
1517
+
1518
+ /** RateLimitWindow の resets 時刻 (ms)。camelCase/snake_case 両対応。秒→ms 補正あり。 */
1519
+ function codexWindowResetMs(w) {
1520
+ if (!w) return null
1521
+ const raw =
1522
+ typeof w.resetsAt === "number"
1523
+ ? w.resetsAt
1524
+ : typeof w.resets_at === "number"
1525
+ ? w.resets_at
1526
+ : null
1527
+ if (typeof raw !== "number") return null
1528
+ return raw < 1e12 ? raw * 1000 : raw
1529
+ }
1530
+
1531
+ /** 「未提供」bucket。欠落窓を 0% と誤表示せず、frontend が「未提供」と描画できるよう
1532
+ * provided:false / percent:null を返す (fail-soft で沈黙させない)。 */
1533
+ function codexNotProvidedBucket() {
1534
+ return {
1535
+ tokens: 0,
1536
+ messages: 0,
1537
+ limit: CODEX_DEFAULT_LIMIT,
1538
+ percent: null,
1539
+ provided: false,
1540
+ windowMinutes: null,
1541
+ oldestTsMs: null,
1542
+ resetAtMs: null,
1543
+ }
1544
+ }
1545
+
1546
+ /**
1547
+ * RateLimitSnapshot (App Server の `account/rateLimits/read`/`updated` または rollout の
1548
+ * `rate_limits`、両 casing) を **窓長で 5h/7d スロットへ分類**する (T06407)。
1549
+ *
1550
+ * - primary/secondary の**位置に依存しない**。各窓の windowDurationMins/window_minutes を見て、
1551
+ * **正準窓長に最も近いスロット**へ割り当てる (5h=300 分 / 7d=10080 分)。
1552
+ * - 提供されない窓 (null / used% 欠落 / duration 不明) は 0% ではなく「未提供」で返す。
1553
+ * - 同一スロットに複数窓が来たら、そのスロットの正準窓長に**より近い**窓を採用する
1554
+ * (先着でなく最近傍。将来 1h+5h のような同帯 2 窓でも実 5h を取りこぼさない)。
1555
+ *
1556
+ * @param {object|null} snapshot RateLimitSnapshot 相当 ({ primary, secondary, ... })
1557
+ * @returns {{ last5h: object, last7d: object }} 各 bucket は provided:true|false を持つ
1558
+ */
1559
+ export function classifyCodexRateLimitWindows(snapshot) {
1560
+ const result = { last5h: codexNotProvidedBucket(), last7d: codexNotProvidedBucket() }
1561
+ if (!snapshot) return result
1562
+ // 各スロットに「これまで割り当てた窓の正準窓長からの距離」を持ち、より近い窓で上書きする。
1563
+ const bestDist = { last5h: Infinity, last7d: Infinity }
1564
+ for (const w of [snapshot.primary, snapshot.secondary]) {
1565
+ const usedPercent = codexWindowUsedPercent(w)
1566
+ if (usedPercent === null) continue
1567
+ const mins = codexWindowMins(w)
1568
+ // duration 不明は分類できない → 未提供のままにする (位置で 5h/7d に決め打ちしない)。
1569
+ if (mins === null) continue
1570
+ // 最も近い正準窓のスロットへ割り当てる。
1571
+ let slot = null
1572
+ let slotDist = Infinity
1573
+ for (const s of CODEX_RATE_LIMIT_SLOTS) {
1574
+ const d = Math.abs(mins - s.canonicalMins)
1575
+ if (d < slotDist) {
1576
+ slotDist = d
1577
+ slot = s.key
1578
+ }
1579
+ }
1580
+ // 同一スロットに複数窓が来たら、正準窓長により近い方を採用 (先着でなく最近傍)。
1581
+ if (slotDist >= bestDist[slot]) continue
1582
+ bestDist[slot] = slotDist
1583
+ result[slot] = {
1584
+ tokens: 0,
1585
+ messages: 0,
1586
+ limit: CODEX_DEFAULT_LIMIT,
1587
+ percent: usedPercent,
1588
+ provided: true,
1589
+ windowMinutes: mins,
1590
+ oldestTsMs: null,
1591
+ resetAtMs: codexWindowResetMs(w),
1592
+ }
1593
+ }
1594
+ return result
1595
+ }
1596
+
1486
1597
  /**
1487
1598
  * 指定 threadId の Codex セッションから usage (5h/7d + context%) を返す。
1488
1599
  * Claude の `getUsage()` と同じ shape (`UsageStats`) を返すため、frontend / wire
@@ -1525,29 +1636,18 @@ export async function codexUsageForThread(threadId) {
1525
1636
  contextPercent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
1526
1637
  }
1527
1638
 
1528
- const bucketize = (w) => {
1529
- if (!w || typeof w.used_percent !== "number") return emptyBucket(CODEX_DEFAULT_LIMIT)
1530
- let resetMs = null
1531
- if (typeof w.resets_at === "number") {
1532
- resetMs = w.resets_at < 1e12 ? w.resets_at * 1000 : w.resets_at
1533
- }
1534
- return {
1535
- tokens: 0,
1536
- messages: 0,
1537
- limit: CODEX_DEFAULT_LIMIT,
1538
- percent: w.used_percent,
1539
- oldestTsMs: null,
1540
- resetAtMs: resetMs,
1541
- }
1542
- }
1639
+ // T06407: 位置固定 (primary→5h / secondary→7d) をやめ、windowDurationMins で分類する。
1640
+ // rollout の rate_limits は snake_case (window_minutes / used_percent / resets_at) だが
1641
+ // classifyCodexRateLimitWindows が両 casing を吸収する。欠落窓は 0% でなく「未提供」。
1642
+ const { last5h, last7d } = classifyCodexRateLimitWindows(rateLimits)
1543
1643
 
1544
1644
  return {
1545
1645
  plan: rateLimits?.plan_type || "codex",
1546
1646
  computedAt: Date.now(),
1547
1647
  source: rateLimits ? "official" : "estimate",
1548
1648
  context: contextPercent,
1549
- last5h: bucketize(rateLimits?.primary),
1550
- last7d: bucketize(rateLimits?.secondary),
1649
+ last5h,
1650
+ last7d,
1551
1651
  }
1552
1652
  }
1553
1653