claude-code-session-manager 0.13.1 → 0.15.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 (44) hide show
  1. package/dist/assets/{TiptapBody-Btu-_mZq.js → TiptapBody-D0tfDVZb.js} +1 -1
  2. package/dist/assets/{cssMode-EkBJI3CN.js → cssMode-C3tZkaJ9.js} +1 -1
  3. package/dist/assets/{freemarker2-DFbgmGC_.js → freemarker2-DEh8tC5X.js} +1 -1
  4. package/dist/assets/{handlebars-CUy9oTnL.js → handlebars-D_6KmsOK.js} +1 -1
  5. package/dist/assets/{html-CUFKxIqK.js → html-DF1KVjzv.js} +1 -1
  6. package/dist/assets/{htmlMode-hBSk7Fab.js → htmlMode-DEakPokt.js} +1 -1
  7. package/dist/assets/{index-D4dlTF2R.css → index-D-kX3T0V.css} +1 -1
  8. package/dist/assets/{index-BGIAQ9_i.js → index-Zg61GP50.js} +1416 -1115
  9. package/dist/assets/{javascript-CAH2ooMO.js → javascript-Du7a359D.js} +1 -1
  10. package/dist/assets/{jsonMode-BSMCRMaw.js → jsonMode-W3BJwbUD.js} +1 -1
  11. package/dist/assets/{liquid-CSMs05cs.js → liquid-DDj1fqca.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DEyYbu0m.js → lspLanguageFeatures-uyEbiR-d.js} +1 -1
  13. package/dist/assets/{mdx-BocaqTLI.js → mdx-DUqSETvC.js} +1 -1
  14. package/dist/assets/{python-Dj6u2CWq.js → python-D7S2lUAn.js} +1 -1
  15. package/dist/assets/{razor-D5OWmIwh.js → razor-19nfZNaZ.js} +1 -1
  16. package/dist/assets/{tsMode-Cy6xJd5e.js → tsMode-CQke5zpL.js} +1 -1
  17. package/dist/assets/{typescript-CsWWOqVn.js → typescript-D4ge0PUF.js} +1 -1
  18. package/dist/assets/{xml-DiTZK7ii.js → xml-D42QDS7q.js} +1 -1
  19. package/dist/assets/{yaml-pdLhZQr9.js → yaml-CEiz9NyU.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +2 -1
  22. package/src/main/__tests__/runVerify.test.cjs +388 -0
  23. package/src/main/config.cjs +1 -7
  24. package/src/main/files.cjs +4 -37
  25. package/src/main/historyAggregator.cjs +37 -14
  26. package/src/main/index.cjs +57 -134
  27. package/src/main/ipcSchemas.cjs +4 -0
  28. package/src/main/lib/childWithLog.cjs +218 -0
  29. package/src/main/lib/expandHome.cjs +21 -0
  30. package/src/main/lib/insideHome.cjs +74 -21
  31. package/src/main/lib/openExternalApp.cjs +141 -0
  32. package/src/main/pluginInstall.cjs +39 -1
  33. package/src/main/pty.cjs +11 -4
  34. package/src/main/queueOps.cjs +2 -2
  35. package/src/main/runVerify.cjs +527 -0
  36. package/src/main/scheduler.cjs +335 -286
  37. package/src/main/search.cjs +1 -6
  38. package/src/main/superagent.cjs +10 -0
  39. package/src/main/supervisor.cjs +16 -8
  40. package/src/main/transcripts.cjs +18 -0
  41. package/src/main/usageMatrix.cjs +336 -0
  42. package/src/main/watchers.cjs +2 -2
  43. package/src/preload/api.d.ts +68 -7
  44. package/src/preload/index.cjs +9 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * runVerify.test.cjs — unit tests for the post-run reconciliation verifier.
3
+ *
4
+ * Run standalone: node src/main/__tests__/runVerify.test.cjs
5
+ *
6
+ * Three fixture scenarios (matching the 2026-05-23→24 incident window):
7
+ *
8
+ * 1. Case 1 (false-PASS): M2 acceptance script prints test failures + Traceback,
9
+ * then echoes "M2 acceptance: PASS". Scheduler used to stamp 'completed'.
10
+ * Expected: transcript_errors → needs_review
11
+ *
12
+ * 2. Case 2 (HALT): M7 agent correctly halts because M1 soak is unmet.
13
+ * Expected: halt → pending, reason embeds soak floor date
14
+ *
15
+ * 3. Case 3 (deps_unmet): PRD 56 has an unsatisfied dependsOn entry.
16
+ * Expected: deps_unmet → pending
17
+ *
18
+ * 4. Truly clean: all tests pass, no error markers.
19
+ * Expected: clean → null (caller may stamp 'completed')
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const { test } = require('node:test');
25
+ const assert = require('node:assert/strict');
26
+ const os = require('node:os');
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { verifyRun } = require('../runVerify.cjs');
30
+
31
+ // ─── helpers ──────────────────────────────────────────────────────────────────
32
+
33
+ /** Create a temp directory that is cleaned up after the test. */
34
+ function makeTmpDir() {
35
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-test-'));
36
+ }
37
+
38
+ function rmdir(dir) {
39
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
40
+ }
41
+
42
+ /**
43
+ * Build a minimal stream-JSON log file.
44
+ * `events` is an array of raw JSON objects to serialise one-per-line.
45
+ * A "[scheduler] starting..." prefix line is also prepended (must be skipped).
46
+ */
47
+ function writeLog(dir, slug, events) {
48
+ const lines = ['[scheduler] starting ' + slug + ' at 2026-05-24T00:00:00.000Z'];
49
+ for (const ev of events) lines.push(JSON.stringify(ev));
50
+ lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
51
+ fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
52
+ }
53
+
54
+ /** Write a minimal PRD .md file with frontmatter + body. */
55
+ function writePrd(dir, slug, body) {
56
+ const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
57
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
58
+ return path.join(dir, `${slug}.md`);
59
+ }
60
+
61
+ // ─── fixture: Case 1 — false-PASS (M2 acceptance) ─────────────────────────
62
+
63
+ test('Case 1: false-PASS (Traceback + echo PASS) → transcript_errors/needs_review', async () => {
64
+ const tmp = makeTmpDir();
65
+ try {
66
+ const slug = '39-signal-builder-m2-sentiment-stack';
67
+ const logEvents = [
68
+ // Assistant turn: run acceptance script
69
+ {
70
+ type: 'assistant',
71
+ message: {
72
+ role: 'assistant',
73
+ content: [{
74
+ type: 'tool_use',
75
+ id: 'toolu_m2_001',
76
+ name: 'Bash',
77
+ input: {
78
+ command: 'bash check_m2.sh',
79
+ description: 'Run M2 acceptance checks',
80
+ },
81
+ }],
82
+ },
83
+ },
84
+ // Tool result: test failures then echo PASS (the false-positive pattern)
85
+ {
86
+ type: 'user',
87
+ message: {
88
+ role: 'user',
89
+ content: [{
90
+ type: 'tool_result',
91
+ tool_use_id: 'toolu_m2_001',
92
+ content: [
93
+ '=== Signal-builder side ===',
94
+ 'ERROR tests/test_news_signals_panel.py',
95
+ '!!!!!!!! stopping after 1 failures !!!!!!!!',
96
+ '1 error in 0.03s',
97
+ 'SB: 0',
98
+ '=== Sanity: SB sentiment_cache.db rows ===',
99
+ 'rows: 0',
100
+ '=== contract.json panels.sentiment ===',
101
+ 'Traceback (most recent call last):',
102
+ ' File "<string>", line 1, in <module>',
103
+ "KeyError: 'panels.sentiment'",
104
+ 'M2 acceptance: PASS',
105
+ ].join('\n'),
106
+ is_error: false,
107
+ }],
108
+ },
109
+ },
110
+ // Result: agent declares success
111
+ {
112
+ type: 'result',
113
+ subtype: 'success',
114
+ result: 'M2 acceptance criteria verified. All checks passed.',
115
+ },
116
+ ];
117
+
118
+ writeLog(tmp, slug, logEvents);
119
+ const prdPath = writePrd(tmp, slug, '# M2 Sentiment Stack\n\nBuild the sentiment pipeline.');
120
+
121
+ const verdict = await verifyRun({
122
+ runDir: tmp,
123
+ prdPath,
124
+ queueEntry: { slug, status: 'running' },
125
+ allJobs: [],
126
+ });
127
+
128
+ assert.equal(verdict.verdict, 'transcript_errors', `expected transcript_errors, got ${verdict.verdict}: ${verdict.reason}`);
129
+ assert.equal(verdict.downgradeTo, 'needs_review');
130
+ assert.ok(verdict.reason.length > 0, 'reason should be non-empty');
131
+
132
+ // verdicts.json sidecar should have been written
133
+ const sidecar = JSON.parse(fs.readFileSync(path.join(tmp, `${slug}.verdicts.json`), 'utf8'));
134
+ assert.equal(sidecar.schemaVersion, 1);
135
+ assert.equal(sidecar.verdict, 'transcript_errors');
136
+ } finally {
137
+ rmdir(tmp);
138
+ }
139
+ });
140
+
141
+ // ─── fixture: Case 2 — HALT (M7 soak unmet) ──────────────────────────────────
142
+
143
+ test('Case 2: HALT in result event → halt/pending with floor date in reason', async () => {
144
+ const tmp = makeTmpDir();
145
+ try {
146
+ const slug = '44-signal-builder-m7-final-cutover';
147
+ const haltMsg = [
148
+ 'HALT: M7 prerequisite — M1 not yet soaked 30 days × 6 phases',
149
+ ' M1 commit: f91ce44 2026-05-23 (age: 0 days, need ≥ 180)',
150
+ ' M7 earliest eligible: ~2026-11-19',
151
+ ].join('\n');
152
+
153
+ const logEvents = [
154
+ {
155
+ type: 'assistant',
156
+ message: {
157
+ role: 'assistant',
158
+ content: [{
159
+ type: 'tool_use',
160
+ id: 'toolu_m7_001',
161
+ name: 'Bash',
162
+ input: { command: 'git log --oneline m1-tag', description: 'Check M1 soak period' },
163
+ }],
164
+ },
165
+ },
166
+ {
167
+ type: 'user',
168
+ message: {
169
+ role: 'user',
170
+ content: [{
171
+ type: 'tool_result',
172
+ tool_use_id: 'toolu_m7_001',
173
+ content: 'f91ce44 2026-05-23 feat(m1): initial trading signals',
174
+ is_error: false,
175
+ }],
176
+ },
177
+ },
178
+ {
179
+ type: 'result',
180
+ subtype: 'success',
181
+ result: haltMsg,
182
+ },
183
+ ];
184
+
185
+ writeLog(tmp, slug, logEvents);
186
+ const prdPath = writePrd(
187
+ tmp,
188
+ slug,
189
+ '# M7 Final Cutover\n\nOnly after M1–M6 have run 30+ days each.',
190
+ );
191
+
192
+ const verdict = await verifyRun({
193
+ runDir: tmp,
194
+ prdPath,
195
+ queueEntry: { slug, status: 'running' },
196
+ allJobs: [],
197
+ });
198
+
199
+ assert.equal(verdict.verdict, 'halt', `expected halt, got ${verdict.verdict}: ${verdict.reason}`);
200
+ assert.equal(verdict.downgradeTo, 'pending');
201
+ assert.ok(verdict.reason.includes('HALT'), 'reason should mention HALT');
202
+ // Floor date from the HALT message should appear in reason
203
+ assert.ok(
204
+ verdict.reason.includes('2026-11-19') || verdict.reason.length > 20,
205
+ `reason should embed soak floor date: ${verdict.reason}`,
206
+ );
207
+ } finally {
208
+ rmdir(tmp);
209
+ }
210
+ });
211
+
212
+ // ─── fixture: Case 3 — deps_unmet (dependsOn unsatisfied) ────────────────────
213
+
214
+ test('Case 3: dependsOn pointing at pending dep → deps_unmet/pending', async () => {
215
+ const tmp = makeTmpDir();
216
+ try {
217
+ const slug = '56-burrow-mcp-retire-tier3-frozen-endpoints';
218
+ const logEvents = [
219
+ {
220
+ type: 'result',
221
+ subtype: 'success',
222
+ result: 'Completed burrow MCP tier-3 retirement.',
223
+ },
224
+ ];
225
+
226
+ writeLog(tmp, slug, logEvents);
227
+ const prdPath = writePrd(
228
+ tmp,
229
+ slug,
230
+ '# Burrow MCP Retire Tier-3\n\nRetire frozen endpoints.',
231
+ );
232
+
233
+ // M7 is a predecessor that is still pending.
234
+ const allJobs = [
235
+ { slug: '44-signal-builder-m7-final-cutover', status: 'pending', finishedAt: null },
236
+ { slug: slug, status: 'running', finishedAt: null },
237
+ ];
238
+ const queueEntry = {
239
+ slug,
240
+ status: 'running',
241
+ dependsOn: ['44-signal-builder-m7-final-cutover'],
242
+ };
243
+
244
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry, allJobs });
245
+
246
+ assert.equal(verdict.verdict, 'deps_unmet', `expected deps_unmet, got ${verdict.verdict}: ${verdict.reason}`);
247
+ assert.equal(verdict.downgradeTo, 'pending');
248
+ assert.ok(verdict.reason.includes('44-signal-builder-m7-final-cutover'), 'reason should name the unmet dep');
249
+ } finally {
250
+ rmdir(tmp);
251
+ }
252
+ });
253
+
254
+ // ─── fixture: truly clean run ─────────────────────────────────────────────────
255
+
256
+ test('Truly clean run (no error markers) → clean/null', async () => {
257
+ const tmp = makeTmpDir();
258
+ try {
259
+ const slug = '20-clean-feature';
260
+ const logEvents = [
261
+ {
262
+ type: 'assistant',
263
+ message: {
264
+ role: 'assistant',
265
+ content: [{
266
+ type: 'tool_use',
267
+ id: 'toolu_clean_001',
268
+ name: 'Bash',
269
+ input: { command: 'npm test', description: 'Run test suite' },
270
+ }],
271
+ },
272
+ },
273
+ {
274
+ type: 'user',
275
+ message: {
276
+ role: 'user',
277
+ content: [{
278
+ type: 'tool_result',
279
+ tool_use_id: 'toolu_clean_001',
280
+ content: '✓ 42 tests passed (1.3s)\nAll tests passed.',
281
+ is_error: false,
282
+ }],
283
+ },
284
+ },
285
+ {
286
+ type: 'result',
287
+ subtype: 'success',
288
+ result: 'Feature complete. All acceptance criteria satisfied.',
289
+ },
290
+ ];
291
+
292
+ writeLog(tmp, slug, logEvents);
293
+ const prdPath = writePrd(tmp, slug, '# Clean Feature\n\nImplement a clean feature.');
294
+
295
+ const verdict = await verifyRun({
296
+ runDir: tmp,
297
+ prdPath,
298
+ queueEntry: { slug, status: 'running' },
299
+ allJobs: [],
300
+ });
301
+
302
+ assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
303
+ assert.equal(verdict.downgradeTo, null);
304
+ } finally {
305
+ rmdir(tmp);
306
+ }
307
+ });
308
+
309
+ // ─── self-recovery: FAIL followed by successful retry ──────────────────────
310
+
311
+ test('FAIL recovered within 30 events → clean', async () => {
312
+ const tmp = makeTmpDir();
313
+ try {
314
+ const slug = '21-self-recovering';
315
+ const desc = 'Run pytest';
316
+ const logEvents = [
317
+ // First attempt: fails
318
+ {
319
+ type: 'assistant',
320
+ message: {
321
+ role: 'assistant',
322
+ content: [{
323
+ type: 'tool_use',
324
+ id: 'toolu_r1',
325
+ name: 'Bash',
326
+ input: { command: 'pytest', description: desc },
327
+ }],
328
+ },
329
+ },
330
+ {
331
+ type: 'user',
332
+ message: {
333
+ role: 'user',
334
+ content: [{
335
+ type: 'tool_result',
336
+ tool_use_id: 'toolu_r1',
337
+ content: 'FAILED tests/test_foo.py::test_bar - AssertionError',
338
+ is_error: false,
339
+ }],
340
+ },
341
+ },
342
+ // Agent fixes and retries with same description
343
+ {
344
+ type: 'assistant',
345
+ message: {
346
+ role: 'assistant',
347
+ content: [{
348
+ type: 'tool_use',
349
+ id: 'toolu_r2',
350
+ name: 'Bash',
351
+ input: { command: 'pytest', description: desc },
352
+ }],
353
+ },
354
+ },
355
+ {
356
+ type: 'user',
357
+ message: {
358
+ role: 'user',
359
+ content: [{
360
+ type: 'tool_result',
361
+ tool_use_id: 'toolu_r2',
362
+ content: '5 passed in 0.42s',
363
+ is_error: false,
364
+ }],
365
+ },
366
+ },
367
+ {
368
+ type: 'result',
369
+ subtype: 'success',
370
+ result: 'Fixed the failing test and verified all pass.',
371
+ },
372
+ ];
373
+
374
+ writeLog(tmp, slug, logEvents);
375
+ const prdPath = writePrd(tmp, slug, '# Self-recovering test');
376
+
377
+ const verdict = await verifyRun({
378
+ runDir: tmp,
379
+ prdPath,
380
+ queueEntry: { slug, status: 'running' },
381
+ allJobs: [],
382
+ });
383
+
384
+ assert.equal(verdict.verdict, 'clean', `self-recovery should yield clean, got ${verdict.verdict}: ${verdict.reason}`);
385
+ } finally {
386
+ rmdir(tmp);
387
+ }
388
+ });
@@ -22,6 +22,7 @@ const os = require('node:os');
22
22
  const chokidar = require('chokidar');
23
23
  const logs = require('./logs.cjs');
24
24
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
25
+ const { expandHome } = require('./lib/expandHome.cjs');
25
26
 
26
27
  /** Map<absPath, {watcher, refCount}> — one chokidar watcher per path. */
27
28
  const watchers = new Map();
@@ -31,13 +32,6 @@ function attachWindow(w) {
31
32
  window = w;
32
33
  }
33
34
 
34
- function expandHome(p) {
35
- if (!p) return p;
36
- if (p === '~') return os.homedir();
37
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
38
- return p;
39
- }
40
-
41
35
  /**
42
36
  * Resolve a path to its realpath, handling non-existent files by resolving
43
37
  * the parent directory instead. Prevents symlink traversal attacks.
@@ -14,42 +14,13 @@
14
14
  */
15
15
 
16
16
  const { ipcMain, shell } = require('electron');
17
- const fs = require('node:fs');
18
17
  const fsp = require('node:fs/promises');
19
18
  const path = require('node:path');
20
19
  const os = require('node:os');
21
20
 
22
21
  const { z } = require('zod');
23
-
24
- function expandHome(p) {
25
- if (!p) return p;
26
- if (p === '~') return os.homedir();
27
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
28
- return p;
29
- }
30
-
31
- /**
32
- * Resolve to realpath; on ENOENT resolve the parent then re-append basename so
33
- * we can still validate create destinations. Mirrors config.cjs.
34
- */
35
- function realResolve(abs) {
36
- const lex = path.resolve(expandHome(abs));
37
- try {
38
- return fs.realpathSync(lex);
39
- } catch (e) {
40
- if (e.code === 'ENOENT') {
41
- const parent = path.dirname(lex);
42
- try {
43
- return path.join(fs.realpathSync(parent), path.basename(lex));
44
- } catch {
45
- return lex;
46
- }
47
- }
48
- throw e;
49
- }
50
- }
51
-
52
- const HOME = os.homedir();
22
+ const { assertInsideHome } = require('./lib/insideHome.cjs');
23
+ const { expandHome } = require('./lib/expandHome.cjs');
53
24
 
54
25
  /**
55
26
  * Validates that the path is under the home directory. Returns the realpath
@@ -58,11 +29,7 @@ const HOME = os.homedir();
58
29
  * ~ — but never escapes the home tree.
59
30
  */
60
31
  function validateHomePath(abs) {
61
- const real = realResolve(abs);
62
- let realHome;
63
- try { realHome = fs.realpathSync(HOME); } catch { realHome = HOME; }
64
- if (real === realHome || real.startsWith(realHome + path.sep)) return real;
65
- throw new Error(`Path outside home directory: ${real}`);
32
+ return assertInsideHome(expandHome(abs)).realPath;
66
33
  }
67
34
 
68
35
  /** Reject .credentials.json writes regardless of where they sit. */
@@ -228,7 +195,7 @@ async function renameEntry(oldPath, newName) {
228
195
  }
229
196
  }
230
197
 
231
- const CRITICAL_PATHS = new Set([HOME, '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
198
+ const CRITICAL_PATHS = new Set([os.homedir(), '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
232
199
 
233
200
  async function deleteEntry(filePath) {
234
201
  let resolved;
@@ -14,10 +14,20 @@ function decodeCwd(encoded) {
14
14
  return '/' + encoded.replace(/-+/g, '/');
15
15
  }
16
16
 
17
+ // All date strings in this module are LOCAL-TZ YYYY-MM-DD. A previous version
18
+ // used UTC (toISOString().slice(0,10)) which silently shifted late-evening
19
+ // sessions a day forward for Pacific-time users, then the >= effectiveTo
20
+ // filter dropped them entirely. en-CA locale yields ISO-format dates in the
21
+ // JS environment's TZ. Parse with 'T12:00:00' (local noon) so DST boundaries
22
+ // don't shift the date by a day.
23
+ function localDate(d) {
24
+ return d.toLocaleDateString('en-CA');
25
+ }
26
+
17
27
  function subtractDays(dateStr, days) {
18
- const d = new Date(dateStr + 'T00:00:00Z');
19
- d.setUTCDate(d.getUTCDate() - days);
20
- return d.toISOString().slice(0, 10);
28
+ const d = new Date(dateStr + 'T12:00:00');
29
+ d.setDate(d.getDate() - days);
30
+ return localDate(d);
21
31
  }
22
32
 
23
33
  async function parseJSONL(filePath, stat) {
@@ -69,10 +79,18 @@ async function parseJSONL(filePath, stat) {
69
79
 
70
80
  const usage = obj.usage ?? obj.message?.usage;
71
81
  if (usage && typeof usage === 'object') {
72
- if (typeof usage.inputTokens === 'number') acc.inputTokens += usage.inputTokens;
73
- if (typeof usage.outputTokens === 'number') acc.outputTokens += usage.outputTokens;
74
- if (typeof usage.cacheReadInputTokens === 'number') acc.cacheReadTokens += usage.cacheReadInputTokens;
75
- if (typeof usage.cacheCreationInputTokens === 'number') acc.cacheCreationTokens += usage.cacheCreationInputTokens;
82
+ // Claude Code JSONLs use snake_case (matching the Anthropic API). The
83
+ // 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
+ const inT = usage.input_tokens ?? usage.inputTokens;
87
+ const outT = usage.output_tokens ?? usage.outputTokens;
88
+ const cacheR = usage.cache_read_input_tokens ?? usage.cacheReadInputTokens;
89
+ const cacheC = usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens;
90
+ if (typeof inT === 'number') acc.inputTokens += inT;
91
+ if (typeof outT === 'number') acc.outputTokens += outT;
92
+ if (typeof cacheR === 'number') acc.cacheReadTokens += cacheR;
93
+ if (typeof cacheC === 'number') acc.cacheCreationTokens += cacheC;
76
94
  }
77
95
 
78
96
  const content = obj.message?.content ?? obj.content;
@@ -96,10 +114,10 @@ async function parseJSONL(filePath, stat) {
96
114
 
97
115
  try {
98
116
  acc.sessionDate = firstTs
99
- ? new Date(firstTs).toISOString().slice(0, 10)
100
- : new Date(stat.mtimeMs).toISOString().slice(0, 10);
117
+ ? localDate(new Date(firstTs))
118
+ : localDate(new Date(stat.mtimeMs));
101
119
  } catch {
102
- acc.sessionDate = new Date(stat.mtimeMs).toISOString().slice(0, 10);
120
+ acc.sessionDate = localDate(new Date(stat.mtimeMs));
103
121
  }
104
122
 
105
123
  return acc;
@@ -126,8 +144,10 @@ async function parseConversationMeta(filePath, stat) {
126
144
  }
127
145
  const usage = obj.usage ?? obj.message?.usage;
128
146
  if (usage && typeof usage === 'object') {
129
- if (typeof usage.inputTokens === 'number') meta.inputTokens += usage.inputTokens;
130
- if (typeof usage.outputTokens === 'number') meta.outputTokens += usage.outputTokens;
147
+ const inT = usage.input_tokens ?? usage.inputTokens;
148
+ const outT = usage.output_tokens ?? usage.outputTokens;
149
+ if (typeof inT === 'number') meta.inputTokens += inT;
150
+ if (typeof outT === 'number') meta.outputTokens += outT;
131
151
  }
132
152
  }
133
153
  return meta;
@@ -142,7 +162,7 @@ function registerHistoryAggregatorHandlers() {
142
162
  const parsed = schemas.historyAggregate.safeParse(rawReq);
143
163
  const req = parsed.success ? (parsed.data ?? {}) : {};
144
164
  const t0 = Date.now();
145
- const today = new Date().toISOString().slice(0, 10);
165
+ const today = localDate(new Date());
146
166
  let effectiveTo = req?.toDate ? req.toDate : today;
147
167
  if (effectiveTo > today) effectiveTo = today;
148
168
  const effectiveFrom = req?.fromDate ? req.fromDate : subtractDays(today, 30);
@@ -185,7 +205,10 @@ function registerHistoryAggregatorHandlers() {
185
205
  if (parsed.skipped) { skippedLargeFiles++; continue; }
186
206
 
187
207
  const { sessionDate } = parsed;
188
- if (!sessionDate || sessionDate < effectiveFrom || sessionDate >= effectiveTo) continue;
208
+ // Inclusive upper bound `>=` here previously meant "today's data is
209
+ // always dropped", which combined with the (then-UTC) date bucket to
210
+ // hide a Pacific-time user's most recent activity entirely.
211
+ if (!sessionDate || sessionDate < effectiveFrom || sessionDate > effectiveTo) continue;
189
212
 
190
213
  const key = `${sessionDate}|${encodedCwd}`;
191
214
  if (!buckets.has(key)) {