tokentracker-cli 1.5.6 → 1.5.8

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 (128) hide show
  1. package/README.md +2 -0
  2. package/dashboard/dist/assets/{AchievementBadge-NKpY___e.js → AchievementBadge-Ck1DmXtu.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-9nXmSX72.js → AchievementsPage-DjvMoc2i.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-DttQciSY.js → AchievementsSection-DvTHE8Vd.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-bvWEdJbe.js → BadgeDetailModal-COa2Rd6M.js} +1 -1
  6. package/dashboard/dist/assets/{BotAnimated-ChuUfph8.js → BotAnimated-CeR3jSce.js} +1 -1
  7. package/dashboard/dist/assets/{Card-SWtLDXt1.js → Card-bQCEOe_K.js} +1 -1
  8. package/dashboard/dist/assets/{CloudCheckoutPage-c2fNl1Rl.js → CloudCheckoutPage-DDH-3QOd.js} +1 -1
  9. package/dashboard/dist/assets/{CloudPage-CfwDjZMS.js → CloudPage-RygH3R8b.js} +2 -2
  10. package/dashboard/dist/assets/{CommandPalette-tWgPa295.js → CommandPalette-E7YqsdAH.js} +1 -1
  11. package/dashboard/dist/assets/{CommunityStatsModal-D7UaaUdM.js → CommunityStatsModal-B5heTDUE.js} +1 -1
  12. package/dashboard/dist/assets/{ConfirmModal-BrBcKhfW.js → ConfirmModal-D3vd882N.js} +1 -1
  13. package/dashboard/dist/assets/{Controls-CcvBkk7W.js → Controls-CK74JkE8.js} +1 -1
  14. package/dashboard/dist/assets/DashboardPage-DwTrlQZQ.js +1 -0
  15. package/dashboard/dist/assets/{DateRangePopover-BkN_pird.js → DateRangePopover-BxVCPuEX.js} +1 -1
  16. package/dashboard/dist/assets/{DevicePage-BbUL7WwT.js → DevicePage-B6x5fBdU.js} +1 -1
  17. package/dashboard/dist/assets/{DialogClose-BXO8SBFr.js → DialogClose-teXmwU1d.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-BSCcGkVT.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-Du332auj.js → DialogTitle-B3m-31sy.js} +1 -1
  20. package/dashboard/dist/assets/{ElectricLogo-CXLYfOt7.js → ElectricLogo-0wyvks4K.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-HSQBOc8B.js → FadeIn-C60pLKXu.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-D5tyQDGE.js → HeaderGithubStar-CCEUzMqo.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-BhidhbnD.js → HoverTooltip-D9RtbhSZ.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-WMEF1oS3.js → IpCheckPage-BCyW0SWY.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-BmO-59ZP.js → LandingPage-DCEofDST.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardPage-C2WyniEt.js → LeaderboardPage-CsTbalIm.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardProBadge-DAZnMRNn.js → LeaderboardProBadge-Crun5RUJ.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardProfileModal-D1BxcloV.js → LeaderboardProfileModal-Bim9cms6.js} +3 -3
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-RPJDTwpG.js → LeaderboardProfilePage-D9pBE7f4.js} +1 -1
  30. package/dashboard/dist/assets/{LimitsPage-9ZOhKvn5.js → LimitsPage-CFhzwKzB.js} +6 -6
  31. package/dashboard/dist/assets/{LocalOnlyNotice-CH6w4hRw.js → LocalOnlyNotice-F_qczacX.js} +1 -1
  32. package/dashboard/dist/assets/{LoginCard-D24HsRjs.js → LoginCard-CQm_T5K2.js} +1 -1
  33. package/dashboard/dist/assets/{LoginModal-sLvHyi9U.js → LoginModal-BUf4mPCT.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-PIvLEw1Y.js → LoginPage-njdEUQEX.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-DYcw_4P1.js → PetPage-COczIU1k.js} +2 -2
  36. package/dashboard/dist/assets/{PopoverPopup-D7ZCWq5f.js → PopoverPopup-DxB1k4g7.js} +1 -1
  37. package/dashboard/dist/assets/{ProviderIcon-B0kB52K-.js → ProviderIcon-BQfkoaQE.js} +1 -1
  38. package/dashboard/dist/assets/{ResetPasswordPage-B_NT4Xfl.js → ResetPasswordPage-PGRVaPTN.js} +1 -1
  39. package/dashboard/dist/assets/{Select-BtxjjP2Q.js → Select-CQVnpoBO.js} +1 -1
  40. package/dashboard/dist/assets/{SelectItemText-Co1M6XRC.js → SelectItemText-DmAn7osh.js} +1 -1
  41. package/dashboard/dist/assets/{SelfHostPage-XadfhFnd.js → SelfHostPage-BUURA-Vt.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-ZEJyAxsQ.js → ServiceStatusPage-DuzSen0Q.js} +1 -1
  43. package/dashboard/dist/assets/SessionsPage-DndGq61W.js +3 -0
  44. package/dashboard/dist/assets/{SettingsPage-D4qHgIxn.js → SettingsPage-CHh0yqsZ.js} +3 -3
  45. package/dashboard/dist/assets/{SkillsPage-CkQCk2uF.js → SkillsPage-CCaYXQWN.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-Bx1vr4b0.js → TokenGalaxy-CGxUtq9x.js} +1 -1
  47. package/dashboard/dist/assets/{TooltipPopup-DQW8FWJO.js → TooltipPopup-B_B4qQD-.js} +1 -1
  48. package/dashboard/dist/assets/{TrendMonitor-BMNcY3N8.js → TrendMonitor-CdRQTLZL.js} +1 -1
  49. package/dashboard/dist/assets/{WidgetsPage-luRTge7O.js → WidgetsPage-DP_w32wh.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-B1j3WgXx.js → WrappedPage-C_DCqHUH.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-CvvpsW-U.js → __vite-browser-external-Lw3ipvYF.js} +3 -3
  52. package/dashboard/dist/assets/{agent-logos-DL4fdks6.js → agent-logos-DlnSKc2A.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-DiOsjgdu.js → arrow-left-CjLKt_5C.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-DBFXV7GU.js → arrow-right-Bh-Ezs8y.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-BIvpDQHo.js → arrow-up-right-C87CvEJ5.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days--psnXUPW.js → calendar-days-B5VHwV9j.js} +1 -1
  57. package/dashboard/dist/assets/{check-DLqbsN00.js → check-D2uclldH.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-C0P0oLzP.js → chevron-down-Beacyoub.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-NSmb7QNy.js → cloud-checkout-3Y7NDSG1.js} +1 -1
  60. package/dashboard/dist/assets/{composite-D7ahPHYQ.js → composite-B28yORTL.js} +1 -1
  61. package/dashboard/dist/assets/{copy-COD7vwd6.js → copy-C8fZJwms.js} +1 -1
  62. package/dashboard/dist/assets/{download-CpSUX4U-.js → download-IIXYYmyo.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-DXUjrooT.js → external-link-BEb0dms0.js} +1 -1
  64. package/dashboard/dist/assets/{flame-Dl3ZT9oo.js → flame-gmfpE-PO.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-Ci49THbK.js → format-tokens-CKwy7NXi.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DTHCN1Of.js → gift-twpInP_O.js} +1 -1
  67. package/dashboard/dist/assets/{icons-CB9s1e1k.js → icons-DQfkB5-5.js} +1 -1
  68. package/dashboard/dist/assets/{index-DFdfNKJM.js → index-ChVgw-dM.js} +1 -1
  69. package/dashboard/dist/assets/{index-Dozwi-Se.js → index-ClwErGKW.js} +1 -1
  70. package/dashboard/dist/assets/{index-BGT8ZQyn.js → index-D2-6Msd9.js} +1 -1
  71. package/dashboard/dist/assets/{index-DeX4t_5p.js → index-DzUxfO5e.js} +1 -1
  72. package/dashboard/dist/assets/{info-CqM2wmSI.js → info-BvndJDmD.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-Byo7Rj3t.js → link-2-BhU_5JKG.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-CMxd78Na.js → loader-circle-BR_62BDE.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-CBjnoeck.js → local-usage-transfer-CfoyR0hM.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-Oowlk_FC.js → modifiers.esm-CnttlnRD.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-B2EgB_9q.js → monitor-smartphone-D4vzGxrq.js} +1 -1
  78. package/dashboard/dist/assets/{play-BSuP-UBL.js → play-BVWEKYTF.js} +1 -1
  79. package/dashboard/dist/assets/{plus-BWemSIuC.js → plus-B9GQ6VvV.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-BpzWuFDH.js → provider-display-DCFgR7FO.js} +1 -1
  81. package/dashboard/dist/assets/{react-cIJ4zx62.js → react-BKUD3UPr.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-C_ZMtbgL.js → safePolygon-DPIi3FGf.js} +1 -1
  83. package/dashboard/dist/assets/{search-BK7eyNds.js → search-BBRJvYNz.js} +1 -1
  84. package/dashboard/dist/assets/{server-BqQHX-rd.js → server-Cg3zBK6a.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-CO5U6gRw.js → shield-check-BBzYplRb.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-CMye76ag.js → skills-api-CcAQ6fJX.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-Bvb61Lvj.js → sortable.esm-kNieGfR9.js} +1 -1
  88. package/dashboard/dist/assets/{store-CICV1KXb.js → store-DQpd_ZKv.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-BKMWT1Ux.js → terminal-zj_c8_SG.js} +1 -1
  90. package/dashboard/dist/assets/{trash-D2Oqim4r.js → trash-Sl7eOlF7.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-CEqwmjct.js → use-community-stats-Bg-4yvQQ.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-CTuzzu3F.js → use-latest-request-guard-COF23Bfs.js} +1 -1
  93. package/dashboard/dist/assets/{use-limits-display-prefs-CA2BLWkY.js → use-limits-display-prefs-QOlhCOgQ.js} +1 -1
  94. package/dashboard/dist/assets/{use-native-settings-B54wHSj9.js → use-native-settings-CKDqyirL.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list--PU9ekxY.js → use-ordered-list-mFHPMcF_.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-B4rpC-Zt.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-BRyR8szg.js → use-transform-CO5wZ0ML.js} +1 -1
  98. package/dashboard/dist/assets/{use-usage-limits-sufKdfDY.js → use-usage-limits-CeKjW8jG.js} +1 -1
  99. package/dashboard/dist/assets/{useCurrency-6oy7kSX8.js → useCurrency-WruYNKxG.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-ZtDdVdJg.js → usePositioner-C3NGxtZa.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-tiOJ-PeE.js → useTokenFormat-ObRZ_T9W.js} +1 -1
  102. package/dashboard/dist/assets/{zap-Dr1dXUjN.js → zap-CyZ3Tf1D.js} +1 -1
  103. package/dashboard/dist/index.html +1 -1
  104. package/dashboard/dist/ip-check.html +1 -1
  105. package/dashboard/dist/leaderboard.html +1 -1
  106. package/dashboard/dist/share.html +1 -1
  107. package/package.json +1 -1
  108. package/src/commands/init.js +9 -0
  109. package/src/commands/status.js +12 -6
  110. package/src/commands/sync.js +22 -4
  111. package/src/lib/claude-backend.js +291 -0
  112. package/src/lib/claude-config.js +1 -1
  113. package/src/lib/cursor-config.js +3 -1
  114. package/src/lib/devin-limits.js +65 -25
  115. package/src/lib/diagnostics.js +3 -0
  116. package/src/lib/doctor.js +13 -0
  117. package/src/lib/local-api.js +2 -0
  118. package/src/lib/local-usage-transfer.js +22 -1
  119. package/src/lib/pricing/index.js +12 -0
  120. package/src/lib/pricing/seed-snapshot.json +1 -1
  121. package/src/lib/rollout.js +70 -15
  122. package/src/lib/session-analytics.js +20 -8
  123. package/src/lib/trae-cn-config.js +32 -2
  124. package/src/lib/usage-limits.js +601 -35
  125. package/dashboard/dist/assets/DashboardPage-DJSgRHGq.js +0 -1
  126. package/dashboard/dist/assets/DialogDescription-B1HyKP0x.js +0 -1
  127. package/dashboard/dist/assets/SessionsPage-Cte1taUu.js +0 -3
  128. package/dashboard/dist/assets/use-reduced-motion-CZ8_YWc2.js +0 -1
@@ -1453,6 +1453,7 @@ function normalizePlatform(value) {
1453
1453
  }
1454
1454
 
1455
1455
  function buildNotifyHandler({ trackerDir, packageName }) {
1456
+ const { backendFromUrl, captureClaudeBackend, hashClaudeRange } = require("../lib/claude-backend");
1456
1457
  // Keep this file dependency-free: Node built-ins only.
1457
1458
  // It must never block Codex; it spawns sync in the background and exits 0.
1458
1459
  const queueSignalPath = path.join(trackerDir, "notify.signal");
@@ -1526,6 +1527,14 @@ if (debugEnabled) {
1526
1527
  } catch (_) {}
1527
1528
  }
1528
1529
 
1530
+ // Capture before the throttle and before spawning sync. Never persist stdin.
1531
+ if (source === 'claude') {
1532
+ try { captureClaudeBackend(trackerDir, JSON.parse(fs.readFileSync(0, 'utf8'))); } catch (_) {}
1533
+ }
1534
+ ${backendFromUrl.toString()}
1535
+ ${hashClaudeRange.toString()}
1536
+ ${captureClaudeBackend.toString()}
1537
+
1529
1538
  // Throttle spawn: at most once per 20 seconds.
1530
1539
  try {
1531
1540
  const throttlePath = path.join(trackerDir, 'sync.throttle');
@@ -856,13 +856,19 @@ async function cmdStatus(argv = []) {
856
856
  // official API, so status surfaces the opt-in flag, the resolved storage
857
857
  // path, and whether the auth blob decrypts — the three inputs a "why is my
858
858
  // TRAE CN data missing" diagnosis needs.
859
- // A resolvable default path does NOT mean the app is installed - the
860
- // macOS/Windows resolver always derives one. Match the sync path semantics
861
- // exactly: installed iff the storage file actually exists on disk.
859
+ // Follow junctions with stat. Only missing paths mean absent; access/IO
860
+ // errors must reach the auth reader and be shown as unreadable, not hidden
861
+ // behind existsSync's false result. Explicit overrides can also be absent.
862
862
  const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
863
- const traeCnInstalled = Boolean(
864
- traeCnStoragePath && fssync.existsSync(traeCnStoragePath),
865
- );
863
+ let traeCnInstalled = false;
864
+ if (traeCnStoragePath) {
865
+ try {
866
+ fssync.statSync(traeCnStoragePath);
867
+ traeCnInstalled = true;
868
+ } catch (error) {
869
+ traeCnInstalled = error?.code !== "ENOENT" && error?.code !== "ENOTDIR";
870
+ }
871
+ }
866
872
  let traeCnAuthState = "not-signed-in";
867
873
  if (traeCnInstalled) {
868
874
  try {
@@ -185,7 +185,7 @@ const {
185
185
  const { purgeProjectUsage } = require("../lib/project-usage-purge");
186
186
  const {
187
187
  fetchTraeCnUsageWithAuth,
188
- resolveTraeCnStoragePath,
188
+ readTraeCnAuthFromStorage,
189
189
  isTraeCnUsageEnabled,
190
190
  } = require("../lib/trae-cn-config");
191
191
  const {
@@ -2315,9 +2315,10 @@ async function cmdSync(argv, context = {}) {
2315
2315
  Math.floor((endTime - 30 * 24 * 60 * 60) / HALF_HOUR_SEC) * HALF_HOUR_SEC,
2316
2316
  );
2317
2317
  try {
2318
- // Absent storage means "not signed in" — a silent skip, not an error.
2319
- const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
2320
- if (traeCnStoragePath && fssync.existsSync(traeCnStoragePath)) {
2318
+ // Read through junctions and let the credential-safe reader distinguish
2319
+ // missing auth from access/IO errors. existsSync masks both as absent.
2320
+ const traeCnAuth = readTraeCnAuthFromStorage({ env: process.env, home });
2321
+ if (traeCnAuth) {
2321
2322
  if (progress?.enabled) {
2322
2323
  progress.start(`Fetching TRAE Work CN usage...`);
2323
2324
  }
@@ -4630,6 +4631,12 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
4630
4631
  // rows whose input_tokens still include cached tokens (6-7x inflated) —
4631
4632
  // while the local dashboard showed corrected numbers.
4632
4633
  bucket = require("../lib/local-api").normalizeQueueRow(bucket);
4634
+ // Backend origins are local provenance; the current cloud accounting
4635
+ // schema has no backend dimension. Do not disclose gateway hosts there.
4636
+ if (bucket.backend_usage) {
4637
+ bucket = { ...bucket };
4638
+ delete bucket.backend_usage;
4639
+ }
4633
4640
  bucketMap.set(`${source}|${model}|${hourStart}`, bucket);
4634
4641
  linesRead += 1;
4635
4642
  if (linesRead >= maxBuckets) break;
@@ -6372,8 +6379,10 @@ async function repairCodexRescanInflation({
6372
6379
  }
6373
6380
  }
6374
6381
  rebuilt = {
6382
+ version: tmpCursors.hourly.version,
6375
6383
  buckets: tmpCursors.hourly.buckets || {},
6376
6384
  groupQueued: tmpCursors.hourly.groupQueued || {},
6385
+ modelAttributionPolicy: tmpCursors.hourly.modelAttributionPolicy,
6377
6386
  codexHashes: Array.isArray(tmpCursors.codexHashes) ? tmpCursors.codexHashes : [],
6378
6387
  codexCompactionResponseIds: Array.isArray(tmpCursors.codexCompactionResponseIds)
6379
6388
  ? tmpCursors.codexCompactionResponseIds
@@ -6510,6 +6519,9 @@ async function repairCodexRescanInflation({
6510
6519
  // rebuilt per-file cursors (offset at EOF) so the later parse in THIS sync
6511
6520
  // does not re-read codex (which would re-inflate project buckets).
6512
6521
  const hourly = (cursors.hourly ||= { buckets: {}, groupQueued: {} });
6522
+ // Without the rebuilt schema version, the next normalization treats these
6523
+ // per-model keys as v1 and overwrites them all with an unknown bucket (#927).
6524
+ hourly.version = rebuilt.version;
6513
6525
  hourly.buckets ||= {};
6514
6526
  hourly.groupQueued ||= {};
6515
6527
  for (const k of Object.keys(hourly.buckets)) {
@@ -6524,6 +6536,12 @@ async function repairCodexRescanInflation({
6524
6536
  for (const [k, v] of Object.entries(rebuilt.groupQueued)) {
6525
6537
  if (k.startsWith("codex|")) hourly.groupQueued[k] = v;
6526
6538
  }
6539
+ // The rebuilt queue already uses the parser's current attribution policy.
6540
+ // Preserve it so the following parse does not re-emit every repaired row.
6541
+ hourly.modelAttributionPolicy = {
6542
+ ...hourly.modelAttributionPolicy,
6543
+ codex: rebuilt.modelAttributionPolicy.codex,
6544
+ };
6527
6545
  cursors.files ||= {};
6528
6546
  for (const fp of Object.keys(cursors.files)) {
6529
6547
  if (isCodexSessionCursorPath(fp)) delete cursors.files[fp];
@@ -0,0 +1,291 @@
1
+ // Local-only accounting provenance. Never infer historical routing from current config.
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const crypto = require("node:crypto");
5
+
6
+ function backendFromUrl(value) {
7
+ try {
8
+ if (typeof value !== "string" || !value.trim()) return { backend: "unknown" };
9
+ const url = new URL(value);
10
+ if (!["http:", "https:"].includes(url.protocol)) return { backend: "unknown" };
11
+ const backend_origin = url.origin;
12
+ return { backend: backend_origin === "https://api.anthropic.com" ? "anthropic" : "custom", backend_origin };
13
+ } catch { return { backend: "unknown" }; }
14
+ }
15
+
16
+ // Hash only the enclosed bytes, in bounded chunks. Persist no transcript text.
17
+ function hashClaudeRange(filePath, start, end) {
18
+ const fs = require("node:fs");
19
+ const hash = require("node:crypto").createHash("sha256");
20
+ const fd = fs.openSync(filePath, "r");
21
+ try {
22
+ const buffer = Buffer.alloc(64 * 1024);
23
+ for (let offset = start; offset < end;) {
24
+ const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - offset), offset);
25
+ if (!read) return null;
26
+ hash.update(buffer.subarray(0, read));
27
+ offset += read;
28
+ }
29
+ return hash.digest("hex");
30
+ } finally { fs.closeSync(fd); }
31
+ }
32
+
33
+ // Embedded verbatim in the dependency-free notify script. Read ONLY the endpoint
34
+ // env var; never credentials. Hash paths/session IDs and discard the hook body.
35
+ function captureClaudeBackend(trackerDir, payload, env = process.env) {
36
+ const fs = require("node:fs");
37
+ const path = require("node:path");
38
+ const crypto = require("node:crypto");
39
+ const event = payload?.hook_event_name;
40
+ if (!["SessionStart", "Stop", "SessionEnd"].includes(event)) return;
41
+ if (typeof payload.transcript_path !== "string" || typeof payload.session_id !== "string" || !payload.session_id) return;
42
+ const identity = backendFromUrl(env.ANTHROPIC_BASE_URL);
43
+ const dir = path.join(trackerDir, "claude-backends");
44
+ const file = crypto.createHash("sha256").update(path.resolve(payload.transcript_path)).digest("hex");
45
+ const session = crypto.createHash("sha256").update(String(payload.session_id)).digest("hex");
46
+ let stat;
47
+ try { stat = fs.statSync(payload.transcript_path); } catch {
48
+ if (event !== "SessionStart") return;
49
+ }
50
+ const observation = { event, session, inode: stat?.ino || 0, offset: stat?.size || 0, ...identity };
51
+ const anchorPath = path.join(dir, file + ".current.json");
52
+ let previous;
53
+ try { previous = JSON.parse(fs.readFileSync(anchorPath, "utf8")); } catch { /* first observation */ }
54
+ if (event === "Stop" && previous?.session === session && previous.event !== "SessionEnd"
55
+ && previous.backend_origin && previous.backend_origin === identity.backend_origin
56
+ && previous.offset < observation.offset) {
57
+ observation.range_hash = hashClaudeRange(payload.transcript_path, previous.offset, observation.offset);
58
+ }
59
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ fs.appendFileSync(path.join(dir, file + ".jsonl"), JSON.stringify(observation) + "\n", { mode: 0o600 });
61
+ fs.writeFileSync(anchorPath, JSON.stringify(observation), { mode: 0o600 });
62
+ fs.writeFileSync(path.join(trackerDir, "claude-backend-current.json"), JSON.stringify(identity), { mode: 0o600 });
63
+ }
64
+
65
+ function readLines(file) {
66
+ try { return fs.readFileSync(file, "utf8").split("\n").flatMap((line) => {
67
+ try { return [JSON.parse(line)]; } catch { return []; }
68
+ }); } catch { return []; }
69
+ }
70
+
71
+ // Parse shared upgrade metadata once per file revision, rather than once per
72
+ // transcript in a large installation. Failed reads are retried on the next call.
73
+ const legacyMetadataCache = new Map();
74
+ function readLegacyMetadata(file) {
75
+ const fd = fs.openSync(file, "r");
76
+ try {
77
+ // Bind the revision stamp and contents to the same open file, even if the
78
+ // metadata pathname is replaced while we read it.
79
+ const stat = fs.fstatSync(fd);
80
+ const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
81
+ const cached = legacyMetadataCache.get(file);
82
+ if (cached?.stamp === stamp) return cached.value;
83
+ const value = JSON.parse(fs.readFileSync(fd, "utf8"));
84
+ legacyMetadataCache.set(file, { stamp, value });
85
+ if (legacyMetadataCache.size > 8) legacyMetadataCache.delete(legacyMetadataCache.keys().next().value);
86
+ return value;
87
+ } finally { fs.closeSync(fd); }
88
+ }
89
+
90
+ // Sync receives flat paths while session metadata keys ordered cross-root groups.
91
+ // Try the scanner's root order and its reverse, accepting ONLY an exact stored
92
+ // hash. Equal basenames alone never establish shared history.
93
+ function preserveLegacyClaudeGroups(files, trackerDir) {
94
+ let meta;
95
+ try { meta = readLegacyMetadata(path.join(trackerDir, "session.queue.jsonl.meta.json")); } catch { return; }
96
+ const groups = new Map();
97
+ for (const file of files) {
98
+ if (typeof file !== "string" || !file) continue;
99
+ const id = path.basename(file).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1];
100
+ if (!id) continue;
101
+ const group = groups.get(id) || [];
102
+ if (!group.includes(file)) group.push(file);
103
+ groups.set(id, group);
104
+ }
105
+ for (const group of groups.values()) {
106
+ if (group.length < 2) continue;
107
+ for (const ordered of [group, [...group].reverse()]) {
108
+ const key = crypto.createHash("sha256").update(`claude\0${ordered.map((file) => path.resolve(file)).join("\0")}`).digest("hex").slice(0, 24);
109
+ const stats = meta.files?.[key]?.stat_key?.split("|");
110
+ if (!stats || stats.length !== ordered.length) continue;
111
+ ordered.forEach((file, index) => {
112
+ try { preserveLegacyClaudePrefix(file, trackerDir, Number(stats[index].split(":")[0])); }
113
+ catch (error) {
114
+ // Discovery can race a removed/unreadable mirror. Let the parser's
115
+ // normal per-file handling continue with the surviving paths.
116
+ if (!["ENOENT", "EACCES", "EPERM", "ESTALE"].includes(error.code)) throw error;
117
+ }
118
+ });
119
+ break;
120
+ }
121
+ }
122
+ }
123
+
124
+ // Preserve the already-accounted prefix when upgrading. The boundary is frozen
125
+ // before either scanner advances its cache; existing per-record decisions still
126
+ // win. Store only byte counts, file identity and a digest, never transcript text.
127
+ function preserveLegacyClaudePrefix(filePath, trackerDir, offset = 0) {
128
+ const key = crypto.createHash("sha256").update(path.resolve(filePath)).digest("hex");
129
+ const dir = path.join(trackerDir, "claude-backends");
130
+ const boundaryPath = path.join(dir, key + ".legacy.json");
131
+ try { return JSON.parse(fs.readFileSync(boundaryPath, "utf8")); } catch { /* first scan */ }
132
+ const stat = fs.statSync(filePath);
133
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size) offset = 0;
134
+ try {
135
+ const cursor = readLegacyMetadata(path.join(trackerDir, "cursors.json"))?.files?.[filePath];
136
+ if (cursor?.inode === stat.ino && cursor.offset <= stat.size) offset = Math.max(offset, cursor.offset || 0);
137
+ } catch { /* no previously ingested queue */ }
138
+ try {
139
+ const cacheKey = crypto.createHash("sha256").update(`claude\0${path.resolve(filePath)}`).digest("hex").slice(0, 24);
140
+ const meta = readLegacyMetadata(path.join(trackerDir, "session.queue.jsonl.meta.json"));
141
+ const size = Number(meta.files?.[cacheKey]?.stat_key?.split(":")[0]);
142
+ if (size <= stat.size) offset = Math.max(offset, size || 0);
143
+ } catch { /* no previously ingested session */ }
144
+ offset = Math.min(Math.max(0, offset), stat.size);
145
+ const boundary = { inode: stat.ino, offset, hash: hashClaudeRange(filePath, 0, offset) };
146
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ try { fs.writeFileSync(boundaryPath, JSON.stringify(boundary), { mode: 0o600, flag: "wx" }); }
148
+ catch (error) { if (error.code !== "EEXIST") throw error; }
149
+ return JSON.parse(fs.readFileSync(boundaryPath, "utf8"));
150
+ }
151
+
152
+ function createBackendResolver(filePath, trackerDir, { legacyOffset = 0 } = {}) {
153
+ const key = crypto.createHash("sha256").update(path.resolve(filePath)).digest("hex");
154
+ const dir = path.join(trackerDir, "claude-backends");
155
+ const ledger = path.join(dir, key + ".usage.jsonl");
156
+ const decisions = new Map();
157
+ let decisionOffset = 0;
158
+ function refreshDecisions() {
159
+ let fd;
160
+ try {
161
+ fd = fs.openSync(ledger, "r");
162
+ const size = fs.fstatSync(fd).size;
163
+ if (size <= decisionOffset) return;
164
+ const bytes = Buffer.alloc(size - decisionOffset);
165
+ fs.readSync(fd, bytes, 0, bytes.length, decisionOffset);
166
+ // Only consume complete append records, including another scanner's.
167
+ const boundary = bytes.lastIndexOf(10) + 1;
168
+ for (const line of bytes.subarray(0, boundary).toString("utf8").split("\n")) {
169
+ try {
170
+ const row = JSON.parse(line);
171
+ if (!decisions.has(row.id)) decisions.set(row.id, row);
172
+ } catch { /* incomplete/corrupt record */ }
173
+ }
174
+ decisionOffset += boundary;
175
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
176
+ finally { if (fd !== undefined) fs.closeSync(fd); }
177
+ }
178
+ refreshDecisions();
179
+ const observations = readLines(path.join(dir, key + ".jsonl"));
180
+ const fileStat = fs.statSync(filePath);
181
+ const inode = fileStat.ino;
182
+ const legacy = preserveLegacyClaudePrefix(filePath, trackerDir, legacyOffset);
183
+ const legacyEnd = legacy.inode === inode && legacy.offset <= fileStat.size
184
+ && hashClaudeRange(filePath, 0, legacy.offset) === legacy.hash ? legacy.offset : 0;
185
+ const intervals = [];
186
+ let previous = null;
187
+ for (const current of observations) {
188
+ if (current.event === "Stop" && previous && previous.event !== "SessionEnd"
189
+ && current.session === previous.session && current.inode === inode
190
+ && (previous.inode === inode || (previous.event === "SessionStart" && previous.offset === 0))
191
+ && current.offset > previous.offset && current.offset <= fileStat.size) {
192
+ const a = backendFromUrl(previous.backend_origin);
193
+ const b = backendFromUrl(current.backend_origin);
194
+ if (a.backend_origin && a.backend_origin === b.backend_origin) {
195
+ intervals.push({ start: previous.offset, end: current.offset, session: current.session, hash: current.range_hash, ...a });
196
+ }
197
+ }
198
+ previous = current;
199
+ }
200
+ // A missed Stop must not bridge multiple turns. Only inspect ranges actually
201
+ // bracketed by observations; no prompts or message bodies leave this function.
202
+ function validInterval(interval) {
203
+ if (interval.valid !== undefined) return interval.valid;
204
+ interval.valid = false;
205
+ const fd = fs.openSync(filePath, "r");
206
+ try {
207
+ const bytes = Buffer.alloc(interval.end - interval.start);
208
+ fs.readSync(fd, bytes, 0, bytes.length, interval.start);
209
+ if (!interval.hash || crypto.createHash("sha256").update(bytes).digest("hex") !== interval.hash) return false;
210
+ let turns = 0;
211
+ for (const line of bytes.toString("utf8").split("\n")) {
212
+ try {
213
+ const row = JSON.parse(line);
214
+ const content = row.message?.content;
215
+ if (row.type === "user" && (typeof content === "string" || content?.some?.((b) => b.type === "text"))) turns++;
216
+ } catch { /* partial boundary / non-JSON */ }
217
+ }
218
+ interval.valid = turns === 1;
219
+ return interval.valid;
220
+ } finally { fs.closeSync(fd); }
221
+ }
222
+ return (obj, start, end) => {
223
+ const id = crypto.createHash("sha256").update(JSON.stringify([
224
+ obj.sessionId, obj.uuid, obj.requestId, obj.message?.id, obj.timestamp, start, end,
225
+ ])).digest("hex");
226
+ const saved = decisions.get(id);
227
+ if (saved) return saved.legacy ? null : backendFromUrl(saved.backend_origin);
228
+ const session = typeof obj.sessionId === "string"
229
+ ? crypto.createHash("sha256").update(obj.sessionId).digest("hex") : null;
230
+ const matches = intervals.filter((i) => i.session === session && start >= i.start && end <= i.end && validInterval(i));
231
+ const identity = end <= legacyEnd ? { legacy: true } : matches.length === 1 ? backendFromUrl(matches[0].backend_origin) : { backend: "unknown" };
232
+ // First ingestion wins, including unknown. Late hooks cannot relabel usage.
233
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
234
+ fs.appendFileSync(ledger, JSON.stringify({ id, ...identity }) + "\n", { mode: 0o600 });
235
+ // Sync and session analytics can run concurrently. O_APPEND orders these
236
+ // small records; read that order so the first durable decision always wins.
237
+ refreshDecisions();
238
+ return decisions.get(id)?.legacy ? null : backendFromUrl(decisions.get(id)?.backend_origin);
239
+ };
240
+ }
241
+
242
+ // Byte offsets, including CRLF and multibyte text, must agree with hook stat sizes.
243
+ async function* claudeLines(filePath, start = 0, end = undefined) {
244
+ const input = fs.createReadStream(filePath, { start, ...(end > start ? { end: end - 1 } : {}) });
245
+ let pending = Buffer.alloc(0);
246
+ let offset = start;
247
+ for await (const chunk of input) {
248
+ pending = Buffer.concat([pending, chunk]);
249
+ let newline;
250
+ while ((newline = pending.indexOf(10)) !== -1) {
251
+ const next = offset + newline + 1;
252
+ yield { line: pending.subarray(0, newline).toString("utf8"), start: offset, end: next };
253
+ offset = next;
254
+ pending = pending.subarray(newline + 1);
255
+ }
256
+ }
257
+ // Preserve complete legacy files without a final newline; defer partial JSON.
258
+ if (pending.length) {
259
+ try { JSON.parse(pending.toString("utf8")); } catch { return; }
260
+ yield { line: pending.toString("utf8"), start: offset, end: offset + pending.length };
261
+ }
262
+ }
263
+
264
+ const TOKEN_FIELDS = ["input_tokens", "cached_input_tokens", "cache_creation_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"];
265
+ function mergeBackendUsage(target, delta) {
266
+ if (!Array.isArray(delta?.backend_usage)) return;
267
+ target.backend_usage ||= [];
268
+ for (const part of delta.backend_usage) {
269
+ const identity = backendFromUrl(part.backend_origin);
270
+ let row = target.backend_usage.find((r) => r.backend === identity.backend && r.backend_origin === identity.backend_origin);
271
+ if (!row) { row = { ...identity }; target.backend_usage.push(row); }
272
+ for (const field of TOKEN_FIELDS) row[field] = (row[field] || 0) + (Number(part[field]) || 0);
273
+ }
274
+ }
275
+ function hasUnknownBackendCost(row) {
276
+ return row?.source === "claude" && row.backend_usage?.some((p) => p.backend !== "anthropic" && p.total_tokens > 0);
277
+ }
278
+ function readCustomBackend({ home, env = process.env }) {
279
+ // Current configuration is ONLY for the limits card, never usage attribution.
280
+ let configured = env.ANTHROPIC_BASE_URL;
281
+ if (!configured) {
282
+ try { configured = JSON.parse(fs.readFileSync(path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "settings.json"), "utf8"))?.env?.ANTHROPIC_BASE_URL; } catch { /* absent */ }
283
+ }
284
+ if (configured) return backendFromUrl(configured);
285
+ try {
286
+ const observed = JSON.parse(fs.readFileSync(path.join(home, ".tokentracker", "tracker", "claude-backend-current.json"), "utf8"));
287
+ return backendFromUrl(observed.backend_origin);
288
+ } catch { return { backend: "unknown" }; }
289
+ }
290
+
291
+ module.exports = { preserveLegacyClaudeGroups, preserveLegacyClaudePrefix, hashClaudeRange, claudeLines, backendFromUrl, captureClaudeBackend, createBackendResolver, mergeBackendUsage, hasUnknownBackendCost, readCustomBackend, TOKEN_FIELDS };
@@ -5,7 +5,7 @@ const { ensureDir, readJson, writeJson } = require("./fs");
5
5
  const { buildNotifyCommand, notifyIdentity } = require("./notify-command");
6
6
 
7
7
  const DEFAULT_EVENT = "SessionEnd";
8
- const CLAUDE_USAGE_EVENTS = ["Stop", DEFAULT_EVENT];
8
+ const CLAUDE_USAGE_EVENTS = ["SessionStart", "Stop", DEFAULT_EVENT];
9
9
 
10
10
  async function upsertClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
11
11
  return upsertClaudeHooks({ settingsPath, hookCommand, events: [event] });
@@ -97,7 +97,9 @@ function extractCursorAuth({ home, platform, env, deps } = {}) {
97
97
  return null;
98
98
  }
99
99
  const jwt = readCursorAccessTokenFromStateDb(stateDbPath, deps);
100
- if (!jwt || jwt.length < 10) return null;
100
+ // Encrypted Cursor Safe Storage values must not be treated as JWTs and must
101
+ // never trigger OS keychain decryption (see #1029 revert / #1043).
102
+ if (!jwt || jwt.length < 10 || jwt.startsWith('cursor-safe-storage:')) return null;
101
103
 
102
104
  // 2. Extract userId — try cli-config.json first, fall back to JWT decode
103
105
  let userId = extractUserIdFromCliConfig(cliConfigPath);
@@ -16,6 +16,7 @@
16
16
 
17
17
  const fs = require("node:fs");
18
18
  const path = require("node:path");
19
+ const { parse: parseToml } = require("smol-toml");
19
20
 
20
21
  const DEVIN_PLAN_STATUS_URL =
21
22
  "https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetPlanStatus";
@@ -28,25 +29,26 @@ function isNonEmptyString(value) {
28
29
  return typeof value === "string" && value.trim().length > 0;
29
30
  }
30
31
 
31
- // Minimal root-level TOML reader for the CLI-written credentials file: bare
32
- // `key = value` lines before the first `[section]`, nothing else.
33
- function parseDevinCredentialsToml(raw) {
34
- const fields = {};
35
- for (const line of String(raw).split(/\r?\n/)) {
36
- const trimmed = line.trim();
37
- if (!trimmed || trimmed.startsWith("#")) continue;
38
- if (trimmed.startsWith("[")) break;
39
- const match = /^([A-Za-z0-9_]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))\s*(?:#.*)?$/.exec(
40
- trimmed,
41
- );
42
- if (!match) continue;
43
- const [, key, doubleQuoted, singleQuoted, bare] = match;
44
- fields[key] = doubleQuoted ?? singleQuoted ?? bare ?? "";
45
- }
46
- return fields;
32
+ // Local sign-in problems are persistent configuration errors, not transient
33
+ // fetch failures: the aggregator's disk-cache fallback must never mask them,
34
+ // so they carry a distinctive code the assembly can gate on.
35
+ function credentialsError(message) {
36
+ const error = new Error(message);
37
+ error.code = "CREDENTIALS_INVALID";
38
+ return error;
47
39
  }
48
40
 
49
- // Returns { apiKey } or null when no sign-in exists (no home, or the file is
41
+ // A credential-file revision, not a token fingerprint or account claim. Both
42
+ // in-place edits (ctime/mtime) and atomic replacements (device/inode) invalidate
43
+ // old windows, including across restarts. Never persist the token or its hash.
44
+ function devinCredentialIdentity(filePath, stat) {
45
+ return JSON.stringify([
46
+ path.resolve(filePath),
47
+ ...[stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String),
48
+ ]);
49
+ }
50
+
51
+ // Returns { apiKey, cacheIdentity } or null when no sign-in exists (no home, or the file is
50
52
  // absent — ENOENT only). Any other read failure or a mangled file throws an
51
53
  // owned, actionable error with no path or filesystem details, so a broken
52
54
  // sign-in can never masquerade as "not configured".
@@ -64,20 +66,42 @@ function readDevinCredentials({ home, env = process.env } = {}) {
64
66
  ? path.join(dir, "devin", "credentials.toml")
65
67
  : path.join(dir, ".local", "share", "devin", "credentials.toml");
66
68
  let raw;
69
+ let descriptor;
70
+ let cacheIdentity = null;
67
71
  try {
68
- raw = fs.readFileSync(base, "utf8");
72
+ // Read the token and revision from the same opened file, so an atomic
73
+ // replacement cannot stamp the old token with the new file's revision.
74
+ descriptor = fs.openSync(base, "r");
75
+ const before = fs.fstatSync(descriptor, { bigint: true });
76
+ if (!before.isFile()) throw new Error("Not a credentials file");
77
+ raw = fs.readFileSync(descriptor, "utf8");
78
+ const after = fs.fstatSync(descriptor, { bigint: true });
79
+ const identity = devinCredentialIdentity(base, before);
80
+ if (identity === devinCredentialIdentity(base, after)) cacheIdentity = identity;
69
81
  } catch (error) {
70
82
  if (error && error.code === "ENOENT") return null;
71
- throw new Error(
83
+ throw credentialsError(
72
84
  "Could not read the Devin CLI credentials file — check its permissions or run `devin auth login` to sign in again.",
73
85
  );
86
+ } finally {
87
+ if (descriptor !== undefined) fs.closeSync(descriptor);
88
+ }
89
+ // Decode the credential document, then select only the root-level auth
90
+ // fields, like the other providers' structured credential readers.
91
+ let fields;
92
+ try {
93
+ fields = parseToml(raw);
94
+ } catch {
95
+ // Parser errors may quote credentials; expose only an owned message.
96
+ throw credentialsError(
97
+ "Could not parse the Devin CLI credentials file — run `devin auth login` to sign in again.",
98
+ );
74
99
  }
75
- const fields = parseDevinCredentialsToml(raw);
76
100
  const apiKey = isNonEmptyString(fields.windsurf_api_key)
77
101
  ? fields.windsurf_api_key.trim()
78
102
  : null;
79
103
  if (!apiKey) {
80
- throw new Error(
104
+ throw credentialsError(
81
105
  "Devin credentials file has no sign-in token — run `devin auth login`.",
82
106
  );
83
107
  }
@@ -85,11 +109,20 @@ function readDevinCredentials({ home, env = process.env } = {}) {
85
109
  ? fields.api_server_url.trim().replace(/\/+$/, "")
86
110
  : null;
87
111
  if (apiServerUrl && apiServerUrl !== DEVIN_API_ORIGIN) {
88
- throw new Error(
112
+ throw credentialsError(
89
113
  "Devin credentials point at a custom api_server_url, which this quota provider does not support.",
90
114
  );
91
115
  }
92
- return { apiKey };
116
+ return { apiKey, cacheIdentity };
117
+ }
118
+
119
+ function resolveDevinCacheIdentity(options) {
120
+ try {
121
+ return readDevinCredentials(options)?.cacheIdentity || null;
122
+ } catch {
123
+ // Unreadable, missing or invalid current credentials must fail closed.
124
+ return null;
125
+ }
93
126
  }
94
127
 
95
128
  function malformed(field) {
@@ -226,9 +259,14 @@ async function fetchDevinLimits({ home, env = process.env, enabled = false, fetc
226
259
  throw error;
227
260
  }
228
261
  if (!response?.ok) {
229
- throw new Error(
262
+ const error = new Error(
230
263
  `Devin quota request was rejected (HTTP ${response?.status ?? "?"}).`,
231
264
  );
265
+ // The aggregate's disk-cache fallback only covers transient failures:
266
+ // carrying the status lets it tell a 429/5xx blip from an ambiguous or
267
+ // persistent rejection like HTTP 400 (docs/devin-limits.md).
268
+ if (typeof response?.status === "number") error.statusCode = response.status;
269
+ throw error;
232
270
  }
233
271
  let body;
234
272
  try {
@@ -243,7 +281,9 @@ async function fetchDevinLimits({ home, env = process.env, enabled = false, fetc
243
281
  plan_label: windows.plan_label,
244
282
  primary_window: windows.primary_window,
245
283
  secondary_window: windows.secondary_window,
284
+ // Internal: the aggregate strips this before returning its payload.
285
+ cache_identity: credentials.cacheIdentity,
246
286
  };
247
287
  }
248
288
 
249
- module.exports = { fetchDevinLimits };
289
+ module.exports = { fetchDevinLimits, resolveDevinCacheIdentity };
@@ -20,6 +20,7 @@ const { probeOpenclawSessionPluginState } = require("./openclaw-session-plugin")
20
20
  const { probeGrokHookState } = require("./grok-hook");
21
21
  const { resolveTrackerPaths } = require("./tracker-paths");
22
22
  const { resolveDeviceTokenPresence } = require("./cloud-device-token");
23
+ const { inspectTraeCnAuth } = require("./trae-cn-config");
23
24
  const wsl = require("./wsl-probe");
24
25
  // TASK-011: Kiro paths inlined here to avoid pulling the ~4000-line
25
26
  // rollout module on every `tokentracker status` / `diagnostics` call.
@@ -195,6 +196,7 @@ async function collectTrackerDiagnostics({
195
196
  ? new Date(uploadThrottle.lastSuccessMs).toISOString()
196
197
  : null;
197
198
  const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
199
+ const traeCn = inspectTraeCnAuth({ home });
198
200
 
199
201
  return {
200
202
  ok: true,
@@ -233,6 +235,7 @@ async function collectTrackerDiagnostics({
233
235
  merge_policy:
234
236
  "Kiro IDE and Kiro CLI both emit source='kiro' in queue.jsonl so token, cost, heatmap, and leaderboard aggregations merge transparently. Use this block to distinguish sub-path contributions.",
235
237
  },
238
+ trae_cn: { ...traeCn, storage_path: redactValue(traeCn.storage_path, home) },
236
239
  scan_roots: {
237
240
  codex: describeScanRoots(scanRoots.codex),
238
241
  claude: describeScanRoots(scanRoots.claude),
package/src/lib/doctor.js CHANGED
@@ -293,6 +293,19 @@ async function checkNetwork({ baseUrl, fetch }) {
293
293
 
294
294
  function buildDiagnosticsChecks(diagnostics) {
295
295
  const checks = [];
296
+ const traeCn = diagnostics?.trae_cn;
297
+ if (traeCn) {
298
+ const { TRAE_CN_AUTH_HELP } = require("./trae-cn-config");
299
+ checks.push({
300
+ id: "trae_cn.auth",
301
+ status: !traeCn.usage_opt_in || traeCn.auth === "readable" ? "ok" : "warn",
302
+ detail: !traeCn.usage_opt_in
303
+ ? "usage sync off (TOKENTRACKER_TRAE_CN_USAGE=1 opts in); auth not read; no TRAE request made"
304
+ : `local auth ${traeCn.auth}; API access not checked (${traeCn.storage_path || "storage.json not found"}). ${TRAE_CN_AUTH_HELP}`,
305
+ critical: false,
306
+ meta: traeCn,
307
+ });
308
+ }
296
309
  const notify = diagnostics?.notify || {};
297
310
  const notifyConfigured = Boolean(
298
311
  notify.codex_notify_configured ||
@@ -1,3 +1,4 @@
1
+ const { hasUnknownBackendCost } = require("./claude-backend");
1
2
  const fs = require("node:fs");
2
3
  const os = require("node:os");
3
4
  const path = require("node:path");
@@ -2942,6 +2943,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2942
2943
  if (!sa.models.has(mdl))
2943
2944
  sa.models.set(mdl, { model: mdl, model_id: mdl, pricing: getModelPricingInfo(mdl, { source: src }), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0, total_cost_usd: "0" } });
2944
2945
  const ma = sa.models.get(mdl);
2946
+ if (hasUnknownBackendCost(row)) ma.pricing = getModelPricingInfo(mdl, row);
2945
2947
  const reportedCost = ["grok", "cline"].includes(src) && Number(row.total_cost_usd) > 0;
2946
2948
  const costSource = reportedCost ? "provider_reported" : "model_pricing";
2947
2949
  ma.cost_source = ma.cost_source && ma.cost_source !== costSource ? "mixed" : costSource;