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.
- package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-BwN4pNC0.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-CVCCMw5o.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/config.cjs +31 -7
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +9 -0
- package/src/main/ipcSchemas.cjs +9 -0
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +16 -2
- package/src/preload/index.cjs +11 -2
- package/dist/assets/index-ZjTDk5b7.js +0 -3197
package/src/main/health.cjs
CHANGED
|
@@ -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
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
* the
|
|
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
|
|
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 {
|
|
651
|
+
return { today, buckets: new Map() };
|
|
580
652
|
}
|
|
581
653
|
|
|
582
|
-
const
|
|
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
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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,
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
{
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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,
|