tokentracker-cli 0.64.2 → 0.64.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dashboard/dist/assets/ActivityHeatmap-BSA4KPkJ.js +42 -0
  2. package/dashboard/dist/assets/{Card-Cg-6DDbN.js → Card-BniOg7K8.js} +1 -1
  3. package/dashboard/dist/assets/ConfirmModal-CxThrcXK.js +1 -0
  4. package/dashboard/dist/assets/DashboardPage-DygQTlzO.js +19 -0
  5. package/dashboard/dist/assets/{DevicePage-C78nzmZm.js → DevicePage-BfRGbds7.js} +1 -1
  6. package/dashboard/dist/assets/DialogTitle-Cd0Nw0hW.js +1 -0
  7. package/dashboard/dist/assets/{FadeIn-RZKrtHup.js → FadeIn-DxDHmb9-.js} +1 -1
  8. package/dashboard/dist/assets/{HeaderGithubStar-B1gFlLrg.js → HeaderGithubStar-2aQXPupt.js} +1 -1
  9. package/dashboard/dist/assets/{IpCheckPage-RrZDmOFh.js → IpCheckPage-m_W_l5Xa.js} +1 -1
  10. package/dashboard/dist/assets/{LandingPage-CV9EvgTg.js → LandingPage-BkusMtg4.js} +1 -1
  11. package/dashboard/dist/assets/{LeaderboardAvatar-_tiTs_tT.js → LeaderboardAvatar-BmpmwHwD.js} +1 -1
  12. package/dashboard/dist/assets/LeaderboardPage-B6wWBsUW.js +2 -0
  13. package/dashboard/dist/assets/{LeaderboardProfileModal-BiQREZ6Z.js → LeaderboardProfileModal-CLlbxbZQ.js} +2 -2
  14. package/dashboard/dist/assets/{LeaderboardProfilePage-DU43hEuh.js → LeaderboardProfilePage-en0u_bG-.js} +2 -2
  15. package/dashboard/dist/assets/LimitsPage-CZXzDyPl.js +3 -0
  16. package/dashboard/dist/assets/{LocalOnlyNotice-Dvcf7F9s.js → LocalOnlyNotice-DNHw_KVs.js} +1 -1
  17. package/dashboard/dist/assets/{LoginPage-UlbsYFKJ.js → LoginPage-D38KpNMd.js} +1 -1
  18. package/dashboard/dist/assets/{PopoverPopup-BsBKLiJu.js → PopoverPopup-C4Ndfprc.js} +1 -1
  19. package/dashboard/dist/assets/{ResetPasswordPage-BuKemqhr.js → ResetPasswordPage-DYF1Iz64.js} +1 -1
  20. package/dashboard/dist/assets/{Select-9oplHX46.js → Select-6qFjabMe.js} +1 -1
  21. package/dashboard/dist/assets/{SelectItemText-CnL08w_W.js → SelectItemText-B234IuCW.js} +1 -1
  22. package/dashboard/dist/assets/SettingsPage-C-2m2LzE.js +1 -0
  23. package/dashboard/dist/assets/SkillsPage-37lQlleG.js +1 -0
  24. package/dashboard/dist/assets/WidgetsPage-D9ANC2dF.js +1 -0
  25. package/dashboard/dist/assets/{WrappedPage-DPAz_bit.js → WrappedPage-hjymf4DR.js} +1 -1
  26. package/dashboard/dist/assets/{agent-logos-uSGgZshc.js → agent-logos-B-NxiwlS.js} +1 -1
  27. package/dashboard/dist/assets/{arrow-up-right-9sot4sNP.js → arrow-up-right-B_iC7jeO.js} +1 -1
  28. package/dashboard/dist/assets/{download-3O0nnnqv.js → download-DSBqpgG5.js} +1 -1
  29. package/dashboard/dist/assets/index-BFIcLxb4.js +2 -0
  30. package/dashboard/dist/assets/main-CGx8BJ4O.css +1 -0
  31. package/dashboard/dist/assets/{main-C5qSiLnL.js → main-DEwjlMQP.js} +19 -8
  32. package/dashboard/dist/assets/{use-limits-display-prefs-Bh7PFn6x.js → use-limits-display-prefs-OhLCvWa8.js} +1 -1
  33. package/dashboard/dist/assets/{use-native-settings-MKCjJWeQ.js → use-native-settings-BOQcPSo9.js} +1 -1
  34. package/dashboard/dist/assets/use-ordered-list-DKOLCb2L.js +5 -0
  35. package/dashboard/dist/assets/{use-quality-per-dollar-pref-D-m9WqCz.js → use-quality-per-dollar-pref-EzQkuFZg.js} +1 -1
  36. package/dashboard/dist/assets/{use-usage-limits-CCu4bGI4.js → use-usage-limits-DD8zzK6Q.js} +1 -1
  37. package/dashboard/dist/assets/{useCurrency-BZmZ49MO.js → useCurrency-D1ldsRAh.js} +1 -1
  38. package/dashboard/dist/assets/{useScrollLock-BdBmNNmw.js → useScrollLock-eUzPJJ4w.js} +1 -1
  39. package/dashboard/dist/index.html +2 -2
  40. package/dashboard/dist/share.html +2 -2
  41. package/package.json +1 -1
  42. package/src/cli.js +7 -0
  43. package/src/commands/status.js +38 -0
  44. package/src/commands/sync.js +1 -0
  45. package/src/lib/pricing/curated-overrides.json +1 -0
  46. package/src/lib/pricing/seed-snapshot.json +1 -1
  47. package/src/lib/project-usage-purge.js +13 -2
  48. package/src/lib/rollout.js +364 -44
  49. package/src/lib/usage-limits.js +161 -1
  50. package/dashboard/dist/assets/ActivityHeatmap-CrW1u40M.js +0 -42
  51. package/dashboard/dist/assets/DashboardPage-Bl8jvnt-.js +0 -19
  52. package/dashboard/dist/assets/DialogTitle-COVyZQVB.js +0 -1
  53. package/dashboard/dist/assets/LeaderboardPage-2tltO9IR.js +0 -6
  54. package/dashboard/dist/assets/LimitsPage-C7xuyH5P.js +0 -3
  55. package/dashboard/dist/assets/SettingsPage-CeqgABWP.js +0 -1
  56. package/dashboard/dist/assets/SkillsPage-CW6aD8GD.js +0 -1
  57. package/dashboard/dist/assets/WidgetsPage-DtOkMdxK.js +0 -1
  58. package/dashboard/dist/assets/index-BeoRn2gJ.js +0 -2
  59. package/dashboard/dist/assets/info-CfCBZRtQ.js +0 -1
  60. package/dashboard/dist/assets/main-DWsITcdg.css +0 -1
@@ -8,9 +8,10 @@ async function purgeProjectUsage({
8
8
  projectQueuePath,
9
9
  projectQueueStatePath,
10
10
  projectState,
11
+ cursors,
11
12
  }) {
12
13
  if (!projectKey || !projectQueuePath || !projectQueueStatePath || !projectState) {
13
- return { removed: 0, kept: 0, removedBuckets: 0 };
14
+ return { removed: 0, kept: 0, removedBuckets: 0, removedProjectCursors: 0 };
14
15
  }
15
16
 
16
17
  let previousOffset = 0;
@@ -37,6 +38,16 @@ async function purgeProjectUsage({
37
38
  }
38
39
  projectState.buckets = buckets;
39
40
 
41
+ let removedProjectCursors = 0;
42
+ const files = cursors?.files && typeof cursors.files === "object" ? cursors.files : {};
43
+ for (const cursor of Object.values(files)) {
44
+ if (!cursor || typeof cursor !== "object") continue;
45
+ if (cursor.project?.projectKey === projectKey) {
46
+ delete cursor.project;
47
+ removedProjectCursors += 1;
48
+ }
49
+ }
50
+
40
51
  let removed = 0;
41
52
  let kept = 0;
42
53
  let nextOffset = 0;
@@ -100,7 +111,7 @@ async function purgeProjectUsage({
100
111
 
101
112
  await fsp.writeFile(projectQueueStatePath, JSON.stringify({ offset: nextOffset }), "utf8");
102
113
 
103
- return { removed, kept, removedBuckets };
114
+ return { removed, kept, removedBuckets, removedProjectCursors };
104
115
  }
105
116
 
106
117
  module.exports = { purgeProjectUsage };
@@ -290,7 +290,32 @@ async function parseClaudeIncremental({
290
290
  const truncated = sameInode && prevOffset > st.size;
291
291
  const startOffset = sameInode && !truncated ? prevOffset : 0;
292
292
 
293
- if (!projectEnabled && sameInode && !truncated && startOffset >= st.size) {
293
+ // Claude's launch cwd is fixed for a session file's lifetime, so once
294
+ // resolved (found, or confirmed absent) from content it never needs
295
+ // re-extracting. Cache it — and the resolved project's git-config
296
+ // freshness fingerprint — on the cursor so idle files with project
297
+ // tracking enabled don't re-walk the filesystem on every sync.
298
+ //
299
+ // "No cwd found" / "cwd not inside a git checkout" only count as settled
300
+ // for genuinely idle files (no new bytes) — there's nothing new to
301
+ // attribute either way. A file that's still growing gets retried: an
302
+ // early sync can catch a session file before its cwd-bearing line lands,
303
+ // and treating that as permanent would silently drop the rest of the
304
+ // session's usage from the project queue.
305
+ const idle = sameInode && !truncated && startOffset >= st.size;
306
+ const cachedCwd = sameInode && !truncated ? prev?.claudeCwd : undefined;
307
+ const cachedProjectFileContext =
308
+ sameInode && !truncated ? prev?.projectFileContext || null : null;
309
+ const cachedContextAbsent = cachedProjectFileContext?.absent === true;
310
+ const projectContextFresh =
311
+ projectEnabled && cachedProjectFileContext && !cachedContextAbsent
312
+ ? await isProjectFileContextFresh(cachedProjectFileContext)
313
+ : false;
314
+ const projectKnownAbsent = cachedCwd === null || cachedContextAbsent;
315
+ const projectSettled =
316
+ !projectEnabled || (idle && projectKnownAbsent) || projectContextFresh;
317
+
318
+ if (idle && projectSettled) {
294
319
  if (cb) {
295
320
  cb({
296
321
  index: idx + 1,
@@ -304,17 +329,32 @@ async function parseClaudeIncremental({
304
329
  continue;
305
330
  }
306
331
 
307
- const projectContext = projectEnabled
308
- ? await resolveProjectContextForFile({
309
- filePath,
332
+ let projectRef = sameInode && !truncated ? prev?.projectRef || null : null;
333
+ let projectKey = sameInode && !truncated ? prev?.projectKey || null : null;
334
+ let nextCwd = cachedCwd;
335
+ let nextProjectFileContext = cachedProjectFileContext;
336
+
337
+ if (projectEnabled && !projectSettled) {
338
+ if (nextCwd === undefined || nextCwd === null) {
339
+ nextCwd = await resolveClaudeFileCwd(filePath);
340
+ }
341
+ if (nextCwd) {
342
+ const projectContext = await resolveProjectContextForPath({
343
+ startDir: nextCwd,
310
344
  projectMetaCache,
311
345
  publicRepoCache,
312
346
  publicRepoResolver,
313
347
  projectState,
314
- })
315
- : null;
316
- const projectRef = projectContext?.projectRef || null;
317
- const projectKey = projectContext?.projectKey || null;
348
+ });
349
+ projectRef = projectContext?.projectRef || null;
350
+ projectKey = projectContext?.projectKey || null;
351
+ nextProjectFileContext = buildProjectFileContext(projectContext);
352
+ } else {
353
+ projectRef = null;
354
+ projectKey = null;
355
+ nextProjectFileContext = { absent: true };
356
+ }
357
+ }
318
358
 
319
359
  const result = await parseClaudeFile({
320
360
  filePath,
@@ -334,6 +374,14 @@ async function parseClaudeIncremental({
334
374
  inode,
335
375
  offset: result.endOffset,
336
376
  updatedAt: new Date().toISOString(),
377
+ ...(projectEnabled
378
+ ? {
379
+ claudeCwd: nextCwd === undefined ? null : nextCwd,
380
+ projectFileContext: nextProjectFileContext,
381
+ projectRef,
382
+ projectKey,
383
+ }
384
+ : {}),
337
385
  };
338
386
 
339
387
  filesProcessed += 1;
@@ -412,6 +460,26 @@ async function parseGeminiIncremental({
412
460
  const key = filePath;
413
461
  const prev = cursors.files[key] || null;
414
462
  const inode = st.ino || 0;
463
+ const size = Number.isFinite(st.size) ? st.size : 0;
464
+ const mtimeMs = Number.isFinite(st.mtimeMs) ? st.mtimeMs : 0;
465
+ const sameFileMetadata =
466
+ prev &&
467
+ prev.inode === inode &&
468
+ prev.size === size &&
469
+ prev.mtimeMs === mtimeMs;
470
+ if (!projectEnabled && sameFileMetadata) {
471
+ if (cb) {
472
+ cb({
473
+ index: idx + 1,
474
+ total: totalFiles,
475
+ filePath,
476
+ filesProcessed,
477
+ eventsAggregated,
478
+ bucketsQueued: touchedBuckets.size,
479
+ });
480
+ }
481
+ continue;
482
+ }
415
483
  let startIndex = prev && prev.inode === inode ? Number(prev.lastIndex || -1) : -1;
416
484
  let lastTotals = prev && prev.inode === inode ? prev.lastTotals || null : null;
417
485
  let lastModel = prev && prev.inode === inode ? prev.lastModel || null : null;
@@ -427,12 +495,38 @@ async function parseGeminiIncremental({
427
495
  : null;
428
496
  const projectRef = projectContext?.projectRef || null;
429
497
  const projectKey = projectContext?.projectKey || null;
498
+ const projectCursor =
499
+ projectKey &&
500
+ prev?.project &&
501
+ prev.project.projectKey === projectKey &&
502
+ prev.project.projectRef === projectRef
503
+ ? prev.project
504
+ : null;
505
+ const projectUpToDate =
506
+ projectKey &&
507
+ sameFileMetadata &&
508
+ projectCursor &&
509
+ Number(projectCursor.lastIndex ?? -1) >= Number(prev?.lastIndex ?? -1);
510
+ if (projectUpToDate) {
511
+ if (cb) {
512
+ cb({
513
+ index: idx + 1,
514
+ total: totalFiles,
515
+ filePath,
516
+ filesProcessed,
517
+ eventsAggregated,
518
+ bucketsQueued: touchedBuckets.size,
519
+ });
520
+ }
521
+ continue;
522
+ }
430
523
 
431
524
  const result = await parseGeminiFile({
432
525
  filePath,
433
526
  startIndex,
434
527
  lastTotals,
435
528
  lastModel,
529
+ projectCursor,
436
530
  hourlyState,
437
531
  touchedBuckets,
438
532
  source: fileSource,
@@ -444,11 +538,25 @@ async function parseGeminiIncremental({
444
538
 
445
539
  cursors.files[key] = {
446
540
  inode,
541
+ size,
542
+ mtimeMs,
447
543
  lastIndex: result.lastIndex,
448
544
  lastTotals: result.lastTotals,
449
545
  lastModel: result.lastModel,
450
546
  updatedAt: new Date().toISOString(),
451
547
  };
548
+ if (projectKey) {
549
+ cursors.files[key].project = {
550
+ projectKey,
551
+ projectRef,
552
+ lastIndex: result.projectLastIndex,
553
+ lastTotals: result.projectLastTotals,
554
+ lastModel: result.projectLastModel,
555
+ updatedAt: new Date().toISOString(),
556
+ };
557
+ } else if (prev?.project) {
558
+ cursors.files[key].project = prev.project;
559
+ }
452
560
 
453
561
  filesProcessed += 1;
454
562
  eventsAggregated += result.eventsAggregated;
@@ -1070,6 +1178,7 @@ async function parseGeminiFile({
1070
1178
  startIndex,
1071
1179
  lastTotals,
1072
1180
  lastModel,
1181
+ projectCursor,
1073
1182
  hourlyState,
1074
1183
  touchedBuckets,
1075
1184
  source,
@@ -1098,57 +1207,96 @@ async function parseGeminiFile({
1098
1207
  let eventsAggregated = 0;
1099
1208
  let model = typeof lastModel === "string" ? lastModel : null;
1100
1209
  let totals = lastTotals && typeof lastTotals === "object" ? lastTotals : null;
1101
- const begin = Number.isFinite(startIndex) ? startIndex + 1 : 0;
1210
+ const projectActive = Boolean(projectKey && projectState && projectTouchedBuckets);
1211
+ let projectStartIndex =
1212
+ projectActive && Number.isFinite(projectCursor?.lastIndex)
1213
+ ? Number(projectCursor.lastIndex)
1214
+ : -1;
1215
+ let projectModel =
1216
+ projectActive && typeof projectCursor?.lastModel === "string"
1217
+ ? projectCursor.lastModel
1218
+ : null;
1219
+ let projectTotals =
1220
+ projectActive && projectCursor?.lastTotals && typeof projectCursor.lastTotals === "object"
1221
+ ? projectCursor.lastTotals
1222
+ : null;
1223
+ if (projectActive && projectStartIndex >= messages.length) {
1224
+ projectStartIndex = -1;
1225
+ projectTotals = null;
1226
+ projectModel = null;
1227
+ }
1228
+ const hourlyBegin = Number.isFinite(startIndex) ? startIndex + 1 : 0;
1229
+ const projectBegin = projectActive ? projectStartIndex + 1 : Number.POSITIVE_INFINITY;
1230
+ const begin = Math.min(hourlyBegin, projectBegin);
1102
1231
 
1103
1232
  for (let idx = begin; idx < messages.length; idx++) {
1104
1233
  const msg = messages[idx];
1105
1234
  if (!msg || typeof msg !== "object") continue;
1106
1235
 
1107
1236
  const normalizedModel = normalizeModelInput(msg.model);
1108
- if (normalizedModel) model = normalizedModel;
1237
+ if (normalizedModel && idx >= hourlyBegin) model = normalizedModel;
1238
+ if (normalizedModel && idx >= projectBegin) projectModel = normalizedModel;
1109
1239
 
1110
1240
  const timestamp = typeof msg.timestamp === "string" ? msg.timestamp : null;
1111
1241
  const currentTotals = normalizeGeminiTokens(msg.tokens);
1112
- if (!timestamp || !currentTotals) {
1242
+ if (idx >= hourlyBegin && (!timestamp || !currentTotals)) {
1113
1243
  totals = currentTotals || totals;
1114
- continue;
1115
1244
  }
1116
-
1117
- const delta = diffGeminiTotals(currentTotals, totals);
1118
- if (!delta || isAllZeroUsage(delta)) {
1119
- totals = currentTotals;
1245
+ if (idx >= projectBegin && (!timestamp || !currentTotals)) {
1246
+ projectTotals = currentTotals || projectTotals;
1247
+ }
1248
+ if (!timestamp || !currentTotals) {
1120
1249
  continue;
1121
1250
  }
1122
- delta.conversation_count = 1;
1123
1251
 
1124
- const bucketStart = toUtcHalfHourStart(timestamp);
1125
- if (!bucketStart) {
1126
- totals = currentTotals;
1127
- continue;
1252
+ let bucketStart = null;
1253
+ if (idx >= hourlyBegin) {
1254
+ const delta = diffGeminiTotals(currentTotals, totals);
1255
+ if (!delta || isAllZeroUsage(delta)) {
1256
+ totals = currentTotals;
1257
+ } else {
1258
+ delta.conversation_count = 1;
1259
+ bucketStart = toUtcHalfHourStart(timestamp);
1260
+ if (bucketStart) {
1261
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
1262
+ addTotals(bucket.totals, delta);
1263
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
1264
+ eventsAggregated += 1;
1265
+ }
1266
+ totals = currentTotals;
1267
+ }
1128
1268
  }
1129
1269
 
1130
- const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
1131
- addTotals(bucket.totals, delta);
1132
- touchedBuckets.add(bucketKey(source, model, bucketStart));
1133
- if (projectKey && projectState && projectTouchedBuckets) {
1134
- const projectBucket = getProjectBucket(
1135
- projectState,
1136
- projectKey,
1137
- source,
1138
- bucketStart,
1139
- projectRef,
1140
- );
1141
- addTotals(projectBucket.totals, delta);
1142
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1270
+ if (idx >= projectBegin) {
1271
+ const projectDelta = diffGeminiTotals(currentTotals, projectTotals);
1272
+ if (!projectDelta || isAllZeroUsage(projectDelta)) {
1273
+ projectTotals = currentTotals;
1274
+ continue;
1275
+ }
1276
+ projectDelta.conversation_count = 1;
1277
+ bucketStart = bucketStart || toUtcHalfHourStart(timestamp);
1278
+ if (bucketStart) {
1279
+ const projectBucket = getProjectBucket(
1280
+ projectState,
1281
+ projectKey,
1282
+ source,
1283
+ bucketStart,
1284
+ projectRef,
1285
+ );
1286
+ addTotals(projectBucket.totals, projectDelta);
1287
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1288
+ }
1289
+ projectTotals = currentTotals;
1143
1290
  }
1144
- eventsAggregated += 1;
1145
- totals = currentTotals;
1146
1291
  }
1147
1292
 
1148
1293
  return {
1149
1294
  lastIndex: messages.length - 1,
1150
1295
  lastTotals: totals,
1151
1296
  lastModel: model,
1297
+ projectLastIndex: projectActive ? messages.length - 1 : null,
1298
+ projectLastTotals: projectTotals,
1299
+ projectLastModel: projectModel,
1152
1300
  eventsAggregated,
1153
1301
  };
1154
1302
  }
@@ -1989,7 +2137,15 @@ async function resolveProjectMetaForPath(startDir, cache) {
1989
2137
  if (configPath) {
1990
2138
  const remoteUrl = await readGitRemoteUrl(configPath);
1991
2139
  const projectRef = canonicalizeProjectRef(remoteUrl);
1992
- const meta = { projectRef: projectRef || null, repoRoot: current };
2140
+ const configStat = await fs.stat(configPath).catch(() => null);
2141
+ const meta = {
2142
+ projectRef: projectRef || null,
2143
+ repoRoot: current,
2144
+ configPath,
2145
+ configMtimeMs:
2146
+ configStat && Number.isFinite(configStat.mtimeMs) ? configStat.mtimeMs : null,
2147
+ configSize: configStat && Number.isFinite(configStat.size) ? configStat.size : null,
2148
+ };
1993
2149
  if (cache) {
1994
2150
  for (const entry of visited) cache.set(entry, meta);
1995
2151
  }
@@ -2129,16 +2285,86 @@ async function resolveProjectContextForPath({
2129
2285
  repoRootHash: meta?.repoRootHash || repoRootHash,
2130
2286
  };
2131
2287
  recordProjectMeta(projectState, normalized);
2288
+ const configFields = {
2289
+ configPath: projectMeta.configPath || null,
2290
+ configMtimeMs: projectMeta.configMtimeMs ?? null,
2291
+ configSize: projectMeta.configSize ?? null,
2292
+ };
2132
2293
  if (normalized.status !== "public_verified") {
2133
- return { projectRef: normalized.projectRef, projectKey: null, status: normalized.status };
2294
+ return {
2295
+ projectRef: normalized.projectRef,
2296
+ projectKey: null,
2297
+ status: normalized.status,
2298
+ ...configFields,
2299
+ };
2134
2300
  }
2135
2301
  return {
2136
2302
  projectRef: normalized.projectRef,
2137
2303
  projectKey: normalized.projectKey,
2138
2304
  status: normalized.status,
2305
+ ...configFields,
2139
2306
  };
2140
2307
  }
2141
2308
 
2309
+ // Builds a small, cursor-persistable freshness fingerprint for a resolved
2310
+ // project context's git config file, so a later sync can tell whether the
2311
+ // resolution is still valid without re-walking the filesystem.
2312
+ function buildProjectFileContext(context) {
2313
+ const configPath = typeof context?.configPath === "string" ? context.configPath : null;
2314
+ if (!configPath) return { absent: true };
2315
+ return {
2316
+ configPath,
2317
+ configMtimeMs: Number.isFinite(context.configMtimeMs) ? context.configMtimeMs : null,
2318
+ configSize: Number.isFinite(context.configSize) ? context.configSize : null,
2319
+ };
2320
+ }
2321
+
2322
+ async function isProjectFileContextFresh(projectFileContext) {
2323
+ if (!projectFileContext || typeof projectFileContext !== "object") return false;
2324
+ if (projectFileContext.absent === true) return false;
2325
+ const configPath =
2326
+ typeof projectFileContext.configPath === "string" ? projectFileContext.configPath : null;
2327
+ if (!configPath) return false;
2328
+ const st = await fs.stat(configPath).catch(() => null);
2329
+ if (!st || !st.isFile()) return false;
2330
+ return st.mtimeMs === projectFileContext.configMtimeMs && st.size === projectFileContext.configSize;
2331
+ }
2332
+
2333
+ // Claude Code session files log the launch cwd on message lines (top-level
2334
+ // "cwd" field) — unlike the file's on-disk location under
2335
+ // ~/.claude/projects/<dash-encoded-cwd>/, which is not itself inside the
2336
+ // real project's git checkout and can never resolve a project via directory
2337
+ // walk. The encoded name can't be decoded reliably either (dashes in the
2338
+ // real path collide with the path-separator encoding), so read the real cwd
2339
+ // from content instead. It's set once at session start and bounded scan is
2340
+ // cheap since it's a top-level field on the first several lines.
2341
+ const CLAUDE_CWD_SCAN_MAX_BYTES = 65536;
2342
+
2343
+ async function resolveClaudeFileCwd(filePath) {
2344
+ const stream = fssync.createReadStream(filePath, {
2345
+ encoding: "utf8",
2346
+ start: 0,
2347
+ end: CLAUDE_CWD_SCAN_MAX_BYTES,
2348
+ });
2349
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2350
+ try {
2351
+ for await (const line of rl) {
2352
+ if (!line || !line.includes('"cwd"')) continue;
2353
+ let obj;
2354
+ try {
2355
+ obj = JSON.parse(line);
2356
+ } catch (_e) {
2357
+ continue;
2358
+ }
2359
+ if (typeof obj?.cwd === "string" && obj.cwd.trim()) return obj.cwd.trim();
2360
+ }
2361
+ } finally {
2362
+ rl.close();
2363
+ stream.close?.();
2364
+ }
2365
+ return null;
2366
+ }
2367
+
2142
2368
  async function resolveGitConfigPath(rootDir) {
2143
2369
  const gitPath = path.join(rootDir, ".git");
2144
2370
  const st = await fs.stat(gitPath).catch(() => null);
@@ -3210,28 +3436,119 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
3210
3436
  // Hermes Agent — SQLite-based (sessions table in ~/.hermes/state.db)
3211
3437
  // ─────────────────────────────────────────────────────────────────────────────
3212
3438
 
3213
- function resolveHermesPath(env = process.env) {
3439
+ function resolveHermesPath(env = process.env, deps = {}) {
3214
3440
  const override = env.TOKENTRACKER_HERMES_HOME;
3215
3441
  if (typeof override === "string" && override.trim().length > 0) {
3216
3442
  return override.trim();
3217
3443
  }
3218
3444
  const home = require("node:os").homedir();
3219
3445
  const defaultPath = path.join(home, ".hermes");
3220
- // Hermes official Windows installer (install.ps1) writes state to
3221
- // %LOCALAPPDATA%\hermes, not ~/.hermes. Prefer it when present so native
3222
- // Windows users don't need to set TOKENTRACKER_HERMES_HOME manually.
3446
+ // On Windows, scan BOTH the native install (%LOCALAPPDATA%\hermes) and
3447
+ // any WSL distros running Hermes Agent. Each install has its own independent
3448
+ // state.db with different sessions, so there is no double-counting risk.
3449
+ // WSL is preferred when both exist because development happens in WSL (#87).
3223
3450
  if (process.platform === "win32") {
3451
+ let nativePath = null;
3224
3452
  const localAppData = typeof env.LOCALAPPDATA === "string" ? env.LOCALAPPDATA.trim() : "";
3225
3453
  if (localAppData.length > 0) {
3226
- const winNative = path.join(localAppData, "hermes");
3454
+ const candidate = path.join(localAppData, "hermes");
3227
3455
  try {
3228
- if (fssync.existsSync(winNative)) return winNative;
3456
+ if (fssync.existsSync(candidate)) nativePath = candidate;
3229
3457
  } catch (_e) { }
3230
3458
  }
3459
+ const wslPath = discoverWslHermesHome(deps);
3460
+ if (wslPath) return wslPath;
3461
+ if (nativePath) return nativePath;
3231
3462
  }
3232
3463
  return defaultPath;
3233
3464
  }
3234
3465
 
3466
+ // ── WSL auto-discovery (Windows host, Hermes installed inside a distro) ──────
3467
+ // `wsl.exe -l -v` prints UTF-16LE; the per-distro `whoami` runs a Linux process
3468
+ // so its stdout is UTF-8. We capture buffers and decode per-call.
3469
+ function defaultRunWsl(args, { utf16 = false } = {}) {
3470
+ const { execFileSync } = require("node:child_process");
3471
+ const buf = execFileSync("wsl.exe", args, {
3472
+ timeout: 5000,
3473
+ windowsHide: true,
3474
+ maxBuffer: 1024 * 1024,
3475
+ stdio: ["ignore", "pipe", "ignore"],
3476
+ });
3477
+ return utf16 ? buf.toString("utf16le") : buf.toString("utf8");
3478
+ }
3479
+
3480
+ // Parse `wsl.exe -l -v` output into [{ name, version, isDefault }]. The default
3481
+ // distro is prefixed with `*`; the VERSION column (1 or 2) decides which UNC
3482
+ // alias to try first (simonlpaige, #87).
3483
+ function parseWslListVerbose(raw) {
3484
+ if (typeof raw !== "string") return [];
3485
+ const distros = [];
3486
+ for (const line of raw.split(/\r?\n/)) {
3487
+ const clean = line.replace(//g, "").replace(//g, "").trim();
3488
+ if (!clean) continue;
3489
+ const cells = clean.split(/\s+/);
3490
+ let isDefault = false;
3491
+ let idx = 0;
3492
+ if (cells[0] === "*") {
3493
+ isDefault = true;
3494
+ idx = 1;
3495
+ }
3496
+ const name = cells[idx];
3497
+ if (!name || name === "NAME") continue; // skip header row
3498
+ const version = parseInt(cells[cells.length - 1], 10);
3499
+ distros.push({
3500
+ name,
3501
+ version: Number.isFinite(version) ? version : null,
3502
+ isDefault,
3503
+ });
3504
+ }
3505
+ return distros;
3506
+ }
3507
+
3508
+ // Default distro first, then listed order — matches what a user expects when
3509
+ // they have one primary distro plus extras.
3510
+ function probeWslDistros(deps = {}) {
3511
+ const runWsl = deps.runWsl || defaultRunWsl;
3512
+ let raw;
3513
+ try {
3514
+ raw = runWsl(["-l", "-v"], { utf16: true });
3515
+ } catch (_e) {
3516
+ return [];
3517
+ }
3518
+ const distros = parseWslListVerbose(raw);
3519
+ return distros.sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0));
3520
+ }
3521
+
3522
+ // Probe each WSL distro for ~/.hermes via UNC. The Linux username rarely equals
3523
+ // %USERNAME%, so we ask the distro directly with `whoami` rather than guessing
3524
+ // (simonlpaige, #87).
3525
+ function discoverWslHermesHome(deps = {}) {
3526
+ const runWsl = deps.runWsl || defaultRunWsl;
3527
+ const existsSync = deps.existsSync || fssync.existsSync;
3528
+ const distros = probeWslDistros(deps);
3529
+ for (const distro of distros) {
3530
+ let user;
3531
+ try {
3532
+ user = String(runWsl(["-d", distro.name, "-e", "whoami"], { utf16: false }) || "").trim();
3533
+ } catch (_e) {
3534
+ user = "";
3535
+ }
3536
+ if (!user) continue;
3537
+ // \\wsl$\ is the legacy alias, \\wsl.localhost\ the newer one. WSL1 distros
3538
+ // sometimes only resolve via \\wsl.localhost\, so order by version.
3539
+ const roots = distro.version === 1
3540
+ ? ["\\\\wsl.localhost\\", "\\\\wsl$\\"]
3541
+ : ["\\\\wsl$\\", "\\\\wsl.localhost\\"];
3542
+ for (const root of roots) {
3543
+ const candidate = `${root}${distro.name}\\home\\${user}\\.hermes`;
3544
+ try {
3545
+ if (existsSync(candidate)) return candidate;
3546
+ } catch (_e) { }
3547
+ }
3548
+ }
3549
+ return null;
3550
+ }
3551
+
3235
3552
  function resolveHermesDbPath(env = process.env) {
3236
3553
  return path.join(resolveHermesPath(env), "state.db");
3237
3554
  }
@@ -9165,6 +9482,9 @@ module.exports = {
9165
9482
  resolveKiroJsonlPath,
9166
9483
  resolveHermesPath,
9167
9484
  resolveHermesDbPath,
9485
+ parseWslListVerbose,
9486
+ probeWslDistros,
9487
+ discoverWslHermesHome,
9168
9488
  resolveCopilotOtelPaths,
9169
9489
  parseRolloutIncremental,
9170
9490
  parseClaudeIncremental,