tokentracker-cli 0.65.0 → 0.65.2

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 (54) hide show
  1. package/README.de.md +476 -0
  2. package/README.ja.md +1 -1
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/dashboard/dist/assets/{ActivityHeatmap-Ccf0I3uP.js → ActivityHeatmap-CggJI8SR.js} +1 -1
  7. package/dashboard/dist/assets/{Card-BSF8dYEt.js → Card-5au9t9nC.js} +1 -1
  8. package/dashboard/dist/assets/{ConfirmModal-Bjp6eg2f.js → ConfirmModal-DdczuiTm.js} +1 -1
  9. package/dashboard/dist/assets/{DashboardPage-BInQkoDe.js → DashboardPage-VAYxz_kf.js} +2 -2
  10. package/dashboard/dist/assets/{DevicePage-B0yxbDDR.js → DevicePage-DQJlqpU3.js} +1 -1
  11. package/dashboard/dist/assets/{DialogTitle-uw-Oa5Qv.js → DialogTitle-DR64sBQk.js} +1 -1
  12. package/dashboard/dist/assets/{FadeIn-DPIBqXRl.js → FadeIn-CGzcyAaJ.js} +1 -1
  13. package/dashboard/dist/assets/{HeaderGithubStar-DDXfJ1uC.js → HeaderGithubStar-DiJi3EYZ.js} +1 -1
  14. package/dashboard/dist/assets/{IpCheckPage-KC93tPlt.js → IpCheckPage-CoGzED0h.js} +1 -1
  15. package/dashboard/dist/assets/{LandingPage-Itx2uPWI.js → LandingPage-D_h4MI77.js} +1 -1
  16. package/dashboard/dist/assets/{LeaderboardAvatar-Bv44b_Kl.js → LeaderboardAvatar-BuSHrfaq.js} +1 -1
  17. package/dashboard/dist/assets/{LeaderboardPage-DDq5gzyz.js → LeaderboardPage-aP0oQNBq.js} +2 -2
  18. package/dashboard/dist/assets/{LeaderboardProfileModal-LQyYrCf1.js → LeaderboardProfileModal-zWSPrjXm.js} +1 -1
  19. package/dashboard/dist/assets/{LeaderboardProfilePage-3you3mbA.js → LeaderboardProfilePage-BNR__NEp.js} +1 -1
  20. package/dashboard/dist/assets/{LimitsPage-CrLvR8vE.js → LimitsPage-CbwEV4tL.js} +1 -1
  21. package/dashboard/dist/assets/{LocalOnlyNotice-Di1gnfIi.js → LocalOnlyNotice-DFxhTdeP.js} +1 -1
  22. package/dashboard/dist/assets/{LoginPage-f9QHz_uZ.js → LoginPage-6m5w2iay.js} +1 -1
  23. package/dashboard/dist/assets/{PopoverPopup-7rjAxAqk.js → PopoverPopup-6bH8WcTu.js} +1 -1
  24. package/dashboard/dist/assets/{ResetPasswordPage-DKA7HGpJ.js → ResetPasswordPage-ByOumS6-.js} +1 -1
  25. package/dashboard/dist/assets/{Select-2gikPo1a.js → Select-DrNJJE2o.js} +1 -1
  26. package/dashboard/dist/assets/{SelectItemText-DZx33XF1.js → SelectItemText-BTHh_2vw.js} +1 -1
  27. package/dashboard/dist/assets/SettingsPage-BIxrrxUF.js +1 -0
  28. package/dashboard/dist/assets/{SkillsPage-B6BJ9UZp.js → SkillsPage-Cg8iXYaS.js} +1 -1
  29. package/dashboard/dist/assets/{WidgetsPage-D8p3Tx7E.js → WidgetsPage-Bi8d2YwV.js} +1 -1
  30. package/dashboard/dist/assets/{WrappedPage-cXEfhti9.js → WrappedPage-Cc2kLDpE.js} +1 -1
  31. package/dashboard/dist/assets/{agent-logos-D3iFlaWg.js → agent-logos-CzNQ3IaQ.js} +1 -1
  32. package/dashboard/dist/assets/{arrow-up-right-D87Aw_ZN.js → arrow-up-right-FgMhJ3Pu.js} +1 -1
  33. package/dashboard/dist/assets/{download-BtcEs1OB.js → download-Bo9t1yfc.js} +1 -1
  34. package/dashboard/dist/assets/{main-DqnXui98.js → main-6K89gDOX.js} +21 -20
  35. package/dashboard/dist/assets/{main-CGx8BJ4O.css → main-BHwig9Um.css} +1 -1
  36. package/dashboard/dist/assets/{use-limits-display-prefs-DAxlRSu2.js → use-limits-display-prefs-DN1nv1HE.js} +1 -1
  37. package/dashboard/dist/assets/{use-native-settings-CgvB_B6W.js → use-native-settings-6ygLVyJu.js} +1 -1
  38. package/dashboard/dist/assets/{use-ordered-list-DOfX6aSy.js → use-ordered-list-li19cWJh.js} +1 -1
  39. package/dashboard/dist/assets/{use-quality-per-dollar-pref-kEbwQwk2.js → use-quality-per-dollar-pref-rNMT2kff.js} +1 -1
  40. package/dashboard/dist/assets/{use-usage-limits-la-KPfJj.js → use-usage-limits-kVHyIhp3.js} +1 -1
  41. package/dashboard/dist/assets/{useCurrency-LDRg7ST0.js → useCurrency-g0G5pwGx.js} +1 -1
  42. package/dashboard/dist/assets/{useScrollLock-lP1Is9IY.js → useScrollLock-Dg7JyjFK.js} +1 -1
  43. package/dashboard/dist/index.html +2 -2
  44. package/dashboard/dist/share.html +2 -2
  45. package/package.json +1 -1
  46. package/src/commands/init.js +3 -2
  47. package/src/commands/serve.js +59 -1
  48. package/src/commands/status.js +26 -17
  49. package/src/commands/sync.js +672 -98
  50. package/src/lib/openclaw-session-plugin.js +2 -2
  51. package/src/lib/pricing/seed-snapshot.json +1 -1
  52. package/src/lib/rollout.js +602 -178
  53. package/src/lib/wsl-probe.js +181 -0
  54. package/dashboard/dist/assets/SettingsPage-CwHPmuZV.js +0 -1
@@ -10,6 +10,7 @@ const {
10
10
  listRolloutFiles,
11
11
  listRolloutFilesDeep,
12
12
  codexSessionIdFromPath,
13
+ filterColdCodexRolloutFiles,
13
14
  listClaudeProjectFiles,
14
15
  listGeminiSessionFiles,
15
16
  listOpencodeMessageFiles,
@@ -152,11 +153,50 @@ const CLOUD_CONVERSATIONS_BACKFILL_KEY = "cloudConversationsBackfill_2026_06";
152
153
  // would lose that history (ref the v6 ground-truth-repair data-loss incident).
153
154
  const CODEX_RESCAN_DEDUP_REPAIR_KEY = "codexRescanDedupRepair_2026_06";
154
155
  const DROID_DUP_SESSION_REPAIR_KEY = "droidDupSessionInflationRepair_2026_06";
156
+ const CODEX_COLD_SCAN_AUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
157
+ const CODEX_COLD_SCAN_AUDIT_MAX_SYNCS = 288;
155
158
  // 0.57.0 mis-attributed mimocode's mirrored Claude/claude-mem history to
156
159
  // source=mimo (read the whole DB instead of only providerID=mimo rows). This
157
160
  // one-time repair purges all source=mimo data from the local queues + cursor
158
161
  // state so the next sync (providerID-filtered reader) rebuilds it correctly.
159
162
  const MIMO_PROVIDER_REPAIR_KEY = "mimoClaudeMislabelRepair_2026_06";
163
+ const AUTO_SYNC_SOURCE_ALIASES = new Map([
164
+ ["code", "every-code"],
165
+ ["everycode", "every-code"],
166
+ ["kilo", "kilo-cli"],
167
+ ["kilo-code", "kilocode"],
168
+ ["kimi_code", "kimi-code"],
169
+ ["roo-code", "roocode"],
170
+ ]);
171
+ const AUTO_SYNC_SOURCES = new Set([
172
+ "antigravity",
173
+ "claude",
174
+ "codebuddy",
175
+ "codex",
176
+ "copilot",
177
+ "craft",
178
+ "cursor",
179
+ "droid",
180
+ "every-code",
181
+ "gemini",
182
+ "goose",
183
+ "grok",
184
+ "hermes",
185
+ "kilo-cli",
186
+ "kilocode",
187
+ "kiro",
188
+ "kimi",
189
+ "kimi-code",
190
+ "mimo",
191
+ "omp",
192
+ "opencode",
193
+ "openclaw",
194
+ "pi",
195
+ "roocode",
196
+ "workbuddy",
197
+ "zcode",
198
+ "zed",
199
+ ]);
160
200
 
161
201
  function warnProviderParseFailure(label, err, opts) {
162
202
  if (opts?.auto) return;
@@ -218,48 +258,88 @@ async function cmdSync(argv) {
218
258
  const mimoHome = process.env.MIMO_HOME || path.join(xdgDataHome, "mimocode");
219
259
  const zcodeHome = process.env.ZCODE_HOME || path.join(home, ".zcode");
220
260
 
221
- // OpenClaw session plugin integration: allow the plugin to request incremental parsing for a single session jsonl.
222
- // We still parse all regular sources so model/source attribution stays complete (e.g. Kimi sessions).
261
+ // OpenClaw session plugin integration: lifecycle hooks request an
262
+ // OpenClaw-only auto sync so unrelated providers do not get walked.
223
263
  const openclawSignal = opts.fromOpenclaw
224
264
  ? resolveOpenclawSignal({ home, env: process.env })
225
265
  : null;
226
266
 
227
- const sources = [
228
- { source: "codex", sessionsDir: path.join(codexHome, "sessions") },
229
- // Codex-Manager archives sessions to ~/.codex/archived_sessions/ on every
230
- // account/channel switch (issue #187). Scanning it too keeps that usage
231
- // counted instead of orphaning it in the cloud (a re-upload's upsert can
232
- // never delete cloud rows whose local source file has vanished). Safe
233
- // against double-counting: the codex event dedup keys on sessionUUID (in
234
- // the filename) + event timestamp, both stable across a sessions/ ->
235
- // archived_sessions/ move, so re-reading an archived copy is a no-op.
236
- { source: "codex", sessionsDir: path.join(codexHome, "archived_sessions"), deep: true },
237
- { source: "every-code", sessionsDir: path.join(codeHome, "sessions") },
238
- ];
267
+ const autoSourceScope = resolveAutoSourceScope(opts);
268
+ const isFullSourceScan = !autoSourceScope;
269
+ const sourceAllowed = (...sources) =>
270
+ !autoSourceScope || sources.includes(autoSourceScope);
271
+
272
+ const sources = [];
273
+ if (sourceAllowed("codex")) {
274
+ sources.push(
275
+ { source: "codex", sessionsDir: path.join(codexHome, "sessions"), codexInventoryCache: true },
276
+ // Codex-Manager archives sessions to ~/.codex/archived_sessions/ on every
277
+ // account/channel switch (issue #187). Scanning it too keeps that usage
278
+ // counted instead of orphaning it in the cloud (a re-upload's upsert can
279
+ // never delete cloud rows whose local source file has vanished). Safe
280
+ // against double-counting: the codex event dedup keys on sessionUUID (in
281
+ // the filename) + event timestamp, both stable across a sessions/ ->
282
+ // archived_sessions/ move, so re-reading an archived copy is a no-op.
283
+ { source: "codex", sessionsDir: path.join(codexHome, "archived_sessions"), deep: true },
284
+ );
285
+ }
286
+ if (sourceAllowed("every-code")) {
287
+ sources.push({ source: "every-code", sessionsDir: path.join(codeHome, "sessions") });
288
+ }
239
289
 
240
290
  const rolloutFiles = [];
241
291
  const seenSessions = new Set();
292
+ const codexDayInventoryCache =
293
+ cursors.codexDayInventoryCache && typeof cursors.codexDayInventoryCache === "object"
294
+ ? cursors.codexDayInventoryCache
295
+ : { version: 1, days: {} };
296
+ if (sourceAllowed("codex")) cursors.codexDayInventoryCache = codexDayInventoryCache;
242
297
  for (const entry of sources) {
243
298
  if (seenSessions.has(entry.sessionsDir)) continue;
244
299
  seenSessions.add(entry.sessionsDir);
245
300
  const files = entry.deep
246
301
  ? await listRolloutFilesDeep(entry.sessionsDir)
247
- : await listRolloutFiles(entry.sessionsDir);
302
+ : await listRolloutFiles(entry.sessionsDir, entry.codexInventoryCache
303
+ ? { dayInventoryCache: codexDayInventoryCache }
304
+ : undefined);
248
305
  for (const filePath of files) {
249
306
  rolloutFiles.push({ path: filePath, source: entry.source });
250
307
  }
251
308
  }
252
309
 
253
- await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
254
- await repairCodexRescanInflation({ cursors, queuePath, queueStatePath, rolloutFiles });
255
- await repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath });
256
- await repairMimoClaudeMislabel({
257
- cursors,
258
- queuePath,
259
- queueStatePath,
260
- projectQueuePath,
261
- projectQueueStatePath,
262
- });
310
+ if (isFullSourceScan) {
311
+ await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
312
+ await repairCodexRescanInflation({
313
+ cursors,
314
+ queuePath,
315
+ queueStatePath,
316
+ projectQueuePath,
317
+ projectQueueStatePath,
318
+ rolloutFiles,
319
+ });
320
+ await repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath });
321
+ await repairMimoClaudeMislabel({
322
+ cursors,
323
+ queuePath,
324
+ queueStatePath,
325
+ projectQueuePath,
326
+ projectQueueStatePath,
327
+ });
328
+ }
329
+
330
+ const codexColdSkipEnabled = opts.auto && isFullSourceScan && sourceAllowed("codex");
331
+ const codexColdAuditDue = codexColdSkipEnabled
332
+ ? isCodexColdScanAuditDue(cursors)
333
+ : false;
334
+ const codexColdFilter = codexColdSkipEnabled
335
+ ? await filterColdCodexRolloutFiles({
336
+ rolloutFiles,
337
+ cursors,
338
+ projectEnabled: true,
339
+ auditDue: codexColdAuditDue,
340
+ })
341
+ : { rolloutFiles, skipped: 0 };
342
+ const rolloutFilesForParse = codexColdFilter.rolloutFiles;
263
343
 
264
344
  const openclawFiles = openclawSignal?.sessionFile
265
345
  ? [{ path: openclawSignal.sessionFile, source: "openclaw" }]
@@ -267,14 +347,15 @@ async function cmdSync(argv) {
267
347
 
268
348
  if (progress?.enabled) {
269
349
  progress.start(
270
- `Parsing ${renderBar(0)} 0/${formatNumber(rolloutFiles.length)} files | buckets 0`,
350
+ `Parsing ${renderBar(0)} 0/${formatNumber(rolloutFilesForParse.length)} files | buckets 0`,
271
351
  );
272
352
  }
273
353
 
274
354
  let parseResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
355
+ let codexParseSucceeded = false;
275
356
  try {
276
357
  parseResult = await parseRolloutIncremental({
277
- rolloutFiles,
358
+ rolloutFiles: rolloutFilesForParse,
278
359
  cursors,
279
360
  queuePath,
280
361
  projectQueuePath,
@@ -288,12 +369,19 @@ async function cmdSync(argv) {
288
369
  );
289
370
  },
290
371
  });
372
+ codexParseSucceeded = true;
291
373
  } catch (err) {
292
374
  warnProviderParseFailure("Codex", err, opts);
293
375
  }
376
+ if (codexColdSkipEnabled && codexParseSucceeded) {
377
+ recordCodexColdScanAudit(cursors, {
378
+ fullAudit: codexColdAuditDue,
379
+ skipped: codexColdFilter.skipped,
380
+ });
381
+ }
294
382
 
295
383
  let openclawResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
296
- if (openclawFiles.length > 0) {
384
+ if (sourceAllowed("openclaw") && openclawFiles.length > 0) {
297
385
  // Only runs when explicitly triggered by the OpenClaw session plugin.
298
386
  try {
299
387
  openclawResult = await parseOpenclawIncremental({
@@ -309,30 +397,34 @@ async function cmdSync(argv) {
309
397
  }
310
398
 
311
399
  let openclawFallback = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
312
- try {
313
- openclawFallback = await applyOpenclawTotalsFallback({
314
- trackerDir,
315
- signal: openclawSignal,
316
- cursors,
317
- queuePath,
318
- projectQueuePath,
319
- });
320
- } catch (err) {
321
- warnProviderParseFailure("OpenClaw", err, opts);
400
+ if (sourceAllowed("openclaw")) {
401
+ try {
402
+ openclawFallback = await applyOpenclawTotalsFallback({
403
+ trackerDir,
404
+ signal: openclawSignal,
405
+ cursors,
406
+ queuePath,
407
+ projectQueuePath,
408
+ });
409
+ } catch (err) {
410
+ warnProviderParseFailure("OpenClaw", err, opts);
411
+ }
322
412
  }
323
413
  openclawResult.filesProcessed += openclawFallback.filesProcessed;
324
414
  openclawResult.eventsAggregated += openclawFallback.eventsAggregated;
325
415
  openclawResult.bucketsQueued += openclawFallback.bucketsQueued;
326
416
 
327
- const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
328
- await reincludeClaudeMemObserverFiles({ cursors, claudeFiles, queuePath, queueStatePath });
329
- await repairClaudeQueueFromGroundTruth({
330
- cursors,
331
- queuePath,
332
- queueStatePath,
333
- projectQueuePath,
334
- projectQueueStatePath,
335
- });
417
+ const claudeFiles = sourceAllowed("claude") ? await listClaudeProjectFiles(claudeProjectsDir) : [];
418
+ if (isFullSourceScan) {
419
+ await reincludeClaudeMemObserverFiles({ cursors, claudeFiles, queuePath, queueStatePath });
420
+ await repairClaudeQueueFromGroundTruth({
421
+ cursors,
422
+ queuePath,
423
+ queueStatePath,
424
+ projectQueuePath,
425
+ projectQueueStatePath,
426
+ });
427
+ }
336
428
  let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
337
429
  if (claudeFiles.length > 0) {
338
430
  if (progress?.enabled) {
@@ -362,7 +454,7 @@ async function cmdSync(argv) {
362
454
  }
363
455
  }
364
456
 
365
- const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
457
+ const geminiFiles = sourceAllowed("gemini") ? await listGeminiSessionFiles(geminiTmpDir) : [];
366
458
  let geminiResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
367
459
  if (geminiFiles.length > 0) {
368
460
  if (progress?.enabled) {
@@ -392,7 +484,7 @@ async function cmdSync(argv) {
392
484
  }
393
485
  }
394
486
 
395
- const antigravityFiles = await listAntigravityTranscripts(geminiHome);
487
+ const antigravityFiles = sourceAllowed("antigravity") ? await listAntigravityTranscripts(geminiHome) : [];
396
488
  let antigravityResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
397
489
  if (antigravityFiles.length > 0) {
398
490
  if (progress?.enabled) {
@@ -422,7 +514,7 @@ async function cmdSync(argv) {
422
514
  }
423
515
  }
424
516
 
425
- const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
517
+ const opencodeFiles = sourceAllowed("opencode") ? await listOpencodeMessageFiles(opencodeStorageDir) : [];
426
518
  let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
427
519
  if (opencodeFiles.length > 0) {
428
520
  if (progress?.enabled) {
@@ -455,7 +547,7 @@ async function cmdSync(argv) {
455
547
  // OpenCode v1.2+ stores messages in SQLite (opencode.db) instead of JSON files.
456
548
  const opencodeDbPath = path.join(opencodeHome, "opencode.db");
457
549
  let opencodeDbResult = { messagesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
458
- const dbMessages = readOpencodeDbMessages(opencodeDbPath);
550
+ const dbMessages = sourceAllowed("opencode") ? readOpencodeDbMessages(opencodeDbPath) : [];
459
551
  if (dbMessages.length > 0) {
460
552
  if (progress?.enabled) {
461
553
  progress.start(
@@ -493,7 +585,7 @@ async function cmdSync(argv) {
493
585
  // the message indexes don't collide.
494
586
  const kiloDbPath = path.join(kiloHome, "kilo.db");
495
587
  let kiloResult = { messagesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
496
- const kiloDbMessages = readOpencodeDbMessages(kiloDbPath);
588
+ const kiloDbMessages = sourceAllowed("kilo-cli") ? readOpencodeDbMessages(kiloDbPath) : [];
497
589
  if (kiloDbMessages.length > 0) {
498
590
  if (progress?.enabled) {
499
591
  progress.start(
@@ -533,7 +625,7 @@ async function cmdSync(argv) {
533
625
  // double-count and mislabel Claude usage as mimo.
534
626
  const mimoDbPath = path.join(mimoHome, "mimocode.db");
535
627
  let mimoResult = { messagesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
536
- const mimoDbMessages = readMimoDbMessages(mimoDbPath);
628
+ const mimoDbMessages = sourceAllowed("mimo") ? readMimoDbMessages(mimoDbPath) : [];
537
629
  if (mimoDbMessages.length > 0) {
538
630
  if (progress?.enabled) {
539
631
  progress.start(
@@ -574,7 +666,7 @@ async function cmdSync(argv) {
574
666
  // would double-count.
575
667
  const zcodeDbPath = path.join(zcodeHome, "cli", "db", "db.sqlite");
576
668
  let zcodeResult = { messagesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
577
- const zcodeDbMessages = readZcodeDbMessages(zcodeDbPath);
669
+ const zcodeDbMessages = sourceAllowed("zcode") ? readZcodeDbMessages(zcodeDbPath) : [];
578
670
  if (zcodeDbMessages.length > 0) {
579
671
  if (progress?.enabled) {
580
672
  progress.start(
@@ -605,7 +697,7 @@ async function cmdSync(argv) {
605
697
  }
606
698
 
607
699
  // ── Kilo Code VS Code extension (Cline-style ui_messages.json) ──
608
- const kilocodeTaskFiles = resolveKilocodeTaskFiles(process.env);
700
+ const kilocodeTaskFiles = sourceAllowed("kilocode") ? resolveKilocodeTaskFiles(process.env) : [];
609
701
  let kilocodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
610
702
  if (kilocodeTaskFiles.length > 0) {
611
703
  if (progress?.enabled) {
@@ -636,7 +728,7 @@ async function cmdSync(argv) {
636
728
  // ── Goose (Block) — SQLite sessions with cumulative tokens per session ──
637
729
  const gooseDbPath = resolveGooseDbPath(process.env);
638
730
  let gooseResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
639
- if (fssync.existsSync(gooseDbPath)) {
731
+ if (sourceAllowed("goose") && gooseDbPath && fssync.existsSync(gooseDbPath)) {
640
732
  if (progress?.enabled) {
641
733
  progress.start(`Parsing Goose ${renderBar(0)} 0 sessions | buckets 0`);
642
734
  }
@@ -661,7 +753,7 @@ async function cmdSync(argv) {
661
753
  }
662
754
 
663
755
  // ── Droid (Factory CLI) — passive reader for ~/.factory/sessions/*.settings.json ──
664
- const droidSettingsFiles = listDroidSettingsFiles(process.env);
756
+ const droidSettingsFiles = sourceAllowed("droid") ? listDroidSettingsFiles(process.env) : [];
665
757
  let droidResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
666
758
  if (droidSettingsFiles.length > 0) {
667
759
  if (progress?.enabled) {
@@ -696,7 +788,7 @@ async function cmdSync(argv) {
696
788
  // ── Zed Agent (all providers; cumulative-delta over SQLite threads) ──
697
789
  const zedDbPath = resolveZedDbPath(process.env);
698
790
  let zedResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
699
- if (fssync.existsSync(zedDbPath)) {
791
+ if (sourceAllowed("zed") && zedDbPath && fssync.existsSync(zedDbPath)) {
700
792
  if (progress?.enabled) {
701
793
  progress.start(`Parsing Zed Agent ${renderBar(0)} 0 threads | buckets 0`);
702
794
  }
@@ -721,7 +813,7 @@ async function cmdSync(argv) {
721
813
  }
722
814
 
723
815
  // ── Roo Code VS Code extension (Cline-derived; rooveterinaryinc.roo-cline) ──
724
- const roocodeTaskFiles = resolveRoocodeTaskFiles(process.env);
816
+ const roocodeTaskFiles = sourceAllowed("roocode") ? resolveRoocodeTaskFiles(process.env) : [];
725
817
  let roocodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
726
818
  if (roocodeTaskFiles.length > 0) {
727
819
  if (progress?.enabled) {
@@ -755,10 +847,12 @@ async function cmdSync(argv) {
755
847
  // cursor records under model="unknown". Purge those local buckets, emit
756
848
  // zero retractions so the cloud upserts overwrite them to zero, and reset
757
849
  // the incremental cursor so the fixed parser re-fetches all affected rows.
758
- await migrateCursorUnknownBuckets({ cursors, queuePath });
850
+ if (isFullSourceScan) {
851
+ await migrateCursorUnknownBuckets({ cursors, queuePath });
852
+ }
759
853
 
760
854
  let cursorResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
761
- if (isCursorInstalled({ home })) {
855
+ if (sourceAllowed("cursor") && isCursorInstalled({ home })) {
762
856
  const cursorAuth = extractCursorSessionToken({ home });
763
857
  if (cursorAuth) {
764
858
  try {
@@ -801,7 +895,7 @@ async function cmdSync(argv) {
801
895
  let kiroResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
802
896
  const kiroDbPath = resolveKiroDbPath();
803
897
  const kiroJsonlPath = resolveKiroJsonlPath();
804
- if (fssync.existsSync(kiroDbPath) || fssync.existsSync(kiroJsonlPath)) {
898
+ if (sourceAllowed("kiro") && (fssync.existsSync(kiroDbPath) || fssync.existsSync(kiroJsonlPath))) {
805
899
  if (progress?.enabled) {
806
900
  progress.start(`Parsing Kiro ${renderBar(0)} | buckets 0`);
807
901
  }
@@ -827,7 +921,7 @@ async function cmdSync(argv) {
827
921
  // ── Hermes Agent (SQLite-based) ──
828
922
  let hermesResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
829
923
  const hermesPath = resolveHermesPath();
830
- if (fssync.existsSync(hermesPath)) {
924
+ if (sourceAllowed("hermes") && hermesPath && fssync.existsSync(hermesPath)) {
831
925
  if (progress?.enabled) {
832
926
  progress.start(`Parsing Hermes ${renderBar(0)} | buckets 0`);
833
927
  }
@@ -858,8 +952,8 @@ async function cmdSync(argv) {
858
952
  // 4 chars/token from user prompt chars and assistant response chars.
859
953
  let kiroCliResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
860
954
  const kiroCliDb = resolveKiroCliDbPath(process.env);
861
- const kiroCliSessionFiles = resolveKiroCliSessionFiles(process.env);
862
- if (fssync.existsSync(kiroCliDb) || kiroCliSessionFiles.length > 0) {
955
+ const kiroCliSessionFiles = sourceAllowed("kiro") ? resolveKiroCliSessionFiles(process.env) : [];
956
+ if (sourceAllowed("kiro") && (fssync.existsSync(kiroCliDb) || kiroCliSessionFiles.length > 0)) {
863
957
  if (progress?.enabled) {
864
958
  progress.start(`Parsing Kiro CLI ${renderBar(0)} | buckets 0`);
865
959
  }
@@ -885,7 +979,7 @@ async function cmdSync(argv) {
885
979
 
886
980
  // ── Kimi (passive wire.jsonl reader) ──
887
981
  let kimiResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
888
- const kimiWireFiles = resolveKimiWireFiles(process.env);
982
+ const kimiWireFiles = sourceAllowed("kimi") ? resolveKimiWireFiles(process.env) : [];
889
983
  if (kimiWireFiles.length > 0) {
890
984
  if (progress?.enabled) {
891
985
  progress.start(`Parsing Kimi Code ${renderBar(0)} | buckets 0`);
@@ -911,7 +1005,7 @@ async function cmdSync(argv) {
911
1005
 
912
1006
  // ── Kimi Code official (@moonshot-ai/kimi-code, ~/.kimi-code) ──
913
1007
  let kimiCodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
914
- const kimiCodeWireFiles = resolveKimiCodeWireFiles(process.env);
1008
+ const kimiCodeWireFiles = sourceAllowed("kimi-code") ? resolveKimiCodeWireFiles(process.env) : [];
915
1009
  if (kimiCodeWireFiles.length > 0) {
916
1010
  if (progress?.enabled) {
917
1011
  progress.start(`Parsing Kimi Code (official) ${renderBar(0)} | buckets 0`);
@@ -939,7 +1033,7 @@ async function cmdSync(argv) {
939
1033
  // Tencent's CodeBuddy CLI is a Claude Code clone; no hook system, so we
940
1034
  // tail the per-session JSONL conversation logs incrementally on each sync.
941
1035
  let codebuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
942
- const codebuddyFiles = resolveCodebuddyProjectFiles(process.env);
1036
+ const codebuddyFiles = sourceAllowed("codebuddy") ? resolveCodebuddyProjectFiles(process.env) : [];
943
1037
  if (codebuddyFiles.length > 0) {
944
1038
  if (progress?.enabled) {
945
1039
  progress.start(`Parsing CodeBuddy ${renderBar(0)} | buckets 0`);
@@ -969,7 +1063,7 @@ async function cmdSync(argv) {
969
1063
  // sub-agent logs nest one level deeper, so the resolver recurses. See the
970
1064
  // parser comment in rollout.js for the cache-aware token math.
971
1065
  let workbuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
972
- const workbuddyFiles = resolveWorkbuddyProjectFiles(process.env);
1066
+ const workbuddyFiles = sourceAllowed("workbuddy") ? resolveWorkbuddyProjectFiles(process.env) : [];
973
1067
  if (workbuddyFiles.length > 0) {
974
1068
  if (progress?.enabled) {
975
1069
  progress.start(`Parsing WorkBuddy ${renderBar(0)} | buckets 0`);
@@ -995,7 +1089,7 @@ async function cmdSync(argv) {
995
1089
 
996
1090
  // ── oh-my-pi (passive ~/.omp/agent/sessions/**/*.jsonl reader) ──
997
1091
  let ompResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
998
- const ompFiles = resolveOmpSessionFiles(process.env);
1092
+ const ompFiles = sourceAllowed("omp") ? resolveOmpSessionFiles(process.env) : [];
999
1093
  if (ompFiles.length > 0) {
1000
1094
  if (progress?.enabled) {
1001
1095
  progress.start(`Parsing oh-my-pi ${renderBar(0)} | buckets 0`);
@@ -1024,7 +1118,7 @@ async function cmdSync(argv) {
1024
1118
  // prevents double-counting when explicit overrides (TOKENTRACKER_OMP_AGENT_DIR /
1025
1119
  // TOKENTRACKER_PI_AGENT_DIR) bypass the install-signal disambiguator.
1026
1120
  let piResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1027
- const piFiles = piAgentDirCollidesWithOmp(process.env)
1121
+ const piFiles = !sourceAllowed("pi") || piAgentDirCollidesWithOmp(process.env)
1028
1122
  ? []
1029
1123
  : resolvePiSessionFiles(process.env);
1030
1124
  if (piFiles.length > 0) {
@@ -1052,7 +1146,7 @@ async function cmdSync(argv) {
1052
1146
 
1053
1147
  // ── Craft Agents (passive ~/.craft-agent + workspaces session.jsonl reader) ──
1054
1148
  let craftResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1055
- const craftFiles = resolveCraftSessionFiles(process.env);
1149
+ const craftFiles = sourceAllowed("craft") ? resolveCraftSessionFiles(process.env) : [];
1056
1150
  if (craftFiles.length > 0) {
1057
1151
  if (progress?.enabled) {
1058
1152
  progress.start(`Parsing Craft ${renderBar(0)} | buckets 0`);
@@ -1079,9 +1173,9 @@ async function cmdSync(argv) {
1079
1173
  // ── Grok Build (xAI) ──
1080
1174
  let grokResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1081
1175
  // Full passive scan of all Grok sessions (historical + any not covered by hook)
1082
- const grokSessions = resolveGrokBuildSessions(process.env);
1176
+ const grokSessions = sourceAllowed("grok") ? resolveGrokBuildSessions(process.env) : [];
1083
1177
  const grokSessionInputs = [...grokSessions];
1084
- if (grokHookSignal && typeof grokHookSignal === "object") {
1178
+ if (sourceAllowed("grok") && grokHookSignal && typeof grokHookSignal === "object") {
1085
1179
  const hookSessionId =
1086
1180
  typeof grokHookSignal.sessionId === "string" && grokHookSignal.sessionId.trim()
1087
1181
  ? grokHookSignal.sessionId.trim()
@@ -1146,13 +1240,13 @@ async function cmdSync(argv) {
1146
1240
  bucketsQueued: grokResult.bucketsQueued + grokScanResult.bucketsQueued,
1147
1241
  };
1148
1242
  }
1149
- if (opts.repairGrok) {
1243
+ if (isFullSourceScan && opts.repairGrok) {
1150
1244
  await repairGrokQueueFromSessionSnapshots({ cursors, queuePath, queueStatePath });
1151
1245
  }
1152
1246
 
1153
1247
  // ── GitHub Copilot CLI (OTEL JSONL files) ──
1154
1248
  let copilotResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1155
- const copilotPaths = resolveCopilotOtelPaths(process.env);
1249
+ const copilotPaths = sourceAllowed("copilot") ? resolveCopilotOtelPaths(process.env) : [];
1156
1250
  if (copilotPaths.length > 0) {
1157
1251
  if (progress?.enabled) {
1158
1252
  progress.start(`Parsing Copilot ${renderBar(0)} | buckets 0`);
@@ -1176,7 +1270,7 @@ async function cmdSync(argv) {
1176
1270
  }
1177
1271
  }
1178
1272
 
1179
- if (cursors?.projectHourly?.projects && projectQueuePath && projectQueueStatePath) {
1273
+ if (isFullSourceScan && cursors?.projectHourly?.projects && projectQueuePath && projectQueueStatePath) {
1180
1274
  for (const [projectKey, meta] of Object.entries(cursors.projectHourly.projects)) {
1181
1275
  if (!meta || typeof meta !== "object") continue;
1182
1276
  if (meta.status !== "blocked" || !meta.purge_pending) continue;
@@ -1192,7 +1286,9 @@ async function cmdSync(argv) {
1192
1286
  }
1193
1287
  }
1194
1288
 
1195
- await applyCloudConversationsBackfill({ cursors, queueStatePath });
1289
+ if (isFullSourceScan) {
1290
+ await applyCloudConversationsBackfill({ cursors, queueStatePath });
1291
+ }
1196
1292
 
1197
1293
  cursors.updatedAt = new Date().toISOString();
1198
1294
  await writeJson(cursorsPath, cursors);
@@ -1253,11 +1349,10 @@ async function cmdSync(argv) {
1253
1349
 
1254
1350
  const afterState = (await readJson(queueStatePath)) || { offset: 0 };
1255
1351
  const queueSize = await safeStatSize(queuePath);
1256
- const projectAfterState = (await readJson(projectQueueStatePath)) || { offset: 0 };
1257
- const projectQueueSize = await safeStatSize(projectQueuePath);
1258
- const pendingBytes =
1259
- Math.max(0, queueSize - Number(afterState.offset || 0)) +
1260
- Math.max(0, projectQueueSize - Number(projectAfterState.offset || 0));
1352
+ // Only the main queue is uploaded by drainQueueToCloud. project.queue.jsonl
1353
+ // is local project-usage state, so counting it here creates false backlog
1354
+ // and can keep auto retry alive even after cloud sync has drained.
1355
+ const pendingBytes = Math.max(0, queueSize - Number(afterState.offset || 0));
1261
1356
 
1262
1357
  if (pendingBytes <= 0) {
1263
1358
  await clearAutoRetry(trackerDir);
@@ -1269,7 +1364,8 @@ async function cmdSync(argv) {
1269
1364
  retryAtMs,
1270
1365
  reason: "backlog",
1271
1366
  pendingBytes,
1272
- source: "auto-backlog",
1367
+ source: autoSourceScope ? `${autoSourceScope}-backlog` : "auto-backlog",
1368
+ syncSource: autoSourceScope,
1273
1369
  autoRetryNoSpawn: runtime.autoRetryNoSpawn,
1274
1370
  });
1275
1371
  }
@@ -1362,6 +1458,7 @@ function parseArgs(argv) {
1362
1458
  fromNotify: false,
1363
1459
  fromRetry: false,
1364
1460
  fromOpenclaw: false,
1461
+ source: null,
1365
1462
  drain: false,
1366
1463
  repairGrok: false,
1367
1464
  };
@@ -1371,6 +1468,11 @@ function parseArgs(argv) {
1371
1468
  else if (a === "--from-notify") out.fromNotify = true;
1372
1469
  else if (a === "--from-retry") out.fromRetry = true;
1373
1470
  else if (a === "--from-openclaw") out.fromOpenclaw = true;
1471
+ else if (a === "--source") {
1472
+ out.source = normalizeSyncSource(argv[i + 1]);
1473
+ i += 1;
1474
+ }
1475
+ else if (a.startsWith("--source=")) out.source = normalizeSyncSource(a.slice("--source=".length));
1374
1476
  else if (a === "--drain") out.drain = true;
1375
1477
  else if (a === "--repair-grok") out.repairGrok = true;
1376
1478
  else throw new Error(`Unknown option: ${a}`);
@@ -1378,6 +1480,70 @@ function parseArgs(argv) {
1378
1480
  return out;
1379
1481
  }
1380
1482
 
1483
+ function resolveAutoSourceScope(opts) {
1484
+ if (!opts?.auto) return null;
1485
+ if (opts.fromOpenclaw) return "openclaw";
1486
+ if (opts.fromRetry) return normalizeSyncSource(opts.source);
1487
+ if (!opts.fromNotify) return null;
1488
+ return normalizeSyncSource(opts.source);
1489
+ }
1490
+
1491
+ function normalizeSyncSource(value) {
1492
+ if (typeof value !== "string") return null;
1493
+ const normalized = value.trim().toLowerCase();
1494
+ if (!normalized) return null;
1495
+ const aliased = AUTO_SYNC_SOURCE_ALIASES.get(normalized) || normalized;
1496
+ return AUTO_SYNC_SOURCES.has(aliased) ? aliased : null;
1497
+ }
1498
+
1499
+ function isCodexColdScanAuditDue(cursors, nowMs = Date.now()) {
1500
+ const state = cursors?.codexColdScanAudit;
1501
+ if (!state || typeof state !== "object" || state.version !== 1) return true;
1502
+ const lastFullScanAtMs = Number(state.lastFullScanAtMs);
1503
+ if (!Number.isFinite(lastFullScanAtMs) || lastFullScanAtMs <= 0) return true;
1504
+ if (Number.isFinite(nowMs) && lastFullScanAtMs - nowMs > 5 * 60 * 1000) return true;
1505
+ if (Number.isFinite(nowMs) && nowMs - lastFullScanAtMs >= CODEX_COLD_SCAN_AUDIT_INTERVAL_MS) {
1506
+ return true;
1507
+ }
1508
+ const syncsSinceFullScan = Number(state.syncsSinceFullScan || 0);
1509
+ return (
1510
+ Number.isFinite(syncsSinceFullScan) &&
1511
+ syncsSinceFullScan >= CODEX_COLD_SCAN_AUDIT_MAX_SYNCS
1512
+ );
1513
+ }
1514
+
1515
+ function recordCodexColdScanAudit(cursors, { fullAudit = false, skipped = 0 } = {}, nowMs = Date.now()) {
1516
+ if (!cursors || typeof cursors !== "object") return;
1517
+ const prev =
1518
+ cursors.codexColdScanAudit && typeof cursors.codexColdScanAudit === "object"
1519
+ ? cursors.codexColdScanAudit
1520
+ : {};
1521
+ const previousSyncs = Number(prev.syncsSinceFullScan || 0);
1522
+ const lastFullScanAtMs = Number(prev.lastFullScanAtMs);
1523
+ const next = {
1524
+ version: 1,
1525
+ lastFullScanAtMs: Number.isFinite(lastFullScanAtMs) && lastFullScanAtMs > 0
1526
+ ? lastFullScanAtMs
1527
+ : nowMs,
1528
+ syncsSinceFullScan: Number.isFinite(previousSyncs) && previousSyncs > 0
1529
+ ? previousSyncs
1530
+ : 0,
1531
+ lastSkippedFiles: Math.max(0, Number(skipped) || 0),
1532
+ updatedAt: new Date(nowMs).toISOString(),
1533
+ };
1534
+ if (fullAudit) {
1535
+ next.lastFullScanAtMs = nowMs;
1536
+ next.lastFullScanAt = new Date(nowMs).toISOString();
1537
+ next.syncsSinceFullScan = 0;
1538
+ } else {
1539
+ next.lastFullScanAt = Number.isFinite(next.lastFullScanAtMs)
1540
+ ? new Date(next.lastFullScanAtMs).toISOString()
1541
+ : null;
1542
+ next.syncsSinceFullScan += 1;
1543
+ }
1544
+ cursors.codexColdScanAudit = next;
1545
+ }
1546
+
1381
1547
  module.exports = {
1382
1548
  cmdSync,
1383
1549
  migrateCursorUnknownBuckets,
@@ -1388,6 +1554,10 @@ module.exports = {
1388
1554
  reincludeClaudeMemObserverFiles,
1389
1555
  repairGrokQueueFromSessionSnapshots,
1390
1556
  applyCloudConversationsBackfill,
1557
+ scheduleAutoRetry,
1558
+ buildAutoRetryScript,
1559
+ isCodexColdScanAuditDue,
1560
+ recordCodexColdScanAudit,
1391
1561
  CURSOR_UNKNOWN_MIGRATION_KEY,
1392
1562
  ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY,
1393
1563
  CODEX_RESCAN_DEDUP_REPAIR_KEY,
@@ -1569,6 +1739,7 @@ async function scheduleAutoRetry({
1569
1739
  reason,
1570
1740
  pendingBytes,
1571
1741
  source,
1742
+ syncSource,
1572
1743
  autoRetryNoSpawn,
1573
1744
  }) {
1574
1745
  const retryMs = coerceRetryMs(retryAtMs);
@@ -1578,19 +1749,33 @@ async function scheduleAutoRetry({
1578
1749
  const nowMs = Date.now();
1579
1750
  const existing = await readJson(retryPath);
1580
1751
  const existingMs = coerceRetryMs(existing?.retryAtMs);
1752
+ const normalizedSyncSource = normalizeSyncSource(syncSource);
1581
1753
  if (existingMs && existingMs >= retryMs - 1000) {
1754
+ const existingSyncSource = normalizeSyncSource(existing?.syncSource);
1755
+ if (existingSyncSource !== normalizedSyncSource) {
1756
+ await writeJson(
1757
+ retryPath,
1758
+ buildAutoRetryPayload({
1759
+ retryMs: existingMs,
1760
+ nowMs,
1761
+ reason,
1762
+ pendingBytes,
1763
+ source,
1764
+ syncSource: normalizedSyncSource,
1765
+ }),
1766
+ );
1767
+ }
1582
1768
  return { scheduled: false, retryAtMs: existingMs };
1583
1769
  }
1584
1770
 
1585
- const payload = {
1586
- version: 1,
1587
- retryAtMs: retryMs,
1588
- retryAt: new Date(retryMs).toISOString(),
1589
- reason: typeof reason === "string" && reason.length > 0 ? reason : "throttled",
1590
- pendingBytes: Math.max(0, Number(pendingBytes || 0)),
1591
- scheduledAt: new Date(nowMs).toISOString(),
1592
- source: typeof source === "string" ? source : "auto",
1593
- };
1771
+ const payload = buildAutoRetryPayload({
1772
+ retryMs,
1773
+ nowMs,
1774
+ reason,
1775
+ pendingBytes,
1776
+ source,
1777
+ syncSource: normalizedSyncSource,
1778
+ });
1594
1779
 
1595
1780
  await writeJson(retryPath, payload);
1596
1781
 
@@ -1609,6 +1794,20 @@ async function scheduleAutoRetry({
1609
1794
  return { scheduled: true, retryAtMs: retryMs };
1610
1795
  }
1611
1796
 
1797
+ function buildAutoRetryPayload({ retryMs, nowMs, reason, pendingBytes, source, syncSource }) {
1798
+ const payload = {
1799
+ version: 1,
1800
+ retryAtMs: retryMs,
1801
+ retryAt: new Date(retryMs).toISOString(),
1802
+ reason: typeof reason === "string" && reason.length > 0 ? reason : "throttled",
1803
+ pendingBytes: Math.max(0, Number(pendingBytes || 0)),
1804
+ scheduledAt: new Date(nowMs).toISOString(),
1805
+ source: typeof source === "string" ? source : "auto",
1806
+ };
1807
+ if (syncSource) payload.syncSource = syncSource;
1808
+ return payload;
1809
+ }
1810
+
1612
1811
  async function clearAutoRetry(trackerDir) {
1613
1812
  const retryPath = path.join(trackerDir, AUTO_RETRY_FILENAME);
1614
1813
  await fs.unlink(retryPath).catch(() => {});
@@ -1636,13 +1835,18 @@ function buildAutoRetryScript({ retryPath, trackerBinPath, fallbackPkg, delayMs
1636
1835
  `const fallbackPkg = ${JSON.stringify(fallbackPkg)};\n` +
1637
1836
  `const delayMs = ${Math.max(0, Math.floor(delayMs || 0))};\n` +
1638
1837
  `setTimeout(() => {\n` +
1838
+ ` let payload = null;\n` +
1639
1839
  ` let retryAtMs = 0;\n` +
1640
1840
  ` try {\n` +
1641
1841
  ` const raw = fs.readFileSync(retryPath, 'utf8');\n` +
1642
- ` retryAtMs = Number(JSON.parse(raw).retryAtMs || 0);\n` +
1842
+ ` payload = JSON.parse(raw);\n` +
1843
+ ` retryAtMs = Number(payload.retryAtMs || 0);\n` +
1643
1844
  ` } catch (_) {}\n` +
1644
1845
  ` if (!retryAtMs || Date.now() + 1000 < retryAtMs) process.exit(0);\n` +
1645
1846
  ` const argv = ['sync', '--auto', '--from-retry'];\n` +
1847
+ ` if (payload && typeof payload.syncSource === 'string' && payload.syncSource.trim()) {\n` +
1848
+ ` argv.push('--source', payload.syncSource.trim());\n` +
1849
+ ` }\n` +
1646
1850
  ` const cmd = fs.existsSync(trackerBinPath)\n` +
1647
1851
  ` ? [process.execPath, trackerBinPath, ...argv]\n` +
1648
1852
  ` : ['npx', '--yes', fallbackPkg, ...argv];\n` +
@@ -2398,7 +2602,14 @@ async function migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rollou
2398
2602
  // contributed is gone from disk (genuinely deleted — Codex-Manager log rotation
2399
2603
  // or user cleanup) the migration is skipped entirely — the forward dedup fix
2400
2604
  // still prevents new double-counting; only this historical correction is deferred.
2401
- async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath, rolloutFiles }) {
2605
+ async function repairCodexRescanInflation({
2606
+ cursors,
2607
+ queuePath,
2608
+ queueStatePath,
2609
+ projectQueuePath,
2610
+ projectQueueStatePath,
2611
+ rolloutFiles,
2612
+ }) {
2402
2613
  if (!cursors || typeof cursors !== "object") return false;
2403
2614
  const migrations = (cursors.migrations ||= {});
2404
2615
  // A COMPLETED run writes an ISO-string timestamp (final — never re-run). A
@@ -2419,6 +2630,7 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2419
2630
  if (fp && src === "codex") codexFiles.push(fp);
2420
2631
  }
2421
2632
  const codexFileSet = new Set(codexFiles);
2633
+ const projectRepairEnabled = typeof projectQueuePath === "string" && projectQueuePath.length > 0;
2422
2634
 
2423
2635
  // GUARD (data-loss prevention, ref the v6 ground-truth-repair incident): the
2424
2636
  // rebuild can only reproduce buckets from the files this sync re-parses
@@ -2439,7 +2651,7 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2439
2651
  }
2440
2652
  if (cursors.files && typeof cursors.files === "object") {
2441
2653
  for (const fp of Object.keys(cursors.files)) {
2442
- if (!/\/\.codex\/(?:archived_)?sessions\//.test(fp)) continue;
2654
+ if (!isCodexSessionCursorPath(fp)) continue;
2443
2655
  if (codexFileSet.has(fp)) continue; // exact file re-scanned this run
2444
2656
  const id = codexSessionIdFromPath(fp);
2445
2657
  if (id && scannedSessionIds.has(id)) continue; // same session scanned elsewhere (moved)
@@ -2461,6 +2673,9 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2461
2673
  // would permanently zero a user's codex history.
2462
2674
  let rebuilt;
2463
2675
  const tmpQueue = `${queuePath}.codexrebuild.${process.pid}.${Date.now()}`;
2676
+ const tmpProjectQueue = projectRepairEnabled
2677
+ ? `${projectQueuePath}.codexrebuild.${process.pid}.${Date.now()}`
2678
+ : null;
2464
2679
  try {
2465
2680
  const tmpCursors = {
2466
2681
  version: 1,
@@ -2472,6 +2687,7 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2472
2687
  rolloutFiles: codexFiles.map((p) => ({ path: p, source: "codex" })),
2473
2688
  cursors: tmpCursors,
2474
2689
  queuePath: tmpQueue,
2690
+ projectQueuePath: tmpProjectQueue,
2475
2691
  });
2476
2692
  let tmpRaw = "";
2477
2693
  try {
@@ -2479,12 +2695,22 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2479
2695
  } catch (e) {
2480
2696
  if (e?.code !== "ENOENT") throw e;
2481
2697
  }
2698
+ let tmpProjectRaw = "";
2699
+ if (tmpProjectQueue) {
2700
+ try {
2701
+ tmpProjectRaw = await fs.readFile(tmpProjectQueue, "utf8");
2702
+ } catch (e) {
2703
+ if (e?.code !== "ENOENT") throw e;
2704
+ }
2705
+ }
2482
2706
  rebuilt = {
2483
2707
  buckets: tmpCursors.hourly.buckets || {},
2484
2708
  groupQueued: tmpCursors.hourly.groupQueued || {},
2485
2709
  codexHashes: Array.isArray(tmpCursors.codexHashes) ? tmpCursors.codexHashes : [],
2486
2710
  files: tmpCursors.files || {},
2487
2711
  queueRows: tmpRaw.split("\n").filter((l) => l.trim()),
2712
+ projectHourly: tmpCursors.projectHourly || null,
2713
+ projectQueueRows: tmpProjectRaw.split("\n").filter((l) => l.trim()),
2488
2714
  };
2489
2715
  } catch (e) {
2490
2716
  console.error(
@@ -2494,6 +2720,7 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2494
2720
  return false;
2495
2721
  } finally {
2496
2722
  await fs.rm(tmpQueue, { force: true }).catch(() => {});
2723
+ if (tmpProjectQueue) await fs.rm(tmpProjectQueue, { force: true }).catch(() => {});
2497
2724
  }
2498
2725
 
2499
2726
  // SANITY: codex files exist on disk but the rebuild produced no codex buckets
@@ -2506,6 +2733,64 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2506
2733
  );
2507
2734
  return false;
2508
2735
  }
2736
+ if (projectRepairEnabled) {
2737
+ const malformedProjectRows = await countMalformedCodexProjectQueueRows(projectQueuePath);
2738
+ if (malformedProjectRows > 0) {
2739
+ console.error(
2740
+ `[sync] codex rescan repair: found ${malformedProjectRows} malformed codex project queue row(s) — skipping to avoid data loss`,
2741
+ );
2742
+ return false;
2743
+ }
2744
+
2745
+ const existingProjectKeys = new Set([
2746
+ ...(await projectUsageKeysFromQueuePath(projectQueuePath, "codex")),
2747
+ ...projectUsageKeysFromState(cursors.projectHourly, "codex"),
2748
+ ]);
2749
+ const rebuiltProjectKeys = new Set([
2750
+ ...projectUsageKeysFromQueueRows(rebuilt.projectQueueRows, "codex"),
2751
+ ...projectUsageKeysFromState(rebuilt.projectHourly, "codex"),
2752
+ ]);
2753
+ const missingProjectKeys = [...existingProjectKeys].filter((key) => !rebuiltProjectKeys.has(key));
2754
+ if (missingProjectKeys.length > 0) {
2755
+ console.error(
2756
+ `[sync] codex rescan repair: project rebuild missed ${missingProjectKeys.length} existing codex project bucket(s) — skipping to avoid data loss`,
2757
+ );
2758
+ return false;
2759
+ }
2760
+
2761
+ const existingProjectTotals = mergeMaxTotals(
2762
+ await projectUsageTotalsFromQueuePath(projectQueuePath, "codex"),
2763
+ projectUsageTotalsFromState(cursors.projectHourly, "codex"),
2764
+ );
2765
+ const rebuiltProjectTotals = mergeMaxTotals(
2766
+ projectUsageTotalsFromQueueRows(rebuilt.projectQueueRows, "codex"),
2767
+ projectUsageTotalsFromState(rebuilt.projectHourly, "codex"),
2768
+ );
2769
+ const existingMainHourTotals = mergeMaxTotals(
2770
+ await mainUsageHourTotalsFromQueuePath(queuePath, "codex"),
2771
+ mainUsageHourTotalsFromState(cursors.hourly, "codex"),
2772
+ );
2773
+ const rebuiltMainHourTotals = mergeMaxTotals(
2774
+ mainUsageHourTotalsFromQueueRows(rebuilt.queueRows, "codex"),
2775
+ mainUsageHourTotalsFromState({ buckets: rebuilt.buckets }, "codex"),
2776
+ );
2777
+ const partialProjectKeys = [];
2778
+ for (const [key, existingTotal] of existingProjectTotals.entries()) {
2779
+ const rebuiltTotal = rebuiltProjectTotals.get(key);
2780
+ if (!Number.isFinite(rebuiltTotal) || rebuiltTotal >= existingTotal) continue;
2781
+ const [, source, hourStart] = key.split("|");
2782
+ const mainKey = `${source}|${hourStart}`;
2783
+ const existingMainTotal = existingMainHourTotals.get(mainKey) || 0;
2784
+ const rebuiltMainTotal = rebuiltMainHourTotals.get(mainKey) || 0;
2785
+ if (rebuiltMainTotal >= existingMainTotal) partialProjectKeys.push(key);
2786
+ }
2787
+ if (partialProjectKeys.length > 0) {
2788
+ console.error(
2789
+ `[sync] codex rescan repair: project rebuild lowered ${partialProjectKeys.length} existing codex project bucket(s) without a matching main-bucket repair — skipping to avoid data loss`,
2790
+ );
2791
+ return false;
2792
+ }
2793
+ }
2509
2794
 
2510
2795
  // COMMIT (only after a verified rebuild). A crash partway just leaves the
2511
2796
  // migration to re-run next sync — re-rebuild + re-strip + re-commit converges.
@@ -2560,14 +2845,65 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2560
2845
  }
2561
2846
  cursors.files ||= {};
2562
2847
  for (const fp of Object.keys(cursors.files)) {
2563
- if (/\/\.codex\/(?:archived_)?sessions\//.test(fp)) delete cursors.files[fp];
2848
+ if (isCodexSessionCursorPath(fp)) delete cursors.files[fp];
2564
2849
  }
2565
2850
  for (const [fp, v] of Object.entries(rebuilt.files)) {
2566
2851
  cursors.files[fp] = v;
2567
2852
  }
2568
2853
  cursors.codexHashes = rebuilt.codexHashes;
2569
2854
 
2570
- // 3. Reset the cloud upload offset so the corrected queue re-uploads. Other
2855
+ // 3. Project usage mirrors the main Codex repair: drop inflated Codex project
2856
+ // rows, append the rebuilt rows, and swap only Codex project buckets. Project
2857
+ // metadata is merged so visibility/purge state from non-Codex sources stays.
2858
+ if (projectRepairEnabled) {
2859
+ let projectRaw = "";
2860
+ try {
2861
+ projectRaw = await fs.readFile(projectQueuePath, "utf8");
2862
+ } catch (e) {
2863
+ if (e?.code !== "ENOENT") throw e;
2864
+ }
2865
+ const keptProjectRows = [];
2866
+ for (const line of projectRaw.split("\n")) {
2867
+ if (!line.trim()) continue;
2868
+ let row;
2869
+ try {
2870
+ row = JSON.parse(line);
2871
+ } catch (_e) {
2872
+ keptProjectRows.push(line);
2873
+ continue;
2874
+ }
2875
+ if (row?.source === "codex") continue;
2876
+ keptProjectRows.push(line);
2877
+ }
2878
+ await ensureDir(path.dirname(projectQueuePath));
2879
+ const tmp = `${projectQueuePath}.tmp.${process.pid}.${Date.now()}`;
2880
+ await fs.writeFile(
2881
+ tmp,
2882
+ keptProjectRows.concat(rebuilt.projectQueueRows).join("\n") + "\n",
2883
+ "utf8",
2884
+ );
2885
+ await fs.rename(tmp, projectQueuePath);
2886
+
2887
+ const projectHourly = (cursors.projectHourly ||= { version: 2, buckets: {}, projects: {} });
2888
+ projectHourly.version = 2;
2889
+ projectHourly.buckets ||= {};
2890
+ projectHourly.projects ||= {};
2891
+ for (const [key, bucket] of Object.entries(projectHourly.buckets)) {
2892
+ const source = typeof bucket?.source === "string" ? bucket.source : key.split("|")[1];
2893
+ if (source === "codex") delete projectHourly.buckets[key];
2894
+ }
2895
+ const rebuiltProjectHourly = rebuilt.projectHourly || {};
2896
+ for (const [key, bucket] of Object.entries(rebuiltProjectHourly.buckets || {})) {
2897
+ const source = typeof bucket?.source === "string" ? bucket.source : key.split("|")[1];
2898
+ if (source === "codex") projectHourly.buckets[key] = bucket;
2899
+ }
2900
+ for (const [key, meta] of Object.entries(rebuiltProjectHourly.projects || {})) {
2901
+ if (meta && typeof meta === "object") projectHourly.projects[key] = meta;
2902
+ }
2903
+ projectHourly.updatedAt = new Date().toISOString();
2904
+ }
2905
+
2906
+ // 4. Reset the cloud upload offset so the corrected queue re-uploads. Other
2571
2907
  // sources re-upsert idempotently (last emission per key wins).
2572
2908
  if (typeof queueStatePath === "string" && queueStatePath) {
2573
2909
  let uploadState = {};
@@ -2581,11 +2917,249 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
2581
2917
  uploadState.note = "reset_after_codex_rescan_dedup_2026_06";
2582
2918
  await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
2583
2919
  }
2920
+ if (projectRepairEnabled && typeof projectQueueStatePath === "string" && projectQueueStatePath) {
2921
+ let uploadState = {};
2922
+ try {
2923
+ uploadState = JSON.parse(await fs.readFile(projectQueueStatePath, "utf8"));
2924
+ } catch (_e) {
2925
+ uploadState = {};
2926
+ }
2927
+ uploadState.offset = 0;
2928
+ uploadState.updatedAt = new Date().toISOString();
2929
+ uploadState.note = "reset_after_codex_rescan_dedup_2026_06";
2930
+ await fs.writeFile(projectQueueStatePath, JSON.stringify(uploadState));
2931
+ }
2584
2932
 
2585
2933
  migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY] = new Date().toISOString();
2586
2934
  return true;
2587
2935
  }
2588
2936
 
2937
+ function isCodexSessionCursorPath(filePath) {
2938
+ if (typeof filePath !== "string") return false;
2939
+ const normalized = filePath.replace(/\\/g, "/");
2940
+ return /\/\.codex\/(?:archived_)?sessions\//.test(normalized);
2941
+ }
2942
+
2943
+ async function projectUsageKeysFromQueuePath(queuePath, source) {
2944
+ if (typeof queuePath !== "string" || !queuePath) return [];
2945
+ let raw = "";
2946
+ try {
2947
+ raw = await fs.readFile(queuePath, "utf8");
2948
+ } catch (e) {
2949
+ if (e?.code !== "ENOENT") throw e;
2950
+ return [];
2951
+ }
2952
+ return projectUsageKeysFromQueueRows(raw.split("\n").filter((line) => line.trim()), source);
2953
+ }
2954
+
2955
+ function projectUsageKeysFromQueueRows(rows, source) {
2956
+ const keys = [];
2957
+ for (const line of Array.isArray(rows) ? rows : []) {
2958
+ let row;
2959
+ try {
2960
+ row = JSON.parse(line);
2961
+ } catch (_e) {
2962
+ continue;
2963
+ }
2964
+ const key = projectUsageKeyFromFields({
2965
+ projectKey: row?.project_key,
2966
+ source: row?.source,
2967
+ hourStart: row?.hour_start,
2968
+ });
2969
+ if (key && row?.source === source) keys.push(key);
2970
+ }
2971
+ return keys;
2972
+ }
2973
+
2974
+ function projectUsageKeysFromState(projectState, source) {
2975
+ const buckets =
2976
+ projectState && typeof projectState === "object" && projectState.buckets
2977
+ ? projectState.buckets
2978
+ : {};
2979
+ const keys = [];
2980
+ for (const [key, bucket] of Object.entries(buckets)) {
2981
+ const bucketSource = typeof bucket?.source === "string" ? bucket.source : key.split("|")[1];
2982
+ if (bucketSource !== source) continue;
2983
+ const usageKey =
2984
+ projectUsageKeyFromFields({
2985
+ projectKey: bucket?.project_key,
2986
+ source: bucketSource,
2987
+ hourStart: bucket?.hour_start,
2988
+ }) || key;
2989
+ keys.push(usageKey);
2990
+ }
2991
+ return keys;
2992
+ }
2993
+
2994
+ function projectUsageKeyFromFields({ projectKey, source, hourStart }) {
2995
+ if (
2996
+ typeof projectKey !== "string" ||
2997
+ typeof source !== "string" ||
2998
+ typeof hourStart !== "string" ||
2999
+ !projectKey ||
3000
+ !source ||
3001
+ !hourStart
3002
+ ) {
3003
+ return null;
3004
+ }
3005
+ return `${projectKey}|${source}|${hourStart}`;
3006
+ }
3007
+
3008
+ async function countMalformedCodexProjectQueueRows(queuePath) {
3009
+ if (typeof queuePath !== "string" || !queuePath) return 0;
3010
+ let raw = "";
3011
+ try {
3012
+ raw = await fs.readFile(queuePath, "utf8");
3013
+ } catch (e) {
3014
+ if (e?.code !== "ENOENT") throw e;
3015
+ return 0;
3016
+ }
3017
+ let count = 0;
3018
+ for (const line of raw.split("\n")) {
3019
+ if (!line.trim()) continue;
3020
+ let row;
3021
+ try {
3022
+ row = JSON.parse(line);
3023
+ } catch (_e) {
3024
+ continue;
3025
+ }
3026
+ if (row?.source !== "codex") continue;
3027
+ const key = projectUsageKeyFromFields({
3028
+ projectKey: row?.project_key,
3029
+ source: row?.source,
3030
+ hourStart: row?.hour_start,
3031
+ });
3032
+ if (!key) count += 1;
3033
+ }
3034
+ return count;
3035
+ }
3036
+
3037
+ async function projectUsageTotalsFromQueuePath(queuePath, source) {
3038
+ if (typeof queuePath !== "string" || !queuePath) return new Map();
3039
+ let raw = "";
3040
+ try {
3041
+ raw = await fs.readFile(queuePath, "utf8");
3042
+ } catch (e) {
3043
+ if (e?.code !== "ENOENT") throw e;
3044
+ return new Map();
3045
+ }
3046
+ return projectUsageTotalsFromQueueRows(raw.split("\n").filter((line) => line.trim()), source);
3047
+ }
3048
+
3049
+ function projectUsageTotalsFromQueueRows(rows, source) {
3050
+ const totals = new Map();
3051
+ for (const line of Array.isArray(rows) ? rows : []) {
3052
+ let row;
3053
+ try {
3054
+ row = JSON.parse(line);
3055
+ } catch (_e) {
3056
+ continue;
3057
+ }
3058
+ const key = projectUsageKeyFromFields({
3059
+ projectKey: row?.project_key,
3060
+ source: row?.source,
3061
+ hourStart: row?.hour_start,
3062
+ });
3063
+ if (!key || row?.source !== source) continue;
3064
+ setMaxTotal(totals, key, Number(row.total_tokens || 0));
3065
+ }
3066
+ return totals;
3067
+ }
3068
+
3069
+ function projectUsageTotalsFromState(projectState, source) {
3070
+ const buckets =
3071
+ projectState && typeof projectState === "object" && projectState.buckets
3072
+ ? projectState.buckets
3073
+ : {};
3074
+ const totals = new Map();
3075
+ for (const [key, bucket] of Object.entries(buckets)) {
3076
+ const bucketSource = typeof bucket?.source === "string" ? bucket.source : key.split("|")[1];
3077
+ if (bucketSource !== source) continue;
3078
+ const usageKey =
3079
+ projectUsageKeyFromFields({
3080
+ projectKey: bucket?.project_key,
3081
+ source: bucketSource,
3082
+ hourStart: bucket?.hour_start,
3083
+ }) || key;
3084
+ setMaxTotal(totals, usageKey, Number(bucket?.totals?.total_tokens || 0));
3085
+ }
3086
+ return totals;
3087
+ }
3088
+
3089
+ async function mainUsageHourTotalsFromQueuePath(queuePath, source) {
3090
+ if (typeof queuePath !== "string" || !queuePath) return new Map();
3091
+ let raw = "";
3092
+ try {
3093
+ raw = await fs.readFile(queuePath, "utf8");
3094
+ } catch (e) {
3095
+ if (e?.code !== "ENOENT") throw e;
3096
+ return new Map();
3097
+ }
3098
+ return mainUsageHourTotalsFromQueueRows(raw.split("\n").filter((line) => line.trim()), source);
3099
+ }
3100
+
3101
+ function mainUsageHourTotalsFromQueueRows(rows, source) {
3102
+ const modelTotals = new Map();
3103
+ for (const line of Array.isArray(rows) ? rows : []) {
3104
+ let row;
3105
+ try {
3106
+ row = JSON.parse(line);
3107
+ } catch (_e) {
3108
+ continue;
3109
+ }
3110
+ if (row?.source !== source || typeof row?.hour_start !== "string") continue;
3111
+ const model = typeof row?.model === "string" && row.model ? row.model : "unknown";
3112
+ setMaxTotal(modelTotals, `${row.source}|${model}|${row.hour_start}`, Number(row.total_tokens || 0));
3113
+ }
3114
+ return collapseModelTotalsByHour(modelTotals);
3115
+ }
3116
+
3117
+ function mainUsageHourTotalsFromState(hourlyState, source) {
3118
+ const buckets =
3119
+ hourlyState && typeof hourlyState === "object" && hourlyState.buckets
3120
+ ? hourlyState.buckets
3121
+ : {};
3122
+ const modelTotals = new Map();
3123
+ for (const [key, bucket] of Object.entries(buckets)) {
3124
+ const parts = key.split("|");
3125
+ const bucketSource = typeof bucket?.source === "string" ? bucket.source : parts[0];
3126
+ if (bucketSource !== source) continue;
3127
+ const model = typeof bucket?.model === "string" && bucket.model ? bucket.model : parts[1] || "unknown";
3128
+ const hourStart =
3129
+ typeof bucket?.hour_start === "string" && bucket.hour_start ? bucket.hour_start : parts[2];
3130
+ if (typeof hourStart !== "string" || !hourStart) continue;
3131
+ setMaxTotal(modelTotals, `${bucketSource}|${model}|${hourStart}`, Number(bucket?.totals?.total_tokens || 0));
3132
+ }
3133
+ return collapseModelTotalsByHour(modelTotals);
3134
+ }
3135
+
3136
+ function collapseModelTotalsByHour(modelTotals) {
3137
+ const totals = new Map();
3138
+ for (const [key, total] of modelTotals.entries()) {
3139
+ const [source, , hourStart] = key.split("|");
3140
+ const hourKey = `${source}|${hourStart}`;
3141
+ totals.set(hourKey, (totals.get(hourKey) || 0) + total);
3142
+ }
3143
+ return totals;
3144
+ }
3145
+
3146
+ function mergeMaxTotals(...maps) {
3147
+ const merged = new Map();
3148
+ for (const map of maps) {
3149
+ if (!(map instanceof Map)) continue;
3150
+ for (const [key, total] of map.entries()) {
3151
+ setMaxTotal(merged, key, total);
3152
+ }
3153
+ }
3154
+ return merged;
3155
+ }
3156
+
3157
+ function setMaxTotal(map, key, total) {
3158
+ if (!key || !Number.isFinite(total)) return;
3159
+ const prev = map.get(key);
3160
+ if (!Number.isFinite(prev) || total > prev) map.set(key, total);
3161
+ }
3162
+
2589
3163
  // One-time repair (#204): when the SAME Droid session id existed in two folders
2590
3164
  // under ~/.factory/sessions, parseDroidIncremental's cumulative-delta loop made the
2591
3165
  // lower-count file look like a reset and re-emitted each duplicate's full total on