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