claude-code-session-manager 0.62.0 → 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 (68) 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-Bn-LMce4.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-DOXvph-Z.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__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. package/src/preload/browserViewPreload.cjs +0 -118
@@ -0,0 +1,84 @@
1
+ /**
2
+ * config-readText-bounded.test.cjs — readText's optional { maxBytes } bounded
3
+ * prefix read. Regression coverage for perf-known-projects-bounded-cwd-read:
4
+ * resolveProjectCwd used to read whole (sometimes multi-MB) transcript files
5
+ * just to find the first line containing "cwd".
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/config-readText-bounded.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fsp = require('node:fs/promises');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const config = require('../config.cjs');
17
+
18
+ const tmpDirs = [];
19
+ afterEach(async () => {
20
+ while (tmpDirs.length) {
21
+ const d = tmpDirs.pop();
22
+ await fsp.rm(d, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ async function mkTmpFile(name, content) {
27
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-readtext-'));
28
+ config.addAllowedRoot(dir);
29
+ tmpDirs.push(dir);
30
+ const abs = path.join(dir, name);
31
+ await fsp.writeFile(abs, content, 'utf8');
32
+ return abs;
33
+ }
34
+
35
+ test('bounded read returns only the requested prefix and sets truncated', async () => {
36
+ const lines = Array.from({ length: 2000 }, (_, i) => `{"line":${i},"pad":"${'x'.repeat(50)}"}`).join('\n') + '\n';
37
+ const abs = await mkTmpFile('big.jsonl', lines);
38
+ const full = await config.readText(abs);
39
+ expect(full.truncated).toBe(false);
40
+
41
+ const bounded = await config.readText(abs, { maxBytes: 1024 });
42
+ expect(bounded.exists).toBe(true);
43
+ expect(bounded.truncated).toBe(true);
44
+ expect(bounded.text.length).toBeLessThan(full.text.length);
45
+ expect(Buffer.byteLength(bounded.text, 'utf8')).toBeLessThanOrEqual(1024);
46
+ // every line handed back must be a complete, parseable JSON line — no
47
+ // partial trailing fragment from the byte cut.
48
+ const returnedLines = bounded.text.split('\n').filter(Boolean);
49
+ for (const line of returnedLines) {
50
+ expect(() => JSON.parse(line)).not.toThrow();
51
+ }
52
+ });
53
+
54
+ test('unbounded read (no opts) is byte-identical to before', async () => {
55
+ const content = 'line one\nline two\nline three\n';
56
+ const abs = await mkTmpFile('plain.txt', content);
57
+ const r = await config.readText(abs);
58
+ expect(r.exists).toBe(true);
59
+ expect(r.text).toBe(content);
60
+ expect(r.truncated).toBe(false);
61
+ expect(typeof r.mtimeMs).toBe('number');
62
+ expect(r.error).toBe(null);
63
+ });
64
+
65
+ test('maxBytes larger than the file returns the whole file with truncated=false', async () => {
66
+ const content = 'short file\ncontent here\n';
67
+ const abs = await mkTmpFile('small.txt', content);
68
+ const r = await config.readText(abs, { maxBytes: 1024 * 1024 });
69
+ expect(r.exists).toBe(true);
70
+ expect(r.text).toBe(content);
71
+ expect(r.truncated).toBe(false);
72
+ });
73
+
74
+ test('bounded read on a missing file behaves like the unbounded case', async () => {
75
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-readtext-missing-'));
76
+ config.addAllowedRoot(dir);
77
+ tmpDirs.push(dir);
78
+ const abs = path.join(dir, 'nope.txt');
79
+ const r = await config.readText(abs, { maxBytes: 1024 });
80
+ expect(r.exists).toBe(false);
81
+ expect(r.text).toBe('');
82
+ expect(r.truncated).toBe(false);
83
+ expect(r.error).toBe(null);
84
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * heapSnapshot.test.cjs — unit tests for heapSnapshot.cjs's default-off
3
+ * gating, IPC/menu wiring, and the capture timeout bound.
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/__tests__/heapSnapshot.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect, beforeEach, afterEach } from 'vitest';
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+ const fsp = require('node:fs/promises');
14
+ const heapSnapshot = require('../heapSnapshot.cjs');
15
+
16
+ const ORIGINAL_FLAG = process.env.SM_HEAP_SNAPSHOT;
17
+
18
+ beforeEach(() => {
19
+ delete process.env.SM_HEAP_SNAPSHOT;
20
+ });
21
+
22
+ afterEach(async () => {
23
+ if (ORIGINAL_FLAG === undefined) delete process.env.SM_HEAP_SNAPSHOT;
24
+ else process.env.SM_HEAP_SNAPSHOT = ORIGINAL_FLAG;
25
+ });
26
+
27
+ test('isEnabled is false by default (no env flag)', () => {
28
+ expect(heapSnapshot.isEnabled()).toBe(false);
29
+ });
30
+
31
+ test('isEnabled is true only when SM_HEAP_SNAPSHOT=1', () => {
32
+ process.env.SM_HEAP_SNAPSHOT = '1';
33
+ expect(heapSnapshot.isEnabled()).toBe(true);
34
+ process.env.SM_HEAP_SNAPSHOT = 'true';
35
+ expect(heapSnapshot.isEnabled()).toBe(false);
36
+ });
37
+
38
+ test('registerIpc registers nothing when disabled — default-off IPC surface', () => {
39
+ const calls = [];
40
+ const fakeIpcMain = { handle: (channel, listener) => calls.push({ channel, listener }) };
41
+ const registered = heapSnapshot.registerIpc({ ipcMain: fakeIpcMain, getWindow: () => null });
42
+ expect(registered).toBe(false);
43
+ expect(calls).toHaveLength(0);
44
+ });
45
+
46
+ test('registerIpc registers the channel when enabled', () => {
47
+ process.env.SM_HEAP_SNAPSHOT = '1';
48
+ const calls = [];
49
+ const fakeIpcMain = { handle: (channel, listener) => calls.push({ channel, listener }) };
50
+ const registered = heapSnapshot.registerIpc({ ipcMain: fakeIpcMain, getWindow: () => null });
51
+ expect(registered).toBe(true);
52
+ expect(calls).toHaveLength(1);
53
+ expect(calls[0].channel).toBe(heapSnapshot.CHANNEL);
54
+ expect(calls[0].channel).toBe('diagnostics:heap-snapshot');
55
+ });
56
+
57
+ test('buildMenuItem returns null when disabled — no menu entry added', () => {
58
+ expect(heapSnapshot.buildMenuItem(() => null)).toBeNull();
59
+ });
60
+
61
+ test('buildMenuItem returns a clickable item when enabled', () => {
62
+ process.env.SM_HEAP_SNAPSHOT = '1';
63
+ const item = heapSnapshot.buildMenuItem(() => null);
64
+ expect(item).not.toBeNull();
65
+ expect(typeof item.click).toBe('function');
66
+ expect(item.label).toMatch(/heap snapshot/i);
67
+ });
68
+
69
+ test('captureSnapshot rejects when disabled, without touching the window', async () => {
70
+ const win = { webContents: { takeHeapSnapshot: () => { throw new Error('should not be called'); } } };
71
+ await expect(heapSnapshot.captureSnapshot(win)).rejects.toThrow(/disabled/i);
72
+ });
73
+
74
+ test('captureSnapshot rejects when no window is available', async () => {
75
+ process.env.SM_HEAP_SNAPSHOT = '1';
76
+ await expect(heapSnapshot.captureSnapshot(null)).rejects.toThrow(/no renderer window/i);
77
+ });
78
+
79
+ test('captureSnapshot writes the file and reports size/duration on success', async () => {
80
+ process.env.SM_HEAP_SNAPSHOT = '1';
81
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-heap-test-'));
82
+ try {
83
+ const win = {
84
+ webContents: {
85
+ takeHeapSnapshot: async (filePath) => {
86
+ await fsp.writeFile(filePath, Buffer.alloc(1024, 1));
87
+ },
88
+ },
89
+ };
90
+ const result = await heapSnapshot.captureSnapshot(win, { dir, now: new Date('2026-08-07T12:00:00.000Z') });
91
+ expect(result.filePath).toBe(path.join(dir, 'heap-2026-08-07T12-00-00-000Z.heapsnapshot'));
92
+ expect(result.bytes).toBe(1024);
93
+ expect(typeof result.ms).toBe('number');
94
+ const stat = await fsp.stat(result.filePath);
95
+ expect(stat.size).toBe(1024);
96
+ } finally {
97
+ await fsp.rm(dir, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ test('captureSnapshot times out instead of hanging forever on a stuck write', async () => {
102
+ process.env.SM_HEAP_SNAPSHOT = '1';
103
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-heap-test-'));
104
+ try {
105
+ const win = {
106
+ webContents: {
107
+ // Never resolves — simulates a stuck/blocked renderer.
108
+ takeHeapSnapshot: () => new Promise(() => {}),
109
+ },
110
+ };
111
+ await expect(
112
+ heapSnapshot.captureSnapshot(win, { dir, timeoutMs: 20 })
113
+ ).rejects.toThrow(/timed out/i);
114
+ } finally {
115
+ await fsp.rm(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ test('snapshotDir points under ~/.claude/session-manager', () => {
120
+ expect(heapSnapshot.snapshotDir()).toBe(path.join(os.homedir(), '.claude', 'session-manager'));
121
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * historyAggregatorIntraday.test.cjs — unit tests for
3
+ * historyAggregator.cjs's refreshIntradayToday() fast walk (PRD: make the
4
+ * periodic intraday refresh proportional to files actually modified today
5
+ * instead of stat-ing every jsonl file on every tick).
6
+ *
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/historyAggregatorIntraday.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, beforeEach, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ let tmpHome;
19
+ let realHome;
20
+ let historyRollup;
21
+ let historyAggregator;
22
+
23
+ function encodeCwd(cwd) {
24
+ return cwd.replace(/\//g, '-');
25
+ }
26
+
27
+ function projectsDirFor(tmp) {
28
+ return path.join(tmp, '.claude', 'projects');
29
+ }
30
+
31
+ function writeTranscript(projectCwd, sessionId, lines, mtime) {
32
+ const encoded = encodeCwd(projectCwd);
33
+ const dir = path.join(projectsDirFor(tmpHome), encoded);
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ const file = path.join(dir, `${sessionId}.jsonl`);
36
+ fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8');
37
+ if (mtime) fs.utimesSync(file, mtime, mtime);
38
+ return file;
39
+ }
40
+
41
+ function userLine(ts) {
42
+ return { role: 'user', ts };
43
+ }
44
+
45
+ function assistantLine(ts, model, tokens = {}) {
46
+ return {
47
+ ts,
48
+ message: {
49
+ role: 'assistant',
50
+ model,
51
+ usage: {
52
+ input_tokens: tokens.in ?? 10,
53
+ output_tokens: tokens.out ?? 5,
54
+ cache_read_input_tokens: tokens.cacheR ?? 0,
55
+ cache_creation_input_tokens: tokens.cacheC ?? 0,
56
+ },
57
+ },
58
+ };
59
+ }
60
+
61
+ // Same top-level-const-baking concern as historyRollup.test.cjs — see its
62
+ // comment. PROJECTS_DIR, ROLLUP_PATH, and config.cjs's allowedRoots are all
63
+ // computed from os.homedir() at first require.
64
+ const MODULES_TO_RELOAD = [
65
+ '../lib/historyRollup.cjs',
66
+ '../historyAggregator.cjs',
67
+ '../config.cjs',
68
+ ];
69
+
70
+ function purgeRequireCache() {
71
+ for (const m of MODULES_TO_RELOAD) {
72
+ try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
73
+ }
74
+ }
75
+
76
+ beforeEach(() => {
77
+ realHome = process.env.HOME;
78
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'history-intraday-test-'));
79
+ process.env.HOME = tmpHome;
80
+ purgeRequireCache();
81
+ historyRollup = require('../lib/historyRollup.cjs');
82
+ historyAggregator = require('../historyAggregator.cjs');
83
+ });
84
+
85
+ afterEach(() => {
86
+ process.env.HOME = realHome;
87
+ purgeRequireCache();
88
+ // tmpHome is a dedicated tmpdir (never the real ~/.claude/projects — HOME
89
+ // is overridden above before any fixture file is written), but clean it up
90
+ // regardless: leaked fixture dirs under a real ~/.claude/projects have
91
+ // caused phantom-project bugs before (see CLAUDE.md's
92
+ // knownProjectAggregate.ts section) — always delete what a test created.
93
+ fs.rmSync(tmpHome, { recursive: true, force: true });
94
+ });
95
+
96
+ function today() {
97
+ return new Date().toLocaleDateString('en-CA');
98
+ }
99
+
100
+ function todayAt(hour) {
101
+ const d = new Date();
102
+ d.setHours(hour, 0, 0, 0);
103
+ return d;
104
+ }
105
+
106
+ function yesterdayAt(hour) {
107
+ const d = new Date();
108
+ d.setDate(d.getDate() - 1);
109
+ d.setHours(hour, 0, 0, 0);
110
+ return d;
111
+ }
112
+
113
+ /**
114
+ * Ground-truth reimplementation of the ORIGINAL (pre-fix) refreshIntradayToday:
115
+ * unconditional readdir-per-directory + sequential stat-per-file, no registry,
116
+ * no concurrency. Kept here (not in production code) purely so tests can
117
+ * assert the new fast-walk's output is byte-for-byte identical to what the
118
+ * old algorithm produced for the same fixture.
119
+ */
120
+ async function legacyRefreshIntradayToday() {
121
+ const PROJECTS_DIR = projectsDirFor(tmpHome);
122
+ const t = today();
123
+
124
+ let projectDirs;
125
+ try {
126
+ projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
127
+ } catch {
128
+ return [];
129
+ }
130
+
131
+ const buckets = new Map();
132
+ for (const projEntry of projectDirs) {
133
+ if (!projEntry.isDirectory()) continue;
134
+ const encodedCwd = projEntry.name;
135
+ const projectDir = path.join(PROJECTS_DIR, encodedCwd);
136
+
137
+ let files;
138
+ try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
139
+
140
+ for (const fileEntry of files) {
141
+ if (!fileEntry.name.endsWith('.jsonl')) continue;
142
+ const filePath = path.join(projectDir, fileEntry.name);
143
+
144
+ let stat;
145
+ try { stat = await fsp.stat(filePath); } catch { continue; }
146
+
147
+ const mtimeDate = new Date(stat.mtimeMs).toLocaleDateString('en-CA');
148
+ if (mtimeDate < t) continue;
149
+
150
+ const { result: parsed } = await historyAggregator.parseJSONL(filePath, stat);
151
+ if (parsed.skipped) continue;
152
+ if (parsed.sessionDate !== t) continue;
153
+
154
+ const key = `${t}|${encodedCwd}`;
155
+ if (!buckets.has(key)) {
156
+ buckets.set(key, {
157
+ date: t,
158
+ encodedCwd,
159
+ promptCount: 0,
160
+ inputTokens: 0,
161
+ outputTokens: 0,
162
+ toolCallCount: 0,
163
+ sessionCount: 0,
164
+ });
165
+ }
166
+ const b = buckets.get(key);
167
+ b.promptCount += parsed.promptCount;
168
+ b.inputTokens += parsed.inputTokens;
169
+ b.outputTokens += parsed.outputTokens;
170
+ b.toolCallCount += parsed.toolCallCount;
171
+ b.sessionCount++;
172
+ }
173
+ }
174
+ return Array.from(buckets.values()).sort((a, b) => a.encodedCwd.localeCompare(b.encodedCwd));
175
+ }
176
+
177
+ /** Read back today's rollup lines as a simplified, order-stable shape for comparison. */
178
+ async function readTodayRollupSimplified() {
179
+ const t = today();
180
+ const map = await historyRollup.readRollup(t, t);
181
+ const byProject = new Map();
182
+ for (const bucket of map.values()) {
183
+ if (bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID) continue;
184
+ const key = bucket.projectDir;
185
+ let row = byProject.get(key);
186
+ if (!row) {
187
+ row = { date: t, encodedCwd: key, promptCount: 0, inputTokens: 0, outputTokens: 0, toolCallCount: 0, sessionCount: 0 };
188
+ byProject.set(key, row);
189
+ }
190
+ if (bucket.modelId === historyRollup.TOTALS_MODEL_ID) {
191
+ row.promptCount += bucket.promptCount || 0;
192
+ row.toolCallCount += bucket.toolCallCount || 0;
193
+ row.sessionCount += bucket.sessionCount || 0;
194
+ } else {
195
+ row.inputTokens += bucket.inputTokens || 0;
196
+ row.outputTokens += bucket.outputTokens || 0;
197
+ }
198
+ }
199
+ return Array.from(byProject.values()).sort((a, b) => a.encodedCwd.localeCompare(b.encodedCwd));
200
+ }
201
+
202
+ test('refreshIntradayToday output matches the old unconditional-walk algorithm for the same fixture', async () => {
203
+ writeTranscript('/repo/alpha', 'sess-1', [
204
+ userLine(todayAt(9).toISOString()),
205
+ assistantLine(todayAt(9).toISOString(), 'claude-sonnet-5', { in: 100, out: 20 }),
206
+ ], todayAt(9));
207
+ writeTranscript('/repo/beta', 'sess-2', [
208
+ userLine(todayAt(10).toISOString()),
209
+ assistantLine(todayAt(10).toISOString(), 'claude-opus-5', { in: 50, out: 10 }),
210
+ ], todayAt(10));
211
+ // An old, untouched-today file mixed in — must be excluded from both.
212
+ writeTranscript('/repo/gamma', 'sess-3', [
213
+ userLine(yesterdayAt(9).toISOString()),
214
+ ], yesterdayAt(9));
215
+
216
+ const expected = await legacyRefreshIntradayToday();
217
+
218
+ const result = await historyAggregator.refreshIntradayToday();
219
+ expect(result.projectsUpdated).toBe(2);
220
+
221
+ const actual = await readTodayRollupSimplified();
222
+ expect(actual).toEqual(expected.map((b) => ({
223
+ date: b.date,
224
+ encodedCwd: b.encodedCwd,
225
+ promptCount: b.promptCount,
226
+ inputTokens: b.inputTokens,
227
+ outputTokens: b.outputTokens,
228
+ toolCallCount: b.toolCallCount,
229
+ sessionCount: b.sessionCount,
230
+ })));
231
+ });
232
+
233
+ test('a file modified today, in a directory the fast path would skip re-enumerating, is still picked up', async () => {
234
+ // Session file whose FIRST event is today (this is what makes its
235
+ // sessionDate bucket "today" at all — sessionDate is derived from the
236
+ // file's first line, not its mtime). The first tick discovers it via a
237
+ // real readdir (new directory) and caches the directory's mtime + file
238
+ // list alongside it.
239
+ const file = writeTranscript('/repo/resumed', 'sess-old', [
240
+ userLine(todayAt(8).toISOString()),
241
+ assistantLine(todayAt(8).toISOString(), 'claude-sonnet-5', { in: 10, out: 2 }),
242
+ ], todayAt(8));
243
+
244
+ const first = await historyAggregator.refreshIntradayToday();
245
+ expect(first.projectsUpdated).toBe(1);
246
+
247
+ // Now append to that SAME file later today, without creating or removing
248
+ // any directory entry — appending to an existing file does NOT change the
249
+ // parent directory's mtime (verified empirically; see the strategy comment
250
+ // above refreshIntradayToday). A naive "skip the whole directory when its
251
+ // mtime is unchanged" fast path would miss this update entirely; the real
252
+ // implementation must keep re-stat-ing every previously-seen file in the
253
+ // directory regardless.
254
+ const dirPath = path.dirname(file);
255
+ const dirStatBefore = fs.statSync(dirPath);
256
+
257
+ fs.appendFileSync(file, JSON.stringify(assistantLine(todayAt(11).toISOString(), 'claude-sonnet-5', { in: 30, out: 6 })) + '\n');
258
+ const todayStamp = todayAt(11);
259
+ fs.utimesSync(file, todayStamp, todayStamp);
260
+
261
+ const dirStatAfter = fs.statSync(dirPath);
262
+ // Sanity check on the very premise this test is exercising: the directory
263
+ // mtime genuinely did not move, so the second tick's fast path is forced to
264
+ // reuse (not re-readdir) this directory's cached file list.
265
+ expect(dirStatAfter.mtimeMs).toBe(dirStatBefore.mtimeMs);
266
+
267
+ const second = await historyAggregator.refreshIntradayToday();
268
+ expect(second.projectsUpdated).toBe(1);
269
+
270
+ const rows = await readTodayRollupSimplified();
271
+ expect(rows).toEqual([
272
+ { date: today(), encodedCwd: encodeCwd('/repo/resumed'), promptCount: 1, inputTokens: 40, outputTokens: 8, toolCallCount: 0, sessionCount: 1 },
273
+ ]);
274
+ });
275
+
276
+ test('a brand-new session file created after the first tick is discovered on the next tick', async () => {
277
+ writeTranscript('/repo/steady', 'sess-a', [
278
+ userLine(todayAt(8).toISOString()),
279
+ ], todayAt(8));
280
+
281
+ const first = await historyAggregator.refreshIntradayToday();
282
+ expect(first.projectsUpdated).toBe(1);
283
+
284
+ // New file in the SAME directory — this DOES bump the directory's mtime
285
+ // (an entry was added), so the registry's cached file list must be
286
+ // refreshed via readdir on the next tick.
287
+ writeTranscript('/repo/steady', 'sess-b', [
288
+ userLine(todayAt(9).toISOString()),
289
+ ], todayAt(9));
290
+
291
+ const second = await historyAggregator.refreshIntradayToday();
292
+ expect(second.projectsUpdated).toBe(1);
293
+
294
+ const rows = await readTodayRollupSimplified();
295
+ expect(rows[0].sessionCount).toBe(2);
296
+ expect(rows[0].promptCount).toBe(2);
297
+ });
298
+
299
+ test('mapWithConcurrency preserves input order and respects the concurrency bound', async () => {
300
+ let inFlight = 0;
301
+ let maxInFlight = 0;
302
+ const items = Array.from({ length: 50 }, (_, i) => i);
303
+ const results = await historyAggregator.mapWithConcurrency(items, 5, async (i) => {
304
+ inFlight++;
305
+ maxInFlight = Math.max(maxInFlight, inFlight);
306
+ await new Promise((resolve) => setTimeout(resolve, 1));
307
+ inFlight--;
308
+ return i * 2;
309
+ });
310
+ expect(results).toEqual(items.map((i) => i * 2));
311
+ expect(maxInFlight).toBeLessThanOrEqual(5);
312
+ expect(maxInFlight).toBeGreaterThan(1);
313
+ });