tokentracker-cli 1.1.4 → 1.1.5

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/README.md +15 -0
  2. package/dashboard/dist/assets/{AchievementBadge-BGA-HT_B.js → AchievementBadge-ClUmGxBJ.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-CtRh5AjH.js → AchievementsPage-BB0DFE3m.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-BarRcKaN.js → AchievementsSection-BMCSk63s.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-Bg0BDAN2.js → BadgeDetailModal-DzJtvuKO.js} +1 -1
  6. package/dashboard/dist/assets/{BotAnimated-By9J3-dn.js → BotAnimated-BviEasW4.js} +1 -1
  7. package/dashboard/dist/assets/{Card-CIoJYi2j.js → Card-BoHr-wLc.js} +1 -1
  8. package/dashboard/dist/assets/{ClawdAnimated-Dkvc2xe8.js → ClawdAnimated-Bwm8BfqY.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-BssDtHRl.js → CommandPalette-Rb0Gfcg9.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-CQrgLlDb.js → CommunityStatsModal-Ck7R7Q-d.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-C3hBPgYc.js → ConfirmModal-52Fu9wOi.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-BSW6a1HS.js → Controls-YlGr187A.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-Dhv7fejy.js → DashboardPage-BRim2Rt6.js} +1 -1
  14. package/dashboard/dist/assets/{DevicePage-D5eZn0wo.js → DevicePage-3mmhzfgd.js} +1 -1
  15. package/dashboard/dist/assets/{DialogClose-BltQpaJo.js → DialogClose-D1KfBYUS.js} +1 -1
  16. package/dashboard/dist/assets/DialogDescription-BhWwQSSg.js +1 -0
  17. package/dashboard/dist/assets/{DialogTitle-CVddcRPZ.js → DialogTitle-DKye2nJK.js} +1 -1
  18. package/dashboard/dist/assets/{FadeIn-goPv73MT.js → FadeIn-mJp_ZFst.js} +1 -1
  19. package/dashboard/dist/assets/{HeaderGithubStar-BDBrrkzt.js → HeaderGithubStar-B2R_aqp9.js} +1 -1
  20. package/dashboard/dist/assets/{HoverTooltip-qyMezXOs.js → HoverTooltip-ElY02914.js} +1 -1
  21. package/dashboard/dist/assets/{IpCheckPage-Ces8JIPb.js → IpCheckPage-CNzPUe3x.js} +1 -1
  22. package/dashboard/dist/assets/{LandingPage-B2TmLGno.js → LandingPage-L25-CxXg.js} +2 -2
  23. package/dashboard/dist/assets/{LeaderboardAvatar-CVVRoNua.js → LeaderboardAvatar-BT7x5wAP.js} +1 -1
  24. package/dashboard/dist/assets/{LeaderboardPage-xtt-bOY8.js → LeaderboardPage-bPce9gOU.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardProfileModal-BM4d_I_r.js → LeaderboardProfileModal-DPrFGAp6.js} +3 -3
  26. package/dashboard/dist/assets/{LeaderboardProfilePage-BWqhFLQX.js → LeaderboardProfilePage-j2D1pwYO.js} +1 -1
  27. package/dashboard/dist/assets/{LimitsPage-BZL4Pcl8.js → LimitsPage-CSED1kBx.js} +1 -1
  28. package/dashboard/dist/assets/{LocalOnlyNotice-D4gnOEJj.js → LocalOnlyNotice-D6OERWez.js} +1 -1
  29. package/dashboard/dist/assets/{LoginCard-CB17FQ5w.js → LoginCard-HgmRdIn8.js} +1 -1
  30. package/dashboard/dist/assets/{LoginModal-BI-O1rH4.js → LoginModal-BSlEmKOV.js} +1 -1
  31. package/dashboard/dist/assets/{LoginPage-DnGzsvJ4.js → LoginPage-HQAhowvh.js} +1 -1
  32. package/dashboard/dist/assets/{PetPage-NRnIKMaJ.js → PetPage-DnbYfu-z.js} +1 -1
  33. package/dashboard/dist/assets/{PopoverPopup-DHoK6UJZ.js → PopoverPopup-DnyVdfB8.js} +1 -1
  34. package/dashboard/dist/assets/{ProviderIcon-BTkdx9o2.js → ProviderIcon-D8kgkZlR.js} +1 -1
  35. package/dashboard/dist/assets/{ResetPasswordPage-C6K0w318.js → ResetPasswordPage-ChtXGykP.js} +1 -1
  36. package/dashboard/dist/assets/{SegmentedControl-gEoWioBE.js → SegmentedControl-dHUpXqFk.js} +1 -1
  37. package/dashboard/dist/assets/{Select-CI-qtEYu.js → Select-C0tpjNiq.js} +1 -1
  38. package/dashboard/dist/assets/{SelectItemText-q-yhzzjw.js → SelectItemText-C3G_w14k.js} +1 -1
  39. package/dashboard/dist/assets/{ServiceStatusPage-C4xD1CEe.js → ServiceStatusPage-D6x4e1r8.js} +1 -1
  40. package/dashboard/dist/assets/{SessionsPage-CS0Yun2z.js → SessionsPage-DZH1QMWd.js} +1 -1
  41. package/dashboard/dist/assets/{SettingsPage-GVY_ktz4.js → SettingsPage-DGmDb2R4.js} +2 -2
  42. package/dashboard/dist/assets/{SkillsPage-COPRL95O.js → SkillsPage-ChloCHPa.js} +1 -1
  43. package/dashboard/dist/assets/{TokenGalaxy-Czi0Ic48.js → TokenGalaxy-DOC9wlkv.js} +1 -1
  44. package/dashboard/dist/assets/{ToolbarRootContext-BZkTiBJB.js → ToolbarRootContext-BB_d-qdY.js} +1 -1
  45. package/dashboard/dist/assets/{TrendMonitor-CnIsuxV-.js → TrendMonitor-DVVzYdc7.js} +1 -1
  46. package/dashboard/dist/assets/{WidgetsPage-OdX_Fsul.js → WidgetsPage-DHVSyU0i.js} +1 -1
  47. package/dashboard/dist/assets/{WrappedPage-h9N63-F8.js → WrappedPage-BDi28nRt.js} +1 -1
  48. package/dashboard/dist/assets/{__vite-browser-external-BFNWaGsk.js → __vite-browser-external-Bnc5aBhq.js} +2 -2
  49. package/dashboard/dist/assets/{agent-logos-BDiPxrST.js → agent-logos-BiGvZiLq.js} +1 -1
  50. package/dashboard/dist/assets/{arrow-up-right-DtFQUeJG.js → arrow-up-right-SvD82rok.js} +1 -1
  51. package/dashboard/dist/assets/{check-C35onaNL.js → check-jL5XErEW.js} +1 -1
  52. package/dashboard/dist/assets/{composite-qxBzts0W.js → composite-DM6geXPA.js} +1 -1
  53. package/dashboard/dist/assets/{copy-D_D71TLc.js → copy-Dy_GIzj2.js} +1 -1
  54. package/dashboard/dist/assets/{download-DMQI2hQe.js → download-BW_FS7nX.js} +1 -1
  55. package/dashboard/dist/assets/{external-link-CemM4dWf.js → external-link-2RIovvCT.js} +1 -1
  56. package/dashboard/dist/assets/{flame-CIvk0SP5.js → flame-CIRJIxxA.js} +1 -1
  57. package/dashboard/dist/assets/{format-tokens-D3JSvcFl.js → format-tokens-CT8ahEK4.js} +1 -1
  58. package/dashboard/dist/assets/{icons-Ccf5W0AL.js → icons-7J1WD0C-.js} +1 -1
  59. package/dashboard/dist/assets/{index-C8Wri_HI.js → index-D3iSSyYr.js} +1 -1
  60. package/dashboard/dist/assets/{index-CFXmD_KX.js → index-Ddb2A6Mx.js} +1 -1
  61. package/dashboard/dist/assets/{index-Brd2evyz.js → index-_vJl6sF7.js} +1 -1
  62. package/dashboard/dist/assets/{info-CafrKEG8.js → info-BjVWcOOC.js} +1 -1
  63. package/dashboard/dist/assets/{link-2-Ds-9Nzlp.js → link-2-CyRWnHWE.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-DA4I55-V.js → loader-circle-DUpG5zah.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-CjMPt5kU.js → maximize-2-BVGCRkFZ.js} +1 -1
  66. package/dashboard/dist/assets/{modifiers.esm-DIxTS09h.js → modifiers.esm-CZI7rED4.js} +1 -1
  67. package/dashboard/dist/assets/{monitor-smartphone-IZpIOxn3.js → monitor-smartphone-DfBtd01T.js} +1 -1
  68. package/dashboard/dist/assets/{plus-vbLFNs70.js → plus-CvbLCvjV.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-BAu_UIfV.js → provider-display-DC46GLrv.js} +1 -1
  70. package/dashboard/dist/assets/{react-Djb4VGKb.js → react-mYr3J8Vw.js} +1 -1
  71. package/dashboard/dist/assets/{search-Ct6LY9Ke.js → search-CFaS1rSj.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-BnGI2gEp.js → skills-api-B1N6seFa.js} +1 -1
  73. package/dashboard/dist/assets/{sortable.esm-CjCGd6g5.js → sortable.esm-BE2GiQaC.js} +1 -1
  74. package/dashboard/dist/assets/{store-CBxkOqRe.js → store-DwBbExah.js} +1 -1
  75. package/dashboard/dist/assets/{terminal-Dlpdp5Az.js → terminal-Bl2BuO9x.js} +1 -1
  76. package/dashboard/dist/assets/{trash-pxUSKv66.js → trash-D8beb27Y.js} +1 -1
  77. package/dashboard/dist/assets/{use-community-stats-tHaixRIw.js → use-community-stats-DJG1DsXR.js} +1 -1
  78. package/dashboard/dist/assets/{use-latest-request-guard-Cs44WPfL.js → use-latest-request-guard-DYK8iyOM.js} +1 -1
  79. package/dashboard/dist/assets/{use-limits-display-prefs-Dw56PXWy.js → use-limits-display-prefs-BGmcFVX3.js} +1 -1
  80. package/dashboard/dist/assets/{use-ordered-list-DpLx8oLV.js → use-ordered-list-Cqia_VIq.js} +1 -1
  81. package/dashboard/dist/assets/use-reduced-motion-CmCk9Sb5.js +1 -0
  82. package/dashboard/dist/assets/{use-session-efficiency-pref-BN3FtBvr.js → use-session-efficiency-pref-Mj3OeVGZ.js} +1 -1
  83. package/dashboard/dist/assets/{use-transform-C4nTGgzb.js → use-transform-D4BQIBHx.js} +1 -1
  84. package/dashboard/dist/assets/{use-usage-limits-Dmwdc1yo.js → use-usage-limits-BFrO7Mtq.js} +1 -1
  85. package/dashboard/dist/assets/{useCurrency-DSc3A_Ih.js → useCurrency-CYJavR2h.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-BHjcMgm8.js → useTokenFormat-CKruI7SP.js} +1 -1
  87. package/dashboard/dist/assets/{zap-BE8i_wu2.js → zap-a82fG76c.js} +1 -1
  88. package/dashboard/dist/index.html +1 -1
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/share.html +1 -1
  92. package/package.json +1 -1
  93. package/src/commands/status.js +57 -2
  94. package/src/commands/sync.js +90 -11
  95. package/src/lib/codex-context-breakdown.js +3 -1
  96. package/src/lib/diagnostics.js +20 -1
  97. package/src/lib/doctor.js +30 -0
  98. package/src/lib/pricing/seed-snapshot.json +1 -1
  99. package/src/lib/scan-roots.js +277 -0
  100. package/src/lib/session-analytics.js +98 -16
  101. package/dashboard/dist/assets/DialogDescription-Cof52Icj.js +0 -1
  102. package/dashboard/dist/assets/use-reduced-motion-DrlJ9X6p.js +0 -1
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+
3
+ // Persistent extra scan roots for Codex and Claude Code (#657).
4
+ //
5
+ // Agent harnesses run Codex / Claude Code with a private home
6
+ // (CODEX_HOME=..., CLAUDE_CONFIG_DIR=...). Those sessions were invisible to
7
+ // TokenTracker unless the sync happened to be spawned by a process that
8
+ // inherited the same environment: a hook-fired sync saw them, the native app's
9
+ // background refresh did not. Worse, the two producers then disagreed about
10
+ // where a rollout's cursor lives (the cursor store classifies a rollout as a
11
+ // Codex path — per-day shard — only when it sits under one of ITS codexRoots,
12
+ // everything else goes to core), never saw each other's cursor, and re-parsed
13
+ // the same file from byte 0 on every alternation (#639: one 30-minute bucket
14
+ // re-emitted 138 times, 59B tokens for a 1.2B day).
15
+ //
16
+ // So roots come from ONE persistent list that every producer reads, and the
17
+ // cursor store's codexRoots are derived from that same list.
18
+ //
19
+ // Per provider, in order (first occurrence wins; later duplicates by realpath
20
+ // are dropped):
21
+ // 1. the roots the caller already scans (native home, WSL install)
22
+ // 2. the spawning process's implicit root — CODEX_HOME / CLAUDE_CONFIG_DIR
23
+ // 3. config.scanRoots.<provider>[] from ~/.tokentracker/tracker/config.json
24
+ //
25
+ // Config shape:
26
+ // { "scanRoots": { "codex": ["~/.local/share/agent/codex"],
27
+ // "claude": ["~/.local/share/agent/claude", "~/.claude-native"] } }
28
+
29
+ const fs = require("node:fs");
30
+ const os = require("node:os");
31
+ const path = require("node:path");
32
+
33
+ const { readJson } = require("./fs");
34
+ const { resolveTrackerPaths } = require("./tracker-paths");
35
+
36
+ const PROVIDERS = Object.freeze({
37
+ codex: Object.freeze({ envVar: "CODEX_HOME" }),
38
+ claude: Object.freeze({ envVar: "CLAUDE_CONFIG_DIR" }),
39
+ });
40
+ const PROVIDER_NAMES = Object.freeze(Object.keys(PROVIDERS));
41
+
42
+ // Turn a configured root into a stable absolute path. `~` expands against
43
+ // `home`, and a RELATIVE entry is resolved against `home` too — never against
44
+ // process.cwd(), which differs between a hook-fired sync, the CLI and the
45
+ // desktop app and would make producers scan different directories.
46
+ function expandHome(value, home) {
47
+ if (typeof value !== "string") return null;
48
+ const trimmed = value.trim();
49
+ if (!trimmed) return null;
50
+ if (trimmed === "~") return path.resolve(home);
51
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
52
+ return path.resolve(home, trimmed.slice(2));
53
+ }
54
+ return path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(home, trimmed);
55
+ }
56
+
57
+ // The spawning process's implicit root — CODEX_HOME / CLAUDE_CONFIG_DIR —
58
+ // normalized ONCE, the same way configured entries are: `~` and relative
59
+ // values are anchored to `home`, never to process.cwd(). Every producer
60
+ // (sync discovery, the cursor store's codexRoots, status, diagnostics, the
61
+ // session browser) must use this value, or a relative CODEX_HOME such as
62
+ // `profiles/codex` is scanned under one directory and cursor-classified
63
+ // under another, and the same rollout replays. Returns null when unset.
64
+ function resolveEnvRoot(provider, { env = process.env, home = os.homedir() } = {}) {
65
+ const spec = PROVIDERS[provider];
66
+ if (!spec) return null;
67
+ const raw = env && typeof env[spec.envVar] === "string" ? env[spec.envVar] : "";
68
+ return expandHome(raw, home);
69
+ }
70
+
71
+ // Accepts { codex: string | string[], claude: string | string[] }. Anything
72
+ // else — wrong type, empty strings, unknown providers — is ignored rather than
73
+ // failing the sync: a malformed config must not stop collection.
74
+ function normalizeScanRootsConfig(raw) {
75
+ const out = {};
76
+ for (const provider of PROVIDER_NAMES) out[provider] = [];
77
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
78
+ for (const provider of PROVIDER_NAMES) {
79
+ const value = raw[provider];
80
+ const list = Array.isArray(value) ? value : (typeof value === "string" ? [value] : []);
81
+ for (const entry of list) {
82
+ if (typeof entry !== "string" || !entry.trim()) continue;
83
+ out[provider].push(entry.trim());
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // { exists, error }: `exists` is true only for a directory that can be
90
+ // LISTED, not merely stat'ed — a stat-able directory whose listing fails
91
+ // (mode 000, ACL) would otherwise be admitted to the scan, yield no files,
92
+ // read as "present" in status/doctor, and slip past the deferred-repair
93
+ // guard in sync. ENOENT and ENOTDIR (or a regular file in a directory's
94
+ // place) mean the root is absent (error null); any other failure (EACCES,
95
+ // ELOOP, EIO, ...) is reported as `error` so callers can say "unreadable"
96
+ // instead of "missing".
97
+ function scanRootDirState(target, deps = {}) {
98
+ const statSync = deps.statSync || fs.statSync;
99
+ const opendirSync = deps.opendirSync || fs.opendirSync;
100
+ try {
101
+ if (!statSync(target).isDirectory()) return { exists: false, error: null };
102
+ // Probe listability without reading entries; large roots stay cheap.
103
+ opendirSync(target).closeSync();
104
+ return { exists: true, error: null };
105
+ } catch (e) {
106
+ const code = e && typeof e.code === "string" ? e.code : "EUNKNOWN";
107
+ if (code === "ENOENT" || code === "ENOTDIR") return { exists: false, error: null };
108
+ return { exists: false, error: code };
109
+ }
110
+ }
111
+
112
+ function isDirectorySync(target, deps) {
113
+ return scanRootDirState(target, deps).exists;
114
+ }
115
+
116
+ function realpathOrSelf(target, deps) {
117
+ const realpathSync = deps.realpathSync || fs.realpathSync;
118
+ try {
119
+ return realpathSync(target);
120
+ } catch {
121
+ return target;
122
+ }
123
+ }
124
+
125
+ function identityKey(target, deps) {
126
+ const resolved = path.resolve(target);
127
+ const real = realpathOrSelf(resolved, deps);
128
+ return real.replace(/\\/g, "/").replace(/\/+$/, "");
129
+ }
130
+
131
+ // Drop later entries that resolve (realpath) to a directory already listed.
132
+ // Used at the projects/ level too: two Claude profiles may symlink one
133
+ // projects/ dir, and reading it under both spellings would double-parse every
134
+ // file, leaving correctness to the bounded claudeHashes layer.
135
+ function dedupeDirsByRealpath(dirs, deps = {}) {
136
+ const seen = new Set();
137
+ const out = [];
138
+ for (const dir of Array.isArray(dirs) ? dirs : []) {
139
+ if (typeof dir !== "string" || !dir) continue;
140
+ const key = identityKey(dir, deps);
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push(dir);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // Resolve the full, ordered, deduplicated root list per provider.
149
+ //
150
+ // base: { codex: string[], claude: string[] } — roots the caller scans
151
+ // anyway (native home, WSL install); they keep their position and
152
+ // are labelled origin "native".
153
+ // env: CODEX_HOME / CLAUDE_CONFIG_DIR become origin "env" unless already
154
+ // covered by base.
155
+ // config.scanRoots.<provider>[] become origin "config".
156
+ //
157
+ // Every entry: { path, realPath, exists, error, origin }. `exists` is a
158
+ // readable-directory check and `error` carries a stat error code other than
159
+ // absence (see scanRootDirState); non-existent or unreadable configured roots
160
+ // are kept in the list (so cursor-path classification stays stable while a
161
+ // volume is unmounted) and callers that walk directories skip them via
162
+ // `exists`.
163
+ function resolveScanRoots({
164
+ home = os.homedir(),
165
+ env = process.env,
166
+ config = null,
167
+ base = {},
168
+ deps = {},
169
+ } = {}) {
170
+ const configured = normalizeScanRootsConfig(config && typeof config === "object" ? config.scanRoots : null);
171
+ const out = {};
172
+ for (const provider of PROVIDER_NAMES) {
173
+ const candidates = [];
174
+ for (const root of Array.isArray(base[provider]) ? base[provider] : []) {
175
+ if (typeof root === "string" && root.trim()) candidates.push({ raw: root, origin: "native" });
176
+ }
177
+ const envRoot = resolveEnvRoot(provider, { env, home });
178
+ if (envRoot) candidates.push({ raw: envRoot, origin: "env" });
179
+ for (const root of configured[provider]) candidates.push({ raw: root, origin: "config" });
180
+
181
+ const seen = new Set();
182
+ const entries = [];
183
+ for (const candidate of candidates) {
184
+ const expanded = expandHome(candidate.raw, home);
185
+ if (!expanded) continue;
186
+ const resolved = expanded;
187
+ const key = identityKey(resolved, deps);
188
+ if (seen.has(key)) continue;
189
+ seen.add(key);
190
+ const state = scanRootDirState(resolved, deps);
191
+ entries.push({
192
+ path: resolved,
193
+ realPath: realpathOrSelf(resolved, deps),
194
+ exists: state.exists,
195
+ error: state.error,
196
+ origin: candidate.origin,
197
+ });
198
+ }
199
+ out[provider] = entries;
200
+ }
201
+ return out;
202
+ }
203
+
204
+ // Append `extraDirs` to `baseDirs`, dropping any extra that resolves (realpath)
205
+ // to a directory already present. Base entries are never collapsed against
206
+ // each other: the native + WSL pair is listed on purpose (see sync.js) and
207
+ // their dedup is handled downstream by file-identity hashes.
208
+ function appendUniqueDirs(baseDirs, extraDirs, deps = {}) {
209
+ const out = [];
210
+ const seen = new Set();
211
+ for (const dir of Array.isArray(baseDirs) ? baseDirs : []) {
212
+ if (typeof dir !== "string" || !dir) continue;
213
+ seen.add(identityKey(dir, deps));
214
+ out.push(dir);
215
+ }
216
+ for (const dir of Array.isArray(extraDirs) ? extraDirs : []) {
217
+ if (typeof dir !== "string" || !dir) continue;
218
+ const key = identityKey(dir, deps);
219
+ if (seen.has(key)) continue;
220
+ seen.add(key);
221
+ out.push(dir);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ // Roots beyond what the caller already scans: the "env" and "config" entries
227
+ // that exist on disk. Paths only, in list order.
228
+ function extraScanRootPaths(entries) {
229
+ return (Array.isArray(entries) ? entries : [])
230
+ .filter((entry) => entry && entry.origin !== "native" && entry.exists)
231
+ .map((entry) => entry.path);
232
+ }
233
+
234
+ function hasAnyScanChild(root, children, deps = {}) {
235
+ for (const child of Array.isArray(children) ? children : [children]) {
236
+ if (isDirectorySync(path.join(root, child), deps)) return true;
237
+ }
238
+ return false;
239
+ }
240
+
241
+ // Human label for status / doctor output.
242
+ function describeScanRootOrigin(entry, provider) {
243
+ if (!entry || entry.origin === "native") return "native";
244
+ if (entry.origin === "env") return PROVIDERS[provider]?.envVar || "env";
245
+ return "scanRoots";
246
+ }
247
+
248
+ // "" for a readable root, " (missing)" for an absent one, " (unreadable: CODE)"
249
+ // for a stat failure other than absence.
250
+ function describeScanRootState(entry) {
251
+ if (!entry || entry.exists) return "";
252
+ return entry.error ? ` (unreadable: ${entry.error})` : " (missing)";
253
+ }
254
+
255
+ // config.scanRoots as persisted in ~/.tokentracker/tracker/config.json for
256
+ // callers that do not already hold the config (the session browser).
257
+ async function loadScanRootsConfig({ home = os.homedir() } = {}) {
258
+ const { trackerDir } = await resolveTrackerPaths({ home });
259
+ const config = await readJson(path.join(trackerDir, "config.json")).catch(() => null);
260
+ return normalizeScanRootsConfig(config && typeof config === "object" ? config.scanRoots : null);
261
+ }
262
+
263
+ module.exports = {
264
+ PROVIDER_NAMES,
265
+ appendUniqueDirs,
266
+ dedupeDirsByRealpath,
267
+ describeScanRootOrigin,
268
+ describeScanRootState,
269
+ expandHome,
270
+ extraScanRootPaths,
271
+ hasAnyScanChild,
272
+ loadScanRootsConfig,
273
+ normalizeScanRootsConfig,
274
+ resolveEnvRoot,
275
+ resolveScanRoots,
276
+ scanRootDirState,
277
+ };
@@ -33,6 +33,14 @@ const { parseCodexRolloutFile } = require("./codex-rollout-parser");
33
33
  const { computeRowCost, getModelPricing } = require("./pricing");
34
34
  const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
35
35
  const wsl = require("./wsl-probe");
36
+ const {
37
+ appendUniqueDirs,
38
+ dedupeDirsByRealpath,
39
+ expandHome,
40
+ loadScanRootsConfig,
41
+ normalizeScanRootsConfig,
42
+ scanRootDirState,
43
+ } = require("./scan-roots");
36
44
 
37
45
  // Bump the sidecar when derived metrics change so cached rows are rebuilt
38
46
  // instead of leaving the dashboard on the previous (over-counted) heuristic.
@@ -1074,8 +1082,11 @@ function providerRoots(home, providerDir, env, deps = {}) {
1074
1082
  && path.resolve(home) === path.resolve(homedir())
1075
1083
  && typeof env?.CODEX_HOME === "string"
1076
1084
  && env.CODEX_HOME.trim();
1085
+ // Same normalization as sync / the cursor store (scan-roots.expandHome):
1086
+ // a relative CODEX_HOME is anchored to home, never to process.cwd(), so
1087
+ // the browser walks the exact root the cursors were keyed under.
1077
1088
  const nativeRoot = useProcessCodexHome
1078
- ? path.resolve(env.CODEX_HOME.trim())
1089
+ ? expandHome(env.CODEX_HOME, home)
1079
1090
  : path.join(home, providerDir);
1080
1091
  roots.push(nativeRoot);
1081
1092
  }
@@ -1095,7 +1106,21 @@ function providerRoots(home, providerDir, env, deps = {}) {
1095
1106
  const wslRoot = discoverWslHome(providerDir, { env });
1096
1107
  if (wslRoot) roots.push(wslRoot);
1097
1108
  }
1098
- return [...new Set(roots)];
1109
+ // Extra roots (#657), mirroring src/commands/sync.js: CLAUDE_CONFIG_DIR is
1110
+ // the spawning process's implicit Claude root (additive — ~/.claude stays
1111
+ // scanned), and config.scanRoots.<provider> arrives as deps.extraRoots from
1112
+ // discoverSessionFiles. Extras are realpath-deduped against the roots above
1113
+ // so one directory listed under two spellings is walked once.
1114
+ const extras = [];
1115
+ const useProcessClaudeConfigDir = providerDir === ".claude"
1116
+ && path.resolve(home) === path.resolve(homedir())
1117
+ && typeof env?.CLAUDE_CONFIG_DIR === "string"
1118
+ && env.CLAUDE_CONFIG_DIR.trim();
1119
+ if (useProcessClaudeConfigDir) extras.push(expandHome(env.CLAUDE_CONFIG_DIR, home));
1120
+ for (const extra of Array.isArray(deps.extraRoots) ? deps.extraRoots : []) {
1121
+ if (typeof extra === "string" && extra) extras.push(path.resolve(extra));
1122
+ }
1123
+ return appendUniqueDirs([...new Set(roots)], extras);
1099
1124
  }
1100
1125
 
1101
1126
  // Group one logical session discovered under more than one root. A group is
@@ -1221,10 +1246,37 @@ function groupCodexFiles(filePaths) {
1221
1246
 
1222
1247
  async function discoverSessionFiles(home, env = process.env, deps = {}) {
1223
1248
  const grokHome = resolveGrokHome(home);
1224
- const claudeRoots = providerRoots(home, ".claude", env, deps);
1225
- const codexRoots = providerRoots(home, ".codex", env, deps);
1249
+ // config.scanRoots (#657): read from the tracker config unless the caller
1250
+ // injects it (tests). A missing or malformed config yields no extras.
1251
+ const scanRootsConfig = deps.scanRootsConfig !== undefined
1252
+ ? normalizeScanRootsConfig(deps.scanRootsConfig)
1253
+ : await loadScanRootsConfig({ home });
1254
+ // expandHome resolves `~` and relative entries against `home` (never cwd);
1255
+ // only readable directories are walked, absence and stat errors alike.
1256
+ const extraRootsFor = (provider) => scanRootsConfig[provider]
1257
+ .map((root) => expandHome(root, home))
1258
+ .filter((root) => root && scanRootDirState(root).exists);
1259
+ const claudeRoots = providerRoots(home, ".claude", env, { ...deps, extraRoots: extraRootsFor("claude") });
1260
+ const codexRoots = providerRoots(home, ".codex", env, { ...deps, extraRoots: extraRootsFor("codex") });
1261
+ // Two distinct roots may share one projects/ dir through a symlink; dedupe
1262
+ // the derived projects dirs by realpath (as sync does) so the browser never
1263
+ // lists the same transcript twice under two spellings.
1264
+ const claudeProjectsDirs = dedupeDirsByRealpath(claudeRoots.map((r) => path.join(r, "projects")));
1265
+ // listClaudeProjectFiles / listRolloutFilesDeep turn a listing error into an
1266
+ // empty result, so a root whose projects/ (or sessions/) exists but cannot be
1267
+ // read would look like "no sessions" and get cached as such. Report those
1268
+ // directories as incomplete discovery instead; the caller decides not to
1269
+ // persist a partial inventory (sync defers its repair on the same state).
1270
+ const incomplete = [];
1271
+ for (const dir of [
1272
+ ...claudeProjectsDirs,
1273
+ ...codexRoots.map((r) => path.join(r, "sessions")),
1274
+ ]) {
1275
+ const state = scanRootDirState(dir);
1276
+ if (state.error !== null) incomplete.push({ path: dir, error: state.error });
1277
+ }
1226
1278
  const [claudeGroups, codexGroups, archivedGroups, grok] = await Promise.all([
1227
- Promise.all(claudeRoots.map((r) => listClaudeProjectFiles(path.join(r, "projects")))),
1279
+ Promise.all(claudeProjectsDirs.map((dir) => listClaudeProjectFiles(dir))),
1228
1280
  Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "sessions")))),
1229
1281
  Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "archived_sessions")))),
1230
1282
  listGrokSessionFiles(path.join(grokHome, "sessions")),
@@ -1239,7 +1291,20 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
1239
1291
  const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
1240
1292
  .split(path.sep)
1241
1293
  .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
1242
- return { claude, codex: groupCodexFiles([...codex, ...archived]), grok };
1294
+ return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, incomplete };
1295
+ }
1296
+
1297
+ // Warn once per unreadable directory per process: the dashboard polls this
1298
+ // path, and a line per poll would flood the serve log.
1299
+ const warnedIncompleteDiscovery = new Set();
1300
+ function warnIncompleteDiscovery(incomplete) {
1301
+ for (const entry of incomplete) {
1302
+ if (warnedIncompleteDiscovery.has(entry.path)) continue;
1303
+ warnedIncompleteDiscovery.add(entry.path);
1304
+ if (!process.env.NODE_TEST_CONTEXT) {
1305
+ console.warn(`[session-analytics] session directory unreadable (${entry.error}), inventory incomplete: ${entry.path}`);
1306
+ }
1307
+ }
1243
1308
  }
1244
1309
 
1245
1310
  function filesSignature(files) {
@@ -1368,6 +1433,19 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1368
1433
  } catch { /* first run */ }
1369
1434
  }
1370
1435
  const discovered = await discoverSessionFiles(home);
1436
+ // Incomplete discovery (an unreadable projects/ or sessions/ dir): never let
1437
+ // a partial inventory replace a complete snapshot. Serve the last complete
1438
+ // sidecar when there is one; otherwise build from what is readable but do
1439
+ // not persist it, so the next refresh tries again.
1440
+ const incompleteDirs = Array.isArray(discovered.incomplete) ? discovered.incomplete : [];
1441
+ if (incompleteDirs.length > 0) {
1442
+ warnIncompleteDiscovery(incompleteDirs);
1443
+ if (!force && previousMeta?.version === SIDECAR_VERSION) {
1444
+ const cached = readSidecar(sidecarPath);
1445
+ Object.defineProperty(cached, "incompleteDirs", { value: incompleteDirs, enumerable: false });
1446
+ return cached;
1447
+ }
1448
+ }
1371
1449
  // Codex thread titles are stored separately from rollout files. Include the
1372
1450
  // index in the overall signature so an index-only rename reaches the
1373
1451
  // per-file dependency check below on the next refresh. Grok titles/metadata
@@ -1437,19 +1515,22 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1437
1515
  nextFiles[cacheKey] = { stat_key: statKey };
1438
1516
  }
1439
1517
  sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
1440
- const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
1441
- await writeAtomic(sidecarPath, content);
1442
- const generatedAt = new Date().toISOString();
1443
- await writeAtomic(metaPath, `${JSON.stringify({
1444
- version: SIDECAR_VERSION,
1445
- signature,
1446
- generated_at: generatedAt,
1447
- checked_at: generatedAt,
1448
- files: nextFiles,
1449
- })}\n`);
1518
+ if (incompleteDirs.length === 0) {
1519
+ const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
1520
+ await writeAtomic(sidecarPath, content);
1521
+ const generatedAt = new Date().toISOString();
1522
+ await writeAtomic(metaPath, `${JSON.stringify({
1523
+ version: SIDECAR_VERSION,
1524
+ signature,
1525
+ generated_at: generatedAt,
1526
+ checked_at: generatedAt,
1527
+ files: nextFiles,
1528
+ })}\n`);
1529
+ }
1450
1530
  // Non-enumerable so the array still behaves exactly like a plain row list
1451
1531
  // for every existing caller (map/filter/JSON of the rows is unaffected).
1452
1532
  Object.defineProperty(sessions, "skippedFiles", { value: skippedFiles, enumerable: false });
1533
+ Object.defineProperty(sessions, "incompleteDirs", { value: incompleteDirs, enumerable: false });
1453
1534
  return sessions;
1454
1535
  }
1455
1536
 
@@ -2006,6 +2087,7 @@ module.exports = {
2006
2087
  resumeCommandFor,
2007
2088
  sessionsToCsv,
2008
2089
  providerRoots,
2090
+ discoverSessionFiles,
2009
2091
  dedupeClaudeFilesAcrossRoots,
2010
2092
  analyticsEntryStatKey,
2011
2093
  };
@@ -1 +0,0 @@
1
- import{r as n,v as a}from"./__vite-browser-external-BFNWaGsk.js";import{u as p}from"./DialogTitle-CVddcRPZ.js";import{u as c}from"./composite-qxBzts0W.js";const x=n.forwardRef(function(e,r){const{render:l,className:u,style:m,id:s,...t}=e,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-BFNWaGsk.js";import{h as r,i as t,p as s}from"./react-Djb4VGKb.js";function i(){!r.current&&t();const[e]=o.useState(s.current);return e}export{i as u};