claude-code-session-manager 0.21.2 → 0.21.4

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 (54) hide show
  1. package/bin/cli.cjs +5 -0
  2. package/dist/assets/{TiptapBody-CepFtp62.js → TiptapBody-CZLSQ6pj.js} +2 -2
  3. package/dist/assets/cssMode-DfqZGMQs.js +1 -0
  4. package/dist/assets/{freemarker2-DqQlU_4i.js → freemarker2-XTPYh37h.js} +1 -1
  5. package/dist/assets/handlebars-DKUF5VyH.js +1 -0
  6. package/dist/assets/html-uqoqsIeI.js +1 -0
  7. package/dist/assets/htmlMode-aMTQs1su.js +1 -0
  8. package/dist/assets/index-BUrrcj7x.js +3525 -0
  9. package/dist/assets/index-DeQI4oVI.css +32 -0
  10. package/dist/assets/javascript-BVxRZMds.js +1 -0
  11. package/dist/assets/{jsonMode-CFEryxme.js → jsonMode-D04xP2s5.js} +4 -4
  12. package/dist/assets/liquid-BkQHTH2P.js +1 -0
  13. package/dist/assets/lspLanguageFeatures-By9uLznH.js +4 -0
  14. package/dist/assets/mdx-Du1IlbjV.js +1 -0
  15. package/dist/assets/{index-CrE67_1W.css → monaco-editor-BTnBOi8r.css} +1 -32
  16. package/dist/assets/monaco-editor-BW5C4Iv1.js +908 -0
  17. package/dist/assets/python-DSlImqXd.js +1 -0
  18. package/dist/assets/razor-BmUVyvSK.js +1 -0
  19. package/dist/assets/{tsMode-CNLm8WAZ.js → tsMode-Btj0TTH7.js} +1 -1
  20. package/dist/assets/typescript-Bzelq9vO.js +1 -0
  21. package/dist/assets/xml-Whd9EaSd.js +1 -0
  22. package/dist/assets/yaml-QYf0-IN8.js +1 -0
  23. package/dist/index.html +4 -2
  24. package/package.json +1 -1
  25. package/src/main/__tests__/runVerify.test.cjs +138 -0
  26. package/src/main/config.cjs +36 -4
  27. package/src/main/historyAggregator.cjs +400 -149
  28. package/src/main/index.cjs +8 -0
  29. package/src/main/ipcSchemas.cjs +42 -13
  30. package/src/main/kg.cjs +87 -30
  31. package/src/main/lib/credentials.cjs +7 -0
  32. package/src/main/lib/e2eStateMachine.cjs +39 -0
  33. package/src/main/runVerify.cjs +51 -5
  34. package/src/main/scheduler/prdParser.cjs +16 -1
  35. package/src/main/scheduler.cjs +171 -13
  36. package/src/main/transcripts.cjs +141 -19
  37. package/src/main/usageMatrix.cjs +7 -3
  38. package/src/main/webRemote.cjs +196 -31
  39. package/src/preload/api.d.ts +40 -0
  40. package/src/preload/index.cjs +7 -0
  41. package/dist/assets/cssMode-8hR_Zezu.js +0 -1
  42. package/dist/assets/handlebars-Ts2NzFcS.js +0 -1
  43. package/dist/assets/html-QjLxt2p6.js +0 -1
  44. package/dist/assets/htmlMode-Dst38sy3.js +0 -1
  45. package/dist/assets/index-XKsJ4Pk3.js +0 -4431
  46. package/dist/assets/javascript-CNxLjNGz.js +0 -1
  47. package/dist/assets/liquid-BBfKLTB_.js +0 -1
  48. package/dist/assets/lspLanguageFeatures-BNyh7ouG.js +0 -4
  49. package/dist/assets/mdx-SaTyS1xC.js +0 -1
  50. package/dist/assets/python-C84TNhMd.js +0 -1
  51. package/dist/assets/razor-BaVJM3L8.js +0 -1
  52. package/dist/assets/typescript-BdrDpzPy.js +0 -1
  53. package/dist/assets/xml-CHJ3Xjjj.js +0 -1
  54. package/dist/assets/yaml-Cg2-K8t3.js +0 -1
@@ -7,8 +7,48 @@ const os = require('node:os');
7
7
  const { schemas } = require('./ipcSchemas.cjs');
8
8
 
9
9
  const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
10
- const SLOW_THRESHOLD_MS = 2_000;
10
+ const PARSE_BUDGET_MS = 2_000;
11
11
  const MAX_FILE_BYTES = 20 * 1024 * 1024;
12
+ const CACHE_MAX = 500;
13
+
14
+ // ── LRU cache ─────────────────────────────────────────────────────────────────
15
+ // Backed by an insertion-order Map: delete+re-insert on access = O(1) LRU.
16
+ class LRUCache {
17
+ constructor(max) {
18
+ this._max = max;
19
+ this._m = new Map();
20
+ }
21
+ get(k) {
22
+ if (!this._m.has(k)) return undefined;
23
+ const v = this._m.get(k);
24
+ this._m.delete(k);
25
+ this._m.set(k, v);
26
+ return v;
27
+ }
28
+ set(k, v) {
29
+ this._m.delete(k);
30
+ this._m.set(k, v);
31
+ if (this._m.size > this._max) this._m.delete(this._m.keys().next().value);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Cache for parseJSONL results.
37
+ * Entry shape: { mtimeMs: number, size: number, readOffset: number, inode: number, result: AggrResult }
38
+ * `size` mirrors stat.size (used for exact-hit comparison).
39
+ * `readOffset` is the byte position of the end of the last complete line
40
+ * (≤ size) — the start position for the next tail-read so we never start
41
+ * mid-line.
42
+ */
43
+ const aggrCache = new LRUCache(CACHE_MAX);
44
+
45
+ /**
46
+ * Cache for parseConversationMeta results.
47
+ * Entry shape: { mtimeMs: number, size: number, readOffset: number, inode: number, result: MetaResult }
48
+ */
49
+ const metaCache = new LRUCache(CACHE_MAX);
50
+
51
+ // ── date helpers ──────────────────────────────────────────────────────────────
12
52
 
13
53
  function decodeCwd(encoded) {
14
54
  return '/' + encoded.replace(/-+/g, '/');
@@ -30,46 +70,37 @@ function subtractDays(dateStr, days) {
30
70
  return localDate(d);
31
71
  }
32
72
 
33
- async function parseJSONL(filePath, stat) {
34
- const acc = {
35
- promptCount: 0,
36
- inputTokens: 0,
37
- outputTokens: 0,
38
- cacheReadTokens: 0,
39
- cacheCreationTokens: 0,
40
- toolCallCount: 0,
41
- toolBreakdown: {},
42
- errorCount: 0,
43
- sessionDate: null,
44
- skipped: false,
45
- };
73
+ // ── low-level I/O ─────────────────────────────────────────────────────────────
46
74
 
47
- if (stat.size > MAX_FILE_BYTES) {
48
- acc.skipped = true;
49
- return acc;
50
- }
51
-
52
- let text;
75
+ /** Read bytes [from, to) from filePath and return as a UTF-8 string. */
76
+ async function readSlice(filePath, from, to) {
77
+ const len = to - from;
78
+ if (len <= 0) return '';
79
+ const fh = await fsp.open(filePath, 'r');
53
80
  try {
54
- text = await fsp.readFile(filePath, 'utf8');
55
- } catch {
56
- return acc;
81
+ const buf = Buffer.alloc(len);
82
+ const { bytesRead } = await fh.read(buf, 0, len, from);
83
+ return buf.subarray(0, bytesRead).toString('utf8');
84
+ } finally {
85
+ await fh.close();
57
86
  }
87
+ }
58
88
 
59
- const lines = text.split('\n');
60
- let firstTs = null;
89
+ // ── line scanners ─────────────────────────────────────────────────────────────
61
90
 
91
+ /**
92
+ * Scan JSONL lines into an aggregate accumulator (mutates acc).
93
+ * Returns the first timestamp seen when captureFirst=true, else null.
94
+ */
95
+ function scanAggrLines(lines, acc, captureFirst) {
96
+ let firstTs = null;
62
97
  for (const raw of lines) {
63
98
  const line = raw.trim();
64
99
  if (!line) continue;
65
100
  let obj;
66
- try {
67
- obj = JSON.parse(line);
68
- } catch {
69
- continue;
70
- }
101
+ try { obj = JSON.parse(line); } catch { continue; }
71
102
 
72
- if (firstTs === null) {
103
+ if (captureFirst && firstTs === null) {
73
104
  const ts = obj.ts ?? obj.timestamp;
74
105
  if (ts) firstTs = ts;
75
106
  }
@@ -81,8 +112,6 @@ async function parseJSONL(filePath, stat) {
81
112
  if (usage && typeof usage === 'object') {
82
113
  // Claude Code JSONLs use snake_case (matching the Anthropic API). The
83
114
  // previous camelCase-only check meant every token count read as 0.
84
- // Accept both shapes for forward-compat with any future renderer-side
85
- // emitter (live.ts already normalizes both).
86
115
  const inT = usage.input_tokens ?? usage.inputTokens;
87
116
  const outT = usage.output_tokens ?? usage.outputTokens;
88
117
  const cacheR = usage.cache_read_input_tokens ?? usage.cacheReadInputTokens;
@@ -111,27 +140,14 @@ async function parseJSONL(filePath, stat) {
111
140
  acc.errorCount++;
112
141
  }
113
142
  }
114
-
115
- try {
116
- acc.sessionDate = firstTs
117
- ? localDate(new Date(firstTs))
118
- : localDate(new Date(stat.mtimeMs));
119
- } catch {
120
- acc.sessionDate = localDate(new Date(stat.mtimeMs));
121
- }
122
-
123
- return acc;
143
+ return firstTs;
124
144
  }
125
145
 
126
- /** Lightweight per-file meta: { firstTs, lastTs, inputTokens, outputTokens, skipped }.
127
- * Powers the `history:list-conversations` IPC used by the Overview detailed-
128
- * stats panel. Single-pass O(L) scan, only honors ts + usage blocks. */
129
- async function parseConversationMeta(filePath, stat) {
130
- const meta = { firstTs: null, lastTs: null, inputTokens: 0, outputTokens: 0, skipped: false };
131
- if (stat.size > MAX_FILE_BYTES) { meta.skipped = true; return meta; }
132
- let text;
133
- try { text = await fsp.readFile(filePath, 'utf8'); } catch { return meta; }
134
- const lines = text.split('\n');
146
+ /**
147
+ * Scan JSONL lines into a conversation-meta accumulator (mutates meta).
148
+ * captureFirst=true: record the first timestamp seen as meta.firstTs.
149
+ */
150
+ function scanMetaLines(lines, meta, captureFirst) {
135
151
  for (const raw of lines) {
136
152
  const line = raw.trim();
137
153
  if (!line) continue;
@@ -139,7 +155,7 @@ async function parseConversationMeta(filePath, stat) {
139
155
  try { obj = JSON.parse(line); } catch { continue; }
140
156
  const ts = obj.ts ?? obj.timestamp;
141
157
  if (ts) {
142
- if (meta.firstTs === null) meta.firstTs = ts;
158
+ if (captureFirst && meta.firstTs === null) meta.firstTs = ts;
143
159
  meta.lastTs = ts;
144
160
  }
145
161
  const usage = obj.usage ?? obj.message?.usage;
@@ -150,109 +166,286 @@ async function parseConversationMeta(filePath, stat) {
150
166
  if (typeof outT === 'number') meta.outputTokens += outT;
151
167
  }
152
168
  }
153
- return meta;
154
169
  }
155
170
 
156
- async function aggregate(req) {
157
- const t0 = Date.now();
158
- const today = localDate(new Date());
159
- let effectiveTo = req?.toDate ? req.toDate : today;
160
- if (effectiveTo > today) effectiveTo = today;
161
- const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
171
+ // ── cached file parsers ───────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Parse a JSONL transcript for history aggregation.
175
+ * Returns { result, cacheHit } where cacheHit=true means no I/O was performed.
176
+ *
177
+ * Cache strategy:
178
+ * same (mtimeMs, size) → exact hit, no I/O
179
+ * size grown, same path → tail-parse new bytes from cached.size, merge
180
+ * otherwise → full reparse (file replaced or truncated)
181
+ */
182
+ async function parseJSONL(filePath, stat) {
183
+ const emptyAcc = () => ({
184
+ promptCount: 0,
185
+ inputTokens: 0,
186
+ outputTokens: 0,
187
+ cacheReadTokens: 0,
188
+ cacheCreationTokens: 0,
189
+ toolCallCount: 0,
190
+ toolBreakdown: {},
191
+ errorCount: 0,
192
+ sessionDate: null,
193
+ skipped: false,
194
+ });
162
195
 
163
- const buckets = new Map();
164
- let partial = false;
165
- let skippedLargeFiles = 0;
196
+ if (stat.size > MAX_FILE_BYTES) {
197
+ return { result: { ...emptyAcc(), skipped: true }, cacheHit: false };
198
+ }
166
199
 
167
- let projectDirs;
168
- try {
169
- projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
170
- } catch {
171
- return { rows: [], partial: false, scannedMs: Date.now() - t0 };
200
+ const cached = aggrCache.get(filePath);
201
+ if (cached) {
202
+ if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
203
+ return { result: cached.result, cacheHit: true };
172
204
  }
173
205
 
174
- for (const projEntry of projectDirs) {
175
- if (!projEntry.isDirectory()) continue;
176
- const encodedCwd = projEntry.name;
177
- const projectDir = path.join(PROJECTS_DIR, encodedCwd);
178
-
179
- let files;
180
- try {
181
- files = await fsp.readdir(projectDir, { withFileTypes: true });
182
- } catch {
183
- continue;
206
+ if (stat.size > cached.size) {
207
+ // Inode change means the file was replaced (e.g. claude --resume
208
+ // compaction). Don't tail-parse a stale byte range into new content.
209
+ if (cached.inode !== undefined && cached.inode !== stat.ino) {
210
+ // fall through to full parse
211
+ } else {
212
+ // Append-only tail parse: read only the new bytes. Use cached.readOffset
213
+ // (the end of the last complete line) as the start so we never begin
214
+ // mid-line. Falls back to cached.size for pre-fix cache entries.
215
+ try {
216
+ const readFrom = cached.readOffset ?? cached.size;
217
+ const tail = await readSlice(filePath, readFrom, stat.size);
218
+ const delta = emptyAcc();
219
+ scanAggrLines(tail.split('\n'), delta, false);
220
+ const prev = cached.result;
221
+ const merged = {
222
+ promptCount: prev.promptCount + delta.promptCount,
223
+ inputTokens: prev.inputTokens + delta.inputTokens,
224
+ outputTokens: prev.outputTokens + delta.outputTokens,
225
+ cacheReadTokens: prev.cacheReadTokens + delta.cacheReadTokens,
226
+ cacheCreationTokens: prev.cacheCreationTokens + delta.cacheCreationTokens,
227
+ toolCallCount: prev.toolCallCount + delta.toolCallCount,
228
+ toolBreakdown: { ...prev.toolBreakdown },
229
+ errorCount: prev.errorCount + delta.errorCount,
230
+ sessionDate: prev.sessionDate, // firstTs doesn't change on appends
231
+ skipped: false,
232
+ };
233
+ for (const [k, v] of Object.entries(delta.toolBreakdown)) {
234
+ merged.toolBreakdown[k] = (merged.toolBreakdown[k] ?? 0) + v;
235
+ }
236
+ // readOffset advances to the last complete newline so the next tail
237
+ // always starts at a line boundary. size stays at stat.size so the
238
+ // exact-hit check works correctly on the next call.
239
+ const lastNl = tail.lastIndexOf('\n');
240
+ const readOffset = lastNl >= 0 ? readFrom + lastNl + 1 : readFrom;
241
+ aggrCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset, inode: stat.ino, result: merged });
242
+ return { result: merged, cacheHit: false };
243
+ } catch {
244
+ // fall through to full parse
245
+ }
184
246
  }
247
+ }
248
+ // size shrank, inode changed, or mtime changed → file was replaced; full reparse below
249
+ }
185
250
 
186
- for (const fileEntry of files) {
187
- if (!fileEntry.name.endsWith('.jsonl')) continue;
188
- const filePath = path.join(projectDir, fileEntry.name);
251
+ // Full parse
252
+ let text;
253
+ try { text = await fsp.readFile(filePath, 'utf8'); } catch {
254
+ return { result: emptyAcc(), cacheHit: false };
255
+ }
189
256
 
190
- let stat;
257
+ const acc = emptyAcc();
258
+ const firstTs = scanAggrLines(text.split('\n'), acc, true);
259
+
260
+ try {
261
+ acc.sessionDate = firstTs
262
+ ? localDate(new Date(firstTs))
263
+ : localDate(new Date(stat.mtimeMs));
264
+ } catch {
265
+ acc.sessionDate = localDate(new Date(stat.mtimeMs));
266
+ }
267
+
268
+ const lastNlFull = text.lastIndexOf('\n');
269
+ const readOffsetFull = lastNlFull >= 0 ? lastNlFull + 1 : 0;
270
+ aggrCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset: readOffsetFull, inode: stat.ino, result: acc });
271
+ return { result: acc, cacheHit: false };
272
+ }
273
+
274
+ /**
275
+ * Parse a JSONL transcript for per-conversation metadata.
276
+ * Returns { result, cacheHit } — same caching strategy as parseJSONL.
277
+ */
278
+ async function parseConversationMeta(filePath, stat) {
279
+ const emptyMeta = () => ({
280
+ firstTs: null,
281
+ lastTs: null,
282
+ inputTokens: 0,
283
+ outputTokens: 0,
284
+ skipped: false,
285
+ });
286
+
287
+ if (stat.size > MAX_FILE_BYTES) {
288
+ return { result: { ...emptyMeta(), skipped: true }, cacheHit: false };
289
+ }
290
+
291
+ const cached = metaCache.get(filePath);
292
+ if (cached) {
293
+ if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
294
+ return { result: cached.result, cacheHit: true };
295
+ }
296
+
297
+ if (stat.size > cached.size) {
298
+ if (cached.inode !== undefined && cached.inode !== stat.ino) {
299
+ // inode changed → file replaced; fall through to full parse
300
+ } else {
191
301
  try {
192
- stat = await fsp.stat(filePath);
302
+ const readFrom = cached.readOffset ?? cached.size;
303
+ const tail = await readSlice(filePath, readFrom, stat.size);
304
+ const delta = emptyMeta();
305
+ scanMetaLines(tail.split('\n'), delta, false);
306
+ const prev = cached.result;
307
+ const merged = {
308
+ firstTs: prev.firstTs, // first timestamp never changes on appends
309
+ lastTs: delta.lastTs ?? prev.lastTs,
310
+ inputTokens: prev.inputTokens + delta.inputTokens,
311
+ outputTokens: prev.outputTokens + delta.outputTokens,
312
+ skipped: false,
313
+ };
314
+ const lastNl = tail.lastIndexOf('\n');
315
+ const readOffset = lastNl >= 0 ? readFrom + lastNl + 1 : readFrom;
316
+ metaCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset, inode: stat.ino, result: merged });
317
+ return { result: merged, cacheHit: false };
193
318
  } catch {
194
- continue;
319
+ // fall through to full parse
195
320
  }
321
+ }
322
+ }
323
+ }
196
324
 
197
- const parsed = await parseJSONL(filePath, stat);
198
- if (parsed.skipped) { skippedLargeFiles++; continue; }
199
-
200
- const { sessionDate } = parsed;
201
- // Inclusive upper bound — `>=` here previously meant "today's data is
202
- // always dropped", which combined with the (then-UTC) date bucket to
203
- // hide a Pacific-time user's most recent activity entirely.
204
- if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
205
-
206
- const key = `${sessionDate}|${encodedCwd}`;
207
- if (!buckets.has(key)) {
208
- buckets.set(key, {
209
- date: sessionDate,
210
- projectCwd: decodeCwd(encodedCwd),
211
- encodedCwd,
212
- promptCount: 0,
213
- inputTokens: 0,
214
- outputTokens: 0,
215
- cacheReadTokens: 0,
216
- cacheCreationTokens: 0,
217
- toolCallCount: 0,
218
- toolBreakdown: {},
219
- sessionCount: 0,
220
- errorCount: 0,
221
- });
222
- }
325
+ // Full parse
326
+ let text;
327
+ try { text = await fsp.readFile(filePath, 'utf8'); } catch {
328
+ return { result: emptyMeta(), cacheHit: false };
329
+ }
223
330
 
224
- const b = buckets.get(key);
225
- b.promptCount += parsed.promptCount;
226
- b.inputTokens += parsed.inputTokens;
227
- b.outputTokens += parsed.outputTokens;
228
- b.cacheReadTokens += parsed.cacheReadTokens;
229
- b.cacheCreationTokens += parsed.cacheCreationTokens;
230
- b.toolCallCount += parsed.toolCallCount;
231
- for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
232
- b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
233
- }
234
- b.sessionCount++;
235
- b.errorCount += parsed.errorCount;
331
+ const meta = emptyMeta();
332
+ scanMetaLines(text.split('\n'), meta, true);
333
+ const lastNlMeta = text.lastIndexOf('\n');
334
+ const readOffsetMeta = lastNlMeta >= 0 ? lastNlMeta + 1 : 0;
335
+ metaCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset: readOffsetMeta, inode: stat.ino, result: meta });
336
+ return { result: meta, cacheHit: false };
337
+ }
338
+
339
+ // ── aggregate ─────────────────────────────────────────────────────────────────
340
+
341
+ async function aggregate(req) {
342
+ const t0 = Date.now();
343
+ const today = localDate(new Date());
344
+ let effectiveTo = req?.toDate ? req.toDate : today;
345
+ if (effectiveTo > today) effectiveTo = today;
346
+ const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
347
+
348
+ const buckets = new Map();
349
+ let truncated = false;
350
+ let skippedLargeFiles = 0;
351
+ let skippedBudgetFiles = 0;
352
+ let parseBudgetSpentMs = 0;
353
+
354
+ let projectDirs;
355
+ try {
356
+ projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
357
+ } catch {
358
+ return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0 };
359
+ }
360
+
361
+ outer:
362
+ for (const projEntry of projectDirs) {
363
+ if (!projEntry.isDirectory()) continue;
364
+ const encodedCwd = projEntry.name;
365
+ const projectDir = path.join(PROJECTS_DIR, encodedCwd);
366
+
367
+ let files;
368
+ try {
369
+ files = await fsp.readdir(projectDir, { withFileTypes: true });
370
+ } catch {
371
+ continue;
372
+ }
373
+
374
+ for (const fileEntry of files) {
375
+ if (!fileEntry.name.endsWith('.jsonl')) continue;
376
+ const filePath = path.join(projectDir, fileEntry.name);
377
+
378
+ let stat;
379
+ try { stat = await fsp.stat(filePath); } catch { continue; }
380
+
381
+ const t1 = Date.now();
382
+ const { result: parsed, cacheHit } = await parseJSONL(filePath, stat);
383
+ if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
384
+
385
+ if (parsed.skipped) { skippedLargeFiles++; continue; }
386
+
387
+ const { sessionDate } = parsed;
388
+ // Inclusive upper bound — `>=` here previously meant "today's data is
389
+ // always dropped", which combined with the (then-UTC) date bucket to
390
+ // hide a Pacific-time user's most recent activity entirely.
391
+ if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
392
+
393
+ const key = `${sessionDate}|${encodedCwd}`;
394
+ if (!buckets.has(key)) {
395
+ buckets.set(key, {
396
+ date: sessionDate,
397
+ projectCwd: decodeCwd(encodedCwd),
398
+ encodedCwd,
399
+ promptCount: 0,
400
+ inputTokens: 0,
401
+ outputTokens: 0,
402
+ cacheReadTokens: 0,
403
+ cacheCreationTokens: 0,
404
+ toolCallCount: 0,
405
+ toolBreakdown: {},
406
+ sessionCount: 0,
407
+ errorCount: 0,
408
+ });
236
409
  }
237
410
 
238
- if (Date.now() - t0 > SLOW_THRESHOLD_MS) {
239
- console.warn(`[historyAggregator] slow scan: ${Date.now() - t0}ms`);
240
- partial = true;
241
- break;
411
+ const b = buckets.get(key);
412
+ b.promptCount += parsed.promptCount;
413
+ b.inputTokens += parsed.inputTokens;
414
+ b.outputTokens += parsed.outputTokens;
415
+ b.cacheReadTokens += parsed.cacheReadTokens;
416
+ b.cacheCreationTokens += parsed.cacheCreationTokens;
417
+ b.toolCallCount += parsed.toolCallCount;
418
+ for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
419
+ b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
420
+ }
421
+ b.sessionCount++;
422
+ b.errorCount += parsed.errorCount;
423
+
424
+ if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
425
+ skippedBudgetFiles++;
426
+ truncated = true;
427
+ console.warn(
428
+ `[historyAggregator] aggregate: parse budget exhausted after ${parseBudgetSpentMs}ms; ` +
429
+ `at least ${skippedBudgetFiles} file(s) skipped`
430
+ );
431
+ break outer;
242
432
  }
243
433
  }
434
+ }
244
435
 
245
- const rows = Array.from(buckets.values()).map((b) => ({
246
- ...b,
247
- estimatedCostUsd: (b.inputTokens * 3 + b.outputTokens * 15) / 1_000_000,
248
- }));
436
+ const rows = Array.from(buckets.values()).map((b) => ({
437
+ ...b,
438
+ estimatedCostUsd: (b.inputTokens * 3 + b.outputTokens * 15) / 1_000_000,
439
+ }));
249
440
 
250
- rows.sort((a, b) => a.date.localeCompare(b.date) || a.projectCwd.localeCompare(b.projectCwd));
441
+ rows.sort((a, b) => a.date.localeCompare(b.date) || a.projectCwd.localeCompare(b.projectCwd));
251
442
 
252
- const scannedMs = Date.now() - t0;
253
- return { rows, partial, scannedMs, skippedLargeFiles };
443
+ const scannedMs = Date.now() - t0;
444
+ return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles };
254
445
  }
255
446
 
447
+ // ── IPC registration ──────────────────────────────────────────────────────────
448
+
256
449
  function registerHistoryAggregatorHandlers() {
257
450
  ipcMain.handle('history:aggregate', async (_e, rawReq) => {
258
451
  // Wire the historyAggregate schema (previously defined but never used).
@@ -264,46 +457,104 @@ function registerHistoryAggregatorHandlers() {
264
457
  return aggregate(req);
265
458
  });
266
459
 
460
+ /** Flat list of all JSONL session files — sessionId, project, mtime, size.
461
+ * Single main-side scan replaces the renderer's serial per-dir IPC loop. */
462
+ ipcMain.handle('history:scan-projects', async () => {
463
+ const t0 = Date.now();
464
+ const sessions = [];
465
+ let projectDirs;
466
+ try {
467
+ projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
468
+ } catch {
469
+ return { sessions: [], scannedMs: 0 };
470
+ }
471
+ for (const proj of projectDirs) {
472
+ if (!proj.isDirectory()) continue;
473
+ const projectDir = path.join(PROJECTS_DIR, proj.name);
474
+ let files;
475
+ try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
476
+ for (const f of files) {
477
+ if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
478
+ const filePath = path.join(projectDir, f.name);
479
+ let stat;
480
+ try { stat = await fsp.stat(filePath); } catch { continue; }
481
+ sessions.push({
482
+ sessionId: f.name.replace(/\.jsonl$/, ''),
483
+ projectEncoded: proj.name,
484
+ path: filePath,
485
+ mtimeMs: stat.mtimeMs,
486
+ sizeBytes: stat.size,
487
+ });
488
+ }
489
+ }
490
+ sessions.sort((a, b) => b.mtimeMs - a.mtimeMs);
491
+ return { sessions, scannedMs: Date.now() - t0 };
492
+ });
493
+
267
494
  /** Per-conversation metadata: one row per JSONL with derived duration +
268
495
  * token totals. Used by the Overview detailed-stats panel to compute
269
496
  * hourly/daily distribution + top-projects. */
270
497
  ipcMain.handle('history:list-conversations', async () => {
271
498
  const t0 = Date.now();
272
499
  const conversations = [];
500
+ let truncated = false;
501
+ let skippedBudgetFiles = 0;
502
+ let parseBudgetSpentMs = 0;
503
+
273
504
  let projectEntries;
274
505
  try {
275
506
  projectEntries = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
276
507
  } catch {
277
- return { conversations: [], scannedMs: Date.now() - t0 };
508
+ return { conversations: [], truncated: false, scannedMs: Date.now() - t0 };
278
509
  }
510
+
511
+ outer:
279
512
  for (const ent of projectEntries) {
280
513
  if (!ent.isDirectory()) continue;
281
514
  const projectDir = path.join(PROJECTS_DIR, ent.name);
282
515
  const projectFolder = '/' + ent.name.replace(/-/g, '/');
283
516
  let files;
284
517
  try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
518
+
285
519
  for (const f of files) {
286
520
  if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
287
521
  const filePath = path.join(projectDir, f.name);
288
522
  let stat;
289
523
  try { stat = await fsp.stat(filePath); } catch { continue; }
290
- const meta = await parseConversationMeta(filePath, stat);
291
- const firstTs = meta.firstTs || new Date(stat.mtimeMs).toISOString();
292
- const duration =
293
- meta.firstTs && meta.lastTs
294
- ? Math.max(0, Date.parse(meta.lastTs) - Date.parse(meta.firstTs))
295
- : undefined;
296
- conversations.push({
297
- timestamp: firstTs,
298
- projectFolder,
299
- stats: {
300
- ...(duration !== undefined ? { duration } : {}),
301
- estimatedTokens: meta.inputTokens + meta.outputTokens,
302
- },
303
- });
524
+
525
+ const t1 = Date.now();
526
+ const { result: meta, cacheHit } = await parseConversationMeta(filePath, stat);
527
+ if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
528
+
529
+ if (!meta.skipped) {
530
+ const firstTs = meta.firstTs || new Date(stat.mtimeMs).toISOString();
531
+ const duration =
532
+ meta.firstTs && meta.lastTs
533
+ ? Math.max(0, Date.parse(meta.lastTs) - Date.parse(meta.firstTs))
534
+ : undefined;
535
+ conversations.push({
536
+ timestamp: firstTs,
537
+ projectFolder,
538
+ stats: {
539
+ ...(duration !== undefined ? { duration } : {}),
540
+ estimatedTokens: meta.inputTokens + meta.outputTokens,
541
+ },
542
+ });
543
+ }
544
+
545
+ if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
546
+ skippedBudgetFiles++;
547
+ truncated = true;
548
+ console.warn(
549
+ `[historyAggregator] list-conversations: parse budget exhausted after ${parseBudgetSpentMs}ms; ` +
550
+ `at least ${skippedBudgetFiles} file(s) skipped`
551
+ );
552
+ break outer;
553
+ }
304
554
  }
305
555
  }
306
- return { conversations, scannedMs: Date.now() - t0 };
556
+
557
+ return { conversations, truncated, scannedMs: Date.now() - t0 };
307
558
  });
308
559
  }
309
560
 
@@ -319,6 +319,14 @@ function createWindow() {
319
319
  // (and Monaco, and Tiptap) all participate in via the standard DOM
320
320
  // selection API, so this single block covers Terminal + Doc Editor + plain
321
321
  // text inputs without per-component wiring.
322
+ // Release any stale chokidar watchers that were opened by the previous
323
+ // renderer frame. Fires on Ctrl+R and HMR full-page reloads — the fresh
324
+ // renderer will re-register its own watches, so the old sender's contribution
325
+ // must be unwound first to avoid refcount ratcheting.
326
+ mainWindow.webContents.on('did-start-navigation', () => {
327
+ configMgr.releaseWatchesForSender(mainWindow.webContents.id);
328
+ });
329
+
322
330
  mainWindow.webContents.on('context-menu', (_e, params) => {
323
331
  const items = [];
324
332
  if (params.editFlags.canCopy) items.push({ label: 'Copy', role: 'copy' });