@praeviso/code-env-switch 0.1.1 → 0.1.3

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 (75) hide show
  1. package/.github/workflows/npm-publish.yml +25 -0
  2. package/AGENTS.md +32 -0
  3. package/PLAN.md +33 -0
  4. package/README.md +24 -0
  5. package/README_zh.md +24 -0
  6. package/bin/cli/args.js +303 -0
  7. package/bin/cli/help.js +77 -0
  8. package/bin/cli/index.js +13 -0
  9. package/bin/commands/add.js +81 -0
  10. package/bin/commands/index.js +21 -0
  11. package/bin/commands/launch.js +330 -0
  12. package/bin/commands/list.js +57 -0
  13. package/bin/commands/show.js +10 -0
  14. package/bin/commands/statusline.js +12 -0
  15. package/bin/commands/unset.js +20 -0
  16. package/bin/commands/use.js +92 -0
  17. package/bin/config/defaults.js +85 -0
  18. package/bin/config/index.js +20 -0
  19. package/bin/config/io.js +72 -0
  20. package/bin/constants.js +27 -0
  21. package/bin/index.js +279 -0
  22. package/bin/profile/display.js +78 -0
  23. package/bin/profile/index.js +26 -0
  24. package/bin/profile/match.js +40 -0
  25. package/bin/profile/resolve.js +79 -0
  26. package/bin/profile/type.js +90 -0
  27. package/bin/shell/detect.js +40 -0
  28. package/bin/shell/index.js +18 -0
  29. package/bin/shell/snippet.js +92 -0
  30. package/bin/shell/utils.js +35 -0
  31. package/bin/statusline/claude.js +153 -0
  32. package/bin/statusline/codex.js +356 -0
  33. package/bin/statusline/index.js +631 -0
  34. package/bin/types.js +5 -0
  35. package/bin/ui/index.js +16 -0
  36. package/bin/ui/interactive.js +189 -0
  37. package/bin/ui/readline.js +76 -0
  38. package/bin/usage/index.js +832 -0
  39. package/code-env.example.json +11 -0
  40. package/package.json +2 -2
  41. package/src/cli/args.ts +318 -0
  42. package/src/cli/help.ts +75 -0
  43. package/src/cli/index.ts +5 -0
  44. package/src/commands/add.ts +91 -0
  45. package/src/commands/index.ts +10 -0
  46. package/src/commands/launch.ts +395 -0
  47. package/src/commands/list.ts +91 -0
  48. package/src/commands/show.ts +12 -0
  49. package/src/commands/statusline.ts +18 -0
  50. package/src/commands/unset.ts +19 -0
  51. package/src/commands/use.ts +121 -0
  52. package/src/config/defaults.ts +88 -0
  53. package/src/config/index.ts +19 -0
  54. package/src/config/io.ts +69 -0
  55. package/src/constants.ts +28 -0
  56. package/src/index.ts +359 -0
  57. package/src/profile/display.ts +77 -0
  58. package/src/profile/index.ts +12 -0
  59. package/src/profile/match.ts +41 -0
  60. package/src/profile/resolve.ts +84 -0
  61. package/src/profile/type.ts +83 -0
  62. package/src/shell/detect.ts +30 -0
  63. package/src/shell/index.ts +6 -0
  64. package/src/shell/snippet.ts +92 -0
  65. package/src/shell/utils.ts +30 -0
  66. package/src/statusline/claude.ts +172 -0
  67. package/src/statusline/codex.ts +393 -0
  68. package/src/statusline/index.ts +920 -0
  69. package/src/types.ts +95 -0
  70. package/src/ui/index.ts +5 -0
  71. package/src/ui/interactive.ts +220 -0
  72. package/src/ui/readline.ts +85 -0
  73. package/src/usage/index.ts +979 -0
  74. package/bin/codenv.js +0 -1316
  75. package/src/codenv.ts +0 -1478
@@ -0,0 +1,979 @@
1
+ /**
2
+ * Usage tracking utilities
3
+ */
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import type { Config, ProfileType } from "../types";
8
+ import { resolvePath } from "../shell/utils";
9
+ import { normalizeType, inferProfileType, getProfileDisplayName } from "../profile/type";
10
+
11
+ interface UsageRecord {
12
+ ts: string;
13
+ type: string;
14
+ profileKey: string | null;
15
+ profileName: string | null;
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ totalTokens: number;
19
+ }
20
+
21
+ interface UsageTotals {
22
+ today: number;
23
+ total: number;
24
+ }
25
+
26
+ interface UsageTotalsIndex {
27
+ byKey: Map<string, UsageTotals>;
28
+ byName: Map<string, UsageTotals>;
29
+ }
30
+
31
+ interface UsageStateEntry {
32
+ mtimeMs: number;
33
+ size: number;
34
+ type: ProfileType;
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ totalTokens: number;
38
+ startTs: string | null;
39
+ endTs: string | null;
40
+ cwd: string | null;
41
+ }
42
+
43
+ interface UsageSessionEntry {
44
+ type: ProfileType;
45
+ inputTokens: number;
46
+ outputTokens: number;
47
+ totalTokens: number;
48
+ startTs: string | null;
49
+ endTs: string | null;
50
+ cwd: string | null;
51
+ }
52
+
53
+ interface UsageStateFile {
54
+ version: number;
55
+ files: Record<string, UsageStateEntry>;
56
+ sessions?: Record<string, UsageSessionEntry>;
57
+ }
58
+
59
+ interface ProfileLogEntry {
60
+ kind: "use" | "session";
61
+ timestamp: string;
62
+ profileKey: string | null;
63
+ profileName: string | null;
64
+ profileType: ProfileType | null;
65
+ configPath: string | null;
66
+ terminalTag: string | null;
67
+ cwd: string | null;
68
+ sessionFile: string | null;
69
+ sessionId: string | null;
70
+ }
71
+
72
+ interface ProfileMatch {
73
+ profileKey: string | null;
74
+ profileName: string | null;
75
+ }
76
+
77
+ interface ProfileResolveResult {
78
+ match: ProfileMatch | null;
79
+ ambiguous: boolean;
80
+ }
81
+
82
+ interface SessionStats {
83
+ inputTokens: number;
84
+ outputTokens: number;
85
+ totalTokens: number;
86
+ startTs: string | null;
87
+ endTs: string | null;
88
+ cwd: string | null;
89
+ sessionId: string | null;
90
+ }
91
+
92
+ interface UsageTotalsInput {
93
+ inputTokens: number | null;
94
+ outputTokens: number | null;
95
+ totalTokens: number | null;
96
+ }
97
+
98
+ function resolveDefaultConfigDir(configPath: string | null): string {
99
+ if (configPath) return path.dirname(configPath);
100
+ return path.join(os.homedir(), ".config", "code-env");
101
+ }
102
+
103
+ export function getUsagePath(config: Config, configPath: string | null): string | null {
104
+ if (config && config.usagePath) return resolvePath(config.usagePath);
105
+ const baseDir = resolveDefaultConfigDir(configPath);
106
+ return path.join(baseDir, "usage.jsonl");
107
+ }
108
+
109
+ export function getUsageStatePath(usagePath: string, config: Config): string {
110
+ if (config && config.usageStatePath) return resolvePath(config.usageStatePath)!;
111
+ return `${usagePath}.state.json`;
112
+ }
113
+
114
+ export function getProfileLogPath(config: Config, configPath: string | null): string {
115
+ if (config && config.profileLogPath) return resolvePath(config.profileLogPath)!;
116
+ const baseDir = resolveDefaultConfigDir(configPath);
117
+ return path.join(baseDir, "profile-log.jsonl");
118
+ }
119
+
120
+ export function getCodexSessionsPath(config: Config): string | null {
121
+ if (config && config.codexSessionsPath) return resolvePath(config.codexSessionsPath);
122
+ if (process.env.CODEX_HOME) {
123
+ return path.join(process.env.CODEX_HOME, "sessions");
124
+ }
125
+ return path.join(os.homedir(), ".codex", "sessions");
126
+ }
127
+
128
+ export function getClaudeSessionsPath(config: Config): string | null {
129
+ if (config && config.claudeSessionsPath) return resolvePath(config.claudeSessionsPath);
130
+ if (process.env.CLAUDE_HOME) {
131
+ return path.join(process.env.CLAUDE_HOME, "projects");
132
+ }
133
+ return path.join(os.homedir(), ".claude", "projects");
134
+ }
135
+
136
+ export function formatTokenCount(value: number | null | undefined): string {
137
+ if (value === null || value === undefined || !Number.isFinite(value)) return "-";
138
+ if (value < 1000) return `${Math.round(value)}`;
139
+ if (value < 1_000_000) return `${(value / 1000).toFixed(2)}K`;
140
+ if (value < 1_000_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
141
+ return `${(value / 1_000_000_000).toFixed(2)}B`;
142
+ }
143
+
144
+ export function buildUsageTotals(records: UsageRecord[]): UsageTotalsIndex {
145
+ const byKey = new Map<string, UsageTotals>();
146
+ const byName = new Map<string, UsageTotals>();
147
+ const todayStart = new Date();
148
+ todayStart.setHours(0, 0, 0, 0);
149
+ const todayStartMs = todayStart.getTime();
150
+ const tomorrowStart = new Date(todayStart);
151
+ tomorrowStart.setDate(todayStart.getDate() + 1);
152
+ const tomorrowStartMs = tomorrowStart.getTime();
153
+
154
+ 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;
159
+ };
160
+
161
+ const addTotals = (map: Map<string, UsageTotals>, key: string, amount: number, ts: string) => {
162
+ if (!key) return;
163
+ const current = map.get(key) || { today: 0, total: 0 };
164
+ current.total += amount;
165
+ if (isToday(ts)) current.today += amount;
166
+ map.set(key, current);
167
+ };
168
+
169
+ for (const record of records) {
170
+ const type = normalizeUsageType(record.type) || "";
171
+ const total = Number(record.totalTokens || 0);
172
+ if (!Number.isFinite(total)) continue;
173
+ if (record.profileKey) {
174
+ addTotals(byKey, `${type}||${record.profileKey}`, total, record.ts);
175
+ }
176
+ if (record.profileName) {
177
+ addTotals(byName, `${type}||${record.profileName}`, total, record.ts);
178
+ }
179
+ }
180
+
181
+ return { byKey, byName };
182
+ }
183
+
184
+ function normalizeUsageType(type: string | null | undefined): string | null {
185
+ if (!type) return null;
186
+ const normalized = normalizeType(type);
187
+ if (normalized) return normalized;
188
+ const trimmed = String(type).trim();
189
+ return trimmed ? trimmed : null;
190
+ }
191
+
192
+ function buildSessionKey(type: ProfileType | null, sessionId: string): string {
193
+ const normalized = normalizeUsageType(type || "");
194
+ return normalized ? `${normalized}::${sessionId}` : sessionId;
195
+ }
196
+
197
+ function toFiniteNumber(value: number | null | undefined): number | null {
198
+ if (value === null || value === undefined) return null;
199
+ const num = Number(value);
200
+ if (!Number.isFinite(num)) return null;
201
+ return num;
202
+ }
203
+
204
+ function buildUsageLookupKey(
205
+ type: string | null | undefined,
206
+ profileId: string | null | undefined
207
+ ): string | null {
208
+ if (!profileId) return null;
209
+ const resolvedType = normalizeUsageType(type);
210
+ if (!resolvedType) return null;
211
+ return `${resolvedType}||${profileId}`;
212
+ }
213
+
214
+ export function readUsageTotalsIndex(
215
+ config: Config,
216
+ configPath: string | null,
217
+ syncUsage: boolean
218
+ ): UsageTotalsIndex | null {
219
+ const usagePath = getUsagePath(config, configPath);
220
+ if (!usagePath) return null;
221
+ if (syncUsage) {
222
+ syncUsageFromSessions(config, configPath, usagePath);
223
+ }
224
+ const records = readUsageRecords(usagePath);
225
+ if (records.length === 0) return null;
226
+ return buildUsageTotals(records);
227
+ }
228
+
229
+ export function resolveUsageTotalsForProfile(
230
+ totals: UsageTotalsIndex,
231
+ type: string | null,
232
+ profileKey: string | null,
233
+ profileName: string | null
234
+ ): UsageTotals | null {
235
+ const keyLookup = buildUsageLookupKey(type, profileKey);
236
+ const nameLookup = buildUsageLookupKey(type, profileName);
237
+ return (
238
+ (keyLookup && totals.byKey.get(keyLookup)) ||
239
+ (nameLookup && totals.byName.get(nameLookup)) ||
240
+ null
241
+ );
242
+ }
243
+
244
+ export function syncUsageFromStatuslineInput(
245
+ config: Config,
246
+ configPath: string | null,
247
+ type: ProfileType | null,
248
+ profileKey: string | null,
249
+ profileName: string | null,
250
+ sessionId: string | null,
251
+ totals: UsageTotalsInput | null,
252
+ cwd: string | null
253
+ ): void {
254
+ if (!sessionId) return;
255
+ if (!totals) return;
256
+ if (!profileKey && !profileName) return;
257
+ const normalizedType = normalizeType(type || "");
258
+ if (!normalizedType) return;
259
+ const usagePath = getUsagePath(config, configPath);
260
+ if (!usagePath) return;
261
+ const inputTokens = toFiniteNumber(totals.inputTokens) ?? 0;
262
+ const outputTokens = toFiniteNumber(totals.outputTokens) ?? 0;
263
+ const totalTokens =
264
+ toFiniteNumber(totals.totalTokens) ?? inputTokens + outputTokens;
265
+ if (!Number.isFinite(totalTokens)) return;
266
+
267
+ const statePath = getUsageStatePath(usagePath, config);
268
+ const lockPath = `${statePath}.lock`;
269
+ const lockFd = acquireLock(lockPath);
270
+ if (lockFd === null) return;
271
+ try {
272
+ const state = readUsageState(statePath);
273
+ const sessions = state.sessions || {};
274
+ const key = buildSessionKey(normalizedType, sessionId);
275
+ 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;
279
+
280
+ let deltaInput = inputTokens - prevInput;
281
+ let deltaOutput = outputTokens - prevOutput;
282
+ let deltaTotal = totalTokens - prevTotal;
283
+ if (deltaTotal < 0 || deltaInput < 0 || deltaOutput < 0) {
284
+ deltaInput = inputTokens;
285
+ deltaOutput = outputTokens;
286
+ deltaTotal = totalTokens;
287
+ }
288
+
289
+ if (deltaTotal > 0) {
290
+ const record: UsageRecord = {
291
+ ts: new Date().toISOString(),
292
+ type: normalizedType,
293
+ profileKey: profileKey || null,
294
+ profileName: profileName || null,
295
+ inputTokens: deltaInput,
296
+ outputTokens: deltaOutput,
297
+ totalTokens: deltaTotal,
298
+ };
299
+ appendUsageRecord(usagePath, record);
300
+ }
301
+
302
+ const now = new Date().toISOString();
303
+ sessions[key] = {
304
+ type: normalizedType,
305
+ inputTokens,
306
+ outputTokens,
307
+ totalTokens,
308
+ startTs: prev ? prev.startTs : now,
309
+ endTs: now,
310
+ cwd: cwd || (prev ? prev.cwd : null),
311
+ };
312
+ state.sessions = sessions;
313
+ writeUsageState(statePath, state);
314
+ } finally {
315
+ releaseLock(lockPath, lockFd);
316
+ }
317
+ }
318
+
319
+ export function logProfileUse(
320
+ config: Config,
321
+ configPath: string | null,
322
+ profileKey: string,
323
+ requestedType: ProfileType | null,
324
+ terminalTag: string | null,
325
+ cwd: string | null
326
+ ): void {
327
+ const profile = config.profiles && config.profiles[profileKey];
328
+ if (!profile) return;
329
+ const inferred = inferProfileType(profileKey, profile, requestedType);
330
+ if (!inferred) return;
331
+ const displayName = getProfileDisplayName(profileKey, profile, requestedType || inferred);
332
+ appendProfileLogEntry(
333
+ config,
334
+ configPath,
335
+ profileKey,
336
+ displayName || profileKey,
337
+ inferred,
338
+ terminalTag,
339
+ cwd,
340
+ "use",
341
+ null,
342
+ null,
343
+ null
344
+ );
345
+ }
346
+
347
+ export function logSessionBinding(
348
+ config: Config,
349
+ configPath: string | null,
350
+ profileType: ProfileType,
351
+ profileKey: string | null,
352
+ profileName: string | null,
353
+ terminalTag: string | null,
354
+ cwd: string | null,
355
+ sessionFile: string | null,
356
+ sessionId: string | null,
357
+ sessionTimestamp: string | null
358
+ ): void {
359
+ if (!profileKey && !profileName) return;
360
+ const key = profileKey ? String(profileKey) : "unknown";
361
+ const name = profileName ? String(profileName) : key;
362
+ appendProfileLogEntry(
363
+ config,
364
+ configPath,
365
+ key,
366
+ name,
367
+ profileType,
368
+ terminalTag,
369
+ cwd,
370
+ "session",
371
+ sessionFile,
372
+ sessionId,
373
+ sessionTimestamp
374
+ );
375
+ }
376
+
377
+ function appendProfileLogEntry(
378
+ config: Config,
379
+ configPath: string | null,
380
+ profileKey: string,
381
+ profileName: string,
382
+ profileType: ProfileType,
383
+ terminalTag: string | null,
384
+ cwd: string | null,
385
+ kind: "use" | "session",
386
+ sessionFile: string | null,
387
+ sessionId: string | null,
388
+ timestamp: string | null
389
+ ) {
390
+ const logPath = getProfileLogPath(config, configPath);
391
+ const dir = path.dirname(logPath);
392
+ if (!fs.existsSync(dir)) {
393
+ fs.mkdirSync(dir, { recursive: true });
394
+ }
395
+ const record = {
396
+ timestamp: timestamp || new Date().toISOString(),
397
+ kind,
398
+ profileKey,
399
+ profileName,
400
+ profileType,
401
+ configPath: configPath || null,
402
+ terminalTag: terminalTag || null,
403
+ cwd: cwd || null,
404
+ sessionFile: sessionFile || null,
405
+ sessionId: sessionId || null,
406
+ };
407
+ fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8");
408
+ }
409
+
410
+ function readProfileLogEntries(paths: string[]): ProfileLogEntry[] {
411
+ const entries: ProfileLogEntry[] = [];
412
+ for (const logPath of paths) {
413
+ if (!logPath || !fs.existsSync(logPath)) continue;
414
+ const raw = fs.readFileSync(logPath, "utf8");
415
+ const lines = raw.split(/\r?\n/);
416
+ for (const line of lines) {
417
+ const trimmed = line.trim();
418
+ if (!trimmed) continue;
419
+ try {
420
+ const parsed = JSON.parse(trimmed);
421
+ if (!parsed || typeof parsed !== "object") continue;
422
+ const rawKind = parsed.kind ? String(parsed.kind).toLowerCase() : "";
423
+ const kind = rawKind === "session" ? "session" : "use";
424
+ entries.push({
425
+ kind,
426
+ timestamp: String(parsed.timestamp ?? ""),
427
+ profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
428
+ profileName: parsed.profileName ? String(parsed.profileName) : null,
429
+ profileType: normalizeType(parsed.profileType) || null,
430
+ configPath: parsed.configPath ? String(parsed.configPath) : null,
431
+ terminalTag: parsed.terminalTag ? String(parsed.terminalTag) : null,
432
+ cwd: parsed.cwd ? String(parsed.cwd) : null,
433
+ sessionFile: parsed.sessionFile ? String(parsed.sessionFile) : null,
434
+ sessionId: parsed.sessionId ? String(parsed.sessionId) : null,
435
+ });
436
+ } catch {
437
+ // ignore invalid lines
438
+ }
439
+ }
440
+ }
441
+ return entries;
442
+ }
443
+
444
+ export function readSessionBindingIndex(
445
+ config: Config,
446
+ configPath: string | null
447
+ ): { byFile: Set<string>; byId: Set<string> } {
448
+ const profileLogPath = getProfileLogPath(config, configPath);
449
+ const entries = readProfileLogEntries([profileLogPath]);
450
+ const byFile = new Set<string>();
451
+ const byId = new Set<string>();
452
+ for (const entry of entries) {
453
+ if (entry.kind !== "session") continue;
454
+ if (entry.sessionFile) byFile.add(entry.sessionFile);
455
+ if (entry.sessionId) byId.add(entry.sessionId);
456
+ }
457
+ return { byFile, byId };
458
+ }
459
+
460
+ function normalizeProfileMatch(
461
+ config: Config,
462
+ entry: ProfileLogEntry,
463
+ type: ProfileType
464
+ ): ProfileMatch {
465
+ const profileKey = entry.profileKey;
466
+ let profileName = entry.profileName;
467
+ if (profileKey && config.profiles && config.profiles[profileKey]) {
468
+ profileName = getProfileDisplayName(
469
+ profileKey,
470
+ config.profiles[profileKey],
471
+ type
472
+ );
473
+ }
474
+ if (!profileName && profileKey) profileName = profileKey;
475
+ return { profileKey, profileName };
476
+ }
477
+
478
+ function resolveUniqueProfileMatch(
479
+ config: Config,
480
+ entries: ProfileLogEntry[],
481
+ type: ProfileType
482
+ ): ProfileResolveResult {
483
+ const uniqueProfiles = new Map<string, ProfileLogEntry>();
484
+ for (const entry of entries) {
485
+ const id = entry.profileKey || entry.profileName || "";
486
+ if (!id) continue;
487
+ if (!uniqueProfiles.has(id)) uniqueProfiles.set(id, entry);
488
+ }
489
+ if (uniqueProfiles.size === 0) return { match: null, ambiguous: false };
490
+ if (uniqueProfiles.size !== 1) return { match: null, ambiguous: true };
491
+ const best = Array.from(uniqueProfiles.values())[0];
492
+ return { match: normalizeProfileMatch(config, best, type), ambiguous: false };
493
+ }
494
+
495
+ function resolveProfileForSession(
496
+ config: Config,
497
+ logEntries: ProfileLogEntry[],
498
+ type: ProfileType,
499
+ sessionFile: string | null,
500
+ sessionId: string | null
501
+ ): ProfileResolveResult {
502
+ const sessionEntries = logEntries.filter(
503
+ (entry) => entry.kind === "session" && entry.profileType === type
504
+ );
505
+
506
+ if (sessionFile) {
507
+ const matches = sessionEntries.filter(
508
+ (entry) => entry.sessionFile && entry.sessionFile === sessionFile
509
+ );
510
+ if (matches.length > 0) {
511
+ const resolved = resolveUniqueProfileMatch(config, matches, type);
512
+ if (resolved.match || resolved.ambiguous) return resolved;
513
+ }
514
+ }
515
+
516
+ if (sessionId) {
517
+ const matches = sessionEntries.filter(
518
+ (entry) => entry.sessionId && entry.sessionId === sessionId
519
+ );
520
+ if (matches.length > 0) {
521
+ const resolved = resolveUniqueProfileMatch(config, matches, type);
522
+ if (resolved.match || resolved.ambiguous) return resolved;
523
+ }
524
+ }
525
+
526
+ return { match: null, ambiguous: false };
527
+ }
528
+
529
+ function readUsageState(statePath: string): UsageStateFile {
530
+ if (!statePath || !fs.existsSync(statePath)) {
531
+ return { version: 1, files: {}, sessions: {} };
532
+ }
533
+ try {
534
+ const raw = fs.readFileSync(statePath, "utf8");
535
+ const parsed = JSON.parse(raw);
536
+ if (!parsed || typeof parsed !== "object") {
537
+ return { version: 1, files: {}, sessions: {} };
538
+ }
539
+ const files =
540
+ parsed.files && typeof parsed.files === "object" ? parsed.files : {};
541
+ const sessions =
542
+ parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
543
+ return { version: 1, files, sessions };
544
+ } catch {
545
+ return { version: 1, files: {}, sessions: {} };
546
+ }
547
+ }
548
+
549
+ function writeUsageState(statePath: string, state: UsageStateFile) {
550
+ const dir = path.dirname(statePath);
551
+ if (!fs.existsSync(dir)) {
552
+ fs.mkdirSync(dir, { recursive: true });
553
+ }
554
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
555
+ }
556
+
557
+ function collectSessionFiles(root: string | null): string[] {
558
+ if (!root || !fs.existsSync(root)) return [];
559
+ const files: string[] = [];
560
+ const stack = [root];
561
+ while (stack.length > 0) {
562
+ const current = stack.pop();
563
+ if (!current) continue;
564
+ let entries: fs.Dirent[] = [];
565
+ try {
566
+ entries = fs.readdirSync(current, { withFileTypes: true });
567
+ } catch {
568
+ continue;
569
+ }
570
+ for (const entry of entries) {
571
+ if (entry.name.startsWith(".")) continue;
572
+ const full = path.join(current, entry.name);
573
+ if (entry.isDirectory()) {
574
+ stack.push(full);
575
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
576
+ files.push(full);
577
+ }
578
+ }
579
+ }
580
+ return files;
581
+ }
582
+
583
+ function updateMinMaxTs(
584
+ current: { start: string | null; end: string | null },
585
+ ts: string
586
+ ) {
587
+ if (!ts) return;
588
+ const time = new Date(ts).getTime();
589
+ if (Number.isNaN(time)) return;
590
+ if (!current.start || new Date(current.start).getTime() > time) {
591
+ current.start = ts;
592
+ }
593
+ if (!current.end || new Date(current.end).getTime() < time) {
594
+ current.end = ts;
595
+ }
596
+ }
597
+
598
+ function parseCodexSessionFile(filePath: string): SessionStats {
599
+ const raw = fs.readFileSync(filePath, "utf8");
600
+ const lines = raw.split(/\r?\n/);
601
+ let maxTotal = 0;
602
+ let maxInput = 0;
603
+ let maxOutput = 0;
604
+ let hasTotal = false;
605
+ let sumLast = 0;
606
+ let sumLastInput = 0;
607
+ let sumLastOutput = 0;
608
+ const tsRange = { start: null as string | null, end: null as string | null };
609
+ let cwd: string | null = null;
610
+ let sessionId: string | null = null;
611
+
612
+ for (const line of lines) {
613
+ const trimmed = line.trim();
614
+ if (!trimmed) continue;
615
+ try {
616
+ const parsed = JSON.parse(trimmed);
617
+ if (!parsed || typeof parsed !== "object") continue;
618
+ if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
619
+ if (!cwd && parsed.type === "session_meta") {
620
+ const payload = parsed.payload || {};
621
+ if (payload && payload.cwd) cwd = String(payload.cwd);
622
+ if (!sessionId && payload && payload.id) {
623
+ sessionId = String(payload.id);
624
+ }
625
+ }
626
+ if (!cwd && parsed.type === "turn_context") {
627
+ const payload = parsed.payload || {};
628
+ if (payload && payload.cwd) cwd = String(payload.cwd);
629
+ }
630
+ if (parsed.type !== "event_msg") continue;
631
+ const payload = parsed.payload;
632
+ if (!payload || payload.type !== "token_count") continue;
633
+ const info = payload.info || {};
634
+ const totalUsage = info.total_token_usage || {};
635
+ const lastUsage = info.last_token_usage || {};
636
+ const totalTokens = Number(totalUsage.total_tokens);
637
+ if (Number.isFinite(totalTokens)) {
638
+ hasTotal = true;
639
+ if (totalTokens > maxTotal) maxTotal = totalTokens;
640
+ const totalInput = Number(totalUsage.input_tokens);
641
+ const totalOutput = Number(totalUsage.output_tokens);
642
+ if (Number.isFinite(totalInput) && totalInput > maxInput) {
643
+ maxInput = totalInput;
644
+ }
645
+ if (Number.isFinite(totalOutput) && totalOutput > maxOutput) {
646
+ maxOutput = totalOutput;
647
+ }
648
+ } else {
649
+ const lastTokens = Number(lastUsage.total_tokens);
650
+ if (Number.isFinite(lastTokens)) sumLast += lastTokens;
651
+ const lastInput = Number(lastUsage.input_tokens);
652
+ const lastOutput = Number(lastUsage.output_tokens);
653
+ if (Number.isFinite(lastInput)) sumLastInput += lastInput;
654
+ if (Number.isFinite(lastOutput)) sumLastOutput += lastOutput;
655
+ }
656
+ } catch {
657
+ // ignore invalid lines
658
+ }
659
+ }
660
+
661
+ if (!hasTotal) {
662
+ maxTotal = sumLast;
663
+ maxInput = sumLastInput;
664
+ maxOutput = sumLastOutput;
665
+ }
666
+
667
+ return {
668
+ inputTokens: maxInput,
669
+ outputTokens: maxOutput,
670
+ totalTokens: maxTotal,
671
+ startTs: tsRange.start,
672
+ endTs: tsRange.end,
673
+ cwd,
674
+ sessionId,
675
+ };
676
+ }
677
+
678
+ function parseClaudeSessionFile(filePath: string): SessionStats {
679
+ const raw = fs.readFileSync(filePath, "utf8");
680
+ const lines = raw.split(/\r?\n/);
681
+ let totalTokens = 0;
682
+ let inputTokens = 0;
683
+ let outputTokens = 0;
684
+ const tsRange = { start: null as string | null, end: null as string | null };
685
+ let cwd: string | null = null;
686
+ let sessionId: string | null = null;
687
+
688
+ for (const line of lines) {
689
+ const trimmed = line.trim();
690
+ if (!trimmed) continue;
691
+ try {
692
+ const parsed = JSON.parse(trimmed);
693
+ if (!parsed || typeof parsed !== "object") continue;
694
+ if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
695
+ if (!cwd && parsed.cwd) cwd = String(parsed.cwd);
696
+ if (!sessionId && parsed.sessionId) {
697
+ sessionId = String(parsed.sessionId);
698
+ }
699
+ const message = parsed.message;
700
+ const usage = message && message.usage ? message.usage : null;
701
+ if (!usage) continue;
702
+ const input = Number(usage.input_tokens ?? 0);
703
+ const output = Number(usage.output_tokens ?? 0);
704
+ const cacheCreate = Number(usage.cache_creation_input_tokens ?? 0);
705
+ const cacheRead = Number(usage.cache_read_input_tokens ?? 0);
706
+ if (Number.isFinite(input)) inputTokens += input;
707
+ if (Number.isFinite(output)) outputTokens += output;
708
+ totalTokens +=
709
+ (Number.isFinite(input) ? input : 0) +
710
+ (Number.isFinite(output) ? output : 0) +
711
+ (Number.isFinite(cacheCreate) ? cacheCreate : 0) +
712
+ (Number.isFinite(cacheRead) ? cacheRead : 0);
713
+ } catch {
714
+ // ignore invalid lines
715
+ }
716
+ }
717
+
718
+ return {
719
+ inputTokens,
720
+ outputTokens,
721
+ totalTokens,
722
+ startTs: tsRange.start,
723
+ endTs: tsRange.end,
724
+ cwd,
725
+ sessionId,
726
+ };
727
+ }
728
+
729
+ const LOCK_STALE_MS = 10 * 60 * 1000;
730
+
731
+ function isProcessAlive(pid: number): boolean {
732
+ try {
733
+ process.kill(pid, 0);
734
+ return true;
735
+ } catch (err) {
736
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
737
+ return code === "EPERM";
738
+ }
739
+ }
740
+
741
+ function readLockInfo(lockPath: string): { pid: number | null; timestampMs: number | null } {
742
+ try {
743
+ const raw = fs.readFileSync(lockPath, "utf8");
744
+ const lines = raw.split(/\r?\n/);
745
+ const pid = Number(lines[0] || "");
746
+ const ts = lines[1] ? new Date(lines[1]).getTime() : Number.NaN;
747
+ return {
748
+ pid: Number.isFinite(pid) && pid > 0 ? pid : null,
749
+ timestampMs: Number.isFinite(ts) ? ts : null,
750
+ };
751
+ } catch {
752
+ return { pid: null, timestampMs: null };
753
+ }
754
+ }
755
+
756
+ function isLockStale(lockPath: string): boolean {
757
+ const info = readLockInfo(lockPath);
758
+ if (info.pid !== null) {
759
+ return !isProcessAlive(info.pid);
760
+ }
761
+ if (info.timestampMs !== null) {
762
+ return Date.now() - info.timestampMs > LOCK_STALE_MS;
763
+ }
764
+ return true;
765
+ }
766
+
767
+ function acquireLock(lockPath: string) {
768
+ const dir = path.dirname(lockPath);
769
+ if (!fs.existsSync(dir)) {
770
+ fs.mkdirSync(dir, { recursive: true });
771
+ }
772
+ const attemptAcquire = () => {
773
+ try {
774
+ const fd = fs.openSync(lockPath, "wx");
775
+ fs.writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`, "utf8");
776
+ return fd;
777
+ } catch (err) {
778
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
779
+ if (code !== "EEXIST") return null;
780
+ }
781
+ return null;
782
+ };
783
+
784
+ const fd = attemptAcquire();
785
+ if (fd !== null) return fd;
786
+ if (!isLockStale(lockPath)) return null;
787
+ try {
788
+ fs.unlinkSync(lockPath);
789
+ } catch {
790
+ return null;
791
+ }
792
+ return attemptAcquire();
793
+ }
794
+
795
+ function releaseLock(lockPath: string, fd: number | null) {
796
+ if (fd === null) return;
797
+ try {
798
+ fs.closeSync(fd);
799
+ } catch {
800
+ // ignore
801
+ }
802
+ try {
803
+ fs.unlinkSync(lockPath);
804
+ } catch {
805
+ // ignore
806
+ }
807
+ }
808
+
809
+ function appendUsageRecord(usagePath: string, record: UsageRecord) {
810
+ const dir = path.dirname(usagePath);
811
+ if (!fs.existsSync(dir)) {
812
+ fs.mkdirSync(dir, { recursive: true });
813
+ }
814
+ fs.appendFileSync(usagePath, `${JSON.stringify(record)}\n`, "utf8");
815
+ }
816
+
817
+ export function readUsageRecords(usagePath: string): UsageRecord[] {
818
+ if (!usagePath || !fs.existsSync(usagePath)) return [];
819
+ const raw = fs.readFileSync(usagePath, "utf8");
820
+ const lines = raw.split(/\r?\n/);
821
+ const records: UsageRecord[] = [];
822
+ for (const line of lines) {
823
+ const trimmed = line.trim();
824
+ if (!trimmed) continue;
825
+ try {
826
+ const parsed = JSON.parse(trimmed);
827
+ 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
+ const type = normalizeType(parsed.type) || String(parsed.type ?? "unknown");
832
+ records.push({
833
+ ts: String(parsed.ts ?? ""),
834
+ type,
835
+ profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
836
+ profileName: parsed.profileName ? String(parsed.profileName) : null,
837
+ inputTokens: Number.isFinite(input) ? input : 0,
838
+ outputTokens: Number.isFinite(output) ? output : 0,
839
+ totalTokens: Number.isFinite(total) ? total : 0,
840
+ });
841
+ } catch {
842
+ // ignore invalid lines
843
+ }
844
+ }
845
+ return records;
846
+ }
847
+
848
+ export function syncUsageFromSessions(
849
+ config: Config,
850
+ configPath: string | null,
851
+ usagePath: string
852
+ ) {
853
+ const statePath = getUsageStatePath(usagePath, config);
854
+ const lockPath = `${statePath}.lock`;
855
+ const lockFd = acquireLock(lockPath);
856
+ if (lockFd === null) return;
857
+ try {
858
+ const profileLogPath = getProfileLogPath(config, configPath);
859
+ const logEntries = readProfileLogEntries([profileLogPath]);
860
+
861
+ const state = readUsageState(statePath);
862
+ const files = state.files || {};
863
+ const sessions = state.sessions || {};
864
+ const codexFiles = collectSessionFiles(getCodexSessionsPath(config));
865
+ const claudeFiles = collectSessionFiles(getClaudeSessionsPath(config));
866
+
867
+ const processFile = (filePath: string, type: ProfileType) => {
868
+ let stat: fs.Stats | null = null;
869
+ try {
870
+ stat = fs.statSync(filePath);
871
+ } catch {
872
+ return;
873
+ }
874
+ if (!stat || !stat.isFile()) return;
875
+ const prev = files[filePath];
876
+ if (prev && prev.mtimeMs === stat.mtimeMs && prev.size === stat.size) {
877
+ return;
878
+ }
879
+ let stats: SessionStats;
880
+ try {
881
+ stats =
882
+ type === "codex"
883
+ ? parseCodexSessionFile(filePath)
884
+ : parseClaudeSessionFile(filePath);
885
+ } catch {
886
+ return;
887
+ }
888
+ const resolved = resolveProfileForSession(
889
+ config,
890
+ logEntries,
891
+ type,
892
+ filePath,
893
+ stats.sessionId
894
+ );
895
+ if (!resolved.match) return;
896
+ const sessionKey =
897
+ stats.sessionId ? buildSessionKey(type, stats.sessionId) : null;
898
+ 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;
902
+ const prevInputMax = sessionPrev
903
+ ? Math.max(prevInput, sessionPrev.inputTokens)
904
+ : prevInput;
905
+ const prevOutputMax = sessionPrev
906
+ ? Math.max(prevOutput, sessionPrev.outputTokens)
907
+ : prevOutput;
908
+ const prevTotalMax = sessionPrev
909
+ ? Math.max(prevTotal, sessionPrev.totalTokens)
910
+ : prevTotal;
911
+ let deltaInput = stats.inputTokens - prevInputMax;
912
+ let deltaOutput = stats.outputTokens - prevOutputMax;
913
+ let deltaTotal = stats.totalTokens - prevTotalMax;
914
+ if (deltaTotal < 0 || deltaInput < 0 || deltaOutput < 0) {
915
+ if (sessionPrev) {
916
+ deltaInput = 0;
917
+ deltaOutput = 0;
918
+ deltaTotal = 0;
919
+ } else {
920
+ deltaInput = stats.inputTokens;
921
+ deltaOutput = stats.outputTokens;
922
+ deltaTotal = stats.totalTokens;
923
+ }
924
+ }
925
+ if (deltaTotal > 0) {
926
+ const record: UsageRecord = {
927
+ ts: stats.endTs || stats.startTs || new Date().toISOString(),
928
+ type,
929
+ profileKey: resolved.match.profileKey,
930
+ profileName: resolved.match.profileName,
931
+ inputTokens: deltaInput,
932
+ outputTokens: deltaOutput,
933
+ totalTokens: deltaTotal,
934
+ };
935
+ appendUsageRecord(usagePath, record);
936
+ }
937
+ if (sessionKey) {
938
+ const nextInput = sessionPrev
939
+ ? Math.max(sessionPrev.inputTokens, stats.inputTokens)
940
+ : stats.inputTokens;
941
+ const nextOutput = sessionPrev
942
+ ? Math.max(sessionPrev.outputTokens, stats.outputTokens)
943
+ : stats.outputTokens;
944
+ const nextTotal = sessionPrev
945
+ ? Math.max(sessionPrev.totalTokens, stats.totalTokens)
946
+ : stats.totalTokens;
947
+ sessions[sessionKey] = {
948
+ type,
949
+ inputTokens: nextInput,
950
+ outputTokens: nextOutput,
951
+ totalTokens: nextTotal,
952
+ startTs: sessionPrev ? sessionPrev.startTs : stats.startTs,
953
+ endTs: stats.endTs || (sessionPrev ? sessionPrev.endTs : null),
954
+ cwd: stats.cwd || (sessionPrev ? sessionPrev.cwd : null),
955
+ };
956
+ }
957
+ files[filePath] = {
958
+ mtimeMs: stat.mtimeMs,
959
+ size: stat.size,
960
+ type,
961
+ inputTokens: stats.inputTokens,
962
+ outputTokens: stats.outputTokens,
963
+ totalTokens: stats.totalTokens,
964
+ startTs: stats.startTs,
965
+ endTs: stats.endTs,
966
+ cwd: stats.cwd,
967
+ };
968
+ };
969
+
970
+ for (const filePath of codexFiles) processFile(filePath, "codex");
971
+ for (const filePath of claudeFiles) processFile(filePath, "claude");
972
+
973
+ state.files = files;
974
+ state.sessions = sessions;
975
+ writeUsageState(statePath, state);
976
+ } finally {
977
+ releaseLock(lockPath, lockFd);
978
+ }
979
+ }