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
@@ -45,6 +45,8 @@ const {
45
45
  parseAntigravityIncremental,
46
46
  resolveCodebuddyProjectFiles,
47
47
  parseCodebuddyIncremental,
48
+ resolveWorkbuddyProjectFiles,
49
+ parseWorkbuddyIncremental,
48
50
  resolveKiroCliSessionFiles,
49
51
  resolveKiroCliDbPath,
50
52
  parseKiroCliIncremental,
@@ -58,7 +60,10 @@ const {
58
60
  parseGooseIncremental,
59
61
  listDroidSettingsFiles,
60
62
  parseDroidIncremental,
63
+ droidSessionIdFromPath,
64
+ resolveDroidModel,
61
65
  bucketKey,
66
+ toUtcHalfHourStart,
62
67
  totalsKey,
63
68
  claudeMessageDedupKey,
64
69
  } = require("../lib/rollout");
@@ -144,6 +149,7 @@ const CLOUD_CONVERSATIONS_BACKFILL_KEY = "cloudConversationsBackfill_2026_06";
144
149
  // ~/.codex/archived_sessions/ which sync does not scan) — clearing its bucket
145
150
  // would lose that history (ref the v6 ground-truth-repair data-loss incident).
146
151
  const CODEX_RESCAN_DEDUP_REPAIR_KEY = "codexRescanDedupRepair_2026_06";
152
+ const DROID_DUP_SESSION_REPAIR_KEY = "droidDupSessionInflationRepair_2026_06";
147
153
 
148
154
  function warnProviderParseFailure(label, err, opts) {
149
155
  if (opts?.auto) return;
@@ -237,6 +243,7 @@ async function cmdSync(argv) {
237
243
 
238
244
  await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
239
245
  await repairCodexRescanInflation({ cursors, queuePath, queueStatePath, rolloutFiles });
246
+ await repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath });
240
247
 
241
248
  const openclawFiles = openclawSignal?.sessionFile
242
249
  ? [{ path: openclawSignal.sessionFile, source: "openclaw" }]
@@ -859,6 +866,36 @@ async function cmdSync(argv) {
859
866
  }
860
867
  }
861
868
 
869
+ // ── WorkBuddy (passive ~/.workbuddy/projects/**/*.jsonl reader) ──
870
+ // Tencent's WorkBuddy is a Claude Code fork in the same family as CodeBuddy;
871
+ // usage rides on function_call records too (not only assistant messages) and
872
+ // sub-agent logs nest one level deeper, so the resolver recurses. See the
873
+ // parser comment in rollout.js for the cache-aware token math.
874
+ let workbuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
875
+ const workbuddyFiles = resolveWorkbuddyProjectFiles(process.env);
876
+ if (workbuddyFiles.length > 0) {
877
+ if (progress?.enabled) {
878
+ progress.start(`Parsing WorkBuddy ${renderBar(0)} | buckets 0`);
879
+ }
880
+ try {
881
+ workbuddyResult = await parseWorkbuddyIncremental({
882
+ projectFiles: workbuddyFiles,
883
+ cursors,
884
+ queuePath,
885
+ env: process.env,
886
+ onProgress: (p) => {
887
+ if (!progress?.enabled) return;
888
+ const pct = p.total > 0 ? p.index / p.total : 1;
889
+ progress.update(
890
+ `Parsing WorkBuddy ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
891
+ );
892
+ },
893
+ });
894
+ } catch (err) {
895
+ warnProviderParseFailure("WorkBuddy", err, opts);
896
+ }
897
+ }
898
+
862
899
  // ── oh-my-pi (passive ~/.omp/agent/sessions/**/*.jsonl reader) ──
863
900
  let ompResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
864
901
  const ompFiles = resolveOmpSessionFiles(process.env);
@@ -1155,6 +1192,7 @@ async function cmdSync(argv) {
1155
1192
  kimiResult.recordsProcessed +
1156
1193
  kimiCodeResult.recordsProcessed +
1157
1194
  codebuddyResult.recordsProcessed +
1195
+ workbuddyResult.recordsProcessed +
1158
1196
  ompResult.recordsProcessed +
1159
1197
  piResult.recordsProcessed +
1160
1198
  craftResult.recordsProcessed +
@@ -1180,6 +1218,7 @@ async function cmdSync(argv) {
1180
1218
  kimiResult.bucketsQueued +
1181
1219
  kimiCodeResult.bucketsQueued +
1182
1220
  codebuddyResult.bucketsQueued +
1221
+ workbuddyResult.bucketsQueued +
1183
1222
  ompResult.bucketsQueued +
1184
1223
  piResult.bucketsQueued +
1185
1224
  craftResult.bucketsQueued +
@@ -1242,12 +1281,14 @@ module.exports = {
1242
1281
  migrateCursorUnknownBuckets,
1243
1282
  migrateRolloutCumulativeDeltaBuckets,
1244
1283
  repairCodexRescanInflation,
1284
+ repairDroidDuplicateSessionInflation,
1245
1285
  reincludeClaudeMemObserverFiles,
1246
1286
  repairGrokQueueFromSessionSnapshots,
1247
1287
  applyCloudConversationsBackfill,
1248
1288
  CURSOR_UNKNOWN_MIGRATION_KEY,
1249
1289
  ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY,
1250
1290
  CODEX_RESCAN_DEDUP_REPAIR_KEY,
1291
+ DROID_DUP_SESSION_REPAIR_KEY,
1251
1292
  CLAUDE_MEM_OBSERVER_REINCLUDE_KEY,
1252
1293
  GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY,
1253
1294
  CLOUD_CONVERSATIONS_BACKFILL_KEY,
@@ -2300,6 +2341,249 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2300
2341
  return true;
2301
2342
  }
2302
2343
 
2344
+ // One-time repair (#204): when the SAME Droid session id existed in two folders
2345
+ // under ~/.factory/sessions, parseDroidIncremental's cumulative-delta loop made the
2346
+ // lower-count file look like a reset and re-emitted each duplicate's full total on
2347
+ // EVERY sync, inflating one (droid, model, hour) bucket without bound (a real ~10M
2348
+ // session showed as 40.06B). The forward fix is dedupeDroidSettingsFilesBySession
2349
+ // inside the parser; this migration repairs already-polluted installs.
2350
+ //
2351
+ // SCOPE — strictly the duplicate sessions' buckets, and ONLY when duplicate files
2352
+ // still exist on disk:
2353
+ // * A from-zero rebuild cannot reconstruct Droid's historical per-sync bucket
2354
+ // distribution — settings.json carries only the CURRENT mtime, not per-turn
2355
+ // timestamps like Codex's jsonl. So we rebuild over the DUPLICATE files only,
2356
+ // and overwrite only the bucket keys those files map to (pollutedKeys) plus
2357
+ // the duplicate sessions' cursor entries. Every other droid bucket and cursor
2358
+ // — clean sessions AND deleted-session history — is left byte-for-byte intact,
2359
+ // so healthy history is never collapsed into the current half-hour.
2360
+ // * No session id has >1 file on disk → this bug never fired here: set the
2361
+ // sentinel, touch nothing.
2362
+ // * Fire only when live > rebuilt over pollutedKeys (actual inflation), so a
2363
+ // fresh install (live empty) is left to the normal same-sync parse.
2364
+ // Droid has no project dimension, so project.queue.jsonl is never involved.
2365
+ async function repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath } = {}) {
2366
+ if (!cursors || typeof cursors !== "object") return false;
2367
+ const migrations = (cursors.migrations ||= {});
2368
+ // Completed run → truthy non-skip sentinel (final). A {skipped:true} object would
2369
+ // retry (codex skip-retry semantics; this repair only ever writes done/none).
2370
+ const prior = migrations[DROID_DUP_SESSION_REPAIR_KEY];
2371
+ if (prior && !(typeof prior === "object" && prior.skipped)) return false;
2372
+
2373
+ // Group current on-disk settings files by session id; collect duplicates.
2374
+ let onDisk;
2375
+ try {
2376
+ onDisk = listDroidSettingsFiles(process.env);
2377
+ } catch {
2378
+ onDisk = [];
2379
+ }
2380
+ const bySession = new Map();
2381
+ for (const fp of onDisk) {
2382
+ const sid = droidSessionIdFromPath(fp);
2383
+ if (!sid) continue;
2384
+ if (!bySession.has(sid)) bySession.set(sid, []);
2385
+ bySession.get(sid).push(fp);
2386
+ }
2387
+ const dupFiles = [];
2388
+ const cleanFiles = [];
2389
+ for (const group of bySession.values()) {
2390
+ if (group.length > 1) dupFiles.push(...group);
2391
+ else cleanFiles.push(...group);
2392
+ }
2393
+ if (dupFiles.length === 0) {
2394
+ migrations[DROID_DUP_SESSION_REPAIR_KEY] = new Date().toISOString();
2395
+ return false;
2396
+ }
2397
+
2398
+ // Map on-disk settings files to the (droid, model, half-hour) bucket keys they
2399
+ // emit under the parser's own keying (mirrors parseDroidIncremental).
2400
+ const bucketKeysForFiles = (files) => {
2401
+ const keys = new Set();
2402
+ for (const fp of files) {
2403
+ let mtimeMs = 0;
2404
+ try {
2405
+ mtimeMs = fssync.statSync(fp).mtimeMs;
2406
+ } catch {
2407
+ continue;
2408
+ }
2409
+ let settings;
2410
+ try {
2411
+ settings = JSON.parse(fssync.readFileSync(fp, "utf8"));
2412
+ } catch {
2413
+ continue;
2414
+ }
2415
+ if (!settings || typeof settings !== "object" || !settings.tokenUsage) continue;
2416
+ const bucketStart = toUtcHalfHourStart(
2417
+ new Date(mtimeMs || Date.now()).toISOString(),
2418
+ );
2419
+ if (!bucketStart) continue;
2420
+ keys.add(bucketKey("droid", resolveDroidModel(settings, fp), bucketStart));
2421
+ }
2422
+ return keys;
2423
+ };
2424
+
2425
+ // A bucket key is (source, model, half-hour) — it carries NO session identity.
2426
+ // pollutedKeys are the buckets the duplicate files emit to; cleanKeys are buckets
2427
+ // a NON-duplicate on-disk session emits to. When a clean session resolves to the
2428
+ // same (model, half-hour) as a duplicate file, they collide on one key. We must
2429
+ // NOT delete-and-replace such a shared bucket: the rebuild runs over duplicate
2430
+ // files only, so replacing the bucket would erase the clean session's tokens
2431
+ // (silent data loss). So repair ONLY buckets owned exclusively by duplicate
2432
+ // sessions; leave shared buckets intact. Residual inflation in a rare shared
2433
+ // bucket is visible and recoverable; destroying real data is not (this is the
2434
+ // dedup-needs-identity-proof rule).
2435
+ const pollutedKeys = bucketKeysForFiles(dupFiles);
2436
+ const cleanKeys = bucketKeysForFiles(cleanFiles);
2437
+ const repairKeys = new Set();
2438
+ for (const k of pollutedKeys) if (!cleanKeys.has(k)) repairKeys.add(k);
2439
+ if (repairKeys.size === 0) {
2440
+ migrations[DROID_DUP_SESSION_REPAIR_KEY] = new Date().toISOString();
2441
+ return false;
2442
+ }
2443
+
2444
+ // Ground-truth rebuild into throwaway state over the DUPLICATE files only
2445
+ // (parseDroidIncremental de-dupes its own input → canonical per session). On any
2446
+ // throw, leave all state untouched and do NOT set the sentinel (retry next sync).
2447
+ let rebuilt;
2448
+ const tmpQueue = `${queuePath}.droidrebuild.${process.pid}.${Date.now()}`;
2449
+ try {
2450
+ const tmpCursors = { hourly: { buckets: {}, groupQueued: {} }, droid: {} };
2451
+ await parseDroidIncremental({
2452
+ settingsFiles: dupFiles,
2453
+ cursors: tmpCursors,
2454
+ queuePath: tmpQueue,
2455
+ env: process.env,
2456
+ prune: true,
2457
+ });
2458
+ let tmpRaw = "";
2459
+ try {
2460
+ tmpRaw = await fs.readFile(tmpQueue, "utf8");
2461
+ } catch (e) {
2462
+ if (e?.code !== "ENOENT") throw e;
2463
+ }
2464
+ rebuilt = {
2465
+ buckets: tmpCursors.hourly.buckets || {},
2466
+ sessionTotals: (tmpCursors.droid && tmpCursors.droid.sessionTotals) || {},
2467
+ queueRows: tmpRaw.split("\n").filter((l) => l.trim()),
2468
+ };
2469
+ } catch (e) {
2470
+ console.error(
2471
+ "[sync] droid dup-session repair: rebuild failed, leaving all data untouched:",
2472
+ e?.message || e,
2473
+ );
2474
+ return false;
2475
+ } finally {
2476
+ await fs.rm(tmpQueue, { force: true }).catch(() => {});
2477
+ }
2478
+
2479
+ // Inflation present? Compare live vs rebuilt totals over the repair-scoped keys
2480
+ // only. Fire only on live > rebuilt (real inflation) — never on a fresh install
2481
+ // (live 0).
2482
+ const liveBuckets = (cursors.hourly && cursors.hourly.buckets) || {};
2483
+ let liveScoped = 0;
2484
+ let rebuiltScoped = 0;
2485
+ for (const key of repairKeys) {
2486
+ liveScoped += Number(liveBuckets[key]?.totals?.total_tokens || 0);
2487
+ rebuiltScoped += Number(rebuilt.buckets[key]?.totals?.total_tokens || 0);
2488
+ }
2489
+ if (liveScoped <= rebuiltScoped) {
2490
+ migrations[DROID_DUP_SESSION_REPAIR_KEY] = new Date().toISOString();
2491
+ return false;
2492
+ }
2493
+
2494
+ // ── COMMIT (atomic) ──
2495
+ await ensureDir(path.dirname(queuePath));
2496
+ await backupExistingFile(queuePath);
2497
+
2498
+ // 1. queue.jsonl: keep every non-droid line verbatim (incl. unparseable) and
2499
+ // every droid row whose bucket key is NOT in repairKeys (clean + shared +
2500
+ // deleted-session history). Drop droid rows in repairKeys; append rebuilt rows.
2501
+ let raw = "";
2502
+ try {
2503
+ raw = await fs.readFile(queuePath, "utf8");
2504
+ } catch (e) {
2505
+ if (e?.code !== "ENOENT") throw e;
2506
+ }
2507
+ // A queue line is in scope if it's a droid row whose bucket is in repairKeys.
2508
+ // Unparseable / non-droid / clean / shared droid rows are kept verbatim.
2509
+ const isRepairDroidRow = (line) => {
2510
+ let row;
2511
+ try {
2512
+ row = JSON.parse(line);
2513
+ } catch {
2514
+ return false;
2515
+ }
2516
+ return (
2517
+ row?.source === "droid" &&
2518
+ repairKeys.has(bucketKey("droid", row.model, row.hour_start))
2519
+ );
2520
+ };
2521
+ const kept = raw
2522
+ .split("\n")
2523
+ .filter((line) => line.trim() && !isRepairDroidRow(line));
2524
+ const rebuiltRepairRows = rebuilt.queueRows.filter(isRepairDroidRow);
2525
+ const tmp = `${queuePath}.tmp.${process.pid}.${Date.now()}`;
2526
+ await fs.writeFile(
2527
+ tmp,
2528
+ kept.concat(rebuiltRepairRows).join("\n") + "\n",
2529
+ "utf8",
2530
+ );
2531
+ await fs.rename(tmp, queuePath);
2532
+
2533
+ // 2. live hourly buckets: delete repair-scoped droid keys, install the rebuilt
2534
+ // buckets for those keys. Other droid buckets untouched.
2535
+ const hourly = (cursors.hourly ||= { version: 3, buckets: {}, groupQueued: {} });
2536
+ hourly.buckets ||= {};
2537
+ hourly.groupQueued ||= {};
2538
+ for (const key of repairKeys) {
2539
+ delete hourly.buckets[key];
2540
+ if (rebuilt.buckets[key]) hourly.buckets[key] = rebuilt.buckets[key];
2541
+ }
2542
+ // Defensive: droid never uses the legacy aggregate path, but drop any stale droid
2543
+ // group markers so a repaired hour can't re-emit as model=unknown.
2544
+ for (const gk of Object.keys(hourly.groupQueued)) {
2545
+ if (gk.startsWith("droid|")) delete hourly.groupQueued[gk];
2546
+ }
2547
+
2548
+ // 3. session cursor: overwrite ONLY the duplicate sessions with the ground-truth
2549
+ // rebuild so the later same-sync droid parse short-circuits (mtime match) and
2550
+ // emits nothing. Clean sessions' cursor entries are correct already — leave
2551
+ // them, or the later parse would re-emit them from zero.
2552
+ const droidState = (cursors.droid ||= {});
2553
+ if (!droidState.sessionTotals || typeof droidState.sessionTotals !== "object") {
2554
+ droidState.sessionTotals = {};
2555
+ }
2556
+ for (const sid of Object.keys(rebuilt.sessionTotals)) {
2557
+ droidState.sessionTotals[sid] = rebuilt.sessionTotals[sid];
2558
+ }
2559
+ droidState.updatedAt = new Date().toISOString();
2560
+
2561
+ // 4. reset the cloud upload offset so corrected rows re-upload (idempotent upsert).
2562
+ if (typeof queueStatePath === "string" && queueStatePath) {
2563
+ let uploadState = {};
2564
+ try {
2565
+ uploadState = JSON.parse(await fs.readFile(queueStatePath, "utf8"));
2566
+ } catch {
2567
+ uploadState = {};
2568
+ }
2569
+ uploadState.offset = 0;
2570
+ uploadState.updatedAt = new Date().toISOString();
2571
+ uploadState.note = "reset_after_droid_dup_session_2026_06";
2572
+ await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
2573
+ }
2574
+
2575
+ migrations[DROID_DUP_SESSION_REPAIR_KEY] = {
2576
+ status: "done",
2577
+ at: new Date().toISOString(),
2578
+ keysRepaired: repairKeys.size,
2579
+ keysSkippedSharedWithCleanSession: pollutedKeys.size - repairKeys.size,
2580
+ liveBefore: liveScoped,
2581
+ rebuiltAfter: rebuiltScoped,
2582
+ deltaReclaimed: liveScoped - rebuiltScoped,
2583
+ };
2584
+ return true;
2585
+ }
2586
+
2303
2587
  // One-time repair migration: rebuild source=claude rows in queue.jsonl from
2304
2588
  // the actual jsonl files using ccusage's algorithm (msgId+reqId global
2305
2589
  // dedup). Earlier `reincludeClaudeMemObserverFiles` versions (v1/v2/v3) each
@@ -27,6 +27,8 @@ async function cmdUninstall(argv) {
27
27
  const claudeSettingsPath = path.join(home, ".claude", "settings.json");
28
28
  const codebuddyDir = process.env.CODEBUDDY_HOME || path.join(home, ".codebuddy");
29
29
  const codebuddySettingsPath = path.join(codebuddyDir, "settings.json");
30
+ const workbuddyDir = process.env.WORKBUDDY_HOME || path.join(home, ".workbuddy");
31
+ const workbuddySettingsPath = path.join(workbuddyDir, "settings.json");
30
32
  const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
31
33
  const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
32
34
  const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
@@ -37,12 +39,14 @@ async function cmdUninstall(argv) {
37
39
  const codeNotifyCmd = ["/usr/bin/env", "node", notifyPath, "--source=every-code"];
38
40
  const claudeHookCommand = buildClaudeHookCommand(notifyPath);
39
41
  const codebuddyHookCommand = buildHookCommand(notifyPath, "codebuddy");
42
+ const workbuddyHookCommand = buildHookCommand(notifyPath, "workbuddy");
40
43
  const geminiHookCommand = buildGeminiHookCommand(notifyPath);
41
44
 
42
45
  const codexConfigExists = await isFile(codexConfigPath);
43
46
  const codeConfigExists = await isFile(codeConfigPath);
44
47
  const claudeConfigExists = await isFile(claudeSettingsPath);
45
48
  const codebuddyConfigExists = await isFile(codebuddySettingsPath);
49
+ const workbuddyConfigExists = await isFile(workbuddySettingsPath);
46
50
  const geminiConfigExists = await isDir(geminiConfigDir);
47
51
  const opencodeConfigExists = await isDir(opencodeConfigDir);
48
52
  const codexRestore = codexConfigExists
@@ -68,6 +72,12 @@ async function cmdUninstall(argv) {
68
72
  hookCommand: codebuddyHookCommand,
69
73
  })
70
74
  : { removed: false, skippedReason: "config-missing" };
75
+ const workbuddyRemove = workbuddyConfigExists
76
+ ? await removeClaudeHook({
77
+ settingsPath: workbuddySettingsPath,
78
+ hookCommand: workbuddyHookCommand,
79
+ })
80
+ : { removed: false, skippedReason: "config-missing" };
71
81
  const geminiRemove = geminiConfigExists
72
82
  ? await removeGeminiHook({ settingsPath: geminiSettingsPath, hookCommand: geminiHookCommand })
73
83
  : { removed: false, skippedReason: "config-missing" };
@@ -132,6 +142,13 @@ async function cmdUninstall(argv) {
132
142
  ? "- CodeBuddy hooks: no change"
133
143
  : "- CodeBuddy hooks: skipped"
134
144
  : "- CodeBuddy hooks: skipped (settings.json not found)",
145
+ workbuddyConfigExists
146
+ ? workbuddyRemove?.removed
147
+ ? `- WorkBuddy hooks removed: ${workbuddySettingsPath}`
148
+ : workbuddyRemove?.skippedReason === "hook-missing"
149
+ ? "- WorkBuddy hooks: no change"
150
+ : "- WorkBuddy hooks: skipped"
151
+ : "- WorkBuddy hooks: skipped (settings.json not found)",
135
152
  geminiConfigExists
136
153
  ? geminiRemove?.removed
137
154
  ? `- Gemini hooks removed: ${geminiSettingsPath}`
@@ -86,7 +86,7 @@ function classifyWritableFailure(filePath) {
86
86
  * @param {string} opts.home - user home directory
87
87
  * @param {Object} opts.hookStatus - per-provider hook-installed booleans
88
88
  * (already collected by status.js); shape: { claude, gemini, codex,
89
- * every_code, opencode, openclaw, codebuddy, grok }
89
+ * every_code, opencode, openclaw, codebuddy, workbuddy, grok }
90
90
  * @returns {PassiveProvider[]}
91
91
  */
92
92
  function detectPassiveProviders({ home, hookStatus }) {
@@ -149,6 +149,16 @@ function detectPassiveProviders({ home, hookStatus }) {
149
149
  settingsPath: path.join(home, ".codebuddy", "settings.json"),
150
150
  }));
151
151
 
152
+ // WorkBuddy — sibling Claude-fork; hook in ~/.workbuddy/settings.json
153
+ out.push(buildEntry({
154
+ name: "workbuddy",
155
+ hookExpected: true,
156
+ hookInstalled: Boolean(hookStatus?.workbuddy),
157
+ logsDir: path.join(home, ".workbuddy"),
158
+ logsPredicate: (_full, name) => name === "projects" || name.endsWith(".jsonl"),
159
+ settingsPath: path.join(home, ".workbuddy", "settings.json"),
160
+ }));
161
+
152
162
  return out;
153
163
  }
154
164
 
@@ -9,8 +9,8 @@
9
9
  "claude-opus-4-8": { "input": 5, "output": 25, "cache_read": 0.5, "cache_write": 6.25, "note": "Opus 4.8 not yet in LiteLLM. Pin to the standard Opus tier (same as 4.6/4.7); remove once LiteLLM carries it." },
10
10
  "kiro-agent": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 },
11
11
  "kiro-cli-agent": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 },
12
- "hy3-preview-agent":{ "input": 0, "output": 0, "cache_read": 0 },
13
- "hy3-preview": { "input": 0, "output": 0, "cache_read": 0 },
12
+ "hy3-preview-agent":{ "input": 0.167, "output": 0.556, "cache_read": 0.056, "cache_write": 0.167, "note": "Tencent Hunyuan Hy3 preview (CodeBuddy/WorkBuddy backend). Official TokenHub rate: 1.2 / 0.4 (cache hit) / 4.0 RMB per MTok in/read/out, converted at ~7.2 RMB/USD. DeepSeek-style cache: no write surcharge, so cache_write = input." },
13
+ "hy3-preview": { "input": 0.167, "output": 0.556, "cache_read": 0.056, "cache_write": 0.167, "note": "See hy3-preview-agent." },
14
14
  "composer-1": { "input": 1.25, "output": 10, "cache_read": 0.125 },
15
15
  "composer-1.5": { "input": 3.5, "output": 17.5, "cache_read": 0.35 },
16
16
  "composer-2": { "input": 0.5, "output": 2.5, "cache_read": 0.2 },
@@ -104,6 +104,22 @@ function normalizeClaudeModel(model) {
104
104
  return m;
105
105
  }
106
106
 
107
+ // WorkBuddy's auto-router records the model as the literal "auto" and never
108
+ // exposes the underlying model it picked. That collides with Cursor's curated
109
+ // alias ("auto" -> "composer-1"), which would misprice WorkBuddy usage as
110
+ // Cursor's Composer. Map it instead to hy3-preview-agent — WorkBuddy's default
111
+ // Tencent Hunyuan model — mirroring how Cursor's "auto" maps to its own default
112
+ // (composer-1). The auto-router can pick pricier models, so this can slightly
113
+ // under-count, but it tracks the token cost of WorkBuddy's representative model
114
+ // rather than an unrelated vendor's. (The raw "auto" string is still
115
+ // stored/displayed; only the pricing lookup is remapped.)
116
+ function normalizeWorkbuddyModel(model) {
117
+ if (typeof model === "string" && model.trim().toLowerCase() === "auto") {
118
+ return "hy3-preview-agent";
119
+ }
120
+ return model;
121
+ }
122
+
107
123
  // Per-source model-name normalizers, applied at pricing-lookup time only (the
108
124
  // raw model name is preserved for storage/display). Add a source here when its
109
125
  // model strings don't match the LiteLLM/curated keys verbatim.
@@ -111,6 +127,7 @@ const SOURCE_MODEL_NORMALIZERS = {
111
127
  antigravity: normalizeAntigravityModel,
112
128
  claude: normalizeClaudeModel,
113
129
  zed: normalizeZedModel,
130
+ workbuddy: normalizeWorkbuddyModel,
114
131
  };
115
132
 
116
133
  // Memoise the sorted-by-length LiteLLM key list. Reverse-substring scan walks