claude-code-session-manager 0.35.18 → 0.37.0

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 (39) hide show
  1. package/dist/assets/{TiptapBody-DgFO_m3g.js → TiptapBody-Cg8YZhVZ.js} +58 -58
  2. package/dist/assets/index-CTTjT08J.css +32 -0
  3. package/dist/assets/index-_iBXLuvt.js +3496 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
  7. package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
  8. package/src/main/__tests__/docEdit.test.cjs +82 -0
  9. package/src/main/__tests__/historyDashboard.test.cjs +163 -0
  10. package/src/main/__tests__/historyRollup.test.cjs +333 -0
  11. package/src/main/__tests__/memoryStale.test.cjs +88 -0
  12. package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +233 -0
  14. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
  15. package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
  16. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
  17. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
  18. package/src/main/chatRunner.cjs +57 -18
  19. package/src/main/config.cjs +8 -0
  20. package/src/main/docEdit.cjs +133 -0
  21. package/src/main/files.cjs +53 -0
  22. package/src/main/historyAggregator.cjs +391 -50
  23. package/src/main/historyDashboard.cjs +226 -0
  24. package/src/main/index.cjs +37 -1
  25. package/src/main/ipcSchemas.cjs +29 -0
  26. package/src/main/lib/broadcastCoalescer.cjs +46 -0
  27. package/src/main/lib/historyRollup.cjs +148 -0
  28. package/src/main/lib/memoryStale.cjs +116 -0
  29. package/src/main/lib/queueHistory.cjs +216 -0
  30. package/src/main/lib/rcaFeedbackHook.cjs +346 -0
  31. package/src/main/lib/schedulerConfig.cjs +16 -0
  32. package/src/main/memoryTool.cjs +49 -0
  33. package/src/main/queueOps.cjs +92 -0
  34. package/src/main/scheduler/prdParser.cjs +52 -1
  35. package/src/main/scheduler.cjs +237 -29
  36. package/src/preload/api.d.ts +92 -1
  37. package/src/preload/index.cjs +7 -0
  38. package/dist/assets/index-4dJkn9nT.js +0 -3559
  39. package/dist/assets/index-DX2w2YhJ.css +0 -32
@@ -5,6 +5,7 @@ const fsp = require('node:fs/promises');
5
5
  const path = require('node:path');
6
6
  const os = require('node:os');
7
7
  const { schemas } = require('./ipcSchemas.cjs');
8
+ const historyRollup = require('./lib/historyRollup.cjs');
8
9
 
9
10
  const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
10
11
  const PARSE_BUDGET_MS = 2_000;
@@ -115,19 +116,23 @@ async function readSlice(filePath, from, to) {
115
116
 
116
117
  /**
117
118
  * Scan JSONL lines into an aggregate accumulator (mutates acc).
118
- * Returns the first timestamp seen when captureFirst=true, else null.
119
+ * Returns { firstTs, lastTs } firstTs is only captured when
120
+ * captureFirst=true (else null); lastTs is the most recent event timestamp
121
+ * seen in this batch of lines (file order, so simply "last one wins").
119
122
  */
120
123
  function scanAggrLines(lines, acc, captureFirst) {
121
124
  let firstTs = null;
125
+ let lastTs = null;
122
126
  for (const raw of lines) {
123
127
  const line = raw.trim();
124
128
  if (!line) continue;
125
129
  let obj;
126
130
  try { obj = JSON.parse(line); } catch { continue; }
127
131
 
128
- if (captureFirst && firstTs === null) {
129
- const ts = obj.ts ?? obj.timestamp;
130
- if (ts) firstTs = ts;
132
+ const ts = obj.ts ?? obj.timestamp;
133
+ if (ts) {
134
+ if (captureFirst && firstTs === null) firstTs = ts;
135
+ lastTs = ts;
131
136
  }
132
137
 
133
138
  const role = obj.role ?? obj.message?.role;
@@ -178,7 +183,21 @@ function scanAggrLines(lines, acc, captureFirst) {
178
183
  acc.errorCount++;
179
184
  }
180
185
  }
181
- return firstTs;
186
+ return { firstTs, lastTs };
187
+ }
188
+
189
+ /**
190
+ * Per-file activeMinutes = elapsed wall-clock time between the file's first
191
+ * and last event, clamped to [0, 24h] so a corrupt/out-of-order timestamp
192
+ * pair can't blow up a day's total. O(1).
193
+ */
194
+ function computeActiveMinutes(firstTs, lastTs) {
195
+ if (!firstTs || !lastTs) return 0;
196
+ const a = Date.parse(firstTs);
197
+ const b = Date.parse(lastTs);
198
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
199
+ const minutes = (b - a) / 60000;
200
+ return Math.max(0, Math.min(24 * 60, minutes));
182
201
  }
183
202
 
184
203
  // ── cached file parsers ───────────────────────────────────────────────────────
@@ -204,6 +223,8 @@ async function parseJSONL(filePath, stat) {
204
223
  byModel: {},
205
224
  errorCount: 0,
206
225
  sessionDate: null,
226
+ firstEventTs: null,
227
+ lastEventTs: null,
207
228
  skipped: false,
208
229
  });
209
230
 
@@ -213,7 +234,11 @@ async function parseJSONL(filePath, stat) {
213
234
 
214
235
  const cached = aggrCache.get(filePath);
215
236
  if (cached) {
216
- if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
237
+ // cached.result.lastEventTs !== undefined guards against pre-activeMinutes
238
+ // cache entries (shape: no firstEventTs/lastEventTs fields) — those must
239
+ // fall through to a full reparse so activeMinutes gets backfilled, rather
240
+ // than being served as a stale exact hit.
241
+ if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs && cached.result.lastEventTs !== undefined) {
217
242
  return { result: cached.result, cacheHit: true };
218
243
  }
219
244
 
@@ -230,7 +255,7 @@ async function parseJSONL(filePath, stat) {
230
255
  const readFrom = cached.readOffset ?? cached.size;
231
256
  const tail = await readSlice(filePath, readFrom, stat.size);
232
257
  const delta = emptyAcc();
233
- scanAggrLines(tail.split('\n'), delta, false);
258
+ const { lastTs: deltaLastTs } = scanAggrLines(tail.split('\n'), delta, false);
234
259
  const prev = cached.result;
235
260
  const merged = {
236
261
  promptCount: prev.promptCount + delta.promptCount,
@@ -243,6 +268,8 @@ async function parseJSONL(filePath, stat) {
243
268
  byModel: {},
244
269
  errorCount: prev.errorCount + delta.errorCount,
245
270
  sessionDate: prev.sessionDate, // firstTs doesn't change on appends
271
+ firstEventTs: prev.firstEventTs ?? null,
272
+ lastEventTs: deltaLastTs ?? (prev.lastEventTs ?? null),
246
273
  skipped: false,
247
274
  };
248
275
  for (const [k, v] of Object.entries(delta.toolBreakdown)) {
@@ -282,7 +309,9 @@ async function parseJSONL(filePath, stat) {
282
309
  }
283
310
 
284
311
  const acc = emptyAcc();
285
- const firstTs = scanAggrLines(text.split('\n'), acc, true);
312
+ const { firstTs, lastTs } = scanAggrLines(text.split('\n'), acc, true);
313
+ acc.firstEventTs = firstTs;
314
+ acc.lastEventTs = lastTs;
286
315
 
287
316
  try {
288
317
  acc.sessionDate = firstTs
@@ -298,6 +327,296 @@ async function parseJSONL(filePath, stat) {
298
327
  return { result: acc, cacheHit: false };
299
328
  }
300
329
 
330
+ // ── rollup bridge ─────────────────────────────────────────────────────────────
331
+ // Converts between the in-memory per-(date,project) accumulator bucket shape
332
+ // used by aggregate()/finalizeClosedDays() and the on-disk rollup line shape
333
+ // (one line per (date, projectDir, modelId) — see historyRollup.cjs header).
334
+
335
+ function emptyBucket(sessionDate, encodedCwd) {
336
+ return {
337
+ date: sessionDate,
338
+ projectCwd: decodeCwd(encodedCwd),
339
+ encodedCwd,
340
+ promptCount: 0,
341
+ inputTokens: 0,
342
+ outputTokens: 0,
343
+ cacheReadTokens: 0,
344
+ cacheCreationTokens: 0,
345
+ toolCallCount: 0,
346
+ toolBreakdown: {},
347
+ byModel: {},
348
+ sessionCount: 0,
349
+ errorCount: 0,
350
+ activeMinutes: 0,
351
+ };
352
+ }
353
+
354
+ /** Fold one parsed-file result into an accumulator bucket (mutates b). */
355
+ function foldParsedIntoBucket(b, parsed) {
356
+ b.promptCount += parsed.promptCount;
357
+ b.inputTokens += parsed.inputTokens;
358
+ b.outputTokens += parsed.outputTokens;
359
+ b.cacheReadTokens += parsed.cacheReadTokens;
360
+ b.cacheCreationTokens += parsed.cacheCreationTokens;
361
+ b.toolCallCount += parsed.toolCallCount;
362
+ for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
363
+ b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
364
+ }
365
+ for (const [modelId, srcBucket] of Object.entries(parsed.byModel ?? {})) {
366
+ const dst = b.byModel[modelId] ?? (b.byModel[modelId] = {
367
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
368
+ });
369
+ dst.inputTokens += srcBucket.inputTokens;
370
+ dst.outputTokens += srcBucket.outputTokens;
371
+ dst.cacheReadTokens += srcBucket.cacheReadTokens;
372
+ dst.cacheCreationTokens += srcBucket.cacheCreationTokens;
373
+ }
374
+ b.sessionCount++;
375
+ b.errorCount += parsed.errorCount;
376
+ b.activeMinutes += computeActiveMinutes(parsed.firstEventTs, parsed.lastEventTs);
377
+ }
378
+
379
+ /** One accumulator bucket → its rollup lines (one totals line + one per model). */
380
+ function bucketToRollupLines(b) {
381
+ const lines = [{
382
+ date: b.date,
383
+ projectDir: b.encodedCwd,
384
+ modelId: historyRollup.TOTALS_MODEL_ID,
385
+ promptCount: b.promptCount,
386
+ inputTokens: 0,
387
+ outputTokens: 0,
388
+ cacheReadTokens: 0,
389
+ cacheCreationTokens: 0,
390
+ toolCallCount: b.toolCallCount,
391
+ toolBreakdown: b.toolBreakdown,
392
+ errorCount: b.errorCount,
393
+ sessionCount: b.sessionCount,
394
+ activeMinutes: b.activeMinutes,
395
+ v: historyRollup.ROLLUP_VERSION,
396
+ }];
397
+ for (const [modelId, mb] of Object.entries(b.byModel)) {
398
+ lines.push({
399
+ date: b.date,
400
+ projectDir: b.encodedCwd,
401
+ modelId,
402
+ promptCount: 0,
403
+ inputTokens: mb.inputTokens,
404
+ outputTokens: mb.outputTokens,
405
+ cacheReadTokens: mb.cacheReadTokens,
406
+ cacheCreationTokens: mb.cacheCreationTokens,
407
+ toolCallCount: 0,
408
+ toolBreakdown: {},
409
+ errorCount: 0,
410
+ sessionCount: 0,
411
+ activeMinutes: 0,
412
+ v: historyRollup.ROLLUP_VERSION,
413
+ });
414
+ }
415
+ return lines;
416
+ }
417
+
418
+ function finalizedMarkerLine(date) {
419
+ return {
420
+ date,
421
+ projectDir: historyRollup.FINALIZED_PROJECT_ID,
422
+ modelId: historyRollup.FINALIZED_MODEL_ID,
423
+ finalizedAt: Date.now(),
424
+ v: historyRollup.ROLLUP_VERSION,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Reconstruct accumulator-shaped rows from rollup lines, restricted to dates
430
+ * in `finalizedDates` — only finalized days are trusted as complete enough
431
+ * to serve without a live re-scan.
432
+ */
433
+ function rollupMapToRows(map, finalizedDates) {
434
+ const grouped = new Map();
435
+ for (const bucket of map.values()) {
436
+ if (bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID && bucket.modelId === historyRollup.FINALIZED_MODEL_ID) continue;
437
+ if (!finalizedDates.has(bucket.date)) continue;
438
+ const groupKey = `${bucket.date}|${bucket.projectDir}`;
439
+ let row = grouped.get(groupKey);
440
+ if (!row) {
441
+ row = emptyBucket(bucket.date, bucket.projectDir);
442
+ grouped.set(groupKey, row);
443
+ }
444
+ if (bucket.modelId === historyRollup.TOTALS_MODEL_ID) {
445
+ row.promptCount += bucket.promptCount || 0;
446
+ row.toolCallCount += bucket.toolCallCount || 0;
447
+ row.errorCount += bucket.errorCount || 0;
448
+ row.sessionCount += bucket.sessionCount || 0;
449
+ row.activeMinutes += bucket.activeMinutes || 0;
450
+ for (const [tool, cnt] of Object.entries(bucket.toolBreakdown || {})) {
451
+ row.toolBreakdown[tool] = (row.toolBreakdown[tool] ?? 0) + cnt;
452
+ }
453
+ } else {
454
+ const dst = row.byModel[bucket.modelId] ?? (row.byModel[bucket.modelId] = {
455
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
456
+ });
457
+ dst.inputTokens += bucket.inputTokens || 0;
458
+ dst.outputTokens += bucket.outputTokens || 0;
459
+ dst.cacheReadTokens += bucket.cacheReadTokens || 0;
460
+ dst.cacheCreationTokens += bucket.cacheCreationTokens || 0;
461
+ row.inputTokens += bucket.inputTokens || 0;
462
+ row.outputTokens += bucket.outputTokens || 0;
463
+ row.cacheReadTokens += bucket.cacheReadTokens || 0;
464
+ row.cacheCreationTokens += bucket.cacheCreationTokens || 0;
465
+ }
466
+ }
467
+ return grouped;
468
+ }
469
+
470
+ /**
471
+ * Boot-time (and self-healing) finalize pass: walks ALL transcripts, buckets
472
+ * every day strictly before today, and persists them to the rollup with a
473
+ * `finalizedAt` stamp. Once a day is finalized, aggregate() trusts the rollup
474
+ * for it — never re-touching the underlying .jsonl files — so it survives
475
+ * both process restarts and transcript deletion/retention cleanup.
476
+ *
477
+ * O(total transcript bytes) — bounded by the shared parseJSONL LRU cache.
478
+ *
479
+ * opts.budgetMs (optional): caps the wall-clock time spent walking project
480
+ * directories. Project directories are visited in a stable sorted order and
481
+ * are each read to completion once started (never split mid-directory) so a
482
+ * date's bucket is only ever built from directories that were fully walked.
483
+ * If the budget runs out before every project directory has been visited,
484
+ * NO date is stamped `finalizedAt` this call (a date isn't safe to trust as
485
+ * complete until every directory has contributed) — the caller can invoke
486
+ * finalizeClosedDays() again later and it will redo the (idempotent) walk.
487
+ * Partial results are still persisted as plain (non-finalized) rollup lines
488
+ * so aggregate() benefits from them immediately.
489
+ *
490
+ * opts.dryRun (optional): computes what would be finalized without writing
491
+ * anything to the rollup file.
492
+ */
493
+ async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
494
+ const deadline = typeof budgetMs === 'number' ? Date.now() + budgetMs : null;
495
+ const today = localDate(new Date());
496
+
497
+ let projectDirs;
498
+ try {
499
+ projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
500
+ } catch {
501
+ return { finalizedDates: [], partial: false };
502
+ }
503
+
504
+ projectDirs = projectDirs
505
+ .filter((e) => e.isDirectory())
506
+ .sort((a, b) => a.name.localeCompare(b.name));
507
+
508
+ const buckets = new Map();
509
+ let partial = false;
510
+
511
+ for (const projEntry of projectDirs) {
512
+ if (deadline !== null && Date.now() >= deadline) {
513
+ partial = true;
514
+ break;
515
+ }
516
+
517
+ const encodedCwd = projEntry.name;
518
+ const projectDir = path.join(PROJECTS_DIR, encodedCwd);
519
+
520
+ let files;
521
+ try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
522
+
523
+ for (const fileEntry of files) {
524
+ if (!fileEntry.name.endsWith('.jsonl')) continue;
525
+ const filePath = path.join(projectDir, fileEntry.name);
526
+
527
+ let stat;
528
+ try { stat = await fsp.stat(filePath); } catch { continue; }
529
+
530
+ const { result: parsed } = await parseJSONL(filePath, stat);
531
+ if (parsed.skipped) continue;
532
+
533
+ const { sessionDate } = parsed;
534
+ if (!sessionDate || sessionDate >= today) continue;
535
+
536
+ const key = `${sessionDate}|${encodedCwd}`;
537
+ if (!buckets.has(key)) buckets.set(key, emptyBucket(sessionDate, encodedCwd));
538
+ foldParsedIntoBucket(buckets.get(key), parsed);
539
+ }
540
+ }
541
+
542
+ const dates = new Set();
543
+ const entries = [];
544
+ for (const b of buckets.values()) {
545
+ dates.add(b.date);
546
+ entries.push(...bucketToRollupLines(b));
547
+ }
548
+ // Only stamp dates as finalized (safe to trust with no live re-scan) when
549
+ // the walk visited every project directory within budget — a partial walk
550
+ // may be missing contributions from directories not yet visited.
551
+ if (!partial) {
552
+ for (const date of dates) entries.push(finalizedMarkerLine(date));
553
+ }
554
+
555
+ if (!dryRun) {
556
+ await historyRollup.appendRollupDays(entries);
557
+ }
558
+ return { finalizedDates: partial ? [] : Array.from(dates), partial };
559
+ }
560
+
561
+ /**
562
+ * Refresh TODAY's rollup buckets from a live (LRU-warm, cheap) parse of every
563
+ * transcript touched today, and upsert them as provisional lines (v2, no
564
+ * finalizedAt) so the History dashboard's "today" row stays current without
565
+ * the renderer ever triggering a transcript scan itself. Meant to be called
566
+ * on a timer (HISTORY_INTRADAY_REFRESH_MS) plus once at app start.
567
+ *
568
+ * readRollup/appendRollupDays last-write-wins per (date, projectDir, modelId)
569
+ * key, so re-running this simply replaces today's previous provisional lines
570
+ * with fresher ones — no separate "clear provisional" step needed.
571
+ */
572
+ async function refreshIntradayToday() {
573
+ const today = localDate(new Date());
574
+
575
+ let projectDirs;
576
+ try {
577
+ projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
578
+ } catch {
579
+ return { date: today, projectsUpdated: 0 };
580
+ }
581
+
582
+ const buckets = new Map();
583
+ for (const projEntry of projectDirs) {
584
+ if (!projEntry.isDirectory()) continue;
585
+ const encodedCwd = projEntry.name;
586
+ const projectDir = path.join(PROJECTS_DIR, encodedCwd);
587
+
588
+ let files;
589
+ try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
590
+
591
+ for (const fileEntry of files) {
592
+ if (!fileEntry.name.endsWith('.jsonl')) continue;
593
+ const filePath = path.join(projectDir, fileEntry.name);
594
+
595
+ let stat;
596
+ try { stat = await fsp.stat(filePath); } catch { continue; }
597
+
598
+ // A file whose mtime is before today can't have been touched today —
599
+ // skip it without even reading it (matches aggregate()'s fast-skip).
600
+ const mtimeDate = localDate(new Date(stat.mtimeMs));
601
+ if (mtimeDate < today) continue;
602
+
603
+ const { result: parsed } = await parseJSONL(filePath, stat);
604
+ if (parsed.skipped) continue;
605
+ if (parsed.sessionDate !== today) continue;
606
+
607
+ const key = `${today}|${encodedCwd}`;
608
+ if (!buckets.has(key)) buckets.set(key, emptyBucket(today, encodedCwd));
609
+ foldParsedIntoBucket(buckets.get(key), parsed);
610
+ }
611
+ }
612
+
613
+ const entries = [];
614
+ for (const b of buckets.values()) entries.push(...bucketToRollupLines(b));
615
+ if (entries.length) await historyRollup.appendRollupDays(entries);
616
+
617
+ return { date: today, projectsUpdated: buckets.size };
618
+ }
619
+
301
620
  // ── aggregate ─────────────────────────────────────────────────────────────────
302
621
 
303
622
  async function aggregate(req) {
@@ -307,6 +626,28 @@ async function aggregate(req) {
307
626
  if (effectiveTo > today) effectiveTo = today;
308
627
  const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
309
628
 
629
+ // Closed (pre-today) days: consult the durable rollup first. A day only
630
+ // short-circuits the live file walk once it's finalized — see
631
+ // finalizeClosedDays(). Unfinalized past days still get live-parsed below
632
+ // (and folded back into the rollup as a self-healing backfill) so results
633
+ // stay correct even before the boot finalize pass has run.
634
+ const yesterday = subtractDays(today, 1);
635
+ const rollupToDate = effectiveTo < today ? effectiveTo : yesterday;
636
+ let rollupMap = new Map();
637
+ if (effectiveFrom <= rollupToDate) {
638
+ rollupMap = await historyRollup.readRollup(effectiveFrom, rollupToDate);
639
+ }
640
+ const finalizedDates = new Set();
641
+ for (const bucket of rollupMap.values()) {
642
+ if (
643
+ bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID &&
644
+ bucket.modelId === historyRollup.FINALIZED_MODEL_ID &&
645
+ bucket.finalizedAt
646
+ ) {
647
+ finalizedDates.add(bucket.date);
648
+ }
649
+ }
650
+
310
651
  const buckets = new Map();
311
652
  let truncated = false;
312
653
  let skippedLargeFiles = 0;
@@ -317,7 +658,7 @@ async function aggregate(req) {
317
658
  try {
318
659
  projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
319
660
  } catch {
320
- return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0 };
661
+ return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0, cacheSavingsUsd: 0, rollupDays: 0, liveDays: 0 };
321
662
  }
322
663
 
323
664
  outer:
@@ -340,6 +681,14 @@ async function aggregate(req) {
340
681
  let stat;
341
682
  try { stat = await fsp.stat(filePath); } catch { continue; }
342
683
 
684
+ // Fast skip: a file last modified on an already-finalized past day is
685
+ // trusted from the rollup and never touched. A file whose true content
686
+ // belongs to an OLDER, not-yet-finalized day gets caught by
687
+ // finalizeClosedDays() the next time it runs (that pass reads full
688
+ // content, not mtime, and finalizes every day it discovers).
689
+ const mtimeDate = localDate(new Date(stat.mtimeMs));
690
+ if (mtimeDate < today && finalizedDates.has(mtimeDate)) continue;
691
+
343
692
  const t1 = Date.now();
344
693
  const { result: parsed, cacheHit } = await parseJSONL(filePath, stat);
345
694
  if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
@@ -352,46 +701,13 @@ async function aggregate(req) {
352
701
  // hide a Pacific-time user's most recent activity entirely.
353
702
  if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
354
703
 
355
- const key = `${sessionDate}|${encodedCwd}`;
356
- if (!buckets.has(key)) {
357
- buckets.set(key, {
358
- date: sessionDate,
359
- projectCwd: decodeCwd(encodedCwd),
360
- encodedCwd,
361
- promptCount: 0,
362
- inputTokens: 0,
363
- outputTokens: 0,
364
- cacheReadTokens: 0,
365
- cacheCreationTokens: 0,
366
- toolCallCount: 0,
367
- toolBreakdown: {},
368
- byModel: {},
369
- sessionCount: 0,
370
- errorCount: 0,
371
- });
372
- }
704
+ // Content-confirmed finalized day (mtime heuristic above missed it) —
705
+ // the rollup is authoritative; don't double count.
706
+ if (sessionDate < today && finalizedDates.has(sessionDate)) continue;
373
707
 
374
- const b = buckets.get(key);
375
- b.promptCount += parsed.promptCount;
376
- b.inputTokens += parsed.inputTokens;
377
- b.outputTokens += parsed.outputTokens;
378
- b.cacheReadTokens += parsed.cacheReadTokens;
379
- b.cacheCreationTokens += parsed.cacheCreationTokens;
380
- b.toolCallCount += parsed.toolCallCount;
381
- for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
382
- b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
383
- }
384
- for (const [modelId, srcBucket] of Object.entries(parsed.byModel ?? {})) {
385
- const dst = b.byModel[modelId] ?? (b.byModel[modelId] = {
386
- inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
387
- });
388
- dst.inputTokens += srcBucket.inputTokens;
389
- dst.outputTokens += srcBucket.outputTokens;
390
- dst.cacheReadTokens += srcBucket.cacheReadTokens;
391
- dst.cacheCreationTokens += srcBucket.cacheCreationTokens;
392
- }
393
- b.sessionCount++;
394
- b.errorCount += parsed.errorCount;
708
+ const key = `${sessionDate}|${encodedCwd}`;
709
+ if (!buckets.has(key)) buckets.set(key, emptyBucket(sessionDate, encodedCwd));
710
+ foldParsedIntoBucket(buckets.get(key), parsed);
395
711
 
396
712
  if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
397
713
  skippedBudgetFiles++;
@@ -405,8 +721,23 @@ async function aggregate(req) {
405
721
  }
406
722
  }
407
723
 
724
+ // Self-healing backfill: any past (pre-today) day we just live-parsed was
725
+ // walked in full above (the file walk isn't restricted to a single day), so
726
+ // fold it into the rollup now — it'll be trusted outright once the next
727
+ // finalize pass stamps it `finalizedAt`.
728
+ const backfillEntries = [];
729
+ for (const b of buckets.values()) {
730
+ if (b.date < today) backfillEntries.push(...bucketToRollupLines(b));
731
+ }
732
+ if (backfillEntries.length) {
733
+ await historyRollup.appendRollupDays(backfillEntries);
734
+ }
735
+
736
+ const rollupRows = rollupMapToRows(rollupMap, finalizedDates);
737
+ const allBucketEntries = [...rollupRows.values(), ...buckets.values()];
738
+
408
739
  let cacheSavingsUsd = 0;
409
- const rows = Array.from(buckets.values()).map((b) => {
740
+ const rows = allBucketEntries.map((b) => {
410
741
  const byModel = {};
411
742
  let estimatedCostUsd = 0;
412
743
  for (const [modelId, bucket] of Object.entries(b.byModel)) {
@@ -427,8 +758,11 @@ async function aggregate(req) {
427
758
 
428
759
  rows.sort((a, b) => a.date.localeCompare(b.date) || a.projectCwd.localeCompare(b.projectCwd));
429
760
 
761
+ const rollupDays = new Set(Array.from(rollupRows.values()).map((r) => r.date)).size;
762
+ const liveDays = new Set(Array.from(buckets.values()).map((r) => r.date)).size;
763
+
430
764
  const scannedMs = Date.now() - t0;
431
- return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles, cacheSavingsUsd };
765
+ return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles, cacheSavingsUsd, rollupDays, liveDays };
432
766
  }
433
767
 
434
768
  // ── IPC registration ──────────────────────────────────────────────────────────
@@ -485,10 +819,17 @@ module.exports = {
485
819
  registerHistoryAggregatorHandlers,
486
820
  remote,
487
821
  MODEL_PRICING,
822
+ finalizeClosedDays,
823
+ refreshIntradayToday,
488
824
  // exported for tests
489
825
  scanAggrLines,
490
826
  parseJSONL,
491
827
  resolvePricingKey,
492
828
  CACHE_MAX,
493
829
  LRUCache,
830
+ computeActiveMinutes,
831
+ // exported for historyDashboard.cjs (pure, no I/O at require-time; reused
832
+ // rather than re-implemented so date-range math stays in one place)
833
+ localDate,
834
+ subtractDays,
494
835
  };