tokentracker-cli 0.87.14 → 0.87.16

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 (109) hide show
  1. package/README.de.md +2 -2
  2. package/README.ja.md +2 -2
  3. package/README.ko.md +2 -2
  4. package/README.md +3 -3
  5. package/README.zh-CN.md +2 -2
  6. package/dashboard/dist/assets/{AchievementBadge-D06ffgXR.js → AchievementBadge-DaKHLTL2.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-D6uGfLgx.js → AchievementsPage-DJdtjodG.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-Chjq16ns.js → AchievementsSection-DVtxGDIP.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-D42zXSN1.js → BadgeDetailModal-CF2dlQkT.js} +1 -1
  10. package/dashboard/dist/assets/{Card-DGOEtq_3.js → Card-DuQWS5aB.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-B6HFLXEb.js → ClawdAnimated-l8AN6_pc.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-1tyBT_Gf.js → CommandPalette-DWKyGhWG.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-Cvbk67o1.js → CommunityStatsModal-CoJ9i3QH.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-RHQRpQG1.js → ConfirmModal-DM6A6p4g.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-WATRUYwX.js → Controls-DBUMibne.js} +1 -1
  16. package/dashboard/dist/assets/{DashboardPage-B-jHLRu6.js → DashboardPage-BgVXjT0v.js} +1 -1
  17. package/dashboard/dist/assets/{DevicePage-CgYxNWgU.js → DevicePage-Beby6BhC.js} +1 -1
  18. package/dashboard/dist/assets/{DialogClose-CIQziRo4.js → DialogClose-nwx-N3WL.js} +1 -1
  19. package/dashboard/dist/assets/DialogDescription-BFVB627n.js +1 -0
  20. package/dashboard/dist/assets/{DialogTitle-DG5OaNy0.js → DialogTitle-Cw53obf0.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-BmtyZXeb.js → FadeIn-CTTwoVh-.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-Cg7Ap56b.js → HeaderGithubStar-BvaEHM9B.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-Dh1v-yph.js → HoverTooltip-CbJqz6cF.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-CqQqdE3D.js → IpCheckPage-I0REWeew.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-Ct4m67m9.js → LandingPage-CuMWPQBC.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardAvatar-3ZA9tDrh.js → LeaderboardAvatar-MHfIP4_U.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardPage-CQZf8G8t.js → LeaderboardPage-DfDM-dH9.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardProfileModal-KaaGOK3_.js → LeaderboardProfileModal-hmpU-JV7.js} +3 -3
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-DsiYvLlz.js → LeaderboardProfilePage-DtUb4dDZ.js} +1 -1
  30. package/dashboard/dist/assets/LimitsPage-CtA0c2G5.js +4 -0
  31. package/dashboard/dist/assets/{LocalOnlyNotice-DMrkrlZY.js → LocalOnlyNotice-WI3Oke4U.js} +1 -1
  32. package/dashboard/dist/assets/{LoginCard-BFQJZmKV.js → LoginCard-DMprjRI7.js} +1 -1
  33. package/dashboard/dist/assets/{LoginModal-Dx4_4UfW.js → LoginModal-DffmK0ro.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-AmxdJOiw.js → LoginPage-B2ZgB6K7.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-DbMcLOoc.js → PetPage-D_U7yXgF.js} +1 -1
  36. package/dashboard/dist/assets/{PopoverPopup-CwOPhh6g.js → PopoverPopup-Ph-9l9jU.js} +1 -1
  37. package/dashboard/dist/assets/{ProviderIcon-C2183DbB.js → ProviderIcon-D2s_KSYC.js} +1 -1
  38. package/dashboard/dist/assets/{ResetPasswordPage-CwQt9Jh0.js → ResetPasswordPage-Cpg2C8BI.js} +1 -1
  39. package/dashboard/dist/assets/{SegmentedControl-2gfEdOHR.js → SegmentedControl-C89jlPzS.js} +1 -1
  40. package/dashboard/dist/assets/{Select-BknJIua6.js → Select-BLPgobGl.js} +1 -1
  41. package/dashboard/dist/assets/{SelectItemText-BNfM6qJ0.js → SelectItemText-CLTEQqQZ.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-_C25Txd5.js → ServiceStatusPage-Bd1ec7bz.js} +1 -1
  43. package/dashboard/dist/assets/{SessionsPage-B_QkotAc.js → SessionsPage-HPbQDLZ5.js} +1 -1
  44. package/dashboard/dist/assets/{SettingsPage-B-vif0ea.js → SettingsPage-CwfcRYYu.js} +1 -1
  45. package/dashboard/dist/assets/{SkillsPage-BX2rApOM.js → SkillsPage-DflhF8pa.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-C9LrhOJ1.js → TokenGalaxy-CylVSV2J.js} +1 -1
  47. package/dashboard/dist/assets/{ToolbarRootContext-Bevy5CSh.js → ToolbarRootContext-BzBAJ8C5.js} +1 -1
  48. package/dashboard/dist/assets/{TrendMonitor-C6axjU1E.js → TrendMonitor-B0IzqLaN.js} +1 -1
  49. package/dashboard/dist/assets/{WidgetsPage-gmpxGRU-.js → WidgetsPage-Bvt5JCoS.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-Lyyj4Lfy.js → WrappedPage-BXjDpjD_.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-DsFkaOPr.js → __vite-browser-external-CDv96ZcG.js} +14 -11
  52. package/dashboard/dist/assets/{agent-logos-CmI9Jrx-.js → agent-logos-bPIt1QVl.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-up-right-BDsE7MjI.js → arrow-up-right-C_6NrnRB.js} +1 -1
  54. package/dashboard/dist/assets/{check-Dv03s0Oi.js → check-ovHAYNwt.js} +1 -1
  55. package/dashboard/dist/assets/{chevron-right-Bg0LlTK2.js → chevron-right-BeHvjzPf.js} +1 -1
  56. package/dashboard/dist/assets/{copy-M7jcXrOp.js → copy-DRQHwFKm.js} +1 -1
  57. package/dashboard/dist/assets/{download-B7-6bJLc.js → download-qPfSWDyf.js} +1 -1
  58. package/dashboard/dist/assets/{external-link-C2dWQL59.js → external-link-t7Qbyp7r.js} +1 -1
  59. package/dashboard/dist/assets/{flame-fuODmpET.js → flame-DlRdMOxO.js} +1 -1
  60. package/dashboard/dist/assets/{format-tokens-BKb3uo9Y.js → format-tokens-DP1DrOxZ.js} +1 -1
  61. package/dashboard/dist/assets/{icons-CZ3rB5D9.js → icons-DBgKdjpV.js} +1 -1
  62. package/dashboard/dist/assets/{index-CMfErAEy.js → index-BFaAjCwg.js} +1 -1
  63. package/dashboard/dist/assets/{index-CyzVAu8L.js → index-C1wNjCL_.js} +1 -1
  64. package/dashboard/dist/assets/{index-DSLv7Mj8.js → index-Dtzl1NhK.js} +1 -1
  65. package/dashboard/dist/assets/{info-gtQU6mdi.js → info-D9fn_ZQQ.js} +1 -1
  66. package/dashboard/dist/assets/limits-providers-JyTKN-3e.js +1 -0
  67. package/dashboard/dist/assets/{link-2-DAoauLvL.js → link-2-CmnuQiZs.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-C6-hnHM5.js → loader-circle-Bm3FqDLX.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-DBWhzAmU.js → maximize-2-CutKDynW.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-Cg5lp-7f.js → modifiers.esm-C94MH21S.js} +1 -1
  71. package/dashboard/dist/assets/{monitor-smartphone-BVkQLoJM.js → monitor-smartphone-wSLhNWq5.js} +1 -1
  72. package/dashboard/dist/assets/{provider-display-N7MWKGSI.js → provider-display-CR8o_M3v.js} +1 -1
  73. package/dashboard/dist/assets/{react-DDPYgpAM.js → react-Cc1Ca2uE.js} +1 -1
  74. package/dashboard/dist/assets/{search-M9zIhfTN.js → search-BXeDDAWS.js} +1 -1
  75. package/dashboard/dist/assets/{skills-api-DPJzchQg.js → skills-api-DBP8kM7W.js} +1 -1
  76. package/dashboard/dist/assets/{sortable.esm-B1tZenH5.js → sortable.esm-Cd_ODlnX.js} +1 -1
  77. package/dashboard/dist/assets/{store-DmPQtoHI.js → store-lmptvFHG.js} +1 -1
  78. package/dashboard/dist/assets/{terminal-BpvBtoDx.js → terminal-965BMhR0.js} +1 -1
  79. package/dashboard/dist/assets/{trash-2-XKMDw3cY.js → trash-2-wq5YeaAT.js} +1 -1
  80. package/dashboard/dist/assets/{use-community-stats-B1BMhURp.js → use-community-stats-BCIgHdTZ.js} +1 -1
  81. package/dashboard/dist/assets/{use-limits-display-prefs-DaZnSXop.js → use-limits-display-prefs-D0MB2ewv.js} +1 -1
  82. package/dashboard/dist/assets/{use-ordered-list-DKa6gE-O.js → use-ordered-list-DrQsh022.js} +1 -1
  83. package/dashboard/dist/assets/use-reduced-motion-DhNppA5X.js +1 -0
  84. package/dashboard/dist/assets/{use-session-efficiency-pref-B9I7_v4k.js → use-session-efficiency-pref-BeBSADa-.js} +1 -1
  85. package/dashboard/dist/assets/{use-transform-Ce5ESEGX.js → use-transform-fRpwp-G3.js} +1 -1
  86. package/dashboard/dist/assets/{use-usage-limits-Ex51qVV9.js → use-usage-limits-C0Tk-ghm.js} +1 -1
  87. package/dashboard/dist/assets/{useCurrency-8kmm6K74.js → useCurrency-BAZNRyKh.js} +1 -1
  88. package/dashboard/dist/assets/{useScrollLock-D3ximygL.js → useScrollLock-CU659jBG.js} +1 -1
  89. package/dashboard/dist/assets/{useTokenFormat-BtVvEcPK.js → useTokenFormat-l0Xd62a5.js} +1 -1
  90. package/dashboard/dist/assets/{zap-y-504T0t.js → zap-CWFkq16a.js} +1 -1
  91. package/dashboard/dist/index.html +4 -4
  92. package/dashboard/dist/ip-check.html +1 -1
  93. package/dashboard/dist/leaderboard.html +1 -1
  94. package/dashboard/dist/share.html +1 -1
  95. package/package.json +1 -1
  96. package/src/commands/serve.js +11 -3
  97. package/src/commands/status.js +34 -0
  98. package/src/commands/sync.js +95 -1
  99. package/src/lib/diagnostics.js +10 -0
  100. package/src/lib/fs.js +79 -1
  101. package/src/lib/pricing/seed-snapshot.json +1 -1
  102. package/src/lib/qoder-limits.js +89 -34
  103. package/src/lib/rollout.js +226 -43
  104. package/src/lib/session-analytics.js +45 -5
  105. package/src/lib/usage-limits.js +93 -14
  106. package/dashboard/dist/assets/DialogDescription-CMZSKLXB.js +0 -1
  107. package/dashboard/dist/assets/LimitsPage-Dv1fg4Jh.js +0 -4
  108. package/dashboard/dist/assets/limits-providers-B9o4daVn.js +0 -1
  109. package/dashboard/dist/assets/use-reduced-motion-fPvY8iYM.js +0 -1
@@ -66,6 +66,7 @@ const {
66
66
  resolveRoocodeTaskFiles,
67
67
  resolveZedDbPath,
68
68
  resolveQoderDbPaths,
69
+ resolveQoderCnDbPaths,
69
70
  resolveClaudeScienceDbPaths,
70
71
  resolveAnythingllmDbPath,
71
72
  resolveGooseDbPath,
@@ -118,6 +119,7 @@ async function cmdStatus(argv = []) {
118
119
  const throttlePath = path.join(trackerDir, "sync.throttle");
119
120
  const uploadThrottlePath = path.join(trackerDir, "upload.throttle.json");
120
121
  const autoRetryPath = path.join(trackerDir, "auto.retry.json");
122
+ const syncSkipPath = path.join(trackerDir, "sync.skip.json");
121
123
  const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
122
124
  const codexConfigPath = path.join(codexHome, "config.toml");
123
125
  const codeHome = process.env.CODE_HOME || path.join(home, ".code");
@@ -153,6 +155,9 @@ async function cmdStatus(argv = []) {
153
155
  await readJson(uploadThrottlePath),
154
156
  );
155
157
  const autoRetry = await readJson(autoRetryPath);
158
+ // Written by sync whenever it had to skip a run. Present here so a stalled
159
+ // parse has a visible cause instead of only a frozen "Last parse".
160
+ const syncSkip = await readJson(syncSkipPath);
156
161
 
157
162
  const queueSize = await safeStatSize(queuePath);
158
163
  const pendingBytes = Math.max(0, queueSize - (queueState.offset || 0));
@@ -212,6 +217,11 @@ async function cmdStatus(argv = []) {
212
217
  const lastUploadError = uploadThrottle.lastError
213
218
  ? `${uploadThrottle.lastErrorAt || "unknown"} ${uploadThrottle.lastError}`
214
219
  : null;
220
+ const syncSkipLine = syncSkip?.at
221
+ ? `- Last sync skipped: ${syncSkip.at} (${syncSkip.reason || "unknown"}${
222
+ syncSkip.detail ? `, ${syncSkip.detail}` : ""
223
+ })`
224
+ : null;
215
225
  const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs || null);
216
226
  const autoRetryLine = autoRetryAt
217
227
  ? `- Auto retry after: ${autoRetryAt} (${autoRetry?.reason || "scheduled"}, pending ${Number(
@@ -428,6 +438,16 @@ async function cmdStatus(argv = []) {
428
438
  const qoderInstalled = qoderActive.length > 0;
429
439
  const qoderDbPath = qoderActive.join(" | ");
430
440
 
441
+ // Qoder CN (国内版) — same schema, separate Application Support/QoderCN dir.
442
+ const qoderCnPaths = resolveQoderCnDbPaths({
443
+ home,
444
+ env: process.env,
445
+ platform: process.platform,
446
+ });
447
+ const qoderCnActive = formatResolvedPaths(qoderCnPaths);
448
+ const qoderCnInstalled = qoderCnActive.length > 0;
449
+ const qoderCnDbPath = qoderCnActive.join(" | ");
450
+
431
451
  // Claude Science — token usage lives on the `frames` table of operon-cli.db.
432
452
  // Unlike the native/WSL pair other providers resolve to, this is an open-ended
433
453
  // list: multi-org installs keep one DB per org (and on Windows they all sit
@@ -731,6 +751,7 @@ async function cmdStatus(argv = []) {
731
751
  next_upload_after: nextUpload || null,
732
752
  backoff_until: backoffUntil || null,
733
753
  last_upload_error: lastUploadError || null,
754
+ last_sync_skipped: syncSkip?.at ? syncSkip : null,
734
755
  auto_retry: autoRetry || null,
735
756
  hooks: {
736
757
  codex_notify: notifyConfigured,
@@ -802,6 +823,9 @@ async function cmdStatus(argv = []) {
802
823
  qoder: qoderInstalled
803
824
  ? { installed: true, detail: qoderDbPath }
804
825
  : { installed: false },
826
+ "qoder-cn": qoderCnInstalled
827
+ ? { installed: true, detail: qoderCnDbPath }
828
+ : { installed: false },
805
829
  "claude-science": claudeScienceInstalled
806
830
  ? { installed: true, detail: claudeScienceDbPath }
807
831
  : { installed: false },
@@ -875,6 +899,7 @@ async function cmdStatus(argv = []) {
875
899
  `- Next upload after: ${nextUpload || "never"}`,
876
900
  `- Backoff until: ${backoffUntil || "never"}`,
877
901
  lastUploadError ? `- Last upload error: ${lastUploadError}` : null,
902
+ syncSkipLine,
878
903
  autoRetryLine,
879
904
  `- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : "unset"}`,
880
905
  `- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : "unset"}`,
@@ -926,6 +951,9 @@ async function cmdStatus(argv = []) {
926
951
  qoderInstalled
927
952
  ? `- Qoder: passive reader (${qoderDbPath})`
928
953
  : null,
954
+ qoderCnInstalled
955
+ ? `- Qoder CN: passive reader (${qoderCnDbPath})`
956
+ : null,
929
957
  claudeScienceInstalled
930
958
  ? `- Claude Science: passive reader (${claudeScienceDbPath})`
931
959
  : null,
@@ -1133,6 +1161,12 @@ function renderLightTable(summary) {
1133
1161
  push("Next upload after", summary.next_upload_after);
1134
1162
  push("Backoff until", summary.backoff_until);
1135
1163
  if (summary.last_upload_error) push("Last upload error", summary.last_upload_error);
1164
+ if (summary.last_sync_skipped) {
1165
+ push(
1166
+ "Last sync skipped",
1167
+ `${summary.last_sync_skipped.at} (${summary.last_sync_skipped.reason || "unknown"})`,
1168
+ );
1169
+ }
1136
1170
 
1137
1171
  for (const [name, state] of Object.entries(summary.hooks || {})) {
1138
1172
  push(`Hook · ${name}`, state ? "set" : "unset");
@@ -14,6 +14,7 @@ const {
14
14
  writeJson,
15
15
  chmod600IfPossible,
16
16
  openLock,
17
+ inspectLock,
17
18
  } = require("../lib/fs");
18
19
  const { physicalJsonlRecords } = require("../lib/jsonl-lines");
19
20
  const {
@@ -28,6 +29,7 @@ const {
28
29
  readMimoDbMessages,
29
30
  readZcodeDbMessages,
30
31
  resolveQoderDbPaths,
32
+ resolveQoderCnDbPaths,
31
33
  readQoderDbMessages,
32
34
  resolveKiroDbPath,
33
35
  resolveKiroJsonlPath,
@@ -277,6 +279,7 @@ const AUTO_SYNC_SOURCES = new Set([
277
279
  "openclaw",
278
280
  "pi",
279
281
  "qoder",
282
+ "qoder-cn",
280
283
  "roocode",
281
284
  "workbuddy",
282
285
  "zcode",
@@ -397,6 +400,38 @@ async function acquireSyncLock(
397
400
  }
398
401
  }
399
402
 
403
+ const SYNC_SKIP_MARKER = "sync.skip.json";
404
+
405
+ // A skipped sync used to exit 0 with no output whatsoever, so lock debris left
406
+ // by a killed run stalled every parse for a day with no visible signal and
407
+ // nothing for `tokentracker status` to report (issue #431). Leave a queryable
408
+ // marker that separates ordinary contention (`lock_busy`) from a lease whose
409
+ // owner is gone (`lock_debris`, the state that needs attention).
410
+ async function recordSyncSkip(trackerDir, lockPath) {
411
+ const holder = await inspectLock(lockPath);
412
+ const reason = holder.exists && holder.pid && !holder.alive ? "lock_debris" : "lock_busy";
413
+ const detail = !holder.exists
414
+ ? "sync lock could not be acquired"
415
+ : holder.pid
416
+ ? `sync lock held by pid ${holder.pid} (${holder.alive ? "running" : "not running"})`
417
+ : "sync lock has no owner record";
418
+ await writeJson(path.join(trackerDir, SYNC_SKIP_MARKER), {
419
+ reason,
420
+ detail,
421
+ at: new Date().toISOString(),
422
+ lockPath,
423
+ });
424
+ return `Sync skipped: ${reason} — ${detail}\n`;
425
+ }
426
+
427
+ async function clearSyncSkip(trackerDir) {
428
+ try {
429
+ await fs.unlink(path.join(trackerDir, SYNC_SKIP_MARKER));
430
+ } catch (error) {
431
+ if (error?.code !== "ENOENT") throw error;
432
+ }
433
+ }
434
+
400
435
  async function cmdSync(argv, context = {}) {
401
436
  const opts = parseArgs(argv);
402
437
  const diagnostics = context && typeof context === "object" ? context.diagnostics : null;
@@ -417,7 +452,13 @@ async function cmdSync(argv, context = {}) {
417
452
 
418
453
  const lockPath = path.join(trackerDir, "sync.lock");
419
454
  const lock = await acquireSyncLock(lockPath, opts, lockWaitOptions);
420
- if (!lock) return;
455
+ if (!lock) {
456
+ // Warn on stderr so the notice reaches interactive and background runs
457
+ // alike without disturbing anything that parses sync's stdout.
458
+ process.stderr.write(await recordSyncSkip(trackerDir, lockPath));
459
+ return;
460
+ }
461
+ await clearSyncSkip(trackerDir);
421
462
 
422
463
  let progress = null;
423
464
  try {
@@ -1159,6 +1200,57 @@ async function cmdSync(argv, context = {}) {
1159
1200
  }
1160
1201
  }
1161
1202
 
1203
+ // ── Qoder CN (国内版) — same SharedClientCache/local.db schema, separate
1204
+ // Application Support/QoderCN data directory. Tracked as its own source
1205
+ // with its own cursor namespace: the two DBs each number rowids from 1, so
1206
+ // a shared cursor would mis-dedup across installs.
1207
+ let qoderCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1208
+ if (sourceAllowed("qoder-cn")) {
1209
+ const qoderCnPaths = resolveQoderCnDbPaths({
1210
+ home,
1211
+ env: process.env,
1212
+ platform: process.platform,
1213
+ });
1214
+ if (qoderCnPaths.native || qoderCnPaths.wsl) {
1215
+ if (progress?.enabled) {
1216
+ progress.start(`Parsing Qoder CN ${renderBar(0)} | buckets 0`);
1217
+ }
1218
+ try {
1219
+ const result = await multiInstallParse({
1220
+ paths: qoderCnPaths,
1221
+ parserFn: async ({ dbPath, ...rest }) => {
1222
+ const dbMessages = await readQoderDbMessages(dbPath, { label: "Qoder CN" });
1223
+ const parsed = await parseQoderDbIncremental({
1224
+ dbMessages,
1225
+ dbPath,
1226
+ sourceKey: "qoder-cn",
1227
+ cursorKey: "qoder-cn",
1228
+ ...rest,
1229
+ });
1230
+ return {
1231
+ recordsProcessed: parsed.messagesProcessed || 0,
1232
+ eventsAggregated: parsed.eventsAggregated || 0,
1233
+ bucketsQueued: parsed.bucketsQueued || 0,
1234
+ };
1235
+ },
1236
+ providerName: "qoder-cn",
1237
+ cursors,
1238
+ queuePath,
1239
+ projectQueuePath,
1240
+ getParams: (dbPath) => ({ dbPath }),
1241
+ onProgress: makeProviderProgress("Qoder CN"),
1242
+ });
1243
+ qoderCnResult = {
1244
+ recordsProcessed: result.recordsProcessed || 0,
1245
+ eventsAggregated: result.eventsAggregated || 0,
1246
+ bucketsQueued: result.bucketsQueued || 0,
1247
+ };
1248
+ } catch (err) {
1249
+ warnProviderParseFailure("Qoder CN", err, opts);
1250
+ }
1251
+ }
1252
+ }
1253
+
1162
1254
  // ── Claude Science (Anthropic's local research workbench, issue #246) ──
1163
1255
  // Per-frame token usage lives on the `frames` table of operon-cli.db.
1164
1256
  // Passive, incremental, subtract-on-change. There can be more than one DB:
@@ -2392,6 +2484,7 @@ async function cmdSync(argv, context = {}) {
2392
2484
  antigravityResult.filesProcessed +
2393
2485
  opencodeResult.filesProcessed +
2394
2486
  qoderResult.recordsProcessed +
2487
+ qoderCnResult.recordsProcessed +
2395
2488
  claudeScienceResult.recordsProcessed +
2396
2489
  cursorResult.recordsProcessed +
2397
2490
  kiroResult.recordsProcessed +
@@ -2423,6 +2516,7 @@ async function cmdSync(argv, context = {}) {
2423
2516
  antigravityResult.bucketsQueued +
2424
2517
  opencodeResult.bucketsQueued +
2425
2518
  qoderResult.bucketsQueued +
2519
+ qoderCnResult.bucketsQueued +
2426
2520
  claudeScienceResult.bucketsQueued +
2427
2521
  cursorResult.bucketsQueued +
2428
2522
  kiroResult.bucketsQueued +
@@ -79,6 +79,7 @@ async function collectTrackerDiagnostics({
79
79
  const throttlePath = path.join(trackerDir, "sync.throttle");
80
80
  const uploadThrottlePath = path.join(trackerDir, "upload.throttle.json");
81
81
  const autoRetryPath = path.join(trackerDir, "auto.retry.json");
82
+ const syncSkipPath = path.join(trackerDir, "sync.skip.json");
82
83
  const codexConfigPath = path.join(codexHome, "config.toml");
83
84
  const codeConfigPath = path.join(codeHome, "config.toml");
84
85
  const claudeConfigPath = path.join(home, ".claude", "settings.json");
@@ -96,6 +97,7 @@ async function collectTrackerDiagnostics({
96
97
  const queueState = (await readJson(queueStatePath)) || { offset: 0 };
97
98
  const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
98
99
  const autoRetry = await readJson(autoRetryPath);
100
+ const syncSkip = await readJson(syncSkipPath);
99
101
 
100
102
  const queueSize = await safeStatSize(queuePath);
101
103
  const offsetBytes = Number(queueState.offset || 0);
@@ -271,6 +273,14 @@ async function collectTrackerDiagnostics({
271
273
  }
272
274
  : null,
273
275
  },
276
+ sync_skip: syncSkip?.at
277
+ ? {
278
+ at: syncSkip.at,
279
+ reason: typeof syncSkip.reason === "string" ? syncSkip.reason : null,
280
+ detail: typeof syncSkip.detail === "string" ? syncSkip.detail : null,
281
+ lock_path: redactValue(syncSkip.lockPath, home),
282
+ }
283
+ : null,
274
284
  auto_retry: autoRetryAt
275
285
  ? {
276
286
  next_retry_at: autoRetryAt,
package/src/lib/fs.js CHANGED
@@ -51,6 +51,7 @@ async function chmod600IfPossible(filePath) {
51
51
  const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
52
52
  const LOCK_HEARTBEAT_MS = 30 * 1000;
53
53
  const MAX_RECLAIM_DEPTH = 4;
54
+ const MAX_RECLAIM_SWEEP_DEPTH = 64;
54
55
  const TRANSITION_GUARD_RETRY_MS = 5;
55
56
  const TRANSITION_GUARD_ATTEMPTS = 1000;
56
57
 
@@ -124,6 +125,66 @@ async function existingLockCanBeReclaimed(lockPath) {
124
125
  }
125
126
  }
126
127
 
128
+ async function inspectLock(lockPath) {
129
+ let raw = null;
130
+ try {
131
+ raw = await fs.readFile(lockPath, "utf8");
132
+ } catch (e) {
133
+ if (e?.code === "ENOENT") return { exists: false, pid: null, alive: false };
134
+ return { exists: true, pid: null, alive: true };
135
+ }
136
+ const owner = parseLockOwner(raw);
137
+ if (!owner) return { exists: true, pid: null, alive: true };
138
+ return { exists: true, pid: owner.pid, alive: isProcessAlive(owner.pid) };
139
+ }
140
+
141
+ // Drop an abandoned lock file. The rename makes the removal atomic, so two
142
+ // sweepers racing on the same debris cannot delete a lease installed in
143
+ // between: only the winner of the rename removes anything.
144
+ async function removeAbandonedLockFile(lockPath) {
145
+ if (!(await existingLockCanBeReclaimed(lockPath))) return false;
146
+ const quarantinePath = `${lockPath}.stale.${process.pid}.${crypto.randomUUID()}`;
147
+ try {
148
+ await fs.rename(lockPath, quarantinePath);
149
+ } catch (_e) {
150
+ return false;
151
+ }
152
+ const owner = parseLockOwner(await fs.readFile(quarantinePath, "utf8").catch(() => ""));
153
+ if (owner) {
154
+ await fs.unlink(heartbeatPathFor(lockPath, owner.token)).catch(() => {});
155
+ }
156
+ await fs.unlink(quarantinePath).catch(() => {});
157
+ return true;
158
+ }
159
+
160
+ // A sync killed while it held a transition guard leaves `${lockPath}.reclaim`
161
+ // behind, and the next reclaimer then nests its own guard on top of that
162
+ // orphan. Four nested levels exhaust MAX_RECLAIM_DEPTH, after which every
163
+ // acquisition gives up forever instead of healing (issue #431). Recursion
164
+ // cannot clean the chain — each level needs a guard one level deeper — so sweep
165
+ // it iteratively. Only levels whose owner is dead or whose heartbeat expired are
166
+ // removed, deepest first: dropping a shallow guard while a deeper orphan
167
+ // survives would just rebuild the same clogged chain on the next attempt.
168
+ async function sweepAbandonedReclaimGuards(lockPath) {
169
+ const chain = [];
170
+ let guardPath = lockPath;
171
+ for (let depth = 0; depth < MAX_RECLAIM_SWEEP_DEPTH; depth += 1) {
172
+ guardPath = `${guardPath}.reclaim`;
173
+ try {
174
+ await fs.access(guardPath);
175
+ } catch (_e) {
176
+ break;
177
+ }
178
+ chain.push(guardPath);
179
+ }
180
+
181
+ let removed = 0;
182
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
183
+ if (await removeAbandonedLockFile(chain[index])) removed += 1;
184
+ }
185
+ return removed > 0;
186
+ }
187
+
127
188
  function startLockHeartbeat(handle, heartbeatHandle) {
128
189
  const beat = async () => {
129
190
  try {
@@ -210,6 +271,7 @@ async function openLock(
210
271
  beforeReleaseUnlink = null,
211
272
  reclaimDepth = 0,
212
273
  serializeRelease = true,
274
+ sweptReclaimGuards = false,
213
275
  } = {},
214
276
  ) {
215
277
  try {
@@ -264,7 +326,22 @@ async function openLock(
264
326
  reclaimDepth: reclaimDepth + 1,
265
327
  serializeRelease: false,
266
328
  });
267
- if (!reclaimGuard) return null;
329
+ if (!reclaimGuard) {
330
+ // Either a live reclaimer holds the guard (nothing to do) or the guard
331
+ // chain is clogged with abandoned guards. Sweeping the latter is the
332
+ // only escape, and it is attempted once per acquisition. beforeReclaim
333
+ // already ran, so the retry must not invoke it a second time.
334
+ if (!sweptReclaimGuards && (await sweepAbandonedReclaimGuards(lockPath))) {
335
+ return openLock(lockPath, {
336
+ quietIfLocked,
337
+ beforeReleaseUnlink,
338
+ reclaimDepth,
339
+ serializeRelease,
340
+ sweptReclaimGuards: true,
341
+ });
342
+ }
343
+ return null;
344
+ }
268
345
 
269
346
  let quarantinePath = null;
270
347
  try {
@@ -326,4 +403,5 @@ module.exports = {
326
403
  writeJson,
327
404
  chmod600IfPossible,
328
405
  openLock,
406
+ inspectLock,
329
407
  };