tokentracker-cli 0.88.0 → 0.88.2

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 (103) hide show
  1. package/README.de.md +2 -1
  2. package/README.ja.md +2 -1
  3. package/README.ko.md +2 -1
  4. package/README.md +9 -6
  5. package/README.zh-CN.md +2 -1
  6. package/dashboard/dist/assets/{AchievementBadge-C6LBwlRW.js → AchievementBadge-BcCrg832.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-bIFOA_jG.js → AchievementsPage-C95dF_C-.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-54qFstYv.js → AchievementsSection-B-VD8CfN.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-DuQD9WfI.js → BadgeDetailModal-CDTJ3tR6.js} +1 -1
  10. package/dashboard/dist/assets/{Card-BSSB4fdU.js → Card-a0RpvhLn.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-D3CW-PaV.js → ClawdAnimated-CaLKUbmC.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-BDNpgOLw.js → CommandPalette-CQpkX7dK.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-DOh00zEU.js → CommunityStatsModal-Cj53g1ky.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-BZnKmQvp.js → ConfirmModal-FHZmD91i.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-CB1IsIKx.js → Controls-BSk2Eph4.js} +1 -1
  16. package/dashboard/dist/assets/{DashboardPage-dGdEyi6L.js → DashboardPage-Ci1jVw-F.js} +1 -1
  17. package/dashboard/dist/assets/{DevicePage-Pa-RjGEW.js → DevicePage-BnCWVmOI.js} +1 -1
  18. package/dashboard/dist/assets/{DialogClose-BFix8fA1.js → DialogClose-BUhPBSeF.js} +1 -1
  19. package/dashboard/dist/assets/DialogDescription-Dp7bBdfJ.js +1 -0
  20. package/dashboard/dist/assets/{DialogTitle-Djq8dbvr.js → DialogTitle-CbNKnmTY.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-BbDnYSlD.js → FadeIn-D87p8xfM.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-VK0p8KvI.js → HeaderGithubStar-kE4I1PyE.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-CxE_Tdkx.js → HoverTooltip-miw72aLh.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-Ce5PrAlg.js → IpCheckPage-D-JoHTz-.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-CLcEW-uO.js → LandingPage-Dx_MOVjJ.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardAvatar-CeEVv5Lf.js → LeaderboardAvatar-arnp_cWI.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardPage-BRtYEATU.js → LeaderboardPage-zsoFUX5G.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardProfileModal-BnUJjdzX.js → LeaderboardProfileModal-CrmSTl9n.js} +3 -3
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-Cp_U5Cu-.js → LeaderboardProfilePage-BSE0fPco.js} +1 -1
  30. package/dashboard/dist/assets/{LimitsPage-Dk83mCC3.js → LimitsPage-BrU9e8AV.js} +1 -1
  31. package/dashboard/dist/assets/{LocalOnlyNotice-B0IFxbO0.js → LocalOnlyNotice-BWDZKn00.js} +1 -1
  32. package/dashboard/dist/assets/{LoginCard-BNiVZu18.js → LoginCard-DS8mxxs_.js} +1 -1
  33. package/dashboard/dist/assets/{LoginModal-DsMQpIhI.js → LoginModal-DhINfVDV.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-CRrHrKIB.js → LoginPage-BKmuhR74.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-DkBEl1o-.js → PetPage-QQk_B1SJ.js} +1 -1
  36. package/dashboard/dist/assets/{PopoverPopup-D1Vj1Efz.js → PopoverPopup-DS6ml7IE.js} +1 -1
  37. package/dashboard/dist/assets/{ProviderIcon-7UhSc2T5.js → ProviderIcon-DkHp1Hrl.js} +1 -1
  38. package/dashboard/dist/assets/{ResetPasswordPage-BHNRVdD_.js → ResetPasswordPage-CvJiufPF.js} +1 -1
  39. package/dashboard/dist/assets/{SegmentedControl-BV-qV5Kt.js → SegmentedControl-DbYmpdj3.js} +1 -1
  40. package/dashboard/dist/assets/{Select-BuECb0BN.js → Select-DJcVfYm7.js} +1 -1
  41. package/dashboard/dist/assets/{SelectItemText-BGD0xkvB.js → SelectItemText-DUQLkHGQ.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-DYlfwLvY.js → ServiceStatusPage-DqPQUM1f.js} +1 -1
  43. package/dashboard/dist/assets/{SessionsPage-BJsG4Y6A.js → SessionsPage-CPOSombb.js} +1 -1
  44. package/dashboard/dist/assets/{SettingsPage-BWoQPbMV.js → SettingsPage-nIfi1h7D.js} +1 -1
  45. package/dashboard/dist/assets/{SkillsPage-DrnjNKaO.js → SkillsPage-BnngQwIu.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-Bw1qTfSn.js → TokenGalaxy-CAVmlIYW.js} +1 -1
  47. package/dashboard/dist/assets/{ToolbarRootContext-BL8XQ3_A.js → ToolbarRootContext-CyG7xQo2.js} +1 -1
  48. package/dashboard/dist/assets/{TrendMonitor-CiaCIHjy.js → TrendMonitor-DyuKVbzV.js} +1 -1
  49. package/dashboard/dist/assets/{WidgetsPage-CpbdEZpd.js → WidgetsPage-72n9-HCD.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-BgXFgYTU.js → WrappedPage-Dp1aUOyA.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-DzYphO_x.js → __vite-browser-external-8kU62_i_.js} +2 -2
  52. package/dashboard/dist/assets/{agent-logos-DmIjuARU.js → agent-logos-ChfjzWPV.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-up-right-BO9KkOdd.js → arrow-up-right-CiTMPVa_.js} +1 -1
  54. package/dashboard/dist/assets/{check-pWcM1WkS.js → check-OQW1qgf_.js} +1 -1
  55. package/dashboard/dist/assets/{chevron-right-9BfpWAq6.js → chevron-right-BitImfyV.js} +1 -1
  56. package/dashboard/dist/assets/{copy-CEJGOfxH.js → copy-BM76c1Ob.js} +1 -1
  57. package/dashboard/dist/assets/{download-DGn3ZMbn.js → download-DaKmqoH2.js} +1 -1
  58. package/dashboard/dist/assets/{external-link-Cr0IWKOC.js → external-link-AL639M-l.js} +1 -1
  59. package/dashboard/dist/assets/{flame-Desm841m.js → flame-BeYD0c6y.js} +1 -1
  60. package/dashboard/dist/assets/{format-tokens-nrsS_nVp.js → format-tokens-DK1Fdxfq.js} +1 -1
  61. package/dashboard/dist/assets/{icons-Dz6888iB.js → icons-BtLj_BDi.js} +1 -1
  62. package/dashboard/dist/assets/{index-Ck7-nAuV.js → index-BHBegQjp.js} +1 -1
  63. package/dashboard/dist/assets/{index-A1XQdrLM.js → index-BK2Bm5Vp.js} +1 -1
  64. package/dashboard/dist/assets/{index-D-wYrxD4.js → index-D4kdhgAz.js} +1 -1
  65. package/dashboard/dist/assets/{info-DYk5fAf_.js → info-CIwDFvrE.js} +1 -1
  66. package/dashboard/dist/assets/{limits-providers-Bnp3QYj9.js → limits-providers-BFkGzLM4.js} +1 -1
  67. package/dashboard/dist/assets/{link-2-CXFEilGt.js → link-2-xFS-GP5p.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-D9TcNFLI.js → loader-circle-83n_EWic.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-Bqi02woy.js → maximize-2-CevrJZKM.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-DBig_B4D.js → modifiers.esm-a1oXNZlE.js} +1 -1
  71. package/dashboard/dist/assets/{monitor-smartphone-B6Xb6Q2B.js → monitor-smartphone-CSP2FeVP.js} +1 -1
  72. package/dashboard/dist/assets/{provider-display-D9VqWtF5.js → provider-display-CamXa0pj.js} +1 -1
  73. package/dashboard/dist/assets/{react-fhU3Lic-.js → react-DV1nb3lW.js} +1 -1
  74. package/dashboard/dist/assets/{search-Dxo_rl9c.js → search-wj8Kxgr5.js} +1 -1
  75. package/dashboard/dist/assets/{skills-api-BycDG-Gj.js → skills-api-BNpGEfgh.js} +1 -1
  76. package/dashboard/dist/assets/{sortable.esm-BgR0p3Td.js → sortable.esm-Xh3nfzFR.js} +1 -1
  77. package/dashboard/dist/assets/{store-XGqQuDi9.js → store-lBXh9-l6.js} +1 -1
  78. package/dashboard/dist/assets/{terminal-Btw95Xe9.js → terminal-uNBlGBuA.js} +1 -1
  79. package/dashboard/dist/assets/{trash-2-MzI5KdIA.js → trash-2-08YOSvJh.js} +1 -1
  80. package/dashboard/dist/assets/{use-community-stats-DqXdGI7Z.js → use-community-stats-CMyCuzcM.js} +1 -1
  81. package/dashboard/dist/assets/{use-limits-display-prefs-DCC_HO5d.js → use-limits-display-prefs-BkqvWQ4X.js} +1 -1
  82. package/dashboard/dist/assets/{use-ordered-list-BrWqGdXP.js → use-ordered-list-CJ9F2mdG.js} +1 -1
  83. package/dashboard/dist/assets/use-reduced-motion-CD_DKLbl.js +1 -0
  84. package/dashboard/dist/assets/{use-session-efficiency-pref-fF02PDXr.js → use-session-efficiency-pref-Hf4gvuAl.js} +1 -1
  85. package/dashboard/dist/assets/{use-transform-BU3YFLLq.js → use-transform-BBP_cds4.js} +1 -1
  86. package/dashboard/dist/assets/{use-usage-limits-Z_aa7E3w.js → use-usage-limits-DsLD0OEm.js} +1 -1
  87. package/dashboard/dist/assets/{useCurrency-BSydrVhG.js → useCurrency-CXngtOdH.js} +1 -1
  88. package/dashboard/dist/assets/{useScrollLock-DfukCBjz.js → useScrollLock-Q65zPe5d.js} +1 -1
  89. package/dashboard/dist/assets/{useTokenFormat-Dh2VNPFE.js → useTokenFormat-CE5zbI_h.js} +1 -1
  90. package/dashboard/dist/assets/{zap-T24MagjI.js → zap-CHYAc2Be.js} +1 -1
  91. package/dashboard/dist/index.html +1 -1
  92. package/dashboard/dist/ip-check.html +1 -1
  93. package/dashboard/dist/leaderboard.html +1 -1
  94. package/dashboard/dist/share.html +1 -1
  95. package/package.json +1 -1
  96. package/src/commands/init.js +16 -0
  97. package/src/commands/status.js +57 -0
  98. package/src/commands/sync.js +4 -2
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/rollout.js +265 -14
  101. package/src/lib/session-analytics.js +220 -145
  102. package/dashboard/dist/assets/DialogDescription-BBVopkKB.js +0 -1
  103. package/dashboard/dist/assets/use-reduced-motion-BbtvaYmJ.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",
@@ -249,20 +251,12 @@ function finalizeRecord(record) {
249
251
  }
250
252
 
251
253
  async function scanClaudeSession(filePath) {
252
- const input = fs.createReadStream(filePath, { encoding: "utf8" });
253
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
254
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
255
+ const primaryFilePath = filePaths[0] || String(filePath || "");
254
256
  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.
257
+ // One logical cross-root group is cached and scanned as a unit, so widening
258
+ // this set across that group stays deterministic. Unrelated files (including
259
+ // same-UUID siblings inside one root) are never grouped here.
266
260
  const seenMessages = new Set();
267
261
  const bounds = emptyBounds();
268
262
  // The basename keeps grouping stable for files that never write a sessionId
@@ -270,7 +264,7 @@ async function scanClaudeSession(filePath) {
270
264
  // session_id — otherwise non-session logs under ~/.claude/projects (e.g.
271
265
  // skill-injections.jsonl, journal.jsonl) yield `claude --resume journal`,
272
266
  // a command that always fails.
273
- let rawSessionId = path.basename(filePath, ".jsonl");
267
+ let rawSessionId = path.basename(primaryFilePath, ".jsonl");
274
268
  let observedSessionId = null;
275
269
  let cwd = null;
276
270
  let model = "unknown";
@@ -290,56 +284,72 @@ async function scanClaudeSession(filePath) {
290
284
  }
291
285
  let lastPromptFingerprint = null;
292
286
 
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);
287
+ // A native path and a WSL path can expose the same logical session with a
288
+ // shared prefix and different tails. Scan every tail, but suppress records
289
+ // already present in an earlier root. Hashes stay in-memory only, preserving
290
+ // the metadata-only persistence contract.
291
+ const priorRecordHashes = new Set();
292
+ for (const currentFilePath of filePaths) {
293
+ const currentRecordHashes = new Set();
294
+ const input = fs.createReadStream(currentFilePath, { encoding: "utf8" });
295
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
296
+ for await (const line of lines) {
297
+ if (filePaths.length > 1) {
298
+ const recordHash = crypto.createHash("sha256").update(line).digest("base64url");
299
+ if (priorRecordHashes.has(recordHash)) continue;
300
+ currentRecordHashes.add(recordHash);
301
+ }
302
+ let obj;
303
+ try { obj = JSON.parse(line); } catch { continue; }
304
+ updateBounds(bounds, obj.timestamp || obj.message?.timestamp);
305
+ if (typeof obj.sessionId === "string" && obj.sessionId) {
306
+ rawSessionId = obj.sessionId;
307
+ observedSessionId = obj.sessionId;
308
+ }
309
+ if (typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
310
+ // Claude writes its own generated one-line summary as an "ai-title" record.
311
+ // It is agent-authored metadata (not the raw prompt body); keep the latest.
312
+ if (obj.type === "ai-title" && typeof obj.aiTitle === "string") {
313
+ const cleaned = cleanSessionTitle(obj.aiTitle);
314
+ if (cleaned) aiTitle = cleaned;
315
+ continue;
316
+ }
317
+ if (obj.type === "user") {
318
+ const prompt = extractClaudePrompt(obj);
319
+ if (!prompt) continue;
320
+ const fingerprint = promptFingerprint(prompt);
321
+ if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
322
+ lastPromptFingerprint = fingerprint;
323
+ closeTurn();
324
+ turns += 1;
325
+ continue;
326
+ }
327
+ if (obj.type !== "assistant" || !obj.message) continue;
328
+ const dedupKey = claudeMessageDedupKey(obj);
329
+ if (dedupKey && seenMessages.has(dedupKey)) continue;
330
+ if (dedupKey) seenMessages.add(dedupKey);
331
+ // Claude writes internal summary/observer messages with model
332
+ // "<synthetic>". They are not a billable model and can appear after the
333
+ // real assistant messages in the same session. Keep the latest real
334
+ // model instead of letting that marker overwrite it.
335
+ const candidateModel = normalizeSessionModel(obj.message.model);
336
+ if (candidateModel) model = candidateModel;
337
+ addTotals(tokens, tokenTotals(obj.message.usage));
338
+ const content = Array.isArray(obj.message.content) ? obj.message.content : [];
339
+ for (const block of content) {
340
+ if (!block || block.type !== "tool_use") continue;
341
+ const name = String(block.name || "").toLowerCase();
342
+ if (EDIT_TOOLS.has(name)) currentHadEdit = true;
343
+ if (name === "agent" || name === "task") {
344
+ subagentCalls += 1;
345
+ const subtype = typeof block.input?.subagent_type === "string"
346
+ ? block.input.subagent_type.trim().slice(0, 64)
347
+ : "unspecified";
348
+ subagentTypes.set(subtype || "unspecified", (subagentTypes.get(subtype || "unspecified") || 0) + 1);
349
+ }
341
350
  }
342
351
  }
352
+ for (const recordHash of currentRecordHashes) priorRecordHashes.add(recordHash);
343
353
  }
344
354
  closeTurn();
345
355
  return finalizeRecord({
@@ -352,7 +362,7 @@ async function scanClaudeSession(filePath) {
352
362
  // wrote one — the UI then falls back to the project name.
353
363
  title: aiTitle,
354
364
  source: "claude",
355
- project_key: projectKey(cwd, filePath),
365
+ project_key: projectKey(cwd, primaryFilePath),
356
366
  project_ref: cwd || null,
357
367
  model,
358
368
  ...bounds,
@@ -367,9 +377,8 @@ async function scanClaudeSession(filePath) {
367
377
  }
368
378
 
369
379
  async function scanCodexDeliverySignals(filePath) {
380
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
370
381
  const bounds = emptyBounds();
371
- const input = fs.createReadStream(filePath, { encoding: "utf8" });
372
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
373
382
  let turns = 0;
374
383
  let editTurns = 0;
375
384
  let retryTurns = 0;
@@ -394,34 +403,46 @@ async function scanCodexDeliverySignals(filePath) {
394
403
  turns += 1;
395
404
  }
396
405
 
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);
406
+ const priorRecordHashes = new Set();
407
+ for (const currentFilePath of filePaths) {
408
+ const currentRecordHashes = new Set();
409
+ const input = fs.createReadStream(currentFilePath, { encoding: "utf8" });
410
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
411
+ for await (const line of lines) {
412
+ if (filePaths.length > 1) {
413
+ const recordHash = crypto.createHash("sha256").update(line).digest("base64url");
414
+ if (priorRecordHashes.has(recordHash)) continue;
415
+ currentRecordHashes.add(recordHash);
416
+ }
417
+ let obj;
418
+ try { obj = JSON.parse(line); } catch { continue; }
419
+ updateBounds(bounds, obj.timestamp);
420
+ if (obj.type === "turn_context") {
421
+ hasTurnContext = true;
422
+ beginTurn(String(obj.payload?.turn_id || obj.timestamp || turns + 1));
423
+ continue;
424
+ }
425
+ const prompt = extractCodexPrompt(obj);
426
+ if (prompt) {
427
+ const fingerprint = promptFingerprint(prompt);
428
+ if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
429
+ lastPromptFingerprint = fingerprint;
430
+ if (!hasTurnContext) beginTurn(String(obj.timestamp || turns + 1));
431
+ continue;
432
+ }
433
+ if (obj.type !== "response_item") continue;
434
+ const toolNames = extractCodexSignalTools(obj.payload);
435
+ if (!toolNames.length) continue;
436
+ if (!currentTurnOpen) beginTurn(String(obj.timestamp || turns + 1));
437
+ if (toolNames.some((name) => EDIT_TOOLS.has(name))) currentHadEdit = true;
438
+ for (const name of toolNames) {
439
+ if (!CODEX_SUBAGENT_TOOLS.has(name)) continue;
440
+ subagentCalls += 1;
441
+ const displayName = name === "multi_agent_v1__spawn_agent" ? "spawn_agent" : name;
442
+ subagentTypes.set(displayName, (subagentTypes.get(displayName) || 0) + 1);
443
+ }
424
444
  }
445
+ for (const recordHash of currentRecordHashes) priorRecordHashes.add(recordHash);
425
446
  }
426
447
  closeTurn();
427
448
  return {
@@ -435,9 +456,11 @@ async function scanCodexDeliverySignals(filePath) {
435
456
  }
436
457
 
437
458
  async function scanCodexSession(filePath) {
459
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
460
+ const primaryFilePath = filePaths[0] || String(filePath || "");
438
461
  const [parsed, signals] = await Promise.all([
439
- parseCodexRolloutFile(filePath),
440
- scanCodexDeliverySignals(filePath),
462
+ parseCodexRolloutFile(filePaths, { seenTokenEvents: new Set() }),
463
+ scanCodexDeliverySignals(filePaths),
441
464
  ]);
442
465
  const parsedModel = normalizeSessionModel(parsed.model);
443
466
  const provider = normalizeSessionModel(parsed.provider);
@@ -450,16 +473,17 @@ async function scanCodexSession(filePath) {
450
473
  // Codex's own thread title from session_index.jsonl (Codex-authored
451
474
  // metadata, keyed by session id). Null when Codex never named the thread —
452
475
  // the UI then falls back to the project name.
453
- const titleIndex = loadCodexTitleIndex(filePath);
454
- const title = (parsed.sessionId && titleIndex.get(parsed.sessionId)) || null;
476
+ const title = parsed.sessionId
477
+ ? filePaths.map(loadCodexTitleIndex).map((index) => index.get(parsed.sessionId)).find(Boolean) || null
478
+ : null;
455
479
  return finalizeRecord({
456
480
  version: SIDECAR_VERSION,
457
- session_hash: sessionHash("codex", parsed.sessionId || filePath),
481
+ session_hash: sessionHash("codex", parsed.sessionId || primaryFilePath),
458
482
  session_id: parsed.sessionId || null,
459
483
  // Local-only: stripped in summarizeSessions before any cloud/CSV export.
460
484
  title,
461
485
  source: "codex",
462
- project_key: projectKey(parsed.cwd, filePath),
486
+ project_key: projectKey(parsed.cwd, primaryFilePath),
463
487
  project_ref: parsed.cwd || null,
464
488
  model,
465
489
  ...signals.bounds,
@@ -795,7 +819,10 @@ function providerRoots(home, providerDir, env, deps = {}) {
795
819
  return [...new Set(roots)];
796
820
  }
797
821
 
798
- // Collapse the same Claude session discovered under more than one root.
822
+ // Group one logical session discovered under more than one root. A group is
823
+ // scanned as a unit so identical prefixes count once while divergent tails are
824
+ // both retained. Same-UUID siblings inside one Claude root stay separate: the
825
+ // path alone cannot prove which one a file in another root mirrors.
799
826
  //
800
827
  // Codex gets this from its session-id pass below; Claude only ever had per-file
801
828
  // message dedup (`claudeMessageDedupKey` inside `scanClaudeSession`), which
@@ -809,13 +836,9 @@ function providerRoots(home, providerDir, env, deps = {}) {
809
836
  // single-install machines are unaffected, and two same-named files inside one
810
837
  // tree stay distinct (unproven identity must not delete a session). A basename
811
838
  // without a session UUID never participates either.
812
- function dedupeClaudeFilesAcrossRoots(groups) {
839
+ function groupClaudeFilesAcrossRoots(groups) {
813
840
  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
- };
841
+ if (rootGroups.length <= 1) return [...new Set(rootGroups[0] || [])].map((filePath) => [filePath]);
819
842
  const sessionIdOf = (filePath) =>
820
843
  path.basename(filePath).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1] || null;
821
844
 
@@ -836,31 +859,85 @@ function dedupeClaudeFilesAcrossRoots(groups) {
836
859
  }
837
860
  }
838
861
 
839
- // Preserve root order for everything kept, so native precedence is stable.
862
+ const groupedBySession = new Map();
863
+ for (const group of rootGroups) {
864
+ for (const filePath of group) {
865
+ const id = sessionIdOf(filePath);
866
+ if (!id || ambiguous.has(id)) continue;
867
+ if (!groupedBySession.has(id)) groupedBySession.set(id, []);
868
+ const paths = groupedBySession.get(id);
869
+ if (!paths.includes(filePath)) paths.push(filePath);
870
+ }
871
+ }
872
+
873
+ // Preserve root order and the first path as the representative cache key.
840
874
  const ordered = [];
841
- const winnerBySession = new Map();
875
+ const emitted = new Set();
842
876
  for (const group of rootGroups) {
843
877
  for (const filePath of group) {
844
878
  const id = sessionIdOf(filePath);
845
879
  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);
880
+ if (!emitted.has(filePath)) {
881
+ ordered.push([filePath]);
882
+ emitted.add(filePath);
883
+ }
853
884
  continue;
854
885
  }
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
- }
886
+ if (emitted.has(id)) continue;
887
+ ordered.push(groupedBySession.get(id) || [filePath]);
888
+ emitted.add(id);
861
889
  }
862
890
  }
863
- return [...new Set(ordered)];
891
+ return ordered;
892
+ }
893
+
894
+ function sameFileContent(filePaths) {
895
+ if (!Array.isArray(filePaths) || filePaths.length <= 1) return true;
896
+ try {
897
+ const first = fs.readFileSync(filePaths[0]);
898
+ return filePaths.slice(1).every((filePath) => {
899
+ const candidate = fs.readFileSync(filePath);
900
+ return candidate.length === first.length && candidate.equals(first);
901
+ });
902
+ } catch {
903
+ return false;
904
+ }
905
+ }
906
+
907
+ // Compatibility helper used by focused discovery tests and callers that only
908
+ // need a flat list. Only byte-identical mirrors collapse; divergent copies are
909
+ // all returned so no transcript tail is discarded.
910
+ function dedupeClaudeFilesAcrossRoots(groups) {
911
+ const mtimeOf = (filePath) => {
912
+ try { return fs.statSync(filePath).mtimeMs; } catch { return -Infinity; }
913
+ };
914
+ const out = [];
915
+ for (const filePaths of groupClaudeFilesAcrossRoots(groups)) {
916
+ if (filePaths.length <= 1 || !sameFileContent(filePaths)) {
917
+ out.push(...filePaths);
918
+ continue;
919
+ }
920
+ out.push(filePaths.reduce((winner, candidate) => (
921
+ mtimeOf(candidate) > mtimeOf(winner) ? candidate : winner
922
+ )));
923
+ }
924
+ return [...new Set(out)];
925
+ }
926
+
927
+ function groupCodexFiles(filePaths) {
928
+ const ordered = [];
929
+ const bySession = new Map();
930
+ for (const filePath of [...new Set(filePaths || [])]) {
931
+ const id = path.basename(filePath).match(/([0-9a-f-]{36})\.jsonl$/i)?.[1] || filePath;
932
+ let group = bySession.get(id);
933
+ if (!group) {
934
+ group = [];
935
+ bySession.set(id, group);
936
+ ordered.push(group);
937
+ }
938
+ group.push(filePath);
939
+ }
940
+ return ordered;
864
941
  }
865
942
 
866
943
  async function discoverSessionFiles(home, env = process.env, deps = {}) {
@@ -873,26 +950,17 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
873
950
  Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "archived_sessions")))),
874
951
  listGrokSessionFiles(path.join(grokHome, "sessions")),
875
952
  ]);
876
- const allClaude = dedupeClaudeFilesAcrossRoots(claudeGroups);
953
+ const allClaude = groupClaudeFilesAcrossRoots(claudeGroups);
877
954
  const codex = [...new Set(codexGroups.flat())];
878
955
  const archived = [...new Set(archivedGroups.flat())];
879
956
  // Claude Memory stores thousands of background observer transcripts beside
880
957
  // real Claude Code sessions. They contain <synthetic>/haiku bookkeeping and
881
958
  // no user coding outcome, so scanning them both slows the card dramatically
882
959
  // and dilutes its efficiency metrics.
883
- const claude = allClaude.filter((filePath) => !filePath
960
+ const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
884
961
  .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 };
962
+ .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
963
+ return { claude, codex: groupCodexFiles([...codex, ...archived]), grok };
896
964
  }
897
965
 
898
966
  function filesSignature(files) {
@@ -907,9 +975,10 @@ function filesSignature(files) {
907
975
  }
908
976
 
909
977
  function sessionFileCacheKey(source, filePath) {
978
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
910
979
  return crypto
911
980
  .createHash("sha256")
912
- .update(`${source}\0${path.resolve(filePath)}`)
981
+ .update(`${source}\0${filePaths.map((value) => path.resolve(value)).join("\0")}`)
913
982
  .digest("hex")
914
983
  .slice(0, 24);
915
984
  }
@@ -964,13 +1033,19 @@ function loadCodexTitleIndex(filePath) {
964
1033
  // Grok titles live in sibling summary.json (and signals can change without
965
1034
  // touching updates.jsonl on some partial flushes).
966
1035
  function analyticsEntryStatKey(source, filePath) {
967
- const sessionStat = sessionFileStatKey(filePath);
968
- if (!sessionStat) return sessionStat;
1036
+ const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
1037
+ const sessionStats = filePaths.map((value) => sessionFileStatKey(value));
1038
+ if (sessionStats.some((value) => !value)) return null;
1039
+ const sessionStat = sessionStats.join("|");
969
1040
  if (source === "codex") {
970
- return `${sessionStat}|title-index:${sessionFileStatKey(codexTitleIndexPathFor(filePath)) || "missing"}`;
1041
+ const titleStats = filePaths.map((value) => (
1042
+ sessionFileStatKey(codexTitleIndexPathFor(value)) || "missing"
1043
+ ));
1044
+ return `${sessionStat}|title-index:${titleStats.join("|")}`;
971
1045
  }
972
1046
  if (source === "grok") {
973
- return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(filePath)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(filePath)) || "missing"}`;
1047
+ const primary = filePaths[0];
1048
+ return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
974
1049
  }
975
1050
  return sessionStat;
976
1051
  }
@@ -1011,9 +1086,9 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1011
1086
  // per-file dependency check below on the next refresh. Grok titles/metadata
1012
1087
  // live in sibling summary.json / signals.json next to updates.jsonl.
1013
1088
  const signature = filesSignature([
1014
- ...discovered.claude,
1015
- ...discovered.codex,
1016
- ...discovered.codex.map(codexTitleIndexPathFor).filter(Boolean),
1089
+ ...discovered.claude.flat(),
1090
+ ...discovered.codex.flat(),
1091
+ ...discovered.codex.flat().map(codexTitleIndexPathFor).filter(Boolean),
1017
1092
  ...discovered.grok,
1018
1093
  ...discovered.grok.map(grokSummaryPathFor),
1019
1094
  ...discovered.grok.map(grokSignalsPathFor),
@@ -1035,8 +1110,8 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1035
1110
  const nextFiles = {};
1036
1111
  const sessions = [];
1037
1112
  const entries = [
1038
- ...discovered.claude.map((filePath) => ({ source: "claude", filePath, scan: scanClaudeSession })),
1039
- ...discovered.codex.map((filePath) => ({ source: "codex", filePath, scan: scanCodexSession })),
1113
+ ...discovered.claude.map((filePaths) => ({ source: "claude", filePath: filePaths, scan: scanClaudeSession })),
1114
+ ...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
1040
1115
  ...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
1041
1116
  ];
1042
1117
  // Files we could not turn into a row (permission denied, half-written line,
@@ -1 +0,0 @@
1
- import{r as n,z as a}from"./__vite-browser-external-DzYphO_x.js";import{u as p}from"./DialogTitle-Djq8dbvr.js";import{u as c}from"./useScrollLock-DfukCBjz.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-DzYphO_x.js";import{h as r,i as t,p as s}from"./react-fhU3Lic-.js";function i(){!r.current&&t();const[e]=o.useState(s.current);return e}export{i as u};