@cocorograph/hub-agent 0.6.5 → 0.6.7
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 +63 -6
- package/src/usage.mjs +97 -2
package/package.json
CHANGED
|
@@ -55,6 +55,7 @@ class ClaudeStreamSession {
|
|
|
55
55
|
onPermission,
|
|
56
56
|
onExit,
|
|
57
57
|
onError,
|
|
58
|
+
onReap,
|
|
58
59
|
}) {
|
|
59
60
|
this.stream_id = stream_id
|
|
60
61
|
this.cwd = cwd
|
|
@@ -66,6 +67,8 @@ class ClaudeStreamSession {
|
|
|
66
67
|
this.onPermission = onPermission
|
|
67
68
|
this.onExit = onExit
|
|
68
69
|
this.onError = onError
|
|
70
|
+
/** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
|
|
71
|
+
this.onReap = onReap
|
|
69
72
|
|
|
70
73
|
/** 次の query() で resume に使う session_id。各ターンの system/init で更新。 */
|
|
71
74
|
this.sessionId = resumeSessionId || null
|
|
@@ -76,6 +79,10 @@ class ClaudeStreamSession {
|
|
|
76
79
|
this._busy = false
|
|
77
80
|
/** detach 済みフラグ (新規ターン受付停止) */
|
|
78
81
|
this._closed = false
|
|
82
|
+
/** 実行中ターンの完走を待ってからクローズする予約フラグ (graceful detach 用)。
|
|
83
|
+
* 端末スリープ / ネット断で browser が切れても、明示的な中断 (interrupt) が
|
|
84
|
+
* 無い限りターンを落とさず最後まで走らせるために使う。 */
|
|
85
|
+
this._reapAfterTurn = false
|
|
79
86
|
/** 現在ターンの AbortController (interrupt 用) */
|
|
80
87
|
this._abortController = null
|
|
81
88
|
|
|
@@ -243,6 +250,19 @@ class ClaudeStreamSession {
|
|
|
243
250
|
if (aborted) {
|
|
244
251
|
this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
|
|
245
252
|
}
|
|
253
|
+
// graceful detach: browser が切れている間にターンが完走したら、ここで遅延
|
|
254
|
+
// クローズする。manager 側で sessions Map から撤去 + exit を emit する。
|
|
255
|
+
if (this._reapAfterTurn && !this._closed) {
|
|
256
|
+
this.logger?.info(
|
|
257
|
+
{ stream_id: this.stream_id },
|
|
258
|
+
"claude turn finished after detach, reaping session",
|
|
259
|
+
)
|
|
260
|
+
try {
|
|
261
|
+
this.close()
|
|
262
|
+
} finally {
|
|
263
|
+
this.onReap?.()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
|
|
@@ -257,6 +277,20 @@ class ClaudeStreamSession {
|
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
|
|
280
|
+
/**
|
|
281
|
+
* graceful detach: 実行中ターンがあれば中断せず完走を待つ (完走後に finally で
|
|
282
|
+
* 自動クローズ + onReap)。アイドルなら即クローズする。
|
|
283
|
+
* @returns {boolean} 即時クローズしたら true / 完走待ちにしたら false
|
|
284
|
+
*/
|
|
285
|
+
gracefulClose() {
|
|
286
|
+
if (this._busy) {
|
|
287
|
+
this._reapAfterTurn = true
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
this.close()
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
260
294
|
/** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
|
|
261
295
|
close() {
|
|
262
296
|
this._closed = true
|
|
@@ -332,6 +366,18 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
332
366
|
onError: (err) => {
|
|
333
367
|
this.emit("error", { stream_id, error: err?.message || String(err) })
|
|
334
368
|
},
|
|
369
|
+
onReap: () => {
|
|
370
|
+
// ターン完走後の遅延クローズ。Map から撤去し exit を emit する。
|
|
371
|
+
if (this.sessions.get(stream_id) === session) {
|
|
372
|
+
this.sessions.delete(stream_id)
|
|
373
|
+
}
|
|
374
|
+
this.emit("exit", {
|
|
375
|
+
stream_id,
|
|
376
|
+
code: 0,
|
|
377
|
+
reason: "detached-after-turn",
|
|
378
|
+
session_id: session.sessionId,
|
|
379
|
+
})
|
|
380
|
+
},
|
|
335
381
|
})
|
|
336
382
|
this.sessions.set(stream_id, session)
|
|
337
383
|
this.logger?.info(
|
|
@@ -381,20 +427,31 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
381
427
|
return true
|
|
382
428
|
}
|
|
383
429
|
|
|
384
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* セッション停止 (graceful)。実行中ターンは中断せず完走させ、完走後に
|
|
432
|
+
* onReap で Map から撤去する。アイドルなら即時撤去。
|
|
433
|
+
* 端末スリープ / ネット断による browser 切断でもターンを落とさないための入口。
|
|
434
|
+
* 明示的に止めたい場合は interrupt() を使う。
|
|
435
|
+
*/
|
|
385
436
|
detach({ stream_id }) {
|
|
386
437
|
const s = this.sessions.get(stream_id)
|
|
387
438
|
if (!s) return false
|
|
388
|
-
s.
|
|
389
|
-
|
|
390
|
-
|
|
439
|
+
const reaped = s.gracefulClose()
|
|
440
|
+
if (reaped) {
|
|
441
|
+
this.sessions.delete(stream_id)
|
|
442
|
+
this.emit("exit", { stream_id, code: 0, reason: "detached", session_id: s.sessionId })
|
|
443
|
+
}
|
|
391
444
|
return true
|
|
392
445
|
}
|
|
393
446
|
|
|
394
|
-
/**
|
|
447
|
+
/** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
|
|
395
448
|
shutdown() {
|
|
396
449
|
for (const stream_id of Array.from(this.sessions.keys())) {
|
|
397
|
-
this.
|
|
450
|
+
const s = this.sessions.get(stream_id)
|
|
451
|
+
if (!s) continue
|
|
452
|
+
s.close()
|
|
453
|
+
this.sessions.delete(stream_id)
|
|
454
|
+
this.emit("exit", { stream_id, code: 0, reason: "shutdown", session_id: s.sessionId })
|
|
398
455
|
}
|
|
399
456
|
}
|
|
400
457
|
|
package/src/usage.mjs
CHANGED
|
@@ -55,6 +55,12 @@ function statuslineSessionsDir() {
|
|
|
55
55
|
|
|
56
56
|
const SESSION_STALE_MS = 15 * 60 * 1000
|
|
57
57
|
|
|
58
|
+
// コンテキスト窓サイズ (トークン)。Opus/Sonnet 4.x は 200k が標準。
|
|
59
|
+
// 1M ベータ等を使う場合は HUB_CONTEXT_WINDOW で上書き。
|
|
60
|
+
const CONTEXT_WINDOW = Number(process.env.HUB_CONTEXT_WINDOW) || 200000
|
|
61
|
+
// チャットモード(SDK)の context% 推定で参照する jsonl の鮮度窓 (直近 N 分)。
|
|
62
|
+
const CONTEXT_JSONL_RECENT_MS = 30 * 60 * 1000
|
|
63
|
+
|
|
58
64
|
const PLAN_LIMITS = {
|
|
59
65
|
pro: { msg5h: 45, msg7d: 315 },
|
|
60
66
|
max_5x: { msg5h: 280, msg7d: 1960 },
|
|
@@ -230,15 +236,104 @@ async function readEstimate(now) {
|
|
|
230
236
|
}
|
|
231
237
|
}
|
|
232
238
|
|
|
239
|
+
/** statusLine cache ファイルの mtime (ms)。存在しなければ 0。 */
|
|
240
|
+
async function statuslineCacheMtime() {
|
|
241
|
+
try {
|
|
242
|
+
return (await fs.stat(statuslineCache())).mtimeMs
|
|
243
|
+
} catch {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 直近にアクティブな session jsonl の最後の assistant.usage から context% を
|
|
250
|
+
* 推定する。チャットモード(SDK headless)は statusLine を書き出さないため、
|
|
251
|
+
* ドーナツ(usage.context)が固まるのを防ぐフォールバック。
|
|
252
|
+
*
|
|
253
|
+
* context トークン = input + cache_read + cache_creation (+ 直近応答の output)。
|
|
254
|
+
* プロンプトキャッシュ有効時は cache_creation / cache_read に大半が乗るため、
|
|
255
|
+
* input_tokens だけだと大幅に過小評価になる点に注意 (3 種を必ず合算する)。
|
|
256
|
+
*
|
|
257
|
+
* @returns {Promise<{ percent: number, mtimeMs: number } | null>}
|
|
258
|
+
*/
|
|
259
|
+
async function latestJsonlContext(now) {
|
|
260
|
+
const projects = await fs.readdir(projectsDir()).catch(() => null)
|
|
261
|
+
if (!projects) return null
|
|
262
|
+
const recent = now - CONTEXT_JSONL_RECENT_MS
|
|
263
|
+
let best = null // { mtimeMs, fp }
|
|
264
|
+
await Promise.all(
|
|
265
|
+
projects.map(async (p) => {
|
|
266
|
+
const dir = path.join(projectsDir(), p)
|
|
267
|
+
const files = await fs.readdir(dir).catch(() => [])
|
|
268
|
+
for (const f of files) {
|
|
269
|
+
if (!f.endsWith(".jsonl")) continue
|
|
270
|
+
const fp = path.join(dir, f)
|
|
271
|
+
try {
|
|
272
|
+
const st = await fs.stat(fp)
|
|
273
|
+
if (st.mtimeMs < recent) continue
|
|
274
|
+
if (!best || st.mtimeMs > best.mtimeMs) best = { mtimeMs: st.mtimeMs, fp }
|
|
275
|
+
} catch {
|
|
276
|
+
/* ignore */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
if (!best) return null
|
|
282
|
+
const text = await readOrNull(best.fp)
|
|
283
|
+
if (!text) return null
|
|
284
|
+
const lines = text.split("\n")
|
|
285
|
+
// 末尾から最初に見つかった assistant.usage を採用 (= 現在の文脈サイズ)
|
|
286
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
287
|
+
const line = lines[i]
|
|
288
|
+
if (!line || !line.includes('"usage"')) continue
|
|
289
|
+
let d
|
|
290
|
+
try {
|
|
291
|
+
d = JSON.parse(line)
|
|
292
|
+
} catch {
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
if (d.type !== "assistant") continue
|
|
296
|
+
const u = d.message?.usage
|
|
297
|
+
if (!u) continue
|
|
298
|
+
const tokens =
|
|
299
|
+
(u.input_tokens || 0) +
|
|
300
|
+
(u.cache_read_input_tokens || 0) +
|
|
301
|
+
(u.cache_creation_input_tokens || 0) +
|
|
302
|
+
(u.output_tokens || 0)
|
|
303
|
+
if (tokens <= 0) continue
|
|
304
|
+
const percent = Math.min(100, Math.round((tokens / CONTEXT_WINDOW) * 1000) / 10)
|
|
305
|
+
return { percent, mtimeMs: best.mtimeMs }
|
|
306
|
+
}
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
|
|
233
310
|
/**
|
|
234
311
|
* 5h / 7d 集計の UsageStats を返す。statusLine が無い環境では transcript
|
|
235
312
|
* から推定する。
|
|
313
|
+
*
|
|
314
|
+
* context%: statusLine 値が最新 jsonl より新しければ official を優先。
|
|
315
|
+
* 古い / 無い (= チャットモードで statusLine 未更新) 場合は jsonl 推定で上書き。
|
|
236
316
|
*/
|
|
237
317
|
export async function getUsage() {
|
|
238
318
|
const now = Date.now()
|
|
239
319
|
const official = await readOfficial(now)
|
|
240
|
-
|
|
241
|
-
|
|
320
|
+
const result = official || (await readEstimate(now))
|
|
321
|
+
|
|
322
|
+
const est = await latestJsonlContext(now)
|
|
323
|
+
if (est) {
|
|
324
|
+
const cacheMtime = await statuslineCacheMtime()
|
|
325
|
+
// statusLine が jsonl と同程度以上に新しければ official.context が信頼できる
|
|
326
|
+
// (ターミナルモード)。jsonl の方が新しい (チャットモード) なら推定で上書き。
|
|
327
|
+
const officialFresh =
|
|
328
|
+
official &&
|
|
329
|
+
typeof official.context === "number" &&
|
|
330
|
+
cacheMtime !== null &&
|
|
331
|
+
cacheMtime >= est.mtimeMs - 5000
|
|
332
|
+
if (!officialFresh) {
|
|
333
|
+
result.context = est.percent
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return result
|
|
242
337
|
}
|
|
243
338
|
|
|
244
339
|
/**
|