tokentracker-cli 1.3.4 → 1.3.6

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 (147) hide show
  1. package/README.de.md +5 -5
  2. package/README.ja.md +3 -3
  3. package/README.ko.md +3 -3
  4. package/README.md +6 -5
  5. package/README.zh-CN.md +3 -3
  6. package/dashboard/dist/assets/{AchievementBadge-CKjtQp-U.js → AchievementBadge-CHDt55IP.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-DpOXrabE.js → AchievementsPage-DpbRHmb8.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DiXyIoa0.js → AchievementsSection-Ck5CiX4P.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-CILTTkPF.js → BadgeDetailModal-BVPqlh0X.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-CrBHWiRi.js → BotAnimated-CVsv6kxT.js} +1 -1
  11. package/dashboard/dist/assets/{Card-DUItW3xI.js → Card-C9FjyAwh.js} +1 -1
  12. package/dashboard/dist/assets/{CloudCheckoutPage-C_t_PDlG.js → CloudCheckoutPage-ChkjId9l.js} +1 -1
  13. package/dashboard/dist/assets/{CloudPage-pvJUrhya.js → CloudPage-D1nrt3MG.js} +1 -1
  14. package/dashboard/dist/assets/{CloudUsageExport-DQbDwiyk.js → CloudUsageExport-jXdoiD0e.js} +1 -1
  15. package/dashboard/dist/assets/{CommandPalette-BvHilrbV.js → CommandPalette-DFnEeZzc.js} +1 -1
  16. package/dashboard/dist/assets/{CommunityStatsModal-CizxEYv-.js → CommunityStatsModal-CZDS8gPA.js} +1 -1
  17. package/dashboard/dist/assets/{ConfirmModal-h9nISVy7.js → ConfirmModal-DRhgBoGE.js} +1 -1
  18. package/dashboard/dist/assets/{Controls-BjdIonTX.js → Controls-BmT6-x_L.js} +1 -1
  19. package/dashboard/dist/assets/{DashboardPage-Bxryr4qp.js → DashboardPage-DfwwXZBo.js} +1 -1
  20. package/dashboard/dist/assets/{DateRangePopover-DpKh-0l8.js → DateRangePopover-DuqNLatr.js} +1 -1
  21. package/dashboard/dist/assets/{DevicePage-DUJR9hJC.js → DevicePage-BtI0bWyg.js} +1 -1
  22. package/dashboard/dist/assets/{DialogClose-BmVAmN-o.js → DialogClose-DjnC3P4V.js} +1 -1
  23. package/dashboard/dist/assets/DialogDescription-yy_QHg7O.js +1 -0
  24. package/dashboard/dist/assets/{DialogTitle-BsaIbN8k.js → DialogTitle-B9NNSz5p.js} +1 -1
  25. package/dashboard/dist/assets/{FadeIn-BpSt2P_W.js → FadeIn-fsVQmJGa.js} +1 -1
  26. package/dashboard/dist/assets/{HeaderGithubStar-Cc_VHB0R.js → HeaderGithubStar-Cg-f1iZc.js} +1 -1
  27. package/dashboard/dist/assets/{HoverTooltip-WFd21iP9.js → HoverTooltip-DRIRlzXH.js} +1 -1
  28. package/dashboard/dist/assets/{IpCheckPage-C_mUPSRB.js → IpCheckPage-DaAubaJQ.js} +1 -1
  29. package/dashboard/dist/assets/{LandingPage-DVR8ClZO.js → LandingPage-DjBGIjHk.js} +2 -2
  30. package/dashboard/dist/assets/LeaderboardPage-BkCXqhz7.js +2 -0
  31. package/dashboard/dist/assets/{LeaderboardProBadge-BjX4EFl2.js → LeaderboardProBadge-Ds-wiWcN.js} +1 -1
  32. package/dashboard/dist/assets/{LeaderboardProfileModal-vbPMNn7W.js → LeaderboardProfileModal-X_rfSQT5.js} +4 -4
  33. package/dashboard/dist/assets/{LeaderboardProfilePage-CqClmuA-.js → LeaderboardProfilePage-rpl1Ozwj.js} +1 -1
  34. package/dashboard/dist/assets/LimitsPage-BUvDBmgD.js +9 -0
  35. package/dashboard/dist/assets/{LocalOnlyNotice-B6nDke_u.js → LocalOnlyNotice-LBH1DBrs.js} +1 -1
  36. package/dashboard/dist/assets/{LoginCard-C28QVjd7.js → LoginCard-B0vRJEcg.js} +1 -1
  37. package/dashboard/dist/assets/{LoginModal-Bx1btNhH.js → LoginModal-DdzgP43f.js} +1 -1
  38. package/dashboard/dist/assets/{LoginPage-DVOGNGYI.js → LoginPage-B4VCQxzn.js} +1 -1
  39. package/dashboard/dist/assets/{PetPage-nOKryTde.js → PetPage-Cmf6aXc9.js} +1 -1
  40. package/dashboard/dist/assets/{PopoverPopup-Ch6NLnu6.js → PopoverPopup-BzfELnIC.js} +1 -1
  41. package/dashboard/dist/assets/{ProviderIcon-BRY2br-g.js → ProviderIcon-ClHXNkQ0.js} +1 -1
  42. package/dashboard/dist/assets/{ResetPasswordPage-ko1OQecQ.js → ResetPasswordPage-BeUpNt_6.js} +1 -1
  43. package/dashboard/dist/assets/{Select-Q8mS3_od.js → Select-BHEXdYHu.js} +1 -1
  44. package/dashboard/dist/assets/{SelectItemText-C1id8GDj.js → SelectItemText-aaQK_QIt.js} +1 -1
  45. package/dashboard/dist/assets/{SelfHostPage-Dzb2bDiM.js → SelfHostPage-ClNhKnlP.js} +1 -1
  46. package/dashboard/dist/assets/{ServiceStatusPage-ewFwz29J.js → ServiceStatusPage-Byd_s6FS.js} +1 -1
  47. package/dashboard/dist/assets/SessionsPage-u3_b4PO7.js +3 -0
  48. package/dashboard/dist/assets/SettingsPage-DweCAbsy.js +2 -0
  49. package/dashboard/dist/assets/{SkillsPage-CcG77tqU.js → SkillsPage-Bb-PVfux.js} +1 -1
  50. package/dashboard/dist/assets/{TokenGalaxy-D2HDrbQR.js → TokenGalaxy-CubA3PXu.js} +1 -1
  51. package/dashboard/dist/assets/{TooltipPopup-C_bF2ZwO.js → TooltipPopup-DMliWhoK.js} +1 -1
  52. package/dashboard/dist/assets/{TrendMonitor-BcyoN5ns.js → TrendMonitor-as8idaOy.js} +1 -1
  53. package/dashboard/dist/assets/{WidgetsPage-BirK0U_N.js → WidgetsPage-DDPR4v0R.js} +1 -1
  54. package/dashboard/dist/assets/{WrappedPage-thsXBuw6.js → WrappedPage-zZzgYz6s.js} +1 -1
  55. package/dashboard/dist/assets/{__vite-browser-external-Bq8nj-JV.js → __vite-browser-external-DWN9Fb-H.js} +12 -12
  56. package/dashboard/dist/assets/__vite-browser-external-LpFw4zXh.css +1 -0
  57. package/dashboard/dist/assets/agent-logos-CoNhCm9-.js +1 -0
  58. package/dashboard/dist/assets/{arrow-left-c_NOSfoq.js → arrow-left-C5hDp7fc.js} +1 -1
  59. package/dashboard/dist/assets/{arrow-right-CP7drKCT.js → arrow-right-DEvMmSiA.js} +1 -1
  60. package/dashboard/dist/assets/{arrow-up-right-DQ6QfCpo.js → arrow-up-right-DxY0neJ4.js} +1 -1
  61. package/dashboard/dist/assets/{calendar-days-D_pxFQvT.js → calendar-days-bzh7-lab.js} +1 -1
  62. package/dashboard/dist/assets/{check-HAS31kTT.js → check-CSZvcC3X.js} +1 -1
  63. package/dashboard/dist/assets/{chevron-down-BIMuk6oN.js → chevron-down-CsiGcG0G.js} +1 -1
  64. package/dashboard/dist/assets/{cloud-checkout-COmDwrUT.js → cloud-checkout-z_eDVwm6.js} +1 -1
  65. package/dashboard/dist/assets/{composite-kLwqpqEk.js → composite-Cq0CmNAv.js} +1 -1
  66. package/dashboard/dist/assets/{download-9RgsZIiH.js → download-DqLpubIF.js} +1 -1
  67. package/dashboard/dist/assets/{external-link-C_gYjHGF.js → external-link-B8E8tbB3.js} +1 -1
  68. package/dashboard/dist/assets/{flame-BSsCvgT8.js → flame-CrJDlnro.js} +1 -1
  69. package/dashboard/dist/assets/{format-tokens-N22Wqv1X.js → format-tokens-DPIGGOlt.js} +1 -1
  70. package/dashboard/dist/assets/gift-CCp4Hh50.js +1 -0
  71. package/dashboard/dist/assets/{host-mode-JzrhHX7F.js → host-mode-xzpMLcwP.js} +1 -1
  72. package/dashboard/dist/assets/{icons-pK99mIMv.js → icons-CbB9Y-eV.js} +1 -1
  73. package/dashboard/dist/assets/{index-BhviBroj.js → index-BiK5-tQp.js} +1 -1
  74. package/dashboard/dist/assets/{index-CmD84EJi.js → index-DX7nYrHb.js} +1 -1
  75. package/dashboard/dist/assets/{index-Dk43KNa6.js → index-zuC7FU-t.js} +1 -1
  76. package/dashboard/dist/assets/{info-D-RLFnSK.js → info-B3NKYwWG.js} +1 -1
  77. package/dashboard/dist/assets/{link-2-BiKXW9mV.js → link-2-BRc8d8M9.js} +1 -1
  78. package/dashboard/dist/assets/{loader-circle-KVUaEKV2.js → loader-circle-Cm5Nsgp-.js} +1 -1
  79. package/dashboard/dist/assets/{modifiers.esm-5NK8j3mT.js → modifiers.esm-CYLYBECB.js} +1 -1
  80. package/dashboard/dist/assets/module-BNnaKM__.js +3 -0
  81. package/dashboard/dist/assets/{monitor-smartphone-u9yr-_Es.js → monitor-smartphone--IZ4D84F.js} +1 -1
  82. package/dashboard/dist/assets/{play-D0MaDCOx.js → play-CUrMOLtq.js} +1 -1
  83. package/dashboard/dist/assets/{plus-DTJVtoAJ.js → plus-0hrCDl4r.js} +1 -1
  84. package/dashboard/dist/assets/{provider-display-YhtG0I9Y.js → provider-display-CNK0FN3Q.js} +1 -1
  85. package/dashboard/dist/assets/{react-gIVxucOB.js → react-CCNSVHEA.js} +1 -1
  86. package/dashboard/dist/assets/{safePolygon-C_sPnJ1L.js → safePolygon-DsolkMUX.js} +1 -1
  87. package/dashboard/dist/assets/{search-DdQRwFYV.js → search-DYmYb9xY.js} +1 -1
  88. package/dashboard/dist/assets/{server-M0rmbGWy.js → server-CYcqzdSW.js} +1 -1
  89. package/dashboard/dist/assets/{shield-check-9-sqQKdD.js → shield-check-5zABLIr6.js} +1 -1
  90. package/dashboard/dist/assets/{skills-api-B0qBWnuK.js → skills-api-CD-ojBGj.js} +1 -1
  91. package/dashboard/dist/assets/{sortable.esm-iqbm7x5D.js → sortable.esm-BCn2Ms6R.js} +1 -1
  92. package/dashboard/dist/assets/{store-DstuWd4a.js → store-B22kwLs0.js} +1 -1
  93. package/dashboard/dist/assets/{terminal-Dz0MxHNG.js → terminal-CTv-oTU2.js} +1 -1
  94. package/dashboard/dist/assets/{trash-DBMFOFVz.js → trash-C5MAh3GO.js} +1 -1
  95. package/dashboard/dist/assets/{use-community-stats-CacbAcUR.js → use-community-stats-BrgrxUD2.js} +1 -1
  96. package/dashboard/dist/assets/{use-latest-request-guard-nBuQVZOR.js → use-latest-request-guard-B1_rbvgH.js} +1 -1
  97. package/dashboard/dist/assets/use-limits-display-prefs-BOk5f-HM.js +1 -0
  98. package/dashboard/dist/assets/{use-native-settings-EAqAFx2x.js → use-native-settings-B2gc_m9W.js} +1 -1
  99. package/dashboard/dist/assets/{use-ordered-list-DGLjjU6Q.js → use-ordered-list-N3sGpJ0u.js} +1 -1
  100. package/dashboard/dist/assets/{use-pet-settings-C5MzWHE7.js → use-pet-settings-sWFXgR3m.js} +2 -2
  101. package/dashboard/dist/assets/use-reduced-motion-C3MBYVqm.js +1 -0
  102. package/dashboard/dist/assets/{use-transform-C25eCuz_.js → use-transform-BsaLmw46.js} +1 -1
  103. package/dashboard/dist/assets/use-usage-limits-wL7v-S0r.js +1 -0
  104. package/dashboard/dist/assets/{useCurrency-t3PYQi1l.js → useCurrency-CbCnjf-N.js} +1 -1
  105. package/dashboard/dist/assets/{usePositioner-C59mj6kG.js → usePositioner-MepYlL0A.js} +1 -1
  106. package/dashboard/dist/assets/{useTokenFormat-C0xQsBGf.js → useTokenFormat-Bq-rwuKa.js} +1 -1
  107. package/dashboard/dist/assets/{zap-CyH0tvKM.js → zap-1ah5e11g.js} +1 -1
  108. package/dashboard/dist/index.html +14 -12
  109. package/dashboard/dist/ip-check.html +2 -2
  110. package/dashboard/dist/leaderboard.html +2 -2
  111. package/dashboard/dist/llms.txt +2 -2
  112. package/dashboard/dist/share.html +2 -2
  113. package/package.json +2 -2
  114. package/src/cli.js +1 -1
  115. package/src/commands/init.js +43 -1
  116. package/src/commands/status.js +27 -0
  117. package/src/commands/sync.js +54 -4
  118. package/src/commands/uninstall.js +19 -0
  119. package/src/lib/claude-config.js +47 -15
  120. package/src/lib/codex-rollout-parser.js +11 -3
  121. package/src/lib/command-runner.js +15 -1
  122. package/src/lib/cursor-config.js +2 -1
  123. package/src/lib/gemini-config.js +3 -2
  124. package/src/lib/local-api.js +13 -3
  125. package/src/lib/notify-command.js +38 -0
  126. package/src/lib/openclaw-session-plugin.js +47 -14
  127. package/src/lib/passive-mode.js +12 -0
  128. package/src/lib/pricing/curated-overrides.json +42 -5
  129. package/src/lib/pricing/matcher.js +14 -5
  130. package/src/lib/pricing/seed-snapshot.json +1 -1
  131. package/src/lib/rollout.js +199 -44
  132. package/src/lib/session-analytics.js +653 -60
  133. package/src/lib/session-performance.js +24 -1
  134. package/src/lib/usage-limits.js +183 -20
  135. package/src/lib/zcode-limits.js +218 -18
  136. package/src/lib/zcode-reset-credits.js +66 -0
  137. package/dashboard/dist/assets/DialogDescription-C0Cgq67g.js +0 -1
  138. package/dashboard/dist/assets/LeaderboardPage-D8ydzVpn.js +0 -2
  139. package/dashboard/dist/assets/LimitsPage-DaRggHge.js +0 -8
  140. package/dashboard/dist/assets/SessionsPage-C6Y8kbnM.js +0 -3
  141. package/dashboard/dist/assets/SettingsPage-nztCXEPs.js +0 -2
  142. package/dashboard/dist/assets/__vite-browser-external-BwmeEfv7.css +0 -1
  143. package/dashboard/dist/assets/agent-logos-CK6SzhI7.js +0 -1
  144. package/dashboard/dist/assets/module-WSx8viXK.js +0 -3
  145. package/dashboard/dist/assets/use-limits-display-prefs-fqg8hs6b.js +0 -1
  146. package/dashboard/dist/assets/use-reduced-motion-DK2oZ2uC.js +0 -1
  147. package/dashboard/dist/assets/use-usage-limits-BhqnEai3.js +0 -1
@@ -28,10 +28,12 @@ const fsp = require("node:fs/promises");
28
28
  const os = require("node:os");
29
29
  const path = require("node:path");
30
30
  const readline = require("node:readline");
31
- const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage } = require("./rollout");
31
+ const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, normalizeOpencodeModelFields, deriveOpencodeMessageFingerprint } = require("./rollout");
32
32
  const { parseCodexRolloutFile } = require("./codex-rollout-parser");
33
33
  const { computeRowCost, getModelPricingInfo } = require("./pricing");
34
- const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector } = require("./session-performance");
34
+ const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector, createZcodePerformanceCollector } = require("./session-performance");
35
+ const { readSqliteJsonRows } = require("./sqlite-reader");
36
+ const { resolveZcodeNativeDbPath } = require("./install-resolver");
35
37
  const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
36
38
  const wsl = require("./wsl-probe");
37
39
  const {
@@ -70,7 +72,16 @@ const {
70
72
  // long-context subset) and folds delivery signals into the token parser's
71
73
  // single pass instead of parsing every Codex file twice.
72
74
  // v14 adds request-level estimated performance and Claude model usage.
73
- const SIDECAR_VERSION = 14;
75
+ // v15/v16 tighten Codex delivery-signal parsing (uncommented bump).
76
+ // v17 adds ZCode sessions from ~/.zcode/cli/db/db.sqlite — one SQLite db
77
+ // scanning to many per-session rows — so every cached row is rebuilt.
78
+ // v18 corrects ZCode project keys and request timestamp units, and invalidates
79
+ // inventories that earlier builds may have cached after a failed database read.
80
+ // v19 scans both ZCode installs as one deduplicated inventory and separates
81
+ // provider retries from repeated user requests.
82
+ // v20 also invalidates unchanged-stat caches after an observed database lock.
83
+ // v21 retains the request count when several ZCode events share a minute.
84
+ const SIDECAR_VERSION = 21;
74
85
  const EDIT_TOOLS = new Set([
75
86
  "apply_patch",
76
87
  "edit",
@@ -123,12 +134,23 @@ function addTotals(target, delta) {
123
134
  for (const key of ["input_tokens", "cached_input_tokens", "cache_creation_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"]) {
124
135
  target[key] = finite(target[key]) + finite(delta?.[key]);
125
136
  }
137
+ for (const key of MODEL_USAGE_SUM_FIELDS) {
138
+ if (!/^(?:long_context_|priority_)|^(?:usage_events|rerouted_usage_events)$/.test(key)) continue;
139
+ if (delta?.[key]) target[key] = finite(target[key]) + finite(delta[key]);
140
+ }
126
141
  }
127
142
 
128
143
  function emptyTotals() {
129
144
  return tokenTotals({});
130
145
  }
131
146
 
147
+ function mergeUsageMetadata(target, usage) {
148
+ for (const field of ["selected_models", "reroute_reasons"]) {
149
+ if (Array.isArray(usage[field])) target[field] = [...new Set([...(target[field] || []), ...usage[field]])];
150
+ }
151
+ if (usage.model_attribution === "effective") target.model_attribution = "effective";
152
+ }
153
+
132
154
  const MODEL_USAGE_SUM_FIELDS = [
133
155
  "input_tokens",
134
156
  "cached_input_tokens",
@@ -241,6 +263,11 @@ function serializeSessionRecord(row) {
241
263
  }
242
264
 
243
265
  function repriceSessionRecord(record) {
266
+ if (Array.isArray(record.usage_buckets)) {
267
+ record.usage_buckets = record.usage_buckets.map((bucket) => ({
268
+ ...bucket, cost_usd: computeRowCost({ source: record.source, ...bucket }),
269
+ }));
270
+ }
244
271
  const modelUsage = normalizeModelUsageRows(record?.model_usage);
245
272
  if (modelUsage.length > 0) {
246
273
  let hasUnpricedUsage = false;
@@ -469,6 +496,19 @@ function finalizeRecord(record) {
469
496
  return record;
470
497
  }
471
498
 
499
+ function sessionStorageUsage(filePaths) {
500
+ let bytes = 0;
501
+ const allocated = process.platform !== "win32";
502
+ for (const file of new Set(filePaths)) {
503
+ try {
504
+ const stat = fs.statSync(file);
505
+ if (!stat.isFile()) continue;
506
+ bytes += allocated && Number.isFinite(stat.blocks) ? stat.blocks * 512 : stat.size;
507
+ } catch { /* deleted or unreadable while scanning */ }
508
+ }
509
+ return { session_store_bytes: bytes, session_store_metric: allocated ? "allocated" : "logical" };
510
+ }
511
+
472
512
  function readableSessionPaths(filePath) {
473
513
  const candidates = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
474
514
  const readable = candidates.filter((value) => sessionFileStatKey(value));
@@ -623,6 +663,7 @@ async function scanClaudeSession(filePath) {
623
663
  // wrote one — the UI then falls back to the project name.
624
664
  title: aiTitle,
625
665
  source: "claude",
666
+ ...sessionStorageUsage(filePaths),
626
667
  project_key: projectKey(cwd, primaryFilePath),
627
668
  project_ref: cwd || null,
628
669
  model,
@@ -735,6 +776,7 @@ async function scanCodexSession(filePath) {
735
776
  const primaryFilePath = filePaths[0] || String(filePath || "");
736
777
  const signalCollector = createCodexDeliverySignalCollector();
737
778
  const performanceCollector = createCodexPerformanceCollector();
779
+ const usageBuckets = new Map();
738
780
  const parsed = await parseCodexRolloutFile(filePaths, {
739
781
  seenTokenEvents: new Set(),
740
782
  collectBreakdowns: false,
@@ -743,7 +785,20 @@ async function scanCodexSession(filePath) {
743
785
  signalCollector.consume(obj, model);
744
786
  performanceCollector.consume(obj, model);
745
787
  },
746
- onUsage: performanceCollector.consumeUsage,
788
+ onUsage(event) {
789
+ if (event.origin !== "compaction") performanceCollector.consumeUsage(event);
790
+ if (!event.delta || event.delta.total_tokens <= 0) return;
791
+ const ms = Date.parse(event.timestamp);
792
+ if (!Number.isFinite(ms)) return;
793
+ // Minute buckets preserve every current IANA local-midnight boundary
794
+ // and every integer tz_offset_minutes accepted by the local API.
795
+ const timestamp = new Date(Math.floor(ms / 60_000) * 60_000).toISOString();
796
+ const key = `${timestamp}\u0000${event.model}`;
797
+ const bucket = usageBuckets.get(key) || { timestamp, model: event.model || "unknown", ...emptyTotals() };
798
+ addTotals(bucket, event.delta);
799
+ mergeUsageMetadata(bucket, event.delta);
800
+ usageBuckets.set(key, bucket);
801
+ },
747
802
  });
748
803
  const signals = signalCollector.finish();
749
804
  const performance = performanceCollector.finish();
@@ -798,10 +853,12 @@ async function scanCodexSession(filePath) {
798
853
  // Local-only: stripped in summarizeSessions before any cloud/CSV export.
799
854
  title,
800
855
  source: "codex",
856
+ ...sessionStorageUsage(filePaths),
801
857
  project_key: projectKey(parsed.cwd, primaryFilePath),
802
858
  project_ref: parsed.cwd || null,
803
859
  model,
804
860
  model_usage: modelUsage,
861
+ usage_buckets: [...usageBuckets.values()],
805
862
  performance: performance.performance,
806
863
  ...signals.bounds,
807
864
  turns: signals.turns || finite(parsed.turnCount),
@@ -1079,6 +1136,7 @@ async function scanGrokSession(filePath) {
1079
1136
  // Local-only: stripped in summarizeSessions before any cloud/CSV export.
1080
1137
  title,
1081
1138
  source: "grok",
1139
+ ...sessionStorageUsage([filePath, grokSummaryPathFor(filePath), grokSignalsPathFor(filePath)]),
1082
1140
  project_key: projectKey(cwd, filePath),
1083
1141
  project_ref: cwd || null,
1084
1142
  model: model || "unknown",
@@ -1115,6 +1173,408 @@ async function scanGrokSession(filePath) {
1115
1173
  });
1116
1174
  }
1117
1175
 
1176
+ // ZCode (Z.ai's CLI, an OpenCode fork) keeps everything in one SQLite DB at
1177
+ // ~/.zcode/cli/db/db.sqlite: a `session` table (id/parent_id/directory/title),
1178
+ // a `model_usage` table (per-request counters) and OpenCode's legacy `message`
1179
+ // table (assistant messages with inclusive token snapshots). Billable tokens
1180
+ // come from readZcodeDbMessages() — the exact same reader, inclusive→disjoint
1181
+ // normalization and sub-agent provider blocklist the usage sync uses — so the
1182
+ // session browser and the usage card can never drift apart. The per-request
1183
+ // signal columns live only in model_usage, so they are read separately and
1184
+ // feed performance/counter estimates only, never token totals.
1185
+ function zcodeTableColumns(dbPath, table) {
1186
+ // throwOnReadFailure: a missing table answers pragma_table_info with an
1187
+ // empty row set (no error), so the only way to land in the reader's failure
1188
+ // path is a genuinely unreadable db — which must propagate, never read as
1189
+ // "no table" (see the throw in scanZcodeSessions below).
1190
+ const rows = readSqliteJsonRows(dbPath, `SELECT name FROM pragma_table_info('${table}')`, {
1191
+ label: "ZCode",
1192
+ timeout: 10_000,
1193
+ maxBuffer: 1024 * 1024,
1194
+ throwOnReadFailure: true,
1195
+ });
1196
+ return new Set((Array.isArray(rows) ? rows : []).map((row) => String(row?.name || "")));
1197
+ }
1198
+
1199
+ // Messages from readZcodeDbMessages already carry disjoint opencode-shaped
1200
+ // tokens ({ input, output, reasoning, cache.read, cache.write }); map them
1201
+ // onto the session pipeline's column names. tokenTotals() recomputes the sum.
1202
+ function zcodeMessageTotals(data) {
1203
+ const tokens = data?.tokens || {};
1204
+ const totals = tokenTotals({
1205
+ input_tokens: tokens.input,
1206
+ cached_input_tokens: tokens.cache?.read,
1207
+ cache_creation_input_tokens: tokens.cache?.write,
1208
+ output_tokens: tokens.output,
1209
+ reasoning_output_tokens: tokens.reasoning,
1210
+ });
1211
+ // The usage queue's total is input + output + cache_creation + cache_read +
1212
+ // reasoning_output (rollout.js addTotals); tokenTotals' generic total omits
1213
+ // reasoning. ZCode splits reasoning out of output, so fold it back in here or
1214
+ // the session browser undercounts every thinking request against the card.
1215
+ totals.total_tokens += totals.reasoning_output_tokens;
1216
+ return totals;
1217
+ }
1218
+
1219
+ const ZCODE_SIGNAL_REQUIRED_COLUMNS = [
1220
+ "session_id", "model_id", "provider_id", "status", "started_at", "id",
1221
+ "output_tokens", "duration_ms", "time_to_first_token_ms", "query_source",
1222
+ ];
1223
+
1224
+ // One db yields many session rows, so unlike the per-file scanners this scan
1225
+ // returns an array (the entries loop in buildSessionAnalyticsInternal handles
1226
+ // both shapes). Errors on the db as a whole propagate: the caller counts the
1227
+ // file as skipped instead of caching an empty inventory as "no sessions".
1228
+ //
1229
+ // WSL dbs arrive as UNC paths (\\wsl$\…), and SQLite over the Plan 9 bridge
1230
+ // cannot take the locks even a read needs ("database is locked"). The usage
1231
+ // readers snapshot such paths query-by-query; the scan runs several queries
1232
+ // plus readZcodeDbMessages against one db, so snapshot once here and run the
1233
+ // whole scan (metadata, signals, turns, tools, messages) against the local
1234
+ // copy — otherwise the metadata query succeeds while the message read fails
1235
+ // on the same database.
1236
+ async function scanZcodeSessions(dbPaths) {
1237
+ const paths = [...new Set((Array.isArray(dbPaths) ? dbPaths : [dbPaths]).filter(Boolean))];
1238
+ const snapshots = [];
1239
+ const inputs = [];
1240
+ try {
1241
+ for (const dbPath of paths) {
1242
+ let effectiveDbPath = dbPath;
1243
+ if (wsl.isUncPath(dbPath)) {
1244
+ try {
1245
+ const snapshot = wsl.snapshotSqliteDb(dbPath);
1246
+ snapshots.push(snapshot);
1247
+ effectiveDbPath = snapshot.path;
1248
+ } catch (_error) { /* Direct read decides whether this UNC server supports SQLite. */ }
1249
+ }
1250
+ inputs.push(readZcodeSessionInputs(effectiveDbPath));
1251
+ }
1252
+ return buildZcodeSessionRecords(inputs, paths[0]);
1253
+ } finally {
1254
+ for (const snapshot of snapshots) snapshot.cleanup();
1255
+ }
1256
+ }
1257
+
1258
+ /** Read metadata and numeric events from one database; real read failures propagate. */
1259
+ function readZcodeSessionInputs(dbPath) {
1260
+ const options = { label: "ZCode", timeout: 30_000, maxBuffer: 50 * 1024 * 1024, readOnly: true, throwOnReadFailure: true };
1261
+ const sessionColumns = zcodeTableColumns(dbPath, "session");
1262
+ if (!sessionColumns.has("id")) return { sessionRows: [], messages: [], requestRows: [], turnRows: [], toolRows: [] };
1263
+ const optionalColumns = ["parent_id", "directory", "title", "time_created", "time_updated"];
1264
+ const selectColumns = ["id", ...optionalColumns.filter((name) => sessionColumns.has(name))];
1265
+ const orderBy = sessionColumns.has("time_created") ? " ORDER BY time_created ASC, id ASC" : " ORDER BY id ASC";
1266
+ const sessionRows = readSqliteJsonRows(
1267
+ dbPath,
1268
+ `SELECT ${selectColumns.join(", ")} FROM session${orderBy}`,
1269
+ options,
1270
+ );
1271
+ const messages = readZcodeDbMessages(dbPath, { readOnly: true }, { propagateReadFailure: true });
1272
+ const modelUsageColumns = zcodeTableColumns(dbPath, "model_usage");
1273
+ const requestRows = ZCODE_SIGNAL_REQUIRED_COLUMNS.every((name) => modelUsageColumns.has(name))
1274
+ ? readSqliteJsonRows(dbPath, `SELECT id, session_id, model_id, status, started_at, output_tokens, duration_ms, time_to_first_token_ms, query_source
1275
+ FROM model_usage
1276
+ WHERE status IN ('completed', 'error')
1277
+ AND trim(provider_id) != ''
1278
+ AND provider_id NOT LIKE '%anthropic%'
1279
+ AND provider_id NOT LIKE '%openai%'
1280
+ AND provider_id NOT LIKE '%google%'
1281
+ ORDER BY started_at ASC, id ASC`, options)
1282
+ : [];
1283
+ const turnColumns = zcodeTableColumns(dbPath, "turn_usage");
1284
+ const turnRows = turnColumns.has("session_id")
1285
+ ? readSqliteJsonRows(dbPath, `SELECT session_id${turnColumns.has("turn_id") ? ", turn_id" : ""} FROM turn_usage`, options)
1286
+ : [];
1287
+ const toolColumns = zcodeTableColumns(dbPath, "tool_usage");
1288
+ const toolRows = ["session_id", "tool_name", "turn_id"].every((name) => toolColumns.has(name))
1289
+ ? readSqliteJsonRows(dbPath, "SELECT session_id, turn_id, tool_name FROM tool_usage", options)
1290
+ : [];
1291
+ return { sessionRows, messages, requestRows, turnRows, toolRows };
1292
+ }
1293
+
1294
+ /** Merge only shared event identities, retaining both installs' distinct tails. */
1295
+ function uniqueZcodeRows(rows, keyFor) {
1296
+ const seen = new Set();
1297
+ return rows.filter((row) => {
1298
+ const key = keyFor(row);
1299
+ if (!key) return true;
1300
+ if (seen.has(key)) return false;
1301
+ seen.add(key);
1302
+ return true;
1303
+ });
1304
+ }
1305
+
1306
+ function buildZcodeSessionRecords(inputs, displayPath) {
1307
+ const collect = (key) => inputs.flatMap((input) => input[key] || []);
1308
+ const eventKey = (row, sessionKey, idKey) => row?.[sessionKey] && row?.[idKey]
1309
+ ? `${row[sessionKey]}\u0000${row[idKey]}` : null;
1310
+ const sessionRows = uniqueZcodeRows(collect("sessionRows"), (row) => row?.id);
1311
+ const fingerprintOwners = new Map();
1312
+ const forkCopies = new Set();
1313
+ const messages = uniqueZcodeRows(collect("messages"), (row) => eventKey(row, "sessionID", "id"))
1314
+ .filter((message) => {
1315
+ // Use the ledger's existing narrow fork-copy contract, including both
1316
+ // timestamps, all token columns, model and provider. Never dedup two
1317
+ // separate turns within one session by counters alone.
1318
+ const fingerprint = deriveOpencodeMessageFingerprint({
1319
+ msg: message.data, totals: zcodeMessageTotals(message.data), source: "zcode",
1320
+ });
1321
+ const owner = fingerprint && fingerprintOwners.get(fingerprint);
1322
+ if (owner && owner !== message.sessionID) {
1323
+ forkCopies.add(eventKey(message, "sessionID", "id"));
1324
+ return false;
1325
+ }
1326
+ if (fingerprint) fingerprintOwners.set(fingerprint, message.sessionID);
1327
+ return true;
1328
+ });
1329
+ const requestRows = uniqueZcodeRows(collect("requestRows"), (row) => eventKey(row, "session_id", "id"))
1330
+ .filter((row) => !forkCopies.has(eventKey(row, "session_id", "id")));
1331
+ const turnRows = uniqueZcodeRows(collect("turnRows"), (row) => eventKey(row, "session_id", "turn_id"));
1332
+ const toolRows = collect("toolRows");
1333
+
1334
+ const messagesBySession = new Map();
1335
+ // propagateReadFailure: a failed native-usage query must abort the scan (the
1336
+ // caller counts the db as skipped) rather than silently degrade to the
1337
+ // legacy tables and cache a zero-token inventory for those sessions.
1338
+ for (const message of messages) {
1339
+ const sessionId = String(message?.sessionID || "").trim();
1340
+ if (!sessionId) continue;
1341
+ let group = messagesBySession.get(sessionId);
1342
+ if (!group) {
1343
+ group = [];
1344
+ messagesBySession.set(sessionId, group);
1345
+ }
1346
+ group.push(message);
1347
+ }
1348
+
1349
+ // Request-level signals (never token totals). Only completed requests feed
1350
+ // duration-based estimates; error rows count toward error_count alone. The
1351
+ // provider filter mirrors isZcodeNativeMessage's blocklist (see rollout.js):
1352
+ // rows ZCode orchestrated through claude/codex/gemini sub-agents already
1353
+ // appear as their own provider's sessions and must not skew signals either.
1354
+ const signalsBySession = new Map();
1355
+ for (const row of requestRows) {
1356
+ const sessionId = String(row?.session_id || "").trim();
1357
+ if (!sessionId) continue;
1358
+ let signals = signalsBySession.get(sessionId);
1359
+ if (!signals) {
1360
+ signals = {
1361
+ collector: createZcodePerformanceCollector(),
1362
+ apiDurationMs: 0,
1363
+ compactionCount: 0,
1364
+ errorCount: 0,
1365
+ // Request wall-clock extremes from model_usage. Messages only carry
1366
+ // startedAt (the reader maps it to timeUpdated), so without these the
1367
+ // last generation's duration never reaches ended_at and a session's
1368
+ // final request reports a zero-length span.
1369
+ firstRequestIso: null,
1370
+ lastRequestEndIso: null,
1371
+ // [startMs, endMs] per request for the active-time union below.
1372
+ activeSpans: [],
1373
+ };
1374
+ signalsBySession.set(sessionId, signals);
1375
+ }
1376
+ // Match readZcodeNativeUsageMessages: databases may store epoch seconds
1377
+ // or milliseconds, but request spans and message bounds must share units.
1378
+ const requestStartMs = coerceEpochMs(row?.started_at);
1379
+ if (requestStartMs > 0) {
1380
+ const startIso = new Date(requestStartMs).toISOString();
1381
+ if (!signals.firstRequestIso || startIso < signals.firstRequestIso) signals.firstRequestIso = startIso;
1382
+ // duration_ms only exists on completed rows; an error request still
1383
+ // occupied its start instant, so its end falls back to the start.
1384
+ const durationMs = row.status === "completed" ? finite(row.duration_ms) : 0;
1385
+ const endIso = new Date(requestStartMs + durationMs).toISOString();
1386
+ if (!signals.lastRequestEndIso || endIso > signals.lastRequestEndIso) signals.lastRequestEndIso = endIso;
1387
+ signals.activeSpans.push([requestStartMs, requestStartMs + durationMs]);
1388
+ }
1389
+ if (row.status === "error") {
1390
+ signals.errorCount += 1;
1391
+ continue;
1392
+ }
1393
+ if (row.query_source === "compact") signals.compactionCount += 1;
1394
+ signals.apiDurationMs += finite(row.duration_ms);
1395
+ signals.collector.consumeUsage({
1396
+ model: normalizeSessionModel(row.model_id) || "unknown",
1397
+ outputTokens: finite(row.output_tokens),
1398
+ durationMs: finite(row.duration_ms),
1399
+ timeToFirstTokenMs: typeof row.time_to_first_token_ms === "number" ? row.time_to_first_token_ms : undefined,
1400
+ });
1401
+ }
1402
+ const turnsBySession = new Map();
1403
+ for (const row of turnRows) {
1404
+ const sessionId = String(row?.session_id || "").trim();
1405
+ if (!sessionId) continue;
1406
+ turnsBySession.set(sessionId, (turnsBySession.get(sessionId) || 0) + 1);
1407
+ }
1408
+
1409
+ // ZCode writes Capitalized tool names (Edit/Write); EDIT_TOOLS is lowercase.
1410
+ // An edit turn is a turn that used an edit tool, matching the Grok definition.
1411
+ const editTurnsBySession = new Map();
1412
+ const editTurnIds = new Map();
1413
+ for (const row of toolRows) {
1414
+ if (!EDIT_TOOLS.has(String(row?.tool_name || "").toLowerCase())) continue;
1415
+ const sessionId = String(row?.session_id || "").trim();
1416
+ const turnId = String(row?.turn_id || "").trim();
1417
+ if (!sessionId || !turnId) continue;
1418
+ let turns = editTurnIds.get(sessionId);
1419
+ if (!turns) {
1420
+ turns = new Set();
1421
+ editTurnIds.set(sessionId, turns);
1422
+ }
1423
+ turns.add(turnId);
1424
+ }
1425
+ for (const [sessionId, turns] of editTurnIds) editTurnsBySession.set(sessionId, turns.size);
1426
+
1427
+ const records = [];
1428
+ for (const row of Array.isArray(sessionRows) ? sessionRows : []) {
1429
+ const sessionId = String(row?.id || "").trim();
1430
+ if (!sessionId) continue;
1431
+ const messages = messagesBySession.get(sessionId) || [];
1432
+ const tokens = emptyTotals();
1433
+ const modelUsageByModel = new Map();
1434
+ const bounds = emptyBounds();
1435
+ const usageBuckets = new Map();
1436
+ const messagePoints = [];
1437
+ let messageCwd = null;
1438
+ for (const message of messages) {
1439
+ const data = message?.data || {};
1440
+ // OpenCode v1 messages carry a flat modelID; v2 (session_message era)
1441
+ // nests it as model.id, and some forks wrote model as a plain string.
1442
+ // Resolve every shape through the shared normalizer so by-model
1443
+ // attribution and pricing match the usage sync on v2-layout databases.
1444
+ const model = normalizeSessionModel(normalizeOpencodeModelFields(data).modelId) || "unknown";
1445
+ const delta = zcodeMessageTotals(data);
1446
+ addTotals(tokens, delta);
1447
+ let usage = modelUsageByModel.get(model);
1448
+ if (!usage) {
1449
+ usage = { model, ...emptyTotals(), usage_events: 0 };
1450
+ modelUsageByModel.set(model, usage);
1451
+ }
1452
+ addTotals(usage, delta);
1453
+ usage.usage_events += 1;
1454
+ // Message timestamps are epoch-ms integers; updateBounds() only accepts
1455
+ // ISO strings and silently drops anything else.
1456
+ const timeUpdated = Number(message?.timeUpdated);
1457
+ if (Number.isFinite(timeUpdated) && timeUpdated > 0) {
1458
+ updateBounds(bounds, new Date(timeUpdated).toISOString());
1459
+ messagePoints.push(timeUpdated);
1460
+ // Minute buckets (same shape as the Codex scanner's) feed the from/to
1461
+ // day filter in scopeSessionUsage; without them a multi-day session
1462
+ // bills its whole lifetime to every day it touches.
1463
+ const timestamp = new Date(Math.floor(timeUpdated / 60_000) * 60_000).toISOString();
1464
+ const bucketKey = `${timestamp}\u0000${model}`;
1465
+ const bucket = usageBuckets.get(bucketKey) || { timestamp, model, ...emptyTotals(), usage_events: 0 };
1466
+ addTotals(bucket, delta);
1467
+ bucket.usage_events += 1;
1468
+ usageBuckets.set(bucketKey, bucket);
1469
+ }
1470
+ if (!messageCwd && typeof data.path?.cwd === "string" && data.path.cwd.trim()) {
1471
+ messageCwd = data.path.cwd.trim();
1472
+ }
1473
+ }
1474
+ // Sessions without any message (claude-import migrations the import did
1475
+ // not rebuild, sessions abandoned before the first reply) keep the row's
1476
+ // own wall-clock span; they end up with zero tokens and the browser's
1477
+ // existing total_tokens > 0 filter drops them from the listing.
1478
+ if (messages.length === 0) {
1479
+ const created = Number(row?.time_created);
1480
+ const updated = Number(row?.time_updated);
1481
+ if (Number.isFinite(created) && created > 0) updateBounds(bounds, new Date(created).toISOString());
1482
+ if (Number.isFinite(updated) && updated > 0) updateBounds(bounds, new Date(updated).toISOString());
1483
+ }
1484
+
1485
+ const signals = signalsBySession.get(sessionId) || null;
1486
+ if (signals) {
1487
+ // Widen the wall-clock span with model_usage request extremes: message
1488
+ // timestamps are request starts, so the final request's duration would
1489
+ // otherwise vanish from ended_at (a 10s request reads as a 0-length
1490
+ // span). Direct comparison instead of updateBounds() keeps active_ms
1491
+ // (idle-gap working time) driven by the interval union below.
1492
+ if (signals.firstRequestIso && (!bounds.started_at || signals.firstRequestIso < bounds.started_at)) {
1493
+ bounds.started_at = signals.firstRequestIso;
1494
+ }
1495
+ if (signals.lastRequestEndIso && (!bounds.ended_at || signals.lastRequestEndIso > bounds.ended_at)) {
1496
+ bounds.ended_at = signals.lastRequestEndIso;
1497
+ }
1498
+ }
1499
+ // Active time = the union of request execution intervals plus message
1500
+ // instants, with spans ≤ IDLE_GAP_MS apart bridged. updateBounds' gap
1501
+ // accumulator only measures message-start-to-message-start, which loses
1502
+ // the execution time of every request but the last one (two 10s requests
1503
+ // around a 1h pause must read 20s, not 10s); the union also collapses
1504
+ // parallel requests instead of counting them twice. Legacy-only sessions
1505
+ // contribute points alone, which degenerates to the same idle-gap span
1506
+ // the generic scanners produce.
1507
+ const spans = [...(signals ? signals.activeSpans : []), ...messagePoints.map((ms) => [ms, ms])]
1508
+ .filter(([startMs, endMs]) => Number.isFinite(startMs) && Number.isFinite(endMs))
1509
+ .sort((a, b) => a[0] - b[0]);
1510
+ let unionActiveMs = 0;
1511
+ let unionStart = null;
1512
+ let unionEnd = null;
1513
+ for (const [startMs, endMs] of spans) {
1514
+ if (unionStart === null) {
1515
+ unionStart = startMs;
1516
+ unionEnd = endMs;
1517
+ continue;
1518
+ }
1519
+ if (startMs <= unionEnd + IDLE_GAP_MS) {
1520
+ if (endMs > unionEnd) unionEnd = endMs;
1521
+ continue;
1522
+ }
1523
+ unionActiveMs += unionEnd - unionStart;
1524
+ unionStart = startMs;
1525
+ unionEnd = endMs;
1526
+ }
1527
+ if (unionStart !== null) unionActiveMs += unionEnd - unionStart;
1528
+ bounds.active_ms = unionActiveMs;
1529
+ const finished = signals ? signals.collector.finish() : { performance: normalizePerformance(), byModel: new Map() };
1530
+ const modelUsage = [...modelUsageByModel.values()].map((usage) => ({
1531
+ ...usage,
1532
+ performance: finished.byModel.get(usage.model) || normalizePerformance(),
1533
+ }));
1534
+ const cwd = typeof row?.directory === "string" && row.directory.trim() ? row.directory.trim() : messageCwd;
1535
+ records.push(finalizeRecord({
1536
+ version: SIDECAR_VERSION,
1537
+ session_hash: sessionHash("zcode", sessionId),
1538
+ session_id: sessionId,
1539
+ // Local-only lineage: ZCode's own parent_id (subagent/workflow children).
1540
+ parent_session_id: typeof row?.parent_id === "string" && row.parent_id.trim() ? row.parent_id.trim() : null,
1541
+ // Local-only: stripped in summarizeSessions before any cloud/CSV export.
1542
+ title: cleanSessionTitle(row?.title),
1543
+ source: "zcode",
1544
+ // A database copied between Windows and POSIX retains its original cwd.
1545
+ project_key: projectKey(cwd && cwd.replace(/\\/g, "/"), displayPath),
1546
+ project_ref: cwd || null,
1547
+ model: modelUsage.length > 1 ? "mixed" : (modelUsage[0]?.model || "unknown"),
1548
+ model_usage: modelUsage,
1549
+ usage_buckets: [...usageBuckets.values()],
1550
+ ...bounds,
1551
+ turns: turnsBySession.get(sessionId) || 0,
1552
+ edit_turns: editTurnsBySession.get(sessionId) || 0,
1553
+ // Provider/API retries are not repeated user requests. This numeric
1554
+ // metadata has no user-prompt repetition evidence.
1555
+ retry_turns: 0,
1556
+ tokens,
1557
+ usage_events: messages.length,
1558
+ usage_precision: messages.length > 0 ? "reported" : "unavailable",
1559
+ cost_source: "model_pricing",
1560
+ provider_cost_usd: null,
1561
+ model_calls: messages.length,
1562
+ api_duration_ms: signals ? signals.apiDurationMs : 0,
1563
+ error_count: signals ? signals.errorCount : 0,
1564
+ compaction_count: signals ? signals.compactionCount : 0,
1565
+ performance: finished.performance,
1566
+ provenance: {
1567
+ source: "local-session-log",
1568
+ confidence: "observed",
1569
+ retry_confidence: "unavailable",
1570
+ content_retained: false,
1571
+ usage: messages.length > 0 ? "zcode-db-messages" : "unavailable",
1572
+ },
1573
+ }));
1574
+ }
1575
+ return records;
1576
+ }
1577
+
1118
1578
  // Multi-root discovery: on Windows a native install and a WSL one both count.
1119
1579
  // `sync` already walks both Claude homes (src/commands/sync.js), so without this
1120
1580
  // the WSL work lands in the token totals but is invisible in the session browser
@@ -1340,7 +1800,36 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
1340
1800
  const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
1341
1801
  .split(path.sep)
1342
1802
  .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
1343
- return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, incomplete };
1803
+ // ZCode has no sessions directory tree — one SQLite db per install. Resolve
1804
+ // it like the usage sync does (src/commands/sync.js): native candidate first
1805
+ // (ZCODE_HOME, home, win32 APPDATA fallback), then the WSL install when the
1806
+ // WSL mode probes it, picked with the same mode priority (default wsl-first)
1807
+ // so the browser reads the same installs the usage card counts. "both"
1808
+ // preserves both paths; the scanner merges shared session/event identities.
1809
+ const zcodePlatform = deps.platform || process.platform;
1810
+ const zcodeNativeDbPath = (deps.resolveZcodeNativeDbPath || resolveZcodeNativeDbPath)({ home, env, platform: zcodePlatform });
1811
+ // Same WSL gate as providerRoots(): discoverWslHome resolves \\wsl$
1812
+ // independently of `home`, so a custom/injected home must not splice the
1813
+ // machine's live WSL db into an isolated scan. `probeWsl` is the deliberate
1814
+ // opt-in (defaults to true only for the machine's own home).
1815
+ const zcodeProbeWsl = deps.probeWsl !== undefined
1816
+ ? Boolean(deps.probeWsl)
1817
+ : path.resolve(home) === path.resolve((deps.homedir || os.homedir)());
1818
+ const zcodeWslHome = zcodePlatform === "win32" && zcodeProbeWsl && wsl.shouldProbeWsl(env)
1819
+ ? (deps.discoverWslHome || wsl.discoverWslHome)(".zcode", { env })
1820
+ : null;
1821
+ const zcodeExisting = (dbPath) => (dbPath && fs.existsSync(dbPath) ? dbPath : null);
1822
+ const zcodeWslDbPath = zcodeWslHome ? path.join(zcodeWslHome, "cli", "db", "db.sqlite") : null;
1823
+ const zcodePaths = zcodePlatform === "win32"
1824
+ ? wsl.resolveAllWin32Paths({
1825
+ nativeValue: zcodeExisting(zcodeNativeDbPath),
1826
+ wslValue: zcodeExisting(zcodeWslDbPath),
1827
+ env,
1828
+ platform: zcodePlatform,
1829
+ })
1830
+ : { native: zcodeExisting(zcodeNativeDbPath), wsl: null };
1831
+ const zcode = [...new Set(Object.values(zcodePaths).filter(Boolean))];
1832
+ return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, zcode, incomplete };
1344
1833
  }
1345
1834
 
1346
1835
  // Warn once per unreadable directory per process: the dashboard polls this
@@ -1445,6 +1934,12 @@ function analyticsEntryStatKey(source, filePath) {
1445
1934
  const primary = filePaths[0];
1446
1935
  return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
1447
1936
  }
1937
+ if (source === "zcode") {
1938
+ // A WAL-mode db can absorb writes without touching the main file's mtime,
1939
+ // so the sidecar files are part of the db's identity for cache checks.
1940
+ return `${sessionStat}|sidecars:${filePaths.map((filePath) =>
1941
+ `wal:${sessionFileStatKey(`${filePath}-wal`) || "missing"}|shm:${sessionFileStatKey(`${filePath}-shm`) || "missing"}`).join("|")}`;
1942
+ }
1448
1943
  return sessionStat;
1449
1944
  }
1450
1945
 
@@ -1467,13 +1962,18 @@ async function writeAtomic(filePath, content) {
1467
1962
  async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, cacheTtlMs = 5 * 60_000 } = {}) {
1468
1963
  const sidecarPath = resolveSessionSidecarPath(home);
1469
1964
  const metaPath = `${sidecarPath}.meta.json`;
1965
+ // A lock/permission failure need not alter the database's stat signature.
1966
+ // Keep a separate durable invalidation marker while preserving the complete
1967
+ // sidecar and metadata. TTL, signature and per-file reuse must all retry it.
1968
+ const incompleteZcodePath = `${metaPath}.zcode-incomplete`;
1969
+ const retryZcodeScan = fs.existsSync(incompleteZcodePath);
1470
1970
  let previousMeta = null;
1471
1971
  if (!force) {
1472
1972
  try {
1473
1973
  previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
1474
1974
  const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
1475
1975
  if (
1476
- previousMeta.version === SIDECAR_VERSION &&
1976
+ !retryZcodeScan && previousMeta.version === SIDECAR_VERSION &&
1477
1977
  Number.isFinite(checkedAt) &&
1478
1978
  Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
1479
1979
  ) {
@@ -1506,8 +2006,10 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1506
2006
  ...discovered.grok,
1507
2007
  ...discovered.grok.map(grokSummaryPathFor),
1508
2008
  ...discovered.grok.map(grokSignalsPathFor),
2009
+ // db + its WAL sidecars: the trio is one logical file for change detection.
2010
+ ...(discovered.zcode || []).flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
1509
2011
  ]);
1510
- if (!force && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
2012
+ if (!force && !retryZcodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
1511
2013
  await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
1512
2014
  return readSidecar(sidecarPath);
1513
2015
  }
@@ -1527,44 +2029,74 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1527
2029
  ...discovered.claude.map((filePaths) => ({ source: "claude", filePath: filePaths, scan: scanClaudeSession })),
1528
2030
  ...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
1529
2031
  ...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
2032
+ ...(discovered.zcode.length ? [{ source: "zcode", filePath: discovered.zcode, scan: scanZcodeSessions }] : []),
1530
2033
  ];
1531
2034
  // Files we could not turn into a row (permission denied, half-written line,
1532
2035
  // vanished mid-scan). Swallowing these silently made sessions disappear with
1533
2036
  // no signal at all; count them so the API can say so.
1534
2037
  let skippedFiles = 0;
2038
+ let incompleteZcodeScan = false;
1535
2039
  for (const entry of entries) {
1536
2040
  const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
1537
2041
  const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
1538
2042
  if (!statKey) {
1539
2043
  skippedFiles += 1;
2044
+ if (entry.source === "zcode") incompleteZcodeScan = true;
1540
2045
  continue;
1541
2046
  }
1542
- let row = null;
1543
- if (!force && previousFiles[cacheKey]?.stat_key === statKey) {
1544
- row = previousRowsByFile.get(cacheKey) || null;
2047
+ let rows = null;
2048
+ if (!force && !(entry.source === "zcode" && retryZcodeScan) && previousFiles[cacheKey]?.stat_key === statKey) {
2049
+ if (entry.source === "zcode") {
2050
+ // One db scans to many cached rows; recover them all from the previous
2051
+ // pass. An empty recovery means the sidecar lost its rows — rescan.
2052
+ const cached = previousRows.filter((row) => row?.source === "zcode");
2053
+ rows = cached.length ? cached : null;
2054
+ } else {
2055
+ const row = previousRowsByFile.get(cacheKey) || null;
2056
+ rows = row ? [row] : null;
2057
+ }
1545
2058
  }
1546
- if (!row) {
2059
+ if (!rows) {
1547
2060
  try {
1548
- row = await entry.scan(entry.filePath);
2061
+ const scanned = await entry.scan(entry.filePath);
2062
+ rows = Array.isArray(scanned) ? scanned.filter(Boolean) : (scanned ? [scanned] : []);
1549
2063
  } catch (error) {
1550
2064
  // One active/partial session must not poison the sidecar, but do not
1551
2065
  // pretend it never existed either.
1552
2066
  skippedFiles += 1;
2067
+ if (entry.source === "zcode") incompleteZcodeScan = true;
1553
2068
  if (!process.env.NODE_TEST_CONTEXT) {
1554
2069
  console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
1555
2070
  }
2071
+ rows = [];
1556
2072
  }
1557
2073
  }
1558
- if (!row) continue;
1559
- repriceSessionRecord(row);
1560
- // A one-way file hash enables incremental reuse without persisting or
1561
- // exposing the user's local session path.
1562
- row._cache_key = cacheKey;
1563
- sessions.push(row);
1564
- nextFiles[cacheKey] = { stat_key: statKey };
2074
+ for (const row of rows) {
2075
+ repriceSessionRecord(row);
2076
+ // A one-way file hash enables incremental reuse without persisting or
2077
+ // exposing the user's local session path. ZCode's rows all share one db,
2078
+ // so key each by the db path plus its session id; a bare session id as a
2079
+ // separate path element would be path.resolve()d against the process cwd
2080
+ // and never match across runs.
2081
+ row._cache_key = entry.source === "zcode" && row.session_id
2082
+ ? `${cacheKey}#${sessionHash("zcode", row.session_id)}`
2083
+ : cacheKey;
2084
+ sessions.push(row);
2085
+ }
2086
+ if (rows.length > 0) nextFiles[cacheKey] = { stat_key: statKey };
2087
+ }
2088
+ if (incompleteZcodeScan) {
2089
+ await writeAtomic(incompleteZcodePath, "1\n");
2090
+ // ZCode databases form one deduplicated inventory. If any read fails, retain its last
2091
+ // complete rows even on a forced refresh; skippedFiles still reports that
2092
+ // these rows could not be refreshed. Older cache schemas must be rebuilt.
2093
+ sessions.push(...readSidecar(sidecarPath).filter((row) =>
2094
+ row.source === "zcode" && row.version === SIDECAR_VERSION));
1565
2095
  }
1566
2096
  sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
1567
- if (incompleteDirs.length === 0) {
2097
+ // A failed database scan is an incomplete inventory. Keep the last complete
2098
+ // snapshot and signature so an unchanged database is retried next build.
2099
+ if (incompleteDirs.length === 0 && !incompleteZcodeScan) {
1568
2100
  const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
1569
2101
  await writeAtomic(sidecarPath, content);
1570
2102
  const generatedAt = new Date().toISOString();
@@ -1575,6 +2107,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1575
2107
  checked_at: generatedAt,
1576
2108
  files: nextFiles,
1577
2109
  })}\n`);
2110
+ await fsp.rm(incompleteZcodePath, { force: true });
1578
2111
  }
1579
2112
  // Non-enumerable so the array still behaves exactly like a plain row list
1580
2113
  // for every existing caller (map/filter/JSON of the rows is unaffected).
@@ -1617,17 +2150,55 @@ function buildSessionAnalytics(options = {}) {
1617
2150
  // exactly the long/resumed ones, which also sort to the top because the sort
1618
2151
  // key is ended_at. On real data a 7-day window lost 6 sessions, the largest
1619
2152
  // 224M tokens. Treat the window as an interval intersection instead.
1620
- function withinDayRange(row, from, to) {
1621
- const startDay = String(row?.started_at || row?.ended_at || "").slice(0, 10);
1622
- const endDay = String(row?.ended_at || row?.started_at || "").slice(0, 10);
2153
+ function sessionUsageDay(timestamp, { timeZone, offsetMinutes } = {}) {
2154
+ const date = new Date(timestamp);
2155
+ if (!Number.isFinite(date.getTime())) return "";
2156
+ if (timeZone) {
2157
+ try {
2158
+ const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date);
2159
+ const part = (name) => parts.find((p) => p.type === name)?.value;
2160
+ return `${part("year")}-${part("month")}-${part("day")}`;
2161
+ } catch { /* fall back to the caller's offset or UTC */ }
2162
+ }
2163
+ return new Date(date.getTime() + (Number.isFinite(offsetMinutes) ? offsetMinutes * 60_000 : 0)).toISOString().slice(0, 10);
2164
+ }
2165
+
2166
+ function withinDayRange(row, from, to, context) {
2167
+ const startDay = sessionUsageDay(row?.started_at || row?.ended_at || "", context);
2168
+ const endDay = sessionUsageDay(row?.ended_at || row?.started_at || "", context);
1623
2169
  if (from && (!endDay || endDay < from)) return false;
1624
2170
  if (to && (!startDay || startDay > to)) return false;
1625
2171
  return true;
1626
2172
  }
1627
2173
 
1628
- function summarizeSessions(sessions, { from = "", to = "", includeSessions = true } = {}) {
1629
- const filtered = annotateCodexThreadUsage((sessions || [])
1630
- .filter((row) => withinDayRange(row, from, to))
2174
+ function scopeSessionUsage(row, from, to, context) {
2175
+ if ((!from && !to) || !Array.isArray(row.usage_buckets)) return { ...row };
2176
+ const buckets = row.usage_buckets.filter((bucket) => {
2177
+ const day = sessionUsageDay(bucket.timestamp, context);
2178
+ return (!from || day >= from) && (!to || day <= to);
2179
+ });
2180
+ const tokens = emptyTotals();
2181
+ const byModel = new Map();
2182
+ for (const bucket of buckets) {
2183
+ addTotals(tokens, bucket);
2184
+ const model = byModel.get(bucket.model) || { model: bucket.model, ...emptyTotals(), usage_events: 0 };
2185
+ addTotals(model, bucket);
2186
+ mergeUsageMetadata(model, bucket);
2187
+ if (!bucket.usage_events) model.usage_events += 1;
2188
+ byModel.set(bucket.model, model);
2189
+ }
2190
+ return repriceSessionRecord({ ...row, tokens, total_tokens: tokens.total_tokens,
2191
+ model_usage: [...byModel.values()], usage_buckets: buckets, cost_usd: 0,
2192
+ lifetime_total_tokens: row.total_tokens, lifetime_cost_usd: row.cost_usd });
2193
+ }
2194
+
2195
+ function summarizeSessions(sessions, { from = "", to = "", includeSessions = true, timeZone, offsetMinutes } = {}) {
2196
+ const context = { timeZone, offsetMinutes };
2197
+ const filtered = annotateThreadUsage((sessions || [])
2198
+ .filter((row) => withinDayRange(row, from, to, context))
2199
+ // Efficiency is a session-lifetime metric: both tokens and edit turns
2200
+ // must describe that same population. The browser scopes consumption
2201
+ // separately; do not divide in-range tokens by lifetime edit counts here.
1631
2202
  .map((row) => ({ ...row })));
1632
2203
  const byModel = new Map();
1633
2204
  const subagents = new Map();
@@ -1714,7 +2285,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
1714
2285
  if (key === primaryModel) agg.retries += finite(row.retry_turns);
1715
2286
  byModel.set(key, agg);
1716
2287
  }
1717
- if (row.source === "codex" && row.parent_session_hash) {
2288
+ if ((row.source === "codex" || row.source === "zcode") && row.parent_session_hash) {
1718
2289
  const name = row.agent_role || row.agent_nickname || "subagent";
1719
2290
  const sub = subagents.get(name) || {
1720
2291
  name,
@@ -1731,9 +2302,9 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
1731
2302
  subagents.set(name, sub);
1732
2303
  continue;
1733
2304
  }
1734
- // Codex child rollouts are first-class rows. A spawn call in the parent is
2305
+ // Codex and ZCode children are first-class rows. A spawn call in the parent is
1735
2306
  // a control-plane event, not token usage, so do not mix in an estimate.
1736
- if (row.source === "codex") continue;
2307
+ if (row.source === "codex" || row.source === "zcode") continue;
1737
2308
  for (const [name, calls] of Object.entries(row.subagent_types || {})) {
1738
2309
  const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
1739
2310
  sub.calls += finite(calls);
@@ -1838,12 +2409,19 @@ function toSessionBrowserRow(row) {
1838
2409
  parent_link_conflict: Boolean(row.parent_link_conflict),
1839
2410
  title: row.title || null,
1840
2411
  source: row.source,
2412
+ ...(Number.isFinite(row.session_store_bytes) ? {
2413
+ session_store_bytes: row.session_store_bytes,
2414
+ session_store_metric: row.session_store_metric,
2415
+ bytes_per_1k_tokens: (row.lifetime_total_tokens ?? row.total_tokens) > 0
2416
+ ? row.session_store_bytes * 1000 / (row.lifetime_total_tokens ?? row.total_tokens) : null,
2417
+ } : {}),
1841
2418
  project_key: row.project_key,
1842
2419
  // project_ref (the local cwd) only ever travels over the local API so the
1843
2420
  // browser can show where a session ran and compose a resume command.
1844
2421
  project_ref: row.project_ref || null,
1845
2422
  model: row.model,
1846
2423
  model_usage: modelUsageForAggregation(row),
2424
+ ...(Array.isArray(row.usage_buckets) ? { usage_buckets: row.usage_buckets } : {}),
1847
2425
  performance: normalizePerformance(row.performance),
1848
2426
  started_at: row.started_at || null,
1849
2427
  ended_at: row.ended_at || null,
@@ -1914,11 +2492,15 @@ function mergeSessionFragments(rows) {
1914
2492
  cur.performance = mergePerformance(cur.performance, row.performance);
1915
2493
  cur.subagent_calls = finite(cur.subagent_calls) + finite(row.subagent_calls);
1916
2494
  addTotals(cur.tokens, row.tokens);
2495
+ if (Array.isArray(row.usage_buckets)) {
2496
+ cur.usage_buckets = [...(cur.usage_buckets || []), ...row.usage_buckets];
2497
+ }
1917
2498
  cur.model_usage = normalizeModelUsageRows([
1918
2499
  ...(cur.model_usage || []),
1919
2500
  ...(row.model_usage || []),
1920
2501
  ]);
1921
2502
  cur.total_tokens = finite(cur.total_tokens) + finite(row.total_tokens);
2503
+ if (Number.isFinite(row.session_store_bytes)) cur.session_store_bytes = finite(cur.session_store_bytes) + row.session_store_bytes;
1922
2504
  cur.cost_usd = finite(cur.cost_usd) + finite(row.cost_usd);
1923
2505
  cur.provider_cost_usd = finite(cur.provider_cost_usd) + finite(row.provider_cost_usd);
1924
2506
  cur.usage_events = finite(cur.usage_events) + finite(row.usage_events);
@@ -1983,32 +2565,36 @@ function mergeSessionFragments(rows) {
1983
2565
  return merged;
1984
2566
  }
1985
2567
 
1986
- // Attach exact Codex parent/child usage without changing the authoritative
1987
- // per-session total_tokens field. Combined fields are derived views only, so
1988
- // summing total_tokens across all rows still equals the global usage ledger.
1989
- function annotateCodexThreadUsage(rows) {
2568
+ // Attach exact Codex/ZCode parent→child usage without changing the
2569
+ // authoritative per-session total_tokens field. Combined fields are derived
2570
+ // views only, so summing total_tokens across all rows still equals the global
2571
+ // usage ledger. ZCode joins the same lineage pass: its subagent/workflow
2572
+ // children carry parent_session_id from the session table, so the browser can
2573
+ // nest them under their parent and count them as its subagents.
2574
+ function annotateThreadUsage(rows) {
1990
2575
  const byId = new Map();
1991
2576
  const childrenById = new Map();
1992
2577
  const linkedParentById = new Map();
2578
+ const identity = (row, id = row.session_id) => `${row.source}\u0000${id}`;
1993
2579
 
1994
2580
  for (const row of rows || []) {
1995
- if (row?.source !== "codex" || typeof row.session_id !== "string" || !row.session_id) continue;
1996
- const existing = byId.get(row.session_id);
2581
+ if ((row?.source !== "codex" && row?.source !== "zcode") || typeof row.session_id !== "string" || !row.session_id) continue;
2582
+ const existing = byId.get(identity(row));
1997
2583
  if (existing && existing !== row) {
1998
2584
  existing.parent_link_conflict = true;
1999
2585
  row.parent_link_conflict = true;
2000
2586
  continue;
2001
2587
  }
2002
- byId.set(row.session_id, row);
2588
+ byId.set(identity(row), row);
2003
2589
  }
2004
2590
 
2005
2591
  function hasParentCycle(start) {
2006
2592
  const seen = new Set();
2007
2593
  let current = start;
2008
- while (current?.parent_session_id && byId.has(current.parent_session_id)) {
2009
- if (seen.has(current.session_id) || current.parent_session_id === current.session_id) return true;
2010
- seen.add(current.session_id);
2011
- current = byId.get(current.parent_session_id);
2594
+ while (current?.parent_session_id && byId.has(identity(current, current.parent_session_id))) {
2595
+ if (seen.has(identity(current)) || current.parent_session_id === current.session_id) return true;
2596
+ seen.add(identity(current));
2597
+ current = byId.get(identity(current, current.parent_session_id));
2012
2598
  }
2013
2599
  return false;
2014
2600
  }
@@ -2016,48 +2602,49 @@ function annotateCodexThreadUsage(rows) {
2016
2602
  for (const row of byId.values()) {
2017
2603
  const parentId = typeof row.parent_session_id === "string" ? row.parent_session_id.trim() : "";
2018
2604
  if (!parentId) continue;
2605
+ const parentKey = identity(row, parentId);
2019
2606
  if (row.parent_link_conflict || hasParentCycle(row)) {
2020
2607
  row.parent_link_conflict = true;
2021
2608
  continue;
2022
2609
  }
2023
- if (!byId.has(parentId)) {
2610
+ if (!byId.has(parentKey)) {
2024
2611
  row.orphaned_subagent = true;
2025
2612
  continue;
2026
2613
  }
2027
- const children = childrenById.get(parentId) || [];
2614
+ const children = childrenById.get(parentKey) || [];
2028
2615
  children.push(row);
2029
- childrenById.set(parentId, children);
2030
- linkedParentById.set(row.session_id, parentId);
2616
+ childrenById.set(parentKey, children);
2617
+ linkedParentById.set(identity(row), parentKey);
2031
2618
  }
2032
2619
 
2033
2620
  const memo = new Map();
2034
2621
  function aggregate(row, visiting = new Set()) {
2035
- if (memo.has(row.session_id)) return memo.get(row.session_id);
2036
- if (visiting.has(row.session_id)) {
2622
+ if (memo.has(identity(row))) return memo.get(identity(row));
2623
+ if (visiting.has(identity(row))) {
2037
2624
  row.parent_link_conflict = true;
2038
2625
  return { count: 0, tokens: 0, cost: 0 };
2039
2626
  }
2040
2627
  const nextVisiting = new Set(visiting);
2041
- nextVisiting.add(row.session_id);
2628
+ nextVisiting.add(identity(row));
2042
2629
  let count = 0;
2043
2630
  let tokens = 0;
2044
2631
  let cost = 0;
2045
- for (const child of childrenById.get(row.session_id) || []) {
2632
+ for (const child of childrenById.get(identity(row)) || []) {
2046
2633
  const nested = aggregate(child, nextVisiting);
2047
2634
  count += 1 + nested.count;
2048
2635
  tokens += finite(child.total_tokens) + nested.tokens;
2049
2636
  cost += finite(child.cost_usd) + nested.cost;
2050
2637
  }
2051
2638
  const result = { count, tokens, cost };
2052
- memo.set(row.session_id, result);
2639
+ memo.set(identity(row), result);
2053
2640
  return result;
2054
2641
  }
2055
2642
 
2056
2643
  function rootFor(row) {
2057
- const seen = new Set([row.session_id]);
2644
+ const seen = new Set([identity(row)]);
2058
2645
  let current = row;
2059
- while (current && linkedParentById.has(current.session_id)) {
2060
- const linkedParentId = linkedParentById.get(current.session_id);
2646
+ while (current && linkedParentById.has(identity(current))) {
2647
+ const linkedParentId = linkedParentById.get(identity(current));
2061
2648
  if (seen.has(linkedParentId)) {
2062
2649
  row.parent_link_conflict = true;
2063
2650
  break;
@@ -2069,12 +2656,14 @@ function annotateCodexThreadUsage(rows) {
2069
2656
  }
2070
2657
 
2071
2658
  for (const row of rows || []) {
2659
+ const threadAware = (row.source === "codex" || row.source === "zcode")
2660
+ && typeof row.session_id === "string" && row.session_id;
2072
2661
  row.own_total_tokens = finite(row.total_tokens);
2073
2662
  row.own_cost_usd = finite(row.cost_usd);
2074
- row.direct_subagent_count = row.source === "codex" && row.session_id
2075
- ? (childrenById.get(row.session_id) || []).length
2663
+ row.direct_subagent_count = threadAware
2664
+ ? (childrenById.get(identity(row)) || []).length
2076
2665
  : 0;
2077
- const nested = row.source === "codex" && row.session_id
2666
+ const nested = threadAware
2078
2667
  ? aggregate(row)
2079
2668
  : { count: 0, tokens: 0, cost: 0 };
2080
2669
  row.descendant_subagent_count = nested.count;
@@ -2082,8 +2671,8 @@ function annotateCodexThreadUsage(rows) {
2082
2671
  row.subagent_cost_usd = nested.cost;
2083
2672
  row.combined_total_tokens = finite(row.total_tokens) + nested.tokens;
2084
2673
  row.combined_cost_usd = finite(row.cost_usd) + nested.cost;
2085
- if (row.source === "codex" && row.session_id) {
2086
- const parent = byId.get(linkedParentById.get(row.session_id));
2674
+ if (threadAware) {
2675
+ const parent = byId.get(linkedParentById.get(identity(row)));
2087
2676
  row.parent_session_hash = parent?.session_hash || null;
2088
2677
  row.root_session_hash = rootFor(row).session_hash || row.session_hash;
2089
2678
  }
@@ -2095,15 +2684,18 @@ function annotateCodexThreadUsage(rows) {
2095
2684
  // summarizeSessions (cloud/CSV safe), this retains session_id + project_ref so
2096
2685
  // the UI can offer one-click resume. Callers must only expose it over the
2097
2686
  // local API.
2098
- function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}) {
2099
- const filtered = annotateCodexThreadUsage(mergeSessionFragments(sessions)
2687
+ function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZone, offsetMinutes } = {}) {
2688
+ const context = { timeZone, offsetMinutes };
2689
+ const filtered = annotateThreadUsage(mergeSessionFragments(sessions)
2100
2690
  // Only sessions that actually spent tokens are worth listing. This drops
2101
2691
  // two kinds of noise: non-session logs under ~/.claude/projects
2102
2692
  // (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
2103
2693
  // the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
2104
2694
  // no model, no cost and nothing to analyze.
2105
2695
  .filter((row) => finite(row.total_tokens) > 0)
2106
- .filter((row) => withinDayRange(row, from, to)));
2696
+ .filter((row) => withinDayRange(row, from, to, context))
2697
+ .map((row) => scopeSessionUsage(row, from, to, context))
2698
+ .filter((row) => !Array.isArray(row.usage_buckets) || finite(row.total_tokens) > 0));
2107
2699
  filtered.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
2108
2700
  const cap = Number(limit) > 0 ? Number(limit) : 0;
2109
2701
  const limited = cap > 0 ? filtered.slice(0, cap) : filtered;
@@ -2137,6 +2729,7 @@ module.exports = {
2137
2729
  scanClaudeSession,
2138
2730
  scanCodexSession,
2139
2731
  scanGrokSession,
2732
+ scanZcodeSessions,
2140
2733
  buildSessionAnalytics,
2141
2734
  summarizeSessions,
2142
2735
  listSessionsForBrowser,