tokentracker-cli 0.79.9 → 0.80.1

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 (74) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-B1WOPlu0.js → AchievementBadge-D617bvS9.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-9kHEQicS.js → AchievementsPage-mzE76Ah9.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-Dw05PYPr.js → AchievementsSection-8CYGBoPJ.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-DaWgiijJ.js → BadgeDetailModal-DM3veVmW.js} +1 -1
  5. package/dashboard/dist/assets/{Card-DYhrLLM4.js → Card-DWONuizy.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-BLy2LUKz.js → ClawdAnimated-CGQXyWuH.js} +1 -1
  7. package/dashboard/dist/assets/{CommunityStatsModal-CWYqfht3.js → CommunityStatsModal-B5yDmJ0o.js} +1 -1
  8. package/dashboard/dist/assets/{ConfirmModal-DcE2C24Y.js → ConfirmModal-d4wpsMPW.js} +1 -1
  9. package/dashboard/dist/assets/{Controls-CI7qw0-b.js → Controls-CMSqknaB.js} +1 -1
  10. package/dashboard/dist/assets/{DashboardPage-CAlgaBa7.js → DashboardPage-Cw6lmVSg.js} +1 -1
  11. package/dashboard/dist/assets/{DevicePage-DinOyaeN.js → DevicePage-CsPSjSLH.js} +1 -1
  12. package/dashboard/dist/assets/{DialogClose-CjfSDrLo.js → DialogClose-Cbs-z783.js} +1 -1
  13. package/dashboard/dist/assets/DialogDescription-B0fmBJxQ.js +1 -0
  14. package/dashboard/dist/assets/{DialogTitle-f7gNmCca.js → DialogTitle-C4mJiIQW.js} +1 -1
  15. package/dashboard/dist/assets/{FadeIn-D_5w0Qh0.js → FadeIn-xOJwCmZ8.js} +1 -1
  16. package/dashboard/dist/assets/{HeaderGithubStar-DTZyNKyL.js → HeaderGithubStar-oRz4rfC8.js} +1 -1
  17. package/dashboard/dist/assets/{IpCheckPage-D8CViF98.js → IpCheckPage-CTwL-Zmz.js} +1 -1
  18. package/dashboard/dist/assets/{LandingPage-CqN5KEjV.js → LandingPage-CqYJ3sZh.js} +1 -1
  19. package/dashboard/dist/assets/{LeaderboardAvatar-CwUVO4wn.js → LeaderboardAvatar-CuF6Kzkj.js} +1 -1
  20. package/dashboard/dist/assets/{LeaderboardPage-B12HJ9LA.js → LeaderboardPage-BvOPIj-b.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardProfileModal-DFzPpsnV.js → LeaderboardProfileModal-DVXuKkRN.js} +3 -3
  22. package/dashboard/dist/assets/{LeaderboardProfilePage-Z0ZYpNFd.js → LeaderboardProfilePage-RE1CPqzM.js} +1 -1
  23. package/dashboard/dist/assets/{LimitsPage-BpwioIa-.js → LimitsPage-BovkRgac.js} +1 -1
  24. package/dashboard/dist/assets/{LocalOnlyNotice-Covi6zdt.js → LocalOnlyNotice-CTFFU_ip.js} +1 -1
  25. package/dashboard/dist/assets/{LoginPage-CRqbhwCx.js → LoginPage-mguBCTBZ.js} +1 -1
  26. package/dashboard/dist/assets/{PetPage-BY_yObVj.js → PetPage-C5KLH6Fm.js} +1 -1
  27. package/dashboard/dist/assets/{PopoverPopup-B2dE3Ary.js → PopoverPopup-CLDh5yIk.js} +1 -1
  28. package/dashboard/dist/assets/{ResetPasswordPage-C8IEJbVc.js → ResetPasswordPage-DnhgHQrV.js} +1 -1
  29. package/dashboard/dist/assets/{Select-BmRLYSqA.js → Select-DPSaPIlz.js} +1 -1
  30. package/dashboard/dist/assets/{SelectItemText-FC46bk8b.js → SelectItemText-jIBbXpqi.js} +1 -1
  31. package/dashboard/dist/assets/{SettingsPage-DipzOQVg.js → SettingsPage-BK0IqSjg.js} +1 -1
  32. package/dashboard/dist/assets/{SkillsPage-CziypUTf.js → SkillsPage-BQNoMpL2.js} +1 -1
  33. package/dashboard/dist/assets/{WidgetsPage-CJegJhVR.js → WidgetsPage-308d8icu.js} +1 -1
  34. package/dashboard/dist/assets/{WrappedPage-DcMiKvzf.js → WrappedPage-T720sQ9S.js} +1 -1
  35. package/dashboard/dist/assets/{agent-logos-S7bF1LwW.js → agent-logos-Blo5C96d.js} +1 -1
  36. package/dashboard/dist/assets/{arrow-up-right-D0yeL56y.js → arrow-up-right-DKz2Ua6U.js} +1 -1
  37. package/dashboard/dist/assets/check-fF6lQGN-.js +1 -0
  38. package/dashboard/dist/assets/{copy-D-z9ZtIu.js → copy-DcUFG0PD.js} +1 -1
  39. package/dashboard/dist/assets/{download-Dwxnd3QG.js → download-CNK1wtxV.js} +1 -1
  40. package/dashboard/dist/assets/{flame-Bdqk9A75.js → flame-BEhJZRaZ.js} +1 -1
  41. package/dashboard/dist/assets/{format-tokens-DNVcCRwm.js → format-tokens-Bsfbd8nj.js} +1 -1
  42. package/dashboard/dist/assets/{info-CMLT6oUI.js → info-DRJLv-aV.js} +1 -1
  43. package/dashboard/dist/assets/{link-2-BmiHu726.js → link-2-C0GdaANY.js} +1 -1
  44. package/dashboard/dist/assets/{loader-circle-CXVUQtzm.js → loader-circle-F9ymYeor.js} +1 -1
  45. package/dashboard/dist/assets/{main-B9m79io-.js → main-CThKxvKx.js} +3 -3
  46. package/dashboard/dist/assets/{provider-display-BoAesdke.js → provider-display-D4NdFjHN.js} +1 -1
  47. package/dashboard/dist/assets/{trash-2-C9Rh2DAL.js → trash-2-D9vulGMW.js} +1 -1
  48. package/dashboard/dist/assets/{use-community-stats-aZwYxrXR.js → use-community-stats-BU4dXEOM.js} +1 -1
  49. package/dashboard/dist/assets/{use-limits-display-prefs-tJKR83RT.js → use-limits-display-prefs-Bx2VJhwl.js} +1 -1
  50. package/dashboard/dist/assets/{use-native-settings-PjfJPK5v.js → use-native-settings-BSXaxaTC.js} +1 -1
  51. package/dashboard/dist/assets/{use-ordered-list-D1QFmZUY.js → use-ordered-list-OA0HwwEL.js} +1 -1
  52. package/dashboard/dist/assets/{use-quality-per-dollar-pref-4N3Su4wC.js → use-quality-per-dollar-pref-Bqooc4FA.js} +1 -1
  53. package/dashboard/dist/assets/{use-transform-D9nurInL.js → use-transform-CZ9FdRGw.js} +1 -1
  54. package/dashboard/dist/assets/{use-usage-limits-DapTpi_6.js → use-usage-limits-Ct_A4wTr.js} +1 -1
  55. package/dashboard/dist/assets/{useCurrency-B0DH9ZZA.js → useCurrency-D4bvdaFB.js} +1 -1
  56. package/dashboard/dist/assets/{useScrollLock-DijV6DYS.js → useScrollLock-ZsWO-GoL.js} +1 -1
  57. package/dashboard/dist/assets/useTokenFormat-B3huMEws.js +1 -0
  58. package/dashboard/dist/assets/{zap-BVfhR3Ck.js → zap-C9Mp-GNU.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 +607 -6
  63. package/src/commands/sync.js +176 -7
  64. package/src/lib/codex-config.js +135 -40
  65. package/src/lib/codex-context-breakdown.js +123 -43
  66. package/src/lib/codex-rollout-parser.js +329 -32
  67. package/src/lib/local-api.js +11 -5
  68. package/src/lib/openclaw-session-plugin.js +13 -0
  69. package/src/lib/pricing/seed-snapshot.json +1 -1
  70. package/src/lib/rollout.js +230 -10
  71. package/src/lib/usage-limits.js +9 -5
  72. package/dashboard/dist/assets/DialogDescription-DWs-KIUA.js +0 -1
  73. package/dashboard/dist/assets/check-Dn9BLYEf.js +0 -1
  74. package/dashboard/dist/assets/useTokenFormat-nxdG6gi9.js +0 -1
@@ -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;
@@ -273,8 +285,10 @@ function mergeCopilotAppDbStates(primary = {}, alias = {}) {
273
285
  return { ...primary, ...newer, sessionTotals };
274
286
  }
275
287
 
276
- async function cmdSync(argv) {
288
+ async function cmdSync(argv, context = {}) {
277
289
  const opts = parseArgs(argv);
290
+ const diagnostics = context && typeof context === "object" ? context.diagnostics : null;
291
+ const syncDiagnostics = diagnostics && typeof diagnostics === "object" ? diagnostics : null;
278
292
  const home = os.homedir();
279
293
  const { trackerDir } = await resolveTrackerPaths({ home });
280
294
 
@@ -405,7 +419,7 @@ async function cmdSync(argv) {
405
419
 
406
420
  if (isFullSourceScan) {
407
421
  await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
408
- await repairCodexRescanInflation({
422
+ const codexRescanRepairRan = await repairCodexRescanInflation({
409
423
  cursors,
410
424
  queuePath,
411
425
  queueStatePath,
@@ -413,6 +427,15 @@ async function cmdSync(argv) {
413
427
  projectQueueStatePath,
414
428
  rolloutFiles,
415
429
  });
430
+ await repairCodexForkReplayInflation({
431
+ cursors,
432
+ queuePath,
433
+ queueStatePath,
434
+ projectQueuePath,
435
+ projectQueueStatePath,
436
+ rolloutFiles,
437
+ legacyRepairRan: codexRescanRepairRan,
438
+ });
416
439
  await repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath });
417
440
  await repairMimoClaudeMislabel({
418
441
  cursors,
@@ -433,6 +456,7 @@ async function cmdSync(argv) {
433
456
  cursors,
434
457
  projectEnabled: true,
435
458
  auditDue: codexColdAuditDue,
459
+ diagnostics: syncDiagnostics,
436
460
  })
437
461
  : { rolloutFiles, skipped: 0 };
438
462
  const rolloutFilesForParse = codexColdFilter.rolloutFiles;
@@ -455,6 +479,7 @@ async function cmdSync(argv) {
455
479
  cursors,
456
480
  queuePath,
457
481
  projectQueuePath,
482
+ diagnostics: syncDiagnostics,
458
483
  onProgress: (p) => {
459
484
  if (!progress?.enabled) return;
460
485
  const pct = p.total > 0 ? p.index / p.total : 1;
@@ -1827,6 +1852,12 @@ async function cmdSync(argv) {
1827
1852
 
1828
1853
  cursors.updatedAt = new Date().toISOString();
1829
1854
  await writeJson(cursorsPath, cursors);
1855
+ if (syncDiagnostics) {
1856
+ const cursorStat = await fs.stat(cursorsPath);
1857
+ syncDiagnostics.cursor_commits = Number(syncDiagnostics.cursor_commits || 0) + 1;
1858
+ syncDiagnostics.cursor_bytes = Number(syncDiagnostics.cursor_bytes || 0) + cursorStat.size;
1859
+ syncDiagnostics.cursor_path = cursorsPath;
1860
+ }
1830
1861
  if (grokHookSignalConsumed && grokHookSignalPath) {
1831
1862
  await fs.unlink(grokHookSignalPath).catch(() => {});
1832
1863
  }
@@ -2097,6 +2128,7 @@ module.exports = {
2097
2128
  migrateCursorUnknownBuckets,
2098
2129
  migrateRolloutCumulativeDeltaBuckets,
2099
2130
  repairCodexRescanInflation,
2131
+ repairCodexForkReplayInflation,
2100
2132
  repairDroidDuplicateSessionInflation,
2101
2133
  repairMimoClaudeMislabel,
2102
2134
  reincludeClaudeMemObserverFiles,
@@ -2109,6 +2141,7 @@ module.exports = {
2109
2141
  CURSOR_UNKNOWN_MIGRATION_KEY,
2110
2142
  ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY,
2111
2143
  CODEX_RESCAN_DEDUP_REPAIR_KEY,
2144
+ CODEX_FORK_REPLAY_REPAIR_KEY,
2112
2145
  DROID_DUP_SESSION_REPAIR_KEY,
2113
2146
  CLAUDE_MEM_OBSERVER_REINCLUDE_KEY,
2114
2147
  GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY,
@@ -3091,6 +3124,11 @@ async function migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rollou
3091
3124
  delete cursors.files[filePath];
3092
3125
  }
3093
3126
  }
3127
+ // The migration clears Codex buckets and reparses the discovered corpus from
3128
+ // byte zero. Persisted event keys belong to the cleared buckets, so retaining
3129
+ // them can suppress the rebuild when a moved session still has an old path
3130
+ // cursor. Rebuild the hash inventory together with the buckets.
3131
+ cursors.codexHashes = [];
3094
3132
 
3095
3133
  const buckets = cursors.hourly?.buckets;
3096
3134
  const retractions = [];
@@ -3157,6 +3195,11 @@ async function repairCodexRescanInflation({
3157
3195
  projectQueuePath,
3158
3196
  projectQueueStatePath,
3159
3197
  rolloutFiles,
3198
+ // The fork-replay repair (#169 follow-up) re-runs this exact rebuild under
3199
+ // its own key: the rebuild always uses the CURRENT parser, so any parser fix
3200
+ // shipped since the last run is applied to the rebuilt history.
3201
+ migrationKey = CODEX_RESCAN_DEDUP_REPAIR_KEY,
3202
+ uploadNote = "reset_after_codex_rescan_dedup_2026_06",
3160
3203
  }) {
3161
3204
  if (!cursors || typeof cursors !== "object") return false;
3162
3205
  const migrations = (cursors.migrations ||= {});
@@ -3167,7 +3210,7 @@ async function repairCodexRescanInflation({
3167
3210
  // found). Treating the skip sentinel as "done" is what left users like #187
3168
3211
  // permanently stuck on the inflated value after upgrading (the key was truthy
3169
3212
  // so the guard never got a second chance).
3170
- const priorRepair = migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY];
3213
+ const priorRepair = migrations[migrationKey];
3171
3214
  if (priorRepair && !(typeof priorRepair === "object" && priorRepair.skipped)) return false;
3172
3215
 
3173
3216
  // Codex session files THIS sync discovered (source === "codex").
@@ -3203,7 +3246,7 @@ async function repairCodexRescanInflation({
3203
3246
  if (codexFileSet.has(fp)) continue; // exact file re-scanned this run
3204
3247
  const id = codexSessionIdFromPath(fp);
3205
3248
  if (id && scannedSessionIds.has(id)) continue; // same session scanned elsewhere (moved)
3206
- migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY] = {
3249
+ migrations[migrationKey] = {
3207
3250
  skipped: true,
3208
3251
  reason: "codex_session_unreproducible",
3209
3252
  at: new Date().toISOString(),
@@ -3462,7 +3505,7 @@ async function repairCodexRescanInflation({
3462
3505
  }
3463
3506
  uploadState.offset = 0;
3464
3507
  uploadState.updatedAt = new Date().toISOString();
3465
- uploadState.note = "reset_after_codex_rescan_dedup_2026_06";
3508
+ uploadState.note = uploadNote;
3466
3509
  await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
3467
3510
  }
3468
3511
  if (projectRepairEnabled && typeof projectQueueStatePath === "string" && projectQueueStatePath) {
@@ -3474,14 +3517,140 @@ async function repairCodexRescanInflation({
3474
3517
  }
3475
3518
  uploadState.offset = 0;
3476
3519
  uploadState.updatedAt = new Date().toISOString();
3477
- uploadState.note = "reset_after_codex_rescan_dedup_2026_06";
3520
+ uploadState.note = uploadNote;
3478
3521
  await fs.writeFile(projectQueueStatePath, JSON.stringify(uploadState));
3479
3522
  }
3480
3523
 
3481
- migrations[CODEX_RESCAN_DEDUP_REPAIR_KEY] = new Date().toISOString();
3524
+ migrations[migrationKey] = new Date().toISOString();
3482
3525
  return true;
3483
3526
  }
3484
3527
 
3528
+ // #169 follow-up: repair the historical inflation left by same-day forked
3529
+ // Codex rollout replays (see CODEX_FORK_REPLAY_REPAIR_KEY). Delegates to the
3530
+ // #187 guarded rebuild with the fork-repair key.
3531
+ async function repairCodexForkReplayInflation({
3532
+ cursors,
3533
+ queuePath,
3534
+ queueStatePath,
3535
+ projectQueuePath,
3536
+ projectQueueStatePath,
3537
+ rolloutFiles,
3538
+ // True when the #187 repair ran its rebuild earlier in THIS sync: that
3539
+ // rebuild already used the fork-aware parser, so the history is clean and a
3540
+ // second rebuild would be pure waste.
3541
+ legacyRepairRan = false,
3542
+ }) {
3543
+ if (!cursors || typeof cursors !== "object") return false;
3544
+ const migrations = (cursors.migrations ||= {});
3545
+ // Same completed-vs-skipped semantics as the #187 repair: an ISO string is
3546
+ // final, a {skipped:true} sentinel retries (the skip condition can clear).
3547
+ const prior = migrations[CODEX_FORK_REPLAY_REPAIR_KEY];
3548
+ if (prior && !(typeof prior === "object" && prior.skipped)) return false;
3549
+
3550
+ if (legacyRepairRan) {
3551
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = new Date().toISOString();
3552
+ return false;
3553
+ }
3554
+
3555
+ // Pre-gate: fork phantom can only exist on installs that have (or had) a
3556
+ // forked rollout. Scanning file heads is cheap; the rebuild re-parses every
3557
+ // codex file. Files forked AFTER the parser fix shipped never accrue
3558
+ // phantom, so a definitive "no forks" verdict may finalize — but ONLY when
3559
+ // this scan can actually stand for the install's whole codex history:
3560
+ // 1. every candidate head was readable (an unreadable candidate could be
3561
+ // the one forked rollout), AND
3562
+ // 2. every codex session recorded in cursors is covered by THIS scan's
3563
+ // file list (same UUID-keyed reproducibility rule as the #187 guard).
3564
+ // A forked rollout can be real yet absent from one run's rolloutFiles —
3565
+ // WSL/native split probing, a temporarily missing ~/.codex, a deleted
3566
+ // file whose phantom is already in history — and finalizing off such a
3567
+ // partial view would permanently foreclose the repair this migration
3568
+ // exists to perform, while the guarded repair itself would only have
3569
+ // deferred retryably.
3570
+ // Either failure defers with the retryable {skipped:true} sentinel.
3571
+ const forkScan = await scanForForkedCodexRollout(rolloutFiles);
3572
+ if (!forkScan.forked) {
3573
+ if (forkScan.indeterminate) {
3574
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = {
3575
+ skipped: true,
3576
+ reason: "fork_scan_indeterminate",
3577
+ at: new Date().toISOString(),
3578
+ };
3579
+ return false;
3580
+ }
3581
+ const scannedSessionIds = new Set();
3582
+ const scannedPaths = new Set();
3583
+ for (const entry of Array.isArray(rolloutFiles) ? rolloutFiles : []) {
3584
+ const fp = typeof entry === "string" ? entry : entry?.path;
3585
+ const src = typeof entry === "string" ? "codex" : String(entry?.source || "codex");
3586
+ if (!fp || src !== "codex") continue;
3587
+ scannedPaths.add(fp);
3588
+ const id = codexSessionIdFromPath(fp);
3589
+ if (id) scannedSessionIds.add(id);
3590
+ }
3591
+ if (cursors.files && typeof cursors.files === "object") {
3592
+ for (const fp of Object.keys(cursors.files)) {
3593
+ if (!isCodexSessionCursorPath(fp)) continue;
3594
+ if (scannedPaths.has(fp)) continue;
3595
+ const id = codexSessionIdFromPath(fp);
3596
+ if (id && scannedSessionIds.has(id)) continue;
3597
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = {
3598
+ skipped: true,
3599
+ reason: "codex_history_not_covered",
3600
+ at: new Date().toISOString(),
3601
+ };
3602
+ return false;
3603
+ }
3604
+ }
3605
+ migrations[CODEX_FORK_REPLAY_REPAIR_KEY] = new Date().toISOString();
3606
+ return false;
3607
+ }
3608
+
3609
+ return repairCodexRescanInflation({
3610
+ cursors,
3611
+ queuePath,
3612
+ queueStatePath,
3613
+ projectQueuePath,
3614
+ projectQueueStatePath,
3615
+ rolloutFiles,
3616
+ migrationKey: CODEX_FORK_REPLAY_REPAIR_KEY,
3617
+ uploadNote: "reset_after_codex_fork_replay_2026_07",
3618
+ });
3619
+ }
3620
+
3621
+ // Scans codex rollout heads for the fork marker. The child session_meta (with
3622
+ // forked_from_id) is the FIRST line of a forked rollout — observed at byte
3623
+ // offset ≤169 across every local sample — so a bounded head read is
3624
+ // sufficient. 64KB leaves ample margin for long base_instructions preceding
3625
+ // it; a substring false positive (the marker quoted in unrelated head content)
3626
+ // only costs one redundant guarded rebuild, never data. Tri-state result:
3627
+ // {forked:true} on a hit, {forked:false, indeterminate:false} only when every
3628
+ // candidate was inspected, {forked:false, indeterminate:true} when any head
3629
+ // read failed — the caller must not finalize "no forks" off a failed read.
3630
+ async function scanForForkedCodexRollout(rolloutFiles) {
3631
+ const HEAD_BYTES = 65536;
3632
+ let indeterminate = false;
3633
+ for (const entry of Array.isArray(rolloutFiles) ? rolloutFiles : []) {
3634
+ const fp = typeof entry === "string" ? entry : entry?.path;
3635
+ const src = typeof entry === "string" ? "codex" : String(entry?.source || "codex");
3636
+ if (!fp || src !== "codex") continue;
3637
+ let fh = null;
3638
+ try {
3639
+ fh = await fs.open(fp, "r");
3640
+ const buf = Buffer.alloc(HEAD_BYTES);
3641
+ const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
3642
+ if (buf.toString("utf8", 0, bytesRead).includes('"forked_from_id"')) {
3643
+ return { forked: true, indeterminate: false };
3644
+ }
3645
+ } catch (_e) {
3646
+ indeterminate = true;
3647
+ } finally {
3648
+ if (fh) await fh.close().catch(() => {});
3649
+ }
3650
+ }
3651
+ return { forked: false, indeterminate };
3652
+ }
3653
+
3485
3654
  function isCodexSessionCursorPath(filePath) {
3486
3655
  if (typeof filePath !== "string") return false;
3487
3656
  const normalized = filePath.replace(/\\/g, "/");
@@ -55,12 +55,12 @@ async function restoreNotify({ configPath, notifyOriginalPath, expectedNotify })
55
55
  const originalNotify = Array.isArray(original?.notify) ? original.notify : null;
56
56
  const currentNotify = extractNotify(text);
57
57
 
58
- if (expectedNotify && !arraysEqual(currentNotify, expectedNotify)) {
59
- return { restored: false, skippedReason: "current-not-managed" };
58
+ if (!originalNotify && expectedNotify && currentNotify == null) {
59
+ return { restored: false, skippedReason: "no-backup-not-installed" };
60
60
  }
61
61
 
62
- if (!originalNotify && expectedNotify && !arraysEqual(currentNotify, expectedNotify)) {
63
- return { restored: false, skippedReason: "no-backup-not-installed" };
62
+ if (expectedNotify && !arraysEqual(currentNotify, expectedNotify)) {
63
+ return { restored: false, skippedReason: "current-not-managed" };
64
64
  }
65
65
 
66
66
  const updated = originalNotify ? setNotify(text, originalNotify) : removeNotify(text);
@@ -161,15 +161,16 @@ function extractNotify(text) {
161
161
  const lines = text.split(/\r?\n/);
162
162
  for (let i = 0; i < lines.length; i++) {
163
163
  const line = lines[i];
164
+ if (isTomlTableHeader(line)) break;
164
165
  const m = line.match(/^\s*notify\s*=\s*(.*)\s*$/);
165
166
  if (!m) continue;
166
167
 
167
168
  const rhs = (m[1] || "").trim();
168
169
  const literal = readTomlArrayLiteral(lines, i, rhs);
169
- if (!literal) continue;
170
+ if (!literal) return null;
170
171
 
171
172
  const parsed = parseTomlStringArray(literal);
172
- if (parsed) return parsed;
173
+ return parsed;
173
174
  }
174
175
  return null;
175
176
  }
@@ -182,6 +183,14 @@ function setNotify(text, notifyCmd) {
182
183
  let replaced = false;
183
184
  for (let i = 0; i < lines.length; i++) {
184
185
  const line = lines[i];
186
+ if (isTomlTableHeader(line)) {
187
+ if (!replaced) {
188
+ out.push(notifyLine);
189
+ replaced = true;
190
+ }
191
+ out.push(...lines.slice(i));
192
+ break;
193
+ }
185
194
  const m = line.match(/^\s*notify\s*=\s*(.*)\s*$/);
186
195
  if (m) {
187
196
  if (!replaced) {
@@ -190,7 +199,7 @@ function setNotify(text, notifyCmd) {
190
199
  }
191
200
 
192
201
  const rhs = (m[1] || "").trim();
193
- i = findTomlArrayBlockEnd(lines, i, rhs);
202
+ i = findNotifyBlockEnd(lines, i, rhs);
194
203
  continue;
195
204
  }
196
205
  out.push(line);
@@ -211,10 +220,14 @@ function removeNotify(text) {
211
220
  const out = [];
212
221
  for (let i = 0; i < lines.length; i++) {
213
222
  const line = lines[i];
223
+ if (isTomlTableHeader(line)) {
224
+ out.push(...lines.slice(i));
225
+ break;
226
+ }
214
227
  const m = line.match(/^\s*notify\s*=\s*(.*)\s*$/);
215
228
  if (m) {
216
229
  const rhs = (m[1] || "").trim();
217
- i = findTomlArrayBlockEnd(lines, i, rhs);
230
+ i = findNotifyBlockEnd(lines, i, rhs);
218
231
  continue;
219
232
  }
220
233
  out.push(line);
@@ -225,45 +238,78 @@ function removeNotify(text) {
225
238
  function parseTomlStringArray(rhs) {
226
239
  // Minimal parser for TOML basic/literal string arrays.
227
240
  if (!rhs.startsWith("[") || !rhs.endsWith("]")) return null;
228
- const inner = rhs.slice(1, -1).trim();
229
- if (!inner) return [];
230
241
 
231
242
  const parts = [];
232
- let current = "";
233
- let inString = false;
234
- let quote = null;
235
- for (let i = 0; i < inner.length; i++) {
236
- const ch = inner[i];
237
- if (!inString) {
238
- if (ch === '"' || ch === "'") {
239
- inString = true;
240
- quote = ch;
241
- current = "";
243
+ let i = 1;
244
+ let expectValue = true;
245
+
246
+ function skipSpaceAndComments() {
247
+ while (i < rhs.length) {
248
+ const ch = rhs[i];
249
+ if (/\s/.test(ch)) {
250
+ i += 1;
251
+ continue;
242
252
  }
243
- continue;
253
+ if (ch === "#") {
254
+ while (i < rhs.length && rhs[i] !== "\n") i += 1;
255
+ continue;
256
+ }
257
+ break;
244
258
  }
245
- if (ch === quote) {
246
- parts.push(current);
247
- inString = false;
248
- quote = null;
249
- continue;
259
+ }
260
+
261
+ function parseString() {
262
+ const quote = rhs[i];
263
+ if (quote !== '"' && quote !== "'") return null;
264
+ i += 1;
265
+ let value = "";
266
+ while (i < rhs.length) {
267
+ const ch = rhs[i];
268
+ if (ch === quote) {
269
+ i += 1;
270
+ return value;
271
+ }
272
+ if (ch === "\n" || ch === "\r") return null;
273
+ if (quote === '"' && ch === "\\") {
274
+ if (i + 1 >= rhs.length) return null;
275
+ const next = rhs[i + 1];
276
+ if (next === "b") value += "\b";
277
+ else if (next === "t") value += "\t";
278
+ else if (next === "n") value += "\n";
279
+ else if (next === "f") value += "\f";
280
+ else if (next === "r") value += "\r";
281
+ else if (next === '"' || next === "\\" || next === "/") value += next;
282
+ else return null;
283
+ i += 2;
284
+ continue;
285
+ }
286
+ value += ch;
287
+ i += 1;
288
+ }
289
+ return null;
290
+ }
291
+
292
+ while (i < rhs.length) {
293
+ skipSpaceAndComments();
294
+ if (rhs[i] === "]") {
295
+ i += 1;
296
+ skipSpaceAndComments();
297
+ return i === rhs.length ? parts : null;
250
298
  }
251
- if (quote === '"' && ch === "\\") {
252
- if (i + 1 >= inner.length) return null;
253
- const next = inner[++i];
254
- if (next === "b") current += "\b";
255
- else if (next === "t") current += "\t";
256
- else if (next === "n") current += "\n";
257
- else if (next === "f") current += "\f";
258
- else if (next === "r") current += "\r";
259
- else if (next === '"' || next === "\\" || next === "/") current += next;
260
- else return null;
299
+ if (!expectValue) {
300
+ if (rhs[i] !== ",") return null;
301
+ i += 1;
302
+ expectValue = true;
261
303
  continue;
262
304
  }
263
- current += ch;
305
+
306
+ const value = parseString();
307
+ if (value == null) return null;
308
+ parts.push(value);
309
+ expectValue = false;
264
310
  }
265
311
 
266
- return parts.length > 0 ? parts : null;
312
+ return null;
267
313
  }
268
314
 
269
315
  function formatTomlStringArray(arr) {
@@ -278,6 +324,7 @@ function readTomlArrayLiteral(lines, startIndex, rhs) {
278
324
  let quote = null;
279
325
  let depth = 0;
280
326
  let sawOpen = false;
327
+ let invalid = false;
281
328
 
282
329
  function scanChunk(chunk) {
283
330
  for (let i = 0; i < chunk.length; i++) {
@@ -295,7 +342,10 @@ function readTomlArrayLiteral(lines, startIndex, rhs) {
295
342
  }
296
343
  if (ch === "]") {
297
344
  depth -= 1;
298
- if (sawOpen && depth === 0) return i;
345
+ if (sawOpen && depth === 0) {
346
+ if (!isTomlArrayTrailer(chunk.slice(i + 1))) invalid = true;
347
+ return i;
348
+ }
299
349
  }
300
350
  continue;
301
351
  }
@@ -305,6 +355,10 @@ function readTomlArrayLiteral(lines, startIndex, rhs) {
305
355
  continue;
306
356
  }
307
357
  if (quote === '"' && ch === "\\") {
358
+ if (i + 1 >= chunk.length) {
359
+ invalid = true;
360
+ return -1;
361
+ }
308
362
  i += 1;
309
363
  }
310
364
  }
@@ -313,11 +367,13 @@ function readTomlArrayLiteral(lines, startIndex, rhs) {
313
367
 
314
368
  const parts = [first];
315
369
  let endPos = scanChunk(first);
370
+ if (invalid) return null;
316
371
  if (endPos !== -1) return first.slice(0, endPos + 1).trim();
317
372
 
318
373
  for (let j = startIndex + 1; j < lines.length; j++) {
319
374
  const line = lines[j];
320
375
  endPos = scanChunk(line);
376
+ if (invalid) return null;
321
377
  if (endPos !== -1) {
322
378
  parts.push(line.slice(0, endPos + 1));
323
379
  return parts.join("\n").trim();
@@ -336,6 +392,7 @@ function findTomlArrayBlockEnd(lines, startIndex, rhs) {
336
392
  let quote = null;
337
393
  let depth = 0;
338
394
  let sawOpen = false;
395
+ let invalid = false;
339
396
 
340
397
  function scanChunk(chunk) {
341
398
  for (let i = 0; i < chunk.length; i++) {
@@ -353,7 +410,10 @@ function findTomlArrayBlockEnd(lines, startIndex, rhs) {
353
410
  }
354
411
  if (ch === "]") {
355
412
  depth -= 1;
356
- if (sawOpen && depth === 0) return true;
413
+ if (sawOpen && depth === 0) {
414
+ if (!isTomlArrayTrailer(chunk.slice(i + 1))) invalid = true;
415
+ return true;
416
+ }
357
417
  }
358
418
  continue;
359
419
  }
@@ -363,6 +423,10 @@ function findTomlArrayBlockEnd(lines, startIndex, rhs) {
363
423
  continue;
364
424
  }
365
425
  if (quote === '"' && ch === "\\") {
426
+ if (i + 1 >= chunk.length) {
427
+ invalid = true;
428
+ return false;
429
+ }
366
430
  i += 1;
367
431
  }
368
432
  }
@@ -370,12 +434,43 @@ function findTomlArrayBlockEnd(lines, startIndex, rhs) {
370
434
  }
371
435
 
372
436
  if (scanChunk(first)) return startIndex;
437
+ if (invalid) return startIndex;
373
438
  for (let j = startIndex + 1; j < lines.length; j++) {
374
439
  if (scanChunk(lines[j])) return j;
440
+ if (invalid) return startIndex;
375
441
  }
376
442
  return startIndex;
377
443
  }
378
444
 
445
+ function findNotifyBlockEnd(lines, startIndex, rhs) {
446
+ const endIndex = findTomlArrayBlockEnd(lines, startIndex, rhs);
447
+ const literal = readTomlArrayLiteral(lines, startIndex, rhs);
448
+ if (
449
+ endIndex !== startIndex ||
450
+ !rhs.trim().startsWith("[") ||
451
+ (literal && parseTomlStringArray(literal))
452
+ ) {
453
+ return endIndex;
454
+ }
455
+
456
+ for (let j = startIndex + 1; j < lines.length; j++) {
457
+ if (isTopLevelTomlBoundary(lines[j])) return j - 1;
458
+ }
459
+ return lines.length - 1;
460
+ }
461
+
462
+ function isTomlTableHeader(line) {
463
+ return /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/.test(line);
464
+ }
465
+
466
+ function isTopLevelTomlBoundary(line) {
467
+ return /^\s*(?:\[\[?[^\]]+\]\]?|(?:[A-Za-z0-9_-]+|"[^"]+"|'[^']+')(?:\s*\.\s*(?:[A-Za-z0-9_-]+|"[^"]+"|'[^']+'))*\s*=)/.test(line);
468
+ }
469
+
470
+ function isTomlArrayTrailer(text) {
471
+ return /^\s*(?:#.*)?$/.test(text);
472
+ }
473
+
379
474
  function arraysEqual(a, b) {
380
475
  if (!Array.isArray(a) || !Array.isArray(b)) return false;
381
476
  if (a.length !== b.length) return false;