@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.
@@ -16,6 +16,7 @@ exports.syncUsageFromStatuslineInput = syncUsageFromStatuslineInput;
16
16
  exports.logProfileUse = logProfileUse;
17
17
  exports.logSessionBinding = logSessionBinding;
18
18
  exports.readSessionBindingIndex = readSessionBindingIndex;
19
+ exports.clearUsageHistory = clearUsageHistory;
19
20
  exports.readUsageRecords = readUsageRecords;
20
21
  exports.syncUsageFromSessions = syncUsageFromSessions;
21
22
  /**
@@ -26,6 +27,7 @@ const path = require("path");
26
27
  const os = require("os");
27
28
  const utils_1 = require("../shell/utils");
28
29
  const type_1 = require("../profile/type");
30
+ const debug_1 = require("../statusline/debug");
29
31
  const pricing_1 = require("./pricing");
30
32
  function resolveProfileForRecord(config, type, record) {
31
33
  if (record.profileKey && config.profiles && config.profiles[record.profileKey]) {
@@ -393,17 +395,32 @@ function syncUsageFromStatuslineInput(config, configPath, type, profileKey, prof
393
395
  let deltaCacheRead = cacheReadTokens - prevCacheRead;
394
396
  let deltaCacheWrite = cacheWriteTokens - prevCacheWrite;
395
397
  let deltaTotal = totalTokens - prevTotal;
396
- if (deltaTotal < 0 ||
397
- deltaInput < 0 ||
398
- deltaOutput < 0 ||
399
- deltaCacheRead < 0 ||
400
- deltaCacheWrite < 0) {
398
+ if (deltaTotal < 0) {
399
+ // Session reset: treat current totals as fresh usage.
401
400
  deltaInput = inputTokens;
402
401
  deltaOutput = outputTokens;
403
402
  deltaCacheRead = cacheReadTokens;
404
403
  deltaCacheWrite = cacheWriteTokens;
405
404
  deltaTotal = totalTokens;
406
405
  }
406
+ else {
407
+ // Clamp negatives caused by reclassification (e.g. cache splits).
408
+ if (deltaInput < 0)
409
+ deltaInput = 0;
410
+ if (deltaOutput < 0)
411
+ deltaOutput = 0;
412
+ if (deltaCacheRead < 0)
413
+ deltaCacheRead = 0;
414
+ if (deltaCacheWrite < 0)
415
+ deltaCacheWrite = 0;
416
+ const breakdownTotal = deltaInput + deltaOutput + deltaCacheRead + deltaCacheWrite;
417
+ if (deltaTotal === 0 && breakdownTotal === 0) {
418
+ deltaTotal = 0;
419
+ }
420
+ else if (breakdownTotal > deltaTotal) {
421
+ deltaTotal = breakdownTotal;
422
+ }
423
+ }
407
424
  if (deltaTotal > 0) {
408
425
  const record = {
409
426
  ts: new Date().toISOString(),
@@ -432,8 +449,11 @@ function syncUsageFromStatuslineInput(config, configPath, type, profileKey, prof
432
449
  endTs: now,
433
450
  cwd: cwd || (prev ? prev.cwd : null),
434
451
  model: resolvedModel,
452
+ profileKey: profileKey || null,
453
+ profileName: profileName || null,
435
454
  };
436
455
  state.sessions = sessions;
456
+ updateUsageStateMetadata(state, usagePath);
437
457
  writeUsageState(statePath, state);
438
458
  }
439
459
  finally {
@@ -588,7 +608,15 @@ function readUsageState(statePath) {
588
608
  }
589
609
  const files = parsed.files && typeof parsed.files === "object" ? parsed.files : {};
590
610
  const sessions = parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
591
- return { version: 1, files, sessions };
611
+ const usageMtimeMs = Number(parsed.usageMtimeMs);
612
+ const usageSize = Number(parsed.usageSize);
613
+ return {
614
+ version: 1,
615
+ files,
616
+ sessions,
617
+ usageMtimeMs: Number.isFinite(usageMtimeMs) ? usageMtimeMs : undefined,
618
+ usageSize: Number.isFinite(usageSize) ? usageSize : undefined,
619
+ };
592
620
  }
593
621
  catch {
594
622
  return { version: 1, files: {}, sessions: {} };
@@ -599,7 +627,93 @@ function writeUsageState(statePath, state) {
599
627
  if (!fs.existsSync(dir)) {
600
628
  fs.mkdirSync(dir, { recursive: true });
601
629
  }
602
- fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
630
+ const payload = `${JSON.stringify(state, null, 2)}\n`;
631
+ const tmpPath = `${statePath}.tmp`;
632
+ try {
633
+ fs.writeFileSync(tmpPath, payload, "utf8");
634
+ fs.renameSync(tmpPath, statePath);
635
+ }
636
+ catch {
637
+ try {
638
+ if (fs.existsSync(tmpPath))
639
+ fs.unlinkSync(tmpPath);
640
+ }
641
+ catch {
642
+ // ignore cleanup failures
643
+ }
644
+ fs.writeFileSync(statePath, payload, "utf8");
645
+ }
646
+ }
647
+ function addSiblingBackupPaths(targets, filePath) {
648
+ if (!filePath)
649
+ return;
650
+ const dir = path.dirname(filePath);
651
+ let entries = [];
652
+ try {
653
+ entries = fs.readdirSync(dir, { withFileTypes: true });
654
+ }
655
+ catch {
656
+ return;
657
+ }
658
+ const base = path.basename(filePath);
659
+ for (const entry of entries) {
660
+ if (!entry.isFile())
661
+ continue;
662
+ if (entry.name === base)
663
+ continue;
664
+ if (entry.name.startsWith(`${base}.`)) {
665
+ targets.add(path.join(dir, entry.name));
666
+ }
667
+ }
668
+ }
669
+ function clearUsageHistory(config, configPath) {
670
+ const targets = new Set();
671
+ const usagePath = getUsagePath(config, configPath);
672
+ if (usagePath) {
673
+ targets.add(usagePath);
674
+ addSiblingBackupPaths(targets, usagePath);
675
+ }
676
+ const statePath = usagePath ? getUsageStatePath(usagePath, config) : null;
677
+ if (statePath) {
678
+ targets.add(statePath);
679
+ targets.add(`${statePath}.lock`);
680
+ addSiblingBackupPaths(targets, statePath);
681
+ }
682
+ const profileLogPath = getProfileLogPath(config, configPath);
683
+ if (profileLogPath) {
684
+ targets.add(profileLogPath);
685
+ addSiblingBackupPaths(targets, profileLogPath);
686
+ }
687
+ const debugPath = (0, debug_1.getStatuslineDebugPath)(configPath);
688
+ if (debugPath) {
689
+ targets.add(debugPath);
690
+ addSiblingBackupPaths(targets, debugPath);
691
+ }
692
+ const removed = [];
693
+ const missing = [];
694
+ const failed = [];
695
+ for (const target of targets) {
696
+ if (!target)
697
+ continue;
698
+ if (!fs.existsSync(target)) {
699
+ missing.push(target);
700
+ continue;
701
+ }
702
+ try {
703
+ const stat = fs.statSync(target);
704
+ if (!stat.isFile())
705
+ continue;
706
+ fs.unlinkSync(target);
707
+ removed.push(target);
708
+ }
709
+ catch (err) {
710
+ failed.push({
711
+ path: target,
712
+ error: err instanceof Error ? err.message : String(err),
713
+ });
714
+ }
715
+ }
716
+ return { removed, missing, failed };
603
717
  }
604
718
  function collectSessionFiles(root) {
605
719
  if (!root || !fs.existsSync(root))
@@ -708,10 +822,12 @@ function parseCodexSessionFile(filePath) {
708
822
  let maxTotal = 0;
709
823
  let maxInput = 0;
710
824
  let maxOutput = 0;
825
+ let maxCachedInput = 0;
711
826
  let hasTotal = false;
712
827
  let sumLast = 0;
713
828
  let sumLastInput = 0;
714
829
  let sumLastOutput = 0;
830
+ let sumLastCachedInput = 0;
715
831
  const tsRange = { start: null, end: null };
716
832
  let cwd = null;
717
833
  let sessionId = null;
@@ -759,12 +875,16 @@ function parseCodexSessionFile(filePath) {
759
875
  maxTotal = totalTokens;
760
876
  const totalInput = Number(totalUsage.input_tokens);
761
877
  const totalOutput = Number(totalUsage.output_tokens);
878
+ const totalCached = Number(totalUsage.cached_input_tokens);
762
879
  if (Number.isFinite(totalInput) && totalInput > maxInput) {
763
880
  maxInput = totalInput;
764
881
  }
765
882
  if (Number.isFinite(totalOutput) && totalOutput > maxOutput) {
766
883
  maxOutput = totalOutput;
767
884
  }
885
+ if (Number.isFinite(totalCached) && totalCached > maxCachedInput) {
886
+ maxCachedInput = totalCached;
887
+ }
768
888
  }
769
889
  else {
770
890
  const lastTokens = Number(lastUsage.total_tokens);
@@ -772,10 +892,13 @@ function parseCodexSessionFile(filePath) {
772
892
  sumLast += lastTokens;
773
893
  const lastInput = Number(lastUsage.input_tokens);
774
894
  const lastOutput = Number(lastUsage.output_tokens);
895
+ const lastCached = Number(lastUsage.cached_input_tokens);
775
896
  if (Number.isFinite(lastInput))
776
897
  sumLastInput += lastInput;
777
898
  if (Number.isFinite(lastOutput))
778
899
  sumLastOutput += lastOutput;
900
+ if (Number.isFinite(lastCached))
901
+ sumLastCachedInput += lastCached;
779
902
  }
780
903
  }
781
904
  catch {
@@ -786,13 +909,18 @@ function parseCodexSessionFile(filePath) {
786
909
  maxTotal = sumLast;
787
910
  maxInput = sumLastInput;
788
911
  maxOutput = sumLastOutput;
912
+ maxCachedInput = sumLastCachedInput;
789
913
  }
914
+ const cacheReadTokens = Math.max(0, maxCachedInput);
915
+ const inputTokens = cacheReadTokens > 0 ? Math.max(0, maxInput - cacheReadTokens) : maxInput;
916
+ const computedTotal = inputTokens + maxOutput + cacheReadTokens;
917
+ const totalTokens = Math.max(maxTotal, computedTotal);
790
918
  return {
791
- inputTokens: maxInput,
919
+ inputTokens,
792
920
  outputTokens: maxOutput,
793
- cacheReadTokens: 0,
921
+ cacheReadTokens,
794
922
  cacheWriteTokens: 0,
795
- totalTokens: maxTotal,
923
+ totalTokens,
796
924
  startTs: tsRange.start,
797
925
  endTs: tsRange.end,
798
926
  cwd,
@@ -955,6 +1083,92 @@ function releaseLock(lockPath, fd) {
955
1083
  // ignore
956
1084
  }
957
1085
  }
1086
+ function readUsageFileStat(usagePath) {
1087
+ if (!usagePath || !fs.existsSync(usagePath))
1088
+ return null;
1089
+ try {
1090
+ return fs.statSync(usagePath);
1091
+ }
1092
+ catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function buildUsageRecordKey(record) {
1097
+ var _a, _b, _c, _d;
1098
+ return JSON.stringify([
1099
+ record.ts,
1100
+ record.type,
1101
+ (_a = record.profileKey) !== null && _a !== void 0 ? _a : null,
1102
+ (_b = record.profileName) !== null && _b !== void 0 ? _b : null,
1103
+ (_c = record.model) !== null && _c !== void 0 ? _c : null,
1104
+ (_d = record.sessionId) !== null && _d !== void 0 ? _d : null,
1105
+ toUsageNumber(record.inputTokens),
1106
+ toUsageNumber(record.outputTokens),
1107
+ toUsageNumber(record.cacheReadTokens),
1108
+ toUsageNumber(record.cacheWriteTokens),
1109
+ toUsageNumber(record.totalTokens),
1110
+ ]);
1111
+ }
1112
+ function buildUsageSessionsFromRecords(records) {
1113
+ const sessions = {};
1114
+ const seen = new Set();
1115
+ for (const record of records) {
1116
+ if (!record.sessionId)
1117
+ continue;
1118
+ const normalizedType = normalizeUsageType(record.type);
1119
+ if (!normalizedType)
1120
+ continue;
1121
+ const recordKey = buildUsageRecordKey(record);
1122
+ if (seen.has(recordKey))
1123
+ continue;
1124
+ seen.add(recordKey);
1125
+ const sessionKey = buildSessionKey(normalizedType, record.sessionId);
1126
+ let entry = sessions[sessionKey];
1127
+ if (!entry) {
1128
+ entry = {
1129
+ type: normalizedType,
1130
+ inputTokens: 0,
1131
+ outputTokens: 0,
1132
+ cacheReadTokens: 0,
1133
+ cacheWriteTokens: 0,
1134
+ totalTokens: 0,
1135
+ startTs: null,
1136
+ endTs: null,
1137
+ cwd: null,
1138
+ model: record.model || null,
1139
+ };
1140
+ sessions[sessionKey] = entry;
1141
+ }
1142
+ entry.inputTokens += toUsageNumber(record.inputTokens);
1143
+ entry.outputTokens += toUsageNumber(record.outputTokens);
1144
+ entry.cacheReadTokens += toUsageNumber(record.cacheReadTokens);
1145
+ entry.cacheWriteTokens += toUsageNumber(record.cacheWriteTokens);
1146
+ entry.totalTokens += toUsageNumber(record.totalTokens);
1147
+ if (record.profileKey)
1148
+ entry.profileKey = record.profileKey;
1149
+ if (record.profileName)
1150
+ entry.profileName = record.profileName;
1151
+ if (!entry.model && record.model)
1152
+ entry.model = record.model;
1153
+ if (record.ts) {
1154
+ const range = { start: entry.startTs, end: entry.endTs };
1155
+ updateMinMaxTs(range, record.ts);
1156
+ entry.startTs = range.start;
1157
+ entry.endTs = range.end;
1158
+ }
1159
+ }
1160
+ return sessions;
1161
+ }
1162
+ function updateUsageStateMetadata(state, usagePath) {
1163
+ const stat = readUsageFileStat(usagePath);
1164
+ if (!stat || !stat.isFile()) {
1165
+ state.usageMtimeMs = undefined;
1166
+ state.usageSize = undefined;
1167
+ return;
1168
+ }
1169
+ state.usageMtimeMs = stat.mtimeMs;
1170
+ state.usageSize = stat.size;
1171
+ }
958
1172
  function appendUsageRecord(usagePath, record) {
959
1173
  const dir = path.dirname(usagePath);
960
1174
  if (!fs.existsSync(dir)) {
@@ -969,6 +1183,7 @@ function readUsageRecords(usagePath) {
969
1183
  const raw = fs.readFileSync(usagePath, "utf8");
970
1184
  const lines = raw.split(/\r?\n/);
971
1185
  const records = [];
1186
+ const seen = new Set();
972
1187
  for (const line of lines) {
973
1188
  const trimmed = line.trim();
974
1189
  if (!trimmed)
@@ -977,19 +1192,29 @@ function readUsageRecords(usagePath) {
977
1192
  const parsed = JSON.parse(trimmed);
978
1193
  if (!parsed || typeof parsed !== "object")
979
1194
  continue;
980
- const input = Number((_a = parsed.inputTokens) !== null && _a !== void 0 ? _a : 0);
981
- const output = Number((_b = parsed.outputTokens) !== null && _b !== void 0 ? _b : 0);
982
- const cacheRead = Number((_e = (_d = (_c = parsed.cacheReadTokens) !== null && _c !== void 0 ? _c : parsed.cache_read_input_tokens) !== null && _d !== void 0 ? _d : parsed.cacheReadInputTokens) !== null && _e !== void 0 ? _e : 0);
983
- const cacheWrite = Number((_j = (_h = (_g = (_f = parsed.cacheWriteTokens) !== null && _f !== void 0 ? _f : parsed.cache_creation_input_tokens) !== null && _g !== void 0 ? _g : parsed.cache_write_input_tokens) !== null && _h !== void 0 ? _h : parsed.cacheWriteInputTokens) !== null && _j !== void 0 ? _j : 0);
1195
+ const type = (0, type_1.normalizeType)(parsed.type) || String((_a = parsed.type) !== null && _a !== void 0 ? _a : "unknown");
1196
+ let input = Number((_b = parsed.inputTokens) !== null && _b !== void 0 ? _b : 0);
1197
+ const output = Number((_c = parsed.outputTokens) !== null && _c !== void 0 ? _c : 0);
1198
+ const cacheRead = Number((_f = (_e = (_d = parsed.cacheReadTokens) !== null && _d !== void 0 ? _d : parsed.cache_read_input_tokens) !== null && _e !== void 0 ? _e : parsed.cacheReadInputTokens) !== null && _f !== void 0 ? _f : 0);
1199
+ const cacheWrite = Number((_k = (_j = (_h = (_g = parsed.cacheWriteTokens) !== null && _g !== void 0 ? _g : parsed.cache_creation_input_tokens) !== null && _h !== void 0 ? _h : parsed.cache_write_input_tokens) !== null && _j !== void 0 ? _j : parsed.cacheWriteInputTokens) !== null && _k !== void 0 ? _k : 0);
1200
+ if (type === "codex" &&
1201
+ Number.isFinite(cacheRead) &&
1202
+ cacheRead > 0 &&
1203
+ Number.isFinite(input) &&
1204
+ Number.isFinite(output)) {
1205
+ const rawTotal = Number(parsed.totalTokens);
1206
+ if (Number.isFinite(rawTotal) && rawTotal <= input + output) {
1207
+ input = Math.max(0, input - cacheRead);
1208
+ }
1209
+ }
984
1210
  const computedTotal = (Number.isFinite(input) ? input : 0) +
985
1211
  (Number.isFinite(output) ? output : 0) +
986
1212
  (Number.isFinite(cacheRead) ? cacheRead : 0) +
987
1213
  (Number.isFinite(cacheWrite) ? cacheWrite : 0);
988
- const total = Number((_k = parsed.totalTokens) !== null && _k !== void 0 ? _k : computedTotal);
1214
+ const total = Number((_l = parsed.totalTokens) !== null && _l !== void 0 ? _l : computedTotal);
989
1215
  const finalTotal = Number.isFinite(total)
990
1216
  ? Math.max(total, computedTotal)
991
1217
  : computedTotal;
992
- const type = (0, type_1.normalizeType)(parsed.type) || String((_l = parsed.type) !== null && _l !== void 0 ? _l : "unknown");
993
1218
  const model = normalizeModelValue(parsed.model) ||
994
1219
  normalizeModelValue(parsed.model_name) ||
995
1220
  normalizeModelValue(parsed.modelName) ||
@@ -1001,7 +1226,7 @@ function readUsageRecords(usagePath) {
1001
1226
  parsed.sessionID ||
1002
1227
  parsed.session ||
1003
1228
  null;
1004
- records.push({
1229
+ const record = {
1005
1230
  ts: String((_m = parsed.ts) !== null && _m !== void 0 ? _m : ""),
1006
1231
  type,
1007
1232
  profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
@@ -1013,7 +1238,12 @@ function readUsageRecords(usagePath) {
1013
1238
  cacheReadTokens: Number.isFinite(cacheRead) ? cacheRead : 0,
1014
1239
  cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : 0,
1015
1240
  totalTokens: Number.isFinite(finalTotal) ? finalTotal : 0,
1016
- });
1241
+ };
1242
+ const key = buildUsageRecordKey(record);
1243
+ if (seen.has(key))
1244
+ continue;
1245
+ seen.add(key);
1246
+ records.push(record);
1017
1247
  }
1018
1248
  catch {
1019
1249
  // ignore invalid lines
@@ -1022,6 +1252,7 @@ function readUsageRecords(usagePath) {
1022
1252
  return records;
1023
1253
  }
1024
1254
  function syncUsageFromSessions(config, configPath, usagePath) {
1255
+ var _a, _b;
1025
1256
  const statePath = getUsageStatePath(usagePath, config);
1026
1257
  const lockPath = `${statePath}.lock`;
1027
1258
  const lockFd = acquireLock(lockPath);
@@ -1032,7 +1263,23 @@ function syncUsageFromSessions(config, configPath, usagePath) {
1032
1263
  const logEntries = readProfileLogEntries([profileLogPath]);
1033
1264
  const state = readUsageState(statePath);
1034
1265
  const files = state.files || {};
1035
- const sessions = state.sessions || {};
1266
+ let sessions = state.sessions || {};
1267
+ const usageStat = readUsageFileStat(usagePath);
1268
+ const hasUsageData = !!usageStat && usageStat.isFile() && usageStat.size > 0;
1269
+ const sessionsEmpty = Object.keys(sessions).length === 0;
1270
+ const hasUsageMeta = Number.isFinite((_a = state.usageMtimeMs) !== null && _a !== void 0 ? _a : Number.NaN) &&
1271
+ Number.isFinite((_b = state.usageSize) !== null && _b !== void 0 ? _b : Number.NaN);
1272
+ const usageOutOfSync = hasUsageData &&
1273
+ (!hasUsageMeta ||
1274
+ state.usageMtimeMs !== usageStat.mtimeMs ||
1275
+ state.usageSize !== usageStat.size);
1276
+ if (hasUsageData && (sessionsEmpty || usageOutOfSync)) {
1277
+ const records = readUsageRecords(usagePath);
1278
+ if (records.length > 0) {
1279
+ sessions = buildUsageSessionsFromRecords(records);
1280
+ }
1281
+ }
1282
+ state.sessions = sessions;
1036
1283
  const codexFiles = collectSessionFiles(getCodexSessionsPath(config));
1037
1284
  const claudeFiles = collectSessionFiles(getClaudeSessionsPath(config));
1038
1285
  const processFile = (filePath, type) => {
@@ -1059,11 +1306,19 @@ function syncUsageFromSessions(config, configPath, usagePath) {
1059
1306
  catch {
1060
1307
  return;
1061
1308
  }
1062
- const resolved = resolveProfileForSession(config, logEntries, type, filePath, stats.sessionId);
1063
- if (!resolved.match)
1064
- return;
1065
1309
  const sessionKey = stats.sessionId ? buildSessionKey(type, stats.sessionId) : null;
1066
1310
  const sessionPrev = sessionKey ? sessions[sessionKey] : null;
1311
+ const sessionProfile = sessionPrev && (sessionPrev.profileKey || sessionPrev.profileName)
1312
+ ? {
1313
+ profileKey: sessionPrev.profileKey || null,
1314
+ profileName: sessionPrev.profileName || null,
1315
+ }
1316
+ : null;
1317
+ const resolved = sessionProfile
1318
+ ? { match: sessionProfile, ambiguous: false }
1319
+ : resolveProfileForSession(config, logEntries, type, filePath, stats.sessionId);
1320
+ if (!resolved.match)
1321
+ return;
1067
1322
  const resolvedModel = (sessionPrev && sessionPrev.model) ||
1068
1323
  stats.model ||
1069
1324
  (prev && prev.model) ||
@@ -1156,6 +1411,8 @@ function syncUsageFromSessions(config, configPath, usagePath) {
1156
1411
  endTs: stats.endTs || (sessionPrev ? sessionPrev.endTs : null),
1157
1412
  cwd: stats.cwd || (sessionPrev ? sessionPrev.cwd : null),
1158
1413
  model: resolvedModel,
1414
+ profileKey: resolved.match.profileKey,
1415
+ profileName: resolved.match.profileName,
1159
1416
  };
1160
1417
  }
1161
1418
  files[filePath] = {
@@ -1179,6 +1436,7 @@ function syncUsageFromSessions(config, configPath, usagePath) {
1179
1436
  processFile(filePath, "claude");
1180
1437
  state.files = files;
1181
1438
  state.sessions = sessions;
1439
+ updateUsageStateMetadata(state, usagePath);
1182
1440
  writeUsageState(statePath, state);
1183
1441
  }
1184
1442
  finally {
package/docs/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Docs Index
2
+
3
+ - [usage.md](usage.md): Usage tracking and statusline input details (Codex/Claude).
@@ -0,0 +1,3 @@
1
+ # 文档索引
2
+
3
+ - [usage_zh.md](usage_zh.md):用量统计逻辑与状态栏输入说明。
package/docs/usage.md ADDED
@@ -0,0 +1,126 @@
1
+ # Usage tracking
2
+
3
+ Usage stats are derived from two sources (statusline input + session logs) and appended to `usage.jsonl`.
4
+
5
+ ## Files and paths
6
+
7
+ - `usage.jsonl`: JSONL records with `ts`, `type`, `profileKey`/`profileName`, `model`, `sessionId`, and token breakdowns.
8
+ - `usage.jsonl.state.json`: per-session totals + per-session-file metadata (mtime/size) used to compute deltas and avoid double counting.
9
+ - `profile-log.jsonl`: profile usage + session binding log (`use` and `session` events).
10
+ - `statusline-debug.jsonl`: optional debug capture when `CODE_ENV_STATUSLINE_DEBUG` is enabled.
11
+ - Paths can be overridden via `usagePath`, `usageStatePath`, `profileLogPath`, `codexSessionsPath`, `claudeSessionsPath`.
12
+
13
+ ## Session binding (profile -> session)
14
+
15
+ - `codenv init` installs a shell wrapper so `codex`/`claude` run via `codenv launch`.
16
+ - `codenv launch` logs profile usage and then finds the latest unbound session file (prefers matching `cwd`, within a small grace window) and records the binding in `profile-log.jsonl`.
17
+ - Sync uses these bindings to attribute session usage to a profile.
18
+
19
+ ## Statusline sync (`codenv statusline --sync-usage`)
20
+
21
+ - Requires `sessionId`, model, and a profile (`profileKey` or `profileName`) to write usage.
22
+ - Reads stdin JSON for totals and computes a delta against the last stored totals in the state file.
23
+ - If totals decrease (session reset), the current totals are treated as fresh usage; otherwise negative sub-deltas are clamped to zero.
24
+ - If the token breakdown exceeds the reported total, the breakdown sum wins.
25
+ - Appends a delta record to `usage.jsonl` and updates the per-session totals in the state file.
26
+
27
+ ## Session log sync (`--sync-usage` and `codenv list`)
28
+
29
+ - Scans Codex sessions under `CODEX_HOME/sessions` (or `~/.codex/sessions`) and Claude sessions under `CLAUDE_HOME/projects` (or `~/.claude/projects`).
30
+ - Codex: parses `event_msg` token_count records and uses max totals; cached input is recorded as cache read and subtracted from input when possible.
31
+ - Claude: sums `message.usage` input/output/cache tokens across the file.
32
+ - Deltas are computed against prior file metadata and per-session maxima in the state file; files without a resolved binding are skipped.
33
+
34
+ ## Daily totals
35
+
36
+ - "Today" is computed in local time between 00:00 and the next 00:00.
37
+
38
+ ## Cost calculation
39
+
40
+ - Uses pricing from the profile (or `pricing.models`, plus defaults) and requires token splits; if splits are missing, cost is omitted.
41
+
42
+ ## Examples
43
+
44
+ ### `usage.jsonl` record
45
+
46
+ Each line is a JSON object (fields may be null/omitted depending on the source):
47
+
48
+ ```json
49
+ {"ts":"2026-01-25T12:34:56.789Z","type":"codex","profileKey":"p_a1b2c3","profileName":"primary","model":"gpt-5.1-codex","sessionId":"a6f9c4d8-1234-5678-9abc-def012345678","inputTokens":1200,"outputTokens":300,"cacheReadTokens":200,"cacheWriteTokens":0,"totalTokens":1700}
50
+ ```
51
+
52
+ `totalTokens` is the max of the reported total and the breakdown sum, so it can be >= input + output + cache.
53
+
54
+ ## Statusline input examples (Codex/Claude)
55
+
56
+ To see real payloads, set `CODE_ENV_STATUSLINE_DEBUG=1` and read the JSONL entries in
57
+ `statusline-debug.jsonl` (or the path from `CODE_ENV_STATUSLINE_DEBUG_PATH`).
58
+
59
+ ### Codex (token_usage totals)
60
+
61
+ ```json
62
+ {
63
+ "type": "codex",
64
+ "session_id": "a6f9c4d8-1234-5678-9abc-def012345678",
65
+ "profile": { "key": "p_a1b2c3", "name": "primary", "type": "codex" },
66
+ "model": "gpt-5.1-codex",
67
+ "token_usage": {
68
+ "total_token_usage": {
69
+ "input_tokens": 1200,
70
+ "output_tokens": 300,
71
+ "cached_input_tokens": 200,
72
+ "cache_creation_input_tokens": 50,
73
+ "total_tokens": 1750
74
+ },
75
+ "last_token_usage": {
76
+ "input_tokens": 100,
77
+ "output_tokens": 20,
78
+ "cached_input_tokens": 10,
79
+ "cache_creation_input_tokens": 0,
80
+ "total_tokens": 130
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Accepted aliases (non-exhaustive):
87
+ - `token_usage.total_token_usage` or `token_usage.totalTokenUsage`
88
+ - `last_token_usage` or `lastTokenUsage`
89
+ - `input_tokens` / `inputTokens` / `input`
90
+ - `output_tokens` / `outputTokens` / `output` / `reasoning_output_tokens`
91
+ - `cached_input_tokens` / `cache_read_input_tokens`
92
+ - `cache_creation_input_tokens` / `cache_write_input_tokens`
93
+ - `total_tokens` / `totalTokens` / `total`
94
+
95
+ `token_usage` can also be a number, or the payload can provide `usage` directly.
96
+
97
+ ### Claude (context_window totals)
98
+
99
+ ```json
100
+ {
101
+ "type": "claude",
102
+ "session_id": "1f2e3d4c-5678-90ab-cdef-1234567890ab",
103
+ "profile": { "key": "p_c3d4e5", "name": "default", "type": "claude" },
104
+ "model": { "display_name": "Claude Sonnet 4.5" },
105
+ "context_window": {
106
+ "total_input_tokens": 800,
107
+ "total_output_tokens": 250,
108
+ "current_usage": {
109
+ "cache_read_input_tokens": 100,
110
+ "cache_creation_input_tokens": 40
111
+ },
112
+ "context_window_size": 200000
113
+ }
114
+ }
115
+ ```
116
+
117
+ Accepted aliases (non-exhaustive):
118
+ - `context_window` or `contextWindow`
119
+ - `current_usage` or `currentUsage`
120
+ - `total_input_tokens` / `totalInputTokens`
121
+ - `total_output_tokens` / `totalOutputTokens`
122
+ - `cache_read_input_tokens` / `cacheReadInputTokens`
123
+ - `cache_creation_input_tokens` / `cacheWriteInputTokens`
124
+
125
+ `usage` can also be provided directly with `todayTokens` / `totalTokens` / `inputTokens` /
126
+ `outputTokens` / `cacheReadTokens` / `cacheWriteTokens`.