tokentracker-cli 0.81.2 → 0.81.3

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 (128) hide show
  1. package/dashboard/dist/assets/AchievementBadge-DiSoAalB.js +1 -0
  2. package/dashboard/dist/assets/AchievementsPage-DI_cvzqy.js +2 -0
  3. package/dashboard/dist/assets/AchievementsSection-ClWZZNvc.js +1 -0
  4. package/dashboard/dist/assets/BadgeDetailModal-DpQeG_6H.js +1 -0
  5. package/dashboard/dist/assets/Card-B0hyofoy.js +1 -0
  6. package/dashboard/dist/assets/ClawdAnimated-DZJGQ3-9.js +1 -0
  7. package/dashboard/dist/assets/CommunityStatsModal-Dcikt0kT.js +1 -0
  8. package/dashboard/dist/assets/ConfirmModal-DCfRP4oI.js +1 -0
  9. package/dashboard/dist/assets/DashboardPage-DBajsWEp.js +1 -0
  10. package/dashboard/dist/assets/DevicePage-De6KuFaJ.js +1 -0
  11. package/dashboard/dist/assets/DialogDescription-uQ4gVIv9.js +1 -0
  12. package/dashboard/dist/assets/DialogTitle-BvElfi0r.js +1 -0
  13. package/dashboard/dist/assets/FadeIn-DZoN7vbQ.js +1 -0
  14. package/dashboard/dist/assets/HeaderGithubStar-C9HQnEsI.js +1 -0
  15. package/dashboard/dist/assets/{IpCheckPage-CMf9CHFr.js → IpCheckPage-82zniXwO.js} +3 -3
  16. package/dashboard/dist/assets/{LandingPage-BT61_TqT.js → LandingPage-Df9fdR0n.js} +168 -168
  17. package/dashboard/dist/assets/LeaderboardAvatar-CjfEgoR8.js +1 -0
  18. package/dashboard/dist/assets/LeaderboardPage-BdbE6Nhj.js +2 -0
  19. package/dashboard/dist/assets/{LeaderboardProfileModal-C_kxqOMn.js → LeaderboardProfileModal-CDTK21dp.js} +4 -4
  20. package/dashboard/dist/assets/{LeaderboardProfilePage-zgNsE0EQ.js → LeaderboardProfilePage-B6QWnwB1.js} +3 -3
  21. package/dashboard/dist/assets/{LimitsPage-BiQpqxj8.js → LimitsPage-f0L0d15j.js} +4 -4
  22. package/dashboard/dist/assets/LocalOnlyNotice-DOcSDrOy.js +1 -0
  23. package/dashboard/dist/assets/LoginPage-WY_sRA7I.js +1 -0
  24. package/dashboard/dist/assets/PetPage-CNbgNE6f.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-Cv525-dF.js +1 -0
  26. package/dashboard/dist/assets/ResetPasswordPage-vrcj6bBP.js +1 -0
  27. package/dashboard/dist/assets/Select-3HkgkO8y.js +1 -0
  28. package/dashboard/dist/assets/SelectItemText-Bz8FAKEv.js +1 -0
  29. package/dashboard/dist/assets/SettingsPage-CVldAt69.js +1 -0
  30. package/dashboard/dist/assets/SkillsPage-Dnz3eGcE.js +1 -0
  31. package/dashboard/dist/assets/TrendMonitor-OopQ06B8.js +19 -0
  32. package/dashboard/dist/assets/WidgetsPage-4dWjZkc9.js +1 -0
  33. package/dashboard/dist/assets/WrappedPage-Dbd-8EKD.js +1 -0
  34. package/dashboard/dist/assets/__vite-browser-external-CQxW1DuO.css +1 -0
  35. package/dashboard/dist/assets/__vite-browser-external-CxN2E6q5.js +1312 -0
  36. package/dashboard/dist/assets/agent-logos-CT9fumzx.js +1 -0
  37. package/dashboard/dist/assets/arrow-up-right-CGnVz9Km.js +1 -0
  38. package/dashboard/dist/assets/check-CkI3WNti.js +1 -0
  39. package/dashboard/dist/assets/copy-Bh3g2_O-.js +1 -0
  40. package/dashboard/dist/assets/download-BITw7nKX.js +1 -0
  41. package/dashboard/dist/assets/flame-ByAsnNar.js +1 -0
  42. package/dashboard/dist/assets/format-tokens-Cu8JtuN4.js +1 -0
  43. package/dashboard/dist/assets/icons-D9OB0N43.js +1 -0
  44. package/dashboard/dist/assets/index-lKnLA6Pd.js +1 -0
  45. package/dashboard/dist/assets/info-Mu2CLf-A.js +1 -0
  46. package/dashboard/dist/assets/link-2-CSlVaUw9.js +1 -0
  47. package/dashboard/dist/assets/loader-circle-CYck0YaS.js +1 -0
  48. package/dashboard/dist/assets/provider-display-BB925tPa.js +42 -0
  49. package/dashboard/dist/assets/terminal-4YysOnTe.js +1 -0
  50. package/dashboard/dist/assets/trash-2-C8-ZfrZG.js +1 -0
  51. package/dashboard/dist/assets/use-community-stats-Cxt-otko.js +1 -0
  52. package/dashboard/dist/assets/use-limits-display-prefs-BUFZh6fe.js +1 -0
  53. package/dashboard/dist/assets/use-native-settings-DbdPXxGf.js +1 -0
  54. package/dashboard/dist/assets/{use-ordered-list-DPbiKnPc.js → use-ordered-list-BZMM-iIj.js} +2 -2
  55. package/dashboard/dist/assets/use-quality-per-dollar-pref-DXoW5A0_.js +1 -0
  56. package/dashboard/dist/assets/use-transform-CuAoUb8H.js +1 -0
  57. package/dashboard/dist/assets/use-usage-limits-BEc3QMzR.js +1 -0
  58. package/dashboard/dist/assets/useCurrency-CpDnFC0N.js +1 -0
  59. package/dashboard/dist/assets/useScrollLock-Vyaif-7C.js +1 -0
  60. package/dashboard/dist/assets/useTokenFormat-B8FrUarf.js +1 -0
  61. package/dashboard/dist/assets/zap-CdeKJ6HL.js +1 -0
  62. package/dashboard/dist/index.html +2 -2
  63. package/dashboard/dist/share.html +2 -2
  64. package/package.json +1 -1
  65. package/src/commands/serve.js +8 -6
  66. package/src/commands/status.js +2 -1
  67. package/src/commands/sync.js +164 -36
  68. package/src/lib/codex-context-breakdown.js +330 -13
  69. package/src/lib/codex-rollout-parser.js +323 -42
  70. package/src/lib/cursor-store.js +1289 -0
  71. package/src/lib/diagnostics.js +8 -5
  72. package/src/lib/fs.js +254 -16
  73. package/src/lib/local-api.js +106 -17
  74. package/src/lib/pricing/index.js +8 -0
  75. package/src/lib/pricing/seed-snapshot.json +1 -1
  76. package/src/lib/rollout.js +101 -9
  77. package/dashboard/dist/assets/AchievementBadge-DXWHZrAL.js +0 -1
  78. package/dashboard/dist/assets/AchievementsPage-BhrQXniL.js +0 -2
  79. package/dashboard/dist/assets/AchievementsSection-gzP3m1Ml.js +0 -1
  80. package/dashboard/dist/assets/BadgeDetailModal-DGKPuvDU.js +0 -1
  81. package/dashboard/dist/assets/Card-BRtYQ-Ot.js +0 -1
  82. package/dashboard/dist/assets/ClawdAnimated-UsNG-qDv.js +0 -1
  83. package/dashboard/dist/assets/CommunityStatsModal-CJQw-P4t.js +0 -1
  84. package/dashboard/dist/assets/ConfirmModal-ByY4GDsy.js +0 -1
  85. package/dashboard/dist/assets/DashboardPage-CuLVnW98.js +0 -19
  86. package/dashboard/dist/assets/DevicePage-D67CxpmR.js +0 -1
  87. package/dashboard/dist/assets/DialogClose-Ci8hkTw5.js +0 -1
  88. package/dashboard/dist/assets/DialogDescription-C9vXUZU0.js +0 -1
  89. package/dashboard/dist/assets/DialogTitle-sLJxKWjv.js +0 -1
  90. package/dashboard/dist/assets/FadeIn-xVds37w6.js +0 -1
  91. package/dashboard/dist/assets/HeaderGithubStar-kK3omgmB.js +0 -1
  92. package/dashboard/dist/assets/LeaderboardAvatar-DyKz6NdT.js +0 -1
  93. package/dashboard/dist/assets/LeaderboardPage-DJ4zihJJ.js +0 -2
  94. package/dashboard/dist/assets/LocalOnlyNotice-DTrrUVbZ.js +0 -1
  95. package/dashboard/dist/assets/LoginPage-BOM9Q2sI.js +0 -1
  96. package/dashboard/dist/assets/PetPage-BURk7RG-.js +0 -1
  97. package/dashboard/dist/assets/PopoverPopup-BV_aoNBd.js +0 -1
  98. package/dashboard/dist/assets/ResetPasswordPage-SBL2LvXC.js +0 -1
  99. package/dashboard/dist/assets/Select-BsRdHde3.js +0 -1
  100. package/dashboard/dist/assets/SelectItemText-DgkrZxIq.js +0 -1
  101. package/dashboard/dist/assets/SettingsPage-DqxVsfnB.js +0 -1
  102. package/dashboard/dist/assets/SkillsPage-B0VfWkOh.js +0 -1
  103. package/dashboard/dist/assets/WidgetsPage-4QQ8wI_g.js +0 -1
  104. package/dashboard/dist/assets/WrappedPage-BK53khVY.js +0 -1
  105. package/dashboard/dist/assets/agent-logos-DXpW_Wmz.js +0 -1
  106. package/dashboard/dist/assets/arrow-up-right-DCc--V5G.js +0 -1
  107. package/dashboard/dist/assets/check-DqxuosV2.js +0 -1
  108. package/dashboard/dist/assets/copy-qChAT7MY.js +0 -1
  109. package/dashboard/dist/assets/download-W9R3sBph.js +0 -1
  110. package/dashboard/dist/assets/flame-0eyhMnfU.js +0 -1
  111. package/dashboard/dist/assets/format-tokens-CkfBUcoa.js +0 -1
  112. package/dashboard/dist/assets/info-DY1O85-U.js +0 -1
  113. package/dashboard/dist/assets/link-2-B_Z66hFT.js +0 -1
  114. package/dashboard/dist/assets/loader-circle-xH40lon2.js +0 -1
  115. package/dashboard/dist/assets/main-CeaB5N36.js +0 -1259
  116. package/dashboard/dist/assets/main-DAc77vwF.css +0 -1
  117. package/dashboard/dist/assets/provider-display-C_XKZQcF.js +0 -42
  118. package/dashboard/dist/assets/trash-2-DVhNJ74r.js +0 -1
  119. package/dashboard/dist/assets/use-community-stats-CO-nr2Vf.js +0 -1
  120. package/dashboard/dist/assets/use-limits-display-prefs-RRqmyoxt.js +0 -1
  121. package/dashboard/dist/assets/use-native-settings-79piPeET.js +0 -1
  122. package/dashboard/dist/assets/use-quality-per-dollar-pref-DyF49QRF.js +0 -1
  123. package/dashboard/dist/assets/use-transform-BvXyGAtb.js +0 -1
  124. package/dashboard/dist/assets/use-usage-limits-BiZ_I-KE.js +0 -1
  125. package/dashboard/dist/assets/useCurrency-D-C1e7eE.js +0 -1
  126. package/dashboard/dist/assets/useScrollLock-BfU2EMvG.js +0 -1
  127. package/dashboard/dist/assets/useTokenFormat-Cm4EvrQp.js +0 -1
  128. package/dashboard/dist/assets/zap-DlAOTwFs.js +0 -1
@@ -3,6 +3,7 @@ const path = require("node:path");
3
3
  const fs = require("node:fs/promises");
4
4
 
5
5
  const { readJson } = require("./fs");
6
+ const { readCursorStateSummary } = require("./cursor-store");
6
7
  const { readCodexNotify, readEveryCodeNotify } = require("./codex-config");
7
8
  const { areClaudeUsageHooksConfigured, buildClaudeHookCommand } = require("./claude-config");
8
9
  const {
@@ -59,7 +60,8 @@ async function collectTrackerDiagnostics({
59
60
  path.join(home, ".grok");
60
61
 
61
62
  const config = await readJson(configPath);
62
- const cursors = await readJson(cursorsPath);
63
+ const cursorSummary = await readCursorStateSummary({ trackerDir, cursorsPath });
64
+ const cursors = cursorSummary.cursors;
63
65
  const queueState = (await readJson(queueStatePath)) || { offset: 0 };
64
66
  const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
65
67
  const autoRetry = await readJson(autoRetryPath);
@@ -165,10 +167,11 @@ async function collectTrackerDiagnostics({
165
167
  },
166
168
  parse: {
167
169
  updated_at: typeof cursors?.updatedAt === "string" ? cursors.updatedAt : null,
168
- file_count:
169
- cursors?.files && typeof cursors.files === "object"
170
- ? Object.keys(cursors.files).length
171
- : null,
170
+ file_count: cursorSummary.fileCount,
171
+ cursor_store: cursorSummary.mode,
172
+ cursor_store_legacy_drift: cursorSummary.legacyDrift === true,
173
+ codex_file_count: cursorSummary.codexFileCount,
174
+ codex_event_count: cursorSummary.codexEventCount,
172
175
  },
173
176
  queue: {
174
177
  size_bytes: queueSize,
package/src/lib/fs.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require("node:fs/promises");
2
+ const crypto = require("node:crypto");
2
3
  const path = require("node:path");
3
4
 
4
5
  async function ensureDir(p) {
@@ -48,28 +49,265 @@ async function chmod600IfPossible(filePath) {
48
49
  }
49
50
 
50
51
  const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
52
+ const LOCK_HEARTBEAT_MS = 30 * 1000;
53
+ const MAX_RECLAIM_DEPTH = 4;
54
+ const TRANSITION_GUARD_RETRY_MS = 5;
55
+ const TRANSITION_GUARD_ATTEMPTS = 1000;
51
56
 
52
- async function openLock(lockPath, { quietIfLocked }) {
57
+ function parseLockOwner(raw) {
58
+ try {
59
+ const parsed = JSON.parse(raw);
60
+ const pid = Number(parsed?.pid);
61
+ const token = typeof parsed?.token === "string" ? parsed.token : null;
62
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !token) return null;
63
+ return { pid, token };
64
+ } catch (_e) {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function heartbeatPathFor(lockPath, token) {
70
+ const tokenDigest = crypto.createHash("sha256").update(token, "utf8").digest("hex");
71
+ return `${lockPath}.heartbeat.${tokenDigest}`;
72
+ }
73
+
74
+ function isProcessAlive(pid) {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch (e) {
79
+ if (e?.code === "ESRCH") return false;
80
+ // EPERM means the process exists but belongs to another user. Unknown
81
+ // errors are treated conservatively so a live lock is never reclaimed.
82
+ return true;
83
+ }
84
+ }
85
+
86
+ async function existingLockCanBeReclaimed(lockPath) {
87
+ let lockHandle = null;
88
+ try {
89
+ // Inspect one opened inode instead of stat-ing a path and then reading
90
+ // that path. The latter is a TOCTOU window and is also unsafe if a
91
+ // concurrent reclaimer replaces the lock between the two operations.
92
+ lockHandle = await fs.open(lockPath, "r");
93
+ const [stat, raw] = await Promise.all([
94
+ lockHandle.stat(),
95
+ lockHandle.readFile({ encoding: "utf8" }),
96
+ ]);
97
+ const owner = parseLockOwner(raw);
98
+ if (owner) {
99
+ if (!isProcessAlive(owner.pid)) return true;
100
+
101
+ // New leases keep a token-specific heartbeat separate from the owner
102
+ // file. This lets a PID that has since been reused be recognized as an
103
+ // abandoned lease without reclaiming a live, long-running sync whose
104
+ // heartbeat is still fresh. Locks from before heartbeat support retain
105
+ // the conservative live-PID behavior.
106
+ let heartbeatHandle = null;
107
+ try {
108
+ heartbeatHandle = await fs.open(heartbeatPathFor(lockPath, owner.token), "r");
109
+ const heartbeat = await heartbeatHandle.stat();
110
+ return Date.now() - heartbeat.mtimeMs > LOCK_STALE_MS;
111
+ } catch (_e) {
112
+ return false;
113
+ } finally {
114
+ await heartbeatHandle?.close().catch(() => {});
115
+ }
116
+ }
117
+ return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
118
+ } catch (e) {
119
+ // The lock disappeared between open and inspection, so retry acquisition.
120
+ if (e?.code === "ENOENT") return true;
121
+ return false;
122
+ } finally {
123
+ await lockHandle?.close().catch(() => {});
124
+ }
125
+ }
126
+
127
+ function startLockHeartbeat(handle, heartbeatHandle) {
128
+ const beat = async () => {
129
+ try {
130
+ const now = new Date();
131
+ await Promise.all([
132
+ handle.utimes(now, now),
133
+ heartbeatHandle.utimes(now, now),
134
+ ]);
135
+ } catch (_e) {
136
+ // A failed heartbeat makes the lease eligible for bounded stale
137
+ // reclamation. The owning sync will still finish or release normally.
138
+ }
139
+ };
140
+ const timer = setInterval(() => {
141
+ void beat();
142
+ }, LOCK_HEARTBEAT_MS);
143
+ timer.unref?.();
144
+ return timer;
145
+ }
146
+
147
+ async function releaseOwnedLock(
148
+ lockPath,
149
+ handle,
150
+ heartbeatHandle,
151
+ heartbeatTimer,
152
+ token,
153
+ {
154
+ beforeReleaseUnlink = null,
155
+ reclaimDepth = 0,
156
+ serializeRelease = true,
157
+ } = {},
158
+ ) {
159
+ let transitionGuard = null;
160
+ if (serializeRelease) {
161
+ // Reclaimers use the same guard while moving a stale lease aside. Holding
162
+ // it across token validation and unlink makes that ownership transition
163
+ // indivisible: an old owner cannot delete a replacement installed by a
164
+ // concurrent stale-lock reclaimer.
165
+ for (let attempt = 0; attempt < TRANSITION_GUARD_ATTEMPTS; attempt += 1) {
166
+ transitionGuard = await openLock(`${lockPath}.reclaim`, {
167
+ quietIfLocked: true,
168
+ reclaimDepth: reclaimDepth + 1,
169
+ // This short-lived internal guard is the serialization primitive; its
170
+ // own release must not recursively acquire another transition guard.
171
+ serializeRelease: false,
172
+ });
173
+ if (transitionGuard) break;
174
+ await new Promise((resolve) => setTimeout(resolve, TRANSITION_GUARD_RETRY_MS));
175
+ }
176
+ if (!transitionGuard) {
177
+ clearInterval(heartbeatTimer);
178
+ await handle.close().catch(() => {});
179
+ await heartbeatHandle?.close().catch(() => {});
180
+ await fs.unlink(heartbeatPathFor(lockPath, token)).catch(() => {});
181
+ throw new Error(`Timed out serializing lock release: ${lockPath}`);
182
+ }
183
+ }
184
+
185
+ try {
186
+ clearInterval(heartbeatTimer);
187
+ await handle.close().catch(() => {});
188
+ await heartbeatHandle?.close().catch(() => {});
189
+ const owner = parseLockOwner(await fs.readFile(lockPath, "utf8"));
190
+ if (owner?.token === token) {
191
+ if (typeof beforeReleaseUnlink === "function") {
192
+ await beforeReleaseUnlink({ lockPath });
193
+ }
194
+ await fs.unlink(lockPath).catch(() => {});
195
+ await fs.unlink(heartbeatPathFor(lockPath, token)).catch(() => {});
196
+ }
197
+ } catch (_e) {
198
+ // Missing or replaced owner paths are already released from this token's
199
+ // perspective. Other failures retain the conservative no-delete outcome.
200
+ } finally {
201
+ await transitionGuard?.release();
202
+ }
203
+ }
204
+
205
+ async function openLock(
206
+ lockPath,
207
+ {
208
+ quietIfLocked = false,
209
+ beforeReclaim = null,
210
+ beforeReleaseUnlink = null,
211
+ reclaimDepth = 0,
212
+ serializeRelease = true,
213
+ } = {},
214
+ ) {
53
215
  try {
54
216
  const handle = await fs.open(lockPath, "wx");
55
- return {
56
- async release() {
57
- await handle.close().catch(() => {});
58
- await fs.unlink(lockPath).catch(() => {});
59
- },
60
- };
217
+ const token = crypto.randomUUID();
218
+ const heartbeatPath = heartbeatPathFor(lockPath, token);
219
+ let heartbeatHandle = null;
220
+ try {
221
+ await handle.writeFile(
222
+ JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }) + "\n",
223
+ "utf8",
224
+ );
225
+ heartbeatHandle = await fs.open(heartbeatPath, "wx");
226
+ await heartbeatHandle.writeFile(`${token}\n`, "utf8");
227
+ const heartbeatTimer = startLockHeartbeat(handle, heartbeatHandle);
228
+ return {
229
+ async release() {
230
+ await releaseOwnedLock(
231
+ lockPath,
232
+ handle,
233
+ heartbeatHandle,
234
+ heartbeatTimer,
235
+ token,
236
+ {
237
+ beforeReleaseUnlink,
238
+ reclaimDepth,
239
+ serializeRelease,
240
+ },
241
+ );
242
+ },
243
+ };
244
+ } catch (e) {
245
+ await heartbeatHandle?.close().catch(() => {});
246
+ await fs.unlink(heartbeatPath).catch(() => {});
247
+ await handle.close().catch(() => {});
248
+ await fs.unlink(lockPath).catch(() => {});
249
+ throw e;
250
+ }
61
251
  } catch (e) {
62
252
  if (e && e.code === "EEXIST") {
63
- // Check if lock is stale
64
- try {
65
- const stat = await fs.stat(lockPath);
66
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
67
- await fs.unlink(lockPath).catch(() => {});
68
- return openLock(lockPath, { quietIfLocked });
253
+ if (await existingLockCanBeReclaimed(lockPath)) {
254
+ if (typeof beforeReclaim === "function") {
255
+ await beforeReclaim({ lockPath });
256
+ }
257
+ if (reclaimDepth >= MAX_RECLAIM_DEPTH) return null;
258
+
259
+ // Serialize all reclaimers before re-checking the target. A stale
260
+ // check can be shared by many contenders; only the holder of this
261
+ // atomic guard may move the old lease out of the way.
262
+ const reclaimGuard = await openLock(`${lockPath}.reclaim`, {
263
+ quietIfLocked: true,
264
+ reclaimDepth: reclaimDepth + 1,
265
+ serializeRelease: false,
266
+ });
267
+ if (!reclaimGuard) return null;
268
+
269
+ let quarantinePath = null;
270
+ try {
271
+ // Re-check while holding the guard. A competing reclaimer may have
272
+ // already replaced the target since our initial stale inspection.
273
+ if (!(await existingLockCanBeReclaimed(lockPath))) return null;
274
+
275
+ quarantinePath = `${lockPath}.stale.${process.pid}.${crypto.randomUUID()}`;
276
+ try {
277
+ await fs.rename(lockPath, quarantinePath);
278
+ } catch (renameError) {
279
+ if (renameError?.code === "ENOENT") {
280
+ return openLock(lockPath, {
281
+ quietIfLocked,
282
+ beforeReleaseUnlink,
283
+ reclaimDepth,
284
+ serializeRelease,
285
+ });
286
+ }
287
+ if (!quietIfLocked) {
288
+ process.stdout.write("Another sync is already running.\n");
289
+ }
290
+ return null;
291
+ }
292
+
293
+ const staleOwner = parseLockOwner(
294
+ await fs.readFile(quarantinePath, "utf8").catch(() => ""),
295
+ );
296
+ if (staleOwner) {
297
+ // The heartbeat name contains the old token, so it cannot belong
298
+ // to a replacement lease created after the atomic rename.
299
+ await fs.unlink(heartbeatPathFor(lockPath, staleOwner.token)).catch(() => {});
300
+ }
301
+ return await openLock(lockPath, {
302
+ quietIfLocked,
303
+ beforeReleaseUnlink,
304
+ reclaimDepth,
305
+ serializeRelease,
306
+ });
307
+ } finally {
308
+ if (quarantinePath) await fs.unlink(quarantinePath).catch(() => {});
309
+ await reclaimGuard.release();
69
310
  }
70
- } catch (_statErr) {
71
- // Lock file disappeared between checks, retry
72
- return openLock(lockPath, { quietIfLocked });
73
311
  }
74
312
  if (!quietIfLocked) {
75
313
  process.stdout.write("Another sync is already running.\n");
@@ -35,6 +35,7 @@ const {
35
35
  getModelPricing,
36
36
  computeRowCost,
37
37
  ensurePricingLoaded,
38
+ getPricingRevision,
38
39
  } = require("./pricing");
39
40
 
40
41
  const {
@@ -66,6 +67,33 @@ const CLAUDE_MEM_OBSERVER_PROJECT_KEY = deriveProjectKeyFromRef(
66
67
  CLAUDE_MEM_OBSERVER_PROJECT_REF,
67
68
  );
68
69
  const PROJECT_USAGE_MAX_ENTRIES = 10;
70
+ const MAX_TIME_ZONE_CACHE_ENTRIES = 16;
71
+
72
+ // The native dashboard fans one refresh out across several local endpoints.
73
+ // Keep one immutable, deduped view of the current queue so those endpoints do
74
+ // not all read and JSON.parse the same append-only file. A file identity +
75
+ // nanosecond timestamp signature invalidates the cache immediately on append,
76
+ // in-place rewrite, or atomic replacement.
77
+ let queueDataCache = null;
78
+ const dailyAggregationCache = new WeakMap();
79
+ const zonedPartsFormatters = new Map();
80
+
81
+ function boundedCacheSet(cache, key, value, maxEntries) {
82
+ if (cache.has(key)) cache.delete(key);
83
+ cache.set(key, value);
84
+ while (cache.size > maxEntries) {
85
+ cache.delete(cache.keys().next().value);
86
+ }
87
+ }
88
+
89
+ function queueFileSignature(queuePath) {
90
+ const stat = fs.statSync(queuePath, { bigint: true });
91
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
92
+ }
93
+
94
+ function timeZoneContextKey({ timeZone, offsetMinutes } = {}) {
95
+ return `${timeZone || ""}|${Number.isFinite(offsetMinutes) ? offsetMinutes : ""}|p${getPricingRevision()}`;
96
+ }
69
97
 
70
98
  // Shared by project-usage-summary and project-usage-detail: reads the
71
99
  // deduped project bucket log, drops the claude-mem pseudo project, and
@@ -160,6 +188,21 @@ function normalizeQueueRow(row) {
160
188
  }
161
189
 
162
190
  function readQueueData(queuePath) {
191
+ let signature;
192
+ try {
193
+ signature = queueFileSignature(queuePath);
194
+ } catch (e) {
195
+ if (queueDataCache?.queuePath === queuePath) queueDataCache = null;
196
+ if (e?.code !== "ENOENT") {
197
+ console.error("[LocalAPI] readQueueData: failed to stat queue:", e?.message || e);
198
+ }
199
+ return [];
200
+ }
201
+
202
+ if (queueDataCache?.queuePath === queuePath && queueDataCache.signature === signature) {
203
+ return queueDataCache.rows;
204
+ }
205
+
163
206
  let raw;
164
207
  try {
165
208
  raw = fs.readFileSync(queuePath, "utf8");
@@ -196,7 +239,9 @@ function readQueueData(queuePath) {
196
239
  const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
197
240
  seen.set(key, normalizeQueueRow(row));
198
241
  }
199
- return Array.from(seen.values());
242
+ const rows = Array.from(seen.values());
243
+ queueDataCache = { queuePath, signature, rows };
244
+ return rows;
200
245
  }
201
246
 
202
247
  function rowDayKey(row, timeZoneContext) {
@@ -214,8 +259,19 @@ function rowDayKey(row, timeZoneContext) {
214
259
  }
215
260
 
216
261
  function aggregateByDay(rows, timeZoneContext = null) {
262
+ const normalizedRows = Array.isArray(rows) ? rows : [];
263
+ const cacheKey = timeZoneContextKey(timeZoneContext || {});
264
+ let cachedByTimeZone = dailyAggregationCache.get(normalizedRows);
265
+ if (cachedByTimeZone?.has(cacheKey)) {
266
+ const cached = cachedByTimeZone.get(cacheKey);
267
+ // Touch the entry so the small per-queue map behaves as an LRU.
268
+ cachedByTimeZone.delete(cacheKey);
269
+ cachedByTimeZone.set(cacheKey, cached);
270
+ return cached;
271
+ }
272
+
217
273
  const byDay = new Map();
218
- for (const row of rows) {
274
+ for (const row of normalizedRows) {
219
275
  if (!row.hour_start) continue;
220
276
  const day = rowDayKey(row, timeZoneContext);
221
277
  if (!day) continue;
@@ -250,7 +306,13 @@ function aggregateByDay(rows, timeZoneContext = null) {
250
306
  const model = row.model || "unknown";
251
307
  a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
252
308
  }
253
- return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
309
+ const daily = Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
310
+ if (!cachedByTimeZone) {
311
+ cachedByTimeZone = new Map();
312
+ dailyAggregationCache.set(normalizedRows, cachedByTimeZone);
313
+ }
314
+ boundedCacheSet(cachedByTimeZone, cacheKey, daily, MAX_TIME_ZONE_CACHE_ENTRIES);
315
+ return daily;
254
316
  }
255
317
 
256
318
  function buildCodexCategoryFallbackFromQueue(queueRows, { from, to, timeZoneContext }) {
@@ -477,17 +539,34 @@ function getZonedParts(date, { timeZone, offsetMinutes } = {}) {
477
539
 
478
540
  if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
479
541
  try {
480
- const formatter = new Intl.DateTimeFormat("en-CA", {
481
- timeZone,
482
- year: "numeric",
483
- month: "2-digit",
484
- day: "2-digit",
485
- hour: "2-digit",
486
- minute: "2-digit",
487
- second: "2-digit",
488
- hourCycle: "h23",
489
- });
490
- const parts = formatter.formatToParts(dt);
542
+ let formatter;
543
+ if (zonedPartsFormatters.has(timeZone)) {
544
+ formatter = zonedPartsFormatters.get(timeZone);
545
+ } else {
546
+ try {
547
+ formatter = new Intl.DateTimeFormat("en-CA", {
548
+ timeZone,
549
+ year: "numeric",
550
+ month: "2-digit",
551
+ day: "2-digit",
552
+ hour: "2-digit",
553
+ minute: "2-digit",
554
+ second: "2-digit",
555
+ hourCycle: "h23",
556
+ });
557
+ } catch {
558
+ // Cache invalid zone ids too; otherwise a bad local query would
559
+ // throw once per queue row before falling back to its fixed offset.
560
+ formatter = null;
561
+ }
562
+ boundedCacheSet(
563
+ zonedPartsFormatters,
564
+ timeZone,
565
+ formatter,
566
+ MAX_TIME_ZONE_CACHE_ENTRIES,
567
+ );
568
+ }
569
+ const parts = formatter?.formatToParts(dt) || [];
491
570
  const values = parts.reduce((acc, part) => {
492
571
  if (part.type && part.value) acc[part.type] = part.value;
493
572
  return acc;
@@ -685,6 +764,7 @@ function runSyncCommand(extraEnv = {}, opts = {}) {
685
764
  const args = [TRACKER_BIN, "sync"];
686
765
  if (opts.auto === true) args.push("--auto");
687
766
  if (opts.background === true) args.push("--background");
767
+ if (opts.publishAccount === true) args.push("--publish-account");
688
768
  if (opts.allLocalSources === true) args.push("--all-local-sources");
689
769
  if (opts.drain === true) args.push("--drain");
690
770
  const child = spawn(process.execPath, args, {
@@ -1696,6 +1776,11 @@ function createLocalApiHandler({ queuePath }) {
1696
1776
  const auto = body.auto === true && !drain;
1697
1777
  const background =
1698
1778
  auto && (body.background === true || body.lightweight === true);
1779
+ // The local server is the trust boundary for cloud-sync preferences.
1780
+ // A persisted device token must not let a native background request
1781
+ // upload after the user has explicitly disabled cloud sync.
1782
+ const publishAccount =
1783
+ background && body.publishAccount === true && getCloudSyncPref();
1699
1784
  const allLocalSources = background && body.allLocalSources === true;
1700
1785
  if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
1701
1786
  extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
@@ -1710,7 +1795,10 @@ function createLocalApiHandler({ queuePath }) {
1710
1795
  extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = allowedBaseUrl;
1711
1796
  localSyncBaseUrl = allowedBaseUrl;
1712
1797
  }
1713
- if (!background && !extraEnv.TOKENTRACKER_DEVICE_TOKEN && getCloudSyncPref() && getRefreshTokenForCloud()) {
1798
+ if ((!background || publishAccount) &&
1799
+ !extraEnv.TOKENTRACKER_DEVICE_TOKEN &&
1800
+ getCloudSyncPref() &&
1801
+ getRefreshTokenForCloud()) {
1714
1802
  let issuedToken = null;
1715
1803
  try {
1716
1804
  issuedToken = await issueDeviceTokenForLocalSync(qp, { baseUrl: localSyncBaseUrl });
@@ -1732,6 +1820,7 @@ function createLocalApiHandler({ queuePath }) {
1732
1820
  drain,
1733
1821
  auto,
1734
1822
  background,
1823
+ publishAccount,
1735
1824
  allLocalSources,
1736
1825
  });
1737
1826
  try {
@@ -1778,7 +1867,8 @@ function createLocalApiHandler({ queuePath }) {
1778
1867
  const to = url.searchParams.get("to") || "";
1779
1868
  const timeZoneContext = getTimeZoneContext(url);
1780
1869
  const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1781
- const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
1870
+ const allDaily = aggregateByDay(rows, timeZoneContext);
1871
+ const daily = allDaily.filter((d) => d.day >= from && d.day <= to);
1782
1872
  const totals = daily.reduce(
1783
1873
  (acc, r) => {
1784
1874
  acc.total_tokens += r.total_tokens;
@@ -1798,7 +1888,6 @@ function createLocalApiHandler({ queuePath }) {
1798
1888
 
1799
1889
  const todayParts = getZonedParts(new Date(), timeZoneContext);
1800
1890
  const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
1801
- const allDaily = aggregateByDay(rows, timeZoneContext);
1802
1891
 
1803
1892
  const shiftDay = (dayStr, delta) => {
1804
1893
  const d = new Date(`${dayStr}T00:00:00Z`);
@@ -37,6 +37,7 @@ const seedRaw = loadSeedSync();
37
37
  const state = {
38
38
  loaded: false,
39
39
  loadingPromise: null,
40
+ revision: 0,
40
41
  litellmRawMap: seedRaw, // raw per-token; field shape from LiteLLM JSON
41
42
  litellmPerMillionMap: buildLitellmPerMillionMap(seedRaw), // USD/MTok
42
43
  source: Object.keys(seedRaw).length ? "seed-snapshot:sync" : null,
@@ -61,6 +62,7 @@ async function ensurePricingLoaded(opts = {}) {
61
62
  state.litellmPerMillionMap = buildLitellmPerMillionMap(state.litellmRawMap);
62
63
  state.source = source;
63
64
  state.loaded = true;
65
+ state.revision += 1;
64
66
  state.negativeCache.clear();
65
67
  return state;
66
68
  } finally {
@@ -79,9 +81,14 @@ function resetPricingForTests() {
79
81
  state.litellmRawMap = seedRaw;
80
82
  state.litellmPerMillionMap = buildLitellmPerMillionMap(seedRaw);
81
83
  state.source = Object.keys(seedRaw).length ? "seed-snapshot:sync" : null;
84
+ state.revision += 1;
82
85
  state.negativeCache.clear();
83
86
  }
84
87
 
88
+ function getPricingRevision() {
89
+ return state.revision;
90
+ }
91
+
85
92
  function getModelPricing(model, opts = {}) {
86
93
  if (!model) return ZERO_PRICING;
87
94
  let lookupSource = null;
@@ -133,6 +140,7 @@ const MODEL_PRICING = curatedOverrides.exact;
133
140
 
134
141
  module.exports = {
135
142
  ensurePricingLoaded,
143
+ getPricingRevision,
136
144
  getModelPricing,
137
145
  computeRowCost,
138
146
  resetPricingForTests,