@cocorograph/hub-agent 0.7.29 → 0.7.32
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 +1 -1
- package/src/claude-history.mjs +62 -1
- package/src/claude-stream-bridge.mjs +72 -0
- package/src/codex-stream-bridge.mjs +23 -2
- package/src/config.mjs +21 -3
- package/src/main.mjs +94 -12
- package/src/tmux.mjs +9 -1
- package/src/usage.mjs +163 -3
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -29,6 +29,50 @@ export const MAX_HISTORY_LINES = 500
|
|
|
29
29
|
/** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
|
|
30
30
|
export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* hydrate 結果のパース済みキャッシュ (2026-07-03: Cockpit セッション切替パフォーマンス改善)。
|
|
34
|
+
*
|
|
35
|
+
* 背景: CockpitChatView は resume セッション切替のたびに毎回 readSessionHistory を呼ぶが
|
|
36
|
+
* (window/page.tsx の `key={selected}` 再マウント設計)、同一ファイルが未変更のまま
|
|
37
|
+
* 数秒〜数分内に再 hydrate されるケースが大半 (行ったり来たりのセッション切替、複数タブ)。
|
|
38
|
+
* readFile → split → 全行 JSON.parse (+ MASK_ENABLED 時は tool_use 走査) は jsonl が
|
|
39
|
+
* 大きいほど無視できないコストになるため、`mtimeMs + size` が前回と一致する間は
|
|
40
|
+
* パース結果をそのまま再利用し、ディスク読み直し・再パースを丸ごとスキップする。
|
|
41
|
+
* ファイルが変わっていれば (ライブ会話が進行 / 別ターミナルから追記等) 通常どおり再読み込み
|
|
42
|
+
* して上書きするので、常に最新のファイル内容を正しく反映する (stale 表示のリスクは無い)。
|
|
43
|
+
*
|
|
44
|
+
* key: filePath, value: { mtimeMs, size, maxLines, result }。maxLines もキーの一部として
|
|
45
|
+
* 比較する (異なる maxLines でのリクエストはキャッシュを再利用しない)。
|
|
46
|
+
*/
|
|
47
|
+
const HYDRATE_CACHE_MAX_ENTRIES = 200
|
|
48
|
+
const hydrateCache = new Map()
|
|
49
|
+
|
|
50
|
+
function getHydrateCache(filePath, mtimeMs, size, maxLines) {
|
|
51
|
+
const entry = hydrateCache.get(filePath)
|
|
52
|
+
if (!entry) return null
|
|
53
|
+
if (entry.mtimeMs !== mtimeMs || entry.size !== size || entry.maxLines !== maxLines) return null
|
|
54
|
+
// LRU: 再利用したエントリを Map の末尾へ移し直す (再挿入で挿入順を更新)。
|
|
55
|
+
hydrateCache.delete(filePath)
|
|
56
|
+
hydrateCache.set(filePath, entry)
|
|
57
|
+
return entry.result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setHydrateCache(filePath, mtimeMs, size, maxLines, result) {
|
|
61
|
+
hydrateCache.delete(filePath)
|
|
62
|
+
hydrateCache.set(filePath, { mtimeMs, size, maxLines, result })
|
|
63
|
+
// 上限超過分は挿入順が古いものから GC (長時間稼働の hub-agent プロセスでメモリが
|
|
64
|
+
// 際限なく増えないようにする)。
|
|
65
|
+
while (hydrateCache.size > HYDRATE_CACHE_MAX_ENTRIES) {
|
|
66
|
+
const oldestKey = hydrateCache.keys().next().value
|
|
67
|
+
hydrateCache.delete(oldestKey)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** テスト用: hydrate キャッシュを全消去する。 */
|
|
72
|
+
export function clearHydrateCacheForTest() {
|
|
73
|
+
hydrateCache.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
32
76
|
/**
|
|
33
77
|
* jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
|
|
34
78
|
* 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
|
|
@@ -97,6 +141,21 @@ export function jsonlPath({ cwd, session_id, projectsRoot }) {
|
|
|
97
141
|
* @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
|
|
98
142
|
*/
|
|
99
143
|
export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
|
|
144
|
+
// stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
|
|
145
|
+
// ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
|
|
146
|
+
let st
|
|
147
|
+
try {
|
|
148
|
+
st = await stat(filePath)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code === "ENOENT") {
|
|
151
|
+
return { events: [], total_lines: 0, truncated: false }
|
|
152
|
+
}
|
|
153
|
+
logger?.warn({ err: err.message, filePath }, "claude history stat failed")
|
|
154
|
+
throw err
|
|
155
|
+
}
|
|
156
|
+
const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
|
|
157
|
+
if (cached) return cached
|
|
158
|
+
|
|
100
159
|
let text
|
|
101
160
|
try {
|
|
102
161
|
text = await readFile(filePath, "utf-8")
|
|
@@ -143,7 +202,9 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
143
202
|
if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
|
|
144
203
|
events.push(event)
|
|
145
204
|
}
|
|
146
|
-
|
|
205
|
+
const result = { events, total_lines, truncated }
|
|
206
|
+
setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
|
|
207
|
+
return result
|
|
147
208
|
}
|
|
148
209
|
|
|
149
210
|
/**
|
|
@@ -1588,6 +1588,78 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1588
1588
|
return this._mergeNeedsAuthCache(servers)
|
|
1589
1589
|
}
|
|
1590
1590
|
|
|
1591
|
+
/**
|
|
1592
|
+
* アカウント全体の 5h/7d rate-limit 使用率 + プラン種別を返す (usage.mjs
|
|
1593
|
+
* recordChatUsageSnapshot に渡す想定)。mcpStatus と同じ control-only query を共有する
|
|
1594
|
+
* (account-level = session/チャット非依存。statusLine が動かない純 SDK チャット運用でも
|
|
1595
|
+
* 取得できる)。
|
|
1596
|
+
*
|
|
1597
|
+
* SDK の `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` は unstable な
|
|
1598
|
+
* 実験的 API のため、失敗 (未実装 SDK バージョン / 一時的なエラー等) は null を返す。
|
|
1599
|
+
* 呼び出し側 (main.mjs `usage.request` ハンドラ) は null なら何もせず、既存の
|
|
1600
|
+
* statusLine / transcript 推定フォールバックに委ねる。
|
|
1601
|
+
*
|
|
1602
|
+
* @returns {Promise<import('@anthropic-ai/claude-agent-sdk').SDKControlGetUsageResponse|null>}
|
|
1603
|
+
*/
|
|
1604
|
+
async getAccountUsage() {
|
|
1605
|
+
try {
|
|
1606
|
+
const q = await this._ensureMcpControlQuery()
|
|
1607
|
+
this._armMcpControlIdle()
|
|
1608
|
+
if (typeof q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET !== "function") {
|
|
1609
|
+
return null
|
|
1610
|
+
}
|
|
1611
|
+
return await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
this.logger?.warn(
|
|
1614
|
+
{ err: err?.message },
|
|
1615
|
+
"getAccountUsage failed (SDK experimental usage API)",
|
|
1616
|
+
)
|
|
1617
|
+
return null
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* 指定 cwd で現在ライブな (browser 接続中、または detach 猶予内の) SDK チャット
|
|
1623
|
+
* session_id を返す。無ければ null。
|
|
1624
|
+
*
|
|
1625
|
+
* TUI モードには tuiReboundSessions による bound session 解決 (main.mjs
|
|
1626
|
+
* `turnActiveForCwd`/`jsonlContextForCwd` 呼び出し) があるが、チャット (SDK stream)
|
|
1627
|
+
* モードには同等の仕組みが無かった。同一 cwd の encode dir にはチャット本体の他に
|
|
1628
|
+
* サブエージェント / headless `claude -p` (記憶蒸留 Stop hook 等) / 過去セッションの
|
|
1629
|
+
* jsonl が堆積するため、bound 指定無しの mtime 最新フォールバックは cross-activity
|
|
1630
|
+
* 汚染 (無関係な jsonl を「そのチャット」と誤認) を起こし得る。本メソッドはチャット版
|
|
1631
|
+
* の bound session 解決として main.mjs (startStateLoop / tmux.list_sessions ハンドラ)
|
|
1632
|
+
* から使う。同一 cwd に複数ライブセッションがある場合は最終アクティビティが最も
|
|
1633
|
+
* 新しいものを採用する。
|
|
1634
|
+
*
|
|
1635
|
+
* @param {string} cwd
|
|
1636
|
+
* @returns {string|null}
|
|
1637
|
+
*/
|
|
1638
|
+
liveSessionIdForCwd(cwd) {
|
|
1639
|
+
if (!cwd) return null
|
|
1640
|
+
let best = null
|
|
1641
|
+
let bestAt = -1
|
|
1642
|
+
const seen = new Set()
|
|
1643
|
+
for (const session of this.sessions.values()) {
|
|
1644
|
+
if (seen.has(session) || session.cwd !== cwd || !session.sessionId) continue
|
|
1645
|
+
seen.add(session)
|
|
1646
|
+
let at = 0
|
|
1647
|
+
for (const t of session.lastActivityByStream.values()) {
|
|
1648
|
+
if (t > at) at = t
|
|
1649
|
+
}
|
|
1650
|
+
// `>=` (2026-07-03): Date.now() はミリ秒精度のため、短時間に複数セッションへ
|
|
1651
|
+
// attach+input すると同一ミリ秒でタイが起き得る (テストや高速な連続操作で実測)。
|
|
1652
|
+
// `this.sessions` は Map で挿入順を保持するため、タイ時は後から attach された
|
|
1653
|
+
// (= イテレーション順で後に来る) セッションを採用するのが「最終アクティビティが
|
|
1654
|
+
// 最も新しい」の意図に合う。厳密な `>` だと先勝ちになり意図と逆転する。
|
|
1655
|
+
if (at >= bestAt) {
|
|
1656
|
+
bestAt = at
|
|
1657
|
+
best = session.sessionId
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return best
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1591
1663
|
/**
|
|
1592
1664
|
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
1593
1665
|
* サーバーを `needs-auth` として補完する。
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
* - 画像/添付入力 (テキストのみ)。
|
|
24
24
|
* - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
|
|
25
25
|
* (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
|
|
26
|
+
*
|
|
27
|
+
* Slice 6 (履歴 hydrate) で追加: `thread/resume` のレスポンスは既定で
|
|
28
|
+
* `thread.turns[].items[]` (= ThreadItem の時系列) を含む (公式スキーマ
|
|
29
|
+
* `ThreadReadResponse.json` の Thread.turns 説明: "Only populated on thread/resume,
|
|
30
|
+
* thread/rollback, thread/fork, and thread/read (when includeTurns is true)")。
|
|
31
|
+
* 追加の `thread/read` 往復は不要で、attach() の戻り値に `history` として平坦化して
|
|
32
|
+
* 返す (browser 側での re-mount 白紙状態を解消する)。
|
|
26
33
|
*/
|
|
27
34
|
import { EventEmitter } from "node:events"
|
|
28
35
|
|
|
@@ -39,6 +46,17 @@ const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
|
|
|
39
46
|
|
|
40
47
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
41
48
|
|
|
49
|
+
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
50
|
+
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
51
|
+
function flattenThreadItems(thread) {
|
|
52
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
53
|
+
const items = []
|
|
54
|
+
for (const turn of turns) {
|
|
55
|
+
if (Array.isArray(turn?.items)) items.push(...turn.items)
|
|
56
|
+
}
|
|
57
|
+
return items
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function userInputFromMessage(message) {
|
|
43
61
|
const content = message?.content
|
|
44
62
|
if (typeof content === "string") {
|
|
@@ -65,7 +83,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/**
|
|
68
|
-
* @returns {Promise<{resuming: boolean, threadId: string}>}
|
|
86
|
+
* @returns {Promise<{resuming: boolean, threadId: string, history: object[]}>}
|
|
69
87
|
*/
|
|
70
88
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
|
|
71
89
|
if (this.sessions.has(stream_id)) {
|
|
@@ -96,7 +114,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
96
114
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
97
115
|
: await client.request("thread/start", startParams)
|
|
98
116
|
session.threadId = result?.thread?.id
|
|
99
|
-
|
|
117
|
+
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
118
|
+
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
119
|
+
const history = resuming ? flattenThreadItems(result?.thread) : []
|
|
120
|
+
return { resuming, threadId: session.threadId, history }
|
|
100
121
|
} catch (err) {
|
|
101
122
|
this.sessions.delete(stream_id)
|
|
102
123
|
client.stop()
|
package/src/config.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
|
|
11
11
|
import path from "node:path"
|
|
12
12
|
import os from "node:os"
|
|
13
|
-
import { execFile } from "node:child_process"
|
|
13
|
+
import { execFile, execFileSync } from "node:child_process"
|
|
14
14
|
import { promisify } from "node:util"
|
|
15
15
|
|
|
16
16
|
const _execFileP = promisify(execFile)
|
|
@@ -21,9 +21,20 @@ const _execFileP = promisify(execFile)
|
|
|
21
21
|
* 重要: Windows のエージェントは WSL2 内で動くため `process.platform` は "linux" を
|
|
22
22
|
* 返す。素の Linux と区別するため、WSL を検出して "wsl" を返す。
|
|
23
23
|
*
|
|
24
|
+
* 判定は 3 段階のフォールバック:
|
|
25
|
+
* 1. `WSL_DISTRO_NAME` / `WSL_INTEROP` 環境変数 (対話シェルなら通常付く)
|
|
26
|
+
* 2. `/proc/version` の内容 (microsoft/wsl 文字列)
|
|
27
|
+
* 3. (2026-07-03 追加) `uname -r` のカーネルリリース文字列。systemd サービス等
|
|
28
|
+
* 非対話起動では 1 の環境変数が unit に伝播しないケースがある (WSL2 + systemd の
|
|
29
|
+
* 典型的な罠。対話シェルにしか WSL_DISTRO_NAME が無く、hub-agent をサービス化
|
|
30
|
+
* すると引き継がれない)。`uname -r` は環境変数に依存せずカーネル自体から
|
|
31
|
+
* "5.15.x-microsoft-standard-WSL2" のような値を返すため、より確実な最終フォールバック
|
|
32
|
+
* として使う。
|
|
33
|
+
*
|
|
34
|
+
* @param {{execImpl?: (cmd: string, args: string[]) => string}} [opts] テスト用の exec 差し替え
|
|
24
35
|
* @returns {"darwin"|"wsl"|"linux"|"win32"|string}
|
|
25
36
|
*/
|
|
26
|
-
export function detectPlatform() {
|
|
37
|
+
export function detectPlatform({ execImpl } = {}) {
|
|
27
38
|
if (process.platform === "darwin") return "darwin"
|
|
28
39
|
if (process.platform === "win32") return "win32"
|
|
29
40
|
if (process.platform === "linux") {
|
|
@@ -32,7 +43,14 @@ export function detectPlatform() {
|
|
|
32
43
|
const v = readFileSync("/proc/version", "utf-8").toLowerCase()
|
|
33
44
|
if (v.includes("microsoft") || v.includes("wsl")) return "wsl"
|
|
34
45
|
} catch {
|
|
35
|
-
/* /proc/version
|
|
46
|
+
/* /proc/version が読めない環境は次のフォールバックへ */
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const run = execImpl || ((cmd, args) => execFileSync(cmd, args, { encoding: "utf-8" }))
|
|
50
|
+
const release = run("uname", ["-r"]).toLowerCase()
|
|
51
|
+
if (release.includes("microsoft") || release.includes("wsl")) return "wsl"
|
|
52
|
+
} catch {
|
|
53
|
+
/* uname が無い/失敗する環境は素の linux 扱い */
|
|
36
54
|
}
|
|
37
55
|
return "linux"
|
|
38
56
|
}
|
package/src/main.mjs
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
getUsage,
|
|
99
99
|
isFreshUnboundBind,
|
|
100
100
|
recordChatRateLimit,
|
|
101
|
+
recordChatUsageSnapshot,
|
|
101
102
|
turnActiveForCwd,
|
|
102
103
|
} from "./usage.mjs"
|
|
103
104
|
import {
|
|
@@ -1343,6 +1344,61 @@ export function enrichTmuxSessionsForListResponse(
|
|
|
1343
1344
|
})
|
|
1344
1345
|
}
|
|
1345
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* state loop 内で「チャット信号 (getChatSignal) でペイン status/context_pct を上書きするか」
|
|
1349
|
+
* を決める純関数 (startStateLoop から抽出、単体テスト用)。
|
|
1350
|
+
*
|
|
1351
|
+
* バグ修正 (2026-07): 従来コードは書き換え「後」の status で processing ガードを判定して
|
|
1352
|
+
* いたため、チャット自身が processing を立てたときに自分自身の条件 (`status !== "processing"`)
|
|
1353
|
+
* を満たせなくなり、SDK チャット生成中はドーナツ (context_pct) が一切更新されなかった。
|
|
1354
|
+
* 本関数は判定を必ず「上書き前の pane 由来 status (paneStatus)」で行う。ペイン
|
|
1355
|
+
* (capture-pane) が実際に processing (= 本物の TUI が生成中) の場合のみチャット信号での
|
|
1356
|
+
* 上書きを止める。それ以外 (pane が idle 等 = SDK チャット専用ペイン含む) は常にチャット
|
|
1357
|
+
* 信号を優先する。
|
|
1358
|
+
*
|
|
1359
|
+
* @param {string} paneStatus capture-pane 由来の生 status (上書き前)
|
|
1360
|
+
* @param {number|null|undefined} paneContextPct capture-pane 由来の生 context_pct
|
|
1361
|
+
* @param {{status?:string|null, context_pct?:number|null}|null} chat getChatSignal() の戻り値
|
|
1362
|
+
* @returns {{status:string, contextPct:number|null|undefined}}
|
|
1363
|
+
*/
|
|
1364
|
+
export function applyChatOverlay(paneStatus, paneContextPct, chat) {
|
|
1365
|
+
let status = paneStatus
|
|
1366
|
+
let contextPct = paneContextPct
|
|
1367
|
+
if (chat) {
|
|
1368
|
+
const paneIsProcessing = paneStatus === "processing"
|
|
1369
|
+
if (chat.status && !paneIsProcessing) status = chat.status
|
|
1370
|
+
if (typeof chat.context_pct === "number" && !paneIsProcessing) {
|
|
1371
|
+
contextPct = chat.context_pct
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return { status, contextPct }
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
|
|
1379
|
+
*
|
|
1380
|
+
* TUI (tuiReboundSessions, tmux セッション名キー) を優先し、無ければ claudeBridge の
|
|
1381
|
+
* ライブ SDK チャットセッション (cwd キー) にフォールバックする。チャットモードには
|
|
1382
|
+
* tmux 上の bind 台帳が無いため、これが無いとチャット信号 stale (90s 無活動) 時の
|
|
1383
|
+
* フォールバックが cwd の encode dir 内の別アクティビティ jsonl (サブエージェント /
|
|
1384
|
+
* headless `claude -p` / 過去セッション等) を「最新」と取り違える cross-activity 汚染を
|
|
1385
|
+
* 起こし得る (TUI 版は tuiReboundSessions で 2026-06 に根治済み。本関数はチャット版の同根治)。
|
|
1386
|
+
*
|
|
1387
|
+
* @param {Map<string,string>|null|undefined} boundSessions ctx.tuiReboundSessions
|
|
1388
|
+
* @param {string} sessionName tmux セッション名
|
|
1389
|
+
* @param {string|null|undefined} cwd
|
|
1390
|
+
* @param {{liveSessionIdForCwd?: (cwd:string)=>string|null}|null|undefined} claudeBridge
|
|
1391
|
+
* @returns {string|null}
|
|
1392
|
+
*/
|
|
1393
|
+
export function resolveStateLoopBoundSessionId(boundSessions, sessionName, cwd, claudeBridge) {
|
|
1394
|
+
const tuiId = boundSessionId(boundSessions, sessionName)
|
|
1395
|
+
if (tuiId) return tuiId
|
|
1396
|
+
if (cwd && claudeBridge && typeof claudeBridge.liveSessionIdForCwd === "function") {
|
|
1397
|
+
return claudeBridge.liveSessionIdForCwd(cwd) || null
|
|
1398
|
+
}
|
|
1399
|
+
return null
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1346
1402
|
export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
|
|
1347
1403
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
1348
1404
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
@@ -1379,25 +1435,25 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1379
1435
|
await refreshContextWindow()
|
|
1380
1436
|
const states = await listSessionStates({ plugins, logger })
|
|
1381
1437
|
for (const s of states) {
|
|
1382
|
-
let status = s.status
|
|
1383
|
-
let contextPct = s.context_pct
|
|
1384
1438
|
// 対話 TUI のペインから読んだ権限モード (素のシェル / SDK チャットでは null)。
|
|
1385
1439
|
// tmux 上で直接 shift+tab した変更もここで拾い、全ブラウザへ追従させる。
|
|
1386
1440
|
const permissionMode = s.permission_mode ?? null
|
|
1387
1441
|
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
1388
1442
|
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
1389
1443
|
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
1444
|
+
// capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
|
|
1445
|
+
// TUI が生成中) のときのみチャット信号で上書きしない (判定は applyChatOverlay
|
|
1446
|
+
// 内部で必ず「上書き前の pane 由来 status (s.status)」を使う。2026-07 バグ修正:
|
|
1447
|
+
// 旧実装は書き換え後の status で判定していたため、SDK チャット生成中はドーナツが
|
|
1448
|
+
// 一切更新されなかった)。素のシェル (SDK チャットのペイン) は processing を
|
|
1449
|
+
// 返せないため、SDK チャットの補完は従来どおり効く。TUI 生成中に stale な SDK
|
|
1450
|
+
// status (waiting) が勝ってステータスドット/三点リーダーが消える事故の防止
|
|
1451
|
+
// (2026-06-12) は paneIsProcessing ガードのまま維持される。
|
|
1390
1452
|
const chat = s.cwd ? getChatSignal(s.cwd) : null
|
|
1453
|
+
const overlay = applyChatOverlay(s.status, s.context_pct, chat)
|
|
1454
|
+
let status = overlay.status
|
|
1455
|
+
let contextPct = overlay.contextPct
|
|
1391
1456
|
if (chat) {
|
|
1392
|
-
// capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
|
|
1393
|
-
// TUI が生成中) のときはチャット信号で上書きしない。素のシェル (SDK チャット
|
|
1394
|
-
// のペイン) は processing を返せないため、SDK チャットの補完は従来どおり効く。
|
|
1395
|
-
// TUI 生成中に stale な SDK status (waiting) が勝ってステータスドット/
|
|
1396
|
-
// 三点リーダーが消える事故の防止 (2026-06-12)。
|
|
1397
|
-
if (chat.status && status !== "processing") status = chat.status
|
|
1398
|
-
if (typeof chat.context_pct === "number" && status !== "processing") {
|
|
1399
|
-
contextPct = chat.context_pct
|
|
1400
|
-
}
|
|
1401
1457
|
// チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
|
|
1402
1458
|
// tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
|
|
1403
1459
|
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
@@ -1466,6 +1522,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1466
1522
|
// T2: tool_use 末尾 active 中の中断マーカー無しクラッシュ判定のため、tmux pane_current_command
|
|
1467
1523
|
// から claude プロセス生存を 3 値で返す paneClaudeAliveOrUnknown を probe として渡す。
|
|
1468
1524
|
// 'dead' (シェル前景)→active=false に倒す。'alive'/'unknown' は active 維持(偽陰性ゼロ)。
|
|
1525
|
+
// - チャット信号が stale (90s 無活動) でこの else-if に落ちるケース: SDK チャットは
|
|
1526
|
+
// tmux bind 台帳 (tuiReboundSessions) を持たないため、従来は boundSessionId が常に
|
|
1527
|
+
// null を返し mtime 最新 jsonl へフォールバックしていた (chat 版の cross-activity
|
|
1528
|
+
// 汚染, 2026-07 発覚)。resolveStateLoopBoundSessionId が claudeBridge のライブ
|
|
1529
|
+
// セッション id へフォールバックすることで TUI と同じ方式で根治する。
|
|
1469
1530
|
let turnActive = null
|
|
1470
1531
|
if (chat?.status === "processing") {
|
|
1471
1532
|
turnActive = true
|
|
@@ -1479,7 +1540,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1479
1540
|
} else if (s.cwd) {
|
|
1480
1541
|
turnActive = await turnActiveForCwd(
|
|
1481
1542
|
s.cwd,
|
|
1482
|
-
|
|
1543
|
+
resolveStateLoopBoundSessionId(boundSessions, s.session_name, s.cwd, claudeBridge),
|
|
1483
1544
|
s.session_name,
|
|
1484
1545
|
{ paneAliveProbe: paneClaudeAliveOrUnknown },
|
|
1485
1546
|
)
|
|
@@ -3032,6 +3093,10 @@ async function dispatch(msg, ctx) {
|
|
|
3032
3093
|
stream_id,
|
|
3033
3094
|
resuming: info.resuming,
|
|
3034
3095
|
thread_id: info.threadId,
|
|
3096
|
+
// Slice 6: 履歴 hydrate。resume 時のみ非空 (thread/start は常に [])。
|
|
3097
|
+
// consumers.py の per-stream relay は content-blind なので backend 側の
|
|
3098
|
+
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
3099
|
+
history: info.history || [],
|
|
3035
3100
|
})
|
|
3036
3101
|
} catch (err) {
|
|
3037
3102
|
ctx.client.send({
|
|
@@ -3311,10 +3376,13 @@ async function dispatch(msg, ctx) {
|
|
|
3311
3376
|
try {
|
|
3312
3377
|
// boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
|
|
3313
3378
|
// (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
|
|
3379
|
+
// claudeBridge: TUI bind が無い SDK チャット cwd 向けの同根治フォールバック
|
|
3380
|
+
// (2026-07 追加。tmux.mjs listSessions 参照)。
|
|
3314
3381
|
const sessions = await listTmuxSessions({
|
|
3315
3382
|
plugins: ctx.plugins,
|
|
3316
3383
|
logger: ctx.logger,
|
|
3317
3384
|
boundSessions: ctx.tuiReboundSessions,
|
|
3385
|
+
claudeBridge: ctx.claudeBridge,
|
|
3318
3386
|
})
|
|
3319
3387
|
// 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
|
|
3320
3388
|
// レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
|
|
@@ -3581,6 +3649,20 @@ async function dispatch(msg, ctx) {
|
|
|
3581
3649
|
}
|
|
3582
3650
|
case "usage.request": {
|
|
3583
3651
|
try {
|
|
3652
|
+
// 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
|
|
3653
|
+
// statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
|
|
3654
|
+
// ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
|
|
3655
|
+
// 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
|
|
3656
|
+
// API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
|
|
3657
|
+
// は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
|
|
3658
|
+
if (ctx.claudeBridge) {
|
|
3659
|
+
try {
|
|
3660
|
+
const acct = await ctx.claudeBridge.getAccountUsage()
|
|
3661
|
+
if (acct) recordChatUsageSnapshot(acct)
|
|
3662
|
+
} catch (err) {
|
|
3663
|
+
ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3584
3666
|
const [usage, sessions] = await Promise.all([
|
|
3585
3667
|
getUsage(),
|
|
3586
3668
|
getSessionUsages(),
|
package/src/tmux.mjs
CHANGED
|
@@ -643,7 +643,15 @@ export async function listSessions(opts = {}) {
|
|
|
643
643
|
// 4. それも無ければ pane scrape の正規表現フォールバック。
|
|
644
644
|
const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
|
|
645
645
|
// bound session があればその jsonl で context% を引く (cwd dir の別セッション jsonl の取り違え回避)。
|
|
646
|
-
|
|
646
|
+
// TUI bind (opts.boundSessions, tmux セッション名キー) を優先し、無ければ SDK チャット
|
|
647
|
+
// (claudeBridge, cwd キー) のライブセッションへフォールバックする。チャットモードには
|
|
648
|
+
// tmux 上の bind 台帳が無いため、これが無いと cold-start (session.state snapshot 未取得)
|
|
649
|
+
// の poll 応答で cwd の別アクティビティ jsonl を「最新」と取り違える cross-activity 汚染が
|
|
650
|
+
// 起き得る (2026-07 発覚。TUI 版は本行の boundSessions 解決で既に根治済みだった)。
|
|
651
|
+
const boundId =
|
|
652
|
+
boundSessionId(opts.boundSessions, s.name) ||
|
|
653
|
+
(cwd && opts.claudeBridge?.liveSessionIdForCwd?.(cwd)) ||
|
|
654
|
+
null
|
|
647
655
|
const jsonlInfo = cwd ? await jsonlContextForCwd(cwd, boundId) : null
|
|
648
656
|
const context_pct =
|
|
649
657
|
jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
|
package/src/usage.mjs
CHANGED
|
@@ -272,6 +272,125 @@ export function whenChatRateLimitsPersisted() {
|
|
|
272
272
|
return _persistInFlight
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// SDK control request (`usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET`)
|
|
277
|
+
// から取得した 5h/7d + plan のスナップショット (プロセス内共有 + ディスク永続化)。
|
|
278
|
+
//
|
|
279
|
+
// 背景: `recordChatRateLimit` (上記, SDK の `rate_limit_event`) は 2026-07 時点の
|
|
280
|
+
// SDK/Claude CLI で `utilization` フィールドが送られなくなり (実測確認済み)、事実上
|
|
281
|
+
// 常時 no-op 化した。加えて statusLine cache (`~/.hub/usage/latest.json`) 自体も
|
|
282
|
+
// `rate_limits` を出さなくなったため (readOfficial 参照)、5h/7d はターミナル・
|
|
283
|
+
// チャット両モードで estimate (transcript 集計の粗い近似) に落ちていた。
|
|
284
|
+
//
|
|
285
|
+
// SDK の control request `usage_EXPERIMENTAL_...()` は実測で正確な utilization/
|
|
286
|
+
// resets_at を返すことを確認済み (claude-agent-sdk 0.3.198)。account-level (session
|
|
287
|
+
// 非依存) なので、claude-stream-bridge.mjs の MCP control-only query を共有して
|
|
288
|
+
// 定期的に呼び出し (main.mjs `usage.request` ハンドラ)、ここに記録する。
|
|
289
|
+
//
|
|
290
|
+
// 永続化先は statusLine cache とは別ファイル (`~/.hub/usage/chat-usage.json`)。
|
|
291
|
+
// 同じ latest.json に混ぜると TUI の statusLine スクリプトが全文上書きで消してしまう
|
|
292
|
+
// (旧 chatRateLimits の設計的弱点)。専用ファイルなら誰にも上書きされない。
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function chatUsageCachePath() {
|
|
296
|
+
return configPath("HUB_CHAT_USAGE_CACHE", os.homedir(), ".hub", "usage", "chat-usage.json")
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @type {{five_hour:{percent:number,resetAtMs:number|null}|null, seven_day:{percent:number,resetAtMs:number|null}|null, plan:string|null, updatedAtMs:number}|null} */
|
|
300
|
+
let chatUsageSnapshot = null
|
|
301
|
+
let _chatUsagePersistInFlight = Promise.resolve()
|
|
302
|
+
|
|
303
|
+
/** SDKControlGetUsageResponse.rate_limits.{five_hour,seven_day} の 1 窓を正規化する。 */
|
|
304
|
+
function normalizeRateLimitWindow(w) {
|
|
305
|
+
if (!w || typeof w !== "object") return null
|
|
306
|
+
const percent = typeof w.utilization === "number" ? w.utilization : null
|
|
307
|
+
if (percent === null) return null
|
|
308
|
+
let resetAtMs = null
|
|
309
|
+
if (typeof w.resets_at === "string") {
|
|
310
|
+
const t = Date.parse(w.resets_at)
|
|
311
|
+
if (!Number.isNaN(t)) resetAtMs = t
|
|
312
|
+
} else if (typeof w.resets_at === "number") {
|
|
313
|
+
resetAtMs = w.resets_at < 1e12 ? w.resets_at * 1000 : w.resets_at
|
|
314
|
+
}
|
|
315
|
+
return { percent, resetAtMs }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* SDK control request `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()`
|
|
320
|
+
* の戻り値 (SDKControlGetUsageResponse) を取り込む。EXPERIMENTAL API なので形が
|
|
321
|
+
* 変わる可能性があり、想定外の入力は無視する (呼び出し側は try/catch で包む前提)。
|
|
322
|
+
*
|
|
323
|
+
* @param {{rate_limits_available?: boolean, rate_limits?: {five_hour?: object|null, seven_day?: object|null}|null, subscription_type?: string|null}|null} usageResponse
|
|
324
|
+
* @param {number} [now]
|
|
325
|
+
*/
|
|
326
|
+
export function recordChatUsageSnapshot(usageResponse, now = Date.now()) {
|
|
327
|
+
if (!usageResponse || typeof usageResponse !== "object") return
|
|
328
|
+
if (usageResponse.rate_limits_available === false) return
|
|
329
|
+
const rl = usageResponse.rate_limits
|
|
330
|
+
if (!rl || typeof rl !== "object") return
|
|
331
|
+
const five = normalizeRateLimitWindow(rl.five_hour)
|
|
332
|
+
const seven = normalizeRateLimitWindow(rl.seven_day)
|
|
333
|
+
if (!five && !seven) return
|
|
334
|
+
chatUsageSnapshot = {
|
|
335
|
+
five_hour: five,
|
|
336
|
+
seven_day: seven,
|
|
337
|
+
plan: usageResponse.subscription_type || chatUsageSnapshot?.plan || null,
|
|
338
|
+
updatedAtMs: now,
|
|
339
|
+
}
|
|
340
|
+
// P5(bug) と同じ直列化パターン (recordChatRateLimit 参照): 並行 persist の tmp 破壊/
|
|
341
|
+
// 中間状態読みを防ぐため、常に前回の persist 完了を待ってから次を実行する。
|
|
342
|
+
_chatUsagePersistInFlight = _chatUsagePersistInFlight.then(
|
|
343
|
+
() => persistChatUsageSnapshot(),
|
|
344
|
+
() => persistChatUsageSnapshot(),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function persistChatUsageSnapshot() {
|
|
349
|
+
const snapshot = chatUsageSnapshot
|
|
350
|
+
if (!snapshot) return
|
|
351
|
+
const p = chatUsageCachePath()
|
|
352
|
+
try {
|
|
353
|
+
await fs.mkdir(path.dirname(p), { recursive: true })
|
|
354
|
+
} catch {
|
|
355
|
+
/* ignore */
|
|
356
|
+
}
|
|
357
|
+
const tmp = `${p}.tmp.${randomUUID()}`
|
|
358
|
+
try {
|
|
359
|
+
await fs.writeFile(tmp, JSON.stringify(snapshot))
|
|
360
|
+
await fs.rename(tmp, p)
|
|
361
|
+
} catch {
|
|
362
|
+
try {
|
|
363
|
+
await fs.unlink(tmp)
|
|
364
|
+
} catch {
|
|
365
|
+
/* ignore */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 直近の chat usage snapshot 書き込みの完了を待つ。テスト用。 */
|
|
371
|
+
export function whenChatUsageSnapshotPersisted() {
|
|
372
|
+
return _chatUsagePersistInFlight
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** プロセス内メモリを優先し、無ければディスク永続化 (hub-agent 再起動直後等) を読む。 */
|
|
376
|
+
async function readChatUsageSnapshot() {
|
|
377
|
+
if (chatUsageSnapshot) return chatUsageSnapshot
|
|
378
|
+
const text = await readOrNull(chatUsageCachePath())
|
|
379
|
+
if (!text) return null
|
|
380
|
+
try {
|
|
381
|
+
const j = JSON.parse(text)
|
|
382
|
+
if (j && typeof j === "object" && typeof j.updatedAtMs === "number") return j
|
|
383
|
+
} catch {
|
|
384
|
+
/* ignore */
|
|
385
|
+
}
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** テスト用: chatUsageSnapshot をクリアする。 */
|
|
390
|
+
export function _resetChatUsageSnapshot() {
|
|
391
|
+
chatUsageSnapshot = null
|
|
392
|
+
}
|
|
393
|
+
|
|
275
394
|
async function readOfficial(now) {
|
|
276
395
|
const text = await readOrNull(statuslineCache())
|
|
277
396
|
if (!text) return null
|
|
@@ -284,8 +403,13 @@ async function readOfficial(now) {
|
|
|
284
403
|
const rl = j.rate_limits ?? j.rateLimits ?? {}
|
|
285
404
|
const five = rl.five_hour ?? rl.fiveHour ?? rl["5h"]
|
|
286
405
|
const seven = rl.seven_day ?? rl.sevenDay ?? rl["7d"]
|
|
287
|
-
if (!five && !seven) return null
|
|
288
406
|
const ctx = j.context_window?.used_percentage ?? j.contextWindow?.used_percentage ?? null
|
|
407
|
+
// 2026-07: Claude CLI 2.1.197+ の statusLine は rate_limits を出さなくなった
|
|
408
|
+
// (chatUsageSnapshot 由来の SDK control request がその代替本命ソースになる。
|
|
409
|
+
// 下の getUsage() 参照)。以前は rate_limits 欠落時に無関係な context_window まで
|
|
410
|
+
// 巻き込んで握りつぶしていた (ドーナツが常に jsonl 推定フォールバックへ落ちる副作用)。
|
|
411
|
+
// rate_limits と context の両方が無いときだけ official ソースとして無意味とみなす。
|
|
412
|
+
if (!five && !seven && ctx === null) return null
|
|
289
413
|
|
|
290
414
|
const bucketize = (b, defaultLimit) => {
|
|
291
415
|
if (!b) return { ...emptyBucket(defaultLimit), limit: defaultLimit }
|
|
@@ -310,7 +434,10 @@ async function readOfficial(now) {
|
|
|
310
434
|
return {
|
|
311
435
|
plan: j.plan || "official",
|
|
312
436
|
computedAt: now,
|
|
313
|
-
|
|
437
|
+
// 5h/7d の実値が伴わない (rate_limits 欠落・context のみ) ときは "official" を名乗らない。
|
|
438
|
+
// badge/表示は source を「5h/7d の信頼度」の意味で使っているため、chatUsageSnapshot 等の
|
|
439
|
+
// 上位レイヤーがまだ埋めていない時点では honest に "estimate" を返す。
|
|
440
|
+
source: five || seven ? "official" : "estimate",
|
|
314
441
|
context: typeof ctx === "number" ? ctx : null,
|
|
315
442
|
last5h: bucketize(five, 100),
|
|
316
443
|
last7d: bucketize(seven, 100),
|
|
@@ -1025,9 +1152,13 @@ export async function getUsage() {
|
|
|
1025
1152
|
// チャット(SDK) の rate_limit_event が statusLine cache より新しければ、5h/7d を
|
|
1026
1153
|
// その実値 (utilization) で上書きする。ターミナルが statusLine を更新していない
|
|
1027
1154
|
// チャットモードでも 5h/7d がライブで動くようになる。
|
|
1155
|
+
// `>=` (2026-07-03): ファイル mtime はミリ秒未満の粒度が丸められる環境があり、
|
|
1156
|
+
// 同一ミリ秒 (またはそれ以下) の解像度で statusLine 書き込みと rate_limit_event が
|
|
1157
|
+
// 揃うとタイになり得る。プロセス内の直近イベント (chatRateLimits) の方が
|
|
1158
|
+
// ディスク read-back より鮮度の情報として確実なため、タイ時は上書きを優先する。
|
|
1028
1159
|
if (
|
|
1029
1160
|
chatRateLimits.updatedAtMs > 0 &&
|
|
1030
|
-
(cacheMtime === null || chatRateLimits.updatedAtMs
|
|
1161
|
+
(cacheMtime === null || chatRateLimits.updatedAtMs >= cacheMtime)
|
|
1031
1162
|
) {
|
|
1032
1163
|
if (chatRateLimits.five_hour) {
|
|
1033
1164
|
result.last5h = {
|
|
@@ -1048,6 +1179,35 @@ export async function getUsage() {
|
|
|
1048
1179
|
result.source = "official"
|
|
1049
1180
|
}
|
|
1050
1181
|
}
|
|
1182
|
+
|
|
1183
|
+
// SDK control request (usage_EXPERIMENTAL...) 由来のスナップショット。上の
|
|
1184
|
+
// chatRateLimits (rate_limit_event) より本命の情報源 (utilization 欠落問題が無い、
|
|
1185
|
+
// 2026-07 時点で実測確認済み)。statusLine / rate_limit_event のどちらよりも新しければ
|
|
1186
|
+
// 上書きする。5h/7d が実際に埋まったときだけ source を official に昇格させる。
|
|
1187
|
+
const chatSnap = await readChatUsageSnapshot()
|
|
1188
|
+
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
|
+
}
|
|
1199
|
+
}
|
|
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
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (chatSnap.plan) result.plan = chatSnap.plan
|
|
1208
|
+
if (chatSnap.five_hour || chatSnap.seven_day) result.source = "official"
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1051
1211
|
return result
|
|
1052
1212
|
}
|
|
1053
1213
|
|