letmecode 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -8,6 +8,8 @@ npx -y letmecode -- -h
8
8
  npx -y letmecode -- --log-to log.txt
9
9
  ```
10
10
 
11
+ `--log-to` now records Claude binary discovery, session-root selection, parsed transcript file summaries, entrypoint matching, raw `/usage` output, and live-window event matching so zero-token windows are diagnosable.
12
+
11
13
  <img width="2308" height="1491" alt="image" src="https://github.com/user-attachments/assets/f3f52d79-00e3-4ff5-bf2f-65f8be632aaa" />
12
14
 
13
15
  ## Controls
@@ -62,8 +62,9 @@ export function buildHelpText() {
62
62
  "",
63
63
  "Trace logging:",
64
64
  " --log-to PATH writes Claude CLI SDK and Claude VSCode detection details,",
65
+ " session root selection, parsed session file summaries, entrypoint matching,",
65
66
  " every candidate binary path check, the final found/not-found result,",
66
- " and the raw /usage command output."
67
+ " and the raw /usage command output plus live window matching details."
67
68
  ].join("\n");
68
69
  }
69
70
  export function createFileTraceLogger(logPath) {
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import https from "node:https";
2
3
  import { createRequire } from "node:module";
3
4
  import fs from "node:fs";
4
5
  import os from "node:os";
@@ -66,6 +67,21 @@ const RATE_CARD = {
66
67
  const UNPRICED_MODELS = new Set([
67
68
  "gpt-oss-120b"
68
69
  ]);
70
+ const ANTIGRAVITY_PRIMARY_WINDOW_MINUTES = 5 * 60;
71
+ const ANTIGRAVITY_WEEKLY_WINDOW_MINUTES = 7 * 24 * 60;
72
+ const ANTIGRAVITY_QUOTA_SUMMARY_PATH = "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
73
+ const ANTIGRAVITY_DEBUG_LOG_PATH = process.env.LETMECODE_ANTIGRAVITY_DEBUG_LOG ??
74
+ path.join(os.tmpdir(), "letmecode-antigravity-debug.jsonl");
75
+ const GEMINI_QUOTA_MODELS = [
76
+ "gemini-3.5-flash",
77
+ "gemini-3.1-pro",
78
+ "gemini-3-flash"
79
+ ];
80
+ const THIRD_PARTY_QUOTA_MODELS = [
81
+ "claude-opus-4-6",
82
+ "claude-sonnet-4-6",
83
+ "gpt-oss-120b"
84
+ ];
69
85
  const MODEL_ALIASES = {
70
86
  "gemini-3-flash-a": "gemini-3-flash",
71
87
  "gemini-3-flash-preview": "gemini-3-flash",
@@ -80,15 +96,33 @@ export class AntigravityUsageProvider extends UsageProviderBase {
80
96
  this.collectUsage =
81
97
  options.collectUsage ??
82
98
  collectAntigravityUsageFromTokscale;
99
+ this.collectQuota =
100
+ options.collectQuota ??
101
+ collectAntigravityQuotaFromLocalRpc;
83
102
  }
84
103
  async getStats(_options = {}) {
85
104
  const warnings = [];
86
- let records = [];
87
- try {
88
- records = await this.collectUsage();
105
+ const [usageResult, quotaResult] = await Promise.allSettled([
106
+ this.collectUsage(),
107
+ this.collectQuota()
108
+ ]);
109
+ const records = usageResult.status === "fulfilled"
110
+ ? usageResult.value
111
+ : [];
112
+ const quotaSnapshot = quotaResult.status === "fulfilled"
113
+ ? quotaResult.value
114
+ : null;
115
+ if (usageResult.status === "rejected") {
116
+ warnings.push("Could not synchronize Antigravity token usage through Tokscale.");
117
+ }
118
+ if (quotaResult.status === "rejected") {
119
+ warnings.push("Live Antigravity quota is unavailable. Ensure the Antigravity IDE is running.");
89
120
  }
90
- catch {
91
- warnings.push("Open Antigravity IDE before running LetMeCode so token usage can be synchronized.");
121
+ else if (quotaResult.value.entries.length === 0) {
122
+ warnings.push("Antigravity local quota RPC responded, but no recognized model quota windows were found.");
123
+ }
124
+ if (isAntigravityDebugEnabled()) {
125
+ warnings.push(`Antigravity debug log: ${ANTIGRAVITY_DEBUG_LOG_PATH}`);
92
126
  }
93
127
  const selectedRecords = deduplicateRecords(records);
94
128
  const duplicateEvents = records.length - selectedRecords.length;
@@ -116,6 +150,7 @@ export class AntigravityUsageProvider extends UsageProviderBase {
116
150
  if (unknownPricedModels.length > 0) {
117
151
  warnings.push(`No Antigravity estimated API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
118
152
  }
153
+ const limitWindows = quotaSnapshot?.entries.map((quota) => buildAntigravityLimitWindow(quota, selectedRecords, quotaSnapshot.fetchedAt)) ?? [];
119
154
  return {
120
155
  providerId: this.id,
121
156
  providerLabel: this.label,
@@ -125,14 +160,16 @@ export class AntigravityUsageProvider extends UsageProviderBase {
125
160
  tokenEvents: selectedRecords.length,
126
161
  totals: sumUsageTotals(modelUsage.map((row) => row.totals)),
127
162
  distinctModels: modelUsage.map((row) => row.modelId),
128
- distinctPlanTypes: [],
129
- rootLabel: "Antigravity IDE sync",
130
- rootPath: "antigravity-ide-rpc"
163
+ distinctPlanTypes: [
164
+ ...new Set(limitWindows.map((window) => window.planType))
165
+ ],
166
+ rootLabel: "Tokscale usage + Antigravity local quota",
167
+ rootPath: getAntigravityCacheRoot()
131
168
  },
132
169
  modelUsage,
133
170
  dayUsage: buildDailyUsageRows(byDay),
134
- primaryLimitWindows: [],
135
- secondaryLimitWindows: [],
171
+ primaryLimitWindows: limitWindows.filter((window) => window.scope === "primary"),
172
+ secondaryLimitWindows: limitWindows.filter((window) => window.scope === "secondary"),
136
173
  warnings
137
174
  };
138
175
  }
@@ -140,6 +177,9 @@ export class AntigravityUsageProvider extends UsageProviderBase {
140
177
  export async function collectAntigravityUsage() {
141
178
  return collectAntigravityUsageFromTokscale();
142
179
  }
180
+ export async function collectAntigravityQuota() {
181
+ return collectAntigravityQuotaFromLocalRpc();
182
+ }
143
183
  async function collectAntigravityUsageFromTokscale() {
144
184
  await runTokscale([
145
185
  "antigravity",
@@ -153,11 +193,459 @@ async function runTokscale(args) {
153
193
  maxBuffer: 32 * 1024 * 1024
154
194
  });
155
195
  }
196
+ async function collectAntigravityQuotaFromLocalRpc() {
197
+ const server = await findAntigravityLocalServer();
198
+ if (!server) {
199
+ throw new Error("Antigravity local language server was not found.");
200
+ }
201
+ const fetchedAt = Date.now();
202
+ const payload = await readAntigravityQuotaSummary(server);
203
+ const entries = parseAntigravityQuotaEntries(payload);
204
+ await writeAntigravityDebugEvent("quota-rpc-response", {
205
+ port: server.port,
206
+ path: ANTIGRAVITY_QUOTA_SUMMARY_PATH,
207
+ entries: entries.map((entry) => ({
208
+ limitId: entry.limitId,
209
+ remainingFraction: entry.remainingFraction,
210
+ resetAt: new Date(entry.resetAt).toISOString(),
211
+ windowMinutes: entry.windowMinutes,
212
+ scope: entry.scope,
213
+ modelIds: entry.modelIds
214
+ })),
215
+ ...(isAntigravityRawDebugEnabled() ? { payload } : {})
216
+ });
217
+ return {
218
+ entries,
219
+ fetchedAt
220
+ };
221
+ }
222
+ function recordsForQuotaWindow(quota, records) {
223
+ if (quota.modelIds.length === 0) {
224
+ return [];
225
+ }
226
+ const endMs = quota.resetAt;
227
+ const startMs = endMs - quota.windowMinutes * 60000;
228
+ const modelIds = new Set(quota.modelIds.map(resolveModelId));
229
+ return records.filter((record) => {
230
+ const modelId = resolveModelId(record.modelId);
231
+ return (record.timestamp >= startMs &&
232
+ record.timestamp < endMs &&
233
+ modelIds.has(modelId));
234
+ });
235
+ }
236
+ function buildAntigravityLimitWindow(quota, records, fetchedAt) {
237
+ const matchingRecords = recordsForQuotaWindow(quota, records);
238
+ const byModel = new Map();
239
+ for (const record of matchingRecords) {
240
+ const modelId = resolveModelId(record.modelId);
241
+ addModelUsage(byModel, modelId, usageRecordToTotals(modelId, record));
242
+ }
243
+ const modelUsage = [...byModel.entries()]
244
+ .map(([modelId, totals]) => ({
245
+ modelId,
246
+ totals
247
+ }))
248
+ .sort((left, right) => right.totals.estimatedCredits -
249
+ left.totals.estimatedCredits);
250
+ const totals = sumUsageTotals(modelUsage.map((row) => row.totals));
251
+ const usedPercent = clampPercent((1 - quota.remainingFraction) * 100);
252
+ // Quota percentage is authoritative from Antigravity RPC. Token totals are
253
+ // reconstructed from locally available Tokscale events inside the same time
254
+ // window and may not match Antigravity's internal quota accounting exactly.
255
+ return {
256
+ scope: quota.scope,
257
+ planType: quota.planType,
258
+ limitId: quota.limitId,
259
+ windowMinutes: quota.windowMinutes,
260
+ startTimeUtcIso: new Date(quota.resetAt - quota.windowMinutes * 60000).toISOString(),
261
+ endTimeUtcIso: new Date(quota.resetAt).toISOString(),
262
+ firstSeenUtcIso: new Date(fetchedAt).toISOString(),
263
+ lastSeenUtcIso: new Date(fetchedAt).toISOString(),
264
+ minUsedPercent: usedPercent,
265
+ maxUsedPercent: usedPercent,
266
+ totals,
267
+ modelUsage,
268
+ eventCount: totals.eventCount
269
+ };
270
+ }
271
+ const antigravityHttpsAgent = new https.Agent({
272
+ rejectUnauthorized: false
273
+ });
274
+ async function findAntigravityLocalServer() {
275
+ const process = await findAntigravityProcess();
276
+ if (!process) {
277
+ await writeAntigravityDebugEvent("process-not-found", {});
278
+ return null;
279
+ }
280
+ const ports = await findListeningLoopbackPorts(process.pid);
281
+ await writeAntigravityDebugEvent("process-found", {
282
+ pid: process.pid,
283
+ ports
284
+ });
285
+ return probeAntigravityPorts(ports, process.csrfToken);
286
+ }
287
+ async function findAntigravityProcess() {
288
+ const fromProc = await findAntigravityProcessFromProc();
289
+ if (fromProc) {
290
+ return fromProc;
291
+ }
292
+ return findAntigravityProcessFromPs();
293
+ }
294
+ async function findAntigravityProcessFromProc() {
295
+ let entries;
296
+ try {
297
+ entries = await fs.promises.readdir("/proc", { withFileTypes: true });
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ for (const entry of entries) {
303
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) {
304
+ continue;
305
+ }
306
+ const pid = Number(entry.name);
307
+ const cmdline = await readProcCmdline(path.join("/proc", entry.name, "cmdline"));
308
+ const process = parseAntigravityProcessFromArgs(pid, cmdline);
309
+ if (process) {
310
+ return process;
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+ async function readProcCmdline(filePath) {
316
+ try {
317
+ const content = await fs.promises.readFile(filePath);
318
+ return content
319
+ .toString("utf8")
320
+ .split("\0")
321
+ .filter(Boolean);
322
+ }
323
+ catch {
324
+ return [];
325
+ }
326
+ }
327
+ async function findAntigravityProcessFromPs() {
328
+ try {
329
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,args="], {
330
+ encoding: "utf8",
331
+ maxBuffer: 4 * 1024 * 1024,
332
+ timeout: 5000
333
+ });
334
+ for (const line of stdout.split(/\r?\n/)) {
335
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
336
+ if (!match) {
337
+ continue;
338
+ }
339
+ const process = parseAntigravityProcessFromArgs(Number(match[1]), splitCommandLineForDiscovery(match[2]));
340
+ if (process) {
341
+ return process;
342
+ }
343
+ }
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ return null;
349
+ }
350
+ function splitCommandLineForDiscovery(value) {
351
+ return value.match(/"[^"]*"|'[^']*'|\S+/g)?.map((part) => part.replace(/^['"]|['"]$/g, "")) ?? [];
352
+ }
353
+ function parseAntigravityProcessFromArgs(pid, args) {
354
+ if (!Number.isInteger(pid) || pid <= 0 || !isAntigravityLanguageServerCommand(args)) {
355
+ return null;
356
+ }
357
+ const csrfToken = readNamedArg(args, "--csrf_token")?.trim();
358
+ if (!csrfToken) {
359
+ return null;
360
+ }
361
+ return { pid, csrfToken };
362
+ }
363
+ function isAntigravityLanguageServerCommand(args) {
364
+ const normalized = args.join(" ").toLowerCase();
365
+ return (normalized.includes("antigravity") &&
366
+ (normalized.includes("language-server") ||
367
+ normalized.includes("language_server") ||
368
+ normalized.includes("extension-server") ||
369
+ normalized.includes("extension_server")));
370
+ }
371
+ function readNamedArg(args, name) {
372
+ for (let index = 0; index < args.length; index += 1) {
373
+ const arg = args[index];
374
+ if (arg === name) {
375
+ return args[index + 1] ?? null;
376
+ }
377
+ if (arg.startsWith(`${name}=`)) {
378
+ return arg.slice(name.length + 1);
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ async function findListeningLoopbackPorts(pid) {
384
+ const parsers = [
385
+ () => findListeningLoopbackPortsWithSs(pid),
386
+ () => findListeningLoopbackPortsWithLsof(pid)
387
+ ];
388
+ for (const parse of parsers) {
389
+ const ports = await parse();
390
+ if (ports.length > 0) {
391
+ return ports;
392
+ }
393
+ }
394
+ return [];
395
+ }
396
+ async function findListeningLoopbackPortsWithSs(pid) {
397
+ try {
398
+ const { stdout } = await execFileAsync("ss", ["-H", "-ltnp"], {
399
+ encoding: "utf8",
400
+ maxBuffer: 1024 * 1024,
401
+ timeout: 5000
402
+ });
403
+ return uniquePorts(stdout
404
+ .split(/\r?\n/)
405
+ .filter((line) => line.includes(`pid=${pid},`) && isLoopbackListenLine(line))
406
+ .map(extractPortFromListenLine));
407
+ }
408
+ catch {
409
+ return [];
410
+ }
411
+ }
412
+ async function findListeningLoopbackPortsWithLsof(pid) {
413
+ try {
414
+ const { stdout } = await execFileAsync("lsof", ["-Pan", "-p", String(pid), "-iTCP", "-sTCP:LISTEN"], {
415
+ encoding: "utf8",
416
+ maxBuffer: 1024 * 1024,
417
+ timeout: 5000
418
+ });
419
+ return uniquePorts(stdout
420
+ .split(/\r?\n/)
421
+ .filter(isLoopbackListenLine)
422
+ .map(extractPortFromListenLine));
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
428
+ function isLoopbackListenLine(line) {
429
+ return /(?:127\.0\.0\.1|localhost|\[::1\]|::1):\d+\b/.test(line);
430
+ }
431
+ function extractPortFromListenLine(line) {
432
+ const matches = [...line.matchAll(/(?:127\.0\.0\.1|localhost|\[::1\]|::1):(\d+)/g)];
433
+ const value = matches.at(-1)?.[1];
434
+ const port = value ? Number(value) : NaN;
435
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
436
+ }
437
+ function uniquePorts(ports) {
438
+ return [...new Set(ports.filter((port) => port !== null))];
439
+ }
440
+ async function probeAntigravityPorts(ports, csrfToken) {
441
+ for (const port of ports) {
442
+ const server = { port, csrfToken };
443
+ try {
444
+ await readAntigravityQuotaSummary(server);
445
+ await writeAntigravityDebugEvent("port-probe-ok", { port });
446
+ return server;
447
+ }
448
+ catch (error) {
449
+ await writeAntigravityDebugEvent("port-probe-failed", {
450
+ port,
451
+ error: error instanceof Error ? error.message : String(error)
452
+ });
453
+ // Try the next loopback listener owned by the same Antigravity process.
454
+ }
455
+ }
456
+ return null;
457
+ }
458
+ async function readAntigravityQuotaSummary(server) {
459
+ return requestAntigravityQuotaSummary(server);
460
+ }
461
+ function requestAntigravityQuotaSummary(server) {
462
+ const body = "{}";
463
+ return new Promise((resolve, reject) => {
464
+ const request = https.request({
465
+ hostname: "127.0.0.1",
466
+ port: server.port,
467
+ path: ANTIGRAVITY_QUOTA_SUMMARY_PATH,
468
+ method: "POST",
469
+ timeout: 5000,
470
+ agent: antigravityHttpsAgent,
471
+ headers: {
472
+ "X-Codeium-Csrf-Token": server.csrfToken,
473
+ "Content-Type": "application/json",
474
+ "Connect-Protocol-Version": "1",
475
+ Accept: "application/json",
476
+ "Content-Length": Buffer.byteLength(body)
477
+ }
478
+ }, (response) => {
479
+ const chunks = [];
480
+ response.on("data", (chunk) => chunks.push(chunk));
481
+ response.on("end", () => {
482
+ const responseBody = Buffer.concat(chunks).toString("utf8");
483
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
484
+ reject(new Error(`Unexpected Antigravity quota summary response: ${response.statusCode ?? "unknown"}`));
485
+ return;
486
+ }
487
+ try {
488
+ resolve(responseBody ? JSON.parse(responseBody) : {});
489
+ }
490
+ catch (error) {
491
+ reject(error);
492
+ }
493
+ });
494
+ });
495
+ request.on("timeout", () => {
496
+ request.destroy(new Error("Timed out reading Antigravity quota summary."));
497
+ });
498
+ request.on("error", reject);
499
+ request.end(body);
500
+ });
501
+ }
502
+ export function parseAntigravityQuotaEntries(payload) {
503
+ const root = asRecord(payload);
504
+ const response = asRecord(root?.response);
505
+ const groups = asArray(response?.groups);
506
+ const entries = [];
507
+ for (const groupValue of groups) {
508
+ const group = asRecord(groupValue);
509
+ if (!group) {
510
+ continue;
511
+ }
512
+ const displayName = asString(group.displayName) ?? "";
513
+ const description = asString(group.description) ?? "";
514
+ const modelIds = resolveQuotaGroupModelIds(displayName, description);
515
+ if (modelIds.length === 0) {
516
+ void writeAntigravityDebugEvent("quota-group-skipped", {
517
+ displayName,
518
+ description
519
+ });
520
+ continue;
521
+ }
522
+ for (const bucketValue of asArray(group.buckets)) {
523
+ const bucket = asRecord(bucketValue);
524
+ if (!bucket) {
525
+ continue;
526
+ }
527
+ const bucketId = asString(bucket.bucketId);
528
+ const windowConfig = resolveQuotaWindow(asString(bucket.window));
529
+ const remainingFraction = asFiniteNumber(bucket.remainingFraction);
530
+ const resetTime = asString(bucket.resetTime);
531
+ const resetAt = resetTime === null ? NaN : Date.parse(resetTime);
532
+ if (!bucketId ||
533
+ remainingFraction === null ||
534
+ remainingFraction < 0 ||
535
+ remainingFraction > 1 ||
536
+ !Number.isFinite(resetAt) ||
537
+ !windowConfig) {
538
+ continue;
539
+ }
540
+ entries.push({
541
+ limitId: bucketId,
542
+ modelIds,
543
+ remainingFraction,
544
+ resetAt,
545
+ windowMinutes: windowConfig.windowMinutes,
546
+ scope: windowConfig.scope,
547
+ planType: "unknown"
548
+ });
549
+ }
550
+ }
551
+ return entries;
552
+ }
553
+ function resolveQuotaWindow(window) {
554
+ switch (window) {
555
+ case "5h":
556
+ return {
557
+ scope: "primary",
558
+ windowMinutes: ANTIGRAVITY_PRIMARY_WINDOW_MINUTES
559
+ };
560
+ case "weekly":
561
+ return {
562
+ scope: "secondary",
563
+ windowMinutes: ANTIGRAVITY_WEEKLY_WINDOW_MINUTES
564
+ };
565
+ default:
566
+ return null;
567
+ }
568
+ }
569
+ function resolveQuotaGroupModelIds(displayName, description) {
570
+ const text = `${displayName} ${description}`.toLowerCase();
571
+ if (text.includes("gemini")) {
572
+ return GEMINI_QUOTA_MODELS;
573
+ }
574
+ if (text.includes("claude") || text.includes("gpt")) {
575
+ return THIRD_PARTY_QUOTA_MODELS;
576
+ }
577
+ return [];
578
+ }
579
+ function asRecord(value) {
580
+ return value && typeof value === "object" && !Array.isArray(value)
581
+ ? value
582
+ : null;
583
+ }
584
+ function asArray(value) {
585
+ return Array.isArray(value) ? value : [];
586
+ }
587
+ function asString(value) {
588
+ return typeof value === "string" && value.trim() ? value.trim() : null;
589
+ }
590
+ function asFiniteNumber(value) {
591
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
592
+ }
593
+ function isAntigravityDebugEnabled() {
594
+ const value = process.env.LETMECODE_DEBUG_ANTIGRAVITY;
595
+ return value === "1" || value === "true" || value === "yes";
596
+ }
597
+ function isAntigravityRawDebugEnabled() {
598
+ const value = process.env.LETMECODE_DEBUG_ANTIGRAVITY_RAW;
599
+ return value === "1" || value === "true" || value === "yes";
600
+ }
601
+ async function writeAntigravityDebugEvent(event, data) {
602
+ if (!isAntigravityDebugEnabled()) {
603
+ return;
604
+ }
605
+ const line = JSON.stringify({
606
+ timestamp: new Date().toISOString(),
607
+ event,
608
+ data: redactDebugValue(data)
609
+ });
610
+ try {
611
+ await fs.promises.mkdir(path.dirname(ANTIGRAVITY_DEBUG_LOG_PATH), {
612
+ recursive: true
613
+ });
614
+ await fs.promises.appendFile(ANTIGRAVITY_DEBUG_LOG_PATH, `${line}\n`, "utf8");
615
+ }
616
+ catch {
617
+ // Debug logging must never break provider stats collection.
618
+ }
619
+ }
620
+ function redactDebugValue(value, key = "") {
621
+ if (isSensitiveDebugKey(key)) {
622
+ return "[redacted]";
623
+ }
624
+ if (Array.isArray(value)) {
625
+ return value.map((item) => redactDebugValue(item));
626
+ }
627
+ if (!value || typeof value !== "object") {
628
+ return value;
629
+ }
630
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [
631
+ entryKey,
632
+ redactDebugValue(entryValue, entryKey)
633
+ ]));
634
+ }
635
+ function isSensitiveDebugKey(key) {
636
+ return /token|csrf|authorization|cookie|email/i.test(key);
637
+ }
156
638
  function numberOrZero(value) {
157
639
  return typeof value === "number" && Number.isFinite(value)
158
640
  ? value
159
641
  : 0;
160
642
  }
643
+ function clampPercent(value) {
644
+ if (!Number.isFinite(value)) {
645
+ return 0;
646
+ }
647
+ return Math.min(100, Math.max(0, value));
648
+ }
161
649
  async function readAntigravityUsageCache(cacheRoot) {
162
650
  const sessionsRoot = path.join(cacheRoot, "sessions");
163
651
  const records = [];
@@ -58,7 +58,8 @@ export class ClaudeUsageProvider extends UsageProviderBase {
58
58
  this.now = options.now ?? (() => new Date());
59
59
  }
60
60
  async getStats(options = {}) {
61
- const resolvedSessionsRoot = await resolveClaudeSessionsRoot(this.root);
61
+ traceClaude(options.traceLogger, this.usageCommandKind, `Starting stats collection with root=${this.root} entrypoints=[${[...this.entrypoints].join(", ")}].`);
62
+ const resolvedSessionsRoot = await resolveClaudeSessionsRoot(this.root, this.usageCommandKind, options.traceLogger);
62
63
  const sessionsRoot = resolvedSessionsRoot.rootPath;
63
64
  const agentName = normalizeAnalyticsAgentName(this.label);
64
65
  const userIdHash = await readClaudeUserIdHash(this.root, this.usageCommandKind, this.readAuthStatusOutput, agentName, options.traceLogger);
@@ -74,9 +75,20 @@ export class ClaudeUsageProvider extends UsageProviderBase {
74
75
  tokenEvents: 0,
75
76
  malformedLines: 0
76
77
  };
77
- const parsedSessionFiles = await loadParsedClaudeSessionFiles(sessionsRoot);
78
+ const parsedSessionFiles = await loadParsedClaudeSessionFiles(sessionsRoot, this.usageCommandKind, options.traceLogger);
79
+ traceClaude(options.traceLogger, this.usageCommandKind, `Loaded ${parsedSessionFiles.length} parsed session file(s) from ${sessionsRoot}.`);
78
80
  for (const file of parsedSessionFiles) {
79
- const matchingEvents = file.events.filter((event) => this.entrypoints.has(event.entrypoint));
81
+ const matchingEvents = file.events.filter((event) => matchesClaudeProviderEvent(event, file, this.entrypoints, this.usageCommandKind));
82
+ traceClaude(options.traceLogger, this.usageCommandKind, [
83
+ `Session file ${describeSessionFilePath(sessionsRoot, file.filePath)}:`,
84
+ `lines=${file.linesRead}`,
85
+ `malformed=${file.malformedLines}`,
86
+ `assistantUsageEvents=${file.events.length}`,
87
+ `matchingEvents=${matchingEvents.length}`,
88
+ `source=${file.sourceKind}`,
89
+ `entrypoints=${summarizeEventCounts(file.events.map((event) => event.entrypoint || "<empty>"))}`,
90
+ `models=${summarizeDistinctValues(file.events.map((event) => event.modelId || "unknown"))}`
91
+ ].join(" "));
80
92
  if (matchingEvents.length === 0) {
81
93
  continue;
82
94
  }
@@ -91,6 +103,16 @@ export class ClaudeUsageProvider extends UsageProviderBase {
91
103
  ...parsedEvents.keyedEvents.values(),
92
104
  ...parsedEvents.unkeyedEvents.values()
93
105
  ];
106
+ traceClaude(options.traceLogger, this.usageCommandKind, [
107
+ `Transcript selection summary: filesWithMatches=${parseTotals.filesScanned}/${parsedSessionFiles.length}`,
108
+ `selectedEvents=${selectedEvents.length}`,
109
+ `duplicateUsageKeys=${parsedEvents.duplicateUsageKeys}`,
110
+ `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`,
111
+ `duplicateUnkeyedEvents=${parsedEvents.duplicateUnkeyedEvents}`
112
+ ].join(" "));
113
+ if (selectedEvents.length === 0 && parsedSessionFiles.length > 0) {
114
+ traceClaude(options.traceLogger, this.usageCommandKind, `No transcript usage matched entrypoints [${[...this.entrypoints].join(", ")}]. Observed entrypoints=${summarizeEventCounts(collectEntryPoints(parsedSessionFiles))}.`);
115
+ }
94
116
  for (const event of selectedEvents) {
95
117
  addModelUsage(byModel, event.modelId, event.totals);
96
118
  const planType = typeof event.rateLimits?.plan_type === "string" ? event.rateLimits.plan_type : undefined;
@@ -141,6 +163,19 @@ export class ClaudeUsageProvider extends UsageProviderBase {
141
163
  const secondaryLimitWindows = liveLimitWindows.secondaryLimitWindows.length > 0
142
164
  ? liveLimitWindows.secondaryLimitWindows
143
165
  : fallbackSecondaryLimitWindows;
166
+ traceClaude(options.traceLogger, this.usageCommandKind, [
167
+ `Finished stats collection:`,
168
+ `filesScanned=${parseTotals.filesScanned}`,
169
+ `linesRead=${parseTotals.linesRead}`,
170
+ `tokenEvents=${parseTotals.tokenEvents}`,
171
+ `models=${modelUsage.length}`,
172
+ `primaryWindows=${primaryLimitWindows.length}`,
173
+ `secondaryWindows=${secondaryLimitWindows.length}`,
174
+ `input=${summaryTotals.inputTokens}`,
175
+ `output=${summaryTotals.outputTokens}`,
176
+ `cacheRead=${summaryTotals.cacheReadInputTokens}`,
177
+ `cacheWrite=${summaryTotals.cacheWriteInputTokens}`
178
+ ].join(" "));
144
179
  return {
145
180
  providerId: this.id,
146
181
  providerLabel: this.label,
@@ -240,17 +275,23 @@ function resolveClaudeCacheWriteBreakdown(usage) {
240
275
  function isSessionFile(filePath) {
241
276
  return filePath.endsWith(".jsonl");
242
277
  }
243
- async function resolveClaudeSessionsRoot(root) {
278
+ async function resolveClaudeSessionsRoot(root, usageCommandKind, traceLogger) {
244
279
  const candidates = buildClaudeSessionsRootCandidates(root);
280
+ traceClaude(traceLogger, usageCommandKind, `Checking ${candidates.length} Claude session root candidate(s).`);
245
281
  for (const candidate of candidates) {
246
- if (await isDirectory(candidate.rootPath)) {
282
+ const exists = await isDirectory(candidate.rootPath);
283
+ traceClaude(traceLogger, usageCommandKind, `Session root candidate ${candidate.rootLabel} -> ${candidate.rootPath} (${exists ? "exists" : "missing"}).`);
284
+ if (exists) {
285
+ traceClaude(traceLogger, usageCommandKind, `Selected session root ${candidate.rootLabel} -> ${candidate.rootPath}.`);
247
286
  return candidate;
248
287
  }
249
288
  }
250
- return candidates[0] ?? {
289
+ const fallbackCandidate = candidates[0] ?? {
251
290
  rootLabel: "~/.claude/projects",
252
291
  rootPath: path.join(path.resolve(root), ".claude", "projects")
253
292
  };
293
+ traceClaude(traceLogger, usageCommandKind, `No session root candidate exists yet; defaulting to ${fallbackCandidate.rootLabel} -> ${fallbackCandidate.rootPath}.`);
294
+ return fallbackCandidate;
254
295
  }
255
296
  function buildClaudeSessionsRootCandidates(root) {
256
297
  const resolvedRoot = path.resolve(root);
@@ -338,28 +379,38 @@ async function* walkSessionFiles(directory) {
338
379
  }
339
380
  }
340
381
  }
341
- async function loadParsedClaudeSessionFiles(sessionsRoot) {
382
+ async function loadParsedClaudeSessionFiles(sessionsRoot, usageCommandKind, traceLogger) {
342
383
  const cacheKey = path.resolve(sessionsRoot);
343
384
  const cached = parsedClaudeSessionFilesCache.get(cacheKey);
344
385
  if (cached) {
345
- return cached;
386
+ const files = await cached;
387
+ traceClaude(traceLogger, usageCommandKind, `Session parse cache hit for ${sessionsRoot} (${files.length} file(s)).`);
388
+ return files;
346
389
  }
347
390
  const pending = (async () => {
348
391
  const files = [];
392
+ traceClaude(traceLogger, usageCommandKind, `Scanning session files under ${sessionsRoot}.`);
349
393
  for await (const filePath of walkSessionFiles(sessionsRoot)) {
350
- files.push(await parseSessionFile(filePath));
394
+ files.push(await parseSessionFile(filePath, sessionsRoot));
351
395
  }
396
+ inferClaudeSessionFileSources(files);
397
+ traceClaude(traceLogger, usageCommandKind, `Completed session file scan under ${sessionsRoot}: ${files.length} file(s) parsed.`);
352
398
  return files;
353
399
  })();
354
400
  parsedClaudeSessionFilesCache.set(cacheKey, pending);
355
401
  return pending;
356
402
  }
357
- async function parseSessionFile(filePath) {
403
+ async function parseSessionFile(filePath, sessionsRoot) {
358
404
  const stream = fs.createReadStream(filePath, { encoding: "utf8" });
359
405
  const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
360
406
  let linesRead = 0;
361
407
  let malformedLines = 0;
362
408
  const events = [];
409
+ const assistantEntryPoints = new Set();
410
+ let hasIdeOpenedFileAttachment = false;
411
+ let hasIdeOpenedFileMarker = false;
412
+ let hasIdeTooling = false;
413
+ let hasQueueOperations = false;
363
414
  for await (const line of lineReader) {
364
415
  linesRead += 1;
365
416
  if (!line.trim()) {
@@ -373,6 +424,19 @@ async function parseSessionFile(filePath) {
373
424
  malformedLines += 1;
374
425
  continue;
375
426
  }
427
+ if (payloadObject.type === "queue-operation") {
428
+ hasQueueOperations = true;
429
+ }
430
+ if (messageContainsIdeOpenedFileMarker(asRecord(payloadObject.message))) {
431
+ hasIdeOpenedFileMarker = true;
432
+ }
433
+ const attachment = asRecord(payloadObject.attachment);
434
+ if (attachment?.type === "opened_file_in_ide") {
435
+ hasIdeOpenedFileAttachment = true;
436
+ }
437
+ if (attachmentHasIdeTooling(attachment)) {
438
+ hasIdeTooling = true;
439
+ }
376
440
  if (payloadObject.type !== "assistant") {
377
441
  continue;
378
442
  }
@@ -383,12 +447,14 @@ async function parseSessionFile(filePath) {
383
447
  }
384
448
  const modelId = String(message?.model ?? "unknown");
385
449
  const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
450
+ const entrypoint = typeof payloadObject.entrypoint === "string" ? payloadObject.entrypoint : "";
386
451
  const rateLimits = extractRateLimits(payloadObject, message);
387
452
  const normalizedUsage = normalizeUsage(usage);
388
453
  const usageKey = buildUsageEventKey(payloadObject, message);
389
454
  const usageSignature = buildUsageSignature(payloadObject, modelId, normalizedUsage);
455
+ assistantEntryPoints.add(entrypoint);
390
456
  events.push({
391
- entrypoint: typeof payloadObject.entrypoint === "string" ? payloadObject.entrypoint : "",
457
+ entrypoint,
392
458
  usageKey,
393
459
  usageSignature,
394
460
  timestampMs: eventTimeMs,
@@ -397,7 +463,101 @@ async function parseSessionFile(filePath) {
397
463
  rateLimits
398
464
  });
399
465
  }
400
- return { linesRead, malformedLines, events };
466
+ return {
467
+ filePath,
468
+ sessionGroupKey: buildClaudeSessionGroupKey(sessionsRoot, filePath),
469
+ linesRead,
470
+ malformedLines,
471
+ sourceKind: "unknown",
472
+ sourceReason: "unclassified",
473
+ signals: {
474
+ assistantEntryPoints: [...assistantEntryPoints].sort(),
475
+ hasIdeOpenedFileAttachment,
476
+ hasIdeOpenedFileMarker,
477
+ hasIdeTooling,
478
+ hasQueueOperations
479
+ },
480
+ events
481
+ };
482
+ }
483
+ function buildClaudeSessionGroupKey(sessionsRoot, filePath) {
484
+ const relativePath = path.relative(sessionsRoot, filePath);
485
+ if (!relativePath || relativePath.startsWith("..")) {
486
+ return filePath;
487
+ }
488
+ const normalizedRelativePath = relativePath.split(path.sep).join("/");
489
+ const subagentMatch = normalizedRelativePath.match(/^(.*\/[^/]+)\/subagents\/[^/]+\.jsonl$/);
490
+ if (subagentMatch?.[1]) {
491
+ return subagentMatch[1];
492
+ }
493
+ return normalizedRelativePath.replace(/\.jsonl$/i, "");
494
+ }
495
+ function inferClaudeSessionFileSources(files) {
496
+ const groups = new Map();
497
+ for (const file of files) {
498
+ const group = groups.get(file.sessionGroupKey) ?? {
499
+ assistantEntryPoints: new Set(),
500
+ hasIdeHints: false
501
+ };
502
+ for (const entrypoint of file.signals.assistantEntryPoints) {
503
+ group.assistantEntryPoints.add(entrypoint);
504
+ }
505
+ group.hasIdeHints =
506
+ group.hasIdeHints ||
507
+ file.signals.hasIdeOpenedFileAttachment ||
508
+ file.signals.hasIdeOpenedFileMarker ||
509
+ file.signals.hasIdeTooling ||
510
+ file.signals.hasQueueOperations;
511
+ groups.set(file.sessionGroupKey, group);
512
+ }
513
+ for (const file of files) {
514
+ const group = groups.get(file.sessionGroupKey);
515
+ const { kind, reason } = classifyClaudeSessionGroup(group);
516
+ file.sourceKind = kind;
517
+ file.sourceReason = reason;
518
+ }
519
+ }
520
+ function classifyClaudeSessionGroup(group) {
521
+ if (!group) {
522
+ return { kind: "unknown", reason: "missing session group signals" };
523
+ }
524
+ if (group.assistantEntryPoints.has("claude-vscode")) {
525
+ return { kind: "vscode", reason: "explicit claude-vscode entrypoint" };
526
+ }
527
+ if (group.assistantEntryPoints.has("sdk-cli") || group.assistantEntryPoints.has("claude")) {
528
+ return { kind: "cli", reason: "explicit sdk-cli/claude entrypoint" };
529
+ }
530
+ if (group.assistantEntryPoints.has("cli")) {
531
+ return group.hasIdeHints
532
+ ? { kind: "vscode", reason: "generic cli entrypoint with IDE session hints" }
533
+ : { kind: "cli", reason: "generic cli entrypoint without IDE session hints" };
534
+ }
535
+ return { kind: "unknown", reason: "no assistant entrypoints" };
536
+ }
537
+ function attachmentHasIdeTooling(attachment) {
538
+ if (attachment?.type !== "deferred_tools_delta") {
539
+ return false;
540
+ }
541
+ return extractStringArray(attachment.addedNames).some((name) => name.startsWith("mcp__ide__"));
542
+ }
543
+ function messageContainsIdeOpenedFileMarker(message) {
544
+ const content = message?.content;
545
+ if (typeof content === "string") {
546
+ return content.includes("<ide_opened_file>");
547
+ }
548
+ if (!Array.isArray(content)) {
549
+ return false;
550
+ }
551
+ return content.some((item) => {
552
+ const contentItem = asRecord(item);
553
+ return typeof contentItem?.text === "string" && contentItem.text.includes("<ide_opened_file>");
554
+ });
555
+ }
556
+ function extractStringArray(value) {
557
+ if (!Array.isArray(value)) {
558
+ return [];
559
+ }
560
+ return value.filter((item) => typeof item === "string");
401
561
  }
402
562
  function buildUsageEventKey(payloadObject, message) {
403
563
  const sessionId = String(payloadObject.sessionId ?? "");
@@ -465,6 +625,18 @@ function shouldReplaceUsageEvent(previous, next) {
465
625
  }
466
626
  return false;
467
627
  }
628
+ function matchesClaudeProviderEvent(event, file, entrypoints, usageCommandKind) {
629
+ if (entrypoints.has(event.entrypoint)) {
630
+ return true;
631
+ }
632
+ if (event.entrypoint !== "cli") {
633
+ return false;
634
+ }
635
+ if (usageCommandKind === "vscode") {
636
+ return file.sourceKind === "vscode";
637
+ }
638
+ return file.sourceKind === "cli";
639
+ }
468
640
  function normalizeTimestamp(value) {
469
641
  return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
470
642
  }
@@ -493,6 +665,41 @@ function describeUsageOutput(output) {
493
665
  }
494
666
  return output.trim() ? output : "<empty>";
495
667
  }
668
+ function describeSessionFilePath(sessionsRoot, filePath) {
669
+ const relativePath = path.relative(sessionsRoot, filePath);
670
+ if (!relativePath || relativePath.startsWith("..")) {
671
+ return filePath;
672
+ }
673
+ return relativePath;
674
+ }
675
+ function summarizeEventCounts(values) {
676
+ const counts = new Map();
677
+ for (const value of values) {
678
+ const normalizedValue = value || "<empty>";
679
+ counts.set(normalizedValue, (counts.get(normalizedValue) ?? 0) + 1);
680
+ }
681
+ if (counts.size === 0) {
682
+ return "<none>";
683
+ }
684
+ return [...counts.entries()]
685
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
686
+ .map(([value, count]) => `${value}:${count}`)
687
+ .join(", ");
688
+ }
689
+ function summarizeDistinctValues(values, limit = 5) {
690
+ const distinctValues = [...new Set([...values].filter(Boolean))].sort();
691
+ if (distinctValues.length === 0) {
692
+ return "<none>";
693
+ }
694
+ const visibleValues = distinctValues.slice(0, limit);
695
+ const remainder = distinctValues.length - visibleValues.length;
696
+ return remainder > 0
697
+ ? `${visibleValues.join(", ")} (+${remainder} more)`
698
+ : visibleValues.join(", ");
699
+ }
700
+ function collectEntryPoints(files) {
701
+ return files.flatMap((file) => file.events.map((event) => event.entrypoint || "<empty>"));
702
+ }
496
703
  function buildClaudeCommandEnvironment() {
497
704
  return {
498
705
  ...process.env,
@@ -506,19 +713,47 @@ async function buildLiveLimitWindows(options) {
506
713
  ]);
507
714
  const snapshots = parseLiveUsageWindowSnapshots(usageOutput, options.now);
508
715
  traceClaude(options.traceLogger, options.usageCommandKind, `Parsed ${snapshots.length} live usage snapshot(s) from /usage output.`);
716
+ if (snapshots.length === 0) {
717
+ traceClaude(options.traceLogger, options.usageCommandKind, "No live usage snapshots matched the expected /usage format.");
718
+ }
509
719
  const resolvedPlanType = subscriptionType || "live";
720
+ traceClaude(options.traceLogger, options.usageCommandKind, `Resolved live plan type ${resolvedPlanType}.`);
721
+ const primaryLimitWindows = snapshots
722
+ .filter((snapshot) => snapshot.scope === "primary")
723
+ .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now));
724
+ const secondaryLimitWindows = snapshots
725
+ .filter((snapshot) => snapshot.scope === "secondary")
726
+ .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now));
727
+ for (let index = 0; index < snapshots.length; index += 1) {
728
+ const snapshot = snapshots[index];
729
+ const row = snapshot.scope === "primary"
730
+ ? primaryLimitWindows.filter((window) => window.limitId === `current-${snapshot.label}`)[0]
731
+ : secondaryLimitWindows.filter((window) => window.limitId === `current-${snapshot.label}`)[0];
732
+ if (!row) {
733
+ continue;
734
+ }
735
+ traceClaude(options.traceLogger, options.usageCommandKind, [
736
+ `Live window ${snapshot.scope}/${snapshot.label}:`,
737
+ `used=${snapshot.usedPercent}%`,
738
+ `range=${row.startTimeUtcIso}->${row.endTimeUtcIso}`,
739
+ `matchedEvents=${row.eventCount}`,
740
+ `input=${row.totals.inputTokens}`,
741
+ `output=${row.totals.outputTokens}`,
742
+ `cacheRead=${row.totals.cacheReadInputTokens}`,
743
+ `cacheWrite=${row.totals.cacheWriteInputTokens}`
744
+ ].join(" "));
745
+ }
510
746
  return {
511
- primaryLimitWindows: snapshots
512
- .filter((snapshot) => snapshot.scope === "primary")
513
- .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now)),
514
- secondaryLimitWindows: snapshots
515
- .filter((snapshot) => snapshot.scope === "secondary")
516
- .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now))
747
+ primaryLimitWindows,
748
+ secondaryLimitWindows
517
749
  };
518
750
  }
519
751
  async function readClaudeSubscriptionType(root, usageCommandKind, override, traceLogger) {
520
752
  const output = await readClaudeAuthStatusOutput(root, usageCommandKind, override, traceLogger);
521
753
  const subscriptionType = parseClaudeSubscriptionType(output);
754
+ if (output && !subscriptionType) {
755
+ traceClaude(traceLogger, usageCommandKind, "Could not parse subscription type from auth status output.");
756
+ }
522
757
  traceClaude(traceLogger, usageCommandKind, `Subscription type result: ${subscriptionType ?? "<none>"}.`);
523
758
  return subscriptionType;
524
759
  }
@@ -602,6 +837,7 @@ async function readClaudeUserIdHash(root, usageCommandKind, override, agentName,
602
837
  const authStatusOutput = await readClaudeAuthStatusOutput(root, usageCommandKind, override, traceLogger);
603
838
  const snapshot = parseClaudeAuthStatusSnapshot(authStatusOutput);
604
839
  if (!snapshot) {
840
+ traceClaude(traceLogger, usageCommandKind, "Auth status output did not yield an analytics identity snapshot.");
605
841
  return null;
606
842
  }
607
843
  return buildUserIdHash([agentName, snapshot.email, snapshot.orgId, snapshot.orgName]);
@@ -16,7 +16,7 @@ export function createProviders() {
16
16
  new AntigravityUsageProvider()
17
17
  ];
18
18
  }
19
- export { AntigravityUsageProvider } from "./antigravity.js";
19
+ export { AntigravityUsageProvider, collectAntigravityQuota, collectAntigravityUsage } from "./antigravity.js";
20
20
  export { ClaudeUsageProvider } from "./claude.js";
21
21
  export { CodexUsageProvider } from "./codex.js";
22
22
  export { CopilotUsageProvider, configureCopilotVsCodeLogging } from "./copilot.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmecode",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Provider-based terminal usage dashboard for LetMeCode.",
5
5
  "author": "devforth.io",
6
6
  "license": "MIT",