@praeviso/code-env-switch 0.1.5 → 0.1.7

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.
@@ -7,6 +7,7 @@ import * as os from "os";
7
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";
10
11
  import { calculateUsageCost, resolvePricingForProfile } from "./pricing";
11
12
 
12
13
  interface UsageRecord {
@@ -53,6 +54,17 @@ interface UsageCostIndex {
53
54
  byName: Map<string, UsageCostTotals>;
54
55
  }
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
+
56
68
  interface UsageStateEntry {
57
69
  mtimeMs: number;
58
70
  size: number;
@@ -79,12 +91,16 @@ interface UsageSessionEntry {
79
91
  endTs: string | null;
80
92
  cwd: string | null;
81
93
  model?: string | null;
94
+ profileKey?: string | null;
95
+ profileName?: string | null;
82
96
  }
83
97
 
84
98
  interface UsageStateFile {
85
99
  version: number;
86
100
  files: Record<string, UsageStateEntry>;
87
101
  sessions?: Record<string, UsageSessionEntry>;
102
+ usageMtimeMs?: number;
103
+ usageSize?: number;
88
104
  }
89
105
 
90
106
  interface ProfileLogEntry {
@@ -573,18 +589,26 @@ export function syncUsageFromStatuslineInput(
573
589
  let deltaCacheRead = cacheReadTokens - prevCacheRead;
574
590
  let deltaCacheWrite = cacheWriteTokens - prevCacheWrite;
575
591
  let deltaTotal = totalTokens - prevTotal;
576
- if (
577
- deltaTotal < 0 ||
578
- deltaInput < 0 ||
579
- deltaOutput < 0 ||
580
- deltaCacheRead < 0 ||
581
- deltaCacheWrite < 0
582
- ) {
592
+ if (deltaTotal < 0) {
593
+ // Session reset: treat current totals as fresh usage.
583
594
  deltaInput = inputTokens;
584
595
  deltaOutput = outputTokens;
585
596
  deltaCacheRead = cacheReadTokens;
586
597
  deltaCacheWrite = cacheWriteTokens;
587
598
  deltaTotal = totalTokens;
599
+ } else {
600
+ // Clamp negatives caused by reclassification (e.g. cache splits).
601
+ if (deltaInput < 0) deltaInput = 0;
602
+ if (deltaOutput < 0) deltaOutput = 0;
603
+ if (deltaCacheRead < 0) deltaCacheRead = 0;
604
+ if (deltaCacheWrite < 0) deltaCacheWrite = 0;
605
+ const breakdownTotal =
606
+ deltaInput + deltaOutput + deltaCacheRead + deltaCacheWrite;
607
+ if (deltaTotal === 0 && breakdownTotal === 0) {
608
+ deltaTotal = 0;
609
+ } else if (breakdownTotal > deltaTotal) {
610
+ deltaTotal = breakdownTotal;
611
+ }
588
612
  }
589
613
 
590
614
  if (deltaTotal > 0) {
@@ -616,8 +640,11 @@ export function syncUsageFromStatuslineInput(
616
640
  endTs: now,
617
641
  cwd: cwd || (prev ? prev.cwd : null),
618
642
  model: resolvedModel,
643
+ profileKey: profileKey || null,
644
+ profileName: profileName || null,
619
645
  };
620
646
  state.sessions = sessions;
647
+ updateUsageStateMetadata(state, usagePath);
621
648
  writeUsageState(statePath, state);
622
649
  } finally {
623
650
  releaseLock(lockPath, lockFd);
@@ -848,7 +875,15 @@ function readUsageState(statePath: string): UsageStateFile {
848
875
  parsed.files && typeof parsed.files === "object" ? parsed.files : {};
849
876
  const sessions =
850
877
  parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
851
- return { version: 1, files, sessions };
878
+ const usageMtimeMs = Number(parsed.usageMtimeMs);
879
+ const usageSize = Number(parsed.usageSize);
880
+ return {
881
+ version: 1,
882
+ files,
883
+ sessions,
884
+ usageMtimeMs: Number.isFinite(usageMtimeMs) ? usageMtimeMs : undefined,
885
+ usageSize: Number.isFinite(usageSize) ? usageSize : undefined,
886
+ };
852
887
  } catch {
853
888
  return { version: 1, files: {}, sessions: {} };
854
889
  }
@@ -859,7 +894,91 @@ function writeUsageState(statePath: string, state: UsageStateFile) {
859
894
  if (!fs.existsSync(dir)) {
860
895
  fs.mkdirSync(dir, { recursive: true });
861
896
  }
862
- fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
897
+ const payload = `${JSON.stringify(state, null, 2)}\n`;
898
+ const tmpPath = `${statePath}.tmp`;
899
+ try {
900
+ fs.writeFileSync(tmpPath, payload, "utf8");
901
+ fs.renameSync(tmpPath, statePath);
902
+ } catch {
903
+ try {
904
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
905
+ } catch {
906
+ // ignore cleanup failures
907
+ }
908
+ fs.writeFileSync(statePath, payload, "utf8");
909
+ }
910
+ }
911
+
912
+ function addSiblingBackupPaths(targets: Set<string>, filePath: string | null) {
913
+ if (!filePath) return;
914
+ const dir = path.dirname(filePath);
915
+ let entries: fs.Dirent[] = [];
916
+ try {
917
+ entries = fs.readdirSync(dir, { withFileTypes: true });
918
+ } catch {
919
+ return;
920
+ }
921
+ const base = path.basename(filePath);
922
+ for (const entry of entries) {
923
+ if (!entry.isFile()) continue;
924
+ if (entry.name === base) continue;
925
+ if (entry.name.startsWith(`${base}.`)) {
926
+ targets.add(path.join(dir, entry.name));
927
+ }
928
+ }
929
+ }
930
+
931
+ export function clearUsageHistory(
932
+ config: Config,
933
+ configPath: string | null
934
+ ): UsageCleanupResult {
935
+ const targets = new Set<string>();
936
+ const usagePath = getUsagePath(config, configPath);
937
+ if (usagePath) {
938
+ targets.add(usagePath);
939
+ addSiblingBackupPaths(targets, usagePath);
940
+ }
941
+ const statePath = usagePath ? getUsageStatePath(usagePath, config) : null;
942
+ if (statePath) {
943
+ targets.add(statePath);
944
+ targets.add(`${statePath}.lock`);
945
+ addSiblingBackupPaths(targets, statePath);
946
+ }
947
+ const profileLogPath = getProfileLogPath(config, configPath);
948
+ if (profileLogPath) {
949
+ targets.add(profileLogPath);
950
+ addSiblingBackupPaths(targets, profileLogPath);
951
+ }
952
+ const debugPath = getStatuslineDebugPath(configPath);
953
+ if (debugPath) {
954
+ targets.add(debugPath);
955
+ addSiblingBackupPaths(targets, debugPath);
956
+ }
957
+
958
+ const removed: string[] = [];
959
+ const missing: string[] = [];
960
+ const failed: UsageCleanupFailure[] = [];
961
+
962
+ for (const target of targets) {
963
+ if (!target) continue;
964
+ if (!fs.existsSync(target)) {
965
+ missing.push(target);
966
+ continue;
967
+ }
968
+ try {
969
+ const stat = fs.statSync(target);
970
+ if (!stat.isFile()) continue;
971
+ fs.unlinkSync(target);
972
+ removed.push(target);
973
+ } catch (err) {
974
+ failed.push({
975
+ path: target,
976
+ error: err instanceof Error ? err.message : String(err),
977
+ });
978
+ }
979
+ }
980
+
981
+ return { removed, missing, failed };
863
982
  }
864
983
 
865
984
  function collectSessionFiles(root: string | null): string[] {
@@ -969,10 +1088,12 @@ function parseCodexSessionFile(filePath: string): SessionStats {
969
1088
  let maxTotal = 0;
970
1089
  let maxInput = 0;
971
1090
  let maxOutput = 0;
1091
+ let maxCachedInput = 0;
972
1092
  let hasTotal = false;
973
1093
  let sumLast = 0;
974
1094
  let sumLastInput = 0;
975
1095
  let sumLastOutput = 0;
1096
+ let sumLastCachedInput = 0;
976
1097
  const tsRange = { start: null as string | null, end: null as string | null };
977
1098
  let cwd: string | null = null;
978
1099
  let sessionId: string | null = null;
@@ -1012,19 +1133,25 @@ function parseCodexSessionFile(filePath: string): SessionStats {
1012
1133
  if (totalTokens > maxTotal) maxTotal = totalTokens;
1013
1134
  const totalInput = Number(totalUsage.input_tokens);
1014
1135
  const totalOutput = Number(totalUsage.output_tokens);
1136
+ const totalCached = Number(totalUsage.cached_input_tokens);
1015
1137
  if (Number.isFinite(totalInput) && totalInput > maxInput) {
1016
1138
  maxInput = totalInput;
1017
1139
  }
1018
1140
  if (Number.isFinite(totalOutput) && totalOutput > maxOutput) {
1019
1141
  maxOutput = totalOutput;
1020
1142
  }
1143
+ if (Number.isFinite(totalCached) && totalCached > maxCachedInput) {
1144
+ maxCachedInput = totalCached;
1145
+ }
1021
1146
  } else {
1022
1147
  const lastTokens = Number(lastUsage.total_tokens);
1023
1148
  if (Number.isFinite(lastTokens)) sumLast += lastTokens;
1024
1149
  const lastInput = Number(lastUsage.input_tokens);
1025
1150
  const lastOutput = Number(lastUsage.output_tokens);
1151
+ const lastCached = Number(lastUsage.cached_input_tokens);
1026
1152
  if (Number.isFinite(lastInput)) sumLastInput += lastInput;
1027
1153
  if (Number.isFinite(lastOutput)) sumLastOutput += lastOutput;
1154
+ if (Number.isFinite(lastCached)) sumLastCachedInput += lastCached;
1028
1155
  }
1029
1156
  } catch {
1030
1157
  // ignore invalid lines
@@ -1035,14 +1162,21 @@ function parseCodexSessionFile(filePath: string): SessionStats {
1035
1162
  maxTotal = sumLast;
1036
1163
  maxInput = sumLastInput;
1037
1164
  maxOutput = sumLastOutput;
1165
+ maxCachedInput = sumLastCachedInput;
1038
1166
  }
1039
1167
 
1168
+ const cacheReadTokens = Math.max(0, maxCachedInput);
1169
+ const inputTokens =
1170
+ cacheReadTokens > 0 ? Math.max(0, maxInput - cacheReadTokens) : maxInput;
1171
+ const computedTotal = inputTokens + maxOutput + cacheReadTokens;
1172
+ const totalTokens = Math.max(maxTotal, computedTotal);
1173
+
1040
1174
  return {
1041
- inputTokens: maxInput,
1175
+ inputTokens,
1042
1176
  outputTokens: maxOutput,
1043
- cacheReadTokens: 0,
1177
+ cacheReadTokens,
1044
1178
  cacheWriteTokens: 0,
1045
- totalTokens: maxTotal,
1179
+ totalTokens,
1046
1180
  startTs: tsRange.start,
1047
1181
  endTs: tsRange.end,
1048
1182
  cwd,
@@ -1194,6 +1328,93 @@ function releaseLock(lockPath: string, fd: number | null) {
1194
1328
  }
1195
1329
  }
1196
1330
 
1331
+ function readUsageFileStat(usagePath: string): fs.Stats | null {
1332
+ if (!usagePath || !fs.existsSync(usagePath)) return null;
1333
+ try {
1334
+ return fs.statSync(usagePath);
1335
+ } catch {
1336
+ return null;
1337
+ }
1338
+ }
1339
+
1340
+ function buildUsageRecordKey(record: UsageRecord): string {
1341
+ return JSON.stringify([
1342
+ record.ts,
1343
+ record.type,
1344
+ record.profileKey ?? null,
1345
+ record.profileName ?? null,
1346
+ record.model ?? null,
1347
+ record.sessionId ?? null,
1348
+ toUsageNumber(record.inputTokens),
1349
+ toUsageNumber(record.outputTokens),
1350
+ toUsageNumber(record.cacheReadTokens),
1351
+ toUsageNumber(record.cacheWriteTokens),
1352
+ toUsageNumber(record.totalTokens),
1353
+ ]);
1354
+ }
1355
+
1356
+ function buildUsageSessionsFromRecords(
1357
+ records: UsageRecord[]
1358
+ ): Record<string, UsageSessionEntry> {
1359
+ const sessions: Record<string, UsageSessionEntry> = {};
1360
+ const seen = new Set<string>();
1361
+ for (const record of records) {
1362
+ if (!record.sessionId) continue;
1363
+ const normalizedType = normalizeUsageType(record.type);
1364
+ if (!normalizedType) continue;
1365
+ const recordKey = buildUsageRecordKey(record);
1366
+ if (seen.has(recordKey)) continue;
1367
+ seen.add(recordKey);
1368
+
1369
+ const sessionKey = buildSessionKey(
1370
+ normalizedType as ProfileType,
1371
+ record.sessionId
1372
+ );
1373
+ let entry = sessions[sessionKey];
1374
+ if (!entry) {
1375
+ entry = {
1376
+ type: normalizedType as ProfileType,
1377
+ inputTokens: 0,
1378
+ outputTokens: 0,
1379
+ cacheReadTokens: 0,
1380
+ cacheWriteTokens: 0,
1381
+ totalTokens: 0,
1382
+ startTs: null,
1383
+ endTs: null,
1384
+ cwd: null,
1385
+ model: record.model || null,
1386
+ };
1387
+ sessions[sessionKey] = entry;
1388
+ }
1389
+ entry.inputTokens += toUsageNumber(record.inputTokens);
1390
+ entry.outputTokens += toUsageNumber(record.outputTokens);
1391
+ entry.cacheReadTokens += toUsageNumber(record.cacheReadTokens);
1392
+ entry.cacheWriteTokens += toUsageNumber(record.cacheWriteTokens);
1393
+ entry.totalTokens += toUsageNumber(record.totalTokens);
1394
+ if (record.profileKey) entry.profileKey = record.profileKey;
1395
+ if (record.profileName) entry.profileName = record.profileName;
1396
+ if (!entry.model && record.model) entry.model = record.model;
1397
+ if (record.ts) {
1398
+ const range = { start: entry.startTs, end: entry.endTs };
1399
+ updateMinMaxTs(range, record.ts);
1400
+ entry.startTs = range.start;
1401
+ entry.endTs = range.end;
1402
+ }
1403
+ }
1404
+ return sessions;
1405
+ }
1406
+
1407
+ function updateUsageStateMetadata(state: UsageStateFile, usagePath: string) {
1408
+ const stat = readUsageFileStat(usagePath);
1409
+ if (!stat || !stat.isFile()) {
1410
+ state.usageMtimeMs = undefined;
1411
+ state.usageSize = undefined;
1412
+ return;
1413
+ }
1414
+ state.usageMtimeMs = stat.mtimeMs;
1415
+ state.usageSize = stat.size;
1416
+ }
1417
+
1197
1418
  function appendUsageRecord(usagePath: string, record: UsageRecord) {
1198
1419
  const dir = path.dirname(usagePath);
1199
1420
  if (!fs.existsSync(dir)) {
@@ -1207,13 +1428,15 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
1207
1428
  const raw = fs.readFileSync(usagePath, "utf8");
1208
1429
  const lines = raw.split(/\r?\n/);
1209
1430
  const records: UsageRecord[] = [];
1431
+ const seen = new Set<string>();
1210
1432
  for (const line of lines) {
1211
1433
  const trimmed = line.trim();
1212
1434
  if (!trimmed) continue;
1213
1435
  try {
1214
1436
  const parsed = JSON.parse(trimmed);
1215
1437
  if (!parsed || typeof parsed !== "object") continue;
1216
- const input = Number(parsed.inputTokens ?? 0);
1438
+ const type = normalizeType(parsed.type) || String(parsed.type ?? "unknown");
1439
+ let input = Number(parsed.inputTokens ?? 0);
1217
1440
  const output = Number(parsed.outputTokens ?? 0);
1218
1441
  const cacheRead = Number(
1219
1442
  parsed.cacheReadTokens ??
@@ -1228,6 +1451,18 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
1228
1451
  parsed.cacheWriteInputTokens ??
1229
1452
  0
1230
1453
  );
1454
+ if (
1455
+ type === "codex" &&
1456
+ Number.isFinite(cacheRead) &&
1457
+ cacheRead > 0 &&
1458
+ Number.isFinite(input) &&
1459
+ Number.isFinite(output)
1460
+ ) {
1461
+ const rawTotal = Number(parsed.totalTokens);
1462
+ if (Number.isFinite(rawTotal) && rawTotal <= input + output) {
1463
+ input = Math.max(0, input - cacheRead);
1464
+ }
1465
+ }
1231
1466
  const computedTotal =
1232
1467
  (Number.isFinite(input) ? input : 0) +
1233
1468
  (Number.isFinite(output) ? output : 0) +
@@ -1239,7 +1474,6 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
1239
1474
  const finalTotal = Number.isFinite(total)
1240
1475
  ? Math.max(total, computedTotal)
1241
1476
  : computedTotal;
1242
- const type = normalizeType(parsed.type) || String(parsed.type ?? "unknown");
1243
1477
  const model =
1244
1478
  normalizeModelValue(parsed.model) ||
1245
1479
  normalizeModelValue(parsed.model_name) ||
@@ -1253,7 +1487,7 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
1253
1487
  parsed.sessionID ||
1254
1488
  parsed.session ||
1255
1489
  null;
1256
- records.push({
1490
+ const record: UsageRecord = {
1257
1491
  ts: String(parsed.ts ?? ""),
1258
1492
  type,
1259
1493
  profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
@@ -1265,7 +1499,11 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
1265
1499
  cacheReadTokens: Number.isFinite(cacheRead) ? cacheRead : 0,
1266
1500
  cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : 0,
1267
1501
  totalTokens: Number.isFinite(finalTotal) ? finalTotal : 0,
1268
- });
1502
+ };
1503
+ const key = buildUsageRecordKey(record);
1504
+ if (seen.has(key)) continue;
1505
+ seen.add(key);
1506
+ records.push(record);
1269
1507
  } catch {
1270
1508
  // ignore invalid lines
1271
1509
  }
@@ -1288,7 +1526,25 @@ export function syncUsageFromSessions(
1288
1526
 
1289
1527
  const state = readUsageState(statePath);
1290
1528
  const files = state.files || {};
1291
- const sessions = state.sessions || {};
1529
+ let sessions = state.sessions || {};
1530
+ const usageStat = readUsageFileStat(usagePath);
1531
+ const hasUsageData = !!usageStat && usageStat.isFile() && usageStat.size > 0;
1532
+ const sessionsEmpty = Object.keys(sessions).length === 0;
1533
+ const hasUsageMeta =
1534
+ Number.isFinite(state.usageMtimeMs ?? Number.NaN) &&
1535
+ Number.isFinite(state.usageSize ?? Number.NaN);
1536
+ const usageOutOfSync =
1537
+ hasUsageData &&
1538
+ (!hasUsageMeta ||
1539
+ state.usageMtimeMs !== usageStat.mtimeMs ||
1540
+ state.usageSize !== usageStat.size);
1541
+ if (hasUsageData && (sessionsEmpty || usageOutOfSync)) {
1542
+ const records = readUsageRecords(usagePath);
1543
+ if (records.length > 0) {
1544
+ sessions = buildUsageSessionsFromRecords(records);
1545
+ }
1546
+ }
1547
+ state.sessions = sessions;
1292
1548
  const codexFiles = collectSessionFiles(getCodexSessionsPath(config));
1293
1549
  const claudeFiles = collectSessionFiles(getClaudeSessionsPath(config));
1294
1550
 
@@ -1313,17 +1569,26 @@ export function syncUsageFromSessions(
1313
1569
  } catch {
1314
1570
  return;
1315
1571
  }
1316
- const resolved = resolveProfileForSession(
1317
- config,
1318
- logEntries,
1319
- type,
1320
- filePath,
1321
- stats.sessionId
1322
- );
1323
- if (!resolved.match) return;
1324
1572
  const sessionKey =
1325
1573
  stats.sessionId ? buildSessionKey(type, stats.sessionId) : null;
1326
1574
  const sessionPrev = sessionKey ? sessions[sessionKey] : null;
1575
+ const sessionProfile =
1576
+ sessionPrev && (sessionPrev.profileKey || sessionPrev.profileName)
1577
+ ? {
1578
+ profileKey: sessionPrev.profileKey || null,
1579
+ profileName: sessionPrev.profileName || null,
1580
+ }
1581
+ : null;
1582
+ const resolved = sessionProfile
1583
+ ? { match: sessionProfile, ambiguous: false }
1584
+ : resolveProfileForSession(
1585
+ config,
1586
+ logEntries,
1587
+ type,
1588
+ filePath,
1589
+ stats.sessionId
1590
+ );
1591
+ if (!resolved.match) return;
1327
1592
  const resolvedModel =
1328
1593
  (sessionPrev && sessionPrev.model) ||
1329
1594
  stats.model ||
@@ -1433,6 +1698,8 @@ export function syncUsageFromSessions(
1433
1698
  endTs: stats.endTs || (sessionPrev ? sessionPrev.endTs : null),
1434
1699
  cwd: stats.cwd || (sessionPrev ? sessionPrev.cwd : null),
1435
1700
  model: resolvedModel,
1701
+ profileKey: resolved.match.profileKey,
1702
+ profileName: resolved.match.profileName,
1436
1703
  };
1437
1704
  }
1438
1705
  files[filePath] = {
@@ -1456,6 +1723,7 @@ export function syncUsageFromSessions(
1456
1723
 
1457
1724
  state.files = files;
1458
1725
  state.sessions = sessions;
1726
+ updateUsageStateMetadata(state, usagePath);
1459
1727
  writeUsageState(statePath, state);
1460
1728
  } finally {
1461
1729
  releaseLock(lockPath, lockFd);