tokentracker-cli 0.81.2 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dashboard/dist/assets/AchievementBadge-D6iPEEbz.js +1 -0
  2. package/dashboard/dist/assets/AchievementsPage-Jry355YE.js +2 -0
  3. package/dashboard/dist/assets/AchievementsSection-ah-kG00E.js +1 -0
  4. package/dashboard/dist/assets/BadgeDetailModal-FyvUrVvF.js +1 -0
  5. package/dashboard/dist/assets/Card-C3nLScju.js +1 -0
  6. package/dashboard/dist/assets/ClawdAnimated-CLHhE26z.js +1 -0
  7. package/dashboard/dist/assets/CommunityStatsModal-DA3PHLV5.js +1 -0
  8. package/dashboard/dist/assets/ConfirmModal-CTDarLrD.js +1 -0
  9. package/dashboard/dist/assets/DashboardPage-DZIiWc3Y.js +1 -0
  10. package/dashboard/dist/assets/DevicePage-CyXjUO8W.js +1 -0
  11. package/dashboard/dist/assets/DialogDescription-ahxSNUcB.js +1 -0
  12. package/dashboard/dist/assets/DialogTitle-z86PHHyk.js +1 -0
  13. package/dashboard/dist/assets/FadeIn-BDWtfIsO.js +1 -0
  14. package/dashboard/dist/assets/HeaderGithubStar-DE1USQbb.js +1 -0
  15. package/dashboard/dist/assets/{IpCheckPage-CMf9CHFr.js → IpCheckPage-B2SkgT0P.js} +3 -3
  16. package/dashboard/dist/assets/{LandingPage-BT61_TqT.js → LandingPage-PU5gxsfY.js} +165 -165
  17. package/dashboard/dist/assets/LeaderboardAvatar-CaOV_hFE.js +1 -0
  18. package/dashboard/dist/assets/LeaderboardPage-B0JtEyRP.js +2 -0
  19. package/dashboard/dist/assets/{LeaderboardProfileModal-C_kxqOMn.js → LeaderboardProfileModal-DtNEbVH8.js} +4 -4
  20. package/dashboard/dist/assets/{LeaderboardProfilePage-zgNsE0EQ.js → LeaderboardProfilePage-DRklax3-.js} +3 -3
  21. package/dashboard/dist/assets/LimitsPage-Dg0cJa40.js +4 -0
  22. package/dashboard/dist/assets/LocalOnlyNotice-si9PMKya.js +1 -0
  23. package/dashboard/dist/assets/LoginPage-DgLwhXKo.js +1 -0
  24. package/dashboard/dist/assets/PetPage-DEZbjk99.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-DX7v1XfE.js +1 -0
  26. package/dashboard/dist/assets/ResetPasswordPage-C6NvRO-Q.js +1 -0
  27. package/dashboard/dist/assets/Select-CH2xVU8X.js +1 -0
  28. package/dashboard/dist/assets/SelectItemText-CCW6JeeN.js +1 -0
  29. package/dashboard/dist/assets/SettingsPage-Cm3uT11D.js +1 -0
  30. package/dashboard/dist/assets/SkillsPage-CljRL6yF.js +1 -0
  31. package/dashboard/dist/assets/TrendMonitor-DM1_WNfw.js +19 -0
  32. package/dashboard/dist/assets/WidgetsPage-mJ1j6hly.js +1 -0
  33. package/dashboard/dist/assets/WrappedPage-Dz71ln4t.js +1 -0
  34. package/dashboard/dist/assets/__vite-browser-external-C7JwykID.css +1 -0
  35. package/dashboard/dist/assets/__vite-browser-external-Eoyj4eG-.js +1341 -0
  36. package/dashboard/dist/assets/agent-logos-D3K_ZRUj.js +1 -0
  37. package/dashboard/dist/assets/arrow-up-right-CT4-gmOS.js +1 -0
  38. package/dashboard/dist/assets/check-B87_qvAg.js +1 -0
  39. package/dashboard/dist/assets/copy-Dtr68epm.js +1 -0
  40. package/dashboard/dist/assets/download-DaWsA_Gj.js +1 -0
  41. package/dashboard/dist/assets/flame-vLBOq0X0.js +1 -0
  42. package/dashboard/dist/assets/format-tokens-C6Pidz3s.js +1 -0
  43. package/dashboard/dist/assets/icons-BaaYJxUN.js +1 -0
  44. package/dashboard/dist/assets/index-lKnLA6Pd.js +1 -0
  45. package/dashboard/dist/assets/info-B46Qt16h.js +1 -0
  46. package/dashboard/dist/assets/limits-providers-13af4PEg.js +1 -0
  47. package/dashboard/dist/assets/link-2-DeN6MJpT.js +1 -0
  48. package/dashboard/dist/assets/loader-circle-ByEuaaSw.js +1 -0
  49. package/dashboard/dist/assets/provider-display-CKbOKeV4.js +42 -0
  50. package/dashboard/dist/assets/terminal-DyicX7lG.js +1 -0
  51. package/dashboard/dist/assets/trash-2-CVCC3DtQ.js +1 -0
  52. package/dashboard/dist/assets/use-community-stats-CExk3D6G.js +1 -0
  53. package/dashboard/dist/assets/use-limits-display-prefs-CRl4uU95.js +1 -0
  54. package/dashboard/dist/assets/use-native-settings-Bi6E9dsE.js +1 -0
  55. package/dashboard/dist/assets/{use-ordered-list-DPbiKnPc.js → use-ordered-list-Dk0p2otw.js} +2 -2
  56. package/dashboard/dist/assets/use-session-efficiency-pref-YAG-OYIh.js +1 -0
  57. package/dashboard/dist/assets/use-transform-lflyghWN.js +1 -0
  58. package/dashboard/dist/assets/use-usage-limits-DJXvJ25Q.js +1 -0
  59. package/dashboard/dist/assets/useCurrency-BtHtcsO5.js +1 -0
  60. package/dashboard/dist/assets/useScrollLock-C2C-Yazq.js +1 -0
  61. package/dashboard/dist/assets/useTokenFormat-BdD057ih.js +1 -0
  62. package/dashboard/dist/assets/zap-DWWma29E.js +1 -0
  63. package/dashboard/dist/index.html +2 -2
  64. package/dashboard/dist/share.html +2 -2
  65. package/package.json +1 -1
  66. package/src/cli.js +6 -0
  67. package/src/commands/serve.js +8 -6
  68. package/src/commands/sessions.js +29 -0
  69. package/src/commands/status.js +2 -1
  70. package/src/commands/sync.js +164 -36
  71. package/src/lib/codex-context-breakdown.js +330 -13
  72. package/src/lib/codex-rollout-parser.js +323 -42
  73. package/src/lib/context-health.js +124 -0
  74. package/src/lib/cursor-store.js +1289 -0
  75. package/src/lib/diagnostics.js +8 -5
  76. package/src/lib/fs.js +254 -16
  77. package/src/lib/git-outcomes.js +147 -0
  78. package/src/lib/local-api.js +151 -20
  79. package/src/lib/outcomes-engine.js +23 -0
  80. package/src/lib/pricing/index.js +8 -0
  81. package/src/lib/pricing/seed-snapshot.json +1 -1
  82. package/src/lib/rollout.js +101 -9
  83. package/src/lib/session-analytics.js +612 -0
  84. package/src/lib/usage-limits.js +21 -0
  85. package/dashboard/dist/assets/AchievementBadge-DXWHZrAL.js +0 -1
  86. package/dashboard/dist/assets/AchievementsPage-BhrQXniL.js +0 -2
  87. package/dashboard/dist/assets/AchievementsSection-gzP3m1Ml.js +0 -1
  88. package/dashboard/dist/assets/BadgeDetailModal-DGKPuvDU.js +0 -1
  89. package/dashboard/dist/assets/Card-BRtYQ-Ot.js +0 -1
  90. package/dashboard/dist/assets/ClawdAnimated-UsNG-qDv.js +0 -1
  91. package/dashboard/dist/assets/CommunityStatsModal-CJQw-P4t.js +0 -1
  92. package/dashboard/dist/assets/ConfirmModal-ByY4GDsy.js +0 -1
  93. package/dashboard/dist/assets/DashboardPage-CuLVnW98.js +0 -19
  94. package/dashboard/dist/assets/DevicePage-D67CxpmR.js +0 -1
  95. package/dashboard/dist/assets/DialogClose-Ci8hkTw5.js +0 -1
  96. package/dashboard/dist/assets/DialogDescription-C9vXUZU0.js +0 -1
  97. package/dashboard/dist/assets/DialogTitle-sLJxKWjv.js +0 -1
  98. package/dashboard/dist/assets/FadeIn-xVds37w6.js +0 -1
  99. package/dashboard/dist/assets/HeaderGithubStar-kK3omgmB.js +0 -1
  100. package/dashboard/dist/assets/LeaderboardAvatar-DyKz6NdT.js +0 -1
  101. package/dashboard/dist/assets/LeaderboardPage-DJ4zihJJ.js +0 -2
  102. package/dashboard/dist/assets/LimitsPage-BiQpqxj8.js +0 -4
  103. package/dashboard/dist/assets/LocalOnlyNotice-DTrrUVbZ.js +0 -1
  104. package/dashboard/dist/assets/LoginPage-BOM9Q2sI.js +0 -1
  105. package/dashboard/dist/assets/PetPage-BURk7RG-.js +0 -1
  106. package/dashboard/dist/assets/PopoverPopup-BV_aoNBd.js +0 -1
  107. package/dashboard/dist/assets/ResetPasswordPage-SBL2LvXC.js +0 -1
  108. package/dashboard/dist/assets/Select-BsRdHde3.js +0 -1
  109. package/dashboard/dist/assets/SelectItemText-DgkrZxIq.js +0 -1
  110. package/dashboard/dist/assets/SettingsPage-DqxVsfnB.js +0 -1
  111. package/dashboard/dist/assets/SkillsPage-B0VfWkOh.js +0 -1
  112. package/dashboard/dist/assets/WidgetsPage-4QQ8wI_g.js +0 -1
  113. package/dashboard/dist/assets/WrappedPage-BK53khVY.js +0 -1
  114. package/dashboard/dist/assets/agent-logos-DXpW_Wmz.js +0 -1
  115. package/dashboard/dist/assets/arrow-up-right-DCc--V5G.js +0 -1
  116. package/dashboard/dist/assets/check-DqxuosV2.js +0 -1
  117. package/dashboard/dist/assets/copy-qChAT7MY.js +0 -1
  118. package/dashboard/dist/assets/download-W9R3sBph.js +0 -1
  119. package/dashboard/dist/assets/flame-0eyhMnfU.js +0 -1
  120. package/dashboard/dist/assets/format-tokens-CkfBUcoa.js +0 -1
  121. package/dashboard/dist/assets/info-DY1O85-U.js +0 -1
  122. package/dashboard/dist/assets/link-2-B_Z66hFT.js +0 -1
  123. package/dashboard/dist/assets/loader-circle-xH40lon2.js +0 -1
  124. package/dashboard/dist/assets/main-CeaB5N36.js +0 -1259
  125. package/dashboard/dist/assets/main-DAc77vwF.css +0 -1
  126. package/dashboard/dist/assets/provider-display-C_XKZQcF.js +0 -42
  127. package/dashboard/dist/assets/trash-2-DVhNJ74r.js +0 -1
  128. package/dashboard/dist/assets/use-community-stats-CO-nr2Vf.js +0 -1
  129. package/dashboard/dist/assets/use-limits-display-prefs-RRqmyoxt.js +0 -1
  130. package/dashboard/dist/assets/use-native-settings-79piPeET.js +0 -1
  131. package/dashboard/dist/assets/use-quality-per-dollar-pref-DyF49QRF.js +0 -1
  132. package/dashboard/dist/assets/use-transform-BvXyGAtb.js +0 -1
  133. package/dashboard/dist/assets/use-usage-limits-BiZ_I-KE.js +0 -1
  134. package/dashboard/dist/assets/useCurrency-D-C1e7eE.js +0 -1
  135. package/dashboard/dist/assets/useScrollLock-BfU2EMvG.js +0 -1
  136. package/dashboard/dist/assets/useTokenFormat-Cm4EvrQp.js +0 -1
  137. 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");
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+
3
+ // Conservative, metadata-only Git attribution. A commit is attributed only
4
+ // when exactly one local AI session overlaps its author timestamp.
5
+
6
+ const { execFile } = require("node:child_process");
7
+ const fs = require("node:fs");
8
+ const fsp = require("node:fs/promises");
9
+ const os = require("node:os");
10
+ const path = require("node:path");
11
+ const crypto = require("node:crypto");
12
+
13
+ function resolveAutoOutcomesPath(home = os.homedir()) {
14
+ return path.join(home, ".tokentracker", "tracker", "auto-outcomes.jsonl");
15
+ }
16
+
17
+ function readJsonl(filePath) {
18
+ try { return fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
19
+ catch { return []; }
20
+ }
21
+
22
+ // Async on purpose: this runs inside the local API request path, where a
23
+ // synchronous spawn would freeze every other endpoint for the duration of
24
+ // each git invocation.
25
+ function runGit(cwd, args) {
26
+ return new Promise((resolve) => {
27
+ execFile(
28
+ "git",
29
+ args,
30
+ { cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 32 * 1024 * 1024 },
31
+ (error, stdout) => resolve(error ? "" : String(stdout).trim()),
32
+ );
33
+ });
34
+ }
35
+
36
+ async function repoRoot(cwd) {
37
+ if (!cwd || !fs.existsSync(cwd)) return null;
38
+ return (await runGit(cwd, ["rev-parse", "--show-toplevel"])) || null;
39
+ }
40
+
41
+ async function commitsForWindow(root, from, to) {
42
+ const format = "%H%x1f%aI%x1f%P%x1f%s%x1e";
43
+ const raw = await runGit(root, ["log", "--all", `--since=${from}`, `--until=${to}`, `--format=${format}`]);
44
+ if (!raw) return [];
45
+ return raw.split("\x1e").map((item) => item.trim()).filter(Boolean).map((item) => {
46
+ const [sha, timestamp, parents, subject] = item.split("\x1f");
47
+ return {
48
+ sha,
49
+ timestamp,
50
+ parent_count: String(parents || "").split(/\s+/).filter(Boolean).length,
51
+ reverted: /^revert\b/i.test(subject || ""),
52
+ };
53
+ }).filter((row) => row.sha && Number.isFinite(Date.parse(row.timestamp)));
54
+ }
55
+
56
+ async function writeAtomic(filePath, content) {
57
+ await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
58
+ const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
59
+ await fsp.writeFile(temp, content, { encoding: "utf8", mode: 0o600 });
60
+ await fsp.rename(temp, filePath);
61
+ }
62
+
63
+ async function buildGitOutcomesInternal(sessions, { home = os.homedir(), force = false, maxAgeDays = 90 } = {}) {
64
+ const groups = new Map();
65
+ const rootCache = new Map();
66
+ const cutoff = Date.now() - Math.max(1, Number(maxAgeDays) || 90) * 86400_000;
67
+ for (const session of sessions || []) {
68
+ if (!session?.project_ref || !session.started_at || !session.ended_at) continue;
69
+ if (Date.parse(session.ended_at) < cutoff) continue;
70
+ let root = rootCache.get(session.project_ref);
71
+ if (root === undefined) {
72
+ root = await repoRoot(session.project_ref);
73
+ rootCache.set(session.project_ref, root);
74
+ }
75
+ if (!root) continue;
76
+ if (!groups.has(root)) groups.set(root, []);
77
+ groups.get(root).push(session);
78
+ }
79
+
80
+ const outputPath = resolveAutoOutcomesPath(home);
81
+ const metaPath = `${outputPath}.meta.json`;
82
+ const signatureHash = crypto.createHash("sha256");
83
+ for (const [root, repoSessions] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
84
+ signatureHash.update(`${root}\0${await runGit(root, ["for-each-ref", "--format=%(objectname)"])}\n`);
85
+ for (const session of repoSessions) signatureHash.update(`${session.session_hash}\0${session.ended_at}\n`);
86
+ }
87
+ const signature = signatureHash.digest("hex");
88
+ if (!force) {
89
+ try {
90
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
91
+ if (meta.version === 1 && meta.signature === signature) return readJsonl(outputPath);
92
+ } catch { /* first run */ }
93
+ }
94
+
95
+ const outcomes = [];
96
+ for (const [root, repoSessions] of groups) {
97
+ const start = repoSessions.reduce((min, row) => !min || row.started_at < min ? row.started_at : min, "");
98
+ const endMs = Math.max(...repoSessions.map((row) => Date.parse(row.ended_at))) + 60 * 60 * 1000;
99
+ const commits = await commitsForWindow(root, start, new Date(endMs).toISOString());
100
+ for (const commit of commits) {
101
+ const commitMs = Date.parse(commit.timestamp);
102
+ const candidates = repoSessions.filter((session) => {
103
+ const sessionStart = Date.parse(session.started_at) - 10 * 60 * 1000;
104
+ const sessionEnd = Date.parse(session.ended_at) + 60 * 60 * 1000;
105
+ return commitMs >= sessionStart && commitMs <= sessionEnd;
106
+ });
107
+ if (candidates.length !== 1) continue;
108
+ const session = candidates[0];
109
+ outcomes.push({
110
+ timestamp: commit.timestamp,
111
+ model: session.model || "unknown",
112
+ tool: session.source || "unknown",
113
+ accepted: !commit.reverted,
114
+ task_type: "git_commit",
115
+ status: commit.reverted ? "reverted" : "committed",
116
+ session_hash: session.session_hash,
117
+ commit_hash: commit.sha.slice(0, 12),
118
+ parent_count: commit.parent_count,
119
+ confidence: "heuristic",
120
+ methodology: "single-overlapping-session",
121
+ });
122
+ }
123
+ }
124
+ outcomes.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
125
+ await writeAtomic(outputPath, outcomes.map((row) => JSON.stringify(row)).join("\n") + (outcomes.length ? "\n" : ""));
126
+ await writeAtomic(metaPath, `${JSON.stringify({ version: 1, signature, generated_at: new Date().toISOString(), max_age_days: maxAgeDays })}\n`);
127
+ return outcomes;
128
+ }
129
+
130
+ // Overlapping outcomes/session-insights requests must not spawn duplicate git
131
+ // scans nor race the atomic sidecar write; share one build per home.
132
+ const gitOutcomesBuilds = new Map();
133
+
134
+ function buildGitOutcomes(sessions, options = {}) {
135
+ const home = path.resolve(String(options.home || os.homedir()));
136
+ const existing = gitOutcomesBuilds.get(home);
137
+ if (existing) return existing;
138
+ const promise = buildGitOutcomesInternal(sessions, { ...options, home });
139
+ gitOutcomesBuilds.set(home, promise);
140
+ const clear = () => {
141
+ if (gitOutcomesBuilds.get(home) === promise) gitOutcomesBuilds.delete(home);
142
+ };
143
+ promise.then(clear, clear);
144
+ return promise;
145
+ }
146
+
147
+ module.exports = { resolveAutoOutcomesPath, buildGitOutcomes, repoRoot, commitsForWindow };