tokentracker-cli 0.93.6 → 0.94.0

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 (133) hide show
  1. package/README.de.md +1 -1
  2. package/README.ja.md +1 -1
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/dashboard/dist/assets/{AchievementBadge-DsAclDw6.js → AchievementBadge-DPqwk2zI.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-D__EwC45.js → AchievementsPage-CELHoMQt.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-BAZNM3Cn.js → AchievementsSection-DdAC0VCb.js} +1 -1
  9. package/dashboard/dist/assets/BadgeDetailModal-Dd4I1SpM.js +1 -0
  10. package/dashboard/dist/assets/{BotAnimated-CwrScgrI.js → BotAnimated-DL2-4mnm.js} +1 -1
  11. package/dashboard/dist/assets/{Card-D7GmLoqP.js → Card-B_MpSRse.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-BEXgb5J_.js → ClawdAnimated-BLZZ4rG6.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-B9Mn3Qt2.js → CommandPalette-B-vfdIbH.js} +1 -1
  14. package/dashboard/dist/assets/CommunityStatsModal-aAico565.js +1 -0
  15. package/dashboard/dist/assets/ConfirmModal-DLnRwUXC.js +1 -0
  16. package/dashboard/dist/assets/{Controls-DxnaHBzv.js → Controls-FZ8wEN5w.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-BByRBsbt.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-4yTYf29M.js → DevicePage-D2IzPc5o.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-BbderFCY.js → DialogClose-BaqQaUUk.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-BUrh0Jod.js +1 -0
  21. package/dashboard/dist/assets/DialogTitle-DrbKjtuS.js +1 -0
  22. package/dashboard/dist/assets/{FadeIn-BFLojPGG.js → FadeIn-GddKDE8i.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-CPBNn61n.js → HeaderGithubStar-DUu-Z5mE.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-DbxnLj8e.js → HoverTooltip-BmDkY35A.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-CfPtXAAF.js → IpCheckPage-CGJyqWk2.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-CocswfFj.js → LandingPage-Bd91ALUj.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-iZLxxZX-.js → LeaderboardAvatar-BGGUwXFd.js} +1 -1
  28. package/dashboard/dist/assets/LeaderboardPage-BMEqHwif.js +2 -0
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-BWu8jbBW.js → LeaderboardProfileModal-BSweJqm2.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-B77gOTzr.js → LeaderboardProfilePage-CRrI3RFz.js} +1 -1
  31. package/dashboard/dist/assets/LimitsPage-XIRDv46K.js +5 -0
  32. package/dashboard/dist/assets/LocalOnlyNotice-CCD-lGWG.js +1 -0
  33. package/dashboard/dist/assets/{LoginCard-D592L-fh.js → LoginCard-lDuqSv4f.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-C7Qm7CVf.js → LoginModal-DStt5VXN.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DkYw5ZQK.js → LoginPage-dmVPSL72.js} +1 -1
  36. package/dashboard/dist/assets/PetPage-ybcZRctF.js +1 -0
  37. package/dashboard/dist/assets/PopoverPopup-Dar_UjxF.js +1 -0
  38. package/dashboard/dist/assets/{ProviderIcon-d0_vhIkS.js → ProviderIcon-Ct9tILpz.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-UDNlpFVu.js → ResetPasswordPage-B4yLh5zx.js} +1 -1
  40. package/dashboard/dist/assets/SegmentedControl-BOE1CEzq.js +1 -0
  41. package/dashboard/dist/assets/{Select-Z2xTQ55f.js → Select-D-bSh7Zz.js} +1 -1
  42. package/dashboard/dist/assets/SelectItemText-DMYjXXq4.js +1 -0
  43. package/dashboard/dist/assets/{ServiceStatusPage-Cfr-QDZ7.js → ServiceStatusPage-CaleS-6_.js} +1 -1
  44. package/dashboard/dist/assets/SessionsPage-DOaY6_E9.js +2 -0
  45. package/dashboard/dist/assets/SettingsPage-iJH0Yyah.js +2 -0
  46. package/dashboard/dist/assets/SkillsPage-BkzJOBLk.js +1 -0
  47. package/dashboard/dist/assets/{TokenGalaxy-CjAY6rey.js → TokenGalaxy-CuXKoMPg.js} +1 -1
  48. package/dashboard/dist/assets/ToolbarRootContext-Dx5GB7F5.js +1 -0
  49. package/dashboard/dist/assets/{TrendMonitor-DAAlILwJ.js → TrendMonitor-BuKmsznP.js} +1 -1
  50. package/dashboard/dist/assets/WidgetsPage-DUaIyGtu.js +1 -0
  51. package/dashboard/dist/assets/{WrappedPage-ByYzOeZ-.js → WrappedPage-BzqlcAUC.js} +1 -1
  52. package/dashboard/dist/assets/__vite-browser-external-BHz2N7Ih.css +1 -0
  53. package/dashboard/dist/assets/{__vite-browser-external-HdCplPKs.js → __vite-browser-external-CVIGknXl.js} +3 -3
  54. package/dashboard/dist/assets/{agent-logos-BW-ajJ7p.js → agent-logos-BdWWxza6.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-CA5IJkqo.js → arrow-up-right-CANSzmm-.js} +1 -1
  56. package/dashboard/dist/assets/{check-BBjF3bSm.js → check-BnJpzXRZ.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-right-DCvGp9iJ.js → chevron-right-DJm0mbG0.js} +1 -1
  58. package/dashboard/dist/assets/{useScrollLock-BUVUIPTX.js → composite-Ccj6KQmh.js} +1 -1
  59. package/dashboard/dist/assets/{copy-ahF8LGeu.js → copy-C2Yc8Evu.js} +1 -1
  60. package/dashboard/dist/assets/{download-DBm8dmvt.js → download-B52JTk6Y.js} +1 -1
  61. package/dashboard/dist/assets/{external-link-C1sJSL7J.js → external-link-DayTwZx-.js} +1 -1
  62. package/dashboard/dist/assets/{flame-B6xukEYd.js → flame-B8GEX2yT.js} +1 -1
  63. package/dashboard/dist/assets/{format-tokens-6TqXlT3q.js → format-tokens-mhkbohkb.js} +1 -1
  64. package/dashboard/dist/assets/{icons-pGe_pInY.js → icons-o8bPXqRP.js} +1 -1
  65. package/dashboard/dist/assets/{index-I9O8xChC.js → index-Bp0ZHDtz.js} +1 -1
  66. package/dashboard/dist/assets/{index-Chb8mb2X.js → index-CK_CduLm.js} +1 -1
  67. package/dashboard/dist/assets/{index-RPV0zdZ9.js → index-MBVWUKpA.js} +1 -1
  68. package/dashboard/dist/assets/{info-DBfJv0yu.js → info-D94QM_vX.js} +1 -1
  69. package/dashboard/dist/assets/{limits-providers-DPQsdVq5.js → limits-providers-BLzMyCiy.js} +1 -1
  70. package/dashboard/dist/assets/{link-2-DNEARrPY.js → link-2-CqBmFaDI.js} +1 -1
  71. package/dashboard/dist/assets/{loader-circle-BCFHUr8X.js → loader-circle-SaxUpDR6.js} +1 -1
  72. package/dashboard/dist/assets/{maximize-2-1EmlA2R-.js → maximize-2-BoXqARks.js} +1 -1
  73. package/dashboard/dist/assets/{modifiers.esm-DGXFSuEq.js → modifiers.esm-Conh_-BD.js} +1 -1
  74. package/dashboard/dist/assets/{monitor-smartphone-DsPPhvsT.js → monitor-smartphone-D6qLR9qd.js} +1 -1
  75. package/dashboard/dist/assets/plus-D51mjIc2.js +1 -0
  76. package/dashboard/dist/assets/{provider-display-C20LtBC5.js → provider-display-DpHPjd_s.js} +1 -1
  77. package/dashboard/dist/assets/{react-B4FPJgs_.js → react-D_AcI466.js} +1 -1
  78. package/dashboard/dist/assets/{search-mkIFPKzW.js → search-CwLcNdvZ.js} +1 -1
  79. package/dashboard/dist/assets/{skills-api-D24vlu2H.js → skills-api-Dqi7Mjgo.js} +1 -1
  80. package/dashboard/dist/assets/{sortable.esm-BNDcP0sW.js → sortable.esm-BPDlUxPb.js} +1 -1
  81. package/dashboard/dist/assets/store-ikeRIRGd.js +1 -0
  82. package/dashboard/dist/assets/{terminal-BxVMmvEx.js → terminal-BfeX3L5T.js} +1 -1
  83. package/dashboard/dist/assets/{trash-2-crt9lDvj.js → trash-2-5_Qlp1k7.js} +1 -1
  84. package/dashboard/dist/assets/{use-community-stats-CTB_uHWV.js → use-community-stats-D90Fy2_j.js} +1 -1
  85. package/dashboard/dist/assets/use-limits-display-prefs-D4BYFaa-.js +1 -0
  86. package/dashboard/dist/assets/{use-ordered-list-BVNb5QWU.js → use-ordered-list-DJxLNdXY.js} +1 -1
  87. package/dashboard/dist/assets/use-reduced-motion-yE3T3cat.js +1 -0
  88. package/dashboard/dist/assets/{use-session-efficiency-pref-Di8nY6Lr.js → use-session-efficiency-pref-CAX_GqXc.js} +1 -1
  89. package/dashboard/dist/assets/{use-transform-Dx9C2nBC.js → use-transform-CeW6Z5R0.js} +1 -1
  90. package/dashboard/dist/assets/use-usage-limits-5HP8k9a3.js +1 -0
  91. package/dashboard/dist/assets/{useCurrency-7dkDciam.js → useCurrency-BarGeFyK.js} +1 -1
  92. package/dashboard/dist/assets/{useTokenFormat-DFSIAsAb.js → useTokenFormat-DUJ9iT9H.js} +1 -1
  93. package/dashboard/dist/assets/{zap-Png-VJSP.js → zap--m0IWoI3.js} +1 -1
  94. package/dashboard/dist/index.html +2 -2
  95. package/dashboard/dist/ip-check.html +2 -2
  96. package/dashboard/dist/leaderboard.html +2 -2
  97. package/dashboard/dist/share.html +2 -2
  98. package/package.json +1 -1
  99. package/src/commands/status.js +3 -1
  100. package/src/commands/sync.js +8 -3
  101. package/src/lib/codex-rollout-parser.js +35 -4
  102. package/src/lib/cursor-config.js +182 -1
  103. package/src/lib/fs.js +12 -3
  104. package/src/lib/local-api.js +57 -0
  105. package/src/lib/pricing/seed-snapshot.json +1 -1
  106. package/src/lib/rollout.js +758 -195
  107. package/src/lib/session-analytics.js +205 -10
  108. package/src/lib/subscription-manager.js +293 -0
  109. package/src/lib/telemetry.js +2 -2
  110. package/src/lib/usage-limits.js +55 -14
  111. package/dashboard/dist/assets/BadgeDetailModal-Dgeer5oB.js +0 -1
  112. package/dashboard/dist/assets/CommunityStatsModal-eJ_a1FMd.js +0 -1
  113. package/dashboard/dist/assets/ConfirmModal-CumOr2J1.js +0 -1
  114. package/dashboard/dist/assets/DashboardPage-BpRRXCRr.js +0 -1
  115. package/dashboard/dist/assets/DialogDescription-C5Ujj254.js +0 -1
  116. package/dashboard/dist/assets/DialogTitle-6epeLIn0.js +0 -1
  117. package/dashboard/dist/assets/LeaderboardPage-yqauB4Q1.js +0 -2
  118. package/dashboard/dist/assets/LimitsPage-C9NeIcft.js +0 -5
  119. package/dashboard/dist/assets/LocalOnlyNotice-HPXWU8TT.js +0 -1
  120. package/dashboard/dist/assets/PetPage-BOhvAJp4.js +0 -1
  121. package/dashboard/dist/assets/PopoverPopup-BK9aqQn1.js +0 -1
  122. package/dashboard/dist/assets/SegmentedControl-DE5m1pTf.js +0 -1
  123. package/dashboard/dist/assets/SelectItemText-Daq-XRZg.js +0 -1
  124. package/dashboard/dist/assets/SessionsPage-nHPj6IAt.js +0 -2
  125. package/dashboard/dist/assets/SettingsPage-NvxUG_9m.js +0 -2
  126. package/dashboard/dist/assets/SkillsPage-zFbZa42f.js +0 -1
  127. package/dashboard/dist/assets/ToolbarRootContext-DnC8LT_8.js +0 -1
  128. package/dashboard/dist/assets/WidgetsPage-CXsfi-mq.js +0 -1
  129. package/dashboard/dist/assets/__vite-browser-external-CiR9-j4L.css +0 -1
  130. package/dashboard/dist/assets/store-B2nqaAqP.js +0 -1
  131. package/dashboard/dist/assets/use-limits-display-prefs-DXXZpjGY.js +0 -1
  132. package/dashboard/dist/assets/use-reduced-motion-aSaQtoJu.js +0 -1
  133. package/dashboard/dist/assets/use-usage-limits-D9vQFB1H.js +0 -1
@@ -54,7 +54,9 @@ const wsl = require("./wsl-probe");
54
54
  // stay valid while Grok entries appear on the next full rebuild.
55
55
  // v11 groups native/WSL copies of one logical Claude/Codex session and scans
56
56
  // their union, deduping shared records while retaining divergent tails.
57
- const SIDECAR_VERSION = 11;
57
+ // v12 retains Codex parent/subagent metadata locally and derives exact child
58
+ // token totals from observed child rollouts instead of spawn-call estimates.
59
+ const SIDECAR_VERSION = 12;
58
60
  const EDIT_TOOLS = new Set([
59
61
  "apply_patch",
60
62
  "edit",
@@ -511,6 +513,18 @@ async function scanCodexSession(filePath) {
511
513
  version: SIDECAR_VERSION,
512
514
  session_hash: sessionHash("codex", parsed.sessionId || primaryFilePath),
513
515
  session_id: parsed.sessionId || null,
516
+ // Local-only lineage. Codex v2 normally writes forked_from_id; keep
517
+ // parent_thread_id as a compatibility fallback, but reject disagreement.
518
+ parent_session_id: parsed.forkedFromId || parsed.parentThreadId || null,
519
+ parent_link_conflict: Boolean(
520
+ parsed.forkedFromId
521
+ && parsed.parentThreadId
522
+ && parsed.forkedFromId !== parsed.parentThreadId
523
+ ),
524
+ forked_from_id: parsed.forkedFromId || null,
525
+ thread_source: typeof parsed.threadSource === "string" ? parsed.threadSource : null,
526
+ agent_nickname: typeof parsed.agentNickname === "string" ? parsed.agentNickname : null,
527
+ agent_role: typeof parsed.agentRole === "string" ? parsed.agentRole : null,
514
528
  // Local-only: stripped in summarizeSessions before any cloud/CSV export.
515
529
  title,
516
530
  source: "codex",
@@ -829,7 +843,14 @@ function providerRoots(home, providerDir, env, deps = {}) {
829
843
  const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
830
844
  const roots = [];
831
845
  if (platform !== "win32" || wsl.shouldProbeNative(env)) {
832
- roots.push(path.join(home, providerDir));
846
+ const useProcessCodexHome = providerDir === ".codex"
847
+ && path.resolve(home) === path.resolve(homedir())
848
+ && typeof env?.CODEX_HOME === "string"
849
+ && env.CODEX_HOME.trim();
850
+ const nativeRoot = useProcessCodexHome
851
+ ? path.resolve(env.CODEX_HOME.trim())
852
+ : path.join(home, providerDir);
853
+ roots.push(nativeRoot);
833
854
  }
834
855
  // Only the machine's own home has a WSL sibling worth probing.
835
856
  // discoverWslHome resolves \\wsl$ independently of `home`, so probing for an
@@ -1029,10 +1050,15 @@ function sessionFileStatKey(filePath) {
1029
1050
  const codexTitleIndexCache = new Map();
1030
1051
 
1031
1052
  function codexTitleIndexPathFor(filePath) {
1032
- const parts = path.resolve(filePath).split(path.sep);
1033
- const idx = parts.lastIndexOf(".codex");
1034
- if (idx === -1) return null;
1035
- return [...parts.slice(0, idx + 1), "session_index.jsonl"].join(path.sep);
1053
+ let current = path.dirname(path.resolve(filePath));
1054
+ while (true) {
1055
+ if (["sessions", "archived_sessions"].includes(path.basename(current))) {
1056
+ return path.join(path.dirname(current), "session_index.jsonl");
1057
+ }
1058
+ const parent = path.dirname(current);
1059
+ if (parent === current) return null;
1060
+ current = parent;
1061
+ }
1036
1062
  }
1037
1063
 
1038
1064
  function loadCodexTitleIndex(filePath) {
@@ -1239,7 +1265,9 @@ function withinDayRange(row, from, to) {
1239
1265
  }
1240
1266
 
1241
1267
  function summarizeSessions(sessions, { from = "", to = "", includeSessions = true } = {}) {
1242
- const filtered = (sessions || []).filter((row) => withinDayRange(row, from, to));
1268
+ const filtered = annotateCodexThreadUsage((sessions || [])
1269
+ .filter((row) => withinDayRange(row, from, to))
1270
+ .map((row) => ({ ...row })));
1243
1271
  const byModel = new Map();
1244
1272
  const subagents = new Map();
1245
1273
  for (const row of filtered) {
@@ -1268,6 +1296,26 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
1268
1296
  agg.edit_cost_usd += finite(row.cost_usd);
1269
1297
  }
1270
1298
  byModel.set(key, agg);
1299
+ if (row.source === "codex" && row.parent_session_hash) {
1300
+ const name = row.agent_role || row.agent_nickname || "subagent";
1301
+ const sub = subagents.get(name) || {
1302
+ name,
1303
+ calls: 0,
1304
+ sessions: 0,
1305
+ total_tokens: 0,
1306
+ cost_usd: 0,
1307
+ attribution: "observed-child-session",
1308
+ };
1309
+ sub.calls += 1;
1310
+ sub.sessions += 1;
1311
+ sub.total_tokens += finite(row.total_tokens);
1312
+ sub.cost_usd += finite(row.cost_usd);
1313
+ subagents.set(name, sub);
1314
+ continue;
1315
+ }
1316
+ // Codex child rollouts are first-class rows. A spawn call in the parent is
1317
+ // a control-plane event, not token usage, so do not mix in an estimate.
1318
+ if (row.source === "codex") continue;
1271
1319
  for (const [name, calls] of Object.entries(row.subagent_types || {})) {
1272
1320
  const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
1273
1321
  sub.calls += finite(calls);
@@ -1301,7 +1349,22 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
1301
1349
  // Git attribution and the local-only session browser, but never leave the
1302
1350
  // Node process through API/CSV payloads.
1303
1351
  sessions: includeSessions
1304
- ? filtered.map(({ project_ref: _projectRef, session_id: _sessionId, title: _title, _cache_key: _cacheKey, ...row }) => row)
1352
+ ? filtered.map(({
1353
+ project_ref: _projectRef,
1354
+ session_id: _sessionId,
1355
+ parent_session_id: _parentSessionId,
1356
+ forked_from_id: _forkedFromId,
1357
+ parent_session_hash: _parentSessionHash,
1358
+ root_session_hash: _rootSessionHash,
1359
+ agent_nickname: _agentNickname,
1360
+ agent_role: _agentRole,
1361
+ thread_source: _threadSource,
1362
+ parent_link_conflict: _parentLinkConflict,
1363
+ orphaned_subagent: _orphanedSubagent,
1364
+ title: _title,
1365
+ _cache_key: _cacheKey,
1366
+ ...row
1367
+ }) => row)
1305
1368
  : [],
1306
1369
  session_count: filtered.length,
1307
1370
  summary: {
@@ -1350,6 +1413,14 @@ function toSessionBrowserRow(row) {
1350
1413
  return {
1351
1414
  session_hash: row.session_hash,
1352
1415
  session_id: row.session_id || null,
1416
+ parent_session_id: row.parent_session_id || null,
1417
+ parent_session_hash: row.parent_session_hash || null,
1418
+ root_session_hash: row.root_session_hash || row.session_hash,
1419
+ thread_kind: row.parent_session_id ? "subagent" : "root",
1420
+ agent_nickname: row.agent_nickname || null,
1421
+ agent_role: row.agent_role || null,
1422
+ orphaned_subagent: Boolean(row.orphaned_subagent),
1423
+ parent_link_conflict: Boolean(row.parent_link_conflict),
1353
1424
  title: row.title || null,
1354
1425
  source: row.source,
1355
1426
  project_key: row.project_key,
@@ -1364,7 +1435,15 @@ function toSessionBrowserRow(row) {
1364
1435
  edit_turns: finite(row.edit_turns),
1365
1436
  retry_turns: finite(row.retry_turns),
1366
1437
  subagent_calls: finite(row.subagent_calls),
1438
+ direct_subagent_count: finite(row.direct_subagent_count),
1439
+ descendant_subagent_count: finite(row.descendant_subagent_count),
1440
+ own_total_tokens: finite(row.total_tokens),
1441
+ subagent_total_tokens: finite(row.subagent_total_tokens),
1442
+ combined_total_tokens: finite(row.combined_total_tokens || row.total_tokens),
1367
1443
  total_tokens: finite(row.total_tokens),
1444
+ own_cost_usd: finite(row.cost_usd),
1445
+ subagent_cost_usd: finite(row.subagent_cost_usd),
1446
+ combined_cost_usd: finite(row.combined_cost_usd || row.cost_usd),
1368
1447
  cost_usd: finite(row.cost_usd),
1369
1448
  productive: Boolean(row.productive),
1370
1449
  first_pass: Boolean(row.first_pass ?? row.one_shot),
@@ -1396,6 +1475,14 @@ function mergeSessionFragments(rows) {
1396
1475
  cur.productive = Boolean(cur.productive) || Boolean(row.productive);
1397
1476
  cur.active_ms = finite(cur.active_ms) + finite(row.active_ms);
1398
1477
  if (!cur.title && row.title) cur.title = row.title;
1478
+ if (!cur.parent_session_id && row.parent_session_id) cur.parent_session_id = row.parent_session_id;
1479
+ if (cur.parent_session_id && row.parent_session_id && cur.parent_session_id !== row.parent_session_id) {
1480
+ cur.parent_session_id = null;
1481
+ cur.parent_link_conflict = true;
1482
+ }
1483
+ if (!cur.forked_from_id && row.forked_from_id) cur.forked_from_id = row.forked_from_id;
1484
+ if (!cur.agent_nickname && row.agent_nickname) cur.agent_nickname = row.agent_nickname;
1485
+ if (!cur.agent_role && row.agent_role) cur.agent_role = row.agent_role;
1399
1486
  // Representative model/project comes from the busiest fragment (most
1400
1487
  // tokens) so a tiny sidechain cannot overwrite the real coding model.
1401
1488
  if (finite(row.total_tokens) > finite(cur._repr_tokens)) {
@@ -1427,19 +1514,127 @@ function mergeSessionFragments(rows) {
1427
1514
  return merged;
1428
1515
  }
1429
1516
 
1517
+ // Attach exact Codex parent/child usage without changing the authoritative
1518
+ // per-session total_tokens field. Combined fields are derived views only, so
1519
+ // summing total_tokens across all rows still equals the global usage ledger.
1520
+ function annotateCodexThreadUsage(rows) {
1521
+ const byId = new Map();
1522
+ const childrenById = new Map();
1523
+ const linkedParentById = new Map();
1524
+
1525
+ for (const row of rows || []) {
1526
+ if (row?.source !== "codex" || typeof row.session_id !== "string" || !row.session_id) continue;
1527
+ const existing = byId.get(row.session_id);
1528
+ if (existing && existing !== row) {
1529
+ existing.parent_link_conflict = true;
1530
+ row.parent_link_conflict = true;
1531
+ continue;
1532
+ }
1533
+ byId.set(row.session_id, row);
1534
+ }
1535
+
1536
+ function hasParentCycle(start) {
1537
+ const seen = new Set();
1538
+ let current = start;
1539
+ while (current?.parent_session_id && byId.has(current.parent_session_id)) {
1540
+ if (seen.has(current.session_id) || current.parent_session_id === current.session_id) return true;
1541
+ seen.add(current.session_id);
1542
+ current = byId.get(current.parent_session_id);
1543
+ }
1544
+ return false;
1545
+ }
1546
+
1547
+ for (const row of byId.values()) {
1548
+ const parentId = typeof row.parent_session_id === "string" ? row.parent_session_id.trim() : "";
1549
+ if (!parentId) continue;
1550
+ if (row.parent_link_conflict || hasParentCycle(row)) {
1551
+ row.parent_link_conflict = true;
1552
+ continue;
1553
+ }
1554
+ if (!byId.has(parentId)) {
1555
+ row.orphaned_subagent = true;
1556
+ continue;
1557
+ }
1558
+ const children = childrenById.get(parentId) || [];
1559
+ children.push(row);
1560
+ childrenById.set(parentId, children);
1561
+ linkedParentById.set(row.session_id, parentId);
1562
+ }
1563
+
1564
+ const memo = new Map();
1565
+ function aggregate(row, visiting = new Set()) {
1566
+ if (memo.has(row.session_id)) return memo.get(row.session_id);
1567
+ if (visiting.has(row.session_id)) {
1568
+ row.parent_link_conflict = true;
1569
+ return { count: 0, tokens: 0, cost: 0 };
1570
+ }
1571
+ const nextVisiting = new Set(visiting);
1572
+ nextVisiting.add(row.session_id);
1573
+ let count = 0;
1574
+ let tokens = 0;
1575
+ let cost = 0;
1576
+ for (const child of childrenById.get(row.session_id) || []) {
1577
+ const nested = aggregate(child, nextVisiting);
1578
+ count += 1 + nested.count;
1579
+ tokens += finite(child.total_tokens) + nested.tokens;
1580
+ cost += finite(child.cost_usd) + nested.cost;
1581
+ }
1582
+ const result = { count, tokens, cost };
1583
+ memo.set(row.session_id, result);
1584
+ return result;
1585
+ }
1586
+
1587
+ function rootFor(row) {
1588
+ const seen = new Set([row.session_id]);
1589
+ let current = row;
1590
+ while (current && linkedParentById.has(current.session_id)) {
1591
+ const linkedParentId = linkedParentById.get(current.session_id);
1592
+ if (seen.has(linkedParentId)) {
1593
+ row.parent_link_conflict = true;
1594
+ break;
1595
+ }
1596
+ seen.add(linkedParentId);
1597
+ current = byId.get(linkedParentId);
1598
+ }
1599
+ return current || row;
1600
+ }
1601
+
1602
+ for (const row of rows || []) {
1603
+ row.own_total_tokens = finite(row.total_tokens);
1604
+ row.own_cost_usd = finite(row.cost_usd);
1605
+ row.direct_subagent_count = row.source === "codex" && row.session_id
1606
+ ? (childrenById.get(row.session_id) || []).length
1607
+ : 0;
1608
+ const nested = row.source === "codex" && row.session_id
1609
+ ? aggregate(row)
1610
+ : { count: 0, tokens: 0, cost: 0 };
1611
+ row.descendant_subagent_count = nested.count;
1612
+ row.subagent_total_tokens = nested.tokens;
1613
+ row.subagent_cost_usd = nested.cost;
1614
+ row.combined_total_tokens = finite(row.total_tokens) + nested.tokens;
1615
+ row.combined_cost_usd = finite(row.cost_usd) + nested.cost;
1616
+ if (row.source === "codex" && row.session_id) {
1617
+ const parent = byId.get(linkedParentById.get(row.session_id));
1618
+ row.parent_session_hash = parent?.session_hash || null;
1619
+ row.root_session_hash = rootFor(row).session_hash || row.session_hash;
1620
+ }
1621
+ }
1622
+ return rows;
1623
+ }
1624
+
1430
1625
  // Local-only session list for the dashboard session browser. Unlike
1431
1626
  // summarizeSessions (cloud/CSV safe), this retains session_id + project_ref so
1432
1627
  // the UI can offer one-click resume. Callers must only expose it over the
1433
1628
  // local API.
1434
1629
  function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}) {
1435
- const filtered = mergeSessionFragments(sessions)
1630
+ const filtered = annotateCodexThreadUsage(mergeSessionFragments(sessions)
1436
1631
  // Only sessions that actually spent tokens are worth listing. This drops
1437
1632
  // two kinds of noise: non-session logs under ~/.claude/projects
1438
1633
  // (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
1439
1634
  // the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
1440
1635
  // no model, no cost and nothing to analyze.
1441
1636
  .filter((row) => finite(row.total_tokens) > 0)
1442
- .filter((row) => withinDayRange(row, from, to));
1637
+ .filter((row) => withinDayRange(row, from, to)));
1443
1638
  filtered.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
1444
1639
  const cap = Number(limit) > 0 ? Number(limit) : 0;
1445
1640
  const limited = cap > 0 ? filtered.slice(0, cap) : filtered;
@@ -0,0 +1,293 @@
1
+ const path = require("node:path");
2
+ const fs = require("node:fs/promises");
3
+ const crypto = require("node:crypto");
4
+
5
+ const { readJsonStrict, writeFileAtomic, chmod600IfPossible } = require("./fs");
6
+
7
+ // Manual subscription manager: user-entered billing plans (service, plan,
8
+ // auto-renew flag, next renewal/expiry timestamp). Distinct from
9
+ // subscriptions.js (auto-detected local plan tiers) and usage-limits.js
10
+ // (rate-limit window resets) — see issue #460.
11
+ const STORE_FILE = "subscription-manager.json";
12
+ const STORE_VERSION = 1;
13
+ const MAX_TEXT_LENGTH = 120;
14
+ const CYCLE_UNITS = new Set(["weekly", "monthly", "yearly"]);
15
+
16
+ function resolveSubscriptionsPath(trackerDir) {
17
+ return path.join(trackerDir, STORE_FILE);
18
+ }
19
+
20
+ function normalizeText(value, { required, field, maxLength = MAX_TEXT_LENGTH } = {}) {
21
+ if (value === null || value === undefined) {
22
+ if (required) throw new Error(`${field} is required`);
23
+ return null;
24
+ }
25
+ if (typeof value !== "string") throw new Error(`${field} must be a string`);
26
+ const trimmed = value.trim();
27
+ if (!trimmed) {
28
+ if (required) throw new Error(`${field} is required`);
29
+ return null;
30
+ }
31
+ if (trimmed.length > maxLength) {
32
+ throw new Error(`${field} must be at most ${maxLength} characters`);
33
+ }
34
+ return trimmed;
35
+ }
36
+
37
+ // Accepts epoch milliseconds or any Date-parseable string. Stored values are
38
+ // UTC ISO strings truncated to whole minutes so comparisons stay clean and a
39
+ // second-level drift between input formats never changes the billed minute.
40
+ function normalizeBillingTime(value, { field = "nextBillingAt", required = true } = {}) {
41
+ let ms;
42
+ if (value === null || value === undefined) {
43
+ if (required) throw new Error(`${field} is required`);
44
+ return null;
45
+ }
46
+ if (typeof value === "number") {
47
+ ms = value;
48
+ } else if (typeof value === "string") {
49
+ const trimmed = value.trim();
50
+ if (!trimmed) {
51
+ if (required) throw new Error(`${field} is required`);
52
+ return null;
53
+ }
54
+ ms = new Date(trimmed).getTime();
55
+ } else {
56
+ throw new Error(`${field} is required`);
57
+ }
58
+ if (!Number.isFinite(ms)) throw new Error(`${field} must be a valid date`);
59
+ return new Date(Math.floor(ms / 60000) * 60000).toISOString();
60
+ }
61
+
62
+ // Billing cycle length. Drives the progress bar span and how far an
63
+ // auto-renew subscription rolls forward after a recorded renewal passes.
64
+ // Defaults to monthly so pre-cycle records keep their original semantics.
65
+ function normalizeCycle(value) {
66
+ if (value === null || value === undefined) return "monthly";
67
+ if (typeof value !== "string" || !CYCLE_UNITS.has(value)) {
68
+ throw new Error("cycle must be one of: weekly, monthly, yearly");
69
+ }
70
+ return value;
71
+ }
72
+
73
+ function normalizeSubscriptionFields(fields) {
74
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
75
+ throw new Error("subscription must be an object");
76
+ }
77
+ if (typeof fields.autoRenew !== "boolean") {
78
+ throw new Error("autoRenew must be a boolean");
79
+ }
80
+ return {
81
+ service: normalizeText(fields.service, { required: true, field: "service" }),
82
+ plan: normalizeText(fields.plan ?? null, { required: false, field: "plan" }),
83
+ // Optional link to a usage-limits provider row (e.g. "codex"). The value
84
+ // is a limits provider id but is not validated against the canonical list
85
+ // here — the backend stays lenient, the dropdown constrains it in the UI.
86
+ provider: normalizeText(fields.provider ?? null, {
87
+ required: false,
88
+ field: "provider",
89
+ maxLength: 64,
90
+ }),
91
+ autoRenew: fields.autoRenew,
92
+ cycle: normalizeCycle(fields.cycle),
93
+ nextBillingAt: normalizeBillingTime(fields.nextBillingAt),
94
+ // Billing anchor: the day the subscription started. The dashboard derives
95
+ // every cycle boundary from this date, so month-end anchors (Jan 31) stay
96
+ // stable instead of drifting after the first clamped month. Optional:
97
+ // records created before the field existed fall back to a derived anchor.
98
+ startedAt:
99
+ fields.startedAt === undefined
100
+ ? null
101
+ : normalizeBillingTime(fields.startedAt, { field: "startedAt", required: false }),
102
+ };
103
+ }
104
+
105
+ function isValidStoredRecord(record) {
106
+ if (!record || typeof record !== "object" || Array.isArray(record)) return false;
107
+ if (typeof record.id !== "string" || !record.id) return false;
108
+ if (typeof record.service !== "string" || !record.service) return false;
109
+ if (typeof record.autoRenew !== "boolean") return false;
110
+ if (typeof record.nextBillingAt !== "string") return false;
111
+ return Number.isFinite(new Date(record.nextBillingAt).getTime());
112
+ }
113
+
114
+ // Read the store. Any state other than a clean file or a plainly missing one
115
+ // must never collapse into an empty store on its own: the next write would
116
+ // silently destroy every stored subscription (user-entered data that cannot
117
+ // be rebuilt from logs). Reads surface the damage as an error; only the write
118
+ // path may recover, and only after backing the original file up.
119
+ async function readStore(filePath, { forWrite = false } = {}) {
120
+ const { status, value, error } = await readJsonStrict(filePath);
121
+ if (status === "missing") {
122
+ return { store: { version: STORE_VERSION, items: [] }, needsBackup: false };
123
+ }
124
+ if (status === "error") {
125
+ // Unreadable (permissions, I/O error): refuse — a blind overwrite would
126
+ // destroy data we were never able to see.
127
+ throw new Error(`Cannot read subscription store: ${error?.message || String(error)}`);
128
+ }
129
+ if (status === "invalid" || !value || typeof value !== "object" || Array.isArray(value)) {
130
+ if (!forWrite) {
131
+ throw new Error("Subscription store is corrupted (invalid JSON)");
132
+ }
133
+ return { store: { version: STORE_VERSION, items: [] }, needsBackup: true };
134
+ }
135
+ if (!Array.isArray(value.items)) {
136
+ // Valid JSON object without a usable items array: nothing recoverable
137
+ // inside, so an empty store is honest. If the key exists with a wrong
138
+ // type, keep the file backed up before a write replaces it.
139
+ return {
140
+ store: { version: STORE_VERSION, items: [] },
141
+ needsBackup: value.items !== undefined,
142
+ };
143
+ }
144
+ const items = [];
145
+ let dropped = false;
146
+ for (const item of value.items) {
147
+ if (isValidStoredRecord(item)) {
148
+ items.push(item);
149
+ } else {
150
+ dropped = true;
151
+ }
152
+ }
153
+ const version = Number.isFinite(value.version) ? value.version : STORE_VERSION;
154
+ return { store: { version, items }, needsBackup: dropped };
155
+ }
156
+
157
+ // A write is about to replace a store that contained unreadable data — keep
158
+ // a copy around first so nothing is lost without a trace. Copy (not rename):
159
+ // if the replacement write then fails, the canonical path must still hold the
160
+ // damaged original, otherwise the next write would treat it as missing and
161
+ // start from an empty store.
162
+ async function backupDamagedStore(filePath) {
163
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
164
+ try {
165
+ await fs.copyFile(filePath, `${filePath}.corrupt-${stamp}`);
166
+ } catch (e) {
167
+ if (e?.code !== "ENOENT") {
168
+ throw new Error(`Cannot back up the damaged subscription store: ${e?.message || String(e)}`);
169
+ }
170
+ }
171
+ }
172
+
173
+ async function loadStoreForWrite(filePath) {
174
+ const { store, needsBackup } = await readStore(filePath, { forWrite: true });
175
+ if (needsBackup) await backupDamagedStore(filePath);
176
+ return store;
177
+ }
178
+
179
+ async function writeStore(filePath, store) {
180
+ // Private from creation: the 0o600 applies to the tmp file before the
181
+ // rename, so a crash before the fallback chmod cannot leave a
182
+ // world-readable store behind.
183
+ await writeFileAtomic(filePath, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
184
+ await chmod600IfPossible(filePath);
185
+ }
186
+
187
+ function sortByNextBillingAt(items) {
188
+ return [...items].sort((a, b) =>
189
+ new Date(a.nextBillingAt).getTime() - new Date(b.nextBillingAt).getTime(),
190
+ );
191
+ }
192
+
193
+ // One subscription per tool: the dashboard derives the subscription's name
194
+ // and inline progress row from the linked provider, so two records sharing a
195
+ // provider cannot be displayed. Enforced inside the store lock's
196
+ // read-modify-write transaction — the frontend picker cannot cover concurrent
197
+ // requests, stale pages, or direct API calls. `excludeId` lets an update
198
+ // re-save its own provider.
199
+ function assertProviderAvailable(items, provider, excludeId = null) {
200
+ if (!provider) return;
201
+ const conflict = items.find(
202
+ (item) => item.provider === provider && item.id !== excludeId,
203
+ );
204
+ if (conflict) {
205
+ throw new Error(
206
+ `Provider "${provider}" already has a subscription (${conflict.id})`,
207
+ );
208
+ }
209
+ }
210
+
211
+ // In-process FIFO queue per store file. Every mutation is a full
212
+ // read-modify-write transaction; without serialization two concurrent writes
213
+ // read the same snapshot and the later one silently drops the earlier change
214
+ // (issue: 30 concurrent creates survived as a single record). The queue keeps
215
+ // independent store files unblocked while ordering operations on the same one.
216
+ const storeLocks = new Map();
217
+
218
+ function withStoreLock(filePath, operation) {
219
+ const previous = storeLocks.get(filePath) || Promise.resolve();
220
+ // Run even if the previous transaction rejected; its error already reached
221
+ // its own caller and must not wedge the queue.
222
+ const run = previous.then(operation, operation);
223
+ storeLocks.set(filePath, run.then(() => undefined, () => undefined));
224
+ return run;
225
+ }
226
+
227
+ async function listSubscriptions({ trackerDir }) {
228
+ const { store } = await readStore(resolveSubscriptionsPath(trackerDir));
229
+ return sortByNextBillingAt(store.items);
230
+ }
231
+
232
+ async function createSubscription({ trackerDir, fields }) {
233
+ // Validate before taking the lock so a bad payload never queues a write.
234
+ const normalized = normalizeSubscriptionFields(fields);
235
+ const filePath = resolveSubscriptionsPath(trackerDir);
236
+ return withStoreLock(filePath, async () => {
237
+ const now = new Date().toISOString();
238
+ const record = {
239
+ id: crypto.randomUUID(),
240
+ ...normalized,
241
+ createdAt: now,
242
+ updatedAt: now,
243
+ };
244
+ const store = await loadStoreForWrite(filePath);
245
+ assertProviderAvailable(store.items, normalized.provider);
246
+ store.items.push(record);
247
+ await writeStore(filePath, store);
248
+ return record;
249
+ });
250
+ }
251
+
252
+ async function updateSubscription({ trackerDir, id, fields }) {
253
+ if (typeof id !== "string" || !id) throw new Error("id is required");
254
+ const filePath = resolveSubscriptionsPath(trackerDir);
255
+ return withStoreLock(filePath, async () => {
256
+ const store = await loadStoreForWrite(filePath);
257
+ const index = store.items.findIndex((item) => item.id === id);
258
+ if (index === -1) throw new Error("Subscription not found");
259
+ const existing = store.items[index];
260
+ // Merge over the existing record so callers may send only changed fields.
261
+ const merged = normalizeSubscriptionFields({ ...existing, ...(fields || {}) });
262
+ assertProviderAvailable(store.items, merged.provider, id);
263
+ const record = {
264
+ ...existing,
265
+ ...merged,
266
+ updatedAt: new Date().toISOString(),
267
+ };
268
+ store.items[index] = record;
269
+ await writeStore(filePath, store);
270
+ return record;
271
+ });
272
+ }
273
+
274
+ async function deleteSubscription({ trackerDir, id }) {
275
+ if (typeof id !== "string" || !id) throw new Error("id is required");
276
+ const filePath = resolveSubscriptionsPath(trackerDir);
277
+ return withStoreLock(filePath, async () => {
278
+ const store = await loadStoreForWrite(filePath);
279
+ const index = store.items.findIndex((item) => item.id === id);
280
+ if (index === -1) throw new Error("Subscription not found");
281
+ store.items.splice(index, 1);
282
+ await writeStore(filePath, store);
283
+ return { removed: true };
284
+ });
285
+ }
286
+
287
+ module.exports = {
288
+ listSubscriptions,
289
+ createSubscription,
290
+ updateSubscription,
291
+ deleteSubscription,
292
+ resolveSubscriptionsPath,
293
+ };
@@ -6,7 +6,7 @@
6
6
  * Privacy contract (documented in README "Privacy" section — keep in sync):
7
7
  * the payload carries exactly four fields — a one-way sha256 hash of the
8
8
  * machine id, the app version, `process.platform`, and the app shell
9
- * (cli / macos / windows). No token counts, no model names, no usernames,
9
+ * (cli / macos / windows / linux). No token counts, no model names, no usernames,
10
10
  * no paths. Opt out with TOKENTRACKER_NO_TELEMETRY=1, the DO_NOT_TRACK
11
11
  * standard, or `"telemetry": false` in config.json.
12
12
  */
@@ -35,7 +35,7 @@ const MACHINE_HASH_NAMESPACE = "tokentracker-telemetry-v1:";
35
35
  // cloud host; sync awaits this at most once per day so the worst-case stall
36
36
  // is bounded and rare.
37
37
  const DEFAULT_TIMEOUT_MS = 10_000;
38
- const VALID_SHELLS = new Set(["cli", "macos", "windows"]);
38
+ const VALID_SHELLS = new Set(["cli", "macos", "windows", "linux"]);
39
39
 
40
40
  const THROTTLE_CONFIG = {
41
41
  intervalMs: HEARTBEAT_INTERVAL_MS,