@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.
- package/.github/workflows/npm-publish.yml +25 -0
- package/AGENTS.md +32 -0
- package/PLAN.md +33 -0
- package/README.md +24 -0
- package/README_zh.md +24 -0
- package/bin/cli/args.js +303 -0
- package/bin/cli/help.js +77 -0
- package/bin/cli/index.js +13 -0
- package/bin/commands/add.js +81 -0
- package/bin/commands/index.js +21 -0
- package/bin/commands/launch.js +330 -0
- package/bin/commands/list.js +57 -0
- package/bin/commands/show.js +10 -0
- package/bin/commands/statusline.js +12 -0
- package/bin/commands/unset.js +20 -0
- package/bin/commands/use.js +92 -0
- package/bin/config/defaults.js +85 -0
- package/bin/config/index.js +20 -0
- package/bin/config/io.js +72 -0
- package/bin/constants.js +27 -0
- package/bin/index.js +279 -0
- package/bin/profile/display.js +78 -0
- package/bin/profile/index.js +26 -0
- package/bin/profile/match.js +40 -0
- package/bin/profile/resolve.js +79 -0
- package/bin/profile/type.js +90 -0
- package/bin/shell/detect.js +40 -0
- package/bin/shell/index.js +18 -0
- package/bin/shell/snippet.js +92 -0
- package/bin/shell/utils.js +35 -0
- package/bin/statusline/claude.js +153 -0
- package/bin/statusline/codex.js +356 -0
- package/bin/statusline/index.js +631 -0
- package/bin/types.js +5 -0
- package/bin/ui/index.js +16 -0
- package/bin/ui/interactive.js +189 -0
- package/bin/ui/readline.js +76 -0
- package/bin/usage/index.js +832 -0
- package/code-env.example.json +11 -0
- package/package.json +2 -2
- package/src/cli/args.ts +318 -0
- package/src/cli/help.ts +75 -0
- package/src/cli/index.ts +5 -0
- package/src/commands/add.ts +91 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/launch.ts +395 -0
- package/src/commands/list.ts +91 -0
- package/src/commands/show.ts +12 -0
- package/src/commands/statusline.ts +18 -0
- package/src/commands/unset.ts +19 -0
- package/src/commands/use.ts +121 -0
- package/src/config/defaults.ts +88 -0
- package/src/config/index.ts +19 -0
- package/src/config/io.ts +69 -0
- package/src/constants.ts +28 -0
- package/src/index.ts +359 -0
- package/src/profile/display.ts +77 -0
- package/src/profile/index.ts +12 -0
- package/src/profile/match.ts +41 -0
- package/src/profile/resolve.ts +84 -0
- package/src/profile/type.ts +83 -0
- package/src/shell/detect.ts +30 -0
- package/src/shell/index.ts +6 -0
- package/src/shell/snippet.ts +92 -0
- package/src/shell/utils.ts +30 -0
- package/src/statusline/claude.ts +172 -0
- package/src/statusline/codex.ts +393 -0
- package/src/statusline/index.ts +920 -0
- package/src/types.ts +95 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/interactive.ts +220 -0
- package/src/ui/readline.ts +85 -0
- package/src/usage/index.ts +979 -0
- package/bin/codenv.js +0 -1316
- 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
|
+
}
|