@cocorograph/hub-agent 0.6.20 → 0.6.21
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-stream-bridge.mjs +7 -1
- package/src/main.mjs +36 -2
- package/src/usage.mjs +198 -0
package/package.json
CHANGED
|
@@ -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
|
@@ -37,7 +37,13 @@ import {
|
|
|
37
37
|
listWorktreeStubs,
|
|
38
38
|
removeWorktree as removeWorktreeDir,
|
|
39
39
|
} from "./tmux.mjs"
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
getSessionUsages,
|
|
42
|
+
getUsage,
|
|
43
|
+
recordChatRateLimit,
|
|
44
|
+
recordChatSessionContext,
|
|
45
|
+
recordChatSessionStatus,
|
|
46
|
+
} from "./usage.mjs"
|
|
41
47
|
|
|
42
48
|
const logger = pino({ name: "hub-agent" })
|
|
43
49
|
|
|
@@ -178,7 +184,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
178
184
|
// そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
|
|
179
185
|
// claude.error を返す経路に分岐 (dispatch 側で判定)。
|
|
180
186
|
if (claudeBridge) {
|
|
181
|
-
claudeBridge.on("event", ({ stream_id, session_id, event }) => {
|
|
187
|
+
claudeBridge.on("event", ({ stream_id, session_id, cwd, event }) => {
|
|
182
188
|
// SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
|
|
183
189
|
// (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
|
|
184
190
|
if (event?.type === "rate_limit_event" && event.rate_limit_info) {
|
|
@@ -188,6 +194,34 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
188
194
|
/* ignore */
|
|
189
195
|
}
|
|
190
196
|
}
|
|
197
|
+
// assistant メッセージの usage から session 別 context% を算出してファイルへ
|
|
198
|
+
// 書き出す (サイドバー各行のドーナツ用。これも statusLine がチャットでは更新
|
|
199
|
+
// されないための補完)。あわせてターン状態を更新する:
|
|
200
|
+
// - assistant (生成中) → processing
|
|
201
|
+
// - result (ターン完了/入力待ち) → waiting
|
|
202
|
+
// webapp のステータスドットは tmux ペインのスクレイプで判定するが、チャットは
|
|
203
|
+
// ペインに TUI を出さないため、この chat_status を優先させて補完する。
|
|
204
|
+
const sid = session_id || event?.session_id
|
|
205
|
+
if (event?.type === "assistant") {
|
|
206
|
+
if (event.message?.usage) {
|
|
207
|
+
try {
|
|
208
|
+
recordChatSessionContext({ sessionId: sid, cwd, message: event.message })
|
|
209
|
+
} catch {
|
|
210
|
+
/* ignore */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
recordChatSessionStatus({ sessionId: sid, cwd, status: "processing" })
|
|
215
|
+
} catch {
|
|
216
|
+
/* ignore */
|
|
217
|
+
}
|
|
218
|
+
} else if (event?.type === "result") {
|
|
219
|
+
try {
|
|
220
|
+
recordChatSessionStatus({ sessionId: sid, cwd, status: "waiting" })
|
|
221
|
+
} catch {
|
|
222
|
+
/* ignore */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
191
225
|
client.send({ type: "claude.event", stream_id, session_id, event })
|
|
192
226
|
})
|
|
193
227
|
claudeBridge.on("permission", ({ stream_id, session_id, request_id, tool_name, input }) => {
|
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,141 @@ 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
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// session 別ファイル書き込みの in-flight promise (テスト/shutdown 用)。
|
|
221
|
+
// context% (assistant) と chat_status (result) の書き込みは read-modify-write の
|
|
222
|
+
// ため、この chain に直列化して lost-update / 競合を防ぐ。
|
|
223
|
+
let _sessionPersistInFlight = Promise.resolve()
|
|
224
|
+
|
|
225
|
+
/** session_id を安全なファイル名としてバリデーション (path traversal 防止)。 */
|
|
226
|
+
function isSafeSessionId(sid) {
|
|
227
|
+
return typeof sid === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(sid)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const VALID_CHAT_STATUS = new Set(["processing", "waiting", "idle"])
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* session 別ファイル (`~/.hub/usage/sessions/<sid>.json`) へ patch を
|
|
234
|
+
* マージ書き込みする (read-modify-write, atomic rename)。
|
|
235
|
+
*
|
|
236
|
+
* webapp サイドバーは getSessionUsages (context ドーナツ) と listSessions
|
|
237
|
+
* (ステータスドット) がこのディレクトリを読むが、ファイルは TUI statusLine
|
|
238
|
+
* でしか更新されないため、チャットモードでは両方が固まる。チャット中は SDK の
|
|
239
|
+
* usage / busy 状態からここへ書き出すことで両方ライブ更新される。
|
|
240
|
+
*
|
|
241
|
+
* context_window と chat_status は別タイミング (assistant / result) で書かれる
|
|
242
|
+
* ため、既存値を破壊しないよう deep merge する。
|
|
243
|
+
*/
|
|
244
|
+
async function mergeSessionFile(sessionId, cwd, patch) {
|
|
245
|
+
if (!isSafeSessionId(sessionId) || !cwd) return
|
|
246
|
+
const dir = statuslineSessionsDir()
|
|
247
|
+
const fp = path.join(dir, `${sessionId}.json`)
|
|
248
|
+
let base = {}
|
|
249
|
+
const existing = await readOrNull(fp)
|
|
250
|
+
if (existing) {
|
|
251
|
+
try {
|
|
252
|
+
const p = JSON.parse(existing)
|
|
253
|
+
if (p && typeof p === "object") base = p
|
|
254
|
+
} catch {
|
|
255
|
+
/* 壊れたファイルは作り直す */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const next = {
|
|
259
|
+
...base,
|
|
260
|
+
...patch,
|
|
261
|
+
session_id: sessionId,
|
|
262
|
+
workspace: { ...(base.workspace || {}), project_dir: cwd },
|
|
263
|
+
_source: "chat",
|
|
264
|
+
}
|
|
265
|
+
// context_window はネストオブジェクトなので明示マージ。
|
|
266
|
+
if (patch.context_window) {
|
|
267
|
+
next.context_window = { ...(base.context_window || {}), ...patch.context_window }
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await fs.mkdir(dir, { recursive: true })
|
|
271
|
+
} catch {
|
|
272
|
+
/* ignore */
|
|
273
|
+
}
|
|
274
|
+
const tmp = `${fp}.tmp.${process.pid}`
|
|
275
|
+
try {
|
|
276
|
+
await fs.writeFile(tmp, JSON.stringify(next))
|
|
277
|
+
await fs.rename(tmp, fp)
|
|
278
|
+
} catch {
|
|
279
|
+
try {
|
|
280
|
+
await fs.unlink(tmp)
|
|
281
|
+
} catch {
|
|
282
|
+
/* ignore */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** session 別ファイル書き込みを直列 chain に積む。 */
|
|
288
|
+
function enqueueSessionWrite(fn) {
|
|
289
|
+
_sessionPersistInFlight = _sessionPersistInFlight.catch(() => {}).then(fn)
|
|
290
|
+
return _sessionPersistInFlight
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* チャット assistant メッセージを取り込んで session 別 context% を更新する
|
|
295
|
+
* (main.mjs の event ハンドラから assistant メッセージで呼ぶ)。fire-and-forget。
|
|
296
|
+
*
|
|
297
|
+
* context トークン = input + cache_read + cache_creation + output(直近応答)。
|
|
298
|
+
* プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を必ず合算する。
|
|
299
|
+
*
|
|
300
|
+
* @param {{ sessionId?: string, cwd?: string, message?: { usage?: object } }} arg
|
|
301
|
+
*/
|
|
302
|
+
export function recordChatSessionContext(arg) {
|
|
303
|
+
if (!arg || typeof arg !== "object") return
|
|
304
|
+
const { sessionId, cwd, message } = arg
|
|
305
|
+
const u = message?.usage
|
|
306
|
+
if (!u) return
|
|
307
|
+
const tokens =
|
|
308
|
+
(u.input_tokens || 0) +
|
|
309
|
+
(u.cache_read_input_tokens || 0) +
|
|
310
|
+
(u.cache_creation_input_tokens || 0) +
|
|
311
|
+
(u.output_tokens || 0)
|
|
312
|
+
if (tokens <= 0) return
|
|
313
|
+
enqueueSessionWrite(async () => {
|
|
314
|
+
const windowSize = await contextWindowSize()
|
|
315
|
+
const percent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
|
|
316
|
+
await mergeSessionFile(sessionId, cwd, { context_window: { used_percentage: percent } })
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* チャットのターン状態 (processing/waiting/idle) を session 別ファイルへ書き出す。
|
|
322
|
+
* main.mjs の event ハンドラから、assistant→processing / result→waiting で呼ぶ。
|
|
323
|
+
* webapp listSessions が tmux スクレイプの代わりにこれを優先する (チャットモードは
|
|
324
|
+
* ペインに TUI を出さずスクレイプが効かないため)。fire-and-forget。
|
|
325
|
+
*
|
|
326
|
+
* @param {{ sessionId?: string, cwd?: string, status?: string }} arg
|
|
327
|
+
*/
|
|
328
|
+
export function recordChatSessionStatus(arg) {
|
|
329
|
+
if (!arg || typeof arg !== "object") return
|
|
330
|
+
const { sessionId, cwd, status } = arg
|
|
331
|
+
if (!VALID_CHAT_STATUS.has(status)) return
|
|
332
|
+
enqueueSessionWrite(() => mergeSessionFile(sessionId, cwd, { chat_status: status }))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 直近の session 別ファイル書き込み (context%/status 両方) の完了を待つ。
|
|
337
|
+
* テスト/shutdown 用。
|
|
338
|
+
* @returns {Promise<void>}
|
|
339
|
+
*/
|
|
340
|
+
export function whenChatSessionContextPersisted() {
|
|
341
|
+
return _sessionPersistInFlight
|
|
144
342
|
}
|
|
145
343
|
|
|
146
344
|
async function readOfficial(now) {
|