tokentracker-cli 0.88.1 → 0.88.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-Crrws1U1.js → AchievementBadge-B6CZtEaS.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-C65tiTnu.js → AchievementsPage-YJJREeYN.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-Gc28pe4U.js → AchievementsSection-Bz8kzUKE.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-D_jEjBVd.js → BadgeDetailModal-B18owwHf.js} +1 -1
  5. package/dashboard/dist/assets/{Card-DlZueiVg.js → Card-D_HcMRXq.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-DrwVfg1s.js → ClawdAnimated-BR4Vm-wp.js} +1 -1
  7. package/dashboard/dist/assets/{CommandPalette-Dj753h1Y.js → CommandPalette-DD0iOGEP.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-PiK4kOib.js → CommunityStatsModal-CfJn-_nP.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-CJz-TMiD.js → ConfirmModal-8zQyesYk.js} +1 -1
  10. package/dashboard/dist/assets/{Controls-DZ0W7HPO.js → Controls-Dd_7ctXC.js} +1 -1
  11. package/dashboard/dist/assets/{DashboardPage-DEDZy960.js → DashboardPage-CyIp3wde.js} +1 -1
  12. package/dashboard/dist/assets/{DevicePage-BR60A3uN.js → DevicePage-BjNEbSgO.js} +1 -1
  13. package/dashboard/dist/assets/{DialogClose-qcbYLZ_r.js → DialogClose-BPRpp4Wp.js} +1 -1
  14. package/dashboard/dist/assets/DialogDescription-FmibVDMx.js +1 -0
  15. package/dashboard/dist/assets/{DialogTitle-KMA1u6gh.js → DialogTitle-Cq4jatmU.js} +1 -1
  16. package/dashboard/dist/assets/{FadeIn-BtTL9V_I.js → FadeIn-DoK6rAcp.js} +1 -1
  17. package/dashboard/dist/assets/{HeaderGithubStar-Ckp60oxi.js → HeaderGithubStar-B3konO6W.js} +1 -1
  18. package/dashboard/dist/assets/{HoverTooltip-DaCmGdvh.js → HoverTooltip-BaDkj8r8.js} +1 -1
  19. package/dashboard/dist/assets/{IpCheckPage-Bs2xgpWT.js → IpCheckPage-BkrAKRYK.js} +1 -1
  20. package/dashboard/dist/assets/{LandingPage-Dj6382lF.js → LandingPage-av_mctyf.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardAvatar-4LQRczW4.js → LeaderboardAvatar-CgdMLdWa.js} +1 -1
  22. package/dashboard/dist/assets/{LeaderboardPage-BKTbW7CC.js → LeaderboardPage-Vb3i4Qpc.js} +2 -2
  23. package/dashboard/dist/assets/{LeaderboardProfileModal-BP-I9FrE.js → LeaderboardProfileModal-CVxiTLDv.js} +3 -3
  24. package/dashboard/dist/assets/{LeaderboardProfilePage-CWzqXfsO.js → LeaderboardProfilePage-BWIwKo1y.js} +1 -1
  25. package/dashboard/dist/assets/{LimitsPage-DJL3rkrZ.js → LimitsPage-CA1Ve4x5.js} +1 -1
  26. package/dashboard/dist/assets/{LocalOnlyNotice-CJ8aY8Yg.js → LocalOnlyNotice-Rm3uyEk3.js} +1 -1
  27. package/dashboard/dist/assets/{LoginCard-RfbqlInv.js → LoginCard-DL0l2lLw.js} +1 -1
  28. package/dashboard/dist/assets/{LoginModal-DEgSV0ho.js → LoginModal-C5Fk3Jms.js} +1 -1
  29. package/dashboard/dist/assets/{LoginPage-biogQhPJ.js → LoginPage-Cu7DAOnx.js} +1 -1
  30. package/dashboard/dist/assets/{PetPage-bbmFMllf.js → PetPage-BgqjLomc.js} +1 -1
  31. package/dashboard/dist/assets/{PopoverPopup-Bx_a2ATx.js → PopoverPopup-CEHZ6QXJ.js} +1 -1
  32. package/dashboard/dist/assets/{ProviderIcon-ClMKnRNE.js → ProviderIcon-BC3XWoZ3.js} +1 -1
  33. package/dashboard/dist/assets/{ResetPasswordPage-B0aAisWC.js → ResetPasswordPage-BrongFEU.js} +1 -1
  34. package/dashboard/dist/assets/{SegmentedControl-85G6K4nz.js → SegmentedControl-D8EHNgNi.js} +1 -1
  35. package/dashboard/dist/assets/{Select-D-PLRmNY.js → Select-D9k3JTvC.js} +1 -1
  36. package/dashboard/dist/assets/{SelectItemText-DOQMVovf.js → SelectItemText-BZ-2piB5.js} +1 -1
  37. package/dashboard/dist/assets/{ServiceStatusPage-C2fq26Du.js → ServiceStatusPage-Cv_KU00i.js} +1 -1
  38. package/dashboard/dist/assets/{SessionsPage-B-Gzrsxr.js → SessionsPage-DIPKSBas.js} +1 -1
  39. package/dashboard/dist/assets/{SettingsPage-DzjURmD5.js → SettingsPage-ClKxRKyx.js} +1 -1
  40. package/dashboard/dist/assets/{SkillsPage-Bmu5fgPF.js → SkillsPage-DvuwO5Yv.js} +1 -1
  41. package/dashboard/dist/assets/{TokenGalaxy-DFVGDrXI.js → TokenGalaxy-DIM8f80U.js} +1 -1
  42. package/dashboard/dist/assets/{ToolbarRootContext-CgWa03di.js → ToolbarRootContext-BprUPTPI.js} +1 -1
  43. package/dashboard/dist/assets/{TrendMonitor-CbLhCRaf.js → TrendMonitor-DWFiGJ35.js} +1 -1
  44. package/dashboard/dist/assets/{WidgetsPage-CClBDKNb.js → WidgetsPage-DL-MG-Q7.js} +1 -1
  45. package/dashboard/dist/assets/{WrappedPage-B1QPw5JI.js → WrappedPage-CGIDKH61.js} +1 -1
  46. package/dashboard/dist/assets/{__vite-browser-external-CV_fKq4F.js → __vite-browser-external-BKzvjCbJ.js} +2 -2
  47. package/dashboard/dist/assets/{agent-logos-D-hDhb_B.js → agent-logos-CbNruoos.js} +1 -1
  48. package/dashboard/dist/assets/{arrow-up-right-DAr-mJ3B.js → arrow-up-right-eLrRdfJt.js} +1 -1
  49. package/dashboard/dist/assets/{check-DPefl5-t.js → check-DZD0Jp7h.js} +1 -1
  50. package/dashboard/dist/assets/{chevron-right-MlzWycxC.js → chevron-right-B500Y_Nj.js} +1 -1
  51. package/dashboard/dist/assets/{copy-B_53rp1c.js → copy-uJNaZG-w.js} +1 -1
  52. package/dashboard/dist/assets/{download-DnlmdACw.js → download-DzvthQjK.js} +1 -1
  53. package/dashboard/dist/assets/{external-link-DytBHNpR.js → external-link-CbRIdeZk.js} +1 -1
  54. package/dashboard/dist/assets/{flame-CO4NpDfZ.js → flame-BGvWQF3z.js} +1 -1
  55. package/dashboard/dist/assets/{format-tokens-CwDQe65M.js → format-tokens-BmLj1DrP.js} +1 -1
  56. package/dashboard/dist/assets/{icons-DvviMgxO.js → icons-oryPazn7.js} +1 -1
  57. package/dashboard/dist/assets/{index-B0Lzl2gQ.js → index-BC_awyRs.js} +1 -1
  58. package/dashboard/dist/assets/{index-Dz9TFADl.js → index-RZ9PpvhZ.js} +1 -1
  59. package/dashboard/dist/assets/{index-hGGiJmvs.js → index-lCPHhuCN.js} +1 -1
  60. package/dashboard/dist/assets/{info-BY_SOAny.js → info-DmArnish.js} +1 -1
  61. package/dashboard/dist/assets/{limits-providers-Cpis_64d.js → limits-providers-DmO_jdt4.js} +1 -1
  62. package/dashboard/dist/assets/{link-2-DaIthsuC.js → link-2-Cp4yrCYD.js} +1 -1
  63. package/dashboard/dist/assets/{loader-circle-DXLU_CS0.js → loader-circle-DGUMo8NS.js} +1 -1
  64. package/dashboard/dist/assets/{maximize-2-CnLZ2V-D.js → maximize-2-BLObxnY1.js} +1 -1
  65. package/dashboard/dist/assets/{modifiers.esm-CtoD-5nh.js → modifiers.esm-B_-ZYAOP.js} +1 -1
  66. package/dashboard/dist/assets/{monitor-smartphone-CwfUXtG9.js → monitor-smartphone-CFtFQCf_.js} +1 -1
  67. package/dashboard/dist/assets/{provider-display-C0gDapSI.js → provider-display-zfwJ-zfN.js} +1 -1
  68. package/dashboard/dist/assets/{react-BNEThWJE.js → react-C9v6bUwR.js} +1 -1
  69. package/dashboard/dist/assets/{search-GYLDIV-1.js → search-DcdG91Gl.js} +1 -1
  70. package/dashboard/dist/assets/{skills-api-noC3sCP_.js → skills-api-Cbj6O4EO.js} +1 -1
  71. package/dashboard/dist/assets/{sortable.esm-Bm4LZh1T.js → sortable.esm-CvVo8fVR.js} +1 -1
  72. package/dashboard/dist/assets/{store-DWhiUbOS.js → store-BCViEsFt.js} +1 -1
  73. package/dashboard/dist/assets/{terminal-BEQDw7-4.js → terminal-BBtxMCI7.js} +1 -1
  74. package/dashboard/dist/assets/{trash-2-Cm7eF7SC.js → trash-2-Dn_E9JQO.js} +1 -1
  75. package/dashboard/dist/assets/{use-community-stats-9a-td9J_.js → use-community-stats-DPAzvqWl.js} +1 -1
  76. package/dashboard/dist/assets/{use-limits-display-prefs-XY6qQJai.js → use-limits-display-prefs-DC9B7mbP.js} +1 -1
  77. package/dashboard/dist/assets/{use-ordered-list-BEaiYX4S.js → use-ordered-list-BUUwYoO2.js} +1 -1
  78. package/dashboard/dist/assets/use-reduced-motion-Bs24cR01.js +1 -0
  79. package/dashboard/dist/assets/{use-session-efficiency-pref-oDZEyqyO.js → use-session-efficiency-pref-OJfrVVxb.js} +1 -1
  80. package/dashboard/dist/assets/{use-transform-LKaVzl1V.js → use-transform-CVLI5mjY.js} +1 -1
  81. package/dashboard/dist/assets/{use-usage-limits-DY3e5o6f.js → use-usage-limits-BXgn5HJr.js} +1 -1
  82. package/dashboard/dist/assets/{useCurrency-CSRY4U-t.js → useCurrency-RNt_kRXK.js} +1 -1
  83. package/dashboard/dist/assets/{useScrollLock-CSZXl0Pl.js → useScrollLock-CGjecig7.js} +1 -1
  84. package/dashboard/dist/assets/{useTokenFormat-f1kcdHaP.js → useTokenFormat-CPg1vv3V.js} +1 -1
  85. package/dashboard/dist/assets/{zap-BMZr9Qc1.js → zap-CamjYQ0R.js} +1 -1
  86. package/dashboard/dist/index.html +1 -1
  87. package/dashboard/dist/ip-check.html +1 -1
  88. package/dashboard/dist/leaderboard.html +1 -1
  89. package/dashboard/dist/share.html +1 -1
  90. package/package.json +1 -1
  91. package/src/commands/sync.js +4 -2
  92. package/src/lib/codex-rollout-parser.js +15 -2
  93. package/src/lib/pricing/seed-snapshot.json +1 -1
  94. package/src/lib/rollout.js +136 -14
  95. package/src/lib/session-analytics.js +252 -145
  96. package/dashboard/dist/assets/DialogDescription-DMUlPgjH.js +0 -1
  97. package/dashboard/dist/assets/use-reduced-motion-C9_Q8rAl.js +0 -1
@@ -52,7 +52,9 @@ const wsl = require("./wsl-probe");
52
52
  // v10 adds Grok Build sessions (~/.grok/sessions/**/updates.jsonl), scanned
53
53
  // from turn_completed.usage + tool_call metadata. Bump so Claude/Codex rows
54
54
  // stay valid while Grok entries appear on the next full rebuild.
55
- const SIDECAR_VERSION = 10;
55
+ // v11 groups native/WSL copies of one logical Claude/Codex session and scans
56
+ // their union, deduping shared records while retaining divergent tails.
57
+ const SIDECAR_VERSION = 11;
56
58
  const EDIT_TOOLS = new Set([
57
59
  "apply_patch",
58
60
  "edit",
@@ -248,21 +250,28 @@ function finalizeRecord(record) {
248
250
  return record;
249
251
  }
250
252
 
253
+ function readableSessionPaths(filePath) {
254
+ const candidates = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
255
+ const readable = candidates.filter((value) => sessionFileStatKey(value));
256
+ if (!readable.length) {
257
+ throw new Error("session files vanished before they could be scanned");
258
+ }
259
+ return readable;
260
+ }
261
+
262
+ function isRecoverableSessionReadError(error) {
263
+ return ["ENOENT", "EACCES", "EPERM", "EISDIR", "EIO", "ESTALE"].includes(error?.code);
264
+ }
265
+
251
266
  async function scanClaudeSession(filePath) {
252
- const input = fs.createReadStream(filePath, { encoding: "utf8" });
253
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
267
+ // Native and WSL mounts can disappear independently after discovery. Keep
268
+ // the surviving mirror instead of dropping the whole logical session.
269
+ const filePaths = readableSessionPaths(filePath);
270
+ const primaryFilePath = filePaths[0] || String(filePath || "");
254
271
  const tokens = emptyTotals();
255
- // Per-file, deliberately. rollout.js persists its dedup set across files to
256
- // catch cross-file duplicates, but that is only sound there because it owns a
257
- // single global total. Here each file becomes an independently cached row, so
258
- // a cross-file set would make a row's tokens depend on which other files were
259
- // scanned first — and the incremental cache reuses rows without replaying
260
- // that state. Measured cost of the per-file scope on real data: 808 assistant
261
- // messages appear in more than one file (fork/resume replays history into a
262
- // new session file), inflating per-session tokens by ~1.5% in aggregate.
263
- // Fixing it needs a deterministic owner (e.g. earliest started_at) plus
264
- // message keys persisted per row — a sidecar redesign, not a one-liner. Do
265
- // NOT "fix" this by widening the set without solving the cache interaction.
272
+ // One logical cross-root group is cached and scanned as a unit, so widening
273
+ // this set across that group stays deterministic. Unrelated files (including
274
+ // same-UUID siblings inside one root) are never grouped here.
266
275
  const seenMessages = new Set();
267
276
  const bounds = emptyBounds();
268
277
  // The basename keeps grouping stable for files that never write a sessionId
@@ -270,7 +279,7 @@ async function scanClaudeSession(filePath) {
270
279
  // session_id — otherwise non-session logs under ~/.claude/projects (e.g.
271
280
  // skill-injections.jsonl, journal.jsonl) yield `claude --resume journal`,
272
281
  // a command that always fails.
273
- let rawSessionId = path.basename(filePath, ".jsonl");
282
+ let rawSessionId = path.basename(primaryFilePath, ".jsonl");
274
283
  let observedSessionId = null;
275
284
  let cwd = null;
276
285
  let model = "unknown";
@@ -290,57 +299,81 @@ async function scanClaudeSession(filePath) {
290
299
  }
291
300
  let lastPromptFingerprint = null;
292
301
 
293
- for await (const line of lines) {
294
- let obj;
295
- try { obj = JSON.parse(line); } catch { continue; }
296
- updateBounds(bounds, obj.timestamp || obj.message?.timestamp);
297
- if (typeof obj.sessionId === "string" && obj.sessionId) {
298
- rawSessionId = obj.sessionId;
299
- observedSessionId = obj.sessionId;
300
- }
301
- if (typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
302
- // Claude writes its own generated one-line summary as an "ai-title" record.
303
- // It is agent-authored metadata (not the raw prompt body); keep the latest.
304
- if (obj.type === "ai-title" && typeof obj.aiTitle === "string") {
305
- const cleaned = cleanSessionTitle(obj.aiTitle);
306
- if (cleaned) aiTitle = cleaned;
307
- continue;
308
- }
309
- if (obj.type === "user") {
310
- const prompt = extractClaudePrompt(obj);
311
- if (!prompt) continue;
312
- const fingerprint = promptFingerprint(prompt);
313
- if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
314
- lastPromptFingerprint = fingerprint;
315
- closeTurn();
316
- turns += 1;
317
- continue;
318
- }
319
- if (obj.type !== "assistant" || !obj.message) continue;
320
- const dedupKey = claudeMessageDedupKey(obj);
321
- if (dedupKey && seenMessages.has(dedupKey)) continue;
322
- if (dedupKey) seenMessages.add(dedupKey);
323
- // Claude writes internal summary/observer messages with model
324
- // "<synthetic>". They are not a billable model and can appear after the
325
- // real assistant messages in the same session. Keep the latest real
326
- // model instead of letting that marker overwrite it.
327
- const candidateModel = normalizeSessionModel(obj.message.model);
328
- if (candidateModel) model = candidateModel;
329
- addTotals(tokens, tokenTotals(obj.message.usage));
330
- const content = Array.isArray(obj.message.content) ? obj.message.content : [];
331
- for (const block of content) {
332
- if (!block || block.type !== "tool_use") continue;
333
- const name = String(block.name || "").toLowerCase();
334
- if (EDIT_TOOLS.has(name)) currentHadEdit = true;
335
- if (name === "agent" || name === "task") {
336
- subagentCalls += 1;
337
- const subtype = typeof block.input?.subagent_type === "string"
338
- ? block.input.subagent_type.trim().slice(0, 64)
339
- : "unspecified";
340
- subagentTypes.set(subtype || "unspecified", (subagentTypes.get(subtype || "unspecified") || 0) + 1);
302
+ // A native path and a WSL path can expose the same logical session with a
303
+ // shared prefix and different tails. Scan every tail, but suppress records
304
+ // already present in an earlier root. Hashes stay in-memory only, preserving
305
+ // the metadata-only persistence contract.
306
+ const priorRecordHashes = new Set();
307
+ let scannedFiles = 0;
308
+ for (const currentFilePath of filePaths) {
309
+ const currentRecordHashes = new Set();
310
+ try {
311
+ const input = fs.createReadStream(currentFilePath, { encoding: "utf8" });
312
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
313
+ for await (const line of lines) {
314
+ if (filePaths.length > 1) {
315
+ const recordHash = crypto.createHash("sha256").update(line).digest("base64url");
316
+ if (priorRecordHashes.has(recordHash)) continue;
317
+ currentRecordHashes.add(recordHash);
318
+ }
319
+ let obj;
320
+ try { obj = JSON.parse(line); } catch { continue; }
321
+ updateBounds(bounds, obj.timestamp || obj.message?.timestamp);
322
+ if (typeof obj.sessionId === "string" && obj.sessionId) {
323
+ rawSessionId = obj.sessionId;
324
+ observedSessionId = obj.sessionId;
325
+ }
326
+ if (typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
327
+ // Claude writes its own generated one-line summary as an "ai-title" record.
328
+ // It is agent-authored metadata (not the raw prompt body); keep the latest.
329
+ if (obj.type === "ai-title" && typeof obj.aiTitle === "string") {
330
+ const cleaned = cleanSessionTitle(obj.aiTitle);
331
+ if (cleaned) aiTitle = cleaned;
332
+ continue;
333
+ }
334
+ if (obj.type === "user") {
335
+ const prompt = extractClaudePrompt(obj);
336
+ if (!prompt) continue;
337
+ const fingerprint = promptFingerprint(prompt);
338
+ if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
339
+ lastPromptFingerprint = fingerprint;
340
+ closeTurn();
341
+ turns += 1;
342
+ continue;
343
+ }
344
+ if (obj.type !== "assistant" || !obj.message) continue;
345
+ const dedupKey = claudeMessageDedupKey(obj);
346
+ if (dedupKey && seenMessages.has(dedupKey)) continue;
347
+ if (dedupKey) seenMessages.add(dedupKey);
348
+ // Claude writes internal summary/observer messages with model
349
+ // "<synthetic>". They are not a billable model and can appear after the
350
+ // real assistant messages in the same session. Keep the latest real
351
+ // model instead of letting that marker overwrite it.
352
+ const candidateModel = normalizeSessionModel(obj.message.model);
353
+ if (candidateModel) model = candidateModel;
354
+ addTotals(tokens, tokenTotals(obj.message.usage));
355
+ const content = Array.isArray(obj.message.content) ? obj.message.content : [];
356
+ for (const block of content) {
357
+ if (!block || block.type !== "tool_use") continue;
358
+ const name = String(block.name || "").toLowerCase();
359
+ if (EDIT_TOOLS.has(name)) currentHadEdit = true;
360
+ if (name === "agent" || name === "task") {
361
+ subagentCalls += 1;
362
+ const subtype = typeof block.input?.subagent_type === "string"
363
+ ? block.input.subagent_type.trim().slice(0, 64)
364
+ : "unspecified";
365
+ subagentTypes.set(subtype || "unspecified", (subagentTypes.get(subtype || "unspecified") || 0) + 1);
366
+ }
367
+ }
341
368
  }
369
+ scannedFiles += 1;
370
+ } catch (error) {
371
+ if (!isRecoverableSessionReadError(error)) throw error;
372
+ } finally {
373
+ for (const recordHash of currentRecordHashes) priorRecordHashes.add(recordHash);
342
374
  }
343
375
  }
376
+ if (!scannedFiles) throw new Error("all grouped Claude session files failed during read");
344
377
  closeTurn();
345
378
  return finalizeRecord({
346
379
  version: SIDECAR_VERSION,
@@ -352,7 +385,7 @@ async function scanClaudeSession(filePath) {
352
385
  // wrote one — the UI then falls back to the project name.
353
386
  title: aiTitle,
354
387
  source: "claude",
355
- project_key: projectKey(cwd, filePath),
388
+ project_key: projectKey(cwd, primaryFilePath),
356
389
  project_ref: cwd || null,
357
390
  model,
358
391
  ...bounds,
@@ -367,9 +400,8 @@ async function scanClaudeSession(filePath) {
367
400
  }
368
401
 
369
402
  async function scanCodexDeliverySignals(filePath) {
403
+ const filePaths = readableSessionPaths(filePath);
370
404
  const bounds = emptyBounds();
371
- const input = fs.createReadStream(filePath, { encoding: "utf8" });
372
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
373
405
  let turns = 0;
374
406
  let editTurns = 0;
375
407
  let retryTurns = 0;
@@ -394,35 +426,55 @@ async function scanCodexDeliverySignals(filePath) {
394
426
  turns += 1;
395
427
  }
396
428
 
397
- for await (const line of lines) {
398
- let obj;
399
- try { obj = JSON.parse(line); } catch { continue; }
400
- updateBounds(bounds, obj.timestamp);
401
- if (obj.type === "turn_context") {
402
- hasTurnContext = true;
403
- beginTurn(String(obj.payload?.turn_id || obj.timestamp || turns + 1));
404
- continue;
405
- }
406
- const prompt = extractCodexPrompt(obj);
407
- if (prompt) {
408
- const fingerprint = promptFingerprint(prompt);
409
- if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
410
- lastPromptFingerprint = fingerprint;
411
- if (!hasTurnContext) beginTurn(String(obj.timestamp || turns + 1));
412
- continue;
413
- }
414
- if (obj.type !== "response_item") continue;
415
- const toolNames = extractCodexSignalTools(obj.payload);
416
- if (!toolNames.length) continue;
417
- if (!currentTurnOpen) beginTurn(String(obj.timestamp || turns + 1));
418
- if (toolNames.some((name) => EDIT_TOOLS.has(name))) currentHadEdit = true;
419
- for (const name of toolNames) {
420
- if (!CODEX_SUBAGENT_TOOLS.has(name)) continue;
421
- subagentCalls += 1;
422
- const displayName = name === "multi_agent_v1__spawn_agent" ? "spawn_agent" : name;
423
- subagentTypes.set(displayName, (subagentTypes.get(displayName) || 0) + 1);
429
+ const priorRecordHashes = new Set();
430
+ let scannedFiles = 0;
431
+ for (const currentFilePath of filePaths) {
432
+ const currentRecordHashes = new Set();
433
+ try {
434
+ const input = fs.createReadStream(currentFilePath, { encoding: "utf8" });
435
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
436
+ for await (const line of lines) {
437
+ if (filePaths.length > 1) {
438
+ const recordHash = crypto.createHash("sha256").update(line).digest("base64url");
439
+ if (priorRecordHashes.has(recordHash)) continue;
440
+ currentRecordHashes.add(recordHash);
441
+ }
442
+ let obj;
443
+ try { obj = JSON.parse(line); } catch { continue; }
444
+ updateBounds(bounds, obj.timestamp);
445
+ if (obj.type === "turn_context") {
446
+ hasTurnContext = true;
447
+ beginTurn(String(obj.payload?.turn_id || obj.timestamp || turns + 1));
448
+ continue;
449
+ }
450
+ const prompt = extractCodexPrompt(obj);
451
+ if (prompt) {
452
+ const fingerprint = promptFingerprint(prompt);
453
+ if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
454
+ lastPromptFingerprint = fingerprint;
455
+ if (!hasTurnContext) beginTurn(String(obj.timestamp || turns + 1));
456
+ continue;
457
+ }
458
+ if (obj.type !== "response_item") continue;
459
+ const toolNames = extractCodexSignalTools(obj.payload);
460
+ if (!toolNames.length) continue;
461
+ if (!currentTurnOpen) beginTurn(String(obj.timestamp || turns + 1));
462
+ if (toolNames.some((name) => EDIT_TOOLS.has(name))) currentHadEdit = true;
463
+ for (const name of toolNames) {
464
+ if (!CODEX_SUBAGENT_TOOLS.has(name)) continue;
465
+ subagentCalls += 1;
466
+ const displayName = name === "multi_agent_v1__spawn_agent" ? "spawn_agent" : name;
467
+ subagentTypes.set(displayName, (subagentTypes.get(displayName) || 0) + 1);
468
+ }
469
+ }
470
+ scannedFiles += 1;
471
+ } catch (error) {
472
+ if (!isRecoverableSessionReadError(error)) throw error;
473
+ } finally {
474
+ for (const recordHash of currentRecordHashes) priorRecordHashes.add(recordHash);
424
475
  }
425
476
  }
477
+ if (!scannedFiles) throw new Error("all grouped Codex signal files failed during read");
426
478
  closeTurn();
427
479
  return {
428
480
  bounds,
@@ -435,9 +487,11 @@ async function scanCodexDeliverySignals(filePath) {
435
487
  }
436
488
 
437
489
  async function scanCodexSession(filePath) {
490
+ const filePaths = readableSessionPaths(filePath);
491
+ const primaryFilePath = filePaths[0] || String(filePath || "");
438
492
  const [parsed, signals] = await Promise.all([
439
- parseCodexRolloutFile(filePath),
440
- scanCodexDeliverySignals(filePath),
493
+ parseCodexRolloutFile(filePaths, { seenTokenEvents: new Set() }),
494
+ scanCodexDeliverySignals(filePaths),
441
495
  ]);
442
496
  const parsedModel = normalizeSessionModel(parsed.model);
443
497
  const provider = normalizeSessionModel(parsed.provider);
@@ -450,16 +504,17 @@ async function scanCodexSession(filePath) {
450
504
  // Codex's own thread title from session_index.jsonl (Codex-authored
451
505
  // metadata, keyed by session id). Null when Codex never named the thread —
452
506
  // the UI then falls back to the project name.
453
- const titleIndex = loadCodexTitleIndex(filePath);
454
- const title = (parsed.sessionId && titleIndex.get(parsed.sessionId)) || null;
507
+ const title = parsed.sessionId
508
+ ? filePaths.map(loadCodexTitleIndex).map((index) => index.get(parsed.sessionId)).find(Boolean) || null
509
+ : null;
455
510
  return finalizeRecord({
456
511
  version: SIDECAR_VERSION,
457
- session_hash: sessionHash("codex", parsed.sessionId || filePath),
512
+ session_hash: sessionHash("codex", parsed.sessionId || primaryFilePath),
458
513
  session_id: parsed.sessionId || null,
459
514
  // Local-only: stripped in summarizeSessions before any cloud/CSV export.
460
515
  title,
461
516
  source: "codex",
462
- project_key: projectKey(parsed.cwd, filePath),
517
+ project_key: projectKey(parsed.cwd, primaryFilePath),
463
518
  project_ref: parsed.cwd || null,
464
519
  model,
465
520
  ...signals.bounds,
@@ -795,7 +850,10 @@ function providerRoots(home, providerDir, env, deps = {}) {
795
850
  return [...new Set(roots)];
796
851
  }
797
852
 
798
- // Collapse the same Claude session discovered under more than one root.
853
+ // Group one logical session discovered under more than one root. A group is
854
+ // scanned as a unit so identical prefixes count once while divergent tails are
855
+ // both retained. Same-UUID siblings inside one Claude root stay separate: the
856
+ // path alone cannot prove which one a file in another root mirrors.
799
857
  //
800
858
  // Codex gets this from its session-id pass below; Claude only ever had per-file
801
859
  // message dedup (`claudeMessageDedupKey` inside `scanClaudeSession`), which
@@ -809,13 +867,9 @@ function providerRoots(home, providerDir, env, deps = {}) {
809
867
  // single-install machines are unaffected, and two same-named files inside one
810
868
  // tree stay distinct (unproven identity must not delete a session). A basename
811
869
  // without a session UUID never participates either.
812
- function dedupeClaudeFilesAcrossRoots(groups) {
870
+ function groupClaudeFilesAcrossRoots(groups) {
813
871
  const rootGroups = (groups || []).filter((group) => Array.isArray(group) && group.length > 0);
814
- if (rootGroups.length <= 1) return [...new Set(rootGroups[0] || [])];
815
-
816
- const mtimeOf = (filePath) => {
817
- try { return fs.statSync(filePath).mtimeMs; } catch { return -Infinity; }
818
- };
872
+ if (rootGroups.length <= 1) return [...new Set(rootGroups[0] || [])].map((filePath) => [filePath]);
819
873
  const sessionIdOf = (filePath) =>
820
874
  path.basename(filePath).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1] || null;
821
875
 
@@ -836,31 +890,85 @@ function dedupeClaudeFilesAcrossRoots(groups) {
836
890
  }
837
891
  }
838
892
 
839
- // Preserve root order for everything kept, so native precedence is stable.
893
+ const groupedBySession = new Map();
894
+ for (const group of rootGroups) {
895
+ for (const filePath of group) {
896
+ const id = sessionIdOf(filePath);
897
+ if (!id || ambiguous.has(id)) continue;
898
+ if (!groupedBySession.has(id)) groupedBySession.set(id, []);
899
+ const paths = groupedBySession.get(id);
900
+ if (!paths.includes(filePath)) paths.push(filePath);
901
+ }
902
+ }
903
+
904
+ // Preserve root order and the first path as the representative cache key.
840
905
  const ordered = [];
841
- const winnerBySession = new Map();
906
+ const emitted = new Set();
842
907
  for (const group of rootGroups) {
843
908
  for (const filePath of group) {
844
909
  const id = sessionIdOf(filePath);
845
910
  if (!id || ambiguous.has(id)) {
846
- if (!ordered.includes(filePath)) ordered.push(filePath);
847
- continue;
848
- }
849
- const previous = winnerBySession.get(id);
850
- if (previous === undefined) {
851
- winnerBySession.set(id, filePath);
852
- ordered.push(filePath);
911
+ if (!emitted.has(filePath)) {
912
+ ordered.push([filePath]);
913
+ emitted.add(filePath);
914
+ }
853
915
  continue;
854
916
  }
855
- if (previous === filePath) continue;
856
- // Same session in an earlier root: keep the newer file, mirroring Codex.
857
- if (mtimeOf(filePath) > mtimeOf(previous)) {
858
- winnerBySession.set(id, filePath);
859
- ordered[ordered.indexOf(previous)] = filePath;
860
- }
917
+ if (emitted.has(id)) continue;
918
+ ordered.push(groupedBySession.get(id) || [filePath]);
919
+ emitted.add(id);
920
+ }
921
+ }
922
+ return ordered;
923
+ }
924
+
925
+ function sameFileContent(filePaths) {
926
+ if (!Array.isArray(filePaths) || filePaths.length <= 1) return true;
927
+ try {
928
+ const first = fs.readFileSync(filePaths[0]);
929
+ return filePaths.slice(1).every((filePath) => {
930
+ const candidate = fs.readFileSync(filePath);
931
+ return candidate.length === first.length && candidate.equals(first);
932
+ });
933
+ } catch {
934
+ return false;
935
+ }
936
+ }
937
+
938
+ // Compatibility helper used by focused discovery tests and callers that only
939
+ // need a flat list. Only byte-identical mirrors collapse; divergent copies are
940
+ // all returned so no transcript tail is discarded.
941
+ function dedupeClaudeFilesAcrossRoots(groups) {
942
+ const mtimeOf = (filePath) => {
943
+ try { return fs.statSync(filePath).mtimeMs; } catch { return -Infinity; }
944
+ };
945
+ const out = [];
946
+ for (const filePaths of groupClaudeFilesAcrossRoots(groups)) {
947
+ if (filePaths.length <= 1 || !sameFileContent(filePaths)) {
948
+ out.push(...filePaths);
949
+ continue;
861
950
  }
951
+ out.push(filePaths.reduce((winner, candidate) => (
952
+ mtimeOf(candidate) > mtimeOf(winner) ? candidate : winner
953
+ )));
862
954
  }
863
- return [...new Set(ordered)];
955
+ return [...new Set(out)];
956
+ }
957
+
958
+ function groupCodexFiles(filePaths) {
959
+ const ordered = [];
960
+ const bySession = new Map();
961
+ for (const filePath of [...new Set(filePaths || [])]) {
962
+ const id = path.basename(filePath).match(/([0-9a-f-]{36})\.jsonl$/i)?.[1] || filePath;
963
+ let group = bySession.get(id);
964
+ if (!group) {
965
+ group = [];
966
+ bySession.set(id, group);
967
+ ordered.push(group);
968
+ }
969
+ group.push(filePath);
970
+ }
971
+ return ordered;
864
972
  }
865
973
 
866
974
  async function discoverSessionFiles(home, env = process.env, deps = {}) {
@@ -873,26 +981,17 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
873
981
  Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "archived_sessions")))),
874
982
  listGrokSessionFiles(path.join(grokHome, "sessions")),
875
983
  ]);
876
- const allClaude = dedupeClaudeFilesAcrossRoots(claudeGroups);
984
+ const allClaude = groupClaudeFilesAcrossRoots(claudeGroups);
877
985
  const codex = [...new Set(codexGroups.flat())];
878
986
  const archived = [...new Set(archivedGroups.flat())];
879
987
  // Claude Memory stores thousands of background observer transcripts beside
880
988
  // real Claude Code sessions. They contain <synthetic>/haiku bookkeeping and
881
989
  // no user coding outcome, so scanning them both slows the card dramatically
882
990
  // and dilutes its efficiency metrics.
883
- const claude = allClaude.filter((filePath) => !filePath
991
+ const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
884
992
  .split(path.sep)
885
- .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX)));
886
- const codexBySession = new Map();
887
- const mtimeOf = (filePath) => {
888
- try { return fs.statSync(filePath).mtimeMs; } catch { return -Infinity; }
889
- };
890
- for (const filePath of [...codex, ...archived]) {
891
- const id = path.basename(filePath).match(/([0-9a-f-]{36})\.jsonl$/i)?.[1] || filePath;
892
- const previous = codexBySession.get(id);
893
- if (!previous || mtimeOf(filePath) > mtimeOf(previous)) codexBySession.set(id, filePath);
894
- }
895
- return { claude, codex: [...codexBySession.values()], grok };
993
+ .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
994
+ return { claude, codex: groupCodexFiles([...codex, ...archived]), grok };
896
995
  }
897
996
 
898
997
  function filesSignature(files) {
@@ -907,9 +1006,10 @@ function filesSignature(files) {
907
1006
  }
908
1007
 
909
1008
  function sessionFileCacheKey(source, filePath) {
1009
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
910
1010
  return crypto
911
1011
  .createHash("sha256")
912
- .update(`${source}\0${path.resolve(filePath)}`)
1012
+ .update(`${source}\0${filePaths.map((value) => path.resolve(value)).join("\0")}`)
913
1013
  .digest("hex")
914
1014
  .slice(0, 24);
915
1015
  }
@@ -964,13 +1064,19 @@ function loadCodexTitleIndex(filePath) {
964
1064
  // Grok titles live in sibling summary.json (and signals can change without
965
1065
  // touching updates.jsonl on some partial flushes).
966
1066
  function analyticsEntryStatKey(source, filePath) {
967
- const sessionStat = sessionFileStatKey(filePath);
968
- if (!sessionStat) return sessionStat;
1067
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
1068
+ const sessionStats = filePaths.map((value) => sessionFileStatKey(value) || "missing");
1069
+ if (sessionStats.every((value) => value === "missing")) return null;
1070
+ const sessionStat = sessionStats.join("|");
969
1071
  if (source === "codex") {
970
- return `${sessionStat}|title-index:${sessionFileStatKey(codexTitleIndexPathFor(filePath)) || "missing"}`;
1072
+ const titleStats = filePaths.map((value) => (
1073
+ sessionFileStatKey(codexTitleIndexPathFor(value)) || "missing"
1074
+ ));
1075
+ return `${sessionStat}|title-index:${titleStats.join("|")}`;
971
1076
  }
972
1077
  if (source === "grok") {
973
- return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(filePath)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(filePath)) || "missing"}`;
1078
+ const primary = filePaths[0];
1079
+ return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
974
1080
  }
975
1081
  return sessionStat;
976
1082
  }
@@ -1011,9 +1117,9 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1011
1117
  // per-file dependency check below on the next refresh. Grok titles/metadata
1012
1118
  // live in sibling summary.json / signals.json next to updates.jsonl.
1013
1119
  const signature = filesSignature([
1014
- ...discovered.claude,
1015
- ...discovered.codex,
1016
- ...discovered.codex.map(codexTitleIndexPathFor).filter(Boolean),
1120
+ ...discovered.claude.flat(),
1121
+ ...discovered.codex.flat(),
1122
+ ...discovered.codex.flat().map(codexTitleIndexPathFor).filter(Boolean),
1017
1123
  ...discovered.grok,
1018
1124
  ...discovered.grok.map(grokSummaryPathFor),
1019
1125
  ...discovered.grok.map(grokSignalsPathFor),
@@ -1035,8 +1141,8 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1035
1141
  const nextFiles = {};
1036
1142
  const sessions = [];
1037
1143
  const entries = [
1038
- ...discovered.claude.map((filePath) => ({ source: "claude", filePath, scan: scanClaudeSession })),
1039
- ...discovered.codex.map((filePath) => ({ source: "codex", filePath, scan: scanCodexSession })),
1144
+ ...discovered.claude.map((filePaths) => ({ source: "claude", filePath: filePaths, scan: scanClaudeSession })),
1145
+ ...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
1040
1146
  ...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
1041
1147
  ];
1042
1148
  // Files we could not turn into a row (permission denied, half-written line,
@@ -1371,4 +1477,5 @@ module.exports = {
1371
1477
  sessionsToCsv,
1372
1478
  providerRoots,
1373
1479
  dedupeClaudeFilesAcrossRoots,
1480
+ analyticsEntryStatKey,
1374
1481
  };
@@ -1 +0,0 @@
1
- import{r as n,z as a}from"./__vite-browser-external-CV_fKq4F.js";import{u as p}from"./DialogTitle-KMA1u6gh.js";import{u as c}from"./useScrollLock-CSZXl0Pl.js";const x=n.forwardRef(function(e,r){const{render:l,className:u,style:m,id:s,...t}=e,{store:i}=p(),o=c(s);return i.useSyncedValueWithCleanup("descriptionElementId",o),a("p",e,{ref:r,props:[{id:o},t]})});export{x as D};
@@ -1 +0,0 @@
1
- import{r as o}from"./__vite-browser-external-CV_fKq4F.js";import{h as r,i as t,p as s}from"./react-BNEThWJE.js";function i(){!r.current&&t();const[e]=o.useState(s.current);return e}export{i as u};