tokentracker-cli 0.79.8 → 0.80.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 (77) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-Ctu3c_8t.js → AchievementBadge-D9jOMhlD.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-B3kBTHDB.js → AchievementsPage-DuswZa4g.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CAnTXGpC.js → AchievementsSection-BJPeE1Uq.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-oTUXEQk9.js → BadgeDetailModal-nNPIsy6i.js} +1 -1
  5. package/dashboard/dist/assets/{Card-BHt5f5cD.js → Card-VENq8eEp.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-ChxXY3l0.js → ClawdAnimated-Cq7TW0vv.js} +1 -1
  7. package/dashboard/dist/assets/{CommunityStatsModal-CVLlItP1.js → CommunityStatsModal-y2VftQuq.js} +1 -1
  8. package/dashboard/dist/assets/{ConfirmModal-ClrILpb3.js → ConfirmModal-CWgnJ4hm.js} +1 -1
  9. package/dashboard/dist/assets/{Controls-D_XBBpIh.js → Controls-BUQ_iZZq.js} +1 -1
  10. package/dashboard/dist/assets/DashboardPage-WkiygMmq.js +19 -0
  11. package/dashboard/dist/assets/{DevicePage-J7DW7yeX.js → DevicePage-Dh3XDtNx.js} +1 -1
  12. package/dashboard/dist/assets/{DialogClose-B1r9gzUF.js → DialogClose-KTFU_B3F.js} +1 -1
  13. package/dashboard/dist/assets/DialogDescription-DIra288n.js +1 -0
  14. package/dashboard/dist/assets/{DialogTitle-BL2puDA7.js → DialogTitle-bza2pIek.js} +1 -1
  15. package/dashboard/dist/assets/{FadeIn-Cd-YT4oT.js → FadeIn-Dm3FVNfr.js} +1 -1
  16. package/dashboard/dist/assets/{HeaderGithubStar-BAtl4a-K.js → HeaderGithubStar-CbSElzLt.js} +1 -1
  17. package/dashboard/dist/assets/{IpCheckPage-BXK_cYN2.js → IpCheckPage-BDON7VCU.js} +1 -1
  18. package/dashboard/dist/assets/{LandingPage-C_16AbpF.js → LandingPage-Ck3CwZrt.js} +1 -1
  19. package/dashboard/dist/assets/{LeaderboardAvatar-B0vUuCYU.js → LeaderboardAvatar-GVUy0Ynq.js} +1 -1
  20. package/dashboard/dist/assets/{LeaderboardPage-Duhdaq09.js → LeaderboardPage-C19Rkt44.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardProfileModal-CFFyHRTB.js → LeaderboardProfileModal-QcEq_o6E.js} +3 -3
  22. package/dashboard/dist/assets/{LeaderboardProfilePage-CjTelV-4.js → LeaderboardProfilePage-pFhI6292.js} +1 -1
  23. package/dashboard/dist/assets/{LimitsPage-DIcXRgSv.js → LimitsPage-ChV3Qezd.js} +1 -1
  24. package/dashboard/dist/assets/{LocalOnlyNotice-DC7wXWds.js → LocalOnlyNotice-ALvpjznF.js} +1 -1
  25. package/dashboard/dist/assets/{LoginPage--8hYT3aq.js → LoginPage-CL-QrJxU.js} +1 -1
  26. package/dashboard/dist/assets/{PetPage-ByWUW7-V.js → PetPage-DSuvdGgd.js} +1 -1
  27. package/dashboard/dist/assets/{PopoverPopup-g_v1fmhH.js → PopoverPopup-jSDQ87Bi.js} +1 -1
  28. package/dashboard/dist/assets/{ResetPasswordPage-BfunstIi.js → ResetPasswordPage-Dk2IDAJq.js} +1 -1
  29. package/dashboard/dist/assets/{Select-C4ShPt1t.js → Select-Cz6WTj7D.js} +1 -1
  30. package/dashboard/dist/assets/{SelectItemText-gw_TdvY6.js → SelectItemText-BZyRjWCM.js} +1 -1
  31. package/dashboard/dist/assets/{SettingsPage-BFCFrDuA.js → SettingsPage-CANz8HMM.js} +1 -1
  32. package/dashboard/dist/assets/{SkillsPage-BNQDySeK.js → SkillsPage-WZkQG_83.js} +1 -1
  33. package/dashboard/dist/assets/{WidgetsPage-B3bHP3Vk.js → WidgetsPage-BEyJ62NV.js} +1 -1
  34. package/dashboard/dist/assets/{WrappedPage-6amxWnWT.js → WrappedPage-Ck_dOx_w.js} +1 -1
  35. package/dashboard/dist/assets/{agent-logos-DPIQzYms.js → agent-logos-DpjHiAbZ.js} +1 -1
  36. package/dashboard/dist/assets/{arrow-up-right-CKcE2mif.js → arrow-up-right-C4iYPBtK.js} +1 -1
  37. package/dashboard/dist/assets/check-BkPUxPZV.js +1 -0
  38. package/dashboard/dist/assets/{copy-vi-V2M_I.js → copy-CHJ-38K6.js} +1 -1
  39. package/dashboard/dist/assets/{download-BH0Spg15.js → download-BSeG8u17.js} +1 -1
  40. package/dashboard/dist/assets/{flame-C3jUAnqA.js → flame-DeHwKcE-.js} +1 -1
  41. package/dashboard/dist/assets/{format-tokens-tnfJT09G.js → format-tokens-B-MJYJyp.js} +1 -1
  42. package/dashboard/dist/assets/{info-CGCZ5JvP.js → info-DA_0xr_o.js} +1 -1
  43. package/dashboard/dist/assets/{link-2-CXULWwZY.js → link-2-noUH83Lc.js} +1 -1
  44. package/dashboard/dist/assets/{loader-circle-B324hSa3.js → loader-circle-wL7KZtm0.js} +1 -1
  45. package/dashboard/dist/assets/{main-vGUuyRq-.js → main-BHfJDFcu.js} +20 -20
  46. package/dashboard/dist/assets/{provider-display-CeoAhoCb.js → provider-display-DxIClOGM.js} +1 -1
  47. package/dashboard/dist/assets/{trash-2-L9vxbDeP.js → trash-2-B8TPUmxZ.js} +1 -1
  48. package/dashboard/dist/assets/{use-community-stats-DLE30Lh0.js → use-community-stats-CXD3MRRE.js} +1 -1
  49. package/dashboard/dist/assets/{use-limits-display-prefs-BxN--PEW.js → use-limits-display-prefs-C2pJVYTn.js} +1 -1
  50. package/dashboard/dist/assets/{use-native-settings-Dn5gWQkJ.js → use-native-settings-BeOBLEZa.js} +1 -1
  51. package/dashboard/dist/assets/{use-ordered-list-DYoz2NJ9.js → use-ordered-list-kAtW8pJm.js} +1 -1
  52. package/dashboard/dist/assets/{use-quality-per-dollar-pref-JXorBQsT.js → use-quality-per-dollar-pref-CFZUZM3S.js} +1 -1
  53. package/dashboard/dist/assets/{use-transform-BTTF5vPC.js → use-transform-B9YwpyKi.js} +1 -1
  54. package/dashboard/dist/assets/{use-usage-limits-BUemSWQ9.js → use-usage-limits-DSX5iKPE.js} +1 -1
  55. package/dashboard/dist/assets/{useCurrency-Cxv4RR3U.js → useCurrency-CKg5K4Nj.js} +1 -1
  56. package/dashboard/dist/assets/{useScrollLock-DTPnHKfF.js → useScrollLock-DyyY_Vyt.js} +1 -1
  57. package/dashboard/dist/assets/useTokenFormat-FfTjEH_m.js +1 -0
  58. package/dashboard/dist/assets/{zap-CG8zuY9S.js → zap-B69x9NCT.js} +1 -1
  59. package/dashboard/dist/index.html +1 -1
  60. package/dashboard/dist/share.html +1 -1
  61. package/package.json +1 -1
  62. package/src/commands/init.js +89 -12
  63. package/src/commands/serve.js +65 -4
  64. package/src/commands/status.js +2 -1
  65. package/src/commands/sync.js +170 -7
  66. package/src/commands/uninstall.js +10 -2
  67. package/src/lib/claude-config.js +69 -28
  68. package/src/lib/diagnostics.js +2 -2
  69. package/src/lib/local-api.js +19 -6
  70. package/src/lib/openclaw-session-plugin.js +13 -0
  71. package/src/lib/pricing/seed-snapshot.json +1 -1
  72. package/src/lib/rollout.js +63 -2
  73. package/src/lib/usage-limits.js +9 -5
  74. package/dashboard/dist/assets/DashboardPage-CizTJox2.js +0 -19
  75. package/dashboard/dist/assets/DialogDescription-Bd9-kaCS.js +0 -1
  76. package/dashboard/dist/assets/check-B5V4_ezt.js +0 -1
  77. package/dashboard/dist/assets/useTokenFormat-DMAb2ec3.js +0 -1
@@ -21,9 +21,11 @@ const {
21
21
  } = require("../lib/codex-config");
22
22
  const {
23
23
  upsertClaudeHook,
24
+ upsertClaudeUsageHooks,
24
25
  buildClaudeHookCommand,
25
26
  buildHookCommand,
26
27
  isClaudeHookConfigured,
28
+ areClaudeUsageHooksConfigured,
27
29
  } = require("../lib/claude-config");
28
30
  const {
29
31
  resolveGeminiConfigDir,
@@ -418,6 +420,78 @@ async function repairCodexNotifyIntegration({ home = os.homedir(), trackerDir, b
418
420
  return { ...result, skippedReason: null, notifyPath };
419
421
  }
420
422
 
423
+ async function repairRuntimeIntegrations({
424
+ home = os.homedir(),
425
+ trackerDir,
426
+ binDir,
427
+ safeMode = true,
428
+ } = {}) {
429
+ const paths = trackerDir && binDir ? { trackerDir, binDir } : await resolveTrackerPaths({ home });
430
+ const resolvedTrackerDir = trackerDir || paths.trackerDir;
431
+ const resolvedBinDir = binDir || paths.binDir;
432
+ const notifyPath = await writeNotifyHandler({
433
+ trackerDir: resolvedTrackerDir,
434
+ binDir: resolvedBinDir,
435
+ });
436
+ const context = buildIntegrationTargets({
437
+ home,
438
+ trackerDir: resolvedTrackerDir,
439
+ notifyPath,
440
+ });
441
+ const integrations = {};
442
+ const warnings = [];
443
+
444
+ const attempt = async (key, work) => {
445
+ try {
446
+ integrations[key] = await work();
447
+ } catch (error) {
448
+ integrations[key] = { changed: false, skippedReason: "repair-failed" };
449
+ warnings.push({ integration: key, error: error?.message || String(error) });
450
+ }
451
+ };
452
+
453
+ await attempt("codex", () => repairCodexNotifyIntegration({
454
+ home,
455
+ trackerDir: resolvedTrackerDir,
456
+ binDir: resolvedBinDir,
457
+ safeMode,
458
+ }));
459
+
460
+ const hookRepairs = [
461
+ ["claude", context.claudeDir, () => upsertClaudeUsageHooks({
462
+ settingsPath: context.claudeSettingsPath,
463
+ hookCommand: context.claudeHookCommand,
464
+ })],
465
+ ["gemini", context.geminiConfigDir, () => upsertGeminiHook({
466
+ settingsPath: context.geminiSettingsPath,
467
+ hookCommand: context.geminiHookCommand,
468
+ })],
469
+ ["codebuddy", context.codebuddyDir, () => upsertClaudeHook({
470
+ settingsPath: context.codebuddySettingsPath,
471
+ hookCommand: context.codebuddyHookCommand,
472
+ })],
473
+ ["workbuddy", context.workbuddyDir, () => upsertClaudeHook({
474
+ settingsPath: context.workbuddySettingsPath,
475
+ hookCommand: context.workbuddyHookCommand,
476
+ })],
477
+ ];
478
+ for (const [key, configDir, repair] of hookRepairs) {
479
+ if (await isDir(configDir)) await attempt(key, repair);
480
+ else integrations[key] = { changed: false, skippedReason: "config-missing" };
481
+ }
482
+
483
+ if (await isDir(context.opencodeConfigDir)) {
484
+ await attempt("opencode", () => upsertOpencodePlugin({
485
+ configDir: context.opencodeConfigDir,
486
+ notifyPath,
487
+ }));
488
+ } else {
489
+ integrations.opencode = { changed: false, skippedReason: "config-missing" };
490
+ }
491
+
492
+ return { notifyPath, integrations, warnings };
493
+ }
494
+
421
495
  function buildIntegrationTargets({ home, trackerDir, notifyPath }) {
422
496
  const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
423
497
  const codexConfigPath = path.join(codexHome, "config.toml");
@@ -495,7 +569,7 @@ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOrigi
495
569
 
496
570
  const claudeDirExists = await isDir(context.claudeDir);
497
571
  if (claudeDirExists) {
498
- await upsertClaudeHook({
572
+ await upsertClaudeUsageHooks({
499
573
  settingsPath: context.claudeSettingsPath,
500
574
  hookCommand: context.claudeHookCommand,
501
575
  });
@@ -787,7 +861,7 @@ async function previewIntegrations({ context }) {
787
861
 
788
862
  const claudeDirExists = await isDir(context.claudeDir);
789
863
  if (claudeDirExists) {
790
- const configured = await isClaudeHookConfigured({
864
+ const configured = await areClaudeUsageHooksConfigured({
791
865
  settingsPath: context.claudeSettingsPath,
792
866
  hookCommand: context.claudeHookCommand,
793
867
  });
@@ -1071,7 +1145,6 @@ const codexOriginalPath = ${JSON.stringify(originalPath)};
1071
1145
  const codeOriginalPath = ${JSON.stringify(path.join(trackerDir, "code_notify_original.json"))};
1072
1146
  const trackerBinPath = ${JSON.stringify(trackerBinPath)};
1073
1147
  const depsMarkerPath = path.join(trackerDir, 'app', 'bin', 'tracker.js');
1074
- const configPath = path.join(trackerDir, 'config.json');
1075
1148
  const fallbackPkg = ${JSON.stringify(fallbackPkg)};
1076
1149
  const selfPath = path.resolve(__filename);
1077
1150
  const home = os.homedir();
@@ -1109,18 +1182,10 @@ if (debugEnabled) {
1109
1182
  // Throttle spawn: at most once per 20 seconds.
1110
1183
  try {
1111
1184
  const throttlePath = path.join(trackerDir, 'sync.throttle');
1112
- let deviceToken = process.env.TOKENTRACKER_DEVICE_TOKEN || null;
1113
- if (!deviceToken) {
1114
- try {
1115
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1116
- if (cfg && typeof cfg.deviceToken === 'string') deviceToken = cfg.deviceToken;
1117
- } catch (_) {}
1118
- }
1119
- const canSync = Boolean(deviceToken && deviceToken.length > 0);
1120
1185
  const now = Date.now();
1121
1186
  let last = 0;
1122
1187
  try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}
1123
- if (canSync && now - last > 20_000) {
1188
+ if (now - last > 20_000) {
1124
1189
  try { fs.writeFileSync(throttlePath, String(now), 'utf8'); } catch (_) {}
1125
1190
  const hasLocalRuntime = fs.existsSync(trackerBinPath);
1126
1191
  const hasLocalDeps = fs.existsSync(depsMarkerPath);
@@ -1241,6 +1306,7 @@ module.exports = {
1241
1306
  buildNotifyHandler,
1242
1307
  installLocalTrackerApp,
1243
1308
  repairCodexNotifyIntegration,
1309
+ repairRuntimeIntegrations,
1244
1310
  };
1245
1311
 
1246
1312
  async function probeFile(p) {
@@ -1266,6 +1332,11 @@ async function isDir(p) {
1266
1332
  }
1267
1333
 
1268
1334
  async function installLocalTrackerApp({ appDir }) {
1335
+ // Test-only escape hatch: skip the ~10 MB runtime copy (src + dashboard/dist +
1336
+ // node_modules) when a test only exercises hook/config/notify wiring. Copying
1337
+ // the bundle per test case dominates the init test suite's wall time. Tests
1338
+ // that assert on the copied runtime simply leave this unset.
1339
+ if (process.env.TOKENTRACKER_SKIP_LOCAL_RUNTIME_COPY === "1") return;
1269
1340
  // Copy the current package's runtime (bin + src) into ~/.tokentracker so notify can run sync without npx.
1270
1341
  const packageRoot = path.resolve(__dirname, "../..");
1271
1342
  const srcFrom = path.join(packageRoot, "src");
@@ -1321,6 +1392,12 @@ async function safeRealpath(p) {
1321
1392
  const FIRST_SYNC_TIMEOUT_MS = 15_000;
1322
1393
 
1323
1394
  async function runFirstSyncAndRead({ trackerBinPath, trackerDir, packageName }) {
1395
+ // Test-only escape hatch: skip spawning a real `sync --drain` subprocess when
1396
+ // a test only exercises init wiring. Returns the same shape derived from an
1397
+ // (empty) queue so callers behave identically without the ~1s spawn per case.
1398
+ if (process.env.TOKENTRACKER_SKIP_FIRST_SYNC === "1") {
1399
+ return readFirstSyncTotals(trackerDir);
1400
+ }
1324
1401
  const fallbackPkg = packageName || "tokentracker-cli";
1325
1402
  const argv = ["sync", "--drain"];
1326
1403
  const hasLocalRuntime = typeof trackerBinPath === "string" && fssync.existsSync(trackerBinPath);
@@ -22,6 +22,7 @@ const WSL_DEFAULT_PORT = 7681;
22
22
  const DEFAULT_MAX_PORT_ATTEMPTS = 20;
23
23
  const NPM_PACKAGE_NAME = "tokentracker-cli";
24
24
  const LOCAL_BIND_HOST = "127.0.0.1";
25
+ const NATIVE_BACKGROUND_SYNC_INTERVAL_MS = 60_000;
25
26
  const STATIC_ASSET_EXTENSIONS = new Set([
26
27
  ".css",
27
28
  ".gif",
@@ -71,11 +72,13 @@ async function cmdServe(argv) {
71
72
  }
72
73
 
73
74
  try {
74
- const { installLocalTrackerApp, repairCodexNotifyIntegration } = require("./init");
75
+ const { installLocalTrackerApp, repairRuntimeIntegrations } = require("./init");
75
76
  await installLocalTrackerApp({ appDir: path.join(trackerDir, "app") });
76
- const repairResult = await repairCodexNotifyIntegration({ trackerDir, binDir, safeMode: true });
77
- if (repairResult?.skippedReason && repairResult.skippedReason !== "config-missing") {
78
- process.stdout.write(`Codex notify repair skipped: ${repairResult.skippedReason}\n`);
77
+ const repairResult = await repairRuntimeIntegrations({ trackerDir, binDir, safeMode: true });
78
+ for (const warning of repairResult?.warnings || []) {
79
+ process.stdout.write(
80
+ `Runtime integration repair warning (${warning.integration}): ${warning.error}\n`,
81
+ );
79
82
  }
80
83
  } catch (e) {
81
84
  process.stdout.write(`Runtime refresh warning: ${e?.message || e}\n`);
@@ -217,6 +220,18 @@ async function cmdServe(argv) {
217
220
 
218
221
  await maybeShowStarCta({ trackerDir });
219
222
 
223
+ // Native desktop apps keep this server alive even when the dashboard window
224
+ // is closed. Provider hooks are the fast path, but they can be removed by a
225
+ // tool update or skipped by a provider. Scan every local source once a
226
+ // minute as a bounded fallback so the queue cannot remain stale indefinitely.
227
+ // `--no-sync` still skips the blocking startup sync; this periodic pass is
228
+ // native-shell-only and uses the lightweight local mode (no cloud upload,
229
+ // Cursor network request, or deep Codex archive scan).
230
+ const nativeBackgroundSync = startNativeBackgroundSync({
231
+ onError: (e) =>
232
+ process.stdout.write(`Background local sync warning: ${e?.message || e}\n`),
233
+ });
234
+
220
235
  // Anonymous daily heartbeat (see src/lib/telemetry.js for the privacy
221
236
  // contract). Fire-and-forget at startup, then re-checked every 6 hours so
222
237
  // long-lived embedded-app servers still count on later days; the shared
@@ -238,6 +253,7 @@ async function cmdServe(argv) {
238
253
  // 5. Graceful shutdown
239
254
  const shutdown = () => {
240
255
  process.stdout.write("\nShutting down...\n");
256
+ nativeBackgroundSync?.stop();
241
257
  server.close(() => process.exit(0));
242
258
  setTimeout(() => process.exit(0), 3000);
243
259
  };
@@ -248,6 +264,49 @@ async function cmdServe(argv) {
248
264
  await new Promise(() => {});
249
265
  }
250
266
 
267
+ function startNativeBackgroundSync({
268
+ appShell = process.env.TOKENTRACKER_APP_SHELL,
269
+ intervalMs = NATIVE_BACKGROUND_SYNC_INTERVAL_MS,
270
+ runSync,
271
+ setIntervalFn = setInterval,
272
+ clearIntervalFn = clearInterval,
273
+ onError = () => {},
274
+ } = {}) {
275
+ const normalizedShell = String(appShell || "").trim().toLowerCase();
276
+ if (normalizedShell !== "macos" && normalizedShell !== "windows") return null;
277
+
278
+ const sync = runSync || (async (args) => {
279
+ const { cmdSync } = require("./sync");
280
+ await cmdSync(args);
281
+ });
282
+ let inFlight = null;
283
+ const run = () => {
284
+ if (inFlight) return inFlight;
285
+ const pending = Promise.resolve()
286
+ .then(() => sync(["--auto", "--background", "--all-local-sources"]))
287
+ .catch((error) => {
288
+ try { onError(error); } catch (_e) {}
289
+ })
290
+ .finally(() => {
291
+ if (inFlight === pending) inFlight = null;
292
+ });
293
+ inFlight = pending;
294
+ return pending;
295
+ };
296
+
297
+ const timer = setIntervalFn(() => {
298
+ void run();
299
+ }, intervalMs);
300
+ timer?.unref?.();
301
+
302
+ return {
303
+ run,
304
+ stop() {
305
+ clearIntervalFn(timer);
306
+ },
307
+ };
308
+ }
309
+
251
310
  function findPidOnPort(port) {
252
311
  try {
253
312
  const out = cp.execFileSync("lsof", ["-ti", `tcp:${port}`], { encoding: "utf8", timeout: 5000 });
@@ -455,4 +514,6 @@ module.exports = {
455
514
  isRunningUnderWsl,
456
515
  resolveDefaultPort,
457
516
  shouldServeSpaFallback,
517
+ startNativeBackgroundSync,
518
+ NATIVE_BACKGROUND_SYNC_INTERVAL_MS,
458
519
  };
@@ -8,6 +8,7 @@ const { readJson } = require("../lib/fs");
8
8
  const { readCodexNotify, readEveryCodeNotify } = require("../lib/codex-config");
9
9
  const {
10
10
  isClaudeHookConfigured,
11
+ areClaudeUsageHooksConfigured,
11
12
  buildClaudeHookCommand,
12
13
  buildHookCommand,
13
14
  } = require("../lib/claude-config");
@@ -155,7 +156,7 @@ async function cmdStatus(argv = []) {
155
156
  const everyCodeNotify = await readEveryCodeNotify(codeConfigPath);
156
157
  const everyCodeConfigured =
157
158
  Array.isArray(everyCodeNotify) && everyCodeNotify.length > 0;
158
- const claudeHookConfigured = await isClaudeHookConfigured({
159
+ const claudeHookConfigured = await areClaudeUsageHooksConfigured({
159
160
  settingsPath: claudeSettingsPath,
160
161
  hookCommand: claudeHookCommand,
161
162
  });
@@ -172,6 +172,18 @@ const CLOUD_CONVERSATIONS_BACKFILL_KEY = "cloudConversationsBackfill_2026_06";
172
172
  // ~/.codex/archived_sessions/ which sync does not scan) — clearing its bucket
173
173
  // would lose that history (ref the v6 ground-truth-repair data-loss incident).
174
174
  const CODEX_RESCAN_DEDUP_REPAIR_KEY = "codexRescanDedupRepair_2026_06";
175
+ // One-time repair (#169 follow-up): forked Codex rollouts replay the parent
176
+ // session's token history into the child file, and until the same-day burst
177
+ // detector landed in rollout.js the parser counted those replayed rows as live
178
+ // usage (PR #169's date guard only caught cross-day forks). Cursors sit at EOF
179
+ // on every already-parsed file, so the forward fix alone never corrects the
180
+ // inflated history. This re-runs the exact #187 guarded rebuild under a new key
181
+ // — the rebuild re-parses every codex file with the CURRENT (fork-aware)
182
+ // parser, so replay rows are excluded — and inherits all its safety properties
183
+ // (unreproducible-session skip, atomic throwaway rebuild, queue strip, upload
184
+ // offset reset for the cloud overwrite). Pre-gated: installs with no forked
185
+ // rollout on disk carry no fork phantom and mark done without the rebuild.
186
+ const CODEX_FORK_REPLAY_REPAIR_KEY = "codexForkReplayRepair_2026_07";
175
187
  const DROID_DUP_SESSION_REPAIR_KEY = "droidDupSessionInflationRepair_2026_06";
176
188
  const CODEX_COLD_SCAN_AUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
177
189
  const CODEX_COLD_SCAN_AUDIT_MAX_SYNCS = 288;
@@ -330,11 +342,18 @@ async function cmdSync(argv) {
330
342
 
331
343
  const autoSourceScope = resolveAutoSourceScope(opts);
332
344
  const isBackgroundLightweightSync = opts.auto && opts.background && !opts.drain;
345
+ const isBackgroundAllLocalSync = isBackgroundLightweightSync && opts.allLocalSources;
333
346
  const isFullSourceScan = !autoSourceScope && !isBackgroundLightweightSync;
334
347
  const sourceAllowed = (...sources) => {
335
348
  if (isBackgroundLightweightSync) {
336
349
  if (autoSourceScope) {
337
- return BACKGROUND_AUTO_SYNC_SOURCES.has(autoSourceScope) && sources.includes(autoSourceScope);
350
+ return (
351
+ (isBackgroundAllLocalSync || BACKGROUND_AUTO_SYNC_SOURCES.has(autoSourceScope)) &&
352
+ sources.includes(autoSourceScope)
353
+ );
354
+ }
355
+ if (isBackgroundAllLocalSync) {
356
+ return sources.some((source) => AUTO_SYNC_SOURCES.has(source));
338
357
  }
339
358
  return sources.some((source) => BACKGROUND_AUTO_SYNC_SOURCES.has(source));
340
359
  }
@@ -398,13 +417,22 @@ async function cmdSync(argv) {
398
417
 
399
418
  if (isFullSourceScan) {
400
419
  await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
401
- await repairCodexRescanInflation({
420
+ const codexRescanRepairRan = await repairCodexRescanInflation({
421
+ cursors,
422
+ queuePath,
423
+ queueStatePath,
424
+ projectQueuePath,
425
+ projectQueueStatePath,
426
+ rolloutFiles,
427
+ });
428
+ await repairCodexForkReplayInflation({
402
429
  cursors,
403
430
  queuePath,
404
431
  queueStatePath,
405
432
  projectQueuePath,
406
433
  projectQueueStatePath,
407
434
  rolloutFiles,
435
+ legacyRepairRan: codexRescanRepairRan,
408
436
  });
409
437
  await repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath });
410
438
  await repairMimoClaudeMislabel({
@@ -1998,6 +2026,7 @@ function parseArgs(argv) {
1998
2026
  source: null,
1999
2027
  drain: false,
2000
2028
  background: false,
2029
+ allLocalSources: false,
2001
2030
  repairGrok: false,
2002
2031
  };
2003
2032
  for (let i = 0; i < argv.length; i++) {
@@ -2013,6 +2042,7 @@ function parseArgs(argv) {
2013
2042
  else if (a.startsWith("--source=")) out.source = normalizeSyncSource(a.slice("--source=".length));
2014
2043
  else if (a === "--drain") out.drain = true;
2015
2044
  else if (a === "--background" || a === "--lightweight") out.background = true;
2045
+ else if (a === "--all-local-sources") out.allLocalSources = true;
2016
2046
  else if (a === "--repair-grok") out.repairGrok = true;
2017
2047
  else throw new Error(`Unknown option: ${a}`);
2018
2048
  }
@@ -2088,6 +2118,7 @@ module.exports = {
2088
2118
  migrateCursorUnknownBuckets,
2089
2119
  migrateRolloutCumulativeDeltaBuckets,
2090
2120
  repairCodexRescanInflation,
2121
+ repairCodexForkReplayInflation,
2091
2122
  repairDroidDuplicateSessionInflation,
2092
2123
  repairMimoClaudeMislabel,
2093
2124
  reincludeClaudeMemObserverFiles,
@@ -2100,6 +2131,7 @@ module.exports = {
2100
2131
  CURSOR_UNKNOWN_MIGRATION_KEY,
2101
2132
  ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY,
2102
2133
  CODEX_RESCAN_DEDUP_REPAIR_KEY,
2134
+ CODEX_FORK_REPLAY_REPAIR_KEY,
2103
2135
  DROID_DUP_SESSION_REPAIR_KEY,
2104
2136
  CLAUDE_MEM_OBSERVER_REINCLUDE_KEY,
2105
2137
  GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY,
@@ -3148,6 +3180,11 @@ async function repairCodexRescanInflation({
3148
3180
  projectQueuePath,
3149
3181
  projectQueueStatePath,
3150
3182
  rolloutFiles,
3183
+ // The fork-replay repair (#169 follow-up) re-runs this exact rebuild under
3184
+ // its own key: the rebuild always uses the CURRENT parser, so any parser fix
3185
+ // shipped since the last run is applied to the rebuilt history.
3186
+ migrationKey = CODEX_RESCAN_DEDUP_REPAIR_KEY,
3187
+ uploadNote = "reset_after_codex_rescan_dedup_2026_06",
3151
3188
  }) {
3152
3189
  if (!cursors || typeof cursors !== "object") return false;
3153
3190
  const migrations = (cursors.migrations ||= {});
@@ -3158,7 +3195,7 @@ async function repairCodexRescanInflation({
3158
3195
  // found). Treating the skip sentinel as "done" is what left users like #187
3159
3196
  // permanently stuck on the inflated value after upgrading (the key was truthy
3160
3197
  // so the guard never got a second chance).
3161
- const priorRepair = migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY];
3198
+ const priorRepair = migrations[migrationKey];
3162
3199
  if (priorRepair && !(typeof priorRepair === "object" && priorRepair.skipped)) return false;
3163
3200
 
3164
3201
  // Codex session files THIS sync discovered (source === "codex").
@@ -3194,7 +3231,7 @@ async function repairCodexRescanInflation({
3194
3231
  if (codexFileSet.has(fp)) continue; // exact file re-scanned this run
3195
3232
  const id = codexSessionIdFromPath(fp);
3196
3233
  if (id && scannedSessionIds.has(id)) continue; // same session scanned elsewhere (moved)
3197
- migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY] = {
3234
+ migrations[migrationKey] = {
3198
3235
  skipped: true,
3199
3236
  reason: "codex_session_unreproducible",
3200
3237
  at: new Date().toISOString(),
@@ -3453,7 +3490,7 @@ async function repairCodexRescanInflation({
3453
3490
  }
3454
3491
  uploadState.offset = 0;
3455
3492
  uploadState.updatedAt = new Date().toISOString();
3456
- uploadState.note = "reset_after_codex_rescan_dedup_2026_06";
3493
+ uploadState.note = uploadNote;
3457
3494
  await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
3458
3495
  }
3459
3496
  if (projectRepairEnabled && typeof projectQueueStatePath === "string" && projectQueueStatePath) {
@@ -3465,14 +3502,140 @@ async function repairCodexRescanInflation({
3465
3502
  }
3466
3503
  uploadState.offset = 0;
3467
3504
  uploadState.updatedAt = new Date().toISOString();
3468
- uploadState.note = "reset_after_codex_rescan_dedup_2026_06";
3505
+ uploadState.note = uploadNote;
3469
3506
  await fs.writeFile(projectQueueStatePath, JSON.stringify(uploadState));
3470
3507
  }
3471
3508
 
3472
- migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY] = new Date().toISOString();
3509
+ migrations[migrationKey] = new Date().toISOString();
3473
3510
  return true;
3474
3511
  }
3475
3512
 
3513
+ // #169 follow-up: repair the historical inflation left by same-day forked
3514
+ // Codex rollout replays (see CODEX_FORK_REPLAY_REPAIR_KEY). Delegates to the
3515
+ // #187 guarded rebuild with the fork-repair key.
3516
+ async function repairCodexForkReplayInflation({
3517
+ cursors,
3518
+ queuePath,
3519
+ queueStatePath,
3520
+ projectQueuePath,
3521
+ projectQueueStatePath,
3522
+ rolloutFiles,
3523
+ // True when the #187 repair ran its rebuild earlier in THIS sync: that
3524
+ // rebuild already used the fork-aware parser, so the history is clean and a
3525
+ // second rebuild would be pure waste.
3526
+ legacyRepairRan = false,
3527
+ }) {
3528
+ if (!cursors || typeof cursors !== "object") return false;
3529
+ const migrations = (cursors.migrations ||= {});
3530
+ // Same completed-vs-skipped semantics as the #187 repair: an ISO string is
3531
+ // final, a {skipped:true} sentinel retries (the skip condition can clear).
3532
+ const prior = migrations[CODEX_FORK_REPLAY_REPAIR_KEY];
3533
+ if (prior && !(typeof prior === "object" && prior.skipped)) return false;
3534
+
3535
+ if (legacyRepairRan) {
3536
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = new Date().toISOString();
3537
+ return false;
3538
+ }
3539
+
3540
+ // Pre-gate: fork phantom can only exist on installs that have (or had) a
3541
+ // forked rollout. Scanning file heads is cheap; the rebuild re-parses every
3542
+ // codex file. Files forked AFTER the parser fix shipped never accrue
3543
+ // phantom, so a definitive "no forks" verdict may finalize — but ONLY when
3544
+ // this scan can actually stand for the install's whole codex history:
3545
+ // 1. every candidate head was readable (an unreadable candidate could be
3546
+ // the one forked rollout), AND
3547
+ // 2. every codex session recorded in cursors is covered by THIS scan's
3548
+ // file list (same UUID-keyed reproducibility rule as the #187 guard).
3549
+ // A forked rollout can be real yet absent from one run's rolloutFiles —
3550
+ // WSL/native split probing, a temporarily missing ~/.codex, a deleted
3551
+ // file whose phantom is already in history — and finalizing off such a
3552
+ // partial view would permanently foreclose the repair this migration
3553
+ // exists to perform, while the guarded repair itself would only have
3554
+ // deferred retryably.
3555
+ // Either failure defers with the retryable {skipped:true} sentinel.
3556
+ const forkScan = await scanForForkedCodexRollout(rolloutFiles);
3557
+ if (!forkScan.forked) {
3558
+ if (forkScan.indeterminate) {
3559
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = {
3560
+ skipped: true,
3561
+ reason: "fork_scan_indeterminate",
3562
+ at: new Date().toISOString(),
3563
+ };
3564
+ return false;
3565
+ }
3566
+ const scannedSessionIds = new Set();
3567
+ const scannedPaths = new Set();
3568
+ for (const entry of Array.isArray(rolloutFiles) ? rolloutFiles : []) {
3569
+ const fp = typeof entry === "string" ? entry : entry?.path;
3570
+ const src = typeof entry === "string" ? "codex" : String(entry?.source || "codex");
3571
+ if (!fp || src !== "codex") continue;
3572
+ scannedPaths.add(fp);
3573
+ const id = codexSessionIdFromPath(fp);
3574
+ if (id) scannedSessionIds.add(id);
3575
+ }
3576
+ if (cursors.files && typeof cursors.files === "object") {
3577
+ for (const fp of Object.keys(cursors.files)) {
3578
+ if (!isCodexSessionCursorPath(fp)) continue;
3579
+ if (scannedPaths.has(fp)) continue;
3580
+ const id = codexSessionIdFromPath(fp);
3581
+ if (id && scannedSessionIds.has(id)) continue;
3582
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = {
3583
+ skipped: true,
3584
+ reason: "codex_history_not_covered",
3585
+ at: new Date().toISOString(),
3586
+ };
3587
+ return false;
3588
+ }
3589
+ }
3590
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = new Date().toISOString();
3591
+ return false;
3592
+ }
3593
+
3594
+ return repairCodexRescanInflation({
3595
+ cursors,
3596
+ queuePath,
3597
+ queueStatePath,
3598
+ projectQueuePath,
3599
+ projectQueueStatePath,
3600
+ rolloutFiles,
3601
+ migrationKey: CODEX_FORK_REPLAY_REPAIR_KEY,
3602
+ uploadNote: "reset_after_codex_fork_replay_2026_07",
3603
+ });
3604
+ }
3605
+
3606
+ // Scans codex rollout heads for the fork marker. The child session_meta (with
3607
+ // forked_from_id) is the FIRST line of a forked rollout — observed at byte
3608
+ // offset ≤169 across every local sample — so a bounded head read is
3609
+ // sufficient. 64KB leaves ample margin for long base_instructions preceding
3610
+ // it; a substring false positive (the marker quoted in unrelated head content)
3611
+ // only costs one redundant guarded rebuild, never data. Tri-state result:
3612
+ // {forked:true} on a hit, {forked:false, indeterminate:false} only when every
3613
+ // candidate was inspected, {forked:false, indeterminate:true} when any head
3614
+ // read failed — the caller must not finalize "no forks" off a failed read.
3615
+ async function scanForForkedCodexRollout(rolloutFiles) {
3616
+ const HEAD_BYTES = 65536;
3617
+ let indeterminate = false;
3618
+ for (const entry of Array.isArray(rolloutFiles) ? rolloutFiles : []) {
3619
+ const fp = typeof entry === "string" ? entry : entry?.path;
3620
+ const src = typeof entry === "string" ? "codex" : String(entry?.source || "codex");
3621
+ if (!fp || src !== "codex") continue;
3622
+ let fh = null;
3623
+ try {
3624
+ fh = await fs.open(fp, "r");
3625
+ const buf = Buffer.alloc(HEAD_BYTES);
3626
+ const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
3627
+ if (buf.toString("utf8", 0, bytesRead).includes('"forked_from_id"')) {
3628
+ return { forked: true, indeterminate: false };
3629
+ }
3630
+ } catch (_e) {
3631
+ indeterminate = true;
3632
+ } finally {
3633
+ if (fh) await fh.close().catch(() => {});
3634
+ }
3635
+ }
3636
+ return { forked: false, indeterminate };
3637
+ }
3638
+
3476
3639
  function isCodexSessionCursorPath(filePath) {
3477
3640
  if (typeof filePath !== "string") return false;
3478
3641
  const normalized = filePath.replace(/\\/g, "/");
@@ -3,7 +3,12 @@ const path = require("node:path");
3
3
  const fs = require("node:fs/promises");
4
4
 
5
5
  const { restoreCodexNotify, restoreEveryCodeNotify } = require("../lib/codex-config");
6
- const { removeClaudeHook, buildClaudeHookCommand, buildHookCommand } = require("../lib/claude-config");
6
+ const {
7
+ removeClaudeHook,
8
+ removeClaudeUsageHooks,
9
+ buildClaudeHookCommand,
10
+ buildHookCommand,
11
+ } = require("../lib/claude-config");
7
12
  const {
8
13
  resolveGeminiConfigDir,
9
14
  resolveGeminiSettingsPath,
@@ -64,7 +69,10 @@ async function cmdUninstall(argv) {
64
69
  })
65
70
  : { restored: false, skippedReason: "config-missing" };
66
71
  const claudeRemove = claudeConfigExists
67
- ? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
72
+ ? await removeClaudeUsageHooks({
73
+ settingsPath: claudeSettingsPath,
74
+ hookCommand: claudeHookCommand,
75
+ })
68
76
  : { removed: false, skippedReason: "config-missing" };
69
77
  const codebuddyRemove = codebuddyConfigExists
70
78
  ? await removeClaudeHook({