tokentracker-cli 1.3.5 → 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 (112) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-Dazs3pEa.js → AchievementBadge-CHDt55IP.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-LWnxZvmw.js → AchievementsPage-DpbRHmb8.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-BLqczmU9.js → AchievementsSection-Ck5CiX4P.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-B_n4aj2S.js → BadgeDetailModal-BVPqlh0X.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-KDWI_YcZ.js → BotAnimated-CVsv6kxT.js} +1 -1
  6. package/dashboard/dist/assets/{Card-BYoxd4OC.js → Card-C9FjyAwh.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-BR2_B03P.js → CloudCheckoutPage-ChkjId9l.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-DL5RkJKK.js → CloudPage-D1nrt3MG.js} +1 -1
  9. package/dashboard/dist/assets/{CloudUsageExport-C_DqZKo1.js → CloudUsageExport-jXdoiD0e.js} +1 -1
  10. package/dashboard/dist/assets/{CommandPalette-DkCaIQI4.js → CommandPalette-DFnEeZzc.js} +1 -1
  11. package/dashboard/dist/assets/{CommunityStatsModal-pX9OePi4.js → CommunityStatsModal-CZDS8gPA.js} +1 -1
  12. package/dashboard/dist/assets/{ConfirmModal-tkgZwz4u.js → ConfirmModal-DRhgBoGE.js} +1 -1
  13. package/dashboard/dist/assets/{Controls-MTUhGWy8.js → Controls-BmT6-x_L.js} +1 -1
  14. package/dashboard/dist/assets/{DashboardPage-SOx-A7lH.js → DashboardPage-DfwwXZBo.js} +1 -1
  15. package/dashboard/dist/assets/{DateRangePopover-B-BMOm3O.js → DateRangePopover-DuqNLatr.js} +1 -1
  16. package/dashboard/dist/assets/{DevicePage-DSK9lm7v.js → DevicePage-BtI0bWyg.js} +1 -1
  17. package/dashboard/dist/assets/{DialogClose-uVUNu4rg.js → DialogClose-DjnC3P4V.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-yy_QHg7O.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-B-VifiA3.js → DialogTitle-B9NNSz5p.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-B6lrj4uo.js → FadeIn-fsVQmJGa.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-Cx5zOhXa.js → HeaderGithubStar-Cg-f1iZc.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-DY1ENf69.js → HoverTooltip-DRIRlzXH.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-D6TEq64d.js → IpCheckPage-DaAubaJQ.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-OdZUfq--.js → LandingPage-DjBGIjHk.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-DCK5xcAt.js → LeaderboardPage-BkCXqhz7.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-CBXJ1u_m.js → LeaderboardProBadge-Ds-wiWcN.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-Ccdzb7QK.js → LeaderboardProfileModal-X_rfSQT5.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CFXlZ0fL.js → LeaderboardProfilePage-rpl1Ozwj.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-5krjm-g9.js → LimitsPage-BUvDBmgD.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-Rk1B8PaZ.js → LocalOnlyNotice-LBH1DBrs.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-DTTIm8vk.js → LoginCard-B0vRJEcg.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-BNcDdjfr.js → LoginModal-DdzgP43f.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-CQgXP7Ee.js → LoginPage-B4VCQxzn.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-Cw_kAPRs.js → PetPage-Cmf6aXc9.js} +1 -1
  35. package/dashboard/dist/assets/{PopoverPopup-NYiGE8A2.js → PopoverPopup-BzfELnIC.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-Bp7iFJ_m.js → ProviderIcon-ClHXNkQ0.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-Cf9kAWDW.js → ResetPasswordPage-BeUpNt_6.js} +1 -1
  38. package/dashboard/dist/assets/{Select-kIcvQFAo.js → Select-BHEXdYHu.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-BpIsAg3b.js → SelectItemText-aaQK_QIt.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-Ch2vvSwI.js → SelfHostPage-ClNhKnlP.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-hoYBvGbu.js → ServiceStatusPage-Byd_s6FS.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-BzRB6q-Z.js → SessionsPage-u3_b4PO7.js} +2 -2
  43. package/dashboard/dist/assets/{SettingsPage-DRl2uX-i.js → SettingsPage-DweCAbsy.js} +2 -2
  44. package/dashboard/dist/assets/{SkillsPage-CkqThTZL.js → SkillsPage-Bb-PVfux.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-eLG-UIZY.js → TokenGalaxy-CubA3PXu.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-bnMJyilQ.js → TooltipPopup-DMliWhoK.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-X7-aGzIv.js → TrendMonitor-as8idaOy.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-CGznIzrW.js → WidgetsPage-DDPR4v0R.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-p31-uUoF.js → WrappedPage-zZzgYz6s.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-2g_4s3ot.js → __vite-browser-external-DWN9Fb-H.js} +2 -2
  51. package/dashboard/dist/assets/{agent-logos-2TVT3fIE.js → agent-logos-CoNhCm9-.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-left-DL0hAp_7.js → arrow-left-C5hDp7fc.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-right-DFCpnV_9.js → arrow-right-DEvMmSiA.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-DLvfE0Wu.js → arrow-up-right-DxY0neJ4.js} +1 -1
  55. package/dashboard/dist/assets/{calendar-days-B0aDoKSG.js → calendar-days-bzh7-lab.js} +1 -1
  56. package/dashboard/dist/assets/{check-C_cfrxiz.js → check-CSZvcC3X.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-down-Ck8ml43e.js → chevron-down-CsiGcG0G.js} +1 -1
  58. package/dashboard/dist/assets/{cloud-checkout-PYnwNfdH.js → cloud-checkout-z_eDVwm6.js} +1 -1
  59. package/dashboard/dist/assets/{composite-INW-ELWX.js → composite-Cq0CmNAv.js} +1 -1
  60. package/dashboard/dist/assets/{download-B7uokeIo.js → download-DqLpubIF.js} +1 -1
  61. package/dashboard/dist/assets/{external-link-CI3crg4K.js → external-link-B8E8tbB3.js} +1 -1
  62. package/dashboard/dist/assets/{flame-DCzvFKV1.js → flame-CrJDlnro.js} +1 -1
  63. package/dashboard/dist/assets/{format-tokens-CSTEYK2m.js → format-tokens-DPIGGOlt.js} +1 -1
  64. package/dashboard/dist/assets/{gift-dEHDFMc6.js → gift-CCp4Hh50.js} +1 -1
  65. package/dashboard/dist/assets/{host-mode-r2SW9Mdv.js → host-mode-xzpMLcwP.js} +1 -1
  66. package/dashboard/dist/assets/{icons-q-3zvEkh.js → icons-CbB9Y-eV.js} +1 -1
  67. package/dashboard/dist/assets/{index-CyHYj9XI.js → index-BiK5-tQp.js} +1 -1
  68. package/dashboard/dist/assets/{index-BV7qwUw4.js → index-DX7nYrHb.js} +1 -1
  69. package/dashboard/dist/assets/{index-BbrsgHf4.js → index-zuC7FU-t.js} +1 -1
  70. package/dashboard/dist/assets/{info-BBdY9hV4.js → info-B3NKYwWG.js} +1 -1
  71. package/dashboard/dist/assets/{link-2-CLPePfXs.js → link-2-BRc8d8M9.js} +1 -1
  72. package/dashboard/dist/assets/{loader-circle-ETrjzfmn.js → loader-circle-Cm5Nsgp-.js} +1 -1
  73. package/dashboard/dist/assets/{modifiers.esm-DHqnp2ol.js → modifiers.esm-CYLYBECB.js} +1 -1
  74. package/dashboard/dist/assets/{monitor-smartphone-D1kdzrdb.js → monitor-smartphone--IZ4D84F.js} +1 -1
  75. package/dashboard/dist/assets/{play-Cxp-ronD.js → play-CUrMOLtq.js} +1 -1
  76. package/dashboard/dist/assets/{plus-4n94VzDK.js → plus-0hrCDl4r.js} +1 -1
  77. package/dashboard/dist/assets/{provider-display-CRlbeO7t.js → provider-display-CNK0FN3Q.js} +1 -1
  78. package/dashboard/dist/assets/{react-DlHjGQFN.js → react-CCNSVHEA.js} +1 -1
  79. package/dashboard/dist/assets/{safePolygon-CVZ27a2_.js → safePolygon-DsolkMUX.js} +1 -1
  80. package/dashboard/dist/assets/{search-BDPr56_h.js → search-DYmYb9xY.js} +1 -1
  81. package/dashboard/dist/assets/{server-Bm7fHvVa.js → server-CYcqzdSW.js} +1 -1
  82. package/dashboard/dist/assets/{shield-check-DtT8ACzI.js → shield-check-5zABLIr6.js} +1 -1
  83. package/dashboard/dist/assets/{skills-api-Dc1-2qu6.js → skills-api-CD-ojBGj.js} +1 -1
  84. package/dashboard/dist/assets/{sortable.esm-B-qN-Xk2.js → sortable.esm-BCn2Ms6R.js} +1 -1
  85. package/dashboard/dist/assets/{store-gHeBU8dQ.js → store-B22kwLs0.js} +1 -1
  86. package/dashboard/dist/assets/{terminal-D4VZbvhg.js → terminal-CTv-oTU2.js} +1 -1
  87. package/dashboard/dist/assets/{trash-BZtLAFQy.js → trash-C5MAh3GO.js} +1 -1
  88. package/dashboard/dist/assets/{use-community-stats-BdqndrbB.js → use-community-stats-BrgrxUD2.js} +1 -1
  89. package/dashboard/dist/assets/{use-latest-request-guard-DhGg1cDS.js → use-latest-request-guard-B1_rbvgH.js} +1 -1
  90. package/dashboard/dist/assets/{use-limits-display-prefs-D3HOcRFv.js → use-limits-display-prefs-BOk5f-HM.js} +1 -1
  91. package/dashboard/dist/assets/{use-native-settings-BniKwA_n.js → use-native-settings-B2gc_m9W.js} +1 -1
  92. package/dashboard/dist/assets/{use-ordered-list-b09H7EHq.js → use-ordered-list-N3sGpJ0u.js} +1 -1
  93. package/dashboard/dist/assets/{use-pet-settings-CPZdlq_e.js → use-pet-settings-sWFXgR3m.js} +2 -2
  94. package/dashboard/dist/assets/use-reduced-motion-C3MBYVqm.js +1 -0
  95. package/dashboard/dist/assets/{use-transform-CIdxjmI8.js → use-transform-BsaLmw46.js} +1 -1
  96. package/dashboard/dist/assets/{use-usage-limits-eJlU65yY.js → use-usage-limits-wL7v-S0r.js} +1 -1
  97. package/dashboard/dist/assets/{useCurrency-CM22CmAq.js → useCurrency-CbCnjf-N.js} +1 -1
  98. package/dashboard/dist/assets/{usePositioner-B3l2ulTw.js → usePositioner-MepYlL0A.js} +1 -1
  99. package/dashboard/dist/assets/{useTokenFormat-D7nzgxKw.js → useTokenFormat-Bq-rwuKa.js} +1 -1
  100. package/dashboard/dist/assets/{zap-Daxct1WV.js → zap-1ah5e11g.js} +1 -1
  101. package/dashboard/dist/index.html +1 -1
  102. package/dashboard/dist/ip-check.html +1 -1
  103. package/dashboard/dist/leaderboard.html +1 -1
  104. package/dashboard/dist/share.html +1 -1
  105. package/package.json +1 -1
  106. package/src/cli.js +1 -1
  107. package/src/lib/pricing/seed-snapshot.json +1 -1
  108. package/src/lib/rollout.js +49 -16
  109. package/src/lib/session-analytics.js +546 -52
  110. package/src/lib/session-performance.js +24 -1
  111. package/dashboard/dist/assets/DialogDescription-DBqGGWNj.js +0 -1
  112. package/dashboard/dist/assets/use-reduced-motion-D1gcKJUo.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 = 16;
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",
@@ -1162,6 +1173,408 @@ async function scanGrokSession(filePath) {
1162
1173
  });
1163
1174
  }
1164
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
+
1165
1578
  // Multi-root discovery: on Windows a native install and a WSL one both count.
1166
1579
  // `sync` already walks both Claude homes (src/commands/sync.js), so without this
1167
1580
  // the WSL work lands in the token totals but is invisible in the session browser
@@ -1387,7 +1800,36 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
1387
1800
  const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
1388
1801
  .split(path.sep)
1389
1802
  .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
1390
- 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 };
1391
1833
  }
1392
1834
 
1393
1835
  // Warn once per unreadable directory per process: the dashboard polls this
@@ -1492,6 +1934,12 @@ function analyticsEntryStatKey(source, filePath) {
1492
1934
  const primary = filePaths[0];
1493
1935
  return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
1494
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
+ }
1495
1943
  return sessionStat;
1496
1944
  }
1497
1945
 
@@ -1514,13 +1962,18 @@ async function writeAtomic(filePath, content) {
1514
1962
  async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, cacheTtlMs = 5 * 60_000 } = {}) {
1515
1963
  const sidecarPath = resolveSessionSidecarPath(home);
1516
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);
1517
1970
  let previousMeta = null;
1518
1971
  if (!force) {
1519
1972
  try {
1520
1973
  previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
1521
1974
  const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
1522
1975
  if (
1523
- previousMeta.version === SIDECAR_VERSION &&
1976
+ !retryZcodeScan && previousMeta.version === SIDECAR_VERSION &&
1524
1977
  Number.isFinite(checkedAt) &&
1525
1978
  Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
1526
1979
  ) {
@@ -1553,8 +2006,10 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1553
2006
  ...discovered.grok,
1554
2007
  ...discovered.grok.map(grokSummaryPathFor),
1555
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`]),
1556
2011
  ]);
1557
- if (!force && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
2012
+ if (!force && !retryZcodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
1558
2013
  await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
1559
2014
  return readSidecar(sidecarPath);
1560
2015
  }
@@ -1574,44 +2029,74 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1574
2029
  ...discovered.claude.map((filePaths) => ({ source: "claude", filePath: filePaths, scan: scanClaudeSession })),
1575
2030
  ...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
1576
2031
  ...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
2032
+ ...(discovered.zcode.length ? [{ source: "zcode", filePath: discovered.zcode, scan: scanZcodeSessions }] : []),
1577
2033
  ];
1578
2034
  // Files we could not turn into a row (permission denied, half-written line,
1579
2035
  // vanished mid-scan). Swallowing these silently made sessions disappear with
1580
2036
  // no signal at all; count them so the API can say so.
1581
2037
  let skippedFiles = 0;
2038
+ let incompleteZcodeScan = false;
1582
2039
  for (const entry of entries) {
1583
2040
  const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
1584
2041
  const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
1585
2042
  if (!statKey) {
1586
2043
  skippedFiles += 1;
2044
+ if (entry.source === "zcode") incompleteZcodeScan = true;
1587
2045
  continue;
1588
2046
  }
1589
- let row = null;
1590
- if (!force && previousFiles[cacheKey]?.stat_key === statKey) {
1591
- 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
+ }
1592
2058
  }
1593
- if (!row) {
2059
+ if (!rows) {
1594
2060
  try {
1595
- row = await entry.scan(entry.filePath);
2061
+ const scanned = await entry.scan(entry.filePath);
2062
+ rows = Array.isArray(scanned) ? scanned.filter(Boolean) : (scanned ? [scanned] : []);
1596
2063
  } catch (error) {
1597
2064
  // One active/partial session must not poison the sidecar, but do not
1598
2065
  // pretend it never existed either.
1599
2066
  skippedFiles += 1;
2067
+ if (entry.source === "zcode") incompleteZcodeScan = true;
1600
2068
  if (!process.env.NODE_TEST_CONTEXT) {
1601
2069
  console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
1602
2070
  }
2071
+ rows = [];
1603
2072
  }
1604
2073
  }
1605
- if (!row) continue;
1606
- repriceSessionRecord(row);
1607
- // A one-way file hash enables incremental reuse without persisting or
1608
- // exposing the user's local session path.
1609
- row._cache_key = cacheKey;
1610
- sessions.push(row);
1611
- 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));
1612
2095
  }
1613
2096
  sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
1614
- 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) {
1615
2100
  const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
1616
2101
  await writeAtomic(sidecarPath, content);
1617
2102
  const generatedAt = new Date().toISOString();
@@ -1622,6 +2107,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1622
2107
  checked_at: generatedAt,
1623
2108
  files: nextFiles,
1624
2109
  })}\n`);
2110
+ await fsp.rm(incompleteZcodePath, { force: true });
1625
2111
  }
1626
2112
  // Non-enumerable so the array still behaves exactly like a plain row list
1627
2113
  // for every existing caller (map/filter/JSON of the rows is unaffected).
@@ -1708,7 +2194,7 @@ function scopeSessionUsage(row, from, to, context) {
1708
2194
 
1709
2195
  function summarizeSessions(sessions, { from = "", to = "", includeSessions = true, timeZone, offsetMinutes } = {}) {
1710
2196
  const context = { timeZone, offsetMinutes };
1711
- const filtered = annotateCodexThreadUsage((sessions || [])
2197
+ const filtered = annotateThreadUsage((sessions || [])
1712
2198
  .filter((row) => withinDayRange(row, from, to, context))
1713
2199
  // Efficiency is a session-lifetime metric: both tokens and edit turns
1714
2200
  // must describe that same population. The browser scopes consumption
@@ -1799,7 +2285,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
1799
2285
  if (key === primaryModel) agg.retries += finite(row.retry_turns);
1800
2286
  byModel.set(key, agg);
1801
2287
  }
1802
- if (row.source === "codex" && row.parent_session_hash) {
2288
+ if ((row.source === "codex" || row.source === "zcode") && row.parent_session_hash) {
1803
2289
  const name = row.agent_role || row.agent_nickname || "subagent";
1804
2290
  const sub = subagents.get(name) || {
1805
2291
  name,
@@ -1816,9 +2302,9 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
1816
2302
  subagents.set(name, sub);
1817
2303
  continue;
1818
2304
  }
1819
- // 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
1820
2306
  // a control-plane event, not token usage, so do not mix in an estimate.
1821
- if (row.source === "codex") continue;
2307
+ if (row.source === "codex" || row.source === "zcode") continue;
1822
2308
  for (const [name, calls] of Object.entries(row.subagent_types || {})) {
1823
2309
  const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
1824
2310
  sub.calls += finite(calls);
@@ -2079,32 +2565,36 @@ function mergeSessionFragments(rows) {
2079
2565
  return merged;
2080
2566
  }
2081
2567
 
2082
- // Attach exact Codex parent/child usage without changing the authoritative
2083
- // per-session total_tokens field. Combined fields are derived views only, so
2084
- // summing total_tokens across all rows still equals the global usage ledger.
2085
- 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) {
2086
2575
  const byId = new Map();
2087
2576
  const childrenById = new Map();
2088
2577
  const linkedParentById = new Map();
2578
+ const identity = (row, id = row.session_id) => `${row.source}\u0000${id}`;
2089
2579
 
2090
2580
  for (const row of rows || []) {
2091
- if (row?.source !== "codex" || typeof row.session_id !== "string" || !row.session_id) continue;
2092
- 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));
2093
2583
  if (existing && existing !== row) {
2094
2584
  existing.parent_link_conflict = true;
2095
2585
  row.parent_link_conflict = true;
2096
2586
  continue;
2097
2587
  }
2098
- byId.set(row.session_id, row);
2588
+ byId.set(identity(row), row);
2099
2589
  }
2100
2590
 
2101
2591
  function hasParentCycle(start) {
2102
2592
  const seen = new Set();
2103
2593
  let current = start;
2104
- while (current?.parent_session_id && byId.has(current.parent_session_id)) {
2105
- if (seen.has(current.session_id) || current.parent_session_id === current.session_id) return true;
2106
- seen.add(current.session_id);
2107
- 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));
2108
2598
  }
2109
2599
  return false;
2110
2600
  }
@@ -2112,48 +2602,49 @@ function annotateCodexThreadUsage(rows) {
2112
2602
  for (const row of byId.values()) {
2113
2603
  const parentId = typeof row.parent_session_id === "string" ? row.parent_session_id.trim() : "";
2114
2604
  if (!parentId) continue;
2605
+ const parentKey = identity(row, parentId);
2115
2606
  if (row.parent_link_conflict || hasParentCycle(row)) {
2116
2607
  row.parent_link_conflict = true;
2117
2608
  continue;
2118
2609
  }
2119
- if (!byId.has(parentId)) {
2610
+ if (!byId.has(parentKey)) {
2120
2611
  row.orphaned_subagent = true;
2121
2612
  continue;
2122
2613
  }
2123
- const children = childrenById.get(parentId) || [];
2614
+ const children = childrenById.get(parentKey) || [];
2124
2615
  children.push(row);
2125
- childrenById.set(parentId, children);
2126
- linkedParentById.set(row.session_id, parentId);
2616
+ childrenById.set(parentKey, children);
2617
+ linkedParentById.set(identity(row), parentKey);
2127
2618
  }
2128
2619
 
2129
2620
  const memo = new Map();
2130
2621
  function aggregate(row, visiting = new Set()) {
2131
- if (memo.has(row.session_id)) return memo.get(row.session_id);
2132
- if (visiting.has(row.session_id)) {
2622
+ if (memo.has(identity(row))) return memo.get(identity(row));
2623
+ if (visiting.has(identity(row))) {
2133
2624
  row.parent_link_conflict = true;
2134
2625
  return { count: 0, tokens: 0, cost: 0 };
2135
2626
  }
2136
2627
  const nextVisiting = new Set(visiting);
2137
- nextVisiting.add(row.session_id);
2628
+ nextVisiting.add(identity(row));
2138
2629
  let count = 0;
2139
2630
  let tokens = 0;
2140
2631
  let cost = 0;
2141
- for (const child of childrenById.get(row.session_id) || []) {
2632
+ for (const child of childrenById.get(identity(row)) || []) {
2142
2633
  const nested = aggregate(child, nextVisiting);
2143
2634
  count += 1 + nested.count;
2144
2635
  tokens += finite(child.total_tokens) + nested.tokens;
2145
2636
  cost += finite(child.cost_usd) + nested.cost;
2146
2637
  }
2147
2638
  const result = { count, tokens, cost };
2148
- memo.set(row.session_id, result);
2639
+ memo.set(identity(row), result);
2149
2640
  return result;
2150
2641
  }
2151
2642
 
2152
2643
  function rootFor(row) {
2153
- const seen = new Set([row.session_id]);
2644
+ const seen = new Set([identity(row)]);
2154
2645
  let current = row;
2155
- while (current && linkedParentById.has(current.session_id)) {
2156
- const linkedParentId = linkedParentById.get(current.session_id);
2646
+ while (current && linkedParentById.has(identity(current))) {
2647
+ const linkedParentId = linkedParentById.get(identity(current));
2157
2648
  if (seen.has(linkedParentId)) {
2158
2649
  row.parent_link_conflict = true;
2159
2650
  break;
@@ -2165,12 +2656,14 @@ function annotateCodexThreadUsage(rows) {
2165
2656
  }
2166
2657
 
2167
2658
  for (const row of rows || []) {
2659
+ const threadAware = (row.source === "codex" || row.source === "zcode")
2660
+ && typeof row.session_id === "string" && row.session_id;
2168
2661
  row.own_total_tokens = finite(row.total_tokens);
2169
2662
  row.own_cost_usd = finite(row.cost_usd);
2170
- row.direct_subagent_count = row.source === "codex" && row.session_id
2171
- ? (childrenById.get(row.session_id) || []).length
2663
+ row.direct_subagent_count = threadAware
2664
+ ? (childrenById.get(identity(row)) || []).length
2172
2665
  : 0;
2173
- const nested = row.source === "codex" && row.session_id
2666
+ const nested = threadAware
2174
2667
  ? aggregate(row)
2175
2668
  : { count: 0, tokens: 0, cost: 0 };
2176
2669
  row.descendant_subagent_count = nested.count;
@@ -2178,8 +2671,8 @@ function annotateCodexThreadUsage(rows) {
2178
2671
  row.subagent_cost_usd = nested.cost;
2179
2672
  row.combined_total_tokens = finite(row.total_tokens) + nested.tokens;
2180
2673
  row.combined_cost_usd = finite(row.cost_usd) + nested.cost;
2181
- if (row.source === "codex" && row.session_id) {
2182
- const parent = byId.get(linkedParentById.get(row.session_id));
2674
+ if (threadAware) {
2675
+ const parent = byId.get(linkedParentById.get(identity(row)));
2183
2676
  row.parent_session_hash = parent?.session_hash || null;
2184
2677
  row.root_session_hash = rootFor(row).session_hash || row.session_hash;
2185
2678
  }
@@ -2193,7 +2686,7 @@ function annotateCodexThreadUsage(rows) {
2193
2686
  // local API.
2194
2687
  function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZone, offsetMinutes } = {}) {
2195
2688
  const context = { timeZone, offsetMinutes };
2196
- const filtered = annotateCodexThreadUsage(mergeSessionFragments(sessions)
2689
+ const filtered = annotateThreadUsage(mergeSessionFragments(sessions)
2197
2690
  // Only sessions that actually spent tokens are worth listing. This drops
2198
2691
  // two kinds of noise: non-session logs under ~/.claude/projects
2199
2692
  // (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
@@ -2236,6 +2729,7 @@ module.exports = {
2236
2729
  scanClaudeSession,
2237
2730
  scanCodexSession,
2238
2731
  scanGrokSession,
2732
+ scanZcodeSessions,
2239
2733
  buildSessionAnalytics,
2240
2734
  summarizeSessions,
2241
2735
  listSessionsForBrowser,