tokentracker-cli 0.84.9 → 0.85.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 (102) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-BgBFpxmU.js → AchievementBadge-DEqbHlmn.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-DHPD3Fnu.js → AchievementsPage-BOaEu1AQ.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DouWVD-3.js → AchievementsSection-C370ngO3.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-Df9eQiA3.js → BadgeDetailModal-D7Wkef0K.js} +1 -1
  5. package/dashboard/dist/assets/{Card-CQ9wBnz6.js → Card-mDEVjNdM.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-XIO-I-5v.js → ClawdAnimated-DdsX-z8n.js} +1 -1
  7. package/dashboard/dist/assets/{CommandPalette-Cy1cJ91B.js → CommandPalette-DhxRIuHC.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-CwKG4LQM.js → CommunityStatsModal-CG-9JPS6.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-BNcWqick.js → ConfirmModal-COhxErLm.js} +1 -1
  10. package/dashboard/dist/assets/DashboardPage-tX0DKgFE.js +1 -0
  11. package/dashboard/dist/assets/{DevicePage-pJ2jNkZV.js → DevicePage-BsSeHx84.js} +1 -1
  12. package/dashboard/dist/assets/{DialogClose-C2SOtkJN.js → DialogClose-eyOW2cbx.js} +1 -1
  13. package/dashboard/dist/assets/DialogDescription-BwtE4lKl.js +1 -0
  14. package/dashboard/dist/assets/{DialogTitle-CGSbB8G7.js → DialogTitle-H8sGiH7c.js} +1 -1
  15. package/dashboard/dist/assets/{FadeIn-CZSiw7me.js → FadeIn-pq2ja50M.js} +1 -1
  16. package/dashboard/dist/assets/{HeaderGithubStar-ZmQhX_kR.js → HeaderGithubStar-DWgcpDHr.js} +1 -1
  17. package/dashboard/dist/assets/{HoverTooltip-D_YdG369.js → HoverTooltip-CcJ_VR65.js} +1 -1
  18. package/dashboard/dist/assets/{IpCheckPage-du886dF0.js → IpCheckPage-CNUN346Q.js} +1 -1
  19. package/dashboard/dist/assets/{LandingPage-BuAak8lU.js → LandingPage-yaoRGsNI.js} +2 -2
  20. package/dashboard/dist/assets/{LeaderboardAvatar-nU0ARYnN.js → LeaderboardAvatar-Ddai4I12.js} +1 -1
  21. package/dashboard/dist/assets/{LeaderboardPage-CngwCvC7.js → LeaderboardPage-Bajkxf63.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardProfileModal-CXZJ0e-T.js → LeaderboardProfileModal-BGYKDgIi.js} +3 -3
  23. package/dashboard/dist/assets/{LeaderboardProfilePage-CZve6s6b.js → LeaderboardProfilePage-Ck5vPaeN.js} +1 -1
  24. package/dashboard/dist/assets/{LimitsPage-CWvoJPk8.js → LimitsPage-Bqobtsw2.js} +1 -1
  25. package/dashboard/dist/assets/{LocalOnlyNotice-DyUAAyyE.js → LocalOnlyNotice-e9KSE3wj.js} +1 -1
  26. package/dashboard/dist/assets/{LoginCard-eEiRfbdv.js → LoginCard-BhTSm2tb.js} +1 -1
  27. package/dashboard/dist/assets/{LoginModal-C16WdIQm.js → LoginModal-CRwuThde.js} +1 -1
  28. package/dashboard/dist/assets/{LoginPage-B2WqpE1t.js → LoginPage-j1i74lcc.js} +1 -1
  29. package/dashboard/dist/assets/{PetPage-DtQWrGmI.js → PetPage-r62ViU1X.js} +1 -1
  30. package/dashboard/dist/assets/{PopoverPopup-CRhqtFLB.js → PopoverPopup-DopNTyD4.js} +1 -1
  31. package/dashboard/dist/assets/{ProviderIcon-DWNH_wYu.js → ProviderIcon-c1LkPhYM.js} +1 -1
  32. package/dashboard/dist/assets/{ResetPasswordPage-DzCczcWZ.js → ResetPasswordPage-DJdDNvtq.js} +1 -1
  33. package/dashboard/dist/assets/{SegmentedControl-CV9ccq6R.js → SegmentedControl-DhWVOTPN.js} +1 -1
  34. package/dashboard/dist/assets/{Select-BrogNNk0.js → Select-DeUmTmsJ.js} +1 -1
  35. package/dashboard/dist/assets/{SelectItemText-Bpu3swt0.js → SelectItemText-pX0B5f7h.js} +1 -1
  36. package/dashboard/dist/assets/{ServiceStatusPage-D_FIrMJP.js → ServiceStatusPage-ejIPz6oF.js} +1 -1
  37. package/dashboard/dist/assets/{SessionsPage-C8TvTy1o.js → SessionsPage-WEbn90GW.js} +2 -2
  38. package/dashboard/dist/assets/{SettingsPage-Bd-tzJAR.js → SettingsPage-B8t5gCeI.js} +1 -1
  39. package/dashboard/dist/assets/{SkillsPage-CKGU9wQ4.js → SkillsPage-BWMlzeAp.js} +1 -1
  40. package/dashboard/dist/assets/{TokenGalaxy-DhHW6L-U.js → TokenGalaxy-DIBRBvMj.js} +1 -1
  41. package/dashboard/dist/assets/{ToolbarRootContext-DQo-4pPB.js → ToolbarRootContext-C6KxLvnI.js} +1 -1
  42. package/dashboard/dist/assets/{TrendMonitor-Ejl8d45g.js → TrendMonitor-BaMNh67w.js} +1 -1
  43. package/dashboard/dist/assets/{WidgetsPage-CY7f4wCD.js → WidgetsPage-DDsy4M3x.js} +1 -1
  44. package/dashboard/dist/assets/{WrappedPage-B4-B2v-v.js → WrappedPage-CiNnUFRI.js} +1 -1
  45. package/dashboard/dist/assets/{__vite-browser-external-BNUFXCfp.js → __vite-browser-external-CM-H8-LW.js} +23 -16
  46. package/dashboard/dist/assets/{agent-logos-BRzutmeR.js → agent-logos-BjVa6oe7.js} +1 -1
  47. package/dashboard/dist/assets/{arrow-up-right-BcrbPk8c.js → arrow-up-right-CCum1mqX.js} +1 -1
  48. package/dashboard/dist/assets/{check-BMxeFPfQ.js → check-ySRQc1YP.js} +1 -1
  49. package/dashboard/dist/assets/{chevron-right-DOstgiRf.js → chevron-right-5vFloHew.js} +1 -1
  50. package/dashboard/dist/assets/{copy-BsKO524t.js → copy-mhfKVMKo.js} +1 -1
  51. package/dashboard/dist/assets/{download-dadqxej7.js → download-GdrXglSx.js} +1 -1
  52. package/dashboard/dist/assets/{external-link-CTu3BMeU.js → external-link-KGFaKc8s.js} +1 -1
  53. package/dashboard/dist/assets/{flame-B_IvWcMn.js → flame-BmNtNoS1.js} +1 -1
  54. package/dashboard/dist/assets/{format-tokens-DIT6hLSb.js → format-tokens-CCpCNw3d.js} +1 -1
  55. package/dashboard/dist/assets/{icons-mWku76bu.js → icons-XLKZavTf.js} +1 -1
  56. package/dashboard/dist/assets/{index-D-gQV-hF.js → index-CsywTeCQ.js} +1 -1
  57. package/dashboard/dist/assets/{index-CboN9E82.js → index-IKpmeZte.js} +1 -1
  58. package/dashboard/dist/assets/{index-D5-w-aGy.js → index-_xU5U5Ct.js} +1 -1
  59. package/dashboard/dist/assets/{info-x6l352SW.js → info-DUV5UpmT.js} +1 -1
  60. package/dashboard/dist/assets/{limits-providers-Bo12VlLJ.js → limits-providers-DFkCZCZj.js} +1 -1
  61. package/dashboard/dist/assets/{link-2-eo_xPxH1.js → link-2-D7c64yLY.js} +1 -1
  62. package/dashboard/dist/assets/{loader-circle-C4n1iGBv.js → loader-circle-sMONRK2M.js} +1 -1
  63. package/dashboard/dist/assets/{maximize-2-pR8xwsI_.js → maximize-2-CVWRgrk9.js} +1 -1
  64. package/dashboard/dist/assets/{provider-display-ErggQWu4.js → provider-display-DSKqVjgm.js} +1 -1
  65. package/dashboard/dist/assets/{react-DYKg02Aq.js → react-c1q4mGkS.js} +1 -1
  66. package/dashboard/dist/assets/{search-B_jbUwPS.js → search-CwD3s-_C.js} +1 -1
  67. package/dashboard/dist/assets/{skills-api-DY5ruGmw.js → skills-api-BRXj8kjT.js} +1 -1
  68. package/dashboard/dist/assets/{store-Cu8i1y26.js → store-lqTW2gfK.js} +1 -1
  69. package/dashboard/dist/assets/{terminal-D3j6X68s.js → terminal-DU1_KbKo.js} +1 -1
  70. package/dashboard/dist/assets/{trash-2-CFpwgJw5.js → trash-2-B8Y5Xc-K.js} +1 -1
  71. package/dashboard/dist/assets/{use-community-stats-C0fKq66G.js → use-community-stats-DYwwimvp.js} +1 -1
  72. package/dashboard/dist/assets/{use-limits-display-prefs-TOp4UAnI.js → use-limits-display-prefs-Bst6ybK3.js} +1 -1
  73. package/dashboard/dist/assets/{use-native-settings-BZie_7z1.js → use-native-settings-Cqle4lgw.js} +1 -1
  74. package/dashboard/dist/assets/{use-ordered-list-Bq92YyUp.js → use-ordered-list-Dtze0ahb.js} +1 -1
  75. package/dashboard/dist/assets/use-reduced-motion-CbZDR54N.js +1 -0
  76. package/dashboard/dist/assets/{use-session-efficiency-pref-BcpytkES.js → use-session-efficiency-pref-gwbDMHXa.js} +1 -1
  77. package/dashboard/dist/assets/{use-transform-Dmmrne2z.js → use-transform-CU0yxXpX.js} +1 -1
  78. package/dashboard/dist/assets/{use-usage-limits-Cw0Z9Tew.js → use-usage-limits-5y1I1T5_.js} +1 -1
  79. package/dashboard/dist/assets/{useCurrency-DyyonINj.js → useCurrency-BfVNrywe.js} +1 -1
  80. package/dashboard/dist/assets/{useScrollLock-DvWw86lJ.js → useScrollLock-CF1m7AMJ.js} +1 -1
  81. package/dashboard/dist/assets/{useTokenFormat-CBkvGfJ1.js → useTokenFormat-DiRwVvRr.js} +1 -1
  82. package/dashboard/dist/assets/{zap-CnYA39Ug.js → zap-BZtlWtuN.js} +1 -1
  83. package/dashboard/dist/index.html +1 -1
  84. package/dashboard/dist/ip-check.html +1 -1
  85. package/dashboard/dist/leaderboard.html +1 -1
  86. package/dashboard/dist/share.html +1 -1
  87. package/package.json +1 -1
  88. package/src/commands/status.js +114 -9
  89. package/src/commands/sync.js +151 -46
  90. package/src/lib/claude-categorizer.js +36 -9
  91. package/src/lib/diagnostics.js +86 -13
  92. package/src/lib/doctor.js +17 -0
  93. package/src/lib/grok-context-breakdown.js +794 -0
  94. package/src/lib/install-resolver.js +10 -2
  95. package/src/lib/local-api.js +21 -3
  96. package/src/lib/multi-install-parser.js +9 -1
  97. package/src/lib/pricing/seed-snapshot.json +1 -1
  98. package/src/lib/rollout.js +337 -44
  99. package/src/lib/session-analytics.js +332 -9
  100. package/dashboard/dist/assets/DashboardPage-Dns_1yXL.js +0 -1
  101. package/dashboard/dist/assets/DialogDescription-Bhfqqmll.js +0 -1
  102. package/dashboard/dist/assets/use-reduced-motion-ZiVeOkvb.js +0 -1
@@ -714,6 +714,30 @@ function formatLocalDate(value) {
714
714
  return `${year}-${month}-${day}`;
715
715
  }
716
716
 
717
+ // Identity hash for cross-environment duplicate session files (native and
718
+ // \\wsl$ views of a synced ~/.claude). sha256 of the first 64KB plus the
719
+ // file size: Claude session headers carry sessionId/cwd so the prefix is
720
+ // discriminating, and folding in the size keeps a grown file distinct from
721
+ // a stale copy. Deliberately excludes inode (unstable across the 9p bridge)
722
+ // and mtime (rewritten by sync tools).
723
+ const CLAUDE_FILE_ID_PREFIX_BYTES = 64 * 1024;
724
+ async function claudeFileIdentityHash(filePath, st) {
725
+ const length = Math.min(CLAUDE_FILE_ID_PREFIX_BYTES, st.size);
726
+ if (length <= 0) return null;
727
+ let fh = null;
728
+ try {
729
+ fh = await fs.open(filePath, "r");
730
+ const buf = Buffer.alloc(length);
731
+ const { bytesRead } = await fh.read(buf, 0, length, 0);
732
+ const hash = crypto.createHash("sha256").update(buf.subarray(0, bytesRead)).digest("hex");
733
+ return `${hash}:${st.size}`;
734
+ } catch (_e) {
735
+ return null;
736
+ } finally {
737
+ if (fh) await fh.close().catch(() => { });
738
+ }
739
+ }
740
+
717
741
  async function parseClaudeIncremental({
718
742
  projectFiles,
719
743
  cursors,
@@ -747,6 +771,22 @@ async function parseClaudeIncremental({
747
771
  cursors.files = {};
748
772
  }
749
773
 
774
+ // Cross-environment file dedup (#307): when the scan list mixes native and
775
+ // \\wsl$ paths, a session file synced between the environments shows up
776
+ // twice under different paths. Identify never-parsed files by content hash
777
+ // and skip the duplicate wholesale — the message-hash layer alone is capped
778
+ // at 100k entries, and eviction would let bulk history double count.
779
+ let sawUncFile = false;
780
+ let sawLocalFile = false;
781
+ for (const entry of files) {
782
+ const p = typeof entry === "string" ? entry : entry?.path;
783
+ if (typeof p !== "string" || !p) continue;
784
+ if (isUncPath(p)) sawUncFile = true;
785
+ else sawLocalFile = true;
786
+ if (sawUncFile && sawLocalFile) break;
787
+ }
788
+ const seenFileIds = sawUncFile && sawLocalFile ? new Map() : null;
789
+
750
790
  for (let idx = 0; idx < files.length; idx++) {
751
791
  const entry = files[idx];
752
792
  const filePath = typeof entry === "string" ? entry : entry?.path;
@@ -766,6 +806,59 @@ async function parseClaudeIncremental({
766
806
  const truncated = sameInode && prevOffset > st.size;
767
807
  const startOffset = sameInode && !truncated ? prevOffset : 0;
768
808
 
809
+ let fileId = null;
810
+ if (seenFileIds) {
811
+ const cachedFileId =
812
+ sameInode && typeof prev?.fileId === "string" && prev?.fileIdSize === st.size
813
+ ? prev.fileId
814
+ : null;
815
+ fileId = cachedFileId || (await claudeFileIdentityHash(filePath, st));
816
+ if (fileId) {
817
+ // Cache on the live cursor object so idle files don't re-hash on
818
+ // every sync (the idle short-circuit below never rewrites cursors).
819
+ if (prev && (prev.fileId !== fileId || prev.fileIdSize !== st.size)) {
820
+ prev.fileId = fileId;
821
+ prev.fileIdSize = st.size;
822
+ }
823
+ const primary = seenFileIds.get(fileId);
824
+ if (primary === undefined) {
825
+ seenFileIds.set(fileId, filePath);
826
+ } else if (!prev && primary !== filePath) {
827
+ // Never-parsed duplicate of a file already accounted this run (the
828
+ // native copy sorts first). Mark it caught-up so future syncs only
829
+ // look at genuinely new tail bytes — message hashes still guard the
830
+ // tail if the copies diverge later.
831
+ cursors.files[key] = {
832
+ inode,
833
+ offset: st.size,
834
+ updatedAt: new Date().toISOString(),
835
+ fileId,
836
+ fileIdSize: st.size,
837
+ duplicateOf: primary,
838
+ ...(projectEnabled
839
+ ? {
840
+ claudeCwd: null,
841
+ projectFileContext: buildProjectFileContext(null),
842
+ projectRef: null,
843
+ projectKey: null,
844
+ }
845
+ : {}),
846
+ };
847
+ if (cb) {
848
+ cb({
849
+ index: idx + 1,
850
+ total: totalFiles,
851
+ filePath,
852
+ filesProcessed,
853
+ eventsAggregated,
854
+ bucketsQueued: touchedBuckets.size,
855
+ });
856
+ }
857
+ continue;
858
+ }
859
+ }
860
+ }
861
+
769
862
  // Claude's launch cwd is fixed for a session file's lifetime, so once
770
863
  // resolved (found, or confirmed absent) from content it never needs
771
864
  // re-extracting. Cache it — and the resolved project's git-config
@@ -850,6 +943,7 @@ async function parseClaudeIncremental({
850
943
  inode,
851
944
  offset: result.endOffset,
852
945
  updatedAt: new Date().toISOString(),
946
+ ...(fileId ? { fileId, fileIdSize: st.size } : {}),
853
947
  ...(projectEnabled
854
948
  ? {
855
949
  claudeCwd: nextCwd === undefined ? null : nextCwd,
@@ -2075,7 +2169,10 @@ async function parseClaudeFile({
2075
2169
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2076
2170
 
2077
2171
  let eventsAggregated = 0;
2078
- const isMainSession = !filePath.includes("/subagents/");
2172
+ // Separator-agnostic: WSL installs are scanned over \\wsl$ UNC paths where
2173
+ // path.join produces backslashes, and a forward-slash-only check would
2174
+ // misclassify subagent transcripts as main sessions (#307).
2175
+ const isMainSession = !/[\\/]subagents[\\/]/.test(filePath);
2079
2176
  for await (const line of rl) {
2080
2177
  if (!line) continue;
2081
2178
 
@@ -2095,13 +2192,25 @@ async function parseClaudeFile({
2095
2192
  typeof content === "string" ||
2096
2193
  (Array.isArray(content) && content.some((b) => b?.type === "text"));
2097
2194
  if (hasText) {
2098
- const userTs = typeof userObj?.timestamp === "string" ? userObj.timestamp : null;
2099
- const userBucketStart = userTs ? toUtcHalfHourStart(userTs) : null;
2100
- if (userBucketStart) {
2101
- const userModel = DEFAULT_MODEL;
2102
- const userBucket = getHourlyBucket(hourlyState, source, userModel, userBucketStart);
2103
- userBucket.totals.conversation_count += 1;
2104
- touchedBuckets.add(bucketKey(source, userModel, userBucketStart));
2195
+ // Dedup by the line's uuid so a synced copy of the session file
2196
+ // (WSL mirror of a divergent native file, inode-reset re-read)
2197
+ // cannot re-count the conversation. Usage rows get the same
2198
+ // guarantee from claudeMessageDedupKey; user lines have no
2199
+ // message.id, so the line uuid is the identity.
2200
+ const userKey =
2201
+ seenMessageHashes && typeof userObj?.uuid === "string" && userObj.uuid
2202
+ ? `u:${userObj.uuid}`
2203
+ : null;
2204
+ if (!userKey || !seenMessageHashes.has(userKey)) {
2205
+ if (userKey) seenMessageHashes.add(userKey);
2206
+ const userTs = typeof userObj?.timestamp === "string" ? userObj.timestamp : null;
2207
+ const userBucketStart = userTs ? toUtcHalfHourStart(userTs) : null;
2208
+ if (userBucketStart) {
2209
+ const userModel = DEFAULT_MODEL;
2210
+ const userBucket = getHourlyBucket(hourlyState, source, userModel, userBucketStart);
2211
+ userBucket.totals.conversation_count += 1;
2212
+ touchedBuckets.add(bucketKey(source, userModel, userBucketStart));
2213
+ }
2105
2214
  }
2106
2215
  }
2107
2216
  }
@@ -4554,37 +4663,60 @@ async function parseCursorApiIncremental({
4554
4663
  // Kiro token tracking (reads from devdata.sqlite or tokens_generated.jsonl)
4555
4664
  // ---------------------------------------------------------------------------
4556
4665
 
4557
- function resolveKiroBasePath() {
4666
+ // Kiro IDE (VS Code fork) globalStorage lives under the editor config root,
4667
+ // which differs per platform: %APPDATA% on Windows, ~/.config on Linux/WSL,
4668
+ // ~/Library/Application Support on macOS.
4669
+ function resolveKiroBasePath(env = process.env) {
4558
4670
  const home = require("node:os").homedir();
4559
- return path.join(
4560
- home,
4561
- "Library",
4562
- "Application Support",
4563
- "Kiro",
4564
- "User",
4565
- "globalStorage",
4566
- "kiro.kiroagent",
4567
- );
4671
+ const suffix = ["Kiro", "User", "globalStorage", "kiro.kiroagent"];
4672
+ if (process.platform === "win32") {
4673
+ const appData = typeof env.APPDATA === "string" && env.APPDATA.trim().length > 0
4674
+ ? env.APPDATA.trim()
4675
+ : path.join(home, "AppData", "Roaming");
4676
+ return path.join(appData, ...suffix);
4677
+ }
4678
+ if (process.platform === "linux") {
4679
+ const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim().length > 0
4680
+ ? env.XDG_CONFIG_HOME.trim()
4681
+ : path.join(home, ".config");
4682
+ return path.join(configHome, ...suffix);
4683
+ }
4684
+ return path.join(home, "Library", "Application Support", ...suffix);
4568
4685
  }
4569
4686
 
4570
- function resolveKiroDbPath() {
4571
- return path.join(resolveKiroBasePath(), "dev_data", "devdata.sqlite");
4687
+ function resolveKiroDbPath(basePath) {
4688
+ return path.join(basePath || resolveKiroBasePath(), "dev_data", "devdata.sqlite");
4572
4689
  }
4573
4690
 
4574
- function resolveKiroJsonlPath() {
4575
- return path.join(resolveKiroBasePath(), "dev_data", "tokens_generated.jsonl");
4691
+ function resolveKiroJsonlPath(basePath) {
4692
+ return path.join(basePath || resolveKiroBasePath(), "dev_data", "tokens_generated.jsonl");
4576
4693
  }
4577
4694
 
4578
4695
  function readKiroDbTokens(dbPath, sinceId, sqliteOptions = {}) {
4579
4696
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
4580
4697
  const minId = Number.isFinite(sinceId) && sinceId > 0 ? sinceId : 0;
4581
4698
  const sql = `SELECT id, model, provider, tokens_prompt, tokens_generated, timestamp FROM tokens_generated WHERE id > ${minId} ORDER BY id ASC`;
4582
- return readSqliteJsonRows(dbPath, sql, {
4583
- label: "Kiro",
4584
- maxBuffer: 10 * 1024 * 1024,
4585
- timeout: 15_000,
4586
- ...sqliteOptions,
4587
- });
4699
+
4700
+ // WSL installs are read over the \\wsl$ UNC bridge; sqlite3 cannot safely
4701
+ // open WAL databases there, so copy db (+ sidecars) to tmp first.
4702
+ let snapshot = null;
4703
+ let effectiveDbPath = dbPath;
4704
+ if (isUncPath(dbPath)) {
4705
+ try {
4706
+ snapshot = snapshotSqliteDb(dbPath);
4707
+ effectiveDbPath = snapshot.path;
4708
+ } catch (_e) { }
4709
+ }
4710
+ try {
4711
+ return readSqliteJsonRows(effectiveDbPath, sql, {
4712
+ label: "Kiro",
4713
+ maxBuffer: 10 * 1024 * 1024,
4714
+ timeout: 15_000,
4715
+ ...sqliteOptions,
4716
+ });
4717
+ } finally {
4718
+ if (snapshot) snapshot.cleanup();
4719
+ }
4588
4720
  }
4589
4721
 
4590
4722
  // Read Kiro token data from JSONL fallback (tokens_generated.jsonl).
@@ -4726,7 +4858,7 @@ function normalizeKiroModelName(raw) {
4726
4858
  return name || null;
4727
4859
  }
4728
4860
 
4729
- async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onProgress, sqliteOptions } = {}) {
4861
+ async function parseKiroIncremental({ basePath, dbPath, jsonlPath, cursors, queuePath, onProgress, sqliteOptions } = {}) {
4730
4862
  await ensureDir(path.dirname(queuePath));
4731
4863
  const kiroState = cursors.kiro && typeof cursors.kiro === "object" ? cursors.kiro : {};
4732
4864
  const lastDbId = typeof kiroState.lastDbId === "number"
@@ -4735,8 +4867,8 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
4735
4867
  const jsonlState = kiroState.jsonl && typeof kiroState.jsonl === "object" ? kiroState.jsonl : {};
4736
4868
  const lastJsonlLine = typeof jsonlState.lastLine === "number" ? jsonlState.lastLine : 0;
4737
4869
 
4738
- const resolvedDbPath = dbPath || resolveKiroDbPath();
4739
- const resolvedJsonlPath = jsonlPath || resolveKiroJsonlPath();
4870
+ const resolvedDbPath = dbPath || resolveKiroDbPath(basePath);
4871
+ const resolvedJsonlPath = jsonlPath || resolveKiroJsonlPath(basePath);
4740
4872
 
4741
4873
  // Try SQLite first, fall back to JSONL.
4742
4874
  let rows = [];
@@ -4784,9 +4916,12 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
4784
4916
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
4785
4917
  }
4786
4918
 
4787
- // Build model timeline from .chat files for model name resolution
4788
- const basePath = resolveKiroBasePath();
4789
- const modelTimeline = buildKiroModelTimeline(basePath);
4919
+ // Build model timeline from .chat files for model name resolution.
4920
+ // Must come from the SAME install as the rows being parsed (dual-install:
4921
+ // a WSL install's rows must not be resolved against native .chat files).
4922
+ const timelineBase = basePath
4923
+ || (dbPath ? path.dirname(path.dirname(dbPath)) : resolveKiroBasePath());
4924
+ const modelTimeline = buildKiroModelTimeline(timelineBase);
4790
4925
 
4791
4926
  const hourlyState = normalizeHourlyState(cursors?.hourly);
4792
4927
  const touchedBuckets = new Set();
@@ -5075,6 +5210,74 @@ function hermesInstallOwnsCursor(hermesPath, flatState, sqliteOptions) {
5075
5210
  return allDbs.some((db) => sqliteDbContainsIds(db, "sessions", sample, sqliteOptions));
5076
5211
  }
5077
5212
 
5213
+ function kiroInstallOwnsCursor(dbPath, flatState, sqliteOptions) {
5214
+ // The flat cursor tracks the max AUTOINCREMENT id it consumed. An install
5215
+ // whose tokens_generated table never reached that id cannot be the flat
5216
+ // cursor's host. Both installs containing the id (short/equal histories)
5217
+ // yields two hits upstream → caller seeds every namespace (safe).
5218
+ const lastDbId = typeof flatState?.lastDbId === "number" && Number.isInteger(flatState.lastDbId) && flatState.lastDbId > 0
5219
+ ? flatState.lastDbId
5220
+ : (typeof flatState?.lastId === "number" && Number.isInteger(flatState.lastId) && flatState.lastId > 0
5221
+ ? flatState.lastId
5222
+ : 0);
5223
+ if (!lastDbId) return false;
5224
+ return sqliteDbContainsIds(dbPath, "tokens_generated", [String(lastDbId)], sqliteOptions);
5225
+ }
5226
+
5227
+ // Kiro CLI request_ids live inside the conversations_v2 JSON payload, not in
5228
+ // an id column — probe via json_each instead of sqliteDbContainsIds.
5229
+ function kiroCliDbContainsRequestIds(dbPath, requestIds, sqliteOptions = {}) {
5230
+ if (!dbPath || !Array.isArray(requestIds) || requestIds.length === 0) return false;
5231
+ if (!fssync.existsSync(dbPath)) return false;
5232
+ const inList = requestIds.map(sqliteStringLiteral).join(",");
5233
+ // Alias both tables: conversations_v2.value and json_each's own `value`
5234
+ // column collide — a bare `value` is "ambiguous column name" and sqlite
5235
+ // errors out (which readSqliteJsonRows swallows into []).
5236
+ const sql =
5237
+ "SELECT 1 FROM conversations_v2 AS c, " +
5238
+ "json_each(json_extract(c.value, '$.user_turn_metadata.requests')) AS je " +
5239
+ `WHERE json_extract(je.value, '$.request_id') IN (${inList}) LIMIT 1`;
5240
+
5241
+ let snapshot = null;
5242
+ let effectiveDbPath = dbPath;
5243
+ if (isUncPath(dbPath)) {
5244
+ try {
5245
+ snapshot = snapshotSqliteDb(dbPath);
5246
+ effectiveDbPath = snapshot.path;
5247
+ } catch (_e) { }
5248
+ }
5249
+ try {
5250
+ const rows = readSqliteJsonRows(effectiveDbPath, sql, {
5251
+ label: "InstallProbe",
5252
+ maxBuffer: 1024 * 1024,
5253
+ timeout: 10_000,
5254
+ ...sqliteOptions,
5255
+ });
5256
+ return Array.isArray(rows) && rows.length > 0;
5257
+ } catch (_e) {
5258
+ return false;
5259
+ } finally {
5260
+ if (snapshot) snapshot.cleanup();
5261
+ }
5262
+ }
5263
+
5264
+ function kiroCliInstallOwnsCursor(dbPath, flatState, sqliteOptions) {
5265
+ // Only SQLite-origin request_id UUIDs are probe evidence: session-file
5266
+ // entries synthesize `${sessionId}:${loopRand}` keys or carry a session_id
5267
+ // tag, and neither appears in conversations_v2. Filter BEFORE sampling so
5268
+ // recent session-file churn cannot blind the probe to older SQLite ids.
5269
+ const requests =
5270
+ flatState?.requests && typeof flatState.requests === "object" ? flatState.requests : {};
5271
+ const sqliteKeyed = {};
5272
+ for (const [k, v] of Object.entries(requests)) {
5273
+ if (k.includes(":")) continue;
5274
+ if (v && typeof v === "object" && typeof v.session_id === "string" && v.session_id) continue;
5275
+ sqliteKeyed[k] = v;
5276
+ }
5277
+ const sample = sampleRecentKeys(sqliteKeyed);
5278
+ return kiroCliDbContainsRequestIds(dbPath, sample, sqliteOptions);
5279
+ }
5280
+
5078
5281
  function hasLegacyHermesDefaultState(hermesState) {
5079
5282
  return (
5080
5283
  typeof hermesState.lastStartedAt === "number" ||
@@ -5307,6 +5510,22 @@ const KIRO_CLI_CREDITS_SIDECAR = "kiro-credits.json";
5307
5510
  function resolveKiroCliDbPath(env = process.env) {
5308
5511
  if (env.KIRO_CLI_DB_PATH) return env.KIRO_CLI_DB_PATH;
5309
5512
  const home = env.HOME || require("node:os").homedir();
5513
+ if (process.platform === "win32") {
5514
+ // Speculative: Kiro CLI descends from Amazon Q CLI, which ships
5515
+ // macOS/Linux only — Windows users run it inside WSL (handled by the
5516
+ // dual-install path in sync). Kept as the conventional %LOCALAPPDATA%
5517
+ // location so a future native build is picked up; harmless when absent.
5518
+ const localAppData = typeof env.LOCALAPPDATA === "string" && env.LOCALAPPDATA.trim().length > 0
5519
+ ? env.LOCALAPPDATA.trim()
5520
+ : path.join(home, "AppData", "Local");
5521
+ return path.join(localAppData, "kiro-cli", "data.sqlite3");
5522
+ }
5523
+ if (process.platform === "linux") {
5524
+ const dataHome = typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.trim().length > 0
5525
+ ? env.XDG_DATA_HOME.trim()
5526
+ : path.join(home, ".local", "share");
5527
+ return path.join(dataHome, "kiro-cli", "data.sqlite3");
5528
+ }
5310
5529
  return path.join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3");
5311
5530
  }
5312
5531
 
@@ -5870,6 +6089,7 @@ async function readKiroCliV2SessionTurns(messagesPath) {
5870
6089
 
5871
6090
  async function writeKiroCliCreditsSidecar({
5872
6091
  queuePath,
6092
+ installKey,
5873
6093
  totalCredits,
5874
6094
  recordCount,
5875
6095
  sessionCount,
@@ -5882,15 +6102,64 @@ async function writeKiroCliCreditsSidecar({
5882
6102
  KIRO_CLI_CREDITS_SIDECAR,
5883
6103
  );
5884
6104
  try {
5885
- await writeJson(sidecarPath, {
5886
- version: 1,
5887
- source: "kiro-cli-usage-summary",
6105
+ // Dual-install (#306): keep one entry per install and aggregate at the
6106
+ // top level, so a second install's write never clobbers the first. The
6107
+ // top-level fields keep the v1 shape readKiroCreditsSummary consumes.
6108
+ let installs = {};
6109
+ try {
6110
+ const prev = JSON.parse(await fs.readFile(sidecarPath, "utf8"));
6111
+ if (prev?.version === 1 && prev.installs && typeof prev.installs === "object") {
6112
+ installs = prev.installs;
6113
+ }
6114
+ } catch {
6115
+ // first write or pre-dual-install sidecar — start fresh
6116
+ }
6117
+ // Expire entries whose install stopped syncing (deleted WSL distro,
6118
+ // distro rename / UNC alias flip leaving a duplicate key, mode switched
6119
+ // to *-only). Without this the read-modify-write merge would inflate the
6120
+ // aggregate with phantom installs forever; a 30-day grace keeps entries
6121
+ // alive across transient wsl.exe probe failures.
6122
+ const staleMs = 30 * 24 * 60 * 60 * 1000;
6123
+ for (const [k, entry] of Object.entries(installs)) {
6124
+ const ts = Date.parse(entry?.updated_at || "");
6125
+ if (!Number.isFinite(ts) || Date.now() - ts > staleMs) delete installs[k];
6126
+ }
6127
+ const key = typeof installKey === "string" && installKey ? installKey : "default";
6128
+ installs[key] = {
5888
6129
  total_credits: Number(totalCredits.toFixed(12)),
5889
6130
  record_count: recordCount,
5890
6131
  session_count: sessionCount,
5891
6132
  file_count: fileCount,
5892
6133
  latest_at: latestAt,
5893
6134
  updated_at: new Date().toISOString(),
6135
+ };
6136
+ let aggCredits = 0;
6137
+ let aggRecords = 0;
6138
+ let aggSessions = 0;
6139
+ let aggFiles = 0;
6140
+ let aggLatestAt = null;
6141
+ for (const entry of Object.values(installs)) {
6142
+ aggCredits += Number(entry.total_credits) || 0;
6143
+ aggRecords += Number(entry.record_count) || 0;
6144
+ aggSessions += Number(entry.session_count) || 0;
6145
+ aggFiles += Number(entry.file_count) || 0;
6146
+ if (
6147
+ typeof entry.latest_at === "string" &&
6148
+ (!aggLatestAt || Date.parse(entry.latest_at) > Date.parse(aggLatestAt))
6149
+ ) {
6150
+ aggLatestAt = entry.latest_at;
6151
+ }
6152
+ }
6153
+ await writeJson(sidecarPath, {
6154
+ version: 1,
6155
+ source: "kiro-cli-usage-summary",
6156
+ total_credits: Number(aggCredits.toFixed(12)),
6157
+ record_count: aggRecords,
6158
+ session_count: aggSessions,
6159
+ file_count: aggFiles,
6160
+ latest_at: aggLatestAt,
6161
+ updated_at: new Date().toISOString(),
6162
+ installs,
5894
6163
  });
5895
6164
  await chmod600IfPossible(sidecarPath);
5896
6165
  } catch {
@@ -5953,13 +6222,29 @@ function readKiroCliRequests(dbPath, env = process.env, sqliteOptions = {}) {
5953
6222
  "json_extract(value, '$.user_turn_metadata.requests') AS requests_json " +
5954
6223
  "FROM conversations_v2 " +
5955
6224
  "WHERE json_extract(value, '$.user_turn_metadata.requests') IS NOT NULL";
5956
- const rows = readSqliteJsonRows(dbPath, sql, {
5957
- label: "Kiro CLI",
5958
- env,
5959
- maxBuffer: 128 * 1024 * 1024,
5960
- timeout: 120_000,
5961
- ...sqliteOptions,
5962
- });
6225
+
6226
+ // WSL installs are read over the \\wsl$ UNC bridge; snapshot to tmp so
6227
+ // sqlite3 never opens a WAL database across the 9p bridge.
6228
+ let snapshot = null;
6229
+ let effectiveDbPath = dbPath;
6230
+ if (isUncPath(dbPath)) {
6231
+ try {
6232
+ snapshot = snapshotSqliteDb(dbPath);
6233
+ effectiveDbPath = snapshot.path;
6234
+ } catch (_e) { }
6235
+ }
6236
+ let rows;
6237
+ try {
6238
+ rows = readSqliteJsonRows(effectiveDbPath, sql, {
6239
+ label: "Kiro CLI",
6240
+ env,
6241
+ maxBuffer: 128 * 1024 * 1024,
6242
+ timeout: 120_000,
6243
+ ...sqliteOptions,
6244
+ });
6245
+ } finally {
6246
+ if (snapshot) snapshot.cleanup();
6247
+ }
5963
6248
  const flat = [];
5964
6249
  for (const row of rows) {
5965
6250
  let requests;
@@ -6058,6 +6343,11 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
6058
6343
  }
6059
6344
  await writeKiroCliCreditsSidecar({
6060
6345
  queuePath,
6346
+ // Install identity mirrors resolveKiroCliSessionFiles' sessions home so
6347
+ // dual installs keep separate sidecar entries.
6348
+ installKey:
6349
+ resolvedEnv.KIRO_HOME ||
6350
+ path.join(resolvedEnv.HOME || require("node:os").homedir(), ".kiro"),
6061
6351
  totalCredits: kiroCreditTotal,
6062
6352
  recordCount: kiroCreditRecords,
6063
6353
  sessionCount: kiroCreditSessions,
@@ -14371,6 +14661,7 @@ module.exports = {
14371
14661
  resolveQoderDbPath,
14372
14662
  resolveQoderDbPaths,
14373
14663
  readQoderDbMessages,
14664
+ resolveKiroBasePath,
14374
14665
  resolveKiroDbPath,
14375
14666
  resolveKiroJsonlPath,
14376
14667
  resolveHermesPath,
@@ -14404,6 +14695,8 @@ module.exports = {
14404
14695
  gooseInstallOwnsCursor,
14405
14696
  zedInstallOwnsCursor,
14406
14697
  hermesInstallOwnsCursor,
14698
+ kiroInstallOwnsCursor,
14699
+ kiroCliInstallOwnsCursor,
14407
14700
  copilotOtelCursorHasLegacyCliUsage,
14408
14701
  pruneCopilotUsageClaims,
14409
14702
  parseCopilotIncremental,