tokentracker-cli 0.87.0 → 0.87.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/dashboard/dist/assets/{AchievementBadge-CU9m5POo.js → AchievementBadge-C468-Rsw.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-ESzayVR5.js → AchievementsPage-CZWzSdiH.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DUVRqw19.js → AchievementsSection-DXt6VbwH.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-CxNQcN6_.js → BadgeDetailModal-BtGvpqQm.js} +1 -1
  5. package/dashboard/dist/assets/{Card-DXIoSOtO.js → Card-DZ-_fXkw.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-BsuxMkNd.js → ClawdAnimated-9rXaHo1-.js} +1 -1
  7. package/dashboard/dist/assets/{CommandPalette-C-amBp21.js → CommandPalette-sqIvZNsH.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-ByFOQube.js → CommunityStatsModal-c8VohIqQ.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-BnUyidbn.js → ConfirmModal-Ct2HLdds.js} +1 -1
  10. package/dashboard/dist/assets/Controls-DPtXGUGi.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-C65hbl8o.js +1 -0
  12. package/dashboard/dist/assets/{DevicePage-B_o-jhku.js → DevicePage-Ds4GEuqY.js} +1 -1
  13. package/dashboard/dist/assets/{DialogClose-BSd7Jiag.js → DialogClose-B2IAbJG4.js} +1 -1
  14. package/dashboard/dist/assets/DialogDescription-DeRza-LI.js +1 -0
  15. package/dashboard/dist/assets/{DialogTitle-XBpneDtQ.js → DialogTitle-BIwLH6gA.js} +1 -1
  16. package/dashboard/dist/assets/{FadeIn-Dg0v4V9p.js → FadeIn-DPS7_DOB.js} +1 -1
  17. package/dashboard/dist/assets/{HeaderGithubStar-BIsQD1OK.js → HeaderGithubStar-CsSgr9Hd.js} +1 -1
  18. package/dashboard/dist/assets/{HoverTooltip-CtmRa8ne.js → HoverTooltip-CLGZW2d-.js} +1 -1
  19. package/dashboard/dist/assets/{IpCheckPage-BUoLz5sL.js → IpCheckPage-DEM_uk69.js} +1 -1
  20. package/dashboard/dist/assets/{LandingPage-BheA-lw8.js → LandingPage-BQ3YzZ68.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardAvatar-DAy9SOdL.js → LeaderboardAvatar-ySznr4Ml.js} +1 -1
  22. package/dashboard/dist/assets/{LeaderboardPage-CwLqtEUN.js → LeaderboardPage-Br4JCoVE.js} +2 -2
  23. package/dashboard/dist/assets/{LeaderboardProfileModal-BZ9yGyjB.js → LeaderboardProfileModal-Drvsb5xF.js} +3 -3
  24. package/dashboard/dist/assets/{LeaderboardProfilePage-Cs-LTfvS.js → LeaderboardProfilePage-BD0P-zZF.js} +1 -1
  25. package/dashboard/dist/assets/{LimitsPage-BQle8e8N.js → LimitsPage-Mbn2mj6S.js} +1 -1
  26. package/dashboard/dist/assets/{LocalOnlyNotice-Bp4CqObQ.js → LocalOnlyNotice-BsKgtoVa.js} +1 -1
  27. package/dashboard/dist/assets/{LoginCard-BIXfWkR2.js → LoginCard-CTQDdHcx.js} +1 -1
  28. package/dashboard/dist/assets/{LoginModal-B-ubPXdr.js → LoginModal-BxCyLDEW.js} +1 -1
  29. package/dashboard/dist/assets/{LoginPage-9arjWT-s.js → LoginPage-ftxnX8g1.js} +1 -1
  30. package/dashboard/dist/assets/PetPage-CQsfqsm8.js +1 -0
  31. package/dashboard/dist/assets/{PopoverPopup-DMg7XE1h.js → PopoverPopup-C16r8jQC.js} +1 -1
  32. package/dashboard/dist/assets/{ProviderIcon-BG3oqeST.js → ProviderIcon-7Tkq9_m6.js} +1 -1
  33. package/dashboard/dist/assets/{ResetPasswordPage-LQ9rfcRE.js → ResetPasswordPage-D-MPpQd1.js} +1 -1
  34. package/dashboard/dist/assets/{SegmentedControl-GY2tRk1u.js → SegmentedControl-CYFrzu9w.js} +1 -1
  35. package/dashboard/dist/assets/{Select-CCngFwmz.js → Select-Dp4V9p6t.js} +1 -1
  36. package/dashboard/dist/assets/{SelectItemText-B9Zm8WTR.js → SelectItemText-CxhBeoxx.js} +1 -1
  37. package/dashboard/dist/assets/{ServiceStatusPage-BxPvk1-W.js → ServiceStatusPage-BT6hvvLW.js} +1 -1
  38. package/dashboard/dist/assets/{SessionsPage-DGo6yAwP.js → SessionsPage-BopeZBiH.js} +1 -1
  39. package/dashboard/dist/assets/SettingsPage-D03GJJBd.js +1 -0
  40. package/dashboard/dist/assets/{SkillsPage-D0PB3WR5.js → SkillsPage-Cr8O6eOH.js} +1 -1
  41. package/dashboard/dist/assets/{TokenGalaxy-Chh_fPIv.js → TokenGalaxy-BoUsrUEs.js} +1 -1
  42. package/dashboard/dist/assets/{ToolbarRootContext-BXacVvrF.js → ToolbarRootContext-CwRaHhMN.js} +1 -1
  43. package/dashboard/dist/assets/{TrendMonitor-CjiCwNmO.js → TrendMonitor-BV2ugEsw.js} +1 -1
  44. package/dashboard/dist/assets/WidgetsPage-B7r4mvrw.js +1 -0
  45. package/dashboard/dist/assets/{WrappedPage-CEpBAMZR.js → WrappedPage-CFU9l22i.js} +1 -1
  46. package/dashboard/dist/assets/__vite-browser-external-CzQk3FUc.css +1 -0
  47. package/dashboard/dist/assets/{__vite-browser-external-Dyag-P6u.js → __vite-browser-external-Db9onipF.js} +10 -3
  48. package/dashboard/dist/assets/{agent-logos-CLQGE0AM.js → agent-logos-DSSn_SPi.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-CU2HDjoG.js → arrow-up-right-COvrArNz.js} +1 -1
  50. package/dashboard/dist/assets/{check-CucGM5lh.js → check-CasSqiev.js} +1 -1
  51. package/dashboard/dist/assets/{chevron-right-DYzUvcID.js → chevron-right-B1PMeNoD.js} +1 -1
  52. package/dashboard/dist/assets/{copy-BFeQ31Tr.js → copy-D67vycfp.js} +1 -1
  53. package/dashboard/dist/assets/{download-Coq_llCX.js → download-adOCFO2v.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-BJiDBW3d.js → external-link-DVanKEuy.js} +1 -1
  55. package/dashboard/dist/assets/{flame-5stsnhc0.js → flame-RbiHZM9b.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens-CpTJbo-2.js → format-tokens-DhMp1e1L.js} +1 -1
  57. package/dashboard/dist/assets/{icons-jsuvmnnO.js → icons-BC0p_aGr.js} +1 -1
  58. package/dashboard/dist/assets/{index-DSD3RLQ4.js → index-A41xjc83.js} +1 -1
  59. package/dashboard/dist/assets/{index-BU6GsTbx.js → index-CaRyTJ6K.js} +1 -1
  60. package/dashboard/dist/assets/{index-Bs97feNM.js → index-DrgBqR3o.js} +1 -1
  61. package/dashboard/dist/assets/{info-DNNUzeV_.js → info-D9LwqlnC.js} +1 -1
  62. package/dashboard/dist/assets/{limits-providers-DKTqNO55.js → limits-providers-CRHpXhmW.js} +1 -1
  63. package/dashboard/dist/assets/{link-2-DC1hnQcE.js → link-2-B5f4qFR0.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-DtInM4Io.js → loader-circle-DCmwZEdS.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-DxpsaYXp.js → maximize-2-BMalEDCZ.js} +1 -1
  66. package/dashboard/dist/assets/{provider-display-hkyvfEJF.js → provider-display-qMFRUZHm.js} +1 -1
  67. package/dashboard/dist/assets/{react-TbJoDSP4.js → react-BnaPNGj8.js} +1 -1
  68. package/dashboard/dist/assets/{search-C7bnqB9b.js → search-BrqBkLs2.js} +1 -1
  69. package/dashboard/dist/assets/{skills-api-D7glOWg7.js → skills-api-vo3iM6yD.js} +1 -1
  70. package/dashboard/dist/assets/{store-CCVlzuqv.js → store-BwMVXuz3.js} +1 -1
  71. package/dashboard/dist/assets/{terminal-uQlsmzGi.js → terminal-BiyUh_8k.js} +1 -1
  72. package/dashboard/dist/assets/{trash-2-B1yuX-oD.js → trash-2-DAr4sqkX.js} +1 -1
  73. package/dashboard/dist/assets/{use-community-stats-0Fs5nY1G.js → use-community-stats-Cg48Zphy.js} +1 -1
  74. package/dashboard/dist/assets/{use-limits-display-prefs-CrftAn62.js → use-limits-display-prefs-DfKtH1Zn.js} +1 -1
  75. package/dashboard/dist/assets/{use-ordered-list-DAdPjLb5.js → use-ordered-list-CnXfrWeF.js} +1 -1
  76. package/dashboard/dist/assets/use-reduced-motion-BIgYrbON.js +1 -0
  77. package/dashboard/dist/assets/{use-session-efficiency-pref-Cc52HiK0.js → use-session-efficiency-pref-CMDTCAAd.js} +1 -1
  78. package/dashboard/dist/assets/{use-transform-D76wHhX0.js → use-transform-B2d9Txt5.js} +1 -1
  79. package/dashboard/dist/assets/{use-usage-limits-B7Prb8rk.js → use-usage-limits-CJbTUl9W.js} +1 -1
  80. package/dashboard/dist/assets/{useCurrency-DWoM_A3s.js → useCurrency-B0hJte5f.js} +1 -1
  81. package/dashboard/dist/assets/{useScrollLock-Bp6ddiQo.js → useScrollLock-DuosKdSB.js} +1 -1
  82. package/dashboard/dist/assets/{useTokenFormat-DZOYz5HT.js → useTokenFormat-DB8pKLFM.js} +1 -1
  83. package/dashboard/dist/assets/{zap-DyeAeO8l.js → zap-rbzC4XgP.js} +1 -1
  84. package/dashboard/dist/index.html +2 -2
  85. package/dashboard/dist/ip-check.html +2 -2
  86. package/dashboard/dist/leaderboard.html +2 -2
  87. package/dashboard/dist/share.html +2 -2
  88. package/package.json +1 -1
  89. package/src/commands/device-login.js +12 -7
  90. package/src/commands/init.js +3 -0
  91. package/src/commands/sync.js +115 -20
  92. package/src/lib/grok-context-breakdown.js +124 -19
  93. package/src/lib/grok-limits.js +374 -9
  94. package/src/lib/pricing/seed-snapshot.json +1 -1
  95. package/src/lib/runtime-config.js +23 -3
  96. package/dashboard/dist/assets/DashboardPage-GyXkKYW0.js +0 -1
  97. package/dashboard/dist/assets/DialogDescription-BFBVGbUT.js +0 -1
  98. package/dashboard/dist/assets/PetPage-BzAVsvh6.js +0 -1
  99. package/dashboard/dist/assets/SettingsPage-ConPEiwO.js +0 -1
  100. package/dashboard/dist/assets/WidgetsPage-CBw9uBCO.js +0 -1
  101. package/dashboard/dist/assets/__vite-browser-external-hXV_ZYTG.css +0 -1
  102. package/dashboard/dist/assets/use-native-settings-CVqB9eoO.js +0 -1
  103. package/dashboard/dist/assets/use-reduced-motion-DVGYs3-k.js +0 -1
@@ -8,7 +8,13 @@ const readline = require("node:readline");
8
8
  const { resolveInstallPaths, ensureFlatCursor } = require("../lib/install-resolver");
9
9
  const { multiInstallParse, mergeBothFileSources } = require("../lib/multi-install-parser");
10
10
  const wsl = require("../lib/wsl-probe");
11
- const { ensureDir, readJson, writeJson, openLock } = require("../lib/fs");
11
+ const {
12
+ ensureDir,
13
+ readJson,
14
+ writeJson,
15
+ chmod600IfPossible,
16
+ openLock,
17
+ } = require("../lib/fs");
12
18
  const { physicalJsonlRecords } = require("../lib/jsonl-lines");
13
19
  const {
14
20
  listRolloutFiles,
@@ -125,7 +131,7 @@ const {
125
131
  openCursorStore,
126
132
  } = require("../lib/cursor-store");
127
133
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
128
- const { resolveRuntimeConfig } = require("../lib/runtime-config");
134
+ const { resolveRuntimeConfig, isLegacyInsforgeBaseUrl } = require("../lib/runtime-config");
129
135
  const { extractTokenCount } = require("../lib/codex-rollout-parser");
130
136
  const {
131
137
  consumeUsageDelta,
@@ -215,6 +221,7 @@ const CODEX_FORK_REPLAY_REPAIR_KEY = "codexForkReplayRepair_2026_07";
215
221
  // contributing rollout before committing the rebuild. The new key is required
216
222
  // so installs that finalized the original migration on a false negative retry.
217
223
  const CODEX_USAGE_LINEAGE_REPAIR_KEY = "codexUsageLineageRepair_2026_07_v2";
224
+ const LEGACY_BASE_URL_MIGRATION_NOTE = "reset_after_legacy_baseurl_migration_2026_07";
218
225
  // Keep the one escalated desktop refresh bounded; explicit full syncs can retry
219
226
  // the same migration without this ceiling when the history needs a deeper scan.
220
227
  const CODEX_BACKGROUND_LINEAGE_SCAN_MAX_BYTES = 1024 * 1024;
@@ -430,6 +437,53 @@ async function cmdSync(argv, context = {}) {
430
437
  }
431
438
 
432
439
  const config = await readJson(configPath);
440
+ let legacyBaseUrlMigration = null;
441
+ // One-time repair for installs initialized before the 2026-04-19 InsForge
442
+ // project migration (0.5.67): init preserved any persisted config.baseUrl,
443
+ // so those installs kept uploading to the retired b46ug8xu project until
444
+ // its backend went dark on 2026-07-27 — every upload since 503s while the
445
+ // dashboard shows no error (silent cloud outage). Reset the upload offset
446
+ // so the whole local queue replays into the current project, clear the 503
447
+ // backoff, then remove the retired URL as the final commit step. Keeping the
448
+ // URL until every prerequisite succeeds makes an interrupted migration
449
+ // retryable on the next sync. Device tokens are opaque random values looked
450
+ // up by SHA-256 hash in tokentracker_device_tokens (not JWT_SECRET-signed);
451
+ // the database was restored into the current project, so preserve the old
452
+ // token unless the local API has already minted a replacement from the
453
+ // user's current login session and passed it through the environment.
454
+ // Ingest is a whole-row upsert per bucket key, making replay idempotent.
455
+ if (config && isLegacyInsforgeBaseUrl(config.baseUrl)) {
456
+ const priorQueueState = (await readJson(queueStatePath)) || {};
457
+ // Prepare the replay exactly once. The note survives successful batches,
458
+ // so retryable failures retain both their committed offset and backoff
459
+ // instead of restarting history and hammering the backend on every hook.
460
+ if (priorQueueState.note !== LEGACY_BASE_URL_MIGRATION_NOTE) {
461
+ await writeJson(queueStatePath, {
462
+ ...priorQueueState,
463
+ offset: 0,
464
+ updatedAt: new Date().toISOString(),
465
+ note: LEGACY_BASE_URL_MIGRATION_NOTE,
466
+ });
467
+ try {
468
+ await fs.unlink(uploadThrottlePath);
469
+ } catch (error) {
470
+ if (error?.code !== "ENOENT") throw error;
471
+ }
472
+ }
473
+ const replacementDeviceToken =
474
+ typeof process.env.TOKENTRACKER_DEVICE_TOKEN === "string"
475
+ ? process.env.TOKENTRACKER_DEVICE_TOKEN.trim()
476
+ : "";
477
+ const previousDeviceToken =
478
+ typeof config.deviceToken === "string" ? config.deviceToken.trim() : "";
479
+ // Do not commit either the replacement token or URL removal yet. The
480
+ // current backend must accept a token first; otherwise a stale environment
481
+ // token could destroy a valid restored credential and make recovery worse.
482
+ legacyBaseUrlMigration = {
483
+ previousDeviceToken,
484
+ replacementDeviceToken,
485
+ };
486
+ }
433
487
  const codexCursorRoots = [process.env.CODEX_HOME || path.join(home, ".codex")];
434
488
  const cursorStore = await openCursorStore({
435
489
  trackerDir,
@@ -2386,24 +2440,28 @@ async function cmdSync(argv, context = {}) {
2386
2440
 
2387
2441
  progress?.stop();
2388
2442
 
2389
- const runtime = resolveRuntimeConfig({ config: config || {}, env: process.env });
2443
+ const runtimeConfig = config ? { ...config } : {};
2444
+ if (legacyBaseUrlMigration?.replacementDeviceToken) {
2445
+ runtimeConfig.deviceToken = legacyBaseUrlMigration.replacementDeviceToken;
2446
+ }
2447
+ const runtime = resolveRuntimeConfig({ config: runtimeConfig, env: process.env });
2390
2448
 
2391
2449
  let uploadResult = { inserted: 0, skipped: 0 };
2392
2450
  let uploadAttempted = false;
2393
- let backgroundUploadDecision = null;
2451
+ let autoUploadDecision = null;
2394
2452
 
2395
- if (opts.publishAccount) {
2453
+ if (opts.publishAccount || (legacyBaseUrlMigration && opts.auto)) {
2396
2454
  const uploadStateBefore = (await readJson(queueStatePath)) || { offset: 0 };
2397
2455
  const queueSizeBefore = await safeStatSize(queuePath);
2398
2456
  const pendingBytesBefore = Math.max(
2399
2457
  0,
2400
2458
  queueSizeBefore - Number(uploadStateBefore.offset || 0),
2401
2459
  );
2402
- // The native five-minute loop is already the success cadence. Reuse the
2403
- // shared decision helper for pending/backoff handling, but intentionally
2404
- // ignore the legacy 30-minute success throttle so account publication
2405
- // does not become stale again. Failure backoff remains authoritative.
2406
- backgroundUploadDecision = decideAutoUpload({
2460
+ // Native publication and every auto-triggered legacy migration share the
2461
+ // failure-backoff gate. Intentionally ignore the 30-minute success
2462
+ // throttle: native refresh owns its own cadence, while a pending migration
2463
+ // should complete as soon as a credential becomes usable.
2464
+ autoUploadDecision = decideAutoUpload({
2407
2465
  nowMs: Date.now(),
2408
2466
  pendingBytes: pendingBytesBefore,
2409
2467
  state: {
@@ -2420,7 +2478,7 @@ async function cmdSync(argv, context = {}) {
2420
2478
 
2421
2479
  if (runtime.deviceToken && runtime.baseUrl &&
2422
2480
  (!isBackgroundLightweightSync || opts.publishAccount) &&
2423
- (!backgroundUploadDecision || backgroundUploadDecision.allowed)) {
2481
+ (!autoUploadDecision || autoUploadDecision.allowed)) {
2424
2482
  uploadAttempted = true;
2425
2483
  // Mirror the machine identity into the purge-surviving seed file so a
2426
2484
  // future `uninstall --purge` + reinstall recovers the same cloud device
@@ -2432,14 +2490,49 @@ async function cmdSync(argv, context = {}) {
2432
2490
  // best effort — upload below must not be blocked by identity mirroring
2433
2491
  }
2434
2492
  try {
2435
- uploadResult = await drainQueueToCloud({
2436
- baseUrl: runtime.baseUrl,
2437
- deviceToken: runtime.deviceToken,
2438
- queuePath,
2439
- queueStatePath,
2440
- maxBatches: opts.drain ? 100 : (backgroundUploadDecision?.maxBatches || 5),
2441
- batchSize: backgroundUploadDecision?.batchSize || 200,
2442
- });
2493
+ let successfulDeviceToken = runtime.deviceToken;
2494
+ const drainWithToken = (deviceToken) =>
2495
+ drainQueueToCloud({
2496
+ baseUrl: runtime.baseUrl,
2497
+ deviceToken,
2498
+ queuePath,
2499
+ queueStatePath,
2500
+ maxBatches: opts.drain ? 100 : (autoUploadDecision?.maxBatches || 5),
2501
+ batchSize: autoUploadDecision?.batchSize || 200,
2502
+ });
2503
+ try {
2504
+ uploadResult = await drainWithToken(successfulDeviceToken);
2505
+ } catch (error) {
2506
+ const fallbackDeviceToken = legacyBaseUrlMigration?.previousDeviceToken;
2507
+ const replacementDeviceToken = legacyBaseUrlMigration?.replacementDeviceToken;
2508
+ const stateAfterFailure = (await readJson(queueStatePath)) || { offset: 0 };
2509
+ const canFallbackWithoutSplittingHistory =
2510
+ (error?.status === 401 || error?.status === 403) &&
2511
+ replacementDeviceToken &&
2512
+ fallbackDeviceToken &&
2513
+ fallbackDeviceToken !== replacementDeviceToken &&
2514
+ Number(stateAfterFailure.offset || 0) === 0;
2515
+ if (!canFallbackWithoutSplittingHistory) throw error;
2516
+ successfulDeviceToken = fallbackDeviceToken;
2517
+ uploadResult = await drainWithToken(successfulDeviceToken);
2518
+ }
2519
+ // A successful ingest response proves which credential belongs to the
2520
+ // current backend. Only now commit the token and remove the retry marker.
2521
+ // Empty queues make no request, so keep the marker until future data can
2522
+ // validate a credential instead of persisting an unverified token.
2523
+ if (legacyBaseUrlMigration && uploadResult.batches > 0) {
2524
+ // device-login does not share the sync lock and may have written a
2525
+ // fresh current-backend config while the scan/upload was running.
2526
+ // Re-read before committing, merge only while the legacy marker still
2527
+ // exists, and never clobber a concurrently completed login.
2528
+ const latestConfig = (await readJson(configPath)) || config;
2529
+ if (isLegacyInsforgeBaseUrl(latestConfig.baseUrl)) {
2530
+ latestConfig.deviceToken = successfulDeviceToken;
2531
+ delete latestConfig.baseUrl;
2532
+ await writeJson(configPath, latestConfig);
2533
+ await chmod600IfPossible(configPath);
2534
+ }
2535
+ }
2443
2536
  // Record success so the exponential backoff step resets — otherwise
2444
2537
  // a single past failure keeps us pessimistically throttled forever.
2445
2538
  uploadThrottleState = recordUploadSuccess({
@@ -3005,6 +3098,7 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
3005
3098
  let offset = Number(state.offset || 0);
3006
3099
  let inserted = 0;
3007
3100
  let skipped = 0;
3101
+ let batches = 0;
3008
3102
 
3009
3103
  const queueSize = await safeStatSize(queuePath);
3010
3104
  const limit = Math.min(Math.max(1, Math.floor(Number(batchSize || 200))), MAX_INGEST_BUCKETS);
@@ -3042,6 +3136,7 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
3042
3136
 
3043
3137
  inserted += Number(data?.inserted || 0);
3044
3138
  skipped += Number(data?.skipped || 0);
3139
+ batches += 1;
3045
3140
 
3046
3141
  offset = result.nextOffset;
3047
3142
  state.offset = offset;
@@ -3049,7 +3144,7 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
3049
3144
  await writeJson(queueStatePath, state);
3050
3145
  }
3051
3146
 
3052
- return { inserted, skipped };
3147
+ return { inserted, skipped, batches };
3053
3148
  }
3054
3149
 
3055
3150
  async function readQueueBatch(queuePath, startOffset, maxBuckets) {
@@ -27,9 +27,16 @@ const {
27
27
  getExecutableName,
28
28
  } = require("./categorizer-utils");
29
29
 
30
- const CACHE_SCHEMA_VERSION = "grok-context-v1";
30
+ const CACHE_SCHEMA_VERSION = "grok-context-v2";
31
31
  const CACHE = new Map();
32
- const CACHE_TTL_MS = 60_000;
32
+ // Result-level TTL. Per-file parse cache is the main win when flipping tabs.
33
+ const CACHE_TTL_MS = 5 * 60_000;
34
+ // Deduplicate concurrent scans when the dashboard remounts the panel quickly.
35
+ const IN_FLIGHT = new Map();
36
+ // Per updates.jsonl parse cache — mirrors Codex PARSED_GROUP_CACHE so switching
37
+ // back to Grok does not re-read every session log on each open.
38
+ const FILE_PARSE_CACHE = new Map();
39
+ const MAX_FILE_PARSE_CACHE_ENTRIES = 512;
33
40
 
34
41
  // ---------------------------------------------------------------------------
35
42
  // Time helpers
@@ -316,7 +323,45 @@ function finalizeExecRows(map) {
316
323
  .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
317
324
  }
318
325
 
326
+ function fileParseCacheKey(updatesPath, { from, to, timeZoneContext } = {}, stat) {
327
+ return [
328
+ CACHE_SCHEMA_VERSION,
329
+ updatesPath,
330
+ from || "",
331
+ to || "",
332
+ timeZoneContext?.timeZone || "",
333
+ timeZoneContext?.offsetMinutes ?? "",
334
+ Number(stat?.size || 0),
335
+ Math.floor(Number(stat?.mtimeMs || 0)),
336
+ ].join("|");
337
+ }
338
+
339
+ function rememberFileParse(key, value) {
340
+ FILE_PARSE_CACHE.delete(key);
341
+ FILE_PARSE_CACHE.set(key, value);
342
+ while (FILE_PARSE_CACHE.size > MAX_FILE_PARSE_CACHE_ENTRIES) {
343
+ const oldest = FILE_PARSE_CACHE.keys().next().value;
344
+ if (oldest == null) break;
345
+ FILE_PARSE_CACHE.delete(oldest);
346
+ }
347
+ }
348
+
319
349
  async function parseGrokUpdatesFile(updatesPath, { from, to, timeZoneContext } = {}) {
350
+ let stat = null;
351
+ try {
352
+ stat = fs.statSync(updatesPath);
353
+ } catch {
354
+ return null;
355
+ }
356
+ if (!stat.isFile() || stat.size <= 0) return null;
357
+
358
+ const cacheKey = fileParseCacheKey(updatesPath, { from, to, timeZoneContext }, stat);
359
+ const hit = FILE_PARSE_CACHE.get(cacheKey);
360
+ if (hit) {
361
+ rememberFileParse(cacheKey, hit);
362
+ return hit;
363
+ }
364
+
320
365
  const byTool = new Map();
321
366
  const byExecKind = new Map();
322
367
  const byExecExit = new Map();
@@ -512,7 +557,7 @@ async function parseGrokUpdatesFile(updatesPath, { from, to, timeZoneContext } =
512
557
  input.destroy?.();
513
558
  }
514
559
 
515
- return {
560
+ const parsedResult = {
516
561
  totals: roundTotals(totals),
517
562
  turnCount,
518
563
  toolBreakdown: { tool_rows: finalizeToolRows(byTool) },
@@ -525,6 +570,8 @@ async function parseGrokUpdatesFile(updatesPath, { from, to, timeZoneContext } =
525
570
  by_exit: finalizeExecRows(byExecExit),
526
571
  },
527
572
  };
573
+ rememberFileParse(cacheKey, parsedResult);
574
+ return parsedResult;
528
575
  }
529
576
 
530
577
  function emptySessionResult() {
@@ -580,38 +627,96 @@ function mergeExecRows(map, rows) {
580
627
  }
581
628
  }
582
629
 
583
- async function computeGrokContextBreakdown({
584
- from = null,
585
- to = null,
586
- top = 50,
587
- timeZoneContext = null,
588
- env = process.env,
589
- } = {}) {
590
- const limitedTop = Number.isFinite(top) && top > 0 ? Math.min(Math.floor(top), 200) : 50;
591
- const sessions = discoverGrokSessionFiles(env);
592
- const inventorySig = crypto
630
+ function buildGrokInventorySignature(sessions) {
631
+ return crypto
593
632
  .createHash("sha256")
594
633
  .update(
595
- sessions
634
+ (sessions || [])
596
635
  .map((s) => `${s.updatesPath}:${s.size}:${Math.floor(s.mtimeMs)}`)
597
636
  .join("|"),
598
637
  )
599
638
  .digest("hex");
600
- const cacheKey = [
639
+ }
640
+
641
+ function buildGrokResultCacheKey({ from, to, top, timeZoneContext, inventorySig }) {
642
+ // Include inventorySig so a changed session file cannot serve a stale aggregate.
643
+ // Unchanged files still skip re-parse via FILE_PARSE_CACHE (Codex-style).
644
+ return [
601
645
  CACHE_SCHEMA_VERSION,
602
646
  from || "",
603
647
  to || "",
604
- limitedTop,
648
+ top,
605
649
  timeZoneContext?.timeZone || "",
606
650
  timeZoneContext?.offsetMinutes ?? "",
607
- inventorySig,
651
+ inventorySig || "",
608
652
  ].join("|");
653
+ }
654
+
655
+ async function computeGrokContextBreakdown(options = {}) {
656
+ const {
657
+ from = null,
658
+ to = null,
659
+ top = 50,
660
+ timeZoneContext = null,
661
+ env = process.env,
662
+ } = options;
663
+ const limitedTop = Number.isFinite(top) && top > 0 ? Math.min(Math.floor(top), 200) : 50;
664
+ const sessions = discoverGrokSessionFiles(env);
665
+ const inventorySig = buildGrokInventorySignature(sessions);
666
+ const cacheKey = buildGrokResultCacheKey({
667
+ from,
668
+ to,
669
+ top: limitedTop,
670
+ timeZoneContext,
671
+ inventorySig,
672
+ });
609
673
 
610
674
  const cached = CACHE.get(cacheKey);
611
675
  if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
612
676
  return cached.value;
613
677
  }
614
678
 
679
+ const existing = IN_FLIGHT.get(cacheKey);
680
+ if (existing) return existing;
681
+
682
+ const run = computeGrokContextBreakdownUncached({
683
+ from,
684
+ to,
685
+ top: limitedTop,
686
+ timeZoneContext,
687
+ env,
688
+ sessions,
689
+ cacheKey,
690
+ });
691
+ IN_FLIGHT.set(cacheKey, run);
692
+ try {
693
+ return await run;
694
+ } finally {
695
+ if (IN_FLIGHT.get(cacheKey) === run) IN_FLIGHT.delete(cacheKey);
696
+ }
697
+ }
698
+
699
+ async function computeGrokContextBreakdownUncached({
700
+ from = null,
701
+ to = null,
702
+ top = 50,
703
+ timeZoneContext = null,
704
+ env = process.env,
705
+ sessions = null,
706
+ cacheKey = null,
707
+ } = {}) {
708
+ const limitedTop = Number.isFinite(top) && top > 0 ? Math.min(Math.floor(top), 200) : 50;
709
+ const sessionList = sessions || discoverGrokSessionFiles(env);
710
+ const resultCacheKey =
711
+ cacheKey ||
712
+ buildGrokResultCacheKey({
713
+ from,
714
+ to,
715
+ top: limitedTop,
716
+ timeZoneContext,
717
+ inventorySig: buildGrokInventorySignature(sessionList),
718
+ });
719
+
615
720
  const grand = emptyTotals();
616
721
  const byTool = new Map();
617
722
  const byExecKind = new Map();
@@ -623,7 +728,7 @@ async function computeGrokContextBreakdown({
623
728
  let messageCount = 0;
624
729
  let sessionCount = 0;
625
730
 
626
- for (const sess of sessions) {
731
+ for (const sess of sessionList) {
627
732
  const parsed = await parseGrokUpdatesFile(sess.updatesPath, {
628
733
  from,
629
734
  to,
@@ -776,7 +881,7 @@ async function computeGrokContextBreakdown({
776
881
  },
777
882
  };
778
883
 
779
- CACHE.set(cacheKey, { at: Date.now(), value: result });
884
+ CACHE.set(resultCacheKey, { at: Date.now(), value: result });
780
885
  while (CACHE.size > 32) {
781
886
  const oldest = [...CACHE.entries()].sort((a, b) => a[1].at - b[1].at)[0];
782
887
  if (oldest) CACHE.delete(oldest[0]);