claude-code-session-manager 0.62.1 → 0.63.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 (45) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-BwN4pNC0.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-CVCCMw5o.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  35. package/src/main/config.cjs +31 -7
  36. package/src/main/health.cjs +32 -0
  37. package/src/main/heapSnapshot.cjs +122 -0
  38. package/src/main/historyAggregator.cjs +176 -36
  39. package/src/main/index.cjs +9 -0
  40. package/src/main/ipcSchemas.cjs +9 -0
  41. package/src/main/lib/runLogRetention.cjs +358 -0
  42. package/src/main/transcripts.cjs +29 -5
  43. package/src/preload/api.d.ts +16 -2
  44. package/src/preload/index.cjs +11 -2
  45. package/dist/assets/index-ZjTDk5b7.js +0 -3197
@@ -14,6 +14,7 @@ const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
14
14
  const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
15
15
  const { migratePrds } = require('./lib/prdMigration.cjs');
16
16
  const queueStore = require('./lib/queueStore.cjs');
17
+ const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
17
18
 
18
19
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
19
20
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
@@ -370,6 +371,37 @@ async function check() {
370
371
  }
371
372
  }
372
373
 
374
+ // 6.6. Run-log retention report (informational only — never blocks health).
375
+ // Read-only against the REAL scheduled-plans/runs/ dir: reports current
376
+ // usage + what the configured policy (if any) would remove. Nothing is
377
+ // ever deleted here; see runLogRetention.cjs's header for the safety
378
+ // model. schedulerRunLogRetention lives in scheduler-machine.json's
379
+ // `config`, same home as every other scheduler machine setting.
380
+ try {
381
+ const retentionCfg = queueState?.config?.schedulerRunLogRetention;
382
+ const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir: DEFAULT_RUNS_DIR });
383
+ const report = computeReport(
384
+ DEFAULT_RUNS_DIR,
385
+ retentionCfg?.policy || {},
386
+ { liveKeys }
387
+ );
388
+ status.components.run_log_retention = {
389
+ ok: true,
390
+ path: DEFAULT_RUNS_DIR,
391
+ totalBytes: report.usage.totalBytes,
392
+ dirCount: report.usage.dirCount,
393
+ runCount: report.usage.runCount,
394
+ oldestRunAt: report.usage.oldestRunAt,
395
+ policyConfigured: !!retentionCfg?.policy,
396
+ retentionEnabled: isRetentionEnabled(queueState?.config || {}),
397
+ wouldRemoveCount: report.eligibleSummary.count,
398
+ wouldRemoveBytes: report.eligibleSummary.bytes,
399
+ wouldRemoveDirs: report.removableDirs.length,
400
+ };
401
+ } catch (e) {
402
+ status.components.run_log_retention = { ok: true, error: e.message };
403
+ }
404
+
373
405
  // 7. Summary scoring: ok if all critical components pass.
374
406
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
375
407
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * heapSnapshot.cjs — on-demand V8 heap snapshot of the RENDERER process,
3
+ * written to disk without restarting the app and without opening a
4
+ * `--remote-debugging-port`.
5
+ *
6
+ * Gated behind SM_HEAP_SNAPSHOT=1 (read fresh on every call, never cached at
7
+ * require time, so a test can flip it per-case). With the flag unset:
8
+ * - registerIpc() registers no IPC channel.
9
+ * - buildMenuItem() returns null (nothing spliced into the Dev menu).
10
+ * - No timer, listener, or allocation is added anywhere.
11
+ *
12
+ * Uses webContents.takeHeapSnapshot(filePath) (Electron's own DevTools-
13
+ * protocol capture) rather than a debugging port — this stays off by
14
+ * default and never listens on a socket. capture() races the write against
15
+ * a hard timeout so a stuck capture can't hang the caller (or a scheduler
16
+ * job waiting on the same event loop) forever; it does not and cannot
17
+ * cancel Electron's in-flight write, it only stops *waiting* on it.
18
+ *
19
+ * Kept free of `require('electron')` at module scope so it can be unit
20
+ * tested with fake ipcMain/webContents objects, no Electron runtime needed.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const path = require('node:path');
26
+ const os = require('node:os');
27
+ const fsp = require('node:fs/promises');
28
+
29
+ const CHANNEL = 'diagnostics:heap-snapshot';
30
+ const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 min — snapshots can be multi-hundred-MB
31
+
32
+ function isEnabled() {
33
+ return process.env.SM_HEAP_SNAPSHOT === '1';
34
+ }
35
+
36
+ function snapshotDir() {
37
+ return path.join(os.homedir(), '.claude', 'session-manager');
38
+ }
39
+
40
+ function timestampedFilename(now = new Date()) {
41
+ return `heap-${now.toISOString().replace(/[:.]/g, '-')}.heapsnapshot`;
42
+ }
43
+
44
+ /**
45
+ * @param {{ webContents: { takeHeapSnapshot(filePath: string): Promise<void> } }} win
46
+ * @param {{ dir?: string, now?: Date, timeoutMs?: number }} [opts]
47
+ * @returns {Promise<{ filePath: string, bytes: number|null, ms: number }>}
48
+ */
49
+ async function captureSnapshot(win, opts = {}) {
50
+ if (!isEnabled()) {
51
+ throw new Error('Heap snapshot capture is disabled — set SM_HEAP_SNAPSHOT=1 and restart to enable.');
52
+ }
53
+ if (!win || win.isDestroyed?.() || !win.webContents) {
54
+ throw new Error('No renderer window available to snapshot.');
55
+ }
56
+ const dir = opts.dir || snapshotDir();
57
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
58
+ await fsp.mkdir(dir, { recursive: true });
59
+ const filePath = path.join(dir, timestampedFilename(opts.now));
60
+
61
+ const started = Date.now();
62
+ let timer;
63
+ const timeout = new Promise((_resolve, reject) => {
64
+ timer = setTimeout(() => {
65
+ reject(new Error(`Heap snapshot timed out after ${timeoutMs}ms (still writing ${filePath})`));
66
+ }, timeoutMs);
67
+ });
68
+ try {
69
+ await Promise.race([win.webContents.takeHeapSnapshot(filePath), timeout]);
70
+ } finally {
71
+ clearTimeout(timer);
72
+ }
73
+
74
+ const stat = await fsp.stat(filePath).catch(() => null);
75
+ const result = { filePath, bytes: stat ? stat.size : null, ms: Date.now() - started };
76
+ console.log(`[heapSnapshot] wrote ${filePath}${stat ? ` (${stat.size} bytes, ${result.ms}ms)` : ''}`);
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Registers the IPC handler ONLY when SM_HEAP_SNAPSHOT=1. No-op otherwise —
82
+ * this is the whole default-off contract for the renderer-facing trigger.
83
+ * @param {{ ipcMain: { handle(channel: string, listener: Function): void } }} deps
84
+ * @param {() => any} getWindow
85
+ */
86
+ function registerIpc({ ipcMain, getWindow }) {
87
+ if (!isEnabled()) return false;
88
+ ipcMain.handle(CHANNEL, () => captureSnapshot(getWindow()));
89
+ return true;
90
+ }
91
+
92
+ /**
93
+ * Menu item template for the Dev menu, or null when disabled. index.cjs
94
+ * splices this into its existing template — never builds a menu unless the
95
+ * flag is set.
96
+ * @param {() => any} getWindow
97
+ */
98
+ function buildMenuItem(getWindow) {
99
+ if (!isEnabled()) return null;
100
+ return {
101
+ label: 'Take Heap Snapshot (renderer)',
102
+ click: async () => {
103
+ try {
104
+ const result = await captureSnapshot(getWindow());
105
+ console.log(`[heapSnapshot] menu capture complete: ${result.filePath}`);
106
+ } catch (err) {
107
+ console.error('[heapSnapshot] menu capture failed:', err && err.message);
108
+ }
109
+ },
110
+ };
111
+ }
112
+
113
+ module.exports = {
114
+ CHANNEL,
115
+ DEFAULT_TIMEOUT_MS,
116
+ isEnabled,
117
+ snapshotDir,
118
+ timestampedFilename,
119
+ captureSnapshot,
120
+ registerIpc,
121
+ buildMenuItem,
122
+ };
@@ -74,6 +74,34 @@ class LRUCache {
74
74
  */
75
75
  const aggrCache = new LRUCache(CACHE_MAX);
76
76
 
77
+ // ── bounded concurrency helper ───────────────────────────────────────────────
78
+
79
+ /**
80
+ * Run async fn(item) over items with at most `limit` in flight at once, via a
81
+ * pull-based worker pool (not chunked batches, so a slow item never stalls
82
+ * the rest of a batch). Node's fs promise APIs are backed by libuv's
83
+ * fixed-size threadpool (UV_THREADPOOL_SIZE, default 4) — firing every call
84
+ * at once via a single Promise.all still queues behind that pool one way or
85
+ * another, so bounding `limit` mainly avoids building thousands of pending
86
+ * promises/Buffers at once for a 25k+-file walk. Results are returned in
87
+ * input order; a thrown fn(item) rejects the whole call (callers here always
88
+ * catch inside fn instead).
89
+ */
90
+ async function mapWithConcurrency(items, limit, fn) {
91
+ const results = new Array(items.length);
92
+ let next = 0;
93
+ async function worker() {
94
+ for (;;) {
95
+ const i = next++;
96
+ if (i >= items.length) return;
97
+ results[i] = await fn(items[i], i);
98
+ }
99
+ }
100
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker);
101
+ await Promise.all(workers);
102
+ return results;
103
+ }
104
+
77
105
  // ── date helpers ──────────────────────────────────────────────────────────────
78
106
 
79
107
  function decodeCwd(encoded) {
@@ -558,63 +586,171 @@ async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
558
586
  return { finalizedDates: partial ? [] : Array.from(dates), partial };
559
587
  }
560
588
 
589
+ // ── intraday fast-walk state ──────────────────────────────────────────────────
590
+ //
591
+ // Two persistent (in-memory only, process-lifetime) caches make repeat ticks
592
+ // cheap:
593
+ //
594
+ // 1. `intradayDirRegistry` — encodedCwd -> { mtimeMs, fileNames }. Verified
595
+ // empirically on this machine (ext4): appending to an EXISTING file does
596
+ // NOT change its parent directory's mtime — only creating/removing/
597
+ // renaming a directory entry does. So a directory's mtime is a safe signal
598
+ // for "no file was added or removed here since last tick", letting us skip
599
+ // re-`readdir()`ing it (2,044 directories on the dev machine — this alone
600
+ // doesn't bound the file-level stat count).
601
+ //
602
+ // 2. `intradayNotToday` — a permanent Set<filePath> of files CONFIRMED to
603
+ // belong to a day other than today, so they can never contribute to
604
+ // today's bucket again. This is the lever that makes the walk actually
605
+ // proportional to files modified today, and it's safe only because of an
606
+ // existing, unrelated invariant this PRD does not change: a transcript's
607
+ // `sessionDate` is fixed by its FIRST message's timestamp and never
608
+ // updates on later appends (see parseJSONL's cache — `merged.sessionDate
609
+ // = prev.sessionDate`), so `aggregate()`/`refreshIntradayToday()` already
610
+ // attribute 100% of a file's activity to its original day no matter how
611
+ // many later days it's appended on. A file whose mtime is confirmed to
612
+ // predate today can have no content newer than that mtime, so its
613
+ // sessionDate can't be today either — that classification is exclusion
614
+ // forever, not just for the rest of today. Combined with #1 (a file
615
+ // relevant to today must have been CREATED today, which does bump its
616
+ // directory's mtime and is therefore always discovered), the walk over
617
+ // time shrinks to: files still active today (small, must keep re-stat-ing
618
+ // to catch growth) + any brand-new file since the last tick. Everything
619
+ // else is skipped without even a stat(2) call. Remaining stat/readdir
620
+ // calls run with bounded concurrency instead of one sequential await per
621
+ // call, since that was the other half of the sequential-walk cost
622
+ // (29,625 stats + 2,044 readdirs, 589ms measured on this machine).
623
+ let intradayDirRegistry = new Map(); // encodedCwd -> { mtimeMs, fileNames: string[] }
624
+ let intradayNotToday = new Set(); // filePath -> confirmed not-today, forever
625
+
626
+ const INTRADAY_STAT_CONCURRENCY = 64;
627
+ const INTRADAY_STRATEGY = 'directory-mtime skips re-readdir of unchanged dirs (new-file detection); ' +
628
+ 'a file confirmed to belong to a past day (mtime, then sessionDate) is excluded from every future tick ' +
629
+ '(sessionDate is pinned to a transcript\'s first message and never changes on later appends); ' +
630
+ `remaining stat/readdir calls run with bounded concurrency (${INTRADAY_STAT_CONCURRENCY} in flight)`;
631
+
632
+ /** Test-only: clears the in-memory directory/exclusion registries between fixtures. */
633
+ function resetIntradayRegistryForTests() {
634
+ intradayDirRegistry = new Map();
635
+ intradayNotToday = new Set();
636
+ }
637
+
561
638
  /**
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.
639
+ * Pure walk-and-parse step (no rollup write) — split out from
640
+ * refreshIntradayToday() so it can be benchmarked read-only against the real
641
+ * ~/.claude/projects (see scripts/bench-intraday-walk.cjs) without touching
642
+ * the rollup file on disk.
571
643
  */
572
- async function refreshIntradayToday() {
644
+ async function computeIntradayBuckets() {
573
645
  const today = localDate(new Date());
574
646
 
575
647
  let projectDirs;
576
648
  try {
577
649
  projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
578
650
  } catch {
579
- return { date: today, projectsUpdated: 0 };
651
+ return { today, buckets: new Map() };
580
652
  }
581
653
 
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);
654
+ const dirEntries = projectDirs.filter((e) => e.isDirectory());
587
655
 
588
- let files;
589
- try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
656
+ // Directory count (thousands) is far smaller than file count (tens of
657
+ // thousands), so stat-ing every directory concurrently is cheap and tells
658
+ // us which ones had an entry added/removed since we last looked.
659
+ const dirStats = await mapWithConcurrency(dirEntries, INTRADAY_STAT_CONCURRENCY, async (entry) => {
660
+ const dirPath = path.join(PROJECTS_DIR, entry.name);
661
+ try {
662
+ const stat = await fsp.stat(dirPath);
663
+ return { encodedCwd: entry.name, dirPath, mtimeMs: stat.mtimeMs };
664
+ } catch {
665
+ return null;
666
+ }
667
+ });
590
668
 
591
- for (const fileEntry of files) {
592
- if (!fileEntry.name.endsWith('.jsonl')) continue;
593
- const filePath = path.join(projectDir, fileEntry.name);
669
+ // Resolve the jsonl file list per directory — reuse the cached list (no
670
+ // readdir syscall) when the directory's mtime hasn't moved since the last
671
+ // tick; re-enumerate only directories that are new or changed. Files
672
+ // already known to be irrelevant to any future "today" are dropped here so
673
+ // they never reach the stat step below.
674
+ const fileCandidates = []; // { filePath, encodedCwd }
675
+ await mapWithConcurrency(dirStats.filter(Boolean), INTRADAY_STAT_CONCURRENCY, async (d) => {
676
+ const cached = intradayDirRegistry.get(d.encodedCwd);
677
+ let fileNames;
678
+ if (cached && cached.mtimeMs === d.mtimeMs) {
679
+ fileNames = cached.fileNames;
680
+ } else {
681
+ let files;
682
+ try { files = await fsp.readdir(d.dirPath, { withFileTypes: true }); } catch { files = []; }
683
+ fileNames = files.filter((f) => f.isFile() && f.name.endsWith('.jsonl')).map((f) => f.name);
684
+ intradayDirRegistry.set(d.encodedCwd, { mtimeMs: d.mtimeMs, fileNames });
685
+ }
686
+ for (const name of fileNames) {
687
+ const filePath = path.join(d.dirPath, name);
688
+ if (intradayNotToday.has(filePath)) continue;
689
+ fileCandidates.push({ filePath, encodedCwd: d.encodedCwd });
690
+ }
691
+ });
594
692
 
595
- let stat;
596
- try { stat = await fsp.stat(filePath); } catch { continue; }
693
+ // Known caveat: parseJSONL separately detects a same-path file replacement
694
+ // (inode change — e.g. a `claude --resume` compaction rewriting a
695
+ // transcript from scratch) and re-parses it in full. If that happens to a
696
+ // path already in intradayNotToday, this walk will never look at it again
697
+ // to notice its (hypothetical) new sessionDate, since exclusion here skips
698
+ // the stat entirely. This is safe in practice: the History dashboard's
699
+ // actual reads (aggregate()) don't consult this cache at all and always
700
+ // re-walk live for "today", so the only blast radius is a stale PROVISIONAL
701
+ // rollup line for the affected project until the next app restart resets
702
+ // this cache — never a wrong number on the dashboard itself.
703
+ const buckets = new Map();
704
+ await mapWithConcurrency(fileCandidates, INTRADAY_STAT_CONCURRENCY, async ({ filePath, encodedCwd }) => {
705
+ let stat;
706
+ try { stat = await fsp.stat(filePath); } catch { return; }
707
+
708
+ // A file whose mtime is before today can't have any content from today —
709
+ // and per the sessionDate-pinning invariant above, can never gain any —
710
+ // so it's excluded forever, with no parse ever needed.
711
+ const mtimeDate = localDate(new Date(stat.mtimeMs));
712
+ if (mtimeDate < today) {
713
+ intradayNotToday.add(filePath);
714
+ return;
715
+ }
597
716
 
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;
717
+ const { result: parsed } = await parseJSONL(filePath, stat);
718
+ if (parsed.skipped) return;
719
+ if (parsed.sessionDate !== today) {
720
+ intradayNotToday.add(filePath);
721
+ return;
722
+ }
602
723
 
603
- const { result: parsed } = await parseJSONL(filePath, stat);
604
- if (parsed.skipped) continue;
605
- if (parsed.sessionDate !== today) continue;
724
+ const key = `${today}|${encodedCwd}`;
725
+ if (!buckets.has(key)) buckets.set(key, emptyBucket(today, encodedCwd));
726
+ foldParsedIntoBucket(buckets.get(key), parsed);
727
+ });
606
728
 
607
- const key = `${today}|${encodedCwd}`;
608
- if (!buckets.has(key)) buckets.set(key, emptyBucket(today, encodedCwd));
609
- foldParsedIntoBucket(buckets.get(key), parsed);
610
- }
611
- }
729
+ return { today, buckets };
730
+ }
731
+
732
+ /**
733
+ * Refresh TODAY's rollup buckets from a live (LRU-warm, cheap) parse of every
734
+ * transcript touched today, and upsert them as provisional lines (v2, no
735
+ * finalizedAt) so the History dashboard's "today" row stays current without
736
+ * the renderer ever triggering a transcript scan itself. Meant to be called
737
+ * on a timer (HISTORY_INTRADAY_REFRESH_MS) plus once at app start.
738
+ *
739
+ * readRollup/appendRollupDays last-write-wins per (date, projectDir, modelId)
740
+ * key, so re-running this simply replaces today's previous provisional lines
741
+ * with fresher ones — no separate "clear provisional" step needed.
742
+ *
743
+ * See INTRADAY_STRATEGY / intradayDirRegistry's comment above for why this is
744
+ * both faster and no less correct than the old unconditional full walk.
745
+ */
746
+ async function refreshIntradayToday() {
747
+ const { today, buckets } = await computeIntradayBuckets();
612
748
 
613
749
  const entries = [];
614
750
  for (const b of buckets.values()) entries.push(...bucketToRollupLines(b));
615
751
  if (entries.length) await historyRollup.appendRollupDays(entries);
616
752
 
617
- return { date: today, projectsUpdated: buckets.size };
753
+ return { date: today, projectsUpdated: buckets.size, strategy: INTRADAY_STRATEGY };
618
754
  }
619
755
 
620
756
  // ── aggregate ─────────────────────────────────────────────────────────────────
@@ -821,6 +957,7 @@ module.exports = {
821
957
  MODEL_PRICING,
822
958
  finalizeClosedDays,
823
959
  refreshIntradayToday,
960
+ computeIntradayBuckets,
824
961
  // exported for tests
825
962
  scanAggrLines,
826
963
  parseJSONL,
@@ -828,6 +965,9 @@ module.exports = {
828
965
  CACHE_MAX,
829
966
  LRUCache,
830
967
  computeActiveMinutes,
968
+ mapWithConcurrency,
969
+ resetIntradayRegistryForTests,
970
+ INTRADAY_STAT_CONCURRENCY,
831
971
  // exported for historyDashboard.cjs (pure, no I/O at require-time; reused
832
972
  // rather than re-implemented so date-range math stays in one place)
833
973
  localDate,
@@ -58,6 +58,7 @@ const { registerBilkoHostIpc } = require('./bilkoHost.cjs');
58
58
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
59
59
  const agentMemory = require('./agentMemory.cjs');
60
60
  const git = require('./git.cjs');
61
+ const heapSnapshot = require('./heapSnapshot.cjs');
61
62
  const filesIpc = require('./files.cjs');
62
63
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
63
64
  const { listExchanges } = require('./exchanges.cjs');
@@ -830,6 +831,10 @@ ipcMain.handle('otel:set-config', async (_e, cfg) => {
830
831
  ipcMain.handle('otel:status', () => otel.status());
831
832
  ipcMain.handle('otel:config-path', () => otelSettings.storePath());
832
833
 
834
+ // Diagnostic-only renderer heap snapshot. No-op unless SM_HEAP_SNAPSHOT=1 —
835
+ // see heapSnapshot.cjs for why this stays off by default.
836
+ heapSnapshot.registerIpc({ ipcMain, getWindow: () => mainWindow });
837
+
833
838
  // --- App lifecycle ---
834
839
 
835
840
  // E2E under xvfb-run has no working GPU; the GPU process exits ~15 seconds
@@ -1037,6 +1042,8 @@ app.whenReady().then(async () => {
1037
1042
  try { await systemPreferences.askForMediaAccess('microphone'); } catch { /* */ }
1038
1043
  }
1039
1044
 
1045
+ const heapSnapshotMenuItem = heapSnapshot.buildMenuItem(() => mainWindow);
1046
+
1040
1047
  const template = [
1041
1048
  {
1042
1049
  label: 'Session Manager',
@@ -1073,6 +1080,8 @@ app.whenReady().then(async () => {
1073
1080
  submenu: [
1074
1081
  { label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
1075
1082
  { label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
1083
+ // Diagnostic-only; absent unless SM_HEAP_SNAPSHOT=1 (see heapSnapshot.cjs).
1084
+ ...(heapSnapshotMenuItem ? [heapSnapshotMenuItem] : []),
1076
1085
  ],
1077
1086
  },
1078
1087
  {
@@ -89,6 +89,14 @@ const transcriptReadRef = z.object({
89
89
  // ──────────────────────────────────────────── Config
90
90
  const configPath = z.object({ path: z.string().min(1).max(4096) });
91
91
 
92
+ // maxBytes: optional bounded prefix-read length in bytes. Capped at 8 MiB —
93
+ // generous for any legitimate "peek at the start of a file" caller while
94
+ // still blocking a renderer from asking for an effectively-unbounded read.
95
+ const configReadText = z.object({
96
+ path: z.string().min(1).max(4096),
97
+ maxBytes: z.number().int().min(1).max(8 * 1024 * 1024).optional(),
98
+ });
99
+
92
100
  // `writer` carries the renderer's declared owner id for the single-writer law
93
101
  // (lib/opsOwnership.cjs). Optional on the wire because most writes target
94
102
  // ~/.claude, not a project ops root; writes INSIDE the ops root are refused
@@ -790,6 +798,7 @@ module.exports = {
790
798
  transcriptPage,
791
799
  transcriptReadRef,
792
800
  configPath,
801
+ configReadText,
793
802
  configWriteJson,
794
803
  configWriteText,
795
804
  configListDir,