tokentracker-cli 0.84.9 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.de.md +5 -4
  2. package/README.ja.md +5 -4
  3. package/README.ko.md +5 -4
  4. package/README.md +8 -7
  5. package/README.zh-CN.md +5 -4
  6. package/dashboard/dist/assets/{AchievementBadge-BgBFpxmU.js → AchievementBadge-BgbH_mX4.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-DHPD3Fnu.js → AchievementsPage-CQqkh1JS.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DouWVD-3.js → AchievementsSection-Dmkxpn0s.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Df9eQiA3.js → BadgeDetailModal-7OQf37lk.js} +1 -1
  10. package/dashboard/dist/assets/{Card-CQ9wBnz6.js → Card-1PmWZRTK.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-XIO-I-5v.js → ClawdAnimated-CMJFagpX.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-Cy1cJ91B.js → CommandPalette-D4OrwF1w.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-CwKG4LQM.js → CommunityStatsModal-BDC9EcwZ.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-BNcWqick.js → ConfirmModal-22DL5C_9.js} +1 -1
  15. package/dashboard/dist/assets/DashboardPage-CEXWzDtt.js +1 -0
  16. package/dashboard/dist/assets/{DevicePage-pJ2jNkZV.js → DevicePage-8uhHB7kX.js} +1 -1
  17. package/dashboard/dist/assets/{DialogClose-C2SOtkJN.js → DialogClose-BcJx6VK-.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-D6zxtOM5.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-CGSbB8G7.js → DialogTitle-gRzunAqv.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-CZSiw7me.js → FadeIn-4rlPdBVe.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-ZmQhX_kR.js → HeaderGithubStar-CR003aO3.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-D_YdG369.js → HoverTooltip-Dgz-dM8Q.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-du886dF0.js → IpCheckPage-B4PJRJP_.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-BuAak8lU.js → LandingPage-3JQe-jOZ.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardAvatar-nU0ARYnN.js → LeaderboardAvatar-CL9LxiEf.js} +1 -1
  26. package/dashboard/dist/assets/{LeaderboardPage-CngwCvC7.js → LeaderboardPage-DhIzM2Z2.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-CXZJ0e-T.js → LeaderboardProfileModal-bbf5uJR5.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CZve6s6b.js → LeaderboardProfilePage-CzuPxscw.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-CWvoJPk8.js → LimitsPage-B59rb2gg.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-DyUAAyyE.js → LocalOnlyNotice-BkK-6n1R.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-eEiRfbdv.js → LoginCard-BFGCzEjE.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-C16WdIQm.js → LoginModal-yhkyscFn.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-B2WqpE1t.js → LoginPage-DwU5s0F7.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DtQWrGmI.js → PetPage-BPscYR6z.js} +1 -1
  35. package/dashboard/dist/assets/{PopoverPopup-CRhqtFLB.js → PopoverPopup-E7PyGTTB.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-DWNH_wYu.js → ProviderIcon-CvmnzwKU.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-DzCczcWZ.js → ResetPasswordPage-DD6qX616.js} +1 -1
  38. package/dashboard/dist/assets/{SegmentedControl-CV9ccq6R.js → SegmentedControl-DjGBL_l1.js} +1 -1
  39. package/dashboard/dist/assets/{Select-BrogNNk0.js → Select-B2MGmsi2.js} +1 -1
  40. package/dashboard/dist/assets/{SelectItemText-Bpu3swt0.js → SelectItemText-BaTwA_ZF.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-D_FIrMJP.js → ServiceStatusPage-0K0y8A6m.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-C8TvTy1o.js → SessionsPage-CxrtRm3B.js} +2 -2
  43. package/dashboard/dist/assets/{SettingsPage-Bd-tzJAR.js → SettingsPage-CyvSWvDP.js} +1 -1
  44. package/dashboard/dist/assets/{SkillsPage-CKGU9wQ4.js → SkillsPage-BVOU8t3t.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-DhHW6L-U.js → TokenGalaxy-w7bwGloC.js} +1 -1
  46. package/dashboard/dist/assets/{ToolbarRootContext-DQo-4pPB.js → ToolbarRootContext-BAzpDZej.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-Ejl8d45g.js → TrendMonitor-DyT7Q4xp.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-CY7f4wCD.js → WidgetsPage-eDDtopQT.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-B4-B2v-v.js → WrappedPage-CKkbdWHF.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-BNUFXCfp.js → __vite-browser-external-SU8sCTLB.js} +23 -16
  51. package/dashboard/dist/assets/{agent-logos-BRzutmeR.js → agent-logos-bkh9XZJd.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-up-right-BcrbPk8c.js → arrow-up-right-D16zCIw8.js} +1 -1
  53. package/dashboard/dist/assets/{check-BMxeFPfQ.js → check-BFL17JWQ.js} +1 -1
  54. package/dashboard/dist/assets/{chevron-right-DOstgiRf.js → chevron-right-CUJgrtkp.js} +1 -1
  55. package/dashboard/dist/assets/{copy-BsKO524t.js → copy-CRQiCjgs.js} +1 -1
  56. package/dashboard/dist/assets/{download-dadqxej7.js → download-Cd12nDrQ.js} +1 -1
  57. package/dashboard/dist/assets/{external-link-CTu3BMeU.js → external-link-CvAfnsG-.js} +1 -1
  58. package/dashboard/dist/assets/{flame-B_IvWcMn.js → flame-CCi681Yl.js} +1 -1
  59. package/dashboard/dist/assets/{format-tokens-DIT6hLSb.js → format-tokens-BAVt8uTZ.js} +1 -1
  60. package/dashboard/dist/assets/{icons-mWku76bu.js → icons-CbKC96gR.js} +1 -1
  61. package/dashboard/dist/assets/{index-D5-w-aGy.js → index--TDvaL1y.js} +1 -1
  62. package/dashboard/dist/assets/{index-D-gQV-hF.js → index-B5b6PCh5.js} +1 -1
  63. package/dashboard/dist/assets/{index-CboN9E82.js → index-B9LWn-mo.js} +1 -1
  64. package/dashboard/dist/assets/{info-x6l352SW.js → info-BPFpD--M.js} +1 -1
  65. package/dashboard/dist/assets/{limits-providers-Bo12VlLJ.js → limits-providers-Cyedt-WJ.js} +1 -1
  66. package/dashboard/dist/assets/{link-2-eo_xPxH1.js → link-2-BkEPPdfC.js} +1 -1
  67. package/dashboard/dist/assets/{loader-circle-C4n1iGBv.js → loader-circle-CzASiylJ.js} +1 -1
  68. package/dashboard/dist/assets/{maximize-2-pR8xwsI_.js → maximize-2-CkwnHUJn.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-ErggQWu4.js → provider-display-BXBBedC-.js} +2 -2
  70. package/dashboard/dist/assets/{react-DYKg02Aq.js → react-BXSctDQj.js} +1 -1
  71. package/dashboard/dist/assets/{search-B_jbUwPS.js → search-_qGma9tM.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-DY5ruGmw.js → skills-api-DdmJjnJ9.js} +1 -1
  73. package/dashboard/dist/assets/{store-Cu8i1y26.js → store-owYCN530.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-D3j6X68s.js → terminal-Clzw-KEF.js} +1 -1
  75. package/dashboard/dist/assets/{trash-2-CFpwgJw5.js → trash-2-C5Giwp_E.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-C0fKq66G.js → use-community-stats-Dk8UjLEr.js} +1 -1
  77. package/dashboard/dist/assets/{use-limits-display-prefs-TOp4UAnI.js → use-limits-display-prefs-CArM595Q.js} +1 -1
  78. package/dashboard/dist/assets/{use-native-settings-BZie_7z1.js → use-native-settings-Cpd69kiF.js} +1 -1
  79. package/dashboard/dist/assets/{use-ordered-list-Bq92YyUp.js → use-ordered-list--CzwhBTx.js} +1 -1
  80. package/dashboard/dist/assets/use-reduced-motion-CcILnpU1.js +1 -0
  81. package/dashboard/dist/assets/{use-session-efficiency-pref-BcpytkES.js → use-session-efficiency-pref-DUXO8cCg.js} +1 -1
  82. package/dashboard/dist/assets/{use-transform-Dmmrne2z.js → use-transform-Cg3kuMj_.js} +1 -1
  83. package/dashboard/dist/assets/{use-usage-limits-Cw0Z9Tew.js → use-usage-limits-DY9_mV28.js} +1 -1
  84. package/dashboard/dist/assets/{useCurrency-DyyonINj.js → useCurrency-BIe86hMn.js} +1 -1
  85. package/dashboard/dist/assets/{useScrollLock-DvWw86lJ.js → useScrollLock-7-hejkNC.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-CBkvGfJ1.js → useTokenFormat-DEtLbRgQ.js} +1 -1
  87. package/dashboard/dist/assets/{zap-CnYA39Ug.js → zap-DUxY1d-O.js} +1 -1
  88. package/dashboard/dist/index.html +12 -10
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/llms.txt +3 -2
  92. package/dashboard/dist/share.html +1 -1
  93. package/package.json +2 -2
  94. package/src/commands/init.js +1 -0
  95. package/src/commands/status.js +129 -9
  96. package/src/commands/sync.js +265 -54
  97. package/src/lib/claude-categorizer.js +36 -9
  98. package/src/lib/diagnostics.js +86 -13
  99. package/src/lib/doctor.js +17 -0
  100. package/src/lib/grok-context-breakdown.js +794 -0
  101. package/src/lib/install-resolver.js +10 -2
  102. package/src/lib/local-api.js +21 -3
  103. package/src/lib/multi-install-parser.js +9 -1
  104. package/src/lib/pricing/seed-snapshot.json +1 -1
  105. package/src/lib/rollout.js +788 -44
  106. package/src/lib/session-analytics.js +332 -9
  107. package/dashboard/dist/assets/DashboardPage-Dns_1yXL.js +0 -1
  108. package/dashboard/dist/assets/DialogDescription-Bhfqqmll.js +0 -1
  109. package/dashboard/dist/assets/use-reduced-motion-ZiVeOkvb.js +0 -1
@@ -48,12 +48,27 @@ const { computeRowCost } = require("./pricing");
48
48
  // - session_id is only set when the log actually contained a sessionId
49
49
  // record. It used to fall back to the file's basename, which produced
50
50
  // resume commands for non-session files (`claude --resume journal`).
51
- const SIDECAR_VERSION = 9;
52
- const EDIT_TOOLS = new Set(["apply_patch", "edit", "write", "multiedit", "notebookedit"]);
51
+ // v10 adds Grok Build sessions (~/.grok/sessions/**/updates.jsonl), scanned
52
+ // from turn_completed.usage + tool_call metadata. Bump so Claude/Codex rows
53
+ // stay valid while Grok entries appear on the next full rebuild.
54
+ const SIDECAR_VERSION = 10;
55
+ const EDIT_TOOLS = new Set([
56
+ "apply_patch",
57
+ "edit",
58
+ "write",
59
+ "multiedit",
60
+ "notebookedit",
61
+ // Grok Build write tools (ACP tool titles / x.ai/tool.name).
62
+ "search_replace",
63
+ "str_replace",
64
+ "create_file",
65
+ "write_file",
66
+ ]);
53
67
  const PLACEHOLDER_MODELS = new Set(["<synthetic>", "synthetic", "<unknown>", "unknown"]);
54
68
  const CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX = "--claude-mem-observer-sessions";
55
69
  const CODEX_SUBAGENT_TOOLS = new Set(["spawn_agent", "multi_agent_v1__spawn_agent"]);
56
70
  const CODEX_SIGNAL_TOOLS = new Set([...EDIT_TOOLS, ...CODEX_SUBAGENT_TOOLS]);
71
+ const GROK_SUBAGENT_TOOLS = new Set(["spawn_subagent", "spawn_agent"]);
57
72
 
58
73
  function normalizeSessionModel(value) {
59
74
  if (typeof value !== "string") return null;
@@ -457,11 +472,302 @@ async function scanCodexSession(filePath) {
457
472
  });
458
473
  }
459
474
 
475
+ // Grok Build sessions live at:
476
+ // ~/.grok/sessions/<url-encoded-cwd>/<session-uuid>/updates.jsonl
477
+ // with sibling summary.json (id/cwd/title) and signals.json (aggregate
478
+ // counters). Billable tokens come only from turn_completed.usage — the same
479
+ // authority as the Grok usage parser — not from context-window totalTokens.
480
+ function resolveGrokHome(home = os.homedir(), env = process.env) {
481
+ if (typeof env.TOKENTRACKER_GROK_HOME === "string" && env.TOKENTRACKER_GROK_HOME.trim()) {
482
+ return path.resolve(env.TOKENTRACKER_GROK_HOME.trim());
483
+ }
484
+ if (typeof env.GROK_HOME === "string" && env.GROK_HOME.trim()) {
485
+ return path.resolve(env.GROK_HOME.trim());
486
+ }
487
+ return path.join(home, ".grok");
488
+ }
489
+
490
+ function grokSummaryPathFor(updatesPath) {
491
+ return path.join(path.dirname(updatesPath), "summary.json");
492
+ }
493
+
494
+ function grokSignalsPathFor(updatesPath) {
495
+ return path.join(path.dirname(updatesPath), "signals.json");
496
+ }
497
+
498
+ function readJsonFileSync(filePath) {
499
+ try {
500
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
505
+
506
+ function grokTimestampIso(obj) {
507
+ const metaMs = Number(obj?.params?._meta?.agentTimestampMs);
508
+ if (Number.isFinite(metaMs) && metaMs > 0) return new Date(metaMs).toISOString();
509
+ const top = Number(obj?.timestamp);
510
+ if (!Number.isFinite(top) || top <= 0) return null;
511
+ // Grok writes unix seconds on the envelope; ms values are already > 1e12.
512
+ const ms = top > 1e12 ? top : top * 1000;
513
+ return new Date(ms).toISOString();
514
+ }
515
+
516
+ // Grok reports inputTokens as the full prompt (including cache hits). Split so
517
+ // pricing can apply cache_read rates correctly — same authority as the usage
518
+ // parser and grok-context-breakdown.readUsageTotals. Prefer the reported
519
+ // totalTokens; do not re-sum input+cached (that double-counts cache hits).
520
+ function grokUsageTotals(usage) {
521
+ if (!usage || typeof usage !== "object") return emptyTotals();
522
+ const inputRaw = finite(usage.inputTokens ?? usage.input_tokens);
523
+ const cached_input_tokens = finite(
524
+ usage.cachedReadTokens ?? usage.cache_read_input_tokens ?? usage.cached_input_tokens,
525
+ );
526
+ const cache_creation_input_tokens = finite(
527
+ usage.cachedWriteTokens ?? usage.cache_creation_input_tokens,
528
+ );
529
+ const output_tokens = finite(usage.outputTokens ?? usage.output_tokens);
530
+ const reasoning_output_tokens = finite(
531
+ usage.reasoningTokens ?? usage.reasoning_output_tokens,
532
+ );
533
+ const input_tokens = Math.max(0, inputRaw - cached_input_tokens);
534
+ let total_tokens = finite(usage.totalTokens ?? usage.total_tokens);
535
+ if (total_tokens <= 0) {
536
+ total_tokens = input_tokens
537
+ + cached_input_tokens
538
+ + cache_creation_input_tokens
539
+ + output_tokens
540
+ + reasoning_output_tokens;
541
+ }
542
+ return {
543
+ input_tokens,
544
+ cached_input_tokens,
545
+ cache_creation_input_tokens,
546
+ output_tokens,
547
+ reasoning_output_tokens,
548
+ total_tokens,
549
+ };
550
+ }
551
+
552
+ function pickGrokModel(usage, fallback) {
553
+ const modelUsage = usage?.modelUsage;
554
+ if (modelUsage && typeof modelUsage === "object") {
555
+ let bestName = null;
556
+ let bestTokens = -1;
557
+ for (const [name, entry] of Object.entries(modelUsage)) {
558
+ const normalized = normalizeSessionModel(name);
559
+ if (!normalized) continue;
560
+ const tokens = finite(entry?.totalTokens ?? entry?.total_tokens)
561
+ + finite(entry?.inputTokens ?? entry?.input_tokens)
562
+ + finite(entry?.outputTokens ?? entry?.output_tokens);
563
+ if (tokens >= bestTokens) {
564
+ bestTokens = tokens;
565
+ bestName = normalized;
566
+ }
567
+ }
568
+ if (bestName) return bestName;
569
+ }
570
+ return normalizeSessionModel(fallback) || "unknown";
571
+ }
572
+
573
+ function extractGrokUserPrompt(update) {
574
+ if (!update || update.sessionUpdate !== "user_message_chunk") return null;
575
+ const content = update.content;
576
+ if (typeof content === "string") {
577
+ const text = content.trim();
578
+ return text || null;
579
+ }
580
+ if (content && typeof content === "object") {
581
+ if (typeof content.text === "string") {
582
+ const text = content.text.trim();
583
+ return text || null;
584
+ }
585
+ }
586
+ return null;
587
+ }
588
+
589
+ function extractGrokToolName(update) {
590
+ if (!update || update.sessionUpdate !== "tool_call") return "";
591
+ const metaName = update._meta?.["x.ai/tool"]?.name;
592
+ return canonicalToolName(metaName || update.title || "");
593
+ }
594
+
595
+ async function listGrokSessionFiles(sessionsRoot) {
596
+ if (!fs.existsSync(sessionsRoot)) return [];
597
+ const found = [];
598
+ async function walk(dir, depth) {
599
+ if (depth > 6) return;
600
+ let entries;
601
+ try {
602
+ entries = await fsp.readdir(dir, { withFileTypes: true });
603
+ } catch {
604
+ return;
605
+ }
606
+ for (const entry of entries) {
607
+ if (!entry.isDirectory()) continue;
608
+ if (entry.name.startsWith(".")) continue;
609
+ const full = path.join(dir, entry.name);
610
+ const updatesPath = path.join(full, "updates.jsonl");
611
+ try {
612
+ const stat = await fsp.stat(updatesPath);
613
+ if (stat.isFile() && stat.size > 0) {
614
+ found.push(updatesPath);
615
+ continue;
616
+ }
617
+ } catch { /* not a session leaf */ }
618
+ await walk(full, depth + 1);
619
+ }
620
+ }
621
+ await walk(sessionsRoot, 0);
622
+ // Deterministic order so filesSignature is stable across readdir() shuffles.
623
+ found.sort((a, b) => a.localeCompare(b));
624
+ return found;
625
+ }
626
+
627
+ async function scanGrokSession(filePath) {
628
+ const summary = readJsonFileSync(grokSummaryPathFor(filePath)) || {};
629
+ const signals = readJsonFileSync(grokSignalsPathFor(filePath)) || {};
630
+ const tokens = emptyTotals();
631
+ const bounds = emptyBounds();
632
+ const dirName = path.basename(path.dirname(filePath));
633
+ let observedSessionId = typeof summary?.info?.id === "string" && summary.info.id
634
+ ? summary.info.id
635
+ : (/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(dirName) ? dirName : null);
636
+ let cwd = typeof summary?.info?.cwd === "string" && summary.info.cwd
637
+ ? summary.info.cwd
638
+ : null;
639
+ // Prefer agent-authored generated_title over free-form session_summary when
640
+ // both exist; both are Grok metadata, never the raw user prompt body.
641
+ const title = cleanSessionTitle(summary.generated_title)
642
+ || cleanSessionTitle(summary.session_summary)
643
+ || null;
644
+ let model = pickGrokModel(null, signals.primaryModelId || summary.current_model_id);
645
+ let turns = 0;
646
+ let editTurns = 0;
647
+ let retryTurns = 0;
648
+ let currentHadEdit = false;
649
+ let subagentCalls = 0;
650
+ const subagentTypes = new Map();
651
+ let lastPromptFingerprint = null;
652
+ // Grok streams user_message_chunk pieces for one prompt. Accumulate until
653
+ // turn_completed, then fingerprint the full prompt for retry detection.
654
+ let openUserTurn = false;
655
+ let userChunkBuffer = "";
656
+
657
+ function closeTurn() {
658
+ if (currentHadEdit) editTurns += 1;
659
+ currentHadEdit = false;
660
+ if (userChunkBuffer) {
661
+ const fingerprint = promptFingerprint(userChunkBuffer);
662
+ if (lastPromptFingerprint && fingerprint === lastPromptFingerprint) retryTurns += 1;
663
+ lastPromptFingerprint = fingerprint;
664
+ }
665
+ openUserTurn = false;
666
+ userChunkBuffer = "";
667
+ }
668
+
669
+ const input = fs.createReadStream(filePath, { encoding: "utf8" });
670
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
671
+ for await (const line of lines) {
672
+ let obj;
673
+ try { obj = JSON.parse(line); } catch { continue; }
674
+ updateBounds(bounds, grokTimestampIso(obj));
675
+ const params = obj.params && typeof obj.params === "object" ? obj.params : {};
676
+ if (typeof params.sessionId === "string" && params.sessionId) {
677
+ observedSessionId = params.sessionId;
678
+ }
679
+ const update = params.update && typeof params.update === "object" ? params.update : null;
680
+ if (!update) continue;
681
+ const sessionUpdate = update.sessionUpdate;
682
+
683
+ if (sessionUpdate === "user_message_chunk") {
684
+ const piece = extractGrokUserPrompt(update);
685
+ if (!piece) continue;
686
+ // First non-empty chunk after a closed turn opens a new user turn;
687
+ // later chunks only extend the fingerprint buffer.
688
+ if (!openUserTurn) {
689
+ turns += 1;
690
+ openUserTurn = true;
691
+ userChunkBuffer = piece;
692
+ } else {
693
+ userChunkBuffer += piece;
694
+ }
695
+ continue;
696
+ }
697
+
698
+ if (sessionUpdate === "tool_call") {
699
+ // Tools belong to the current user turn. After turn_completed closes a
700
+ // turn, a tool-only follow-up must open a new anonymous turn — otherwise
701
+ // edit_turns can exceed turns.
702
+ if (!openUserTurn) {
703
+ turns += 1;
704
+ openUserTurn = true;
705
+ }
706
+ const name = extractGrokToolName(update);
707
+ if (EDIT_TOOLS.has(name)) currentHadEdit = true;
708
+ if (GROK_SUBAGENT_TOOLS.has(name)) {
709
+ subagentCalls += 1;
710
+ const display = name === "spawn_agent" ? "spawn_subagent" : name;
711
+ subagentTypes.set(display, (subagentTypes.get(display) || 0) + 1);
712
+ }
713
+ continue;
714
+ }
715
+
716
+ if (sessionUpdate === "turn_completed") {
717
+ closeTurn();
718
+ const usage = update.usage;
719
+ addTotals(tokens, grokUsageTotals(usage));
720
+ const candidate = pickGrokModel(usage, model);
721
+ if (candidate && candidate !== "unknown") model = candidate;
722
+ continue;
723
+ }
724
+ }
725
+ closeTurn();
726
+
727
+ // Prefer observed turn_completed totals. Never invent billable totals from
728
+ // signals.contextTokensUsed (context-window occupancy is not API usage);
729
+ // leave zeros so listSessionsForBrowser filters empty sessions out.
730
+ if (turns === 0 && finite(signals.turnCount) > 0) {
731
+ turns = finite(signals.turnCount);
732
+ }
733
+ if (!Number.isFinite(Date.parse(bounds.started_at || "")) && summary.created_at) {
734
+ updateBounds(bounds, summary.created_at);
735
+ }
736
+ if (!Number.isFinite(Date.parse(bounds.ended_at || "")) && (summary.last_active_at || summary.updated_at)) {
737
+ updateBounds(bounds, summary.last_active_at || summary.updated_at);
738
+ }
739
+ if ((!model || model === "unknown") && signals.primaryModelId) {
740
+ model = normalizeSessionModel(signals.primaryModelId) || model;
741
+ }
742
+
743
+ return finalizeRecord({
744
+ version: SIDECAR_VERSION,
745
+ session_hash: sessionHash("grok", observedSessionId || filePath),
746
+ session_id: observedSessionId || null,
747
+ // Local-only: stripped in summarizeSessions before any cloud/CSV export.
748
+ title,
749
+ source: "grok",
750
+ project_key: projectKey(cwd, filePath),
751
+ project_ref: cwd || null,
752
+ model: model || "unknown",
753
+ ...bounds,
754
+ turns,
755
+ edit_turns: editTurns,
756
+ retry_turns: retryTurns,
757
+ subagent_calls: subagentCalls,
758
+ subagent_types: Object.fromEntries([...subagentTypes.entries()].sort()),
759
+ tokens,
760
+ provenance: { source: "local-session-log", confidence: "observed", retry_confidence: "inferred", content_retained: false },
761
+ });
762
+ }
763
+
460
764
  async function discoverSessionFiles(home) {
461
- const [allClaude, codex, archived] = await Promise.all([
765
+ const grokHome = resolveGrokHome(home);
766
+ const [allClaude, codex, archived, grok] = await Promise.all([
462
767
  listClaudeProjectFiles(path.join(home, ".claude", "projects")),
463
768
  listRolloutFilesDeep(path.join(home, ".codex", "sessions")),
464
769
  listRolloutFilesDeep(path.join(home, ".codex", "archived_sessions")),
770
+ listGrokSessionFiles(path.join(grokHome, "sessions")),
465
771
  ]);
466
772
  // Claude Memory stores thousands of background observer transcripts beside
467
773
  // real Claude Code sessions. They contain <synthetic>/haiku bookkeeping and
@@ -479,7 +785,7 @@ async function discoverSessionFiles(home) {
479
785
  const previous = codexBySession.get(id);
480
786
  if (!previous || mtimeOf(filePath) > mtimeOf(previous)) codexBySession.set(id, filePath);
481
787
  }
482
- return { claude, codex: [...codexBySession.values()] };
788
+ return { claude, codex: [...codexBySession.values()], grok };
483
789
  }
484
790
 
485
791
  function filesSignature(files) {
@@ -548,10 +854,18 @@ function loadCodexTitleIndex(filePath) {
548
854
  // A Codex row also depends on session_index.jsonl, not just its rollout file.
549
855
  // Include that dependency in the incremental cache key so renaming a thread is
550
856
  // visible after the next refresh instead of leaving a stale title indefinitely.
857
+ // Grok titles live in sibling summary.json (and signals can change without
858
+ // touching updates.jsonl on some partial flushes).
551
859
  function analyticsEntryStatKey(source, filePath) {
552
860
  const sessionStat = sessionFileStatKey(filePath);
553
- if (!sessionStat || source !== "codex") return sessionStat;
554
- return `${sessionStat}|title-index:${sessionFileStatKey(codexTitleIndexPathFor(filePath)) || "missing"}`;
861
+ if (!sessionStat) return sessionStat;
862
+ if (source === "codex") {
863
+ return `${sessionStat}|title-index:${sessionFileStatKey(codexTitleIndexPathFor(filePath)) || "missing"}`;
864
+ }
865
+ if (source === "grok") {
866
+ return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(filePath)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(filePath)) || "missing"}`;
867
+ }
868
+ return sessionStat;
555
869
  }
556
870
 
557
871
  function readSidecar(sidecarPath) {
@@ -587,11 +901,15 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
587
901
  const discovered = await discoverSessionFiles(home);
588
902
  // Codex thread titles are stored separately from rollout files. Include the
589
903
  // index in the overall signature so an index-only rename reaches the
590
- // per-file dependency check below on the next refresh.
904
+ // per-file dependency check below on the next refresh. Grok titles/metadata
905
+ // live in sibling summary.json / signals.json next to updates.jsonl.
591
906
  const signature = filesSignature([
592
907
  ...discovered.claude,
593
908
  ...discovered.codex,
594
909
  ...discovered.codex.map(codexTitleIndexPathFor).filter(Boolean),
910
+ ...discovered.grok,
911
+ ...discovered.grok.map(grokSummaryPathFor),
912
+ ...discovered.grok.map(grokSignalsPathFor),
595
913
  ]);
596
914
  if (!force && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
597
915
  await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
@@ -612,6 +930,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
612
930
  const entries = [
613
931
  ...discovered.claude.map((filePath) => ({ source: "claude", filePath, scan: scanClaudeSession })),
614
932
  ...discovered.codex.map((filePath) => ({ source: "codex", filePath, scan: scanCodexSession })),
933
+ ...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
615
934
  ];
616
935
  // Files we could not turn into a row (permission denied, half-written line,
617
936
  // vanished mid-scan). Swallowing these silently made sessions disappear with
@@ -664,8 +983,8 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
664
983
  return sessions;
665
984
  }
666
985
 
667
- // A cold scan walks every local Claude/Codex session file. Period switches can
668
- // issue overlapping requests while that scan is still running; share the
986
+ // A cold scan walks every local Claude/Codex/Grok session file. Period switches
987
+ // can issue overlapping requests while that scan is still running; share the
669
988
  // promise per home so those requests wait for one scan instead of multiplying
670
989
  // the disk work and racing the atomic sidecar write.
671
990
  const sessionAnalyticsBuilds = new Map();
@@ -808,6 +1127,9 @@ function resumeCommandFor(source, sessionId) {
808
1127
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(id)) return null;
809
1128
  if (source === "claude") return `claude --resume ${id}`;
810
1129
  if (source === "codex") return `codex resume ${id}`;
1130
+ // Grok Build: `grok --resume <session-id>` (also accepts a title). The
1131
+ // command must still be run from the session's project_ref cwd.
1132
+ if (source === "grok") return `grok --resume ${id}`;
811
1133
  return null;
812
1134
  }
813
1135
 
@@ -934,6 +1256,7 @@ module.exports = {
934
1256
  sessionHash,
935
1257
  scanClaudeSession,
936
1258
  scanCodexSession,
1259
+ scanGrokSession,
937
1260
  buildSessionAnalytics,
938
1261
  summarizeSessions,
939
1262
  listSessionsForBrowser,