tokentracker-cli 0.54.0 → 0.56.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 (50) hide show
  1. package/dashboard/dist/assets/{ActivityHeatmap-DIsZx2z4.js → ActivityHeatmap-B_tfEHcm.js} +1 -1
  2. package/dashboard/dist/assets/{Card-0V2Ex9cw.js → Card-D-kLfqyr.js} +1 -1
  3. package/dashboard/dist/assets/{DashboardPage-DLo34VPW.js → DashboardPage-C97dctZq.js} +1 -1
  4. package/dashboard/dist/assets/{DevicePage-QNynrWNU.js → DevicePage-B36WBvsn.js} +1 -1
  5. package/dashboard/dist/assets/{DialogTitle-BY1-42yj.js → DialogTitle--VIyiTEE.js} +1 -1
  6. package/dashboard/dist/assets/{FadeIn-D59fo8Dn.js → FadeIn-B8pvdF9J.js} +1 -1
  7. package/dashboard/dist/assets/{HeaderGithubStar-DJlMAZns.js → HeaderGithubStar-B0gHTi9N.js} +1 -1
  8. package/dashboard/dist/assets/{IpCheckPage-DVMBkpmm.js → IpCheckPage-Bq6MMJHQ.js} +1 -1
  9. package/dashboard/dist/assets/{LandingPage-DMM05RF0.js → LandingPage-CZIEkNWt.js} +1 -1
  10. package/dashboard/dist/assets/{LeaderboardAvatar-BG0bFShj.js → LeaderboardAvatar-BMRcGGFZ.js} +1 -1
  11. package/dashboard/dist/assets/{LeaderboardPage-Dy5UfAYd.js → LeaderboardPage-B5ffZ11y.js} +3 -3
  12. package/dashboard/dist/assets/{LeaderboardProfileModal-BylAxWKx.js → LeaderboardProfileModal-Bz2n03P4.js} +1 -1
  13. package/dashboard/dist/assets/{LeaderboardProfilePage-MUET5vln.js → LeaderboardProfilePage-BP5loLjy.js} +1 -1
  14. package/dashboard/dist/assets/LimitsPage-DzJSi6xG.js +2 -0
  15. package/dashboard/dist/assets/{LocalOnlyNotice-BUYlymbq.js → LocalOnlyNotice-CvfM7Yeu.js} +1 -1
  16. package/dashboard/dist/assets/{LoginPage-CKCLLD_7.js → LoginPage-D2LALlWv.js} +1 -1
  17. package/dashboard/dist/assets/{PopoverPopup-DLaSmwv9.js → PopoverPopup-BAT6qwPl.js} +1 -1
  18. package/dashboard/dist/assets/{ResetPasswordPage-B-XkZjqd.js → ResetPasswordPage-s76Qggro.js} +1 -1
  19. package/dashboard/dist/assets/{Select-DoNgnzPY.js → Select-CMEwM-Mo.js} +1 -1
  20. package/dashboard/dist/assets/{SelectItemText-KYJX0YNl.js → SelectItemText-s-8ZSDQn.js} +1 -1
  21. package/dashboard/dist/assets/{SettingsPage-Bt0lteef.js → SettingsPage-BKzrvCej.js} +1 -1
  22. package/dashboard/dist/assets/{SkillsPage-CqYjPOpv.js → SkillsPage-BX5iuYSx.js} +1 -1
  23. package/dashboard/dist/assets/{WidgetsPage-C-dqSkpr.js → WidgetsPage-CBD2lBZ1.js} +1 -1
  24. package/dashboard/dist/assets/{WrappedPage-gOOrJixn.js → WrappedPage-DpNAeMIM.js} +1 -1
  25. package/dashboard/dist/assets/{agent-logos-vN1kmaBa.js → agent-logos-Bggjr2yj.js} +1 -1
  26. package/dashboard/dist/assets/{arrow-up-right-DdyabaUL.js → arrow-up-right-C6z7x7NL.js} +1 -1
  27. package/dashboard/dist/assets/{download-BHKReypS.js → download-DBjVOuOZ.js} +1 -1
  28. package/dashboard/dist/assets/{info-DjLLVV9a.js → info-DJ0Ty3Yt.js} +1 -1
  29. package/dashboard/dist/assets/main-Bb0Bwbp7.css +1 -0
  30. package/dashboard/dist/assets/{main-BOS2AECp.js → main-Cqhrkqr2.js} +17 -14
  31. package/dashboard/dist/assets/{use-limits-display-prefs-BVcWtHtV.js → use-limits-display-prefs-DddAHmHH.js} +1 -1
  32. package/dashboard/dist/assets/{use-native-settings-CM4dizvm.js → use-native-settings-Cha6She4.js} +1 -1
  33. package/dashboard/dist/assets/{use-usage-limits-BS52iVYf.js → use-usage-limits-BJXjE59K.js} +1 -1
  34. package/dashboard/dist/assets/{useCurrency-C94ZEUyU.js → useCurrency-C63XmlQt.js} +1 -1
  35. package/dashboard/dist/assets/{useScrollLock-k9nwyt1S.js → useScrollLock-CHF80tR1.js} +1 -1
  36. package/dashboard/dist/index.html +2 -2
  37. package/dashboard/dist/share.html +2 -2
  38. package/package.json +2 -2
  39. package/src/commands/init.js +37 -1
  40. package/src/commands/status.js +27 -0
  41. package/src/commands/sync.js +284 -0
  42. package/src/commands/uninstall.js +17 -0
  43. package/src/lib/passive-mode.js +11 -1
  44. package/src/lib/pricing/curated-overrides.json +2 -2
  45. package/src/lib/pricing/matcher.js +17 -0
  46. package/src/lib/pricing/seed-snapshot.json +1 -1
  47. package/src/lib/rollout.js +415 -12
  48. package/src/lib/usage-limits.js +139 -15
  49. package/dashboard/dist/assets/LimitsPage-fYoLqW5m.js +0 -2
  50. package/dashboard/dist/assets/main-DCfktJsK.css +0 -1
@@ -4958,6 +4958,313 @@ async function parseCodebuddyIncremental({
4958
4958
  return { recordsProcessed, eventsAggregated, bucketsQueued };
4959
4959
  }
4960
4960
 
4961
+ // ─────────────────────────────────────────────────────────────────────────────
4962
+ // WorkBuddy — passive JSONL reader (~/.workbuddy/projects/<cwd>/**/*.jsonl)
4963
+ //
4964
+ // Tencent's WorkBuddy is a Claude-Code fork in the same "buddy" family as
4965
+ // CodeBuddy, but it differs from CodeBuddy's reader in three load-bearing ways
4966
+ // (each verified against real ~/.workbuddy logs, NOT assumed from CodeBuddy):
4967
+ //
4968
+ // 1. Usage lives on `function_call` records too — not only on
4969
+ // `type=="message" && role=="assistant"`. Each LLM round-trip (whether it
4970
+ // ends in a tool call or a text reply) carries its own providerData.rawUsage.
4971
+ // We therefore aggregate EVERY record that has providerData.rawUsage and
4972
+ // dedup per response id, instead of filtering by record type.
4973
+ //
4974
+ // 2. Sub-agent traffic is nested two levels deeper:
4975
+ // ~/.workbuddy/projects/<cwd>/<sessionId>.jsonl (main)
4976
+ // ~/.workbuddy/projects/<cwd>/<sessionId>/subagents/agent-*.jsonl (sub)
4977
+ // CodeBuddy's resolver only globs the top level, so we recurse to pick up
4978
+ // sub-agent usage (tool-results/*.txt are naturally skipped — not .jsonl).
4979
+ //
4980
+ // 3. rawUsage is OpenAI/DeepSeek-shaped and prompt_tokens is the FULL prompt
4981
+ // (cache reads + cache writes + genuinely-new input). The cache split is
4982
+ // mirrored two ways depending on which upstream the auto-router picked:
4983
+ // • Anthropic-style: cache_read_input_tokens / cache_creation_input_tokens
4984
+ // • DeepSeek/OpenAI-style: prompt_tokens_details.cached_tokens /
4985
+ // prompt_cache_hit_tokens (cache_creation_input_tokens then 0)
4986
+ // Reasoning is reported inside completion_tokens (verified:
4987
+ // rawUsage.total_tokens === prompt_tokens + completion_tokens).
4988
+ //
4989
+ // Token math (matches the repo's queue convention; subtract BOTH cache reads
4990
+ // AND cache writes from prompt_tokens — CodeBuddy's "prompt_tokens - cacheRead"
4991
+ // only works because its cache_creation is always 0; WorkBuddy writes cache
4992
+ // heavily, so the naive formula double-counts cache writes ~2x):
4993
+ // cacheRead = max(cache_read_input_tokens, prompt_tokens_details.cached_tokens,
4994
+ // prompt_cache_hit_tokens)
4995
+ // cacheCreate = cache_creation_input_tokens
4996
+ // input_tokens = prompt_tokens - cacheRead - cacheCreate
4997
+ // cached_input_tokens = cacheRead
4998
+ // cache_creation_input_tokens = cacheCreate
4999
+ // reasoning_output_tokens = completion_tokens_details.reasoning_tokens
5000
+ // output_tokens = completion_tokens - reasoning_output_tokens
5001
+ // total_tokens = sum of the above (== prompt_tokens + completion_tokens)
5002
+ //
5003
+ // model is the auto-router placeholder ("auto") — WorkBuddy does not expose
5004
+ // the underlying model in the log, so we emit it verbatim.
5005
+ // ─────────────────────────────────────────────────────────────────────────────
5006
+
5007
+ function resolveWorkbuddyHome(env = process.env) {
5008
+ const home = env.HOME || require("node:os").homedir();
5009
+ return env.WORKBUDDY_HOME || path.join(home, ".workbuddy");
5010
+ }
5011
+
5012
+ function resolveWorkbuddyDefaultModel(env = process.env) {
5013
+ const fallback = "auto";
5014
+ try {
5015
+ const settingsPath = path.join(resolveWorkbuddyHome(env), "settings.json");
5016
+ const raw = fssync.readFileSync(settingsPath, "utf8");
5017
+ const parsed = JSON.parse(raw);
5018
+ if (parsed && typeof parsed === "object" && typeof parsed.model === "string" && parsed.model.trim()) {
5019
+ return parsed.model.trim();
5020
+ }
5021
+ } catch (_e) {
5022
+ // settings missing or malformed — fall through
5023
+ }
5024
+ return fallback;
5025
+ }
5026
+
5027
+ // Recursively collect every *.jsonl under ~/.workbuddy/projects so that
5028
+ // per-session conversation logs AND their nested subagents/agent-*.jsonl files
5029
+ // are both discovered. Non-.jsonl artefacts (tool-results/*.txt) are ignored.
5030
+ function resolveWorkbuddyProjectFiles(env = process.env) {
5031
+ const projectsDir = path.join(resolveWorkbuddyHome(env), "projects");
5032
+ if (!fssync.existsSync(projectsDir)) return [];
5033
+ const files = [];
5034
+ const walk = (dir) => {
5035
+ let entries;
5036
+ try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
5037
+ for (const entry of entries) {
5038
+ const full = path.join(dir, entry.name);
5039
+ let isDir = entry.isDirectory();
5040
+ let isFile = entry.isFile();
5041
+ // Resolve symlinks defensively (Dirent flags are false for symlinks).
5042
+ if (!isDir && !isFile) {
5043
+ try {
5044
+ const st = fssync.statSync(full);
5045
+ isDir = st.isDirectory();
5046
+ isFile = st.isFile();
5047
+ } catch { continue; }
5048
+ }
5049
+ if (isDir) walk(full);
5050
+ else if (isFile && entry.name.endsWith(".jsonl")) files.push(full);
5051
+ }
5052
+ };
5053
+ walk(projectsDir);
5054
+ files.sort((a, b) => a.localeCompare(b));
5055
+ return files;
5056
+ }
5057
+
5058
+ async function parseWorkbuddyIncremental({
5059
+ projectFiles,
5060
+ cursors,
5061
+ queuePath,
5062
+ onProgress,
5063
+ env,
5064
+ defaultModel,
5065
+ } = {}) {
5066
+ await ensureDir(path.dirname(queuePath));
5067
+ const workbuddyState =
5068
+ cursors.workbuddy && typeof cursors.workbuddy === "object" ? cursors.workbuddy : {};
5069
+ const seenIds = new Set(
5070
+ Array.isArray(workbuddyState.seenIds) ? workbuddyState.seenIds : [],
5071
+ );
5072
+ const fileOffsets =
5073
+ workbuddyState.fileOffsets && typeof workbuddyState.fileOffsets === "object"
5074
+ ? { ...workbuddyState.fileOffsets }
5075
+ : {};
5076
+
5077
+ const files = Array.isArray(projectFiles)
5078
+ ? projectFiles
5079
+ : resolveWorkbuddyProjectFiles(env || process.env);
5080
+ const fallbackModel = defaultModel || resolveWorkbuddyDefaultModel(env || process.env);
5081
+
5082
+ if (files.length === 0) {
5083
+ cursors.workbuddy = {
5084
+ ...workbuddyState,
5085
+ seenIds: Array.from(seenIds),
5086
+ fileOffsets,
5087
+ updatedAt: new Date().toISOString(),
5088
+ };
5089
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5090
+ }
5091
+
5092
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
5093
+ const touchedBuckets = new Set();
5094
+ const cb = typeof onProgress === "function" ? onProgress : null;
5095
+ let recordsProcessed = 0;
5096
+ let eventsAggregated = 0;
5097
+
5098
+ for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
5099
+ const filePath = files[fileIdx];
5100
+ let stat;
5101
+ try { stat = fssync.statSync(filePath); } catch { continue; }
5102
+
5103
+ const prevEntry = fileOffsets[filePath] || {};
5104
+ const prevSize = Number(prevEntry.size) || 0;
5105
+ const prevIno = prevEntry.ino;
5106
+ // Re-read from start if file shrunk (truncate/rewrite) or inode changed
5107
+ // (file deleted + recreated). Otherwise pick up after the last read offset.
5108
+ const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
5109
+ const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
5110
+ if (stat.size <= startOffset) continue;
5111
+
5112
+ let stream;
5113
+ try {
5114
+ stream = fssync.createReadStream(filePath, {
5115
+ encoding: "utf8",
5116
+ start: startOffset,
5117
+ });
5118
+ } catch { continue; }
5119
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
5120
+
5121
+ for await (const line of rl) {
5122
+ if (!line || !line.trim()) continue;
5123
+ let entry;
5124
+ try { entry = JSON.parse(line); } catch { continue; }
5125
+ if (!entry || typeof entry !== "object") continue;
5126
+
5127
+ // Usage is carried on ANY record with providerData.rawUsage — assistant
5128
+ // messages AND function_call records. Aggregate them all; dedup per id.
5129
+ const provider = entry.providerData;
5130
+ const rawUsage = provider && typeof provider === "object" ? provider.rawUsage : null;
5131
+ if (!rawUsage || typeof rawUsage !== "object") continue;
5132
+
5133
+ const sessionId =
5134
+ typeof entry.sessionId === "string" && entry.sessionId
5135
+ ? entry.sessionId
5136
+ : path.basename(filePath, ".jsonl");
5137
+ const tsMs =
5138
+ Number.isFinite(Number(entry.timestamp)) && Number(entry.timestamp) > 0
5139
+ ? Number(entry.timestamp)
5140
+ : null;
5141
+ // One usage record per LLM round-trip; the response id is the most stable
5142
+ // dedup key, then providerData.messageId, then session+timestamp.
5143
+ const messageId =
5144
+ typeof entry.id === "string" && entry.id
5145
+ ? entry.id
5146
+ : typeof provider.messageId === "string" && provider.messageId
5147
+ ? provider.messageId
5148
+ : tsMs != null
5149
+ ? `${sessionId}:${tsMs}`
5150
+ : null;
5151
+ if (!messageId) continue;
5152
+ if (seenIds.has(messageId)) continue;
5153
+
5154
+ recordsProcessed++;
5155
+
5156
+ const promptTokens = toNonNegativeInt(rawUsage.prompt_tokens);
5157
+ const completionTokens = toNonNegativeInt(rawUsage.completion_tokens);
5158
+ const promptDetails =
5159
+ rawUsage.prompt_tokens_details && typeof rawUsage.prompt_tokens_details === "object"
5160
+ ? rawUsage.prompt_tokens_details
5161
+ : {};
5162
+ const completionDetails =
5163
+ rawUsage.completion_tokens_details && typeof rawUsage.completion_tokens_details === "object"
5164
+ ? rawUsage.completion_tokens_details
5165
+ : {};
5166
+
5167
+ // Cache reads are mirrored across up to three fields depending on which
5168
+ // upstream the auto-router used; take the largest non-zero mirror.
5169
+ const cacheRead = Math.max(
5170
+ toNonNegativeInt(rawUsage.cache_read_input_tokens),
5171
+ toNonNegativeInt(promptDetails.cached_tokens),
5172
+ toNonNegativeInt(rawUsage.prompt_cache_hit_tokens),
5173
+ );
5174
+ const cacheCreation = toNonNegativeInt(rawUsage.cache_creation_input_tokens);
5175
+ // prompt_tokens is the FULL prompt: subtract BOTH reads and writes so
5176
+ // input_tokens is pure non-cached input (no double-counting cache writes).
5177
+ const inputTokens = Math.max(0, promptTokens - cacheRead - cacheCreation);
5178
+ // completion_tokens INCLUDES reasoning (verified: total == prompt+completion).
5179
+ const reasoningTokens = Math.min(completionTokens, toNonNegativeInt(completionDetails.reasoning_tokens));
5180
+ const outputTokens = Math.max(0, completionTokens - reasoningTokens);
5181
+
5182
+ if (
5183
+ inputTokens === 0 &&
5184
+ outputTokens === 0 &&
5185
+ cacheRead === 0 &&
5186
+ cacheCreation === 0 &&
5187
+ reasoningTokens === 0
5188
+ ) {
5189
+ seenIds.add(messageId);
5190
+ continue;
5191
+ }
5192
+
5193
+ if (tsMs == null) {
5194
+ seenIds.add(messageId);
5195
+ continue;
5196
+ }
5197
+ const tsIso = new Date(tsMs).toISOString();
5198
+ const bucketStart = toUtcHalfHourStart(tsIso);
5199
+ if (!bucketStart) continue;
5200
+
5201
+ const model =
5202
+ normalizeModelInput(provider.model) ||
5203
+ normalizeModelInput(provider.requestModelId) ||
5204
+ normalizeModelInput(entry.model) ||
5205
+ fallbackModel;
5206
+
5207
+ const delta = {
5208
+ input_tokens: inputTokens,
5209
+ cached_input_tokens: cacheRead,
5210
+ cache_creation_input_tokens: cacheCreation,
5211
+ output_tokens: outputTokens,
5212
+ reasoning_output_tokens: reasoningTokens,
5213
+ total_tokens:
5214
+ inputTokens + outputTokens + cacheRead + cacheCreation + reasoningTokens,
5215
+ conversation_count: 1,
5216
+ };
5217
+
5218
+ const bucket = getHourlyBucket(hourlyState, "workbuddy", model, bucketStart);
5219
+ addTotals(bucket.totals, delta);
5220
+ touchedBuckets.add(bucketKey("workbuddy", model, bucketStart));
5221
+ seenIds.add(messageId);
5222
+ eventsAggregated++;
5223
+
5224
+ if (cb) {
5225
+ cb({
5226
+ index: fileIdx + 1,
5227
+ total: files.length,
5228
+ recordsProcessed,
5229
+ eventsAggregated,
5230
+ bucketsQueued: touchedBuckets.size,
5231
+ });
5232
+ }
5233
+ }
5234
+
5235
+ let postStat = stat;
5236
+ try { postStat = fssync.statSync(filePath); } catch {}
5237
+ fileOffsets[filePath] = {
5238
+ size: postStat.size,
5239
+ mtimeMs: postStat.mtimeMs,
5240
+ ino: postStat.ino,
5241
+ };
5242
+ }
5243
+
5244
+ // Cap dedup set to last 10k IDs to bound cursor state size — same convention
5245
+ // as CodeBuddy/Kimi/Copilot so cursors.json doesn't grow unbounded.
5246
+ const seenArr = Array.from(seenIds);
5247
+ const cappedSeen =
5248
+ seenArr.length > 10_000 ? seenArr.slice(seenArr.length - 10_000) : seenArr;
5249
+
5250
+ const bucketsQueued = await enqueueTouchedBuckets({
5251
+ queuePath,
5252
+ hourlyState,
5253
+ touchedBuckets,
5254
+ });
5255
+ const updatedAt = new Date().toISOString();
5256
+ hourlyState.updatedAt = updatedAt;
5257
+ cursors.hourly = hourlyState;
5258
+ cursors.workbuddy = {
5259
+ ...workbuddyState,
5260
+ seenIds: cappedSeen,
5261
+ fileOffsets,
5262
+ updatedAt,
5263
+ };
5264
+
5265
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
5266
+ }
5267
+
4961
5268
  // ─────────────────────────────────────────────────────────────────────────────
4962
5269
  // oh-my-pi (omp) — passive JSONL reader (~/.omp/agent/sessions/**/*.jsonl)
4963
5270
  //
@@ -6283,6 +6590,101 @@ function droidSessionIdFromPath(filePath) {
6283
6590
  return base.slice(0, -".settings.json".length);
6284
6591
  }
6285
6592
 
6593
+ // Resolve a Droid bucket model id. ccusage's chain: settings.model → sidecar
6594
+ // <id>.jsonl scrape → `<provider>-unknown` derived from providerLock or inferred
6595
+ // from the model fragment. Extracted so the dup-session repair migration can
6596
+ // reproduce the exact same bucket key a settings file would have emitted under.
6597
+ function resolveDroidModel(settings, filePath) {
6598
+ let model = normalizeDroidModelName(settings.model);
6599
+ if (!model) model = extractDroidModelFromSidecarJsonl(filePath);
6600
+ if (!model) {
6601
+ let provider = normalizeDroidProvider(settings.providerLock);
6602
+ if (provider === "unknown") {
6603
+ provider = inferDroidProviderFromModel(settings.model || "");
6604
+ }
6605
+ model = defaultDroidModelForProvider(provider);
6606
+ }
6607
+ return model;
6608
+ }
6609
+
6610
+ // When the SAME Droid session id (the basename, which is the cursor key) appears
6611
+ // in more than one folder under ~/.factory/sessions, every such file shares one
6612
+ // sessionTotals[sessionId] entry. Processing them in a single parse loop makes the
6613
+ // lower-count file look like a session reset and re-emit the full cumulative on
6614
+ // every sync — unbounded inflation (issue #204). De-dupe to ONE canonical file per
6615
+ // session id BEFORE the loop. Canonical = the most complete cumulative snapshot:
6616
+ // largest max(five-field sum, totalTokens) (applyDroidTotalFallback already spills
6617
+ // totalTokens into the five fields, so summing the filled fields IS that max);
6618
+ // ties go to the newest mtime, then the lexicographically smaller path. A session
6619
+ // id with a single file passes through untouched (no disk read).
6620
+ function dedupeDroidSettingsFilesBySession(files) {
6621
+ const list = Array.isArray(files) ? files : [];
6622
+ const groups = new Map();
6623
+ for (const filePath of list) {
6624
+ if (typeof filePath !== "string") continue;
6625
+ const sessionId = droidSessionIdFromPath(filePath);
6626
+ if (!sessionId) continue;
6627
+ if (!groups.has(sessionId)) groups.set(sessionId, []);
6628
+ groups.get(sessionId).push(filePath);
6629
+ }
6630
+ const out = [];
6631
+ for (const group of groups.values()) {
6632
+ if (group.length === 1) {
6633
+ out.push(group[0]);
6634
+ continue;
6635
+ }
6636
+ let best = null;
6637
+ let bestMetric = -1;
6638
+ let bestMtime = -1;
6639
+ for (const filePath of group) {
6640
+ let mtimeMs = 0;
6641
+ try {
6642
+ mtimeMs = fssync.statSync(filePath).mtimeMs;
6643
+ } catch {
6644
+ continue;
6645
+ }
6646
+ let settings;
6647
+ try {
6648
+ settings = JSON.parse(fssync.readFileSync(filePath, "utf8"));
6649
+ } catch {
6650
+ continue;
6651
+ }
6652
+ const usage =
6653
+ settings && typeof settings === "object" && settings.tokenUsage
6654
+ ? settings.tokenUsage
6655
+ : {};
6656
+ const filled = applyDroidTotalFallback({
6657
+ input: Math.max(0, Number(usage.inputTokens || 0)),
6658
+ output: Math.max(0, Number(usage.outputTokens || 0)),
6659
+ cacheCreation: Math.max(0, Number(usage.cacheCreationTokens || 0)),
6660
+ cacheRead: Math.max(0, Number(usage.cacheReadTokens || 0)),
6661
+ thinking: Math.max(0, Number(usage.thinkingTokens || 0)),
6662
+ totalTokens: Math.max(0, Number(usage.totalTokens || 0)),
6663
+ });
6664
+ const metric =
6665
+ filled.input +
6666
+ filled.output +
6667
+ filled.cacheCreation +
6668
+ filled.cacheRead +
6669
+ filled.thinking;
6670
+ const better =
6671
+ metric > bestMetric ||
6672
+ (metric === bestMetric && mtimeMs > bestMtime) ||
6673
+ (metric === bestMetric &&
6674
+ mtimeMs === bestMtime &&
6675
+ (best === null || filePath.localeCompare(best) < 0));
6676
+ if (better) {
6677
+ best = filePath;
6678
+ bestMetric = metric;
6679
+ bestMtime = mtimeMs;
6680
+ }
6681
+ }
6682
+ out.push(best || group[0]);
6683
+ }
6684
+ out.sort((a, b) => a.localeCompare(b));
6685
+ return out;
6686
+ }
6687
+
6286
6688
  async function parseDroidIncremental({
6287
6689
  settingsFiles,
6288
6690
  cursors,
@@ -6304,9 +6706,11 @@ async function parseDroidIncremental({
6304
6706
  ? { ...droidState.sessionTotals }
6305
6707
  : {};
6306
6708
 
6307
- const files = Array.isArray(settingsFiles)
6308
- ? settingsFiles
6309
- : listDroidSettingsFiles(env || process.env);
6709
+ const files = dedupeDroidSettingsFilesBySession(
6710
+ Array.isArray(settingsFiles)
6711
+ ? settingsFiles
6712
+ : listDroidSettingsFiles(env || process.env),
6713
+ );
6310
6714
 
6311
6715
  if (files.length === 0) {
6312
6716
  cursors.droid = {
@@ -6455,15 +6859,7 @@ async function parseDroidIncremental({
6455
6859
  // inferred from the model fragment we did find. Same fallback string set
6456
6860
  // (claude-unknown / gpt-unknown / gemini-unknown / grok-unknown) so
6457
6861
  // empty-model sessions bucket identically across both tools.
6458
- let model = normalizeDroidModelName(settings.model);
6459
- if (!model) model = extractDroidModelFromSidecarJsonl(filePath);
6460
- if (!model) {
6461
- let provider = normalizeDroidProvider(settings.providerLock);
6462
- if (provider === "unknown") {
6463
- provider = inferDroidProviderFromModel(settings.model || "");
6464
- }
6465
- model = defaultDroidModelForProvider(provider);
6466
- }
6862
+ const model = resolveDroidModel(settings, filePath);
6467
6863
 
6468
6864
  // Token normalization: inputTokens already excludes cache reads (matches
6469
6865
  // Anthropic API convention), so cache columns slot in directly. Thinking
@@ -8663,6 +9059,10 @@ module.exports = {
8663
9059
  resolveCodebuddyProjectFiles,
8664
9060
  resolveCodebuddyDefaultModel,
8665
9061
  parseCodebuddyIncremental,
9062
+ resolveWorkbuddyHome,
9063
+ resolveWorkbuddyProjectFiles,
9064
+ resolveWorkbuddyDefaultModel,
9065
+ parseWorkbuddyIncremental,
8666
9066
  resolveKiroCliSessionFiles,
8667
9067
  resolveKiroCliDbPath,
8668
9068
  parseKiroCliIncremental,
@@ -8699,6 +9099,8 @@ module.exports = {
8699
9099
  droidSessionIdFromPath,
8700
9100
  extractDroidModelFromSidecarJsonl,
8701
9101
  applyDroidTotalFallback,
9102
+ resolveDroidModel,
9103
+ dedupeDroidSettingsFilesBySession,
8702
9104
  parseDroidIncremental,
8703
9105
  resolvePiHome,
8704
9106
  resolvePiAgentDir,
@@ -8719,6 +9121,7 @@ module.exports = {
8719
9121
  // Exposed so the queue-repair migration can mutate cursors state in the
8720
9122
  // same key format sync uses elsewhere.
8721
9123
  bucketKey,
9124
+ toUtcHalfHourStart,
8722
9125
  totalsKey,
8723
9126
  claudeMessageDedupKey,
8724
9127
  groupBucketKey,
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const http = require("node:http");
7
7
  const https = require("node:https");
8
+ const { performance } = require("node:perf_hooks");
8
9
 
9
10
  const {
10
11
  detectClaudeCodeSubscriptionDetails,
@@ -174,6 +175,8 @@ async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttem
174
175
  // reading mislabels it as "5h". Aligned with steipete/CodexBar's rate-window normalizer.
175
176
  const CODEX_SESSION_WINDOW_SECONDS = 18000;
176
177
  const CODEX_WEEKLY_WINDOW_SECONDS = 604800;
178
+ const CODEX_RESET_CREDIT_LIST_TIMEOUT_MS = 3000;
179
+ const CODEX_RESET_CREDIT_LIST_TIMEOUT_GUARD_MS = 25;
177
180
 
178
181
  function classifyCodexWindow(window) {
179
182
  if (!window || typeof window !== "object") return null;
@@ -291,9 +294,103 @@ function normalizeCodexSparkRateWindows(additionalRateLimits) {
291
294
  };
292
295
  }
293
296
 
297
+ function normalizeCodexResetCreditCount(value) {
298
+ return Number.isInteger(value) && value >= 0 ? value : null;
299
+ }
300
+
301
+ function normalizeCodexResetCredit(row, nowMs) {
302
+ if (!row || typeof row !== "object" || Array.isArray(row)) return null;
303
+ if (row.status !== "available") return null;
304
+
305
+ const hasResetType = row.reset_type !== undefined && row.reset_type !== null;
306
+ if (hasResetType && row.reset_type !== "codex_rate_limits") return null;
307
+
308
+ const expiresAt = row.expires_at;
309
+ if (typeof expiresAt !== "string") return null;
310
+ const expiresAtMs = Date.parse(expiresAt);
311
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs < nowMs) return null;
312
+
313
+ const credit = {
314
+ status: row.status,
315
+ expires_at: expiresAt,
316
+ };
317
+ if (typeof row.reset_type === "string") {
318
+ credit.reset_type = row.reset_type;
319
+ }
320
+ if (typeof row.granted_at === "string") {
321
+ credit.granted_at = row.granted_at;
322
+ }
323
+ return { credit, expiresAtMs };
324
+ }
325
+
326
+ function normalizeCodexResetCredits(resetCredits, nowMs = Date.now()) {
327
+ if (!resetCredits || typeof resetCredits !== "object" || Array.isArray(resetCredits)) return null;
328
+
329
+ const availableCount = normalizeCodexResetCreditCount(resetCredits.available_count);
330
+ const totalEarnedCount = normalizeCodexResetCreditCount(resetCredits.total_earned_count);
331
+ const normalized = [];
332
+ if (Array.isArray(resetCredits.credits) && availableCount !== 0) {
333
+ for (const row of resetCredits.credits) {
334
+ const entry = normalizeCodexResetCredit(row, nowMs);
335
+ if (entry) normalized.push(entry);
336
+ }
337
+ }
338
+
339
+ const credits = normalized
340
+ .sort((a, b) => a.expiresAtMs - b.expiresAtMs)
341
+ .slice(0, 50)
342
+ .map((entry) => entry.credit);
343
+
344
+ if (availableCount === null && totalEarnedCount === null && credits.length === 0) {
345
+ return null;
346
+ }
347
+
348
+ return {
349
+ available_count: availableCount,
350
+ total_earned_count: totalEarnedCount,
351
+ credits: availableCount === 0 ? [] : credits,
352
+ };
353
+ }
354
+
355
+ function codexResetCreditListTimeoutMs(remainingProviderBudgetMs) {
356
+ if (!Number.isFinite(remainingProviderBudgetMs)) {
357
+ return CODEX_RESET_CREDIT_LIST_TIMEOUT_MS;
358
+ }
359
+ if (remainingProviderBudgetMs <= 0) return 0;
360
+ const guardedBudgetMs = Math.floor(remainingProviderBudgetMs - CODEX_RESET_CREDIT_LIST_TIMEOUT_GUARD_MS);
361
+ if (guardedBudgetMs <= 0) return 0;
362
+ return Math.min(CODEX_RESET_CREDIT_LIST_TIMEOUT_MS, guardedBudgetMs);
363
+ }
364
+
365
+ async function fetchCodexResetCreditList(fetchImpl, headers, timeoutMs = CODEX_RESET_CREDIT_LIST_TIMEOUT_MS) {
366
+ let timer = null;
367
+ try {
368
+ const request = Promise.resolve()
369
+ .then(() => fetchImpl("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", {
370
+ method: "GET",
371
+ headers,
372
+ }))
373
+ .then(async (res) => {
374
+ if (!res.ok) return null;
375
+ return normalizeCodexResetCredits(await res.json());
376
+ });
377
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
378
+ return await request;
379
+ }
380
+ const timeout = new Promise((resolve) => {
381
+ timer = setTimeout(() => resolve(null), timeoutMs);
382
+ });
383
+ return await Promise.race([request, timeout]);
384
+ } catch (_err) {
385
+ return null;
386
+ } finally {
387
+ if (timer) clearTimeout(timer);
388
+ }
389
+ }
390
+
294
391
  async function fetchCodexUsageLimits(
295
392
  accessToken,
296
- { fetchImpl = fetch, accountId = null } = {},
393
+ { fetchImpl = fetch, accountId = null, providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS } = {},
297
394
  ) {
298
395
  const headers = {
299
396
  Authorization: `Bearer ${accessToken}`,
@@ -305,27 +402,53 @@ async function fetchCodexUsageLimits(
305
402
  headers["ChatGPT-Account-Id"] = accountId;
306
403
  }
307
404
 
308
- const res = await fetchImpl("https://chatgpt.com/backend-api/wham/usage", {
309
- method: "GET",
310
- headers,
311
- });
312
- // 401/403/404 from wham means "no usage data available for this auth state" — render
313
- // a neutral empty state instead of a red "Fetch failed" error.
314
- if (res.status === 401 || res.status === 403 || res.status === 404) {
405
+ const startedAtMs = performance.now();
406
+ const usage = await withProviderTimeout(Promise.resolve()
407
+ .then(() => fetchImpl("https://chatgpt.com/backend-api/wham/usage", {
408
+ method: "GET",
409
+ headers,
410
+ }))
411
+ .then(async (res) => {
412
+ // 401/403/404 from wham means "no usage data available for this auth state" — render
413
+ // a neutral empty state instead of a red "Fetch failed" error.
414
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
415
+ return { body: null };
416
+ }
417
+ if (res.status !== 200) {
418
+ throw new Error(`Codex API returned ${res.status}`);
419
+ }
420
+ return { body: await res.json() };
421
+ }), "Codex", providerTimeoutMs);
422
+ if (!usage.body) {
315
423
  return {
316
424
  primary_window: null,
317
425
  secondary_window: null,
318
426
  spark_primary_window: null,
319
427
  spark_secondary_window: null,
428
+ reset_credits: null,
320
429
  };
321
430
  }
322
- if (!res.ok) {
323
- throw new Error(`Codex API returned ${res.status}`);
431
+ const body = usage.body;
432
+ let resetCredits = normalizeCodexResetCredits(body.rate_limit_reset_credits);
433
+ // This semi-private sibling endpoint is read-only; /wham/usage remains the stable count fallback.
434
+ const remainingProviderBudgetMs = Number.isFinite(providerTimeoutMs) && providerTimeoutMs > 0
435
+ ? providerTimeoutMs - (performance.now() - startedAtMs)
436
+ : CODEX_RESET_CREDIT_LIST_TIMEOUT_MS;
437
+ const resetCreditListTimeoutMs = codexResetCreditListTimeoutMs(remainingProviderBudgetMs);
438
+ if (resetCreditListTimeoutMs > 0) {
439
+ const resetCreditsList = await fetchCodexResetCreditList(
440
+ fetchImpl,
441
+ headers,
442
+ resetCreditListTimeoutMs,
443
+ );
444
+ if (resetCreditsList) {
445
+ resetCredits = resetCreditsList;
446
+ }
324
447
  }
325
- const body = await res.json();
326
448
  return {
327
449
  ...normalizeCodexRateWindows(body.rate_limit || {}),
328
450
  ...normalizeCodexSparkRateWindows(body.additional_rate_limits),
451
+ reset_credits: resetCredits,
329
452
  };
330
453
  }
331
454
 
@@ -2174,11 +2297,11 @@ async function fetchUsageLimitsUncached({
2174
2297
  )
2175
2298
  : Promise.resolve(null),
2176
2299
  codexToken
2177
- ? withProviderTimeout(
2178
- fetchCodexUsageLimits(codexToken, { fetchImpl: providerFetch, accountId: codexAccountId }),
2179
- "Codex",
2300
+ ? fetchCodexUsageLimits(codexToken, {
2301
+ fetchImpl: providerFetch,
2302
+ accountId: codexAccountId,
2180
2303
  providerTimeoutMs,
2181
- ).then(
2304
+ }).then(
2182
2305
  (value) => ({ status: "fulfilled", value }),
2183
2306
  (reason) => ({ status: "rejected", reason }),
2184
2307
  )
@@ -2257,6 +2380,7 @@ async function fetchUsageLimitsUncached({
2257
2380
  secondary_window: codexResult.value.secondary_window,
2258
2381
  spark_primary_window: codexResult.value.spark_primary_window,
2259
2382
  spark_secondary_window: codexResult.value.spark_secondary_window,
2383
+ reset_credits: codexResult.value.reset_credits,
2260
2384
  };
2261
2385
  }
2262
2386