@praeviso/code-env-switch 0.1.4 → 0.1.6

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.
@@ -4,23 +4,37 @@
4
4
  import * as fs from "fs";
5
5
  import * as path from "path";
6
6
  import * as os from "os";
7
- import type { Config, ProfileType } from "../types";
7
+ import type { Config, Profile, ProfileType } from "../types";
8
8
  import { resolvePath } from "../shell/utils";
9
9
  import { normalizeType, inferProfileType, getProfileDisplayName } from "../profile/type";
10
+ import { getStatuslineDebugPath } from "../statusline/debug";
11
+ import { calculateUsageCost, resolvePricingForProfile } from "./pricing";
10
12
 
11
13
  interface UsageRecord {
12
14
  ts: string;
13
15
  type: string;
14
16
  profileKey: string | null;
15
17
  profileName: string | null;
18
+ model: string | null;
19
+ sessionId?: string | null;
16
20
  inputTokens: number;
17
21
  outputTokens: number;
22
+ cacheReadTokens: number;
23
+ cacheWriteTokens: number;
18
24
  totalTokens: number;
19
25
  }
20
26
 
21
27
  interface UsageTotals {
22
28
  today: number;
23
29
  total: number;
30
+ todayInput: number;
31
+ totalInput: number;
32
+ todayOutput: number;
33
+ totalOutput: number;
34
+ todayCacheRead: number;
35
+ totalCacheRead: number;
36
+ todayCacheWrite: number;
37
+ totalCacheWrite: number;
24
38
  }
25
39
 
26
40
  interface UsageTotalsIndex {
@@ -28,32 +42,63 @@ interface UsageTotalsIndex {
28
42
  byName: Map<string, UsageTotals>;
29
43
  }
30
44
 
45
+ interface UsageCostTotals {
46
+ today: number;
47
+ total: number;
48
+ todayTokens: number;
49
+ totalTokens: number;
50
+ }
51
+
52
+ interface UsageCostIndex {
53
+ byKey: Map<string, UsageCostTotals>;
54
+ byName: Map<string, UsageCostTotals>;
55
+ }
56
+
57
+ export interface UsageCleanupFailure {
58
+ path: string;
59
+ error: string;
60
+ }
61
+
62
+ export interface UsageCleanupResult {
63
+ removed: string[];
64
+ missing: string[];
65
+ failed: UsageCleanupFailure[];
66
+ }
67
+
31
68
  interface UsageStateEntry {
32
69
  mtimeMs: number;
33
70
  size: number;
34
71
  type: ProfileType;
35
72
  inputTokens: number;
36
73
  outputTokens: number;
74
+ cacheReadTokens: number;
75
+ cacheWriteTokens: number;
37
76
  totalTokens: number;
38
77
  startTs: string | null;
39
78
  endTs: string | null;
40
79
  cwd: string | null;
80
+ model?: string | null;
41
81
  }
42
82
 
43
83
  interface UsageSessionEntry {
44
84
  type: ProfileType;
45
85
  inputTokens: number;
46
86
  outputTokens: number;
87
+ cacheReadTokens: number;
88
+ cacheWriteTokens: number;
47
89
  totalTokens: number;
48
90
  startTs: string | null;
49
91
  endTs: string | null;
50
92
  cwd: string | null;
93
+ model?: string | null;
51
94
  }
52
95
 
53
96
  interface UsageStateFile {
54
97
  version: number;
55
98
  files: Record<string, UsageStateEntry>;
56
99
  sessions?: Record<string, UsageSessionEntry>;
100
+ usageMtimeMs?: number;
101
+ usageSize?: number;
57
102
  }
58
103
 
59
104
  interface ProfileLogEntry {
@@ -82,19 +127,46 @@ interface ProfileResolveResult {
82
127
  interface SessionStats {
83
128
  inputTokens: number;
84
129
  outputTokens: number;
130
+ cacheReadTokens: number;
131
+ cacheWriteTokens: number;
85
132
  totalTokens: number;
86
133
  startTs: string | null;
87
134
  endTs: string | null;
88
135
  cwd: string | null;
89
136
  sessionId: string | null;
137
+ model: string | null;
90
138
  }
91
139
 
92
140
  interface UsageTotalsInput {
93
141
  inputTokens: number | null;
94
142
  outputTokens: number | null;
143
+ cacheReadTokens: number | null;
144
+ cacheWriteTokens: number | null;
95
145
  totalTokens: number | null;
96
146
  }
97
147
 
148
+ function resolveProfileForRecord(
149
+ config: Config,
150
+ type: string | null,
151
+ record: UsageRecord
152
+ ): Profile | null {
153
+ if (record.profileKey && config.profiles && config.profiles[record.profileKey]) {
154
+ return config.profiles[record.profileKey];
155
+ }
156
+ if (record.profileName && config.profiles) {
157
+ const matches = Object.entries(config.profiles).find(([key, entry]) => {
158
+ const displayName = getProfileDisplayName(key, entry, type || undefined);
159
+ return (
160
+ displayName === record.profileName ||
161
+ entry.name === record.profileName ||
162
+ key === record.profileName
163
+ );
164
+ });
165
+ if (matches) return matches[1];
166
+ }
167
+ return null;
168
+ }
169
+
98
170
  function resolveDefaultConfigDir(configPath: string | null): string {
99
171
  if (configPath) return path.dirname(configPath);
100
172
  return path.join(os.homedir(), ".config", "code-env");
@@ -141,40 +213,175 @@ export function formatTokenCount(value: number | null | undefined): string {
141
213
  return `${(value / 1_000_000_000).toFixed(2)}B`;
142
214
  }
143
215
 
144
- export function buildUsageTotals(records: UsageRecord[]): UsageTotalsIndex {
145
- const byKey = new Map<string, UsageTotals>();
146
- const byName = new Map<string, UsageTotals>();
216
+ function createUsageTotals(): UsageTotals {
217
+ return {
218
+ today: 0,
219
+ total: 0,
220
+ todayInput: 0,
221
+ totalInput: 0,
222
+ todayOutput: 0,
223
+ totalOutput: 0,
224
+ todayCacheRead: 0,
225
+ totalCacheRead: 0,
226
+ todayCacheWrite: 0,
227
+ totalCacheWrite: 0,
228
+ };
229
+ }
230
+
231
+ function createUsageCostTotals(): UsageCostTotals {
232
+ return { today: 0, total: 0, todayTokens: 0, totalTokens: 0 };
233
+ }
234
+
235
+ function toUsageNumber(value: number | null | undefined): number {
236
+ const num = Number(value ?? 0);
237
+ return Number.isFinite(num) ? num : 0;
238
+ }
239
+
240
+ function getTodayWindow(): { startMs: number; endMs: number } {
147
241
  const todayStart = new Date();
148
242
  todayStart.setHours(0, 0, 0, 0);
149
- const todayStartMs = todayStart.getTime();
243
+ const startMs = todayStart.getTime();
150
244
  const tomorrowStart = new Date(todayStart);
151
245
  tomorrowStart.setDate(todayStart.getDate() + 1);
152
- const tomorrowStartMs = tomorrowStart.getTime();
246
+ return { startMs, endMs: tomorrowStart.getTime() };
247
+ }
248
+
249
+ function isTimestampInWindow(
250
+ ts: string,
251
+ startMs: number,
252
+ endMs: number
253
+ ): boolean {
254
+ if (!ts) return false;
255
+ const time = new Date(ts).getTime();
256
+ if (Number.isNaN(time)) return false;
257
+ return time >= startMs && time < endMs;
258
+ }
259
+
260
+ export function buildUsageTotals(records: UsageRecord[]): UsageTotalsIndex {
261
+ const byKey = new Map<string, UsageTotals>();
262
+ const byName = new Map<string, UsageTotals>();
263
+ const { startMs, endMs } = getTodayWindow();
153
264
 
154
265
  const isToday = (ts: string) => {
155
- if (!ts) return false;
156
- const time = new Date(ts).getTime();
157
- if (Number.isNaN(time)) return false;
158
- return time >= todayStartMs && time < tomorrowStartMs;
266
+ return isTimestampInWindow(ts, startMs, endMs);
159
267
  };
160
268
 
161
- const addTotals = (map: Map<string, UsageTotals>, key: string, amount: number, ts: string) => {
269
+ const addTotals = (
270
+ map: Map<string, UsageTotals>,
271
+ key: string,
272
+ amounts: {
273
+ total: number;
274
+ input: number;
275
+ output: number;
276
+ cacheRead: number;
277
+ cacheWrite: number;
278
+ },
279
+ ts: string
280
+ ) => {
162
281
  if (!key) return;
163
- const current = map.get(key) || { today: 0, total: 0 };
164
- current.total += amount;
165
- if (isToday(ts)) current.today += amount;
282
+ const current = map.get(key) || createUsageTotals();
283
+ current.total += amounts.total;
284
+ current.totalInput += amounts.input;
285
+ current.totalOutput += amounts.output;
286
+ current.totalCacheRead += amounts.cacheRead;
287
+ current.totalCacheWrite += amounts.cacheWrite;
288
+ if (isToday(ts)) {
289
+ current.today += amounts.total;
290
+ current.todayInput += amounts.input;
291
+ current.todayOutput += amounts.output;
292
+ current.todayCacheRead += amounts.cacheRead;
293
+ current.todayCacheWrite += amounts.cacheWrite;
294
+ }
166
295
  map.set(key, current);
167
296
  };
168
297
 
169
298
  for (const record of records) {
170
299
  const type = normalizeUsageType(record.type) || "";
171
- const total = Number(record.totalTokens || 0);
300
+ const input = toUsageNumber(record.inputTokens);
301
+ const output = toUsageNumber(record.outputTokens);
302
+ const cacheRead = toUsageNumber(record.cacheReadTokens);
303
+ const cacheWrite = toUsageNumber(record.cacheWriteTokens);
304
+ const computedTotal = input + output + cacheRead + cacheWrite;
305
+ const rawTotal = Number(record.totalTokens ?? computedTotal);
306
+ const total = Number.isFinite(rawTotal)
307
+ ? Math.max(rawTotal, computedTotal)
308
+ : computedTotal;
172
309
  if (!Number.isFinite(total)) continue;
173
310
  if (record.profileKey) {
174
- addTotals(byKey, `${type}||${record.profileKey}`, total, record.ts);
311
+ addTotals(
312
+ byKey,
313
+ `${type}||${record.profileKey}`,
314
+ { total, input, output, cacheRead, cacheWrite },
315
+ record.ts
316
+ );
175
317
  }
176
318
  if (record.profileName) {
177
- addTotals(byName, `${type}||${record.profileName}`, total, record.ts);
319
+ addTotals(
320
+ byName,
321
+ `${type}||${record.profileName}`,
322
+ { total, input, output, cacheRead, cacheWrite },
323
+ record.ts
324
+ );
325
+ }
326
+ }
327
+
328
+ return { byKey, byName };
329
+ }
330
+
331
+ function buildUsageCostIndex(records: UsageRecord[], config: Config): UsageCostIndex {
332
+ const byKey = new Map<string, UsageCostTotals>();
333
+ const byName = new Map<string, UsageCostTotals>();
334
+ const { startMs, endMs } = getTodayWindow();
335
+
336
+ const addCost = (
337
+ map: Map<string, UsageCostTotals>,
338
+ key: string,
339
+ cost: number,
340
+ tokens: number,
341
+ ts: string
342
+ ) => {
343
+ if (!key) return;
344
+ const current = map.get(key) || createUsageCostTotals();
345
+ current.total += cost;
346
+ current.totalTokens += tokens;
347
+ if (isTimestampInWindow(ts, startMs, endMs)) {
348
+ current.today += cost;
349
+ current.todayTokens += tokens;
350
+ }
351
+ map.set(key, current);
352
+ };
353
+
354
+ for (const record of records) {
355
+ const model = normalizeModelValue(record.model);
356
+ if (!model) continue;
357
+ const type = normalizeUsageType(record.type) || "";
358
+ const profile =
359
+ record.profileKey && config.profiles ? config.profiles[record.profileKey] : null;
360
+ const pricing = resolvePricingForProfile(config, profile || null, model);
361
+ if (!pricing) continue;
362
+ const cost = calculateUsageCost(
363
+ {
364
+ totalTokens: record.totalTokens,
365
+ inputTokens: record.inputTokens,
366
+ outputTokens: record.outputTokens,
367
+ cacheReadTokens: record.cacheReadTokens,
368
+ cacheWriteTokens: record.cacheWriteTokens,
369
+ },
370
+ pricing
371
+ );
372
+ if (cost === null || !Number.isFinite(cost)) continue;
373
+ const billedTokens =
374
+ toUsageNumber(record.inputTokens) +
375
+ toUsageNumber(record.outputTokens) +
376
+ toUsageNumber(record.cacheReadTokens) +
377
+ toUsageNumber(record.cacheWriteTokens);
378
+ const billedTotal =
379
+ billedTokens > 0 ? billedTokens : toUsageNumber(record.totalTokens);
380
+ if (record.profileKey) {
381
+ addCost(byKey, `${type}||${record.profileKey}`, cost, billedTotal, record.ts);
382
+ }
383
+ if (record.profileName) {
384
+ addCost(byName, `${type}||${record.profileName}`, cost, billedTotal, record.ts);
178
385
  }
179
386
  }
180
387
 
@@ -189,6 +396,12 @@ function normalizeUsageType(type: string | null | undefined): string | null {
189
396
  return trimmed ? trimmed : null;
190
397
  }
191
398
 
399
+ function normalizeModelValue(value: unknown): string | null {
400
+ if (typeof value !== "string") return null;
401
+ const trimmed = value.trim();
402
+ return trimmed ? trimmed : null;
403
+ }
404
+
192
405
  function buildSessionKey(type: ProfileType | null, sessionId: string): string {
193
406
  const normalized = normalizeUsageType(type || "");
194
407
  return normalized ? `${normalized}::${sessionId}` : sessionId;
@@ -226,6 +439,72 @@ export function readUsageTotalsIndex(
226
439
  return buildUsageTotals(records);
227
440
  }
228
441
 
442
+ export function readUsageCostIndex(
443
+ config: Config,
444
+ configPath: string | null,
445
+ syncUsage: boolean
446
+ ): UsageCostIndex | null {
447
+ const usagePath = getUsagePath(config, configPath);
448
+ if (!usagePath) return null;
449
+ if (syncUsage) {
450
+ syncUsageFromSessions(config, configPath, usagePath);
451
+ }
452
+ const records = readUsageRecords(usagePath);
453
+ if (records.length === 0) return null;
454
+ const costs = buildUsageCostIndex(records, config);
455
+ if (costs.byKey.size === 0 && costs.byName.size === 0) return null;
456
+ return costs;
457
+ }
458
+
459
+ export function readUsageSessionCost(
460
+ config: Config,
461
+ configPath: string | null,
462
+ type: string | null,
463
+ sessionId: string | null,
464
+ syncUsage: boolean
465
+ ): number | null {
466
+ if (!sessionId) return null;
467
+ const usagePath = getUsagePath(config, configPath);
468
+ if (!usagePath) return null;
469
+ if (syncUsage) {
470
+ syncUsageFromSessions(config, configPath, usagePath);
471
+ }
472
+ const records = readUsageRecords(usagePath);
473
+ if (records.length === 0) return null;
474
+ const normalizedType = normalizeUsageType(type);
475
+ let total = 0;
476
+ let hasCost = false;
477
+ for (const record of records) {
478
+ if (!record.sessionId) continue;
479
+ if (record.sessionId !== sessionId) continue;
480
+ if (
481
+ normalizedType &&
482
+ normalizeUsageType(record.type) !== normalizedType
483
+ ) {
484
+ continue;
485
+ }
486
+ const model = normalizeModelValue(record.model);
487
+ if (!model) continue;
488
+ const profile = resolveProfileForRecord(config, normalizedType, record);
489
+ const pricing = resolvePricingForProfile(config, profile, model);
490
+ if (!pricing) continue;
491
+ const cost = calculateUsageCost(
492
+ {
493
+ totalTokens: record.totalTokens,
494
+ inputTokens: record.inputTokens,
495
+ outputTokens: record.outputTokens,
496
+ cacheReadTokens: record.cacheReadTokens,
497
+ cacheWriteTokens: record.cacheWriteTokens,
498
+ },
499
+ pricing
500
+ );
501
+ if (cost === null || !Number.isFinite(cost)) continue;
502
+ total += cost;
503
+ hasCost = true;
504
+ }
505
+ return hasCost ? total : null;
506
+ }
507
+
229
508
  export function resolveUsageTotalsForProfile(
230
509
  totals: UsageTotalsIndex,
231
510
  type: string | null,
@@ -241,6 +520,21 @@ export function resolveUsageTotalsForProfile(
241
520
  );
242
521
  }
243
522
 
523
+ export function resolveUsageCostForProfile(
524
+ costs: UsageCostIndex,
525
+ type: string | null,
526
+ profileKey: string | null,
527
+ profileName: string | null
528
+ ): UsageCostTotals | null {
529
+ const keyLookup = buildUsageLookupKey(type, profileKey);
530
+ const nameLookup = buildUsageLookupKey(type, profileName);
531
+ return (
532
+ (keyLookup && costs.byKey.get(keyLookup)) ||
533
+ (nameLookup && costs.byName.get(nameLookup)) ||
534
+ null
535
+ );
536
+ }
537
+
244
538
  export function syncUsageFromStatuslineInput(
245
539
  config: Config,
246
540
  configPath: string | null,
@@ -249,19 +543,28 @@ export function syncUsageFromStatuslineInput(
249
543
  profileName: string | null,
250
544
  sessionId: string | null,
251
545
  totals: UsageTotalsInput | null,
252
- cwd: string | null
546
+ cwd: string | null,
547
+ model: string | null
253
548
  ): void {
254
549
  if (!sessionId) return;
255
550
  if (!totals) return;
256
551
  if (!profileKey && !profileName) return;
552
+ const resolvedModel = normalizeModelValue(model);
553
+ if (!resolvedModel) return;
257
554
  const normalizedType = normalizeType(type || "");
258
555
  if (!normalizedType) return;
259
556
  const usagePath = getUsagePath(config, configPath);
260
557
  if (!usagePath) return;
261
558
  const inputTokens = toFiniteNumber(totals.inputTokens) ?? 0;
262
559
  const outputTokens = toFiniteNumber(totals.outputTokens) ?? 0;
560
+ const cacheReadTokens = toFiniteNumber(totals.cacheReadTokens) ?? 0;
561
+ const cacheWriteTokens = toFiniteNumber(totals.cacheWriteTokens) ?? 0;
263
562
  const totalTokens =
264
- toFiniteNumber(totals.totalTokens) ?? inputTokens + outputTokens;
563
+ toFiniteNumber(totals.totalTokens) ??
564
+ inputTokens +
565
+ outputTokens +
566
+ cacheReadTokens +
567
+ cacheWriteTokens;
265
568
  if (!Number.isFinite(totalTokens)) return;
266
569
 
267
570
  const statePath = getUsageStatePath(usagePath, config);
@@ -273,17 +576,37 @@ export function syncUsageFromStatuslineInput(
273
576
  const sessions = state.sessions || {};
274
577
  const key = buildSessionKey(normalizedType, sessionId);
275
578
  const prev = sessions[key];
276
- const prevInput = prev ? prev.inputTokens : 0;
277
- const prevOutput = prev ? prev.outputTokens : 0;
278
- const prevTotal = prev ? prev.totalTokens : 0;
579
+ const prevInput = prev ? toUsageNumber(prev.inputTokens) : 0;
580
+ const prevOutput = prev ? toUsageNumber(prev.outputTokens) : 0;
581
+ const prevCacheRead = prev ? toUsageNumber(prev.cacheReadTokens) : 0;
582
+ const prevCacheWrite = prev ? toUsageNumber(prev.cacheWriteTokens) : 0;
583
+ const prevTotal = prev ? toUsageNumber(prev.totalTokens) : 0;
279
584
 
280
585
  let deltaInput = inputTokens - prevInput;
281
586
  let deltaOutput = outputTokens - prevOutput;
587
+ let deltaCacheRead = cacheReadTokens - prevCacheRead;
588
+ let deltaCacheWrite = cacheWriteTokens - prevCacheWrite;
282
589
  let deltaTotal = totalTokens - prevTotal;
283
- if (deltaTotal < 0 || deltaInput < 0 || deltaOutput < 0) {
590
+ if (deltaTotal < 0) {
591
+ // Session reset: treat current totals as fresh usage.
284
592
  deltaInput = inputTokens;
285
593
  deltaOutput = outputTokens;
594
+ deltaCacheRead = cacheReadTokens;
595
+ deltaCacheWrite = cacheWriteTokens;
286
596
  deltaTotal = totalTokens;
597
+ } else {
598
+ // Clamp negatives caused by reclassification (e.g. cache splits).
599
+ if (deltaInput < 0) deltaInput = 0;
600
+ if (deltaOutput < 0) deltaOutput = 0;
601
+ if (deltaCacheRead < 0) deltaCacheRead = 0;
602
+ if (deltaCacheWrite < 0) deltaCacheWrite = 0;
603
+ const breakdownTotal =
604
+ deltaInput + deltaOutput + deltaCacheRead + deltaCacheWrite;
605
+ if (deltaTotal === 0 && breakdownTotal === 0) {
606
+ deltaTotal = 0;
607
+ } else if (breakdownTotal > deltaTotal) {
608
+ deltaTotal = breakdownTotal;
609
+ }
287
610
  }
288
611
 
289
612
  if (deltaTotal > 0) {
@@ -292,8 +615,12 @@ export function syncUsageFromStatuslineInput(
292
615
  type: normalizedType,
293
616
  profileKey: profileKey || null,
294
617
  profileName: profileName || null,
618
+ model: resolvedModel,
619
+ sessionId,
295
620
  inputTokens: deltaInput,
296
621
  outputTokens: deltaOutput,
622
+ cacheReadTokens: deltaCacheRead,
623
+ cacheWriteTokens: deltaCacheWrite,
297
624
  totalTokens: deltaTotal,
298
625
  };
299
626
  appendUsageRecord(usagePath, record);
@@ -304,12 +631,16 @@ export function syncUsageFromStatuslineInput(
304
631
  type: normalizedType,
305
632
  inputTokens,
306
633
  outputTokens,
634
+ cacheReadTokens,
635
+ cacheWriteTokens,
307
636
  totalTokens,
308
637
  startTs: prev ? prev.startTs : now,
309
638
  endTs: now,
310
639
  cwd: cwd || (prev ? prev.cwd : null),
640
+ model: resolvedModel,
311
641
  };
312
642
  state.sessions = sessions;
643
+ updateUsageStateMetadata(state, usagePath);
313
644
  writeUsageState(statePath, state);
314
645
  } finally {
315
646
  releaseLock(lockPath, lockFd);
@@ -540,7 +871,15 @@ function readUsageState(statePath: string): UsageStateFile {
540
871
  parsed.files && typeof parsed.files === "object" ? parsed.files : {};
541
872
  const sessions =
542
873
  parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
543
- return { version: 1, files, sessions };
874
+ const usageMtimeMs = Number(parsed.usageMtimeMs);
875
+ const usageSize = Number(parsed.usageSize);
876
+ return {
877
+ version: 1,
878
+ files,
879
+ sessions,
880
+ usageMtimeMs: Number.isFinite(usageMtimeMs) ? usageMtimeMs : undefined,
881
+ usageSize: Number.isFinite(usageSize) ? usageSize : undefined,
882
+ };
544
883
  } catch {
545
884
  return { version: 1, files: {}, sessions: {} };
546
885
  }
@@ -551,7 +890,91 @@ function writeUsageState(statePath: string, state: UsageStateFile) {
551
890
  if (!fs.existsSync(dir)) {
552
891
  fs.mkdirSync(dir, { recursive: true });
553
892
  }
554
- fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
893
+ const payload = `${JSON.stringify(state, null, 2)}\n`;
894
+ const tmpPath = `${statePath}.tmp`;
895
+ try {
896
+ fs.writeFileSync(tmpPath, payload, "utf8");
897
+ fs.renameSync(tmpPath, statePath);
898
+ } catch {
899
+ try {
900
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
901
+ } catch {
902
+ // ignore cleanup failures
903
+ }
904
+ fs.writeFileSync(statePath, payload, "utf8");
905
+ }
906
+ }
907
+
908
+ function addSiblingBackupPaths(targets: Set<string>, filePath: string | null) {
909
+ if (!filePath) return;
910
+ const dir = path.dirname(filePath);
911
+ let entries: fs.Dirent[] = [];
912
+ try {
913
+ entries = fs.readdirSync(dir, { withFileTypes: true });
914
+ } catch {
915
+ return;
916
+ }
917
+ const base = path.basename(filePath);
918
+ for (const entry of entries) {
919
+ if (!entry.isFile()) continue;
920
+ if (entry.name === base) continue;
921
+ if (entry.name.startsWith(`${base}.`)) {
922
+ targets.add(path.join(dir, entry.name));
923
+ }
924
+ }
925
+ }
926
+
927
+ export function clearUsageHistory(
928
+ config: Config,
929
+ configPath: string | null
930
+ ): UsageCleanupResult {
931
+ const targets = new Set<string>();
932
+ const usagePath = getUsagePath(config, configPath);
933
+ if (usagePath) {
934
+ targets.add(usagePath);
935
+ addSiblingBackupPaths(targets, usagePath);
936
+ }
937
+ const statePath = usagePath ? getUsageStatePath(usagePath, config) : null;
938
+ if (statePath) {
939
+ targets.add(statePath);
940
+ targets.add(`${statePath}.lock`);
941
+ addSiblingBackupPaths(targets, statePath);
942
+ }
943
+ const profileLogPath = getProfileLogPath(config, configPath);
944
+ if (profileLogPath) {
945
+ targets.add(profileLogPath);
946
+ addSiblingBackupPaths(targets, profileLogPath);
947
+ }
948
+ const debugPath = getStatuslineDebugPath(configPath);
949
+ if (debugPath) {
950
+ targets.add(debugPath);
951
+ addSiblingBackupPaths(targets, debugPath);
952
+ }
953
+
954
+ const removed: string[] = [];
955
+ const missing: string[] = [];
956
+ const failed: UsageCleanupFailure[] = [];
957
+
958
+ for (const target of targets) {
959
+ if (!target) continue;
960
+ if (!fs.existsSync(target)) {
961
+ missing.push(target);
962
+ continue;
963
+ }
964
+ try {
965
+ const stat = fs.statSync(target);
966
+ if (!stat.isFile()) continue;
967
+ fs.unlinkSync(target);
968
+ removed.push(target);
969
+ } catch (err) {
970
+ failed.push({
971
+ path: target,
972
+ error: err instanceof Error ? err.message : String(err),
973
+ });
974
+ }
975
+ }
976
+
977
+ return { removed, missing, failed };
555
978
  }
556
979
 
557
980
  function collectSessionFiles(root: string | null): string[] {
@@ -595,19 +1018,82 @@ function updateMinMaxTs(
595
1018
  }
596
1019
  }
597
1020
 
1021
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
1022
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1023
+ }
1024
+
1025
+ function coerceModelFromValue(value: unknown): string | null {
1026
+ if (typeof value === "string") return normalizeModelValue(value);
1027
+ if (!isPlainObject(value)) return null;
1028
+ return (
1029
+ normalizeModelValue(value.displayName) ||
1030
+ normalizeModelValue(value.display_name) ||
1031
+ normalizeModelValue(value.name) ||
1032
+ normalizeModelValue(value.id) ||
1033
+ normalizeModelValue(value.model) ||
1034
+ normalizeModelValue(value.model_name) ||
1035
+ normalizeModelValue(value.model_id) ||
1036
+ normalizeModelValue(value.modelId) ||
1037
+ null
1038
+ );
1039
+ }
1040
+
1041
+ function pickModelFromObject(record: Record<string, unknown>): string | null {
1042
+ return (
1043
+ coerceModelFromValue(record.model) ||
1044
+ normalizeModelValue(record.model_name) ||
1045
+ normalizeModelValue(record.modelName) ||
1046
+ normalizeModelValue(record.model_id) ||
1047
+ normalizeModelValue(record.modelId) ||
1048
+ normalizeModelValue(record.model_display_name) ||
1049
+ normalizeModelValue(record.modelDisplayName) ||
1050
+ null
1051
+ );
1052
+ }
1053
+
1054
+ function extractModelFromRecord(record: Record<string, unknown>): string | null {
1055
+ const direct = pickModelFromObject(record);
1056
+ if (direct) return direct;
1057
+ const message = isPlainObject(record.message)
1058
+ ? (record.message as Record<string, unknown>)
1059
+ : null;
1060
+ if (message) {
1061
+ const fromMessage = pickModelFromObject(message);
1062
+ if (fromMessage) return fromMessage;
1063
+ }
1064
+ const payload = isPlainObject(record.payload)
1065
+ ? (record.payload as Record<string, unknown>)
1066
+ : null;
1067
+ if (payload) {
1068
+ const fromPayload = pickModelFromObject(payload);
1069
+ if (fromPayload) return fromPayload;
1070
+ const info = isPlainObject(payload.info)
1071
+ ? (payload.info as Record<string, unknown>)
1072
+ : null;
1073
+ if (info) {
1074
+ const fromInfo = pickModelFromObject(info);
1075
+ if (fromInfo) return fromInfo;
1076
+ }
1077
+ }
1078
+ return null;
1079
+ }
1080
+
598
1081
  function parseCodexSessionFile(filePath: string): SessionStats {
599
1082
  const raw = fs.readFileSync(filePath, "utf8");
600
1083
  const lines = raw.split(/\r?\n/);
601
1084
  let maxTotal = 0;
602
1085
  let maxInput = 0;
603
1086
  let maxOutput = 0;
1087
+ let maxCachedInput = 0;
604
1088
  let hasTotal = false;
605
1089
  let sumLast = 0;
606
1090
  let sumLastInput = 0;
607
1091
  let sumLastOutput = 0;
1092
+ let sumLastCachedInput = 0;
608
1093
  const tsRange = { start: null as string | null, end: null as string | null };
609
1094
  let cwd: string | null = null;
610
1095
  let sessionId: string | null = null;
1096
+ let model: string | null = null;
611
1097
 
612
1098
  for (const line of lines) {
613
1099
  const trimmed = line.trim();
@@ -615,6 +1101,10 @@ function parseCodexSessionFile(filePath: string): SessionStats {
615
1101
  try {
616
1102
  const parsed = JSON.parse(trimmed);
617
1103
  if (!parsed || typeof parsed !== "object") continue;
1104
+ if (!model) {
1105
+ const candidate = extractModelFromRecord(parsed as Record<string, unknown>);
1106
+ if (candidate) model = candidate;
1107
+ }
618
1108
  if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
619
1109
  if (!cwd && parsed.type === "session_meta") {
620
1110
  const payload = parsed.payload || {};
@@ -639,19 +1129,25 @@ function parseCodexSessionFile(filePath: string): SessionStats {
639
1129
  if (totalTokens > maxTotal) maxTotal = totalTokens;
640
1130
  const totalInput = Number(totalUsage.input_tokens);
641
1131
  const totalOutput = Number(totalUsage.output_tokens);
1132
+ const totalCached = Number(totalUsage.cached_input_tokens);
642
1133
  if (Number.isFinite(totalInput) && totalInput > maxInput) {
643
1134
  maxInput = totalInput;
644
1135
  }
645
1136
  if (Number.isFinite(totalOutput) && totalOutput > maxOutput) {
646
1137
  maxOutput = totalOutput;
647
1138
  }
1139
+ if (Number.isFinite(totalCached) && totalCached > maxCachedInput) {
1140
+ maxCachedInput = totalCached;
1141
+ }
648
1142
  } else {
649
1143
  const lastTokens = Number(lastUsage.total_tokens);
650
1144
  if (Number.isFinite(lastTokens)) sumLast += lastTokens;
651
1145
  const lastInput = Number(lastUsage.input_tokens);
652
1146
  const lastOutput = Number(lastUsage.output_tokens);
1147
+ const lastCached = Number(lastUsage.cached_input_tokens);
653
1148
  if (Number.isFinite(lastInput)) sumLastInput += lastInput;
654
1149
  if (Number.isFinite(lastOutput)) sumLastOutput += lastOutput;
1150
+ if (Number.isFinite(lastCached)) sumLastCachedInput += lastCached;
655
1151
  }
656
1152
  } catch {
657
1153
  // ignore invalid lines
@@ -662,16 +1158,26 @@ function parseCodexSessionFile(filePath: string): SessionStats {
662
1158
  maxTotal = sumLast;
663
1159
  maxInput = sumLastInput;
664
1160
  maxOutput = sumLastOutput;
1161
+ maxCachedInput = sumLastCachedInput;
665
1162
  }
666
1163
 
1164
+ const cacheReadTokens = Math.max(0, maxCachedInput);
1165
+ const inputTokens =
1166
+ cacheReadTokens > 0 ? Math.max(0, maxInput - cacheReadTokens) : maxInput;
1167
+ const computedTotal = inputTokens + maxOutput + cacheReadTokens;
1168
+ const totalTokens = Math.max(maxTotal, computedTotal);
1169
+
667
1170
  return {
668
- inputTokens: maxInput,
1171
+ inputTokens,
669
1172
  outputTokens: maxOutput,
670
- totalTokens: maxTotal,
1173
+ cacheReadTokens,
1174
+ cacheWriteTokens: 0,
1175
+ totalTokens,
671
1176
  startTs: tsRange.start,
672
1177
  endTs: tsRange.end,
673
1178
  cwd,
674
1179
  sessionId,
1180
+ model,
675
1181
  };
676
1182
  }
677
1183
 
@@ -681,9 +1187,12 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
681
1187
  let totalTokens = 0;
682
1188
  let inputTokens = 0;
683
1189
  let outputTokens = 0;
1190
+ let cacheReadTokens = 0;
1191
+ let cacheWriteTokens = 0;
684
1192
  const tsRange = { start: null as string | null, end: null as string | null };
685
1193
  let cwd: string | null = null;
686
1194
  let sessionId: string | null = null;
1195
+ let model: string | null = null;
687
1196
 
688
1197
  for (const line of lines) {
689
1198
  const trimmed = line.trim();
@@ -691,6 +1200,10 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
691
1200
  try {
692
1201
  const parsed = JSON.parse(trimmed);
693
1202
  if (!parsed || typeof parsed !== "object") continue;
1203
+ if (!model) {
1204
+ const candidate = extractModelFromRecord(parsed as Record<string, unknown>);
1205
+ if (candidate) model = candidate;
1206
+ }
694
1207
  if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
695
1208
  if (!cwd && parsed.cwd) cwd = String(parsed.cwd);
696
1209
  if (!sessionId && parsed.sessionId) {
@@ -705,6 +1218,8 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
705
1218
  const cacheRead = Number(usage.cache_read_input_tokens ?? 0);
706
1219
  if (Number.isFinite(input)) inputTokens += input;
707
1220
  if (Number.isFinite(output)) outputTokens += output;
1221
+ if (Number.isFinite(cacheCreate)) cacheWriteTokens += cacheCreate;
1222
+ if (Number.isFinite(cacheRead)) cacheReadTokens += cacheRead;
708
1223
  totalTokens +=
709
1224
  (Number.isFinite(input) ? input : 0) +
710
1225
  (Number.isFinite(output) ? output : 0) +
@@ -718,11 +1233,14 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
718
1233
  return {
719
1234
  inputTokens,
720
1235
  outputTokens,
1236
+ cacheReadTokens,
1237
+ cacheWriteTokens,
721
1238
  totalTokens,
722
1239
  startTs: tsRange.start,
723
1240
  endTs: tsRange.end,
724
1241
  cwd,
725
1242
  sessionId,
1243
+ model,
726
1244
  };
727
1245
  }
728
1246
 
@@ -806,6 +1324,91 @@ function releaseLock(lockPath: string, fd: number | null) {
806
1324
  }
807
1325
  }
808
1326
 
1327
+ function readUsageFileStat(usagePath: string): fs.Stats | null {
1328
+ if (!usagePath || !fs.existsSync(usagePath)) return null;
1329
+ try {
1330
+ return fs.statSync(usagePath);
1331
+ } catch {
1332
+ return null;
1333
+ }
1334
+ }
1335
+
1336
+ function buildUsageRecordKey(record: UsageRecord): string {
1337
+ return JSON.stringify([
1338
+ record.ts,
1339
+ record.type,
1340
+ record.profileKey ?? null,
1341
+ record.profileName ?? null,
1342
+ record.model ?? null,
1343
+ record.sessionId ?? null,
1344
+ toUsageNumber(record.inputTokens),
1345
+ toUsageNumber(record.outputTokens),
1346
+ toUsageNumber(record.cacheReadTokens),
1347
+ toUsageNumber(record.cacheWriteTokens),
1348
+ toUsageNumber(record.totalTokens),
1349
+ ]);
1350
+ }
1351
+
1352
+ function buildUsageSessionsFromRecords(
1353
+ records: UsageRecord[]
1354
+ ): Record<string, UsageSessionEntry> {
1355
+ const sessions: Record<string, UsageSessionEntry> = {};
1356
+ const seen = new Set<string>();
1357
+ for (const record of records) {
1358
+ if (!record.sessionId) continue;
1359
+ const normalizedType = normalizeUsageType(record.type);
1360
+ if (!normalizedType) continue;
1361
+ const recordKey = buildUsageRecordKey(record);
1362
+ if (seen.has(recordKey)) continue;
1363
+ seen.add(recordKey);
1364
+
1365
+ const sessionKey = buildSessionKey(
1366
+ normalizedType as ProfileType,
1367
+ record.sessionId
1368
+ );
1369
+ let entry = sessions[sessionKey];
1370
+ if (!entry) {
1371
+ entry = {
1372
+ type: normalizedType as ProfileType,
1373
+ inputTokens: 0,
1374
+ outputTokens: 0,
1375
+ cacheReadTokens: 0,
1376
+ cacheWriteTokens: 0,
1377
+ totalTokens: 0,
1378
+ startTs: null,
1379
+ endTs: null,
1380
+ cwd: null,
1381
+ model: record.model || null,
1382
+ };
1383
+ sessions[sessionKey] = entry;
1384
+ }
1385
+ entry.inputTokens += toUsageNumber(record.inputTokens);
1386
+ entry.outputTokens += toUsageNumber(record.outputTokens);
1387
+ entry.cacheReadTokens += toUsageNumber(record.cacheReadTokens);
1388
+ entry.cacheWriteTokens += toUsageNumber(record.cacheWriteTokens);
1389
+ entry.totalTokens += toUsageNumber(record.totalTokens);
1390
+ if (!entry.model && record.model) entry.model = record.model;
1391
+ if (record.ts) {
1392
+ const range = { start: entry.startTs, end: entry.endTs };
1393
+ updateMinMaxTs(range, record.ts);
1394
+ entry.startTs = range.start;
1395
+ entry.endTs = range.end;
1396
+ }
1397
+ }
1398
+ return sessions;
1399
+ }
1400
+
1401
+ function updateUsageStateMetadata(state: UsageStateFile, usagePath: string) {
1402
+ const stat = readUsageFileStat(usagePath);
1403
+ if (!stat || !stat.isFile()) {
1404
+ state.usageMtimeMs = undefined;
1405
+ state.usageSize = undefined;
1406
+ return;
1407
+ }
1408
+ state.usageMtimeMs = stat.mtimeMs;
1409
+ state.usageSize = stat.size;
1410
+ }
1411
+
809
1412
  function appendUsageRecord(usagePath: string, record: UsageRecord) {
810
1413
  const dir = path.dirname(usagePath);
811
1414
  if (!fs.existsSync(dir)) {
@@ -819,25 +1422,82 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
819
1422
  const raw = fs.readFileSync(usagePath, "utf8");
820
1423
  const lines = raw.split(/\r?\n/);
821
1424
  const records: UsageRecord[] = [];
1425
+ const seen = new Set<string>();
822
1426
  for (const line of lines) {
823
1427
  const trimmed = line.trim();
824
1428
  if (!trimmed) continue;
825
1429
  try {
826
1430
  const parsed = JSON.parse(trimmed);
827
1431
  if (!parsed || typeof parsed !== "object") continue;
828
- const input = Number(parsed.inputTokens ?? 0);
829
- const output = Number(parsed.outputTokens ?? 0);
830
- const total = Number(parsed.totalTokens ?? input + output);
831
1432
  const type = normalizeType(parsed.type) || String(parsed.type ?? "unknown");
832
- records.push({
1433
+ let input = Number(parsed.inputTokens ?? 0);
1434
+ const output = Number(parsed.outputTokens ?? 0);
1435
+ const cacheRead = Number(
1436
+ parsed.cacheReadTokens ??
1437
+ parsed.cache_read_input_tokens ??
1438
+ parsed.cacheReadInputTokens ??
1439
+ 0
1440
+ );
1441
+ const cacheWrite = Number(
1442
+ parsed.cacheWriteTokens ??
1443
+ parsed.cache_creation_input_tokens ??
1444
+ parsed.cache_write_input_tokens ??
1445
+ parsed.cacheWriteInputTokens ??
1446
+ 0
1447
+ );
1448
+ if (
1449
+ type === "codex" &&
1450
+ Number.isFinite(cacheRead) &&
1451
+ cacheRead > 0 &&
1452
+ Number.isFinite(input) &&
1453
+ Number.isFinite(output)
1454
+ ) {
1455
+ const rawTotal = Number(parsed.totalTokens);
1456
+ if (Number.isFinite(rawTotal) && rawTotal <= input + output) {
1457
+ input = Math.max(0, input - cacheRead);
1458
+ }
1459
+ }
1460
+ const computedTotal =
1461
+ (Number.isFinite(input) ? input : 0) +
1462
+ (Number.isFinite(output) ? output : 0) +
1463
+ (Number.isFinite(cacheRead) ? cacheRead : 0) +
1464
+ (Number.isFinite(cacheWrite) ? cacheWrite : 0);
1465
+ const total = Number(
1466
+ parsed.totalTokens ?? computedTotal
1467
+ );
1468
+ const finalTotal = Number.isFinite(total)
1469
+ ? Math.max(total, computedTotal)
1470
+ : computedTotal;
1471
+ const model =
1472
+ normalizeModelValue(parsed.model) ||
1473
+ normalizeModelValue(parsed.model_name) ||
1474
+ normalizeModelValue(parsed.modelName) ||
1475
+ normalizeModelValue(parsed.model_id) ||
1476
+ normalizeModelValue(parsed.modelId) ||
1477
+ null;
1478
+ const sessionId =
1479
+ parsed.sessionId ||
1480
+ parsed.session_id ||
1481
+ parsed.sessionID ||
1482
+ parsed.session ||
1483
+ null;
1484
+ const record: UsageRecord = {
833
1485
  ts: String(parsed.ts ?? ""),
834
1486
  type,
835
1487
  profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
836
1488
  profileName: parsed.profileName ? String(parsed.profileName) : null,
1489
+ model,
1490
+ sessionId: sessionId ? String(sessionId) : null,
837
1491
  inputTokens: Number.isFinite(input) ? input : 0,
838
1492
  outputTokens: Number.isFinite(output) ? output : 0,
839
- totalTokens: Number.isFinite(total) ? total : 0,
840
- });
1493
+ cacheReadTokens: Number.isFinite(cacheRead) ? cacheRead : 0,
1494
+ cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : 0,
1495
+ totalTokens: Number.isFinite(finalTotal) ? finalTotal : 0,
1496
+ };
1497
+ const key = buildUsageRecordKey(record);
1498
+ if (seen.has(key)) continue;
1499
+ seen.add(key);
1500
+ records.push(record);
841
1501
  } catch {
842
1502
  // ignore invalid lines
843
1503
  }
@@ -860,7 +1520,25 @@ export function syncUsageFromSessions(
860
1520
 
861
1521
  const state = readUsageState(statePath);
862
1522
  const files = state.files || {};
863
- const sessions = state.sessions || {};
1523
+ let sessions = state.sessions || {};
1524
+ const usageStat = readUsageFileStat(usagePath);
1525
+ const hasUsageData = !!usageStat && usageStat.isFile() && usageStat.size > 0;
1526
+ const sessionsEmpty = Object.keys(sessions).length === 0;
1527
+ const hasUsageMeta =
1528
+ Number.isFinite(state.usageMtimeMs ?? Number.NaN) &&
1529
+ Number.isFinite(state.usageSize ?? Number.NaN);
1530
+ const usageOutOfSync =
1531
+ hasUsageData &&
1532
+ (!hasUsageMeta ||
1533
+ state.usageMtimeMs !== usageStat.mtimeMs ||
1534
+ state.usageSize !== usageStat.size);
1535
+ if (hasUsageData && (sessionsEmpty || usageOutOfSync)) {
1536
+ const records = readUsageRecords(usagePath);
1537
+ if (records.length > 0) {
1538
+ sessions = buildUsageSessionsFromRecords(records);
1539
+ }
1540
+ }
1541
+ state.sessions = sessions;
864
1542
  const codexFiles = collectSessionFiles(getCodexSessionsPath(config));
865
1543
  const claudeFiles = collectSessionFiles(getClaudeSessionsPath(config));
866
1544
 
@@ -896,29 +1574,54 @@ export function syncUsageFromSessions(
896
1574
  const sessionKey =
897
1575
  stats.sessionId ? buildSessionKey(type, stats.sessionId) : null;
898
1576
  const sessionPrev = sessionKey ? sessions[sessionKey] : null;
899
- const prevInput = prev ? prev.inputTokens : 0;
900
- const prevOutput = prev ? prev.outputTokens : 0;
901
- const prevTotal = prev ? prev.totalTokens : 0;
1577
+ const resolvedModel =
1578
+ (sessionPrev && sessionPrev.model) ||
1579
+ stats.model ||
1580
+ (prev && prev.model) ||
1581
+ null;
1582
+ const prevInput = prev ? toUsageNumber(prev.inputTokens) : 0;
1583
+ const prevOutput = prev ? toUsageNumber(prev.outputTokens) : 0;
1584
+ const prevCacheRead = prev ? toUsageNumber(prev.cacheReadTokens) : 0;
1585
+ const prevCacheWrite = prev ? toUsageNumber(prev.cacheWriteTokens) : 0;
1586
+ const prevTotal = prev ? toUsageNumber(prev.totalTokens) : 0;
902
1587
  const prevInputMax = sessionPrev
903
- ? Math.max(prevInput, sessionPrev.inputTokens)
1588
+ ? Math.max(prevInput, toUsageNumber(sessionPrev.inputTokens))
904
1589
  : prevInput;
905
1590
  const prevOutputMax = sessionPrev
906
- ? Math.max(prevOutput, sessionPrev.outputTokens)
1591
+ ? Math.max(prevOutput, toUsageNumber(sessionPrev.outputTokens))
907
1592
  : prevOutput;
1593
+ const prevCacheReadMax = sessionPrev
1594
+ ? Math.max(prevCacheRead, toUsageNumber(sessionPrev.cacheReadTokens))
1595
+ : prevCacheRead;
1596
+ const prevCacheWriteMax = sessionPrev
1597
+ ? Math.max(prevCacheWrite, toUsageNumber(sessionPrev.cacheWriteTokens))
1598
+ : prevCacheWrite;
908
1599
  const prevTotalMax = sessionPrev
909
- ? Math.max(prevTotal, sessionPrev.totalTokens)
1600
+ ? Math.max(prevTotal, toUsageNumber(sessionPrev.totalTokens))
910
1601
  : prevTotal;
911
1602
  let deltaInput = stats.inputTokens - prevInputMax;
912
1603
  let deltaOutput = stats.outputTokens - prevOutputMax;
1604
+ let deltaCacheRead = stats.cacheReadTokens - prevCacheReadMax;
1605
+ let deltaCacheWrite = stats.cacheWriteTokens - prevCacheWriteMax;
913
1606
  let deltaTotal = stats.totalTokens - prevTotalMax;
914
- if (deltaTotal < 0 || deltaInput < 0 || deltaOutput < 0) {
1607
+ if (
1608
+ deltaTotal < 0 ||
1609
+ deltaInput < 0 ||
1610
+ deltaOutput < 0 ||
1611
+ deltaCacheRead < 0 ||
1612
+ deltaCacheWrite < 0
1613
+ ) {
915
1614
  if (sessionPrev) {
916
1615
  deltaInput = 0;
917
1616
  deltaOutput = 0;
1617
+ deltaCacheRead = 0;
1618
+ deltaCacheWrite = 0;
918
1619
  deltaTotal = 0;
919
1620
  } else {
920
1621
  deltaInput = stats.inputTokens;
921
1622
  deltaOutput = stats.outputTokens;
1623
+ deltaCacheRead = stats.cacheReadTokens;
1624
+ deltaCacheWrite = stats.cacheWriteTokens;
922
1625
  deltaTotal = stats.totalTokens;
923
1626
  }
924
1627
  }
@@ -928,30 +1631,58 @@ export function syncUsageFromSessions(
928
1631
  type,
929
1632
  profileKey: resolved.match.profileKey,
930
1633
  profileName: resolved.match.profileName,
1634
+ model: resolvedModel,
1635
+ sessionId: stats.sessionId,
931
1636
  inputTokens: deltaInput,
932
1637
  outputTokens: deltaOutput,
1638
+ cacheReadTokens: deltaCacheRead,
1639
+ cacheWriteTokens: deltaCacheWrite,
933
1640
  totalTokens: deltaTotal,
934
1641
  };
935
1642
  appendUsageRecord(usagePath, record);
936
1643
  }
937
1644
  if (sessionKey) {
938
1645
  const nextInput = sessionPrev
939
- ? Math.max(sessionPrev.inputTokens, stats.inputTokens)
1646
+ ? Math.max(
1647
+ toUsageNumber(sessionPrev.inputTokens),
1648
+ stats.inputTokens
1649
+ )
940
1650
  : stats.inputTokens;
941
1651
  const nextOutput = sessionPrev
942
- ? Math.max(sessionPrev.outputTokens, stats.outputTokens)
1652
+ ? Math.max(
1653
+ toUsageNumber(sessionPrev.outputTokens),
1654
+ stats.outputTokens
1655
+ )
943
1656
  : stats.outputTokens;
1657
+ const nextCacheRead = sessionPrev
1658
+ ? Math.max(
1659
+ toUsageNumber(sessionPrev.cacheReadTokens),
1660
+ stats.cacheReadTokens
1661
+ )
1662
+ : stats.cacheReadTokens;
1663
+ const nextCacheWrite = sessionPrev
1664
+ ? Math.max(
1665
+ toUsageNumber(sessionPrev.cacheWriteTokens),
1666
+ stats.cacheWriteTokens
1667
+ )
1668
+ : stats.cacheWriteTokens;
944
1669
  const nextTotal = sessionPrev
945
- ? Math.max(sessionPrev.totalTokens, stats.totalTokens)
1670
+ ? Math.max(
1671
+ toUsageNumber(sessionPrev.totalTokens),
1672
+ stats.totalTokens
1673
+ )
946
1674
  : stats.totalTokens;
947
1675
  sessions[sessionKey] = {
948
1676
  type,
949
1677
  inputTokens: nextInput,
950
1678
  outputTokens: nextOutput,
1679
+ cacheReadTokens: nextCacheRead,
1680
+ cacheWriteTokens: nextCacheWrite,
951
1681
  totalTokens: nextTotal,
952
1682
  startTs: sessionPrev ? sessionPrev.startTs : stats.startTs,
953
1683
  endTs: stats.endTs || (sessionPrev ? sessionPrev.endTs : null),
954
1684
  cwd: stats.cwd || (sessionPrev ? sessionPrev.cwd : null),
1685
+ model: resolvedModel,
955
1686
  };
956
1687
  }
957
1688
  files[filePath] = {
@@ -960,10 +1691,13 @@ export function syncUsageFromSessions(
960
1691
  type,
961
1692
  inputTokens: stats.inputTokens,
962
1693
  outputTokens: stats.outputTokens,
1694
+ cacheReadTokens: stats.cacheReadTokens,
1695
+ cacheWriteTokens: stats.cacheWriteTokens,
963
1696
  totalTokens: stats.totalTokens,
964
1697
  startTs: stats.startTs,
965
1698
  endTs: stats.endTs,
966
1699
  cwd: stats.cwd,
1700
+ model: resolvedModel,
967
1701
  };
968
1702
  };
969
1703
 
@@ -972,6 +1706,7 @@ export function syncUsageFromSessions(
972
1706
 
973
1707
  state.files = files;
974
1708
  state.sessions = sessions;
1709
+ updateUsageStateMetadata(state, usagePath);
975
1710
  writeUsageState(statePath, state);
976
1711
  } finally {
977
1712
  releaseLock(lockPath, lockFd);