dsh-all-usage 1.0.5 → 1.0.6

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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/lib/index.js +328 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
15
15
  - **摘要与工作区**:Token 用量、缓存命中、账户余额、连续使用、工作区 Token 分布和明细
16
16
  - **导出**:按当前时间范围和模型聚合方式导出 CSV
17
17
  - **时间范围**:今日、近 30 天、近 90 天、全部,或在全部可扫描历史日数据中自定义起止日期;热力图始终展示最近 53 周
18
- - **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all-usage-aliases`
18
+ - **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all_usage_aliases`
19
19
  - **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
20
20
 
21
21
  ### 截图 / Screenshots
@@ -64,8 +64,9 @@ dsh plugin --profile web add github:ParticleLight/dsh-all-usage
64
64
 
65
65
  ### 数据说明
66
66
 
67
- - 使用次数与 Token 全部来自 DSH 持久化会话日志,插件激活时会自动回填全部历史,插件卸载/重启后数据不丢
67
+ - 使用次数与 Token 来自 DSH 会话日志,并在 `session/flush` 时写入独立用量账本;插件激活时会回填日志与账本历史,插件卸载/重启后已成功持久化的数据不丢
68
68
  - 按日范围统计会保留全部可读取历史会话的有使用记录日期;热力图仅作为最近 53 周的固定视图窗口
69
+ - 会话删除后,已成功 flush 的用量仍从独立账本恢复;会话销毁提示和周期对账只负责触发重建,不会删除账本记录
69
70
  - 同一会话的同一 `turn / step` 只保留一份最终 usage;重试或替换消息会替换旧贡献,不重复累计
70
71
  - 看板中的总处理量 = 输入 + 输出 + 缓存读写 + 推理;缓存命中表示复用的上下文 Token,不等于新生成 Token 或实际费用
71
72
  - 余额查询走 DeepSeek 官方 `/user/balance` 接口;未配置 API Key 时卡片显示引导文案
@@ -87,7 +88,7 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
87
88
  - **Summary and workspaces**: processed tokens, cache hits, account balance, usage streaks, workspace distribution, and details
88
89
  - **CSV export**: export data using the selected time range and aggregation mode
89
90
  - **Time ranges**: today, last 30 days, last 90 days, all time, or a custom start/end date across all available historical daily data; the heatmap always shows the latest 53 weeks
90
- - **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all-usage-aliases`
91
+ - **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all_usage_aliases`
91
92
  - **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
92
93
 
93
94
  ### Installation
@@ -130,8 +131,9 @@ The profile patch layer hot-reloads; save the file and refresh the page.
130
131
 
131
132
  ### Data semantics
132
133
 
133
- - All calls and tokens come from persisted DSH session logs; historical data is backfilled when the plugin activates and survives reloads or uninstall/reinstall cycles
134
+ - Calls and tokens come from DSH session logs and a separate usage ledger written at `session/flush`; readable logs and ledger history are backfilled when the plugin activates, so successfully persisted data survives reloads or session deletion
134
135
  - Day-level range data retains every readable historical session date with tracked usage; the heatmap is only a fixed latest-53-week view
136
+ - After a session is deleted, successfully flushed usage is restored from the separate ledger; disposal hints and periodic reconciliation trigger rebuilds without deleting ledger rows
135
137
  - For each session and logical `turn / step`, only the final usage contribution is kept; retries or replaced messages do not double-count
136
138
  - Processed tokens = input + output + cache read/write + reasoning; a cache hit means reused context, not newly generated tokens or actual cost
137
139
  - Balance data comes from DeepSeek’s official `/user/balance` endpoint; the card shows guidance when no API key is configured
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { randomBytes, timingSafeEqual } from 'node:crypto'
4
4
 
5
5
  const name = 'dsh-all-usage'
6
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer']
6
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'storage']
7
7
 
8
8
  // webServer route handlers do not inherit the connection API fence; keep this plugin
9
9
  // local and require a browser-originated capability for state-changing reads/writes.
@@ -98,9 +98,24 @@ function apply(ctx) {
98
98
  let aliasWriteChain = Promise.resolve()
99
99
  let balanceCache = { fetchedAt: 0, payload: null }
100
100
  const requestToken = randomBytes(32).toString('base64url')
101
+ const ledgerRecords = new Map()
102
+ let ledgerUnit = null
103
+ let ledgerReady = Promise.resolve()
104
+ let ledgerWriteChain = Promise.resolve()
105
+ const LEDGER_VERSION = 1
106
+ let ledgerRevision = Date.now()
101
107
  let disposed = false
102
108
  let baselineRetryDelay = 1000
103
109
  let baselineRetryScheduled = false
110
+ let baselineFallbackTimer = null
111
+ const knownSessionIds = new Set()
112
+ let aggregationGeneration = 0
113
+ let reconcileHintScheduled = false
114
+ let reconcilePending = false
115
+ let reconcileInFlight = false
116
+ let reconcileTimer = null
117
+ const RECONCILE_INTERVAL_MS = 120000
118
+ const RECONCILE_HINT_DELAY_MS = 3000
104
119
 
105
120
  function dayKey(ms) {
106
121
  const d = new Date(ms)
@@ -113,6 +128,45 @@ function apply(ctx) {
113
128
  function num(v) {
114
129
  return typeof v === 'number' && Number.isFinite(v) ? v : 0
115
130
  }
131
+ async function safeContextTimeout(ms) {
132
+ if (disposed) return false
133
+ try {
134
+ await ctx.timeout(ms)
135
+ return !disposed
136
+ } catch (err) {
137
+ if (!disposed) console.error('[all-usage] context timer unavailable:', err)
138
+ return false
139
+ }
140
+ }
141
+ function resetAggregationState() {
142
+ aggregationGeneration += 1
143
+ wsMeta.clear()
144
+ pathIndex.clear()
145
+ memberOf.clear()
146
+ byDay.clear()
147
+ byDayUtc.clear()
148
+ perWorkspace.clear()
149
+ perModel.clear()
150
+ usageByStep.clear()
151
+ sessionModel.clear()
152
+ sessionCount.clear()
153
+ sessionSeq.clear()
154
+ chains.clear()
155
+ knownSessionIds.clear()
156
+ totals.turns = 0
157
+ totals.input = 0
158
+ totals.output = 0
159
+ totals.cacheRead = 0
160
+ totals.cacheWrite = 0
161
+ totals.reasoning = 0
162
+ scan.started = false
163
+ scan.done = false
164
+ scan.scanned = 0
165
+ scan.total = 0
166
+ scan.failed = 0
167
+ baselineRetryDelay = 1000
168
+ return aggregationGeneration
169
+ }
116
170
  function ensureDay(dayMap, date) {
117
171
  let day = dayMap.get(date)
118
172
  if (day === undefined) {
@@ -236,6 +290,93 @@ function apply(ctx) {
236
290
  function modelFromMessage(data, fallback) {
237
291
  return routeLabel(data && data.message && data.message.source) || fallback
238
292
  }
293
+ function nextLedgerRevision() {
294
+ ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
295
+ return ledgerRevision
296
+ }
297
+ function ledgerEventKey(event, index) {
298
+ return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
299
+ }
300
+ function buildLedgerRecord(session, workspaceId, source = 'scan') {
301
+ const sid = session && typeof session.id === 'string' ? session.id : ''
302
+ const events = session && Array.isArray(session.events) ? session.events : []
303
+ if (sid === '' || workspaceId === undefined) return null
304
+ const turns = new Map()
305
+ const usage = new Map()
306
+ let currentModel
307
+ for (let index = 0; index < events.length; index += 1) {
308
+ const event = events[index]
309
+ if (event === null || typeof event !== 'object') continue
310
+ const data = event.data
311
+ if (event.type === 'request/context' || event.type === 'request/header') {
312
+ const model = modelFromRoute(data)
313
+ if (model !== undefined) currentModel = model
314
+ continue
315
+ }
316
+ if (event.type === 'turn/end') {
317
+ const key = ledgerEventKey(event, index)
318
+ turns.set(key, { key, time: event.time, workspaceId })
319
+ continue
320
+ }
321
+ if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
322
+ const values = usageValues(data.usage)
323
+ const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
324
+ ? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
325
+ const eventSeq = typeof event.seq === 'number' ? event.seq : -1
326
+ const key = usageStepKey(sid, data, event.seq)
327
+ const previous = usage.get(key)
328
+ if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
329
+ usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
330
+ }
331
+ return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
332
+ }
333
+ function normalizeLedgerRecord(raw, key) {
334
+ if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
335
+ if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
336
+ const turnMap = new Map()
337
+ for (const turn of raw.turns) {
338
+ if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
339
+ }
340
+ const usageMap = new Map()
341
+ for (const item of raw.usage) {
342
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
343
+ const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
344
+ const previous = usageMap.get(normalized.key)
345
+ if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
346
+ }
347
+ const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
348
+ ledgerRevision = Math.max(ledgerRevision, updatedAt)
349
+ return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
350
+ }
351
+ function applyLedgerRecord(record) {
352
+ if (record === null || record === undefined) return
353
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
354
+ for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
355
+ if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
356
+ }
357
+ function ledgerRank(record) {
358
+ return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
359
+ }
360
+ function storeLedgerRecord(record) {
361
+ const current = ledgerRecords.get(record.sessionId)
362
+ if (current !== undefined) {
363
+ const nextRank = ledgerRank(record)
364
+ const currentRank = ledgerRank(current)
365
+ if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
366
+ }
367
+ ledgerRecords.set(record.sessionId, record)
368
+ return record
369
+ }
370
+ function persistLedgerRecord(record) {
371
+ if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
372
+ const write = ledgerWriteChain.then(async () => {
373
+ if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
374
+ })
375
+ ledgerWriteChain = write.catch((err) => {
376
+ console.error('[all-usage] usage ledger write failed:', err)
377
+ })
378
+ return ledgerWriteChain
379
+ }
239
380
  function foldEvent(wsId, time, type, data, sid, seq) {
240
381
  if (type === 'request/context' || type === 'request/header') {
241
382
  const model = modelFromRoute(data)
@@ -276,13 +417,14 @@ function apply(ctx) {
276
417
  if (wsId !== undefined) memberOf.set(sid, wsId)
277
418
  return wsId
278
419
  }
279
- async function processLiveEvent(sid, wsId, event) {
280
- if (disposed) return
420
+ async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
421
+ if (disposed || generation !== aggregationGeneration) return
281
422
  const seq = typeof event.seq === 'number' ? event.seq : -1
282
423
  const last = sessionSeq.get(sid)
283
424
  if (last === undefined) {
284
425
  try {
285
426
  const snap = await ctx.sessionQuery.readSession(sid)
427
+ if (disposed || generation !== aggregationGeneration) return
286
428
  if (snap && Array.isArray(snap.events)) {
287
429
  foldEvents(wsId, snap.events, undefined, sid)
288
430
  sessionSeq.set(sid, lastSeqOf(snap.events))
@@ -295,6 +437,7 @@ function apply(ctx) {
295
437
  if (seq > last + 1) {
296
438
  try {
297
439
  const snap = await ctx.sessionQuery.readSession(sid)
440
+ if (disposed || generation !== aggregationGeneration) return
298
441
  if (snap && Array.isArray(snap.events)) {
299
442
  foldEvents(wsId, snap.events, last, sid)
300
443
  sessionSeq.set(sid, lastSeqOf(snap.events))
@@ -307,21 +450,55 @@ function apply(ctx) {
307
450
  sessionCount.add(sid)
308
451
  }
309
452
 
453
+ // ---------- durable usage ledger ----------
454
+ async function loadLedger() {
455
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
456
+ try {
457
+ const backend = storage.backend.get('json')
458
+ if (backend === undefined || backend === null || backend.kv === undefined) return
459
+ const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
460
+ if (disposed) { await unit.close().catch(() => {}); return }
461
+ ledgerUnit = unit
462
+ const snapshot = await unit.loadAll()
463
+ const rows = snapshot && snapshot.tables && snapshot.tables.sessions
464
+ if (rows !== null && rows !== undefined && typeof rows === 'object') {
465
+ for (const [key, raw] of Object.entries(rows)) {
466
+ const record = normalizeLedgerRecord(raw, key)
467
+ if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
468
+ else ledgerRecords.set(key, record)
469
+ }
470
+ }
471
+ } catch (err) {
472
+ console.error('[all-usage] usage ledger unavailable:', err)
473
+ }
474
+ }
475
+
310
476
  // ---------- baseline scan over durable logs ----------
311
- function scheduleBaselineRetry() {
312
- if (disposed || baselineRetryScheduled || scan.done) return
477
+ function scheduleNativeBaselineRetry(generation, delay) {
478
+ if (disposed || baselineFallbackTimer !== null) return
479
+ baselineFallbackTimer = setTimeout(() => {
480
+ baselineFallbackTimer = null
481
+ if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
482
+ }, delay)
483
+ if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
484
+ }
485
+ function scheduleBaselineRetry(generation = aggregationGeneration) {
486
+ if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
313
487
  baselineRetryScheduled = true
314
488
  const delay = baselineRetryDelay
315
489
  baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
316
- void Promise.resolve(ctx.timeout(delay)).then(() => {
490
+ void safeContextTimeout(delay).then((ready) => {
317
491
  baselineRetryScheduled = false
318
- if (!disposed && !scan.started && !scan.done) return runBaseline()
492
+ if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
493
+ if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
319
494
  return undefined
320
495
  })
321
496
  }
322
- async function runBaseline() {
323
- if (scan.started || disposed) return
497
+ async function runBaseline(generation = aggregationGeneration) {
498
+ if (scan.started || disposed || generation !== aggregationGeneration) return
324
499
  scan.started = true
500
+ await ledgerReady
501
+ if (disposed || generation !== aggregationGeneration) return
325
502
  let setupFailed = false
326
503
  try {
327
504
  const workspaces = ctx.workspaceRegistry.list()
@@ -346,16 +523,27 @@ function apply(ctx) {
346
523
  } catch (err) {
347
524
  console.error('[all-usage] session list failed:', err)
348
525
  }
349
- if (disposed) return
526
+ if (disposed || generation !== aggregationGeneration) return
350
527
  if (setupFailed || !Array.isArray(records)) {
351
528
  // A transient registry failure must not be reported as a completed empty scan.
352
529
  scan.started = false
353
- scheduleBaselineRetry()
530
+ scheduleBaselineRetry(generation)
354
531
  return
355
532
  }
356
533
  scan.total = records.length
534
+ const listedSessionIds = new Set()
357
535
  for (const record of records) {
358
- if (disposed) return
536
+ if (record === undefined || record === null || record.header === undefined) continue
537
+ const sid = record.header.id
538
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
539
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
540
+ if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
541
+ }
542
+ for (const [sid, record] of ledgerRecords) {
543
+ if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
544
+ }
545
+ for (const record of records) {
546
+ if (disposed || generation !== aggregationGeneration) return
359
547
  if (record === undefined || record === null || record.header === undefined) {
360
548
  scan.scanned += 1
361
549
  continue
@@ -367,26 +555,114 @@ function apply(ctx) {
367
555
  scan.scanned += 1
368
556
  continue
369
557
  }
558
+ listedSessionIds.add(sid)
370
559
  await enqueue(sid, async () => {
371
- if (disposed) return
560
+ if (disposed || generation !== aggregationGeneration) return
372
561
  try {
373
562
  if (sessionSeq.has(sid)) return
374
563
  const snap = await ctx.sessionQuery.readSession(sid)
564
+ if (disposed || generation !== aggregationGeneration) return
375
565
  if (snap && Array.isArray(snap.events)) {
376
- foldEvents(wsId, snap.events, undefined, sid)
566
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
567
+ const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
568
+ if (canonical === ledger) {
569
+ void persistLedgerRecord(ledger)
570
+ foldEvents(wsId, snap.events, undefined, sid)
571
+ } else if (canonical !== undefined) {
572
+ applyLedgerRecord(canonical)
573
+ }
377
574
  sessionSeq.set(sid, lastSeqOf(snap.events))
378
575
  sessionCount.add(sid)
379
576
  }
380
577
  } catch (err) {
381
- sessionSeq.set(sid, -1)
382
- scan.failed += 1
578
+ const saved = ledgerRecords.get(sid)
579
+ if (saved !== undefined) {
580
+ applyLedgerRecord(saved)
581
+ sessionSeq.set(sid, -1)
582
+ sessionCount.add(sid)
583
+ } else if (generation === aggregationGeneration) {
584
+ sessionSeq.set(sid, -1)
585
+ scan.failed += 1
586
+ }
383
587
  } finally {
384
- scan.scanned += 1
588
+ if (generation === aggregationGeneration) scan.scanned += 1
385
589
  }
386
590
  })
387
- await ctx.timeout(0)
591
+ if (!(await safeContextTimeout(0))) {
592
+ scan.started = false
593
+ scheduleBaselineRetry(generation)
594
+ return
595
+ }
388
596
  }
597
+ if (disposed || generation !== aggregationGeneration) return
598
+ await ledgerWriteChain
599
+ if (disposed || generation !== aggregationGeneration) return
600
+ knownSessionIds.clear()
601
+ for (const sid of listedSessionIds) knownSessionIds.add(sid)
389
602
  scan.done = true
603
+ if (reconcilePending) scheduleReconcileHint()
604
+ }
605
+
606
+ function sessionIdsFromRecords(records) {
607
+ const ids = new Set()
608
+ for (const record of records) {
609
+ if (record === undefined || record === null || record.header === undefined) continue
610
+ const sid = record.header.id
611
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
612
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
613
+ if (sid !== undefined && wsId !== undefined) ids.add(sid)
614
+ }
615
+ return ids
616
+ }
617
+ async function reconcileSessions() {
618
+ if (disposed || reconcileInFlight || !scan.done) return
619
+ reconcilePending = false
620
+ reconcileInFlight = true
621
+ try {
622
+ const records = await ctx.sessionQuery.listSessions()
623
+ if (disposed || !Array.isArray(records)) return
624
+ const currentIds = sessionIdsFromRecords(records)
625
+ let removed = false
626
+ for (const sid of knownSessionIds) {
627
+ if (!currentIds.has(sid)) { removed = true; break }
628
+ }
629
+ if (removed && !disposed && scan.done) {
630
+ console.info('[all-usage] session removal detected; rebuilding usage index')
631
+ const generation = resetAggregationState()
632
+ void runBaseline(generation)
633
+ return
634
+ }
635
+ knownSessionIds.clear()
636
+ for (const sid of currentIds) knownSessionIds.add(sid)
637
+ } catch (err) {
638
+ console.error('[all-usage] session reconciliation failed:', err)
639
+ } finally {
640
+ reconcileInFlight = false
641
+ if (reconcilePending && !disposed) scheduleReconcileHint()
642
+ }
643
+ }
644
+ function scheduleReconcileHint() {
645
+ if (disposed) return
646
+ reconcilePending = true
647
+ if (reconcileHintScheduled || reconcileInFlight) return
648
+ reconcileHintScheduled = true
649
+ void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
650
+ reconcileHintScheduled = false
651
+ if (ready && !disposed) void reconcileSessions()
652
+ }, () => {
653
+ reconcileHintScheduled = false
654
+ })
655
+ }
656
+ function scheduleReconcileTimer() {
657
+ if (disposed || reconcileTimer !== null) return
658
+ reconcileTimer = setTimeout(() => {
659
+ reconcileTimer = null
660
+ if (!disposed) {
661
+ void reconcileSessions()
662
+ scheduleReconcileTimer()
663
+ }
664
+ }, RECONCILE_INTERVAL_MS)
665
+ if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
390
666
  }
391
667
 
392
668
  // ---------- live feed ----------
@@ -399,7 +675,23 @@ function apply(ctx) {
399
675
  if (typeof sid !== 'string') return
400
676
  const wsId = wsForLiveSession(session, sid)
401
677
  if (wsId === undefined) return
402
- enqueue(sid, () => processLiveEvent(sid, wsId, event))
678
+ const generation = aggregationGeneration
679
+ knownSessionIds.add(sid)
680
+ enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
681
+ })
682
+ ctx.on('session/flush', async (session) => {
683
+ if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
684
+ await ledgerReady
685
+ if (disposed) return
686
+ const wsId = wsForLiveSession(session, session.id)
687
+ if (wsId === undefined) return
688
+ const ledger = buildLedgerRecord(session, wsId, 'flush')
689
+ if (ledger === null) return
690
+ const canonical = storeLedgerRecord(ledger)
691
+ if (canonical === ledger) await persistLedgerRecord(ledger)
692
+ })
693
+ ctx.on('session/disposed', () => {
694
+ if (!disposed) scheduleReconcileHint()
403
695
  })
404
696
 
405
697
  // ---------- workspace aliases (durable, schema-free KV unit) ----------
@@ -408,7 +700,7 @@ function apply(ctx) {
408
700
  try {
409
701
  const backend = storage.backend.get('json')
410
702
  if (backend === undefined || backend === null || backend.kv === undefined) return
411
- const unit = await backend.kv.open({ name: 'all-usage-aliases', version: 0, tables: [], hasGlobal: true })
703
+ const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
412
704
  kvUnit = unit
413
705
  const snap = await unit.loadAll()
414
706
  const g = snap && snap.global
@@ -442,12 +734,26 @@ function apply(ctx) {
442
734
  }
443
735
  ctx.effect(() => () => {
444
736
  disposed = true
737
+ if (reconcileTimer !== null) {
738
+ clearTimeout(reconcileTimer)
739
+ reconcileTimer = null
740
+ }
741
+ if (baselineFallbackTimer !== null) {
742
+ clearTimeout(baselineFallbackTimer)
743
+ baselineFallbackTimer = null
744
+ }
445
745
  })
446
746
  ctx.effect(() => () => {
447
747
  const unit = kvUnit
448
748
  kvUnit = null
449
749
  if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
450
750
  })
751
+ ctx.effect(() => async () => {
752
+ const unit = ledgerUnit
753
+ await ledgerWriteChain
754
+ if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
755
+ if (ledgerUnit === unit) ledgerUnit = null
756
+ })
451
757
 
452
758
  // ---------- snapshot for the client ----------
453
759
  function serializeDays(dayMap) {
@@ -642,7 +948,9 @@ function apply(ctx) {
642
948
  }
643
949
 
644
950
  // ---------- start the historical backfill immediately ----------
951
+ ledgerReady = loadLedger()
645
952
  void runBaseline()
953
+ scheduleReconcileTimer()
646
954
  void loadAliases()
647
955
  }
648
956
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",