@cocorograph/hub-agent 0.6.20 → 0.6.22
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/chat-signals.mjs +85 -0
- package/src/claude-stream-bridge.mjs +7 -1
- package/src/main.mjs +90 -8
- package/src/state.mjs +30 -5
- package/src/usage.mjs +74 -0
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* チャット(SDK stream-json)モードの per-session 状態を、Hub ホスト型 Cockpit の
|
|
3
|
+
* サイドバー信号 (session.state / session.event) に橋渡しするための in-process ストア。
|
|
4
|
+
*
|
|
5
|
+
* 背景:
|
|
6
|
+
* Hub ホスト型 Cockpit のサイドバーは以下を「TUI 専用シグナル」から導出している:
|
|
7
|
+
* - ステータスドット / 各行 context%: hub-agent の startStateLoop が
|
|
8
|
+
* detectSessionState (tmux capture-pane スクレイプ) で算出して session.state を push
|
|
9
|
+
* - 時系列ソート: bundle hook (cockpit_session_event_hook.py) が
|
|
10
|
+
* /tmp/cockpit_session_events/<tmux名>.json を書き、hub-agent が watch して
|
|
11
|
+
* session.event を push → frontend の eventAt sort key
|
|
12
|
+
* チャットモードは tmux ペインに Claude TUI を描画せず、hook も TUI 経由でしか
|
|
13
|
+
* 発火しないため、これらが更新されずドット/ドーナツ/ソートが固まる。
|
|
14
|
+
*
|
|
15
|
+
* 解決:
|
|
16
|
+
* claude-stream-bridge の SDK event (assistant / result) を main.mjs で捕捉し、
|
|
17
|
+
* ここに cwd キーで status / context_pct / 最終アクティビティ時刻を記録する。
|
|
18
|
+
* startStateLoop が tmux セッションを走査する際、session の cwd に一致する新鮮な
|
|
19
|
+
* チャット信号があれば status / context_pct を上書きし、turnAt 更新時には
|
|
20
|
+
* session-event ファイルを書いてソートを動かす。
|
|
21
|
+
*
|
|
22
|
+
* cwd をキーにする理由: チャットセッションは tmux セッション名を持たず (SDK 子
|
|
23
|
+
* プロセス)、サイドバー行 (= tmux セッション) とは cwd で対応するため。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// cwd → { status, context_pct, turnAt, updatedAtMs }
|
|
27
|
+
const _byCwd = new Map()
|
|
28
|
+
|
|
29
|
+
// チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
|
|
30
|
+
// スクレイプ結果へフォールバックさせる (チャット終了/別モード移行の検知)。
|
|
31
|
+
const CHAT_SIGNAL_STALE_MS = 15 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
const VALID_STATUS = new Set(["processing", "waiting", "idle"])
|
|
34
|
+
|
|
35
|
+
function _now() {
|
|
36
|
+
return Date.now()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
|
|
41
|
+
* turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
|
|
42
|
+
*
|
|
43
|
+
* @param {string} cwd セッションの作業ディレクトリ
|
|
44
|
+
* @param {{ status?: string, contextPct?: number|null }} patch
|
|
45
|
+
*/
|
|
46
|
+
export function recordChatActivity(cwd, patch = {}) {
|
|
47
|
+
if (!cwd || typeof cwd !== "string") return
|
|
48
|
+
const now = _now()
|
|
49
|
+
const prev = _byCwd.get(cwd) || {
|
|
50
|
+
status: null,
|
|
51
|
+
context_pct: null,
|
|
52
|
+
turnAt: 0,
|
|
53
|
+
updatedAtMs: 0,
|
|
54
|
+
}
|
|
55
|
+
const next = { ...prev, turnAt: now, updatedAtMs: now }
|
|
56
|
+
if (typeof patch.status === "string" && VALID_STATUS.has(patch.status)) {
|
|
57
|
+
next.status = patch.status
|
|
58
|
+
}
|
|
59
|
+
if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
|
|
60
|
+
next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
|
|
61
|
+
}
|
|
62
|
+
_byCwd.set(cwd, next)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* cwd に対応する生きているチャット信号を返す。stale / 不在なら null。
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {number} [now]
|
|
70
|
+
* @returns {{ status: string|null, context_pct: number|null, turnAt: number, updatedAtMs: number } | null}
|
|
71
|
+
*/
|
|
72
|
+
export function getChatSignal(cwd, now = _now()) {
|
|
73
|
+
if (!cwd) return null
|
|
74
|
+
const sig = _byCwd.get(cwd)
|
|
75
|
+
if (!sig) return null
|
|
76
|
+
if (now - sig.updatedAtMs > CHAT_SIGNAL_STALE_MS) return null
|
|
77
|
+
return sig
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** テスト用: ストアを空にする。 */
|
|
81
|
+
export function _resetChatSignals() {
|
|
82
|
+
_byCwd.clear()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { CHAT_SIGNAL_STALE_MS }
|
|
@@ -902,7 +902,13 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
902
902
|
logger: this.logger,
|
|
903
903
|
onEvent: (event) => {
|
|
904
904
|
// stream_id は再アタッチで変わるため session.stream_id (最新) を使う。
|
|
905
|
-
|
|
905
|
+
// cwd は per-session の context% ドーナツ書き出し (usage.mjs) で使う。
|
|
906
|
+
this.emit("event", {
|
|
907
|
+
stream_id: session.stream_id,
|
|
908
|
+
session_id: session.sessionId,
|
|
909
|
+
cwd: session.cwd,
|
|
910
|
+
event,
|
|
911
|
+
})
|
|
906
912
|
// session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
|
|
907
913
|
if (session.sessionId) this._liveBySession.set(session.sessionId, session)
|
|
908
914
|
},
|
package/src/main.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, watch as fsWatch } from "node:fs"
|
|
14
|
-
import { mkdir, readFile, readdir } from "node:fs/promises"
|
|
14
|
+
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
|
|
15
15
|
import os from "node:os"
|
|
16
16
|
import path from "node:path"
|
|
17
17
|
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
removeWorktree as removeWorktreeDir,
|
|
39
39
|
} from "./tmux.mjs"
|
|
40
40
|
import { getSessionUsages, getUsage, recordChatRateLimit } from "./usage.mjs"
|
|
41
|
+
import { getChatSignal, recordChatActivity } from "./chat-signals.mjs"
|
|
41
42
|
|
|
42
43
|
const logger = pino({ name: "hub-agent" })
|
|
43
44
|
|
|
@@ -178,7 +179,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
178
179
|
// そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
|
|
179
180
|
// claude.error を返す経路に分岐 (dispatch 側で判定)。
|
|
180
181
|
if (claudeBridge) {
|
|
181
|
-
claudeBridge.on("event", ({ stream_id, session_id, event }) => {
|
|
182
|
+
claudeBridge.on("event", ({ stream_id, session_id, cwd, event }) => {
|
|
182
183
|
// SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
|
|
183
184
|
// (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
|
|
184
185
|
if (event?.type === "rate_limit_event" && event.rate_limit_info) {
|
|
@@ -188,6 +189,26 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
188
189
|
/* ignore */
|
|
189
190
|
}
|
|
190
191
|
}
|
|
192
|
+
// Hub ホスト型 Cockpit 用: SDK event から per-session の状態をチャット信号として
|
|
193
|
+
// 記録する。startStateLoop が cwd 一致で session.state (ステータスドット/各行
|
|
194
|
+
// ドーナツ) と session.event (ソート) に橋渡しする。チャットは tmux ペインに
|
|
195
|
+
// TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
|
|
196
|
+
// - assistant (生成中) → processing + usage から context%
|
|
197
|
+
// - result (ターン完了/入力待ち) → waiting
|
|
198
|
+
if (event?.type === "assistant") {
|
|
199
|
+
const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
|
|
200
|
+
try {
|
|
201
|
+
recordChatActivity(cwd, { status: "processing", contextPct: pct })
|
|
202
|
+
} catch {
|
|
203
|
+
/* ignore */
|
|
204
|
+
}
|
|
205
|
+
} else if (event?.type === "result") {
|
|
206
|
+
try {
|
|
207
|
+
recordChatActivity(cwd, { status: "waiting" })
|
|
208
|
+
} catch {
|
|
209
|
+
/* ignore */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
191
212
|
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
192
213
|
})
|
|
193
214
|
claudeBridge.on("permission", ({ stream_id, session_id, request_id, tool_name, input }) => {
|
|
@@ -298,6 +319,48 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
298
319
|
const SESSION_EVENTS_DIR =
|
|
299
320
|
process.env.COCKPIT_SESSION_EVENTS_DIR || "/tmp/cockpit_session_events"
|
|
300
321
|
|
|
322
|
+
// context 窓サイズ (トークン)。usage.mjs と同じ既定。1M ベータ等は env で上書き。
|
|
323
|
+
const CONTEXT_WINDOW_TOKENS = Number(process.env.HUB_CONTEXT_WINDOW) || 200000
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* SDK assistant メッセージの usage から context 使用率 (%) を概算する。
|
|
327
|
+
* context = input + cache_read + cache_creation + output(直近応答)。
|
|
328
|
+
* プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を合算する。
|
|
329
|
+
*/
|
|
330
|
+
function contextPctFromUsage(u) {
|
|
331
|
+
if (!u) return null
|
|
332
|
+
const tokens =
|
|
333
|
+
(u.input_tokens || 0) +
|
|
334
|
+
(u.cache_read_input_tokens || 0) +
|
|
335
|
+
(u.cache_creation_input_tokens || 0) +
|
|
336
|
+
(u.output_tokens || 0)
|
|
337
|
+
if (tokens <= 0) return null
|
|
338
|
+
return Math.min(100, Math.round((tokens / CONTEXT_WINDOW_TOKENS) * 1000) / 10)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* sort 用の session-event ファイルをチャットのターン境界で書き出す
|
|
343
|
+
* (`/tmp/cockpit_session_events/<tmux名>.json`)。bundle hook が TUI で書くものと
|
|
344
|
+
* 同形式 (`{ event, at }`)。既存の watcher がこれを拾って session.event を push し、
|
|
345
|
+
* frontend の eventAt sort key を更新する。atomic write、全エラー握りつぶし。
|
|
346
|
+
*/
|
|
347
|
+
async function writeSessionEventFile(sessionName, event, at) {
|
|
348
|
+
if (!sessionName || /[/\\]/.test(sessionName)) return
|
|
349
|
+
const fp = path.join(SESSION_EVENTS_DIR, `${sessionName}.json`)
|
|
350
|
+
const tmp = `${fp}.tmp.${process.pid}`
|
|
351
|
+
try {
|
|
352
|
+
await mkdir(SESSION_EVENTS_DIR, { recursive: true })
|
|
353
|
+
await writeFile(tmp, JSON.stringify({ event, at }))
|
|
354
|
+
await rename(tmp, fp)
|
|
355
|
+
} catch {
|
|
356
|
+
try {
|
|
357
|
+
await unlink(tmp)
|
|
358
|
+
} catch {
|
|
359
|
+
/* ignore */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
301
364
|
/**
|
|
302
365
|
* `/tmp/cockpit_session_events/` 全 .json を読んで session_name → {event, at}
|
|
303
366
|
* の Map を返す (Phase 4: tmux.list_sessions レスポンスに含めて配信する用)。
|
|
@@ -414,6 +477,7 @@ async function startSessionEventWatcher({ client, logger }) {
|
|
|
414
477
|
*/
|
|
415
478
|
function startStateLoop({ client, plugins, logger, intervalMs }) {
|
|
416
479
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
480
|
+
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
417
481
|
let stopped = false
|
|
418
482
|
|
|
419
483
|
const tick = async () => {
|
|
@@ -421,21 +485,39 @@ function startStateLoop({ client, plugins, logger, intervalMs }) {
|
|
|
421
485
|
try {
|
|
422
486
|
const states = await listSessionStates({ plugins, logger })
|
|
423
487
|
for (const s of states) {
|
|
488
|
+
let status = s.status
|
|
489
|
+
let contextPct = s.context_pct
|
|
490
|
+
// チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
|
|
491
|
+
// session の cwd に一致する新鮮なチャット信号があれば status/context% を
|
|
492
|
+
// 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
|
|
493
|
+
const chat = s.cwd ? getChatSignal(s.cwd) : null
|
|
494
|
+
if (chat) {
|
|
495
|
+
if (chat.status) status = chat.status
|
|
496
|
+
if (typeof chat.context_pct === "number") contextPct = chat.context_pct
|
|
497
|
+
// チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
|
|
498
|
+
// tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
|
|
499
|
+
const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
|
|
500
|
+
if (chat.turnAt > prevTurnAt) {
|
|
501
|
+
lastTurnAtByName.set(s.session_name, chat.turnAt)
|
|
502
|
+
const ev = chat.status === "processing" ? "prompt_submit" : "stop"
|
|
503
|
+
writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
|
|
504
|
+
}
|
|
505
|
+
}
|
|
424
506
|
const prev = lastByName.get(s.session_name)
|
|
425
507
|
if (
|
|
426
508
|
!prev ||
|
|
427
|
-
prev.status !==
|
|
428
|
-
prev.context_pct !==
|
|
509
|
+
prev.status !== status ||
|
|
510
|
+
prev.context_pct !== contextPct
|
|
429
511
|
) {
|
|
430
512
|
lastByName.set(s.session_name, {
|
|
431
|
-
status
|
|
432
|
-
context_pct:
|
|
513
|
+
status,
|
|
514
|
+
context_pct: contextPct,
|
|
433
515
|
})
|
|
434
516
|
client.send({
|
|
435
517
|
type: "session.state",
|
|
436
518
|
session_name: s.session_name,
|
|
437
|
-
status
|
|
438
|
-
context_pct:
|
|
519
|
+
status,
|
|
520
|
+
context_pct: contextPct,
|
|
439
521
|
})
|
|
440
522
|
}
|
|
441
523
|
}
|
package/src/state.mjs
CHANGED
|
@@ -83,6 +83,28 @@ export async function capturePane(sessionName, opts = {}) {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* tmux session の active pane の current_path を取得する (chat 信号との cwd 照合用)。
|
|
88
|
+
* 取れなければ null。
|
|
89
|
+
*/
|
|
90
|
+
export async function getSessionCwd(sessionName, opts = {}) {
|
|
91
|
+
const tmuxBin = opts.tmuxBin || "tmux"
|
|
92
|
+
try {
|
|
93
|
+
const { stdout } = await execFileP(tmuxBin, [
|
|
94
|
+
"display-message",
|
|
95
|
+
"-p",
|
|
96
|
+
"-t",
|
|
97
|
+
`${sessionName}:`,
|
|
98
|
+
"-F",
|
|
99
|
+
"#{pane_current_path}",
|
|
100
|
+
])
|
|
101
|
+
const s = stdout.trim()
|
|
102
|
+
return s || null
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
/**
|
|
87
109
|
* 1 session の現在状態を取得する。plugin hook で上書き可。
|
|
88
110
|
*
|
|
@@ -111,14 +133,17 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
111
133
|
return { status: defaultStatus, context_pct: defaultContextPct }
|
|
112
134
|
}
|
|
113
135
|
|
|
114
|
-
/** 全 session の現在状態を取得する。 */
|
|
136
|
+
/** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
|
|
115
137
|
export async function listSessionStates(opts = {}) {
|
|
116
138
|
const names = await listSessionNames(opts)
|
|
117
139
|
return Promise.all(
|
|
118
|
-
names.map(async (name) =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
names.map(async (name) => {
|
|
141
|
+
const [state, cwd] = await Promise.all([
|
|
142
|
+
detectSessionState(name, opts),
|
|
143
|
+
getSessionCwd(name, opts),
|
|
144
|
+
])
|
|
145
|
+
return { session_name: name, cwd, ...state }
|
|
146
|
+
}),
|
|
122
147
|
)
|
|
123
148
|
}
|
|
124
149
|
|
package/src/usage.mjs
CHANGED
|
@@ -124,9 +124,72 @@ function utilizationToPercent(u) {
|
|
|
124
124
|
return u <= 1 ? Math.round(u * 1000) / 10 : Math.round(u * 10) / 10
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// persist の in-flight promise。テストやシャットダウンで書き込み完了を待てるよう保持。
|
|
128
|
+
let _persistInFlight = Promise.resolve()
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 捕捉済みの chatRateLimits を statusLine cache (`~/.hub/usage/latest.json`) に
|
|
132
|
+
* マージ書き込みする。
|
|
133
|
+
*
|
|
134
|
+
* webapp 側 (`cockpit/webapp/lib/usage.ts`) のフッターは hub-agent の getUsage を
|
|
135
|
+
* 呼ばず、このファイルを直接読む (readOfficial)。そのファイルは TUI の statusLine
|
|
136
|
+
* でしか更新されないため、チャットモードでは 5h/7d が固まる。ここで rate_limit_event
|
|
137
|
+
* の実値をファイルへ書き戻すことで、webapp のフッターもチャット中にライブ更新される。
|
|
138
|
+
*
|
|
139
|
+
* 既存フィールド (context_window 等、TUI が書いた値) は保持し、rate_limits のみ
|
|
140
|
+
* 更新する。書き込みは tmp → rename の atomic write。全エラーは握りつぶす。
|
|
141
|
+
*/
|
|
142
|
+
async function persistChatRateLimitsToCache() {
|
|
143
|
+
const p = statuslineCache()
|
|
144
|
+
let base = {}
|
|
145
|
+
const existing = await readOrNull(p)
|
|
146
|
+
if (existing) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(existing)
|
|
149
|
+
if (parsed && typeof parsed === "object") base = parsed
|
|
150
|
+
} catch {
|
|
151
|
+
/* 壊れたファイルは作り直す */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const rl =
|
|
155
|
+
base.rate_limits && typeof base.rate_limits === "object" ? { ...base.rate_limits } : {}
|
|
156
|
+
if (chatRateLimits.five_hour) {
|
|
157
|
+
rl.five_hour = {
|
|
158
|
+
used_percentage: chatRateLimits.five_hour.percent,
|
|
159
|
+
resets_at: chatRateLimits.five_hour.resetAtMs ?? rl.five_hour?.resets_at ?? null,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (chatRateLimits.seven_day) {
|
|
163
|
+
rl.seven_day = {
|
|
164
|
+
used_percentage: chatRateLimits.seven_day.percent,
|
|
165
|
+
resets_at: chatRateLimits.seven_day.resetAtMs ?? rl.seven_day?.resets_at ?? null,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
base.rate_limits = rl
|
|
169
|
+
try {
|
|
170
|
+
await fs.mkdir(path.dirname(p), { recursive: true })
|
|
171
|
+
} catch {
|
|
172
|
+
/* ignore */
|
|
173
|
+
}
|
|
174
|
+
const tmp = `${p}.tmp.${process.pid}`
|
|
175
|
+
try {
|
|
176
|
+
await fs.writeFile(tmp, JSON.stringify(base))
|
|
177
|
+
await fs.rename(tmp, p)
|
|
178
|
+
} catch {
|
|
179
|
+
try {
|
|
180
|
+
await fs.unlink(tmp)
|
|
181
|
+
} catch {
|
|
182
|
+
/* ignore */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
127
187
|
/**
|
|
128
188
|
* SDK の SDKRateLimitInfo を取り込む (main.mjs の rate_limit_event ハンドラから呼ぶ)。
|
|
129
189
|
* rateLimitType: 'five_hour' / 'seven_day*' を 5h / 7d スロットに振り分ける。
|
|
190
|
+
*
|
|
191
|
+
* 取り込んだ値はプロセス内 (hub-agent の getUsage 用) と statusLine cache ファイル
|
|
192
|
+
* (webapp フッター用) の両方に反映する。ファイル書き込みは fire-and-forget。
|
|
130
193
|
*/
|
|
131
194
|
export function recordChatRateLimit(info) {
|
|
132
195
|
if (!info || typeof info !== "object") return
|
|
@@ -141,6 +204,17 @@ export function recordChatRateLimit(info) {
|
|
|
141
204
|
}
|
|
142
205
|
chatRateLimits[slot] = { percent, resetAtMs }
|
|
143
206
|
chatRateLimits.updatedAtMs = Date.now()
|
|
207
|
+
// webapp フッター (ファイルベース readOfficial) 用に latest.json へ書き戻す。
|
|
208
|
+
// fire-and-forget だが in-flight promise は保持 (flush 可能にする)。
|
|
209
|
+
_persistInFlight = persistChatRateLimitsToCache()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 直近の statusLine cache 書き込みの完了を待つ。テストや graceful shutdown 用。
|
|
214
|
+
* @returns {Promise<void>}
|
|
215
|
+
*/
|
|
216
|
+
export function whenChatRateLimitsPersisted() {
|
|
217
|
+
return _persistInFlight
|
|
144
218
|
}
|
|
145
219
|
|
|
146
220
|
async function readOfficial(now) {
|