tokelytics 0.2.0 → 0.3.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 (3) hide show
  1. package/README.md +12 -3
  2. package/bin/tokelytics.mjs +1446 -446
  3. package/package.json +18 -6
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/config.ts
4
- import { promises as fs3 } from "node:fs";
5
- import * as path5 from "node:path";
4
+ import { promises as fs4 } from "node:fs";
5
+ import * as path6 from "node:path";
6
6
 
7
7
  // src/paths.ts
8
- import { promises as fs2 } from "node:fs";
8
+ import { promises as fs3 } from "node:fs";
9
9
  import * as os3 from "node:os";
10
- import * as path4 from "node:path";
10
+ import * as path5 from "node:path";
11
11
  import { randomUUID } from "node:crypto";
12
12
 
13
13
  // ../packages/core/dist/types.js
@@ -63,15 +63,134 @@ function hourOf(ts) {
63
63
  function rollupId(provider, day) {
64
64
  return `${provider}_${day}`;
65
65
  }
66
- function aggregateSession(sessionId, turns) {
67
- const agg = {
68
- sessionId,
69
- provider: turns[0]?.provider ?? "claude",
70
- project: "unknown",
71
- gitBranch: "",
66
+ var TIMELINE_BUCKET_MS = 5 * 60 * 1e3;
67
+ var TIMELINE_RETENTION_MS = 8 * 24 * 60 * 60 * 1e3;
68
+
69
+ // ../packages/core/dist/snapshot.js
70
+ var SNAPSHOT_RECENT_TURNS = 32;
71
+ var SNAPSHOT_SESSIONS = 100;
72
+ var SNAPSHOT_RETENTION_DAYS = 120;
73
+ var SNAPSHOT_TARGET_BYTES = 7e5;
74
+ function emptyDashboardSnapshot(now = /* @__PURE__ */ new Date()) {
75
+ return {
76
+ version: 2,
77
+ updatedAt: now.toISOString(),
78
+ recentTurns: [],
79
+ sessions: [],
80
+ rollups: [],
81
+ limits: []
82
+ };
83
+ }
84
+ function mergeDashboardSnapshot(previous, turns, limits, device, now = /* @__PURE__ */ new Date()) {
85
+ const next = structuredClone(previous ?? emptyDashboardSnapshot(now));
86
+ next.version = 2;
87
+ if (turns.length > 0) {
88
+ next.recentTurns = mergeRecentTurns(next.recentTurns, turns);
89
+ next.sessions = mergeSessions(next.sessions, turns);
90
+ next.rollups = mergeRollups(next.rollups, turns, now);
91
+ }
92
+ if (limits.length > 0)
93
+ next.limits = mergeLimits(next.limits, limits);
94
+ if (device)
95
+ next.device = device;
96
+ if (turns.length > 0 || limits.length > 0 || device)
97
+ next.updatedAt = now.toISOString();
98
+ return next;
99
+ }
100
+ function prepareDashboardSnapshotForCloud(snapshot2, targetBytes = SNAPSHOT_TARGET_BYTES) {
101
+ const next = structuredClone(snapshot2);
102
+ next.recentTurns = next.recentTurns.map(sanitizeTurn);
103
+ next.sessions = next.sessions.map((session) => ({
104
+ ...session,
105
+ project: safeLabel(session.project),
106
+ gitBranch: safeLabel(session.gitBranch, 120)
107
+ }));
108
+ if (next.device)
109
+ next.device.name = safeLabel(next.device.name, 120);
110
+ while (snapshotBytes(next) > targetBytes && next.sessions.length > 20)
111
+ next.sessions.pop();
112
+ while (snapshotBytes(next) > targetBytes && next.rollups.length > 30)
113
+ next.rollups.shift();
114
+ while (snapshotBytes(next) > targetBytes && next.recentTurns.length > 8)
115
+ next.recentTurns.shift();
116
+ if (snapshotBytes(next) > targetBytes) {
117
+ next.recentTurns = next.recentTurns.map((turn) => ({ ...turn, fileReads: void 0 }));
118
+ }
119
+ if (snapshotBytes(next) > targetBytes) {
120
+ throw new Error(`Dashboard snapshot exceeds the ${targetBytes} byte safety limit after pruning.`);
121
+ }
122
+ return next;
123
+ }
124
+ function snapshotBytes(snapshot2) {
125
+ return new TextEncoder().encode(JSON.stringify(snapshot2)).byteLength;
126
+ }
127
+ function mergeRecentTurns(existing, incoming) {
128
+ const byId = new Map(existing.map((turn) => [turn.turnId, turn]));
129
+ for (const turn of incoming)
130
+ byId.set(turn.turnId, turn);
131
+ return [...byId.values()].sort((a, b) => a.ts.localeCompare(b.ts)).slice(-SNAPSHOT_RECENT_TURNS);
132
+ }
133
+ function sanitizeTurn(turn) {
134
+ return {
135
+ ...turn,
136
+ cwd: void 0,
137
+ project: safeLabel(turn.project),
138
+ gitBranch: safeLabel(turn.gitBranch, 120),
139
+ toolName: safeLabel(turn.toolName ?? void 0, 80) || void 0,
140
+ fileReads: turn.fileReads?.slice(0, 20).map((read) => ({
141
+ ...read,
142
+ path: safePath(read.path)
143
+ }))
144
+ };
145
+ }
146
+ function safeLabel(value, max = 160) {
147
+ if (!value)
148
+ return "";
149
+ return [...value].filter((character) => {
150
+ const code = character.charCodeAt(0);
151
+ return code > 31 && code !== 127;
152
+ }).join("").trim().slice(0, max);
153
+ }
154
+ function safePath(value) {
155
+ const normalized = value.replaceAll("\\", "/");
156
+ const parts = normalized.split("/").filter((part) => part && part !== "." && part !== ".." && !/^[A-Za-z]:$/.test(part));
157
+ return parts.slice(-3).join("/").slice(-200);
158
+ }
159
+ function mergeSessions(existing, turns) {
160
+ const byId = new Map(existing.map((session) => [session.sessionId, { ...session }]));
161
+ for (const turn of turns) {
162
+ const session = byId.get(turn.sessionId) ?? emptySession(turn);
163
+ session.turnCount++;
164
+ session.inputTokens += turn.inputTokens;
165
+ session.outputTokens += turn.outputTokens;
166
+ session.cacheReadTokens += turn.cacheReadTokens;
167
+ session.cacheCreationTokens += turn.cacheCreationTokens;
168
+ session.cachedInputTokens += turn.cachedInputTokens;
169
+ session.reasoningTokens += turn.reasoningTokens;
170
+ if (!session.firstTs || turn.ts < session.firstTs)
171
+ session.firstTs = turn.ts;
172
+ if (!session.lastTs || turn.ts > session.lastTs)
173
+ session.lastTs = turn.ts;
174
+ if (turn.project && session.project === "unknown")
175
+ session.project = turn.project;
176
+ if (turn.gitBranch && !session.gitBranch)
177
+ session.gitBranch = turn.gitBranch;
178
+ if (turn.model && (!session.model || modelPriority(turn.model) > modelPriority(session.model))) {
179
+ session.model = turn.model;
180
+ }
181
+ byId.set(turn.sessionId, session);
182
+ }
183
+ return [...byId.values()].sort((a, b) => b.lastTs.localeCompare(a.lastTs)).slice(0, SNAPSHOT_SESSIONS);
184
+ }
185
+ function emptySession(turn) {
186
+ return {
187
+ sessionId: turn.sessionId,
188
+ provider: turn.provider,
189
+ project: turn.project || "unknown",
190
+ gitBranch: turn.gitBranch || "",
72
191
  firstTs: "",
73
192
  lastTs: "",
74
- model: "",
193
+ model: turn.model || "",
75
194
  turnCount: 0,
76
195
  inputTokens: 0,
77
196
  outputTokens: 0,
@@ -80,124 +199,59 @@ function aggregateSession(sessionId, turns) {
80
199
  cachedInputTokens: 0,
81
200
  reasoningTokens: 0
82
201
  };
83
- for (const t of turns) {
84
- agg.turnCount++;
85
- agg.inputTokens += t.inputTokens;
86
- agg.outputTokens += t.outputTokens;
87
- agg.cacheReadTokens += t.cacheReadTokens;
88
- agg.cacheCreationTokens += t.cacheCreationTokens;
89
- agg.cachedInputTokens += t.cachedInputTokens;
90
- agg.reasoningTokens += t.reasoningTokens;
91
- if (t.ts && (!agg.firstTs || t.ts < agg.firstTs))
92
- agg.firstTs = t.ts;
93
- if (t.ts && (!agg.lastTs || t.ts > agg.lastTs))
94
- agg.lastTs = t.ts;
95
- if (t.project && agg.project === "unknown")
96
- agg.project = t.project;
97
- if (t.gitBranch && !agg.gitBranch)
98
- agg.gitBranch = t.gitBranch;
99
- if (t.model && modelPriority(t.model) > modelPriority(agg.model))
100
- agg.model = t.model;
101
- }
102
- if (!agg.model) {
103
- const withModel = turns.find((t) => t.model);
104
- if (withModel)
105
- agg.model = withModel.model;
106
- }
107
- return agg;
108
- }
109
- function emptyModelTotals(model) {
110
- return {
111
- model,
112
- turns: 0,
113
- inputTokens: 0,
114
- outputTokens: 0,
115
- cacheReadTokens: 0,
116
- cacheCreationTokens: 0,
117
- cachedInputTokens: 0,
118
- reasoningTokens: 0
119
- };
120
202
  }
121
- function buildRollup(provider, day, turns) {
122
- const models = {};
123
- const hours = /* @__PURE__ */ new Map();
124
- for (const t of turns) {
125
- const key = t.model || "unknown";
126
- const m = models[key] ??= emptyModelTotals(key);
127
- m.turns++;
128
- m.inputTokens += t.inputTokens;
129
- m.outputTokens += t.outputTokens;
130
- m.cacheReadTokens += t.cacheReadTokens;
131
- m.cacheCreationTokens += t.cacheCreationTokens;
132
- m.cachedInputTokens += t.cachedInputTokens;
133
- m.reasoningTokens += t.reasoningTokens;
134
- const h = hourOf(t.ts);
135
- if (h !== null) {
136
- const bucket = hours.get(h) ?? { hour: h, output: 0, turns: 0 };
137
- bucket.output += t.outputTokens;
203
+ function mergeRollups(existing, turns, now) {
204
+ const byId = new Map(existing.map((rollup) => [rollup.id, structuredClone(rollup)]));
205
+ for (const turn of turns) {
206
+ const day = dayOf(turn.ts);
207
+ if (!day)
208
+ continue;
209
+ const id = rollupId(turn.provider, day);
210
+ const rollup = byId.get(id) ?? { id, provider: turn.provider, day, models: {}, hourly: [] };
211
+ const model = turn.model || "unknown";
212
+ const totals = rollup.models[model] ??= {
213
+ model,
214
+ turns: 0,
215
+ inputTokens: 0,
216
+ outputTokens: 0,
217
+ cacheReadTokens: 0,
218
+ cacheCreationTokens: 0,
219
+ cachedInputTokens: 0,
220
+ reasoningTokens: 0
221
+ };
222
+ totals.turns++;
223
+ totals.inputTokens += turn.inputTokens;
224
+ totals.outputTokens += turn.outputTokens;
225
+ totals.cacheReadTokens += turn.cacheReadTokens;
226
+ totals.cacheCreationTokens += turn.cacheCreationTokens;
227
+ totals.cachedInputTokens += turn.cachedInputTokens;
228
+ totals.reasoningTokens += turn.reasoningTokens;
229
+ const hour = hourOf(turn.ts);
230
+ if (hour !== null) {
231
+ const bucket = rollup.hourly.find((item) => item.hour === hour) ?? { hour, output: 0, turns: 0 };
232
+ bucket.output += turn.outputTokens;
138
233
  bucket.turns++;
139
- hours.set(h, bucket);
234
+ if (!rollup.hourly.some((item) => item.hour === hour))
235
+ rollup.hourly.push(bucket);
236
+ rollup.hourly.sort((a, b) => a.hour - b.hour);
140
237
  }
238
+ byId.set(id, rollup);
141
239
  }
142
- return {
143
- id: rollupId(provider, day),
144
- provider,
145
- day,
146
- models,
147
- hourly: [...hours.values()].sort((a, b) => a.hour - b.hour)
148
- };
149
- }
150
- var TIMELINE_BUCKET_MS = 5 * 60 * 1e3;
151
- var TIMELINE_RETENTION_MS = 8 * 24 * 60 * 60 * 1e3;
152
- function bucketStartMs(ts) {
153
- const ms = Date.parse(ts);
154
- if (!Number.isFinite(ms))
155
- return null;
156
- return Math.floor(ms / TIMELINE_BUCKET_MS) * TIMELINE_BUCKET_MS;
240
+ const cutoff = new Date(now);
241
+ cutoff.setUTCDate(cutoff.getUTCDate() - (SNAPSHOT_RETENTION_DAYS - 1));
242
+ const cutoffDay = cutoff.toISOString().slice(0, 10);
243
+ return [...byId.values()].filter((rollup) => rollup.day >= cutoffDay).sort((a, b) => a.day.localeCompare(b.day) || a.provider.localeCompare(b.provider));
157
244
  }
158
- function dayOfMs(t) {
159
- return new Date(t).toISOString().slice(0, 10);
160
- }
161
- function buildTimelineBuckets(turns) {
162
- const map = /* @__PURE__ */ new Map();
163
- for (const t of turns) {
164
- const start = bucketStartMs(t.ts);
165
- if (start === null)
166
- continue;
167
- const bucket = map.get(start) ?? { t: start, models: {} };
168
- const key = t.model || "unknown";
169
- const m = bucket.models[key] ??= { i: 0, o: 0, cr: 0, cc: 0, ci: 0, rs: 0 };
170
- m.i += t.inputTokens;
171
- m.o += t.outputTokens;
172
- m.cr += t.cacheReadTokens;
173
- m.cc += t.cacheCreationTokens;
174
- m.ci += t.cachedInputTokens;
175
- m.rs += t.reasoningTokens;
176
- map.set(start, bucket);
177
- }
178
- return [...map.values()].sort((a, b) => a.t - b.t);
179
- }
180
- function mergeTimeline(existing, recomputed, affectedDays, nowMs) {
181
- const cutoff = nowMs - TIMELINE_RETENTION_MS;
182
- const kept = existing.filter((b) => b.t >= cutoff && !affectedDays.has(dayOfMs(b.t)));
183
- const fresh = recomputed.filter((b) => b.t >= cutoff);
184
- return [...kept, ...fresh].sort((a, b) => a.t - b.t);
185
- }
186
- function affectedKeys(turns) {
187
- const sessionIds = /* @__PURE__ */ new Set();
188
- const dayBuckets = /* @__PURE__ */ new Map();
189
- for (const t of turns) {
190
- sessionIds.add(t.sessionId);
191
- const day = dayOf(t.ts);
192
- if (day)
193
- dayBuckets.set(rollupId(t.provider, day), { provider: t.provider, day });
194
- }
195
- return { sessionIds: [...sessionIds], dayBuckets: [...dayBuckets.values()] };
245
+ function mergeLimits(existing, incoming) {
246
+ const byProvider = new Map(existing.map((limit) => [limit.provider, limit]));
247
+ for (const limit of incoming)
248
+ byProvider.set(limit.provider, limit);
249
+ return [...byProvider.values()];
196
250
  }
197
251
 
198
252
  // ../packages/core/dist/providers/claude.js
199
253
  import * as os from "node:os";
200
- import * as path2 from "node:path";
254
+ import * as path3 from "node:path";
201
255
 
202
256
  // ../packages/core/dist/fs-scan.js
203
257
  import { promises as fs } from "node:fs";
@@ -261,13 +315,144 @@ async function readJsonlFrom(filePath, fromLine) {
261
315
  return { totalLines: lines.length, entries };
262
316
  }
263
317
 
318
+ // ../packages/core/dist/providers/file-read.js
319
+ import { statSync } from "node:fs";
320
+ import * as path2 from "node:path";
321
+ var BYTES_PER_TOKEN = 4;
322
+ var APPROX_BYTES_PER_LINE = 120;
323
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
324
+ ".7z",
325
+ ".avif",
326
+ ".bmp",
327
+ ".class",
328
+ ".dll",
329
+ ".dylib",
330
+ ".exe",
331
+ ".gif",
332
+ ".gz",
333
+ ".ico",
334
+ ".jar",
335
+ ".jpeg",
336
+ ".jpg",
337
+ ".mov",
338
+ ".mp3",
339
+ ".mp4",
340
+ ".pdf",
341
+ ".png",
342
+ ".so",
343
+ ".tar",
344
+ ".wav",
345
+ ".webm",
346
+ ".webp",
347
+ ".zip"
348
+ ]);
349
+ function isGeneratedPath(value) {
350
+ const normalized = value.replace(/\\/g, "/").toLowerCase();
351
+ return /(^|\/)(node_modules|dist|build|out|coverage|target|vendor|\.next|\.nuxt|\.cache)(\/|$)/.test(normalized) || /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|cargo\.lock)$/.test(normalized) || /\.(min\.(js|css)|map|bundle\.(js|css))$/.test(normalized) || /(^|\/)[^/]*bundle[^/]*\.(js|css)$/.test(normalized);
352
+ }
353
+ function isTextTokenCandidatePath(value) {
354
+ return !BINARY_EXTENSIONS.has(path2.extname(value).toLowerCase());
355
+ }
356
+ function displayPath(absolutePath, cwd) {
357
+ const normalized = absolutePath.replace(/\\/g, "/");
358
+ const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
359
+ if (normalizedCwd && normalized.toLowerCase().startsWith(`${normalizedCwd.toLowerCase()}/`)) {
360
+ return normalized.slice(normalizedCwd.length + 1);
361
+ }
362
+ const parts = normalized.split("/").filter(Boolean);
363
+ return parts.slice(-3).join("/") || path2.basename(absolutePath);
364
+ }
365
+ function fileReadFromPath(rawPath, cwd, limitLines) {
366
+ if (!rawPath.trim())
367
+ return null;
368
+ if (!isTextTokenCandidatePath(rawPath))
369
+ return null;
370
+ const rawIsAbsolute = path2.isAbsolute(rawPath);
371
+ const absolutePath = rawIsAbsolute ? path2.normalize(rawPath) : path2.resolve(cwd || process.cwd(), rawPath);
372
+ let bytes = 0;
373
+ try {
374
+ const stat4 = statSync(absolutePath);
375
+ if (!stat4.isFile())
376
+ return null;
377
+ bytes = stat4.size;
378
+ } catch {
379
+ }
380
+ if (limitLines && limitLines > 0 && bytes > 0) {
381
+ bytes = Math.min(bytes, limitLines * APPROX_BYTES_PER_LINE);
382
+ }
383
+ const normalizedRaw = rawPath.replace(/\\/g, "/").replace(/^\.\//, "");
384
+ const safePath2 = !rawIsAbsolute && !normalizedRaw.startsWith("../") ? normalizedRaw : displayPath(absolutePath, cwd);
385
+ return {
386
+ path: safePath2,
387
+ bytes,
388
+ estimatedTokens: bytes > 0 ? Math.ceil(bytes / BYTES_PER_TOKEN) : 0,
389
+ generated: isGeneratedPath(safePath2)
390
+ };
391
+ }
392
+ function parseJsonObject(value) {
393
+ if (typeof value !== "string")
394
+ return {};
395
+ try {
396
+ const parsed = JSON.parse(value);
397
+ return parsed && typeof parsed === "object" ? parsed : {};
398
+ } catch {
399
+ return {};
400
+ }
401
+ }
402
+ function candidateAfterCommand(command, tool) {
403
+ const escaped = tool.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
404
+ const rx = new RegExp(`(?:^|[;|&]\\s*)${escaped}\\s+(?:(?:-[\\w-]+)(?:\\s+\\d+)?\\s+)*(?:"([^"]+)"|'([^']+)'|([^\\s;|&]+))`, "gim");
405
+ const out = [];
406
+ for (const match of command.matchAll(rx)) {
407
+ const value = match[1] ?? match[2] ?? match[3];
408
+ if (value && !value.startsWith("-") && !value.startsWith("$") && !value.startsWith("(") && !value.startsWith("%")) {
409
+ out.push(value);
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+ function fileReadsFromCodexCall(name, argsValue, fallbackCwd) {
415
+ const args = parseJsonObject(argsValue);
416
+ const cwd = typeof args["workdir"] === "string" ? args["workdir"] : fallbackCwd;
417
+ if (name === "view_image")
418
+ return [];
419
+ if (name !== "shell_command")
420
+ return [];
421
+ const command = typeof args["command"] === "string" ? args["command"] : "";
422
+ const candidates = [
423
+ ...candidateAfterCommand(command, "Get-Content"),
424
+ ...candidateAfterCommand(command, "gc"),
425
+ ...candidateAfterCommand(command, "cat"),
426
+ ...candidateAfterCommand(command, "type"),
427
+ ...candidateAfterCommand(command, "more"),
428
+ ...candidateAfterCommand(command, "head"),
429
+ ...candidateAfterCommand(command, "tail")
430
+ ];
431
+ return [...new Set(candidates)].flatMap((candidate) => {
432
+ const read = fileReadFromPath(candidate, cwd);
433
+ return read ? [read] : [];
434
+ });
435
+ }
436
+ function fillMissingReadEstimate(reads, output) {
437
+ if (typeof output !== "string" || output.length === 0)
438
+ return;
439
+ const missing = reads.filter((read) => read.estimatedTokens === 0);
440
+ if (missing.length === 0)
441
+ return;
442
+ const each = Math.max(1, Math.ceil(output.length / BYTES_PER_TOKEN / missing.length));
443
+ for (const read of missing) {
444
+ read.estimatedTokens = each;
445
+ read.bytes = each * BYTES_PER_TOKEN;
446
+ }
447
+ }
448
+
264
449
  // ../packages/core/dist/providers/claude.js
265
450
  var MTIME_EPSILON_MS = 1;
266
451
  function defaultClaudeDirs(home = os.homedir()) {
267
452
  return [
268
- path2.join(home, ".claude", "projects"),
453
+ path3.join(home, ".claude", "projects"),
269
454
  // Xcode coding-assistant integration (macOS); silently skipped if absent.
270
- path2.join(home, "Library", "Developer", "Xcode", "CodingAssistant", "ClaudeAgentConfig", "projects")
455
+ path3.join(home, "Library", "Developer", "Xcode", "CodingAssistant", "ClaudeAgentConfig", "projects")
271
456
  ];
272
457
  }
273
458
  function parseClaudeEntries(entries) {
@@ -292,14 +477,20 @@ function parseClaudeEntries(entries) {
292
477
  if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens === 0)
293
478
  continue;
294
479
  let toolName = null;
480
+ const fileReads = [];
481
+ const cwd = rec.cwd ?? "";
295
482
  for (const item of msg.content ?? []) {
296
483
  if (item && typeof item === "object" && item.type === "tool_use") {
297
- toolName = item.name ?? null;
298
- break;
484
+ toolName ??= item.name ?? null;
485
+ if (item.name === "Read" && typeof item.input?.file_path === "string") {
486
+ const limit = typeof item.input.limit === "number" ? item.input.limit : void 0;
487
+ const read = fileReadFromPath(item.input.file_path, cwd, limit);
488
+ if (read)
489
+ fileReads.push(read);
490
+ }
299
491
  }
300
492
  }
301
493
  const messageId = msg.id ?? "";
302
- const cwd = rec.cwd ?? "";
303
494
  const turn = {
304
495
  provider: "claude",
305
496
  turnId: messageId ? `claude:${messageId}` : `claude:noid:${sessionId}:${lineNo}`,
@@ -313,6 +504,7 @@ function parseClaudeEntries(entries) {
313
504
  cachedInputTokens: 0,
314
505
  reasoningTokens: 0,
315
506
  toolName,
507
+ fileReads,
316
508
  project: projectNameFromCwd(cwd),
317
509
  cwd,
318
510
  gitBranch: rec.gitBranch ?? ""
@@ -324,6 +516,63 @@ function parseClaudeEntries(entries) {
324
516
  }
325
517
  return [...noId, ...seen.values()];
326
518
  }
519
+ function asNumber(v) {
520
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
521
+ return Number.isFinite(n) ? n : void 0;
522
+ }
523
+ function labelClaudeWindow(id) {
524
+ const k = id.toLowerCase();
525
+ if (k.includes("5") || k.includes("session"))
526
+ return "Session (5hr)";
527
+ if (k.includes("7") || k.includes("week"))
528
+ return "Weekly (7 day)";
529
+ return "Usage";
530
+ }
531
+ function collectClaudeWindows(value, prefix = "") {
532
+ if (!value || typeof value !== "object")
533
+ return [];
534
+ const obj = value;
535
+ const usedPercent = asNumber(obj["used_percentage"] ?? obj["usedPercent"] ?? obj["used_percent"]);
536
+ const resetsRaw = obj["resets_at"] ?? obj["resetsAt"] ?? obj["reset_at"];
537
+ const resetsAt = typeof resetsRaw === "string" ? resetsRaw : asNumber(resetsRaw) !== void 0 ? new Date(asNumber(resetsRaw) * 1e3).toISOString() : void 0;
538
+ const resetAfterSeconds = asNumber(obj["resets_in_seconds"] ?? obj["reset_after_seconds"] ?? obj["resetAfterSeconds"]);
539
+ const current = usedPercent === void 0 ? [] : [
540
+ {
541
+ id: prefix || "usage",
542
+ label: labelClaudeWindow(prefix || "usage"),
543
+ usedPercent,
544
+ resetsAt,
545
+ resetAfterSeconds
546
+ }
547
+ ];
548
+ for (const [key, child] of Object.entries(obj)) {
549
+ if (!child || typeof child !== "object")
550
+ continue;
551
+ current.push(...collectClaudeWindows(child, prefix ? `${prefix}.${key}` : key));
552
+ }
553
+ return current;
554
+ }
555
+ function parseClaudeLimitSnapshots(entries, syncedAt = /* @__PURE__ */ new Date()) {
556
+ let latest = null;
557
+ for (const { record } of entries) {
558
+ if (!record || typeof record !== "object")
559
+ continue;
560
+ const rec = record;
561
+ const rateLimits = rec.rateLimits ?? rec["rate_limits"];
562
+ if (!rateLimits)
563
+ continue;
564
+ const windows = collectClaudeWindows(rateLimits).filter((w, idx, arr) => arr.findIndex((x) => x.id === w.id) === idx);
565
+ if (windows.length === 0)
566
+ continue;
567
+ latest = {
568
+ provider: "claude",
569
+ source: "claude.rateLimits",
570
+ updatedAt: syncedAt.toISOString(),
571
+ windows
572
+ };
573
+ }
574
+ return latest ? [latest] : [];
575
+ }
327
576
  var ClaudeConnector = class {
328
577
  id = "claude";
329
578
  dirs;
@@ -336,6 +585,7 @@ var ClaudeConnector = class {
336
585
  async collect(state) {
337
586
  const nextFiles = { ...state.files };
338
587
  const turns = [];
588
+ const scannedEntries = [];
339
589
  for (const dir of this.dirs) {
340
590
  const files = await walkFiles(dir, (name) => name.endsWith(".jsonl"));
341
591
  for (const filePath of files) {
@@ -348,22 +598,30 @@ var ClaudeConnector = class {
348
598
  }
349
599
  const fromLine = cursor ? cursor.lines : 0;
350
600
  const { totalLines, entries } = await readJsonlFrom(filePath, fromLine);
601
+ scannedEntries.push(...entries);
351
602
  if (entries.length > 0) {
352
603
  turns.push(...parseClaudeEntries(entries));
353
604
  }
354
605
  nextFiles[filePath] = { mtimeMs, lines: totalLines };
355
606
  }
356
607
  }
357
- return { turns, state: { files: nextFiles } };
608
+ return { turns, limits: parseClaudeLimitSnapshots(scannedEntries), state: { files: nextFiles } };
358
609
  }
359
610
  };
360
611
 
361
612
  // ../packages/core/dist/providers/codex.js
613
+ import { promises as fs2 } from "node:fs";
362
614
  import * as os2 from "node:os";
363
- import * as path3 from "node:path";
615
+ import * as path4 from "node:path";
364
616
  var MTIME_EPSILON_MS2 = 1;
617
+ var RATE_LIMIT_REFRESH_MS = 6e4;
618
+ var MAX_RATE_LIMIT_LOG_BYTES = 16 * 1024 * 1024;
365
619
  function defaultCodexDirs(home = os2.homedir()) {
366
- return [path3.join(home, ".codex", "sessions")];
620
+ return [path4.join(home, ".codex", "sessions")];
621
+ }
622
+ function defaultCodexRateLimitFiles(home = os2.homedir()) {
623
+ const base = path4.join(home, ".codex");
624
+ return [path4.join(base, "logs_2.sqlite"), path4.join(base, "logs_2.sqlite-wal")];
367
625
  }
368
626
  function usageFromObj(obj) {
369
627
  if (!obj || typeof obj !== "object")
@@ -452,8 +710,29 @@ function parseCodexEntries(fileId, entries) {
452
710
  let prevCumulative = null;
453
711
  let seq = 0;
454
712
  let runningModel = meta.model;
713
+ let pendingReads = [];
714
+ let pendingToolName = null;
715
+ const readsByCall = /* @__PURE__ */ new Map();
455
716
  for (const { record } of entries) {
456
- const recModel = pickModel(record?.["payload"]) ?? pickModel(record);
717
+ const rec = record;
718
+ const payload = rec["payload"];
719
+ if (payload?.["type"] === "function_call") {
720
+ const name = typeof payload["name"] === "string" ? payload["name"] : "";
721
+ pendingToolName = name || pendingToolName;
722
+ const reads = fileReadsFromCodexCall(name, payload["arguments"], meta.cwd);
723
+ if (reads.length > 0) {
724
+ pendingReads = [...pendingReads, ...reads];
725
+ const callId = typeof payload["call_id"] === "string" ? payload["call_id"] : "";
726
+ if (callId)
727
+ readsByCall.set(callId, reads);
728
+ }
729
+ } else if (payload?.["type"] === "function_call_output") {
730
+ const callId = typeof payload["call_id"] === "string" ? payload["call_id"] : "";
731
+ const reads = readsByCall.get(callId);
732
+ if (reads)
733
+ fillMissingReadEstimate(reads, payload["output"]);
734
+ }
735
+ const recModel = pickModel(payload) ?? pickModel(record);
457
736
  if (recModel)
458
737
  runningModel = recModel;
459
738
  const ev = extractUsageEvent(record);
@@ -489,26 +768,148 @@ function parseCodexEntries(fileId, entries) {
489
768
  cacheCreationTokens: 0,
490
769
  cachedInputTokens: delta.cachedInput,
491
770
  reasoningTokens: delta.reasoning,
492
- toolName: null,
771
+ toolName: pendingToolName,
772
+ fileReads: pendingReads,
493
773
  project: meta.project,
494
774
  cwd: meta.cwd,
495
775
  gitBranch: meta.gitBranch
496
776
  });
777
+ pendingReads = [];
778
+ pendingToolName = null;
779
+ readsByCall.clear();
497
780
  seq++;
498
781
  }
499
782
  return turns;
500
783
  }
501
784
  function codexFileId(filePath) {
502
- return path3.basename(filePath).replace(/\.jsonl$/i, "");
785
+ return path4.basename(filePath).replace(/\.jsonl$/i, "");
786
+ }
787
+ function asNumber2(v) {
788
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
789
+ return Number.isFinite(n) ? n : void 0;
790
+ }
791
+ function codexWindowLabel(id, minutes) {
792
+ if (minutes === 300)
793
+ return "5 hour usage limit";
794
+ if (minutes === 10080)
795
+ return "Weekly usage limit";
796
+ return id === "primary" ? "Primary usage limit" : id === "secondary" ? "Secondary usage limit" : "Usage limit";
797
+ }
798
+ function toNativeWindow(id, raw) {
799
+ if (!raw)
800
+ return null;
801
+ const usedPercent = asNumber2(raw.used_percent);
802
+ if (usedPercent === void 0)
803
+ return null;
804
+ const windowMinutes = asNumber2(raw.window_minutes);
805
+ const resetAfterSeconds = asNumber2(raw.reset_after_seconds);
806
+ const resetAtSeconds = asNumber2(raw.reset_at);
807
+ return {
808
+ id,
809
+ label: codexWindowLabel(id, windowMinutes),
810
+ usedPercent,
811
+ windowMinutes,
812
+ resetsAt: resetAtSeconds ? new Date(resetAtSeconds * 1e3).toISOString() : void 0,
813
+ resetAfterSeconds
814
+ };
815
+ }
816
+ function extractJsonObject(text, start) {
817
+ let depth = 0;
818
+ let inString = false;
819
+ let escaped = false;
820
+ for (let i = start; i < text.length; i++) {
821
+ const ch = text[i];
822
+ if (inString) {
823
+ if (escaped) {
824
+ escaped = false;
825
+ } else if (ch === "\\") {
826
+ escaped = true;
827
+ } else if (ch === '"') {
828
+ inString = false;
829
+ }
830
+ continue;
831
+ }
832
+ if (ch === '"') {
833
+ inString = true;
834
+ } else if (ch === "{") {
835
+ depth++;
836
+ } else if (ch === "}") {
837
+ depth--;
838
+ if (depth === 0)
839
+ return text.slice(start, i + 1);
840
+ }
841
+ }
842
+ return null;
843
+ }
844
+ function parseCodexRateLimitSnapshot(text, syncedAt = /* @__PURE__ */ new Date()) {
845
+ const needle = '"type":"codex.rate_limits"';
846
+ let pos = -1;
847
+ let latest = null;
848
+ while ((pos = text.indexOf(needle, pos + 1)) !== -1) {
849
+ const start = text.lastIndexOf("{", pos);
850
+ if (start < 0)
851
+ continue;
852
+ const json = extractJsonObject(text, start);
853
+ if (!json)
854
+ continue;
855
+ let payload;
856
+ try {
857
+ payload = JSON.parse(json);
858
+ } catch {
859
+ continue;
860
+ }
861
+ if (payload.type !== "codex.rate_limits")
862
+ continue;
863
+ const windows = [
864
+ toNativeWindow("primary", payload.rate_limits?.primary),
865
+ toNativeWindow("secondary", payload.rate_limits?.secondary)
866
+ ].filter((w) => Boolean(w));
867
+ if (windows.length === 0)
868
+ continue;
869
+ latest = {
870
+ provider: "codex",
871
+ source: "codex.rate_limits",
872
+ planType: typeof payload.plan_type === "string" ? payload.plan_type : void 0,
873
+ updatedAt: syncedAt.toISOString(),
874
+ windows
875
+ };
876
+ }
877
+ return latest;
878
+ }
879
+ async function readCodexRateLimits(files) {
880
+ const chunks = [];
881
+ for (const file of files) {
882
+ try {
883
+ const stat4 = await fs2.stat(file);
884
+ const length = Math.min(stat4.size, MAX_RATE_LIMIT_LOG_BYTES);
885
+ const handle = await fs2.open(file, "r");
886
+ try {
887
+ const buffer = Buffer.alloc(length);
888
+ await handle.read(buffer, 0, length, Math.max(0, stat4.size - length));
889
+ chunks.push(buffer.toString("latin1"));
890
+ } finally {
891
+ await handle.close();
892
+ }
893
+ } catch {
894
+ }
895
+ }
896
+ if (chunks.length === 0)
897
+ return [];
898
+ const snapshot2 = parseCodexRateLimitSnapshot(chunks.join("\n"), /* @__PURE__ */ new Date());
899
+ return snapshot2 ? [snapshot2] : [];
503
900
  }
504
901
  var CodexConnector = class {
505
902
  id = "codex";
506
903
  dirs;
507
- constructor(dirs = defaultCodexDirs()) {
904
+ rateLimitFiles;
905
+ cachedLimits = [];
906
+ nextLimitReadAt = 0;
907
+ constructor(dirs = defaultCodexDirs(), rateLimitFiles = defaultCodexRateLimitFiles()) {
508
908
  this.dirs = dirs;
909
+ this.rateLimitFiles = rateLimitFiles;
509
910
  }
510
911
  watchPaths() {
511
- return this.dirs;
912
+ return [...this.dirs, ...this.rateLimitFiles];
512
913
  }
513
914
  async collect(state) {
514
915
  const nextFiles = { ...state.files };
@@ -525,44 +926,71 @@ var CodexConnector = class {
525
926
  }
526
927
  const { totalLines, entries } = await readJsonlFrom(filePath, 0);
527
928
  if (entries.length > 0) {
528
- turns.push(...parseCodexEntries(codexFileId(filePath), entries));
929
+ const parsed = parseCodexEntries(codexFileId(filePath), entries);
930
+ if (cursor && totalLines >= cursor.lines) {
931
+ const previousCount = parseCodexEntries(codexFileId(filePath), entries.filter((entry) => entry.lineNo <= cursor.lines)).length;
932
+ turns.push(...parsed.slice(previousCount));
933
+ } else {
934
+ turns.push(...parsed);
935
+ }
529
936
  }
530
937
  nextFiles[filePath] = { mtimeMs, lines: totalLines };
531
938
  }
532
939
  }
533
- return { turns, state: { files: nextFiles } };
940
+ if (Date.now() >= this.nextLimitReadAt) {
941
+ const latest = await readCodexRateLimits(this.rateLimitFiles);
942
+ if (latest.length > 0)
943
+ this.cachedLimits = latest;
944
+ this.nextLimitReadAt = Date.now() + RATE_LIMIT_REFRESH_MS;
945
+ }
946
+ return { turns, limits: this.cachedLimits, state: { files: nextFiles } };
534
947
  }
535
948
  };
536
949
 
537
950
  // src/paths.ts
538
951
  function configDir() {
539
- return process.env.TOKELYTICS_HOME ?? path4.join(os3.homedir(), ".tokelytics");
952
+ return process.env.TOKELYTICS_HOME ?? path5.join(os3.homedir(), ".tokelytics");
540
953
  }
541
- var statePath = () => path4.join(configDir(), "state.json");
542
- var credsPath = () => path4.join(configDir(), "credentials.json");
954
+ var statePath = () => path5.join(configDir(), "state.json");
955
+ var stateBackupPath = () => path5.join(configDir(), "state.backup.json");
956
+ var credsPath = () => path5.join(configDir(), "credentials.json");
957
+ var updatePath = () => path5.join(configDir(), "update.json");
958
+ var watchLockPath = () => path5.join(configDir(), "watch.lock");
543
959
  async function ensureDir() {
544
- await fs2.mkdir(configDir(), { recursive: true });
960
+ await fs3.mkdir(configDir(), { recursive: true });
545
961
  }
546
962
  async function readJson(file) {
547
963
  try {
548
- return JSON.parse(await fs2.readFile(file, "utf-8"));
964
+ return JSON.parse(await fs3.readFile(file, "utf-8"));
549
965
  } catch {
550
966
  return null;
551
967
  }
552
968
  }
553
- async function writeJson(file, value) {
969
+ async function writeJson(file, value, backup) {
554
970
  await ensureDir();
555
- await fs2.writeFile(file, JSON.stringify(value, null, 2), "utf-8");
971
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
972
+ await fs3.writeFile(temp, JSON.stringify(value, null, 2), "utf-8");
973
+ try {
974
+ if (backup) {
975
+ try {
976
+ await fs3.copyFile(file, backup);
977
+ } catch {
978
+ }
979
+ }
980
+ await fs3.rename(temp, file);
981
+ } finally {
982
+ await fs3.rm(temp, { force: true }).catch(() => void 0);
983
+ }
556
984
  }
557
985
  async function loadState() {
558
- const s = await readJson(statePath());
986
+ const s = await readJson(statePath()) ?? await readJson(stateBackupPath());
559
987
  if (s && s.deviceId && s.scan) return s;
560
- const fresh = { deviceId: randomUUID(), scan: emptyScanState() };
988
+ const fresh = { deviceId: randomUUID(), scan: emptyScanState(), publication: {} };
561
989
  await writeJson(statePath(), fresh);
562
990
  return fresh;
563
991
  }
564
992
  async function saveState(state) {
565
- await writeJson(statePath(), state);
993
+ await writeJson(statePath(), state, stateBackupPath());
566
994
  }
567
995
  async function loadCredentials() {
568
996
  return readJson(credsPath());
@@ -570,16 +998,59 @@ async function loadCredentials() {
570
998
  async function saveCredentials(creds) {
571
999
  await writeJson(credsPath(), creds);
572
1000
  try {
573
- await fs2.chmod(credsPath(), 384);
1001
+ await fs3.chmod(credsPath(), 384);
574
1002
  } catch {
575
1003
  }
576
1004
  }
577
1005
  async function clearCredentials() {
578
1006
  try {
579
- await fs2.rm(credsPath(), { force: true });
1007
+ await fs3.rm(credsPath(), { force: true });
580
1008
  } catch {
581
1009
  }
582
1010
  }
1011
+ async function loadUpdateState() {
1012
+ return await readJson(updatePath()) ?? {};
1013
+ }
1014
+ async function saveUpdateState(state) {
1015
+ await writeJson(updatePath(), state);
1016
+ }
1017
+ async function acquireWatchLock() {
1018
+ await ensureDir();
1019
+ const file = watchLockPath();
1020
+ for (let attempt = 0; attempt < 2; attempt++) {
1021
+ try {
1022
+ const handle = await fs3.open(file, "wx");
1023
+ await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }), "utf-8");
1024
+ await handle.close();
1025
+ let released = false;
1026
+ return {
1027
+ async release() {
1028
+ if (released) return;
1029
+ released = true;
1030
+ const lock = await readJson(file);
1031
+ if (lock?.pid === process.pid) await fs3.rm(file, { force: true });
1032
+ }
1033
+ };
1034
+ } catch (error) {
1035
+ const code = error.code;
1036
+ if (code !== "EEXIST") throw error;
1037
+ const lock = await readJson(file);
1038
+ if (lock?.pid && processIsAlive(lock.pid)) {
1039
+ throw new Error(`Tokelytics is already running (PID ${lock.pid}).`, { cause: error });
1040
+ }
1041
+ await fs3.rm(file, { force: true });
1042
+ }
1043
+ }
1044
+ throw new Error("Could not acquire the Tokelytics watcher lock.");
1045
+ }
1046
+ function processIsAlive(pid) {
1047
+ try {
1048
+ process.kill(pid, 0);
1049
+ return true;
1050
+ } catch (error) {
1051
+ return error.code === "EPERM";
1052
+ }
1053
+ }
583
1054
 
584
1055
  // src/config.ts
585
1056
  var DEFAULT_FIREBASE = {
@@ -613,7 +1084,7 @@ function fromEnv() {
613
1084
  }
614
1085
  async function fromFile() {
615
1086
  try {
616
- const raw = await fs3.readFile(path5.join(configDir(), "config.json"), "utf-8");
1087
+ const raw = await fs4.readFile(path6.join(configDir(), "config.json"), "utf-8");
617
1088
  return JSON.parse(raw);
618
1089
  } catch {
619
1090
  return {};
@@ -656,7 +1127,7 @@ function openBrowser(url) {
656
1127
  }
657
1128
  }
658
1129
  function captureOAuthCode(buildUrl) {
659
- return new Promise((resolve3, reject) => {
1130
+ return new Promise((resolve4, reject) => {
660
1131
  const server = http.createServer((req, res) => {
661
1132
  const u = new URL(req.url ?? "/", "http://localhost");
662
1133
  const code = u.searchParams.get("code");
@@ -666,7 +1137,7 @@ function captureOAuthCode(buildUrl) {
666
1137
  `<html><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>Tokelytics</h2><p>${code ? "You're signed in. You can close this tab." : "Sign-in failed: " + error}</p></body></html>`
667
1138
  );
668
1139
  server.close();
669
- if (code) resolve3({ code });
1140
+ if (code) resolve4({ code });
670
1141
  else reject(new Error(`OAuth failed: ${error ?? "no code returned"}`));
671
1142
  });
672
1143
  server.listen(0, "127.0.0.1", () => {
@@ -761,13 +1232,13 @@ var CORS_HEADERS = {
761
1232
  "Access-Control-Allow-Headers": "Content-Type"
762
1233
  };
763
1234
  function readBody(req) {
764
- return new Promise((resolve3, reject) => {
1235
+ return new Promise((resolve4, reject) => {
765
1236
  let data = "";
766
1237
  req.on("data", (chunk) => {
767
1238
  data += chunk;
768
1239
  if (data.length > 1e6) req.destroy();
769
1240
  });
770
- req.on("end", () => resolve3(data));
1241
+ req.on("end", () => resolve4(data));
771
1242
  req.on("error", reject);
772
1243
  });
773
1244
  }
@@ -784,10 +1255,9 @@ async function exchangeRefreshToken(cfg, refreshToken) {
784
1255
  async function browserHandoffLogin(cfg) {
785
1256
  const nonce = randomBytes(5).toString("hex").toUpperCase();
786
1257
  const TIMEOUT_MS = 5 * 6e4;
787
- const payload = await new Promise((resolve3, reject) => {
788
- let timer;
1258
+ const payload = await new Promise((resolve4, reject) => {
789
1259
  function finish(act) {
790
- if (timer) clearTimeout(timer);
1260
+ clearTimeout(timer);
791
1261
  server.close();
792
1262
  act();
793
1263
  }
@@ -817,10 +1287,10 @@ async function browserHandoffLogin(cfg) {
817
1287
  }
818
1288
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
819
1289
  res.end(JSON.stringify({ ok: true }));
820
- finish(() => resolve3(body));
1290
+ finish(() => resolve4(body));
821
1291
  }).catch((err) => finish(() => reject(err)));
822
1292
  });
823
- timer = setTimeout(
1293
+ const timer = setTimeout(
824
1294
  () => finish(() => reject(new Error('Timed out waiting for browser sign-in. Run "tokelytics login" again.'))),
825
1295
  TIMEOUT_MS
826
1296
  );
@@ -876,13 +1346,300 @@ async function restoreSession(cfg) {
876
1346
  }
877
1347
 
878
1348
  // src/runner.ts
879
- import * as os4 from "node:os";
1349
+ import * as os5 from "node:os";
880
1350
 
881
1351
  // src/connectors.ts
882
1352
  function buildConnectors() {
883
1353
  return [new ClaudeConnector(), new CodexConnector()];
884
1354
  }
885
1355
 
1356
+ // src/provider-limits.ts
1357
+ import { promises as fs5 } from "node:fs";
1358
+ import * as os4 from "node:os";
1359
+ import * as path7 from "node:path";
1360
+ var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
1361
+ var CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
1362
+ var REQUEST_TIMEOUT_MS = 1e4;
1363
+ var REFRESH_INTERVAL_MS = 6e4;
1364
+ function asNumber3(value) {
1365
+ const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
1366
+ return Number.isFinite(number) ? number : void 0;
1367
+ }
1368
+ function asIso(value) {
1369
+ if (typeof value === "string") {
1370
+ const date = new Date(value);
1371
+ return Number.isFinite(date.getTime()) ? date.toISOString() : void 0;
1372
+ }
1373
+ const seconds = asNumber3(value);
1374
+ if (seconds === void 0 || seconds <= 0) return void 0;
1375
+ return new Date(seconds * 1e3).toISOString();
1376
+ }
1377
+ function snapshot(provider, source, status, message, updatedAt, windows = [], planType) {
1378
+ return {
1379
+ provider,
1380
+ source,
1381
+ status,
1382
+ statusMessage: message,
1383
+ planType,
1384
+ updatedAt: updatedAt.toISOString(),
1385
+ windows
1386
+ };
1387
+ }
1388
+ function parseClaudeUsageResponse(value, updatedAt = /* @__PURE__ */ new Date(), planType) {
1389
+ const response = value ?? {};
1390
+ const windows = [];
1391
+ const add = (id, label, minutes, bucket) => {
1392
+ const usedPercent = asNumber3(bucket?.utilization);
1393
+ if (usedPercent === void 0) return;
1394
+ windows.push({
1395
+ id,
1396
+ label,
1397
+ usedPercent,
1398
+ windowMinutes: minutes,
1399
+ resetsAt: asIso(bucket?.resets_at)
1400
+ });
1401
+ };
1402
+ add("session", "5-hour", 300, response.five_hour);
1403
+ add("weekly", "Weekly", 10080, response.seven_day);
1404
+ return snapshot(
1405
+ "claude",
1406
+ "claude.oauth_usage",
1407
+ windows.length > 0 ? "available" : "unavailable",
1408
+ windows.length > 0 ? void 0 : "Claude did not return allowance windows. Retrying automatically.",
1409
+ updatedAt,
1410
+ windows,
1411
+ planType
1412
+ );
1413
+ }
1414
+ function parseCodexUsageResponse(value, updatedAt = /* @__PURE__ */ new Date()) {
1415
+ const response = value ?? {};
1416
+ const details = response.rate_limit;
1417
+ const windows = [];
1418
+ const add = (id, label, fallbackMinutes, window) => {
1419
+ const usedPercent = asNumber3(window?.used_percent);
1420
+ if (usedPercent === void 0) return;
1421
+ const windowSeconds = asNumber3(window?.limit_window_seconds);
1422
+ windows.push({
1423
+ id,
1424
+ label,
1425
+ usedPercent,
1426
+ windowMinutes: windowSeconds ? Math.round(windowSeconds / 60) : fallbackMinutes,
1427
+ resetsAt: asIso(window?.reset_at)
1428
+ });
1429
+ };
1430
+ add("primary", "5-hour", 300, details?.primary_window);
1431
+ add("secondary", "Weekly", 10080, details?.secondary_window);
1432
+ return snapshot(
1433
+ "codex",
1434
+ "codex.oauth_usage",
1435
+ windows.length > 0 ? "available" : "unavailable",
1436
+ windows.length > 0 ? void 0 : "Codex did not return allowance windows. Retrying automatically.",
1437
+ updatedAt,
1438
+ windows,
1439
+ typeof response.plan_type === "string" ? response.plan_type : void 0
1440
+ );
1441
+ }
1442
+ async function readJson2(file) {
1443
+ try {
1444
+ return JSON.parse(await fs5.readFile(file, "utf-8"));
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ }
1449
+ async function readClaudeCredentials(home, env) {
1450
+ const configDir2 = env.CLAUDE_CONFIG_DIR || path7.join(home, ".claude");
1451
+ const root = await readJson2(path7.join(configDir2, ".credentials.json"));
1452
+ const oauth = root?.["claudeAiOauth"];
1453
+ if (!oauth || typeof oauth !== "object") return null;
1454
+ const data = oauth;
1455
+ const accessToken = data["accessToken"];
1456
+ if (typeof accessToken !== "string" || !accessToken) return null;
1457
+ return {
1458
+ accessToken,
1459
+ expiresAt: asNumber3(data["expiresAt"]),
1460
+ planType: typeof data["subscriptionType"] === "string" ? data["subscriptionType"] : void 0
1461
+ };
1462
+ }
1463
+ async function readCodexCredentials(home, env) {
1464
+ const configDir2 = env.CODEX_HOME || path7.join(home, ".codex");
1465
+ const root = await readJson2(path7.join(configDir2, "auth.json"));
1466
+ const tokens = root?.["tokens"];
1467
+ if (!tokens || typeof tokens !== "object") return null;
1468
+ const data = tokens;
1469
+ const accessToken = data["access_token"];
1470
+ if (typeof accessToken !== "string" || !accessToken) return null;
1471
+ return {
1472
+ accessToken,
1473
+ accountId: typeof data["account_id"] === "string" ? data["account_id"] : void 0
1474
+ };
1475
+ }
1476
+ async function requestJson(fetcher, url, headers) {
1477
+ const response = await fetcher(url, {
1478
+ headers,
1479
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1480
+ });
1481
+ if (!response.ok) return { status: response.status };
1482
+ try {
1483
+ return { status: response.status, body: await response.json() };
1484
+ } catch {
1485
+ return { status: response.status };
1486
+ }
1487
+ }
1488
+ function authMessage(provider) {
1489
+ return `${provider} sign-in needs refresh. Open ${provider} Code once; Tokelytics will retry automatically.`;
1490
+ }
1491
+ var ClaudeLimitCollector = class {
1492
+ constructor(options) {
1493
+ this.options = options;
1494
+ }
1495
+ provider = "claude";
1496
+ cached = null;
1497
+ nextRefreshAt = 0;
1498
+ async collect() {
1499
+ const updatedAt = this.options.now();
1500
+ if (this.cached && updatedAt.getTime() < this.nextRefreshAt) return this.cached;
1501
+ this.nextRefreshAt = updatedAt.getTime() + REFRESH_INTERVAL_MS;
1502
+ const fresh = await this.collectFresh(updatedAt);
1503
+ if (fresh.status === "error" && this.cached?.status === "available") {
1504
+ return {
1505
+ ...this.cached,
1506
+ statusMessage: "Latest refresh was delayed; showing the last provider-reported values."
1507
+ };
1508
+ }
1509
+ this.cached = fresh;
1510
+ return fresh;
1511
+ }
1512
+ async collectFresh(updatedAt) {
1513
+ try {
1514
+ const credentials = await readClaudeCredentials(this.options.home, this.options.env);
1515
+ if (!credentials) {
1516
+ return snapshot(
1517
+ "claude",
1518
+ "claude.oauth_usage",
1519
+ "unavailable",
1520
+ "Claude Code is not signed in on this machine.",
1521
+ updatedAt
1522
+ );
1523
+ }
1524
+ const result = await requestJson(this.options.fetcher, CLAUDE_USAGE_URL, {
1525
+ Authorization: `Bearer ${credentials.accessToken}`,
1526
+ "anthropic-beta": "oauth-2025-04-20"
1527
+ });
1528
+ if (result.status === 401 || result.status === 403) {
1529
+ return snapshot("claude", "claude.oauth_usage", "error", authMessage("Claude"), updatedAt);
1530
+ }
1531
+ if (result.status === 429) {
1532
+ return snapshot(
1533
+ "claude",
1534
+ "claude.oauth_usage",
1535
+ "error",
1536
+ "Claude temporarily limited usage checks. Retrying automatically.",
1537
+ updatedAt
1538
+ );
1539
+ }
1540
+ if (!result.body) {
1541
+ return snapshot(
1542
+ "claude",
1543
+ "claude.oauth_usage",
1544
+ "error",
1545
+ "Claude usage service could not be reached. Retrying automatically.",
1546
+ updatedAt
1547
+ );
1548
+ }
1549
+ return parseClaudeUsageResponse(result.body, updatedAt, credentials.planType);
1550
+ } catch {
1551
+ return snapshot(
1552
+ "claude",
1553
+ "claude.oauth_usage",
1554
+ "error",
1555
+ "Claude usage service could not be reached. Retrying automatically.",
1556
+ updatedAt
1557
+ );
1558
+ }
1559
+ }
1560
+ };
1561
+ var CodexLimitCollector = class {
1562
+ constructor(options) {
1563
+ this.options = options;
1564
+ }
1565
+ provider = "codex";
1566
+ cached = null;
1567
+ nextRefreshAt = 0;
1568
+ async collect() {
1569
+ const updatedAt = this.options.now();
1570
+ if (this.cached && updatedAt.getTime() < this.nextRefreshAt) return this.cached;
1571
+ this.nextRefreshAt = updatedAt.getTime() + REFRESH_INTERVAL_MS;
1572
+ const fresh = await this.collectFresh(updatedAt);
1573
+ if (fresh.status === "error" && this.cached?.status === "available") {
1574
+ return {
1575
+ ...this.cached,
1576
+ statusMessage: "Latest refresh was delayed; showing the last provider-reported values."
1577
+ };
1578
+ }
1579
+ this.cached = fresh;
1580
+ return fresh;
1581
+ }
1582
+ async collectFresh(updatedAt) {
1583
+ try {
1584
+ const credentials = await readCodexCredentials(this.options.home, this.options.env);
1585
+ if (!credentials) {
1586
+ return snapshot(
1587
+ "codex",
1588
+ "codex.oauth_usage",
1589
+ "unavailable",
1590
+ "Codex is not signed in on this machine.",
1591
+ updatedAt
1592
+ );
1593
+ }
1594
+ const headers = {
1595
+ Authorization: `Bearer ${credentials.accessToken}`,
1596
+ "User-Agent": "codex-cli"
1597
+ };
1598
+ if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
1599
+ const result = await requestJson(this.options.fetcher, CODEX_USAGE_URL, headers);
1600
+ if (result.status === 401 || result.status === 403) {
1601
+ return snapshot("codex", "codex.oauth_usage", "error", authMessage("Codex"), updatedAt);
1602
+ }
1603
+ if (result.status === 429) {
1604
+ return snapshot(
1605
+ "codex",
1606
+ "codex.oauth_usage",
1607
+ "error",
1608
+ "Codex temporarily limited usage checks. Retrying automatically.",
1609
+ updatedAt
1610
+ );
1611
+ }
1612
+ if (!result.body) {
1613
+ return snapshot(
1614
+ "codex",
1615
+ "codex.oauth_usage",
1616
+ "error",
1617
+ "Codex usage service could not be reached. Retrying automatically.",
1618
+ updatedAt
1619
+ );
1620
+ }
1621
+ return parseCodexUsageResponse(result.body, updatedAt);
1622
+ } catch {
1623
+ return snapshot(
1624
+ "codex",
1625
+ "codex.oauth_usage",
1626
+ "error",
1627
+ "Codex usage service could not be reached. Retrying automatically.",
1628
+ updatedAt
1629
+ );
1630
+ }
1631
+ }
1632
+ };
1633
+ function buildNativeLimitCollectors(options = {}) {
1634
+ const resolved = {
1635
+ home: options.home ?? os4.homedir(),
1636
+ env: options.env ?? process.env,
1637
+ fetcher: options.fetcher ?? fetch,
1638
+ now: options.now ?? (() => /* @__PURE__ */ new Date())
1639
+ };
1640
+ return [new ClaudeLimitCollector(resolved), new CodexLimitCollector(resolved)];
1641
+ }
1642
+
886
1643
  // src/firestore-rest.ts
887
1644
  function encodeValue(v) {
888
1645
  if (v === null || v === void 0) return { nullValue: null };
@@ -918,6 +1675,14 @@ function decodeFields(fields) {
918
1675
  for (const [k, val] of Object.entries(fields)) out[k] = decodeValue(val);
919
1676
  return out;
920
1677
  }
1678
+ var FirestoreRestError = class extends Error {
1679
+ constructor(message, status, retryAfterMs) {
1680
+ super(message);
1681
+ this.status = status;
1682
+ this.retryAfterMs = retryAfterMs;
1683
+ this.name = "FirestoreRestError";
1684
+ }
1685
+ };
921
1686
  var FirestoreRest = class {
922
1687
  projectId;
923
1688
  getToken;
@@ -947,15 +1712,20 @@ var FirestoreRest = class {
947
1712
  const res = await fetch(this.url(`${this.docsRoot()}:commit`), {
948
1713
  method: "POST",
949
1714
  headers: await this.headers(),
950
- body: JSON.stringify({ writes })
1715
+ body: JSON.stringify({ writes }),
1716
+ signal: AbortSignal.timeout(15e3)
951
1717
  });
952
- if (!res.ok) throw new Error(`Firestore commit failed (${res.status}): ${await res.text()}`);
1718
+ if (!res.ok) throw await firestoreError("commit", res);
953
1719
  }
954
1720
  /** Fetch a single document's decoded fields, or null if it doesn't exist. */
955
1721
  async getDoc(name) {
956
- const res = await fetch(this.url(name), { method: "GET", headers: await this.headers() });
1722
+ const res = await fetch(this.url(name), {
1723
+ method: "GET",
1724
+ headers: await this.headers(),
1725
+ signal: AbortSignal.timeout(15e3)
1726
+ });
957
1727
  if (res.status === 404) return null;
958
- if (!res.ok) throw new Error(`Firestore get failed (${res.status}): ${await res.text()}`);
1728
+ if (!res.ok) throw await firestoreError("get", res);
959
1729
  const doc = await res.json();
960
1730
  return doc.fields ? decodeFields(doc.fields) : {};
961
1731
  }
@@ -973,127 +1743,190 @@ var FirestoreRest = class {
973
1743
  const res = await fetch(this.url(`${parent}:runQuery`), {
974
1744
  method: "POST",
975
1745
  headers: await this.headers(),
976
- body: JSON.stringify({ structuredQuery: { from: [{ collectionId }], where } })
1746
+ body: JSON.stringify({ structuredQuery: { from: [{ collectionId }], where } }),
1747
+ signal: AbortSignal.timeout(15e3)
977
1748
  });
978
- if (!res.ok) throw new Error(`Firestore query failed (${res.status}): ${await res.text()}`);
1749
+ if (!res.ok) throw await firestoreError("query", res);
979
1750
  const rows = await res.json();
980
1751
  return rows.filter((r) => r.document?.fields).map((r) => decodeFields(r.document.fields));
981
1752
  }
982
1753
  };
983
-
984
- // src/sink.ts
985
- function timelineDaysByProvider(buckets, nowMs) {
986
- const cutoffDay = new Date(nowMs - TIMELINE_RETENTION_MS).toISOString().slice(0, 10);
987
- const out = /* @__PURE__ */ new Map();
988
- for (const { provider, day } of buckets) {
989
- if (day < cutoffDay) continue;
990
- let set = out.get(provider);
991
- if (!set) {
992
- set = /* @__PURE__ */ new Set();
993
- out.set(provider, set);
994
- }
995
- set.add(day);
996
- }
997
- return out;
1754
+ async function firestoreError(operation, response) {
1755
+ const body = (await response.text()).slice(0, 800);
1756
+ const retryAfter = response.headers.get("retry-after");
1757
+ const seconds = retryAfter ? Number(retryAfter) : Number.NaN;
1758
+ return new FirestoreRestError(
1759
+ `Firestore ${operation} failed (${response.status}): ${body}`,
1760
+ response.status,
1761
+ Number.isFinite(seconds) ? seconds * 1e3 : void 0
1762
+ );
998
1763
  }
999
1764
 
1000
1765
  // src/firestore-sink.ts
1001
- var BATCH = 400;
1002
1766
  var FirestoreSink = class {
1003
- constructor(fs4, uid) {
1004
- this.fs = fs4;
1767
+ constructor(fs6, uid) {
1768
+ this.fs = fs6;
1005
1769
  this.uid = uid;
1006
1770
  }
1007
- async writeTurns(turns) {
1008
- for (let i = 0; i < turns.length; i += BATCH) {
1009
- const slice = turns.slice(i, i + BATCH);
1010
- await this.fs.upsert(
1011
- slice.map((t) => ({
1012
- name: this.fs.docName("users", this.uid, "turns", t.turnId),
1013
- fields: { ...t, day: dayOf(t.ts) }
1014
- }))
1015
- );
1016
- }
1017
- }
1018
- async turnsForSession(sessionId) {
1019
- const rows = await this.fs.queryEqual(["users", this.uid], "turns", [
1020
- { field: "sessionId", op: "EQUAL", value: sessionId }
1021
- ]);
1022
- return rows;
1023
- }
1024
- async turnsForBucket(provider, day) {
1025
- const rows = await this.fs.queryEqual(["users", this.uid], "turns", [
1026
- { field: "provider", op: "EQUAL", value: provider },
1027
- { field: "day", op: "EQUAL", value: day }
1028
- ]);
1029
- return rows;
1030
- }
1031
- async recomputeSessions(sessionIds) {
1032
- for (const sid of sessionIds) {
1033
- const turns = await this.turnsForSession(sid);
1034
- if (!turns.length) continue;
1035
- const agg = aggregateSession(sid, turns);
1036
- await this.fs.upsert([{ name: this.fs.docName("users", this.uid, "sessions", sid), fields: { ...agg } }]);
1037
- }
1038
- }
1039
- async recomputeRollups(buckets) {
1040
- for (const { provider, day } of buckets) {
1041
- const turns = await this.turnsForBucket(provider, day);
1042
- if (!turns.length) continue;
1043
- const rollup = buildRollup(provider, day, turns);
1044
- await this.fs.upsert([
1045
- { name: this.fs.docName("users", this.uid, "rollups", rollupId(provider, day)), fields: { ...rollup } }
1046
- ]);
1047
- }
1048
- }
1049
- async recomputeTimelines(buckets) {
1050
- const now = Date.now();
1051
- for (const [provider, days] of timelineDaysByProvider(buckets, now)) {
1052
- const recomputed = [];
1053
- for (const day of days) {
1054
- recomputed.push(...buildTimelineBuckets(await this.turnsForBucket(provider, day)));
1055
- }
1056
- const name = this.fs.docName("users", this.uid, "timelines", provider);
1057
- const existingDoc = await this.fs.getDoc(name);
1058
- const existing = existingDoc?.["buckets"] ?? [];
1059
- const merged = mergeTimeline(existing, recomputed, days, now);
1060
- await this.fs.upsert([
1061
- { name, fields: { provider, buckets: merged, updatedAt: new Date(now).toISOString() } }
1062
- ]);
1063
- }
1064
- }
1065
- async touchDevice(meta) {
1771
+ async writeDashboardSnapshot(snapshot2) {
1772
+ if (!snapshot2.device?.deviceId) throw new Error("Dashboard snapshot is missing its device id.");
1773
+ const cloudSnapshot = prepareDashboardSnapshotForCloud(snapshot2);
1066
1774
  await this.fs.upsert([
1067
- { name: this.fs.docName("users", this.uid, "devices", meta.deviceId), fields: { ...meta } }
1775
+ {
1776
+ name: this.fs.docName("users", this.uid, "machines", snapshot2.device.deviceId),
1777
+ fields: { ...cloudSnapshot }
1778
+ }
1068
1779
  ]);
1069
1780
  }
1070
1781
  };
1071
1782
 
1783
+ // src/version.ts
1784
+ var AGENT_VERSION = "0.3.1";
1785
+
1072
1786
  // src/sync.ts
1073
- async function runSync(connectors, sink, state, device) {
1787
+ async function runSync(connectors, sink, state, device, limitCollectors = [], options = {}) {
1788
+ const now = options.now ?? /* @__PURE__ */ new Date();
1789
+ const refreshLimits = options.refreshLimits ?? true;
1790
+ const publication = {
1791
+ ...options.publication,
1792
+ limitFingerprints: { ...options.publication?.limitFingerprints }
1793
+ };
1794
+ const cloudSyncIntervalMs = options.cloudSyncIntervalMs ?? 0;
1795
+ const maxCloudWritesPerDay = options.maxCloudWritesPerDay ?? 16;
1074
1796
  let st = state;
1075
1797
  const collected = [];
1798
+ const limits = [];
1076
1799
  const byProvider = {};
1077
1800
  for (const c of connectors) {
1078
- const { turns: turns2, state: next } = await c.collect(st);
1801
+ const { turns: turns2, limits: nativeLimits2 = [], state: next } = await c.collect(st);
1079
1802
  st = next;
1803
+ if (refreshLimits) limits.push(...nativeLimits2);
1080
1804
  for (const t of turns2) {
1081
1805
  collected.push(t);
1082
1806
  byProvider[t.provider] = (byProvider[t.provider] ?? 0) + 1;
1083
1807
  }
1084
1808
  }
1809
+ if (refreshLimits) {
1810
+ const providerLimits = await Promise.all(
1811
+ limitCollectors.map(async (collector) => {
1812
+ try {
1813
+ return await collector.collect();
1814
+ } catch {
1815
+ return null;
1816
+ }
1817
+ })
1818
+ );
1819
+ limits.push(...providerLimits.filter((limit) => Boolean(limit)));
1820
+ }
1085
1821
  const unique = /* @__PURE__ */ new Map();
1086
1822
  for (const t of collected) unique.set(t.turnId, t);
1087
1823
  const turns = [...unique.values()];
1088
- if (turns.length > 0) {
1089
- await sink.writeTurns(turns);
1090
- const { sessionIds, dayBuckets } = affectedKeys(turns);
1091
- await sink.recomputeSessions(sessionIds);
1092
- await sink.recomputeRollups(dayBuckets);
1093
- await sink.recomputeTimelines(dayBuckets);
1824
+ const nativeLimits = refreshLimits ? dedupeLimits(limits) : [];
1825
+ const changedLimits = nativeLimits.filter(
1826
+ (limit) => publication.limitFingerprints?.[limit.provider] !== limitFingerprint(limit)
1827
+ );
1828
+ if (changedLimits.length > 0) {
1829
+ publication.limitFingerprints ??= {};
1830
+ for (const limit of changedLimits) {
1831
+ publication.limitFingerprints[limit.provider] = limitFingerprint(limit);
1832
+ }
1833
+ }
1834
+ if (refreshLimits) publication.lastLimitCheckAt = now.toISOString();
1835
+ const heartbeatMs = options.deviceHeartbeatMs ?? 0;
1836
+ const previousHeartbeat = Date.parse(publication.lastDeviceAt ?? "");
1837
+ const heartbeatDue = heartbeatMs === 0 || !Number.isFinite(previousHeartbeat) || now.getTime() - previousHeartbeat >= heartbeatMs;
1838
+ const updateDevice = turns.length > 0 || changedLimits.length > 0 || !options.dashboard?.device || options.forceDeviceHeartbeat || heartbeatDue;
1839
+ const dashboard = mergeDashboardSnapshot(
1840
+ options.dashboard,
1841
+ turns,
1842
+ changedLimits,
1843
+ updateDevice ? { ...device, lastSyncAt: now.toISOString() } : void 0,
1844
+ now
1845
+ );
1846
+ if (updateDevice) {
1847
+ publication.lastDeviceAt = now.toISOString();
1848
+ }
1849
+ const changed = turns.length > 0 || changedLimits.length > 0 || updateDevice;
1850
+ if (changed) publication.lastLocalEventAt = now.toISOString();
1851
+ publication.dashboardDirty = Boolean(publication.dashboardDirty || changed);
1852
+ resetDailyBudget(publication, now);
1853
+ const lastCloudWrite = Date.parse(publication.lastCloudWriteAt ?? "");
1854
+ const cloudWriteDue = options.forceCloudPublish || !Number.isFinite(lastCloudWrite) || now.getTime() - lastCloudWrite >= cloudSyncIntervalMs;
1855
+ const hasBudget = (publication.cloudWritesToday ?? 0) < maxCloudWritesPerDay;
1856
+ let published = false;
1857
+ let publicationError;
1858
+ dashboard.sync = {
1859
+ agentVersion: AGENT_VERSION,
1860
+ cloudWritesToday: publication.cloudWritesToday ?? 0,
1861
+ maxCloudWritesPerDay,
1862
+ localChangesPending: Boolean(publication.dashboardDirty),
1863
+ lastLocalEventAt: publication.lastLocalEventAt,
1864
+ lastCloudWriteAt: publication.lastCloudWriteAt
1865
+ };
1866
+ if (publication.dashboardDirty && cloudWriteDue && hasBudget) {
1867
+ try {
1868
+ dashboard.sync = {
1869
+ ...dashboard.sync,
1870
+ cloudWritesToday: (publication.cloudWritesToday ?? 0) + 1,
1871
+ localChangesPending: false,
1872
+ lastCloudWriteAt: now.toISOString()
1873
+ };
1874
+ await sink.writeDashboardSnapshot(dashboard);
1875
+ publication.cloudWritesToday = (publication.cloudWritesToday ?? 0) + 1;
1876
+ publication.lastCloudWriteAt = now.toISOString();
1877
+ publication.dashboardDirty = false;
1878
+ published = true;
1879
+ } catch (error) {
1880
+ dashboard.sync.localChangesPending = true;
1881
+ publicationError = error;
1882
+ }
1094
1883
  }
1095
- await sink.touchDevice({ ...device, lastSyncAt: (/* @__PURE__ */ new Date()).toISOString() });
1096
- return { newTurns: turns.length, byProvider, state: st };
1884
+ return {
1885
+ processedTurns: turns.length,
1886
+ processedLimits: changedLimits.length,
1887
+ byProvider,
1888
+ state: st,
1889
+ dashboard,
1890
+ publication,
1891
+ published,
1892
+ publicationError
1893
+ };
1894
+ }
1895
+ function dedupeLimits(limits) {
1896
+ const byProvider = /* @__PURE__ */ new Map();
1897
+ for (const limit of limits) {
1898
+ const current = byProvider.get(limit.provider);
1899
+ if (!current || limitScore(limit) >= limitScore(current)) byProvider.set(limit.provider, limit);
1900
+ }
1901
+ return [...byProvider.values()];
1902
+ }
1903
+ function limitScore(limit) {
1904
+ const available = (limit.status ?? (limit.windows.length > 0 ? "available" : "unavailable")) === "available";
1905
+ const oauth = limit.source.endsWith("oauth_usage");
1906
+ return (available ? 100 : 0) + (oauth ? 10 : 0) + limit.windows.length;
1907
+ }
1908
+ function limitFingerprint(limit) {
1909
+ return JSON.stringify({
1910
+ provider: limit.provider,
1911
+ source: limit.source,
1912
+ status: limit.status,
1913
+ statusMessage: limit.statusMessage,
1914
+ planType: limit.planType,
1915
+ windows: limit.windows
1916
+ });
1917
+ }
1918
+ function resetDailyBudget(publication, now) {
1919
+ const parts = new Intl.DateTimeFormat("en-US", {
1920
+ timeZone: "America/Los_Angeles",
1921
+ year: "numeric",
1922
+ month: "2-digit",
1923
+ day: "2-digit"
1924
+ }).formatToParts(now);
1925
+ const value = (type) => parts.find((part) => part.type === type)?.value ?? "";
1926
+ const day = `${value("year")}-${value("month")}-${value("day")}`;
1927
+ if (publication.cloudWriteDay === day) return;
1928
+ publication.cloudWriteDay = day;
1929
+ publication.cloudWritesToday = 0;
1097
1930
  }
1098
1931
 
1099
1932
  // src/runner.ts
@@ -1102,6 +1935,7 @@ async function createRunner() {
1102
1935
  const session = await restoreSession(cfg);
1103
1936
  if (!session) throw new Error('Not signed in. Run "tokelytics login" first.');
1104
1937
  const connectors = buildConnectors();
1938
+ const limitCollectors = buildNativeLimitCollectors();
1105
1939
  const rest = new FirestoreRest({
1106
1940
  projectId: cfg.firebase.projectId,
1107
1941
  getToken: () => session.getToken(),
@@ -1113,34 +1947,55 @@ async function createRunner() {
1113
1947
  watchPaths() {
1114
1948
  return connectors.flatMap((c) => c.watchPaths());
1115
1949
  },
1116
- async syncOnce() {
1950
+ async syncOnce(options = {}) {
1117
1951
  const state = await loadState();
1118
1952
  const device = {
1119
1953
  deviceId: state.deviceId,
1120
- name: os4.hostname(),
1954
+ name: os5.hostname(),
1121
1955
  lastSyncAt: "",
1122
1956
  providers: connectors.map((c) => c.id)
1123
1957
  };
1124
- const res = await runSync(connectors, sink, state.scan, device);
1125
- await saveState({ deviceId: state.deviceId, scan: res.state });
1126
- return { newTurns: res.newTurns, byProvider: res.byProvider };
1958
+ const scan = state.dashboard ? state.scan : emptyScanState();
1959
+ const res = await runSync(connectors, sink, scan, device, limitCollectors, {
1960
+ ...options,
1961
+ publication: state.publication,
1962
+ dashboard: state.dashboard
1963
+ });
1964
+ const nextState = {
1965
+ deviceId: state.deviceId,
1966
+ scan: res.state,
1967
+ dashboard: res.dashboard,
1968
+ publication: res.publication
1969
+ };
1970
+ if (!sameState(state, nextState)) await saveState(nextState);
1971
+ if (res.publicationError) throw res.publicationError;
1972
+ return {
1973
+ processedTurns: res.processedTurns,
1974
+ processedLimits: res.processedLimits,
1975
+ byProvider: res.byProvider,
1976
+ published: res.published,
1977
+ cloudWritesToday: res.publication.cloudWritesToday ?? 0
1978
+ };
1127
1979
  }
1128
1980
  };
1129
1981
  }
1982
+ function sameState(left, right) {
1983
+ return JSON.stringify(left) === JSON.stringify(right);
1984
+ }
1130
1985
  async function registerDevice(cfg, session) {
1131
- const rest = new FirestoreRest({
1132
- projectId: cfg.firebase.projectId,
1133
- getToken: () => session.getToken(),
1134
- emulatorHost: cfg.firestoreEmulatorHost
1135
- });
1136
- const sink = new FirestoreSink(rest, session.uid);
1137
1986
  const state = await loadState();
1138
1987
  const connectors = buildConnectors();
1139
- await sink.touchDevice({
1988
+ const now = /* @__PURE__ */ new Date();
1989
+ const device = {
1140
1990
  deviceId: state.deviceId,
1141
- name: os4.hostname(),
1142
- lastSyncAt: "",
1991
+ name: os5.hostname(),
1992
+ lastSyncAt: now.toISOString(),
1143
1993
  providers: connectors.map((c) => c.id)
1994
+ };
1995
+ await saveState({
1996
+ ...state,
1997
+ dashboard: mergeDashboardSnapshot(state.dashboard, [], [], device, now),
1998
+ publication: { ...state.publication, dashboardDirty: true, lastDeviceAt: now.toISOString() }
1144
1999
  });
1145
2000
  }
1146
2001
 
@@ -1220,7 +2075,7 @@ var ReaddirpStream = class extends Readable {
1220
2075
  this._directoryFilter = normalizeFilter(opts.directoryFilter);
1221
2076
  const statMethod = opts.lstat ? lstat : stat;
1222
2077
  if (wantBigintFsStats) {
1223
- this._stat = (path6) => statMethod(path6, { bigint: true });
2078
+ this._stat = (path8) => statMethod(path8, { bigint: true });
1224
2079
  } else {
1225
2080
  this._stat = statMethod;
1226
2081
  }
@@ -1245,8 +2100,8 @@ var ReaddirpStream = class extends Readable {
1245
2100
  const par = this.parent;
1246
2101
  const fil = par && par.files;
1247
2102
  if (fil && fil.length > 0) {
1248
- const { path: path6, depth } = par;
1249
- const slice = fil.splice(0, batch).map((dirent) => this._formatEntry(dirent, path6));
2103
+ const { path: path8, depth } = par;
2104
+ const slice = fil.splice(0, batch).map((dirent) => this._formatEntry(dirent, path8));
1250
2105
  const awaited = await Promise.all(slice);
1251
2106
  for (const entry of awaited) {
1252
2107
  if (!entry)
@@ -1286,21 +2141,21 @@ var ReaddirpStream = class extends Readable {
1286
2141
  this.reading = false;
1287
2142
  }
1288
2143
  }
1289
- async _exploreDir(path6, depth) {
2144
+ async _exploreDir(path8, depth) {
1290
2145
  let files;
1291
2146
  try {
1292
- files = await readdir(path6, this._rdOptions);
2147
+ files = await readdir(path8, this._rdOptions);
1293
2148
  } catch (error) {
1294
2149
  this._onError(error);
1295
2150
  }
1296
- return { files, depth, path: path6 };
2151
+ return { files, depth, path: path8 };
1297
2152
  }
1298
- async _formatEntry(dirent, path6) {
2153
+ async _formatEntry(dirent, path8) {
1299
2154
  let entry;
1300
- const basename4 = this._isDirent ? dirent.name : dirent;
2155
+ const basename5 = this._isDirent ? dirent.name : dirent;
1301
2156
  try {
1302
- const fullPath = presolve(pjoin(path6, basename4));
1303
- entry = { path: prelative(this._root, fullPath), fullPath, basename: basename4 };
2157
+ const fullPath = presolve(pjoin(path8, basename5));
2158
+ entry = { path: prelative(this._root, fullPath), fullPath, basename: basename5 };
1304
2159
  entry[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
1305
2160
  } catch (err) {
1306
2161
  this._onError(err);
@@ -1699,16 +2554,16 @@ var delFromSet = (main2, prop, item) => {
1699
2554
  };
1700
2555
  var isEmptySet = (val) => val instanceof Set ? val.size === 0 : !val;
1701
2556
  var FsWatchInstances = /* @__PURE__ */ new Map();
1702
- function createFsWatchInstance(path6, options, listener, errHandler, emitRaw) {
2557
+ function createFsWatchInstance(path8, options, listener, errHandler, emitRaw) {
1703
2558
  const handleEvent = (rawEvent, evPath) => {
1704
- listener(path6);
1705
- emitRaw(rawEvent, evPath, { watchedPath: path6 });
1706
- if (evPath && path6 !== evPath) {
1707
- fsWatchBroadcast(sysPath.resolve(path6, evPath), KEY_LISTENERS, sysPath.join(path6, evPath));
2559
+ listener(path8);
2560
+ emitRaw(rawEvent, evPath, { watchedPath: path8 });
2561
+ if (evPath && path8 !== evPath) {
2562
+ fsWatchBroadcast(sysPath.resolve(path8, evPath), KEY_LISTENERS, sysPath.join(path8, evPath));
1708
2563
  }
1709
2564
  };
1710
2565
  try {
1711
- return fs_watch(path6, {
2566
+ return fs_watch(path8, {
1712
2567
  persistent: options.persistent
1713
2568
  }, handleEvent);
1714
2569
  } catch (error) {
@@ -1724,12 +2579,12 @@ var fsWatchBroadcast = (fullPath, listenerType, val1, val2, val3) => {
1724
2579
  listener(val1, val2, val3);
1725
2580
  });
1726
2581
  };
1727
- var setFsWatchListener = (path6, fullPath, options, handlers) => {
2582
+ var setFsWatchListener = (path8, fullPath, options, handlers) => {
1728
2583
  const { listener, errHandler, rawEmitter } = handlers;
1729
2584
  let cont = FsWatchInstances.get(fullPath);
1730
2585
  let watcher;
1731
2586
  if (!options.persistent) {
1732
- watcher = createFsWatchInstance(path6, options, listener, errHandler, rawEmitter);
2587
+ watcher = createFsWatchInstance(path8, options, listener, errHandler, rawEmitter);
1733
2588
  if (!watcher)
1734
2589
  return;
1735
2590
  return watcher.close.bind(watcher);
@@ -1740,7 +2595,7 @@ var setFsWatchListener = (path6, fullPath, options, handlers) => {
1740
2595
  addAndConvert(cont, KEY_RAW, rawEmitter);
1741
2596
  } else {
1742
2597
  watcher = createFsWatchInstance(
1743
- path6,
2598
+ path8,
1744
2599
  options,
1745
2600
  fsWatchBroadcast.bind(null, fullPath, KEY_LISTENERS),
1746
2601
  errHandler,
@@ -1755,7 +2610,7 @@ var setFsWatchListener = (path6, fullPath, options, handlers) => {
1755
2610
  cont.watcherUnusable = true;
1756
2611
  if (isWindows && error.code === "EPERM") {
1757
2612
  try {
1758
- const fd = await open(path6, "r");
2613
+ const fd = await open(path8, "r");
1759
2614
  await fd.close();
1760
2615
  broadcastErr(error);
1761
2616
  } catch (err) {
@@ -1786,7 +2641,7 @@ var setFsWatchListener = (path6, fullPath, options, handlers) => {
1786
2641
  };
1787
2642
  };
1788
2643
  var FsWatchFileInstances = /* @__PURE__ */ new Map();
1789
- var setFsWatchFileListener = (path6, fullPath, options, handlers) => {
2644
+ var setFsWatchFileListener = (path8, fullPath, options, handlers) => {
1790
2645
  const { listener, rawEmitter } = handlers;
1791
2646
  let cont = FsWatchFileInstances.get(fullPath);
1792
2647
  const copts = cont && cont.options;
@@ -1808,7 +2663,7 @@ var setFsWatchFileListener = (path6, fullPath, options, handlers) => {
1808
2663
  });
1809
2664
  const currmtime = curr.mtimeMs;
1810
2665
  if (curr.size !== prev.size || currmtime > prev.mtimeMs || currmtime === 0) {
1811
- foreach(cont.listeners, (listener2) => listener2(path6, curr));
2666
+ foreach(cont.listeners, (listener2) => listener2(path8, curr));
1812
2667
  }
1813
2668
  })
1814
2669
  };
@@ -1836,13 +2691,13 @@ var NodeFsHandler = class {
1836
2691
  * @param listener on fs change
1837
2692
  * @returns closer for the watcher instance
1838
2693
  */
1839
- _watchWithNodeFs(path6, listener) {
2694
+ _watchWithNodeFs(path8, listener) {
1840
2695
  const opts = this.fsw.options;
1841
- const directory = sysPath.dirname(path6);
1842
- const basename4 = sysPath.basename(path6);
2696
+ const directory = sysPath.dirname(path8);
2697
+ const basename5 = sysPath.basename(path8);
1843
2698
  const parent = this.fsw._getWatchedDir(directory);
1844
- parent.add(basename4);
1845
- const absolutePath = sysPath.resolve(path6);
2699
+ parent.add(basename5);
2700
+ const absolutePath = sysPath.resolve(path8);
1846
2701
  const options = {
1847
2702
  persistent: opts.persistent
1848
2703
  };
@@ -1851,13 +2706,13 @@ var NodeFsHandler = class {
1851
2706
  let closer;
1852
2707
  if (opts.usePolling) {
1853
2708
  const enableBin = opts.interval !== opts.binaryInterval;
1854
- options.interval = enableBin && isBinaryPath(basename4) ? opts.binaryInterval : opts.interval;
1855
- closer = setFsWatchFileListener(path6, absolutePath, options, {
2709
+ options.interval = enableBin && isBinaryPath(basename5) ? opts.binaryInterval : opts.interval;
2710
+ closer = setFsWatchFileListener(path8, absolutePath, options, {
1856
2711
  listener,
1857
2712
  rawEmitter: this.fsw._emitRaw
1858
2713
  });
1859
2714
  } else {
1860
- closer = setFsWatchListener(path6, absolutePath, options, {
2715
+ closer = setFsWatchListener(path8, absolutePath, options, {
1861
2716
  listener,
1862
2717
  errHandler: this._boundHandleError,
1863
2718
  rawEmitter: this.fsw._emitRaw
@@ -1874,12 +2729,12 @@ var NodeFsHandler = class {
1874
2729
  return;
1875
2730
  }
1876
2731
  const dirname3 = sysPath.dirname(file);
1877
- const basename4 = sysPath.basename(file);
2732
+ const basename5 = sysPath.basename(file);
1878
2733
  const parent = this.fsw._getWatchedDir(dirname3);
1879
2734
  let prevStats = stats;
1880
- if (parent.has(basename4))
2735
+ if (parent.has(basename5))
1881
2736
  return;
1882
- const listener = async (path6, newStats) => {
2737
+ const listener = async (path8, newStats) => {
1883
2738
  if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file, 5))
1884
2739
  return;
1885
2740
  if (!newStats || newStats.mtimeMs === 0) {
@@ -1893,18 +2748,18 @@ var NodeFsHandler = class {
1893
2748
  this.fsw._emit(EV.CHANGE, file, newStats2);
1894
2749
  }
1895
2750
  if ((isMacos || isLinux || isFreeBSD) && prevStats.ino !== newStats2.ino) {
1896
- this.fsw._closeFile(path6);
2751
+ this.fsw._closeFile(path8);
1897
2752
  prevStats = newStats2;
1898
2753
  const closer2 = this._watchWithNodeFs(file, listener);
1899
2754
  if (closer2)
1900
- this.fsw._addPathCloser(path6, closer2);
2755
+ this.fsw._addPathCloser(path8, closer2);
1901
2756
  } else {
1902
2757
  prevStats = newStats2;
1903
2758
  }
1904
2759
  } catch (error) {
1905
- this.fsw._remove(dirname3, basename4);
2760
+ this.fsw._remove(dirname3, basename5);
1906
2761
  }
1907
- } else if (parent.has(basename4)) {
2762
+ } else if (parent.has(basename5)) {
1908
2763
  const at = newStats.atimeMs;
1909
2764
  const mt = newStats.mtimeMs;
1910
2765
  if (!at || at <= mt || mt !== prevStats.mtimeMs) {
@@ -1929,7 +2784,7 @@ var NodeFsHandler = class {
1929
2784
  * @param item basename of this item
1930
2785
  * @returns true if no more processing is needed for this entry.
1931
2786
  */
1932
- async _handleSymlink(entry, directory, path6, item) {
2787
+ async _handleSymlink(entry, directory, path8, item) {
1933
2788
  if (this.fsw.closed) {
1934
2789
  return;
1935
2790
  }
@@ -1939,7 +2794,7 @@ var NodeFsHandler = class {
1939
2794
  this.fsw._incrReadyCount();
1940
2795
  let linkPath;
1941
2796
  try {
1942
- linkPath = await fsrealpath(path6);
2797
+ linkPath = await fsrealpath(path8);
1943
2798
  } catch (e) {
1944
2799
  this.fsw._emitReady();
1945
2800
  return true;
@@ -1949,12 +2804,12 @@ var NodeFsHandler = class {
1949
2804
  if (dir.has(item)) {
1950
2805
  if (this.fsw._symlinkPaths.get(full) !== linkPath) {
1951
2806
  this.fsw._symlinkPaths.set(full, linkPath);
1952
- this.fsw._emit(EV.CHANGE, path6, entry.stats);
2807
+ this.fsw._emit(EV.CHANGE, path8, entry.stats);
1953
2808
  }
1954
2809
  } else {
1955
2810
  dir.add(item);
1956
2811
  this.fsw._symlinkPaths.set(full, linkPath);
1957
- this.fsw._emit(EV.ADD, path6, entry.stats);
2812
+ this.fsw._emit(EV.ADD, path8, entry.stats);
1958
2813
  }
1959
2814
  this.fsw._emitReady();
1960
2815
  return true;
@@ -1983,9 +2838,9 @@ var NodeFsHandler = class {
1983
2838
  return;
1984
2839
  }
1985
2840
  const item = entry.path;
1986
- let path6 = sysPath.join(directory, item);
2841
+ let path8 = sysPath.join(directory, item);
1987
2842
  current.add(item);
1988
- if (entry.stats.isSymbolicLink() && await this._handleSymlink(entry, directory, path6, item)) {
2843
+ if (entry.stats.isSymbolicLink() && await this._handleSymlink(entry, directory, path8, item)) {
1989
2844
  return;
1990
2845
  }
1991
2846
  if (this.fsw.closed) {
@@ -1994,11 +2849,11 @@ var NodeFsHandler = class {
1994
2849
  }
1995
2850
  if (item === target || !target && !previous.has(item)) {
1996
2851
  this.fsw._incrReadyCount();
1997
- path6 = sysPath.join(dir, sysPath.relative(dir, path6));
1998
- this._addToNodeFs(path6, initialAdd, wh, depth + 1);
2852
+ path8 = sysPath.join(dir, sysPath.relative(dir, path8));
2853
+ this._addToNodeFs(path8, initialAdd, wh, depth + 1);
1999
2854
  }
2000
2855
  }).on(EV.ERROR, this._boundHandleError);
2001
- return new Promise((resolve3, reject) => {
2856
+ return new Promise((resolve4, reject) => {
2002
2857
  if (!stream)
2003
2858
  return reject();
2004
2859
  stream.once(STR_END, () => {
@@ -2007,7 +2862,7 @@ var NodeFsHandler = class {
2007
2862
  return;
2008
2863
  }
2009
2864
  const wasThrottled = throttler ? throttler.clear() : false;
2010
- resolve3(void 0);
2865
+ resolve4(void 0);
2011
2866
  previous.getChildren().filter((item) => {
2012
2867
  return item !== directory && !current.has(item);
2013
2868
  }).forEach((item) => {
@@ -2064,13 +2919,13 @@ var NodeFsHandler = class {
2064
2919
  * @param depth Child path actually targeted for watch
2065
2920
  * @param target Child path actually targeted for watch
2066
2921
  */
2067
- async _addToNodeFs(path6, initialAdd, priorWh, depth, target) {
2922
+ async _addToNodeFs(path8, initialAdd, priorWh, depth, target) {
2068
2923
  const ready = this.fsw._emitReady;
2069
- if (this.fsw._isIgnored(path6) || this.fsw.closed) {
2924
+ if (this.fsw._isIgnored(path8) || this.fsw.closed) {
2070
2925
  ready();
2071
2926
  return false;
2072
2927
  }
2073
- const wh = this.fsw._getWatchHelpers(path6);
2928
+ const wh = this.fsw._getWatchHelpers(path8);
2074
2929
  if (priorWh) {
2075
2930
  wh.filterPath = (entry) => priorWh.filterPath(entry);
2076
2931
  wh.filterDir = (entry) => priorWh.filterDir(entry);
@@ -2086,8 +2941,8 @@ var NodeFsHandler = class {
2086
2941
  const follow = this.fsw.options.followSymlinks;
2087
2942
  let closer;
2088
2943
  if (stats.isDirectory()) {
2089
- const absPath = sysPath.resolve(path6);
2090
- const targetPath = follow ? await fsrealpath(path6) : path6;
2944
+ const absPath = sysPath.resolve(path8);
2945
+ const targetPath = follow ? await fsrealpath(path8) : path8;
2091
2946
  if (this.fsw.closed)
2092
2947
  return;
2093
2948
  closer = await this._handleDir(wh.watchPath, stats, initialAdd, depth, target, wh, targetPath);
@@ -2097,29 +2952,29 @@ var NodeFsHandler = class {
2097
2952
  this.fsw._symlinkPaths.set(absPath, targetPath);
2098
2953
  }
2099
2954
  } else if (stats.isSymbolicLink()) {
2100
- const targetPath = follow ? await fsrealpath(path6) : path6;
2955
+ const targetPath = follow ? await fsrealpath(path8) : path8;
2101
2956
  if (this.fsw.closed)
2102
2957
  return;
2103
2958
  const parent = sysPath.dirname(wh.watchPath);
2104
2959
  this.fsw._getWatchedDir(parent).add(wh.watchPath);
2105
2960
  this.fsw._emit(EV.ADD, wh.watchPath, stats);
2106
- closer = await this._handleDir(parent, stats, initialAdd, depth, path6, wh, targetPath);
2961
+ closer = await this._handleDir(parent, stats, initialAdd, depth, path8, wh, targetPath);
2107
2962
  if (this.fsw.closed)
2108
2963
  return;
2109
2964
  if (targetPath !== void 0) {
2110
- this.fsw._symlinkPaths.set(sysPath.resolve(path6), targetPath);
2965
+ this.fsw._symlinkPaths.set(sysPath.resolve(path8), targetPath);
2111
2966
  }
2112
2967
  } else {
2113
2968
  closer = this._handleFile(wh.watchPath, stats, initialAdd);
2114
2969
  }
2115
2970
  ready();
2116
2971
  if (closer)
2117
- this.fsw._addPathCloser(path6, closer);
2972
+ this.fsw._addPathCloser(path8, closer);
2118
2973
  return false;
2119
2974
  } catch (error) {
2120
2975
  if (this.fsw._handleError(error)) {
2121
2976
  ready();
2122
- return path6;
2977
+ return path8;
2123
2978
  }
2124
2979
  }
2125
2980
  }
@@ -2162,26 +3017,26 @@ function createPattern(matcher) {
2162
3017
  }
2163
3018
  return () => false;
2164
3019
  }
2165
- function normalizePath(path6) {
2166
- if (typeof path6 !== "string")
3020
+ function normalizePath(path8) {
3021
+ if (typeof path8 !== "string")
2167
3022
  throw new Error("string expected");
2168
- path6 = sysPath2.normalize(path6);
2169
- path6 = path6.replace(/\\/g, "/");
3023
+ path8 = sysPath2.normalize(path8);
3024
+ path8 = path8.replace(/\\/g, "/");
2170
3025
  let prepend = false;
2171
- if (path6.startsWith("//"))
3026
+ if (path8.startsWith("//"))
2172
3027
  prepend = true;
2173
3028
  const DOUBLE_SLASH_RE2 = /\/\//;
2174
- while (path6.match(DOUBLE_SLASH_RE2))
2175
- path6 = path6.replace(DOUBLE_SLASH_RE2, "/");
3029
+ while (path8.match(DOUBLE_SLASH_RE2))
3030
+ path8 = path8.replace(DOUBLE_SLASH_RE2, "/");
2176
3031
  if (prepend)
2177
- path6 = "/" + path6;
2178
- return path6;
3032
+ path8 = "/" + path8;
3033
+ return path8;
2179
3034
  }
2180
3035
  function matchPatterns(patterns, testString, stats) {
2181
- const path6 = normalizePath(testString);
3036
+ const path8 = normalizePath(testString);
2182
3037
  for (let index = 0; index < patterns.length; index++) {
2183
3038
  const pattern = patterns[index];
2184
- if (pattern(path6, stats)) {
3039
+ if (pattern(path8, stats)) {
2185
3040
  return true;
2186
3041
  }
2187
3042
  }
@@ -2221,19 +3076,19 @@ var toUnix = (string) => {
2221
3076
  }
2222
3077
  return str;
2223
3078
  };
2224
- var normalizePathToUnix = (path6) => toUnix(sysPath2.normalize(toUnix(path6)));
2225
- var normalizeIgnored = (cwd = "") => (path6) => {
2226
- if (typeof path6 === "string") {
2227
- return normalizePathToUnix(sysPath2.isAbsolute(path6) ? path6 : sysPath2.join(cwd, path6));
3079
+ var normalizePathToUnix = (path8) => toUnix(sysPath2.normalize(toUnix(path8)));
3080
+ var normalizeIgnored = (cwd = "") => (path8) => {
3081
+ if (typeof path8 === "string") {
3082
+ return normalizePathToUnix(sysPath2.isAbsolute(path8) ? path8 : sysPath2.join(cwd, path8));
2228
3083
  } else {
2229
- return path6;
3084
+ return path8;
2230
3085
  }
2231
3086
  };
2232
- var getAbsolutePath = (path6, cwd) => {
2233
- if (sysPath2.isAbsolute(path6)) {
2234
- return path6;
3087
+ var getAbsolutePath = (path8, cwd) => {
3088
+ if (sysPath2.isAbsolute(path8)) {
3089
+ return path8;
2235
3090
  }
2236
- return sysPath2.join(cwd, path6);
3091
+ return sysPath2.join(cwd, path8);
2237
3092
  };
2238
3093
  var EMPTY_SET = Object.freeze(/* @__PURE__ */ new Set());
2239
3094
  var DirEntry = class {
@@ -2288,10 +3143,10 @@ var DirEntry = class {
2288
3143
  var STAT_METHOD_F = "stat";
2289
3144
  var STAT_METHOD_L = "lstat";
2290
3145
  var WatchHelper = class {
2291
- constructor(path6, follow, fsw) {
3146
+ constructor(path8, follow, fsw) {
2292
3147
  this.fsw = fsw;
2293
- const watchPath = path6;
2294
- this.path = path6 = path6.replace(REPLACER_RE, "");
3148
+ const watchPath = path8;
3149
+ this.path = path8 = path8.replace(REPLACER_RE, "");
2295
3150
  this.watchPath = watchPath;
2296
3151
  this.fullWatchPath = sysPath2.resolve(watchPath);
2297
3152
  this.dirParts = [];
@@ -2413,20 +3268,20 @@ var FSWatcher = class extends EventEmitter {
2413
3268
  this._closePromise = void 0;
2414
3269
  let paths = unifyPaths(paths_);
2415
3270
  if (cwd) {
2416
- paths = paths.map((path6) => {
2417
- const absPath = getAbsolutePath(path6, cwd);
3271
+ paths = paths.map((path8) => {
3272
+ const absPath = getAbsolutePath(path8, cwd);
2418
3273
  return absPath;
2419
3274
  });
2420
3275
  }
2421
- paths.forEach((path6) => {
2422
- this._removeIgnoredPath(path6);
3276
+ paths.forEach((path8) => {
3277
+ this._removeIgnoredPath(path8);
2423
3278
  });
2424
3279
  this._userIgnored = void 0;
2425
3280
  if (!this._readyCount)
2426
3281
  this._readyCount = 0;
2427
3282
  this._readyCount += paths.length;
2428
- Promise.all(paths.map(async (path6) => {
2429
- const res = await this._nodeFsHandler._addToNodeFs(path6, !_internal, void 0, 0, _origAdd);
3283
+ Promise.all(paths.map(async (path8) => {
3284
+ const res = await this._nodeFsHandler._addToNodeFs(path8, !_internal, void 0, 0, _origAdd);
2430
3285
  if (res)
2431
3286
  this._emitReady();
2432
3287
  return res;
@@ -2448,17 +3303,17 @@ var FSWatcher = class extends EventEmitter {
2448
3303
  return this;
2449
3304
  const paths = unifyPaths(paths_);
2450
3305
  const { cwd } = this.options;
2451
- paths.forEach((path6) => {
2452
- if (!sysPath2.isAbsolute(path6) && !this._closers.has(path6)) {
3306
+ paths.forEach((path8) => {
3307
+ if (!sysPath2.isAbsolute(path8) && !this._closers.has(path8)) {
2453
3308
  if (cwd)
2454
- path6 = sysPath2.join(cwd, path6);
2455
- path6 = sysPath2.resolve(path6);
3309
+ path8 = sysPath2.join(cwd, path8);
3310
+ path8 = sysPath2.resolve(path8);
2456
3311
  }
2457
- this._closePath(path6);
2458
- this._addIgnoredPath(path6);
2459
- if (this._watched.has(path6)) {
3312
+ this._closePath(path8);
3313
+ this._addIgnoredPath(path8);
3314
+ if (this._watched.has(path8)) {
2460
3315
  this._addIgnoredPath({
2461
- path: path6,
3316
+ path: path8,
2462
3317
  recursive: true
2463
3318
  });
2464
3319
  }
@@ -2522,38 +3377,38 @@ var FSWatcher = class extends EventEmitter {
2522
3377
  * @param stats arguments to be passed with event
2523
3378
  * @returns the error if defined, otherwise the value of the FSWatcher instance's `closed` flag
2524
3379
  */
2525
- async _emit(event, path6, stats) {
3380
+ async _emit(event, path8, stats) {
2526
3381
  if (this.closed)
2527
3382
  return;
2528
3383
  const opts = this.options;
2529
3384
  if (isWindows)
2530
- path6 = sysPath2.normalize(path6);
3385
+ path8 = sysPath2.normalize(path8);
2531
3386
  if (opts.cwd)
2532
- path6 = sysPath2.relative(opts.cwd, path6);
2533
- const args = [path6];
3387
+ path8 = sysPath2.relative(opts.cwd, path8);
3388
+ const args = [path8];
2534
3389
  if (stats != null)
2535
3390
  args.push(stats);
2536
3391
  const awf = opts.awaitWriteFinish;
2537
3392
  let pw;
2538
- if (awf && (pw = this._pendingWrites.get(path6))) {
3393
+ if (awf && (pw = this._pendingWrites.get(path8))) {
2539
3394
  pw.lastChange = /* @__PURE__ */ new Date();
2540
3395
  return this;
2541
3396
  }
2542
3397
  if (opts.atomic) {
2543
3398
  if (event === EVENTS.UNLINK) {
2544
- this._pendingUnlinks.set(path6, [event, ...args]);
3399
+ this._pendingUnlinks.set(path8, [event, ...args]);
2545
3400
  setTimeout(() => {
2546
- this._pendingUnlinks.forEach((entry, path7) => {
3401
+ this._pendingUnlinks.forEach((entry, path9) => {
2547
3402
  this.emit(...entry);
2548
3403
  this.emit(EVENTS.ALL, ...entry);
2549
- this._pendingUnlinks.delete(path7);
3404
+ this._pendingUnlinks.delete(path9);
2550
3405
  });
2551
3406
  }, typeof opts.atomic === "number" ? opts.atomic : 100);
2552
3407
  return this;
2553
3408
  }
2554
- if (event === EVENTS.ADD && this._pendingUnlinks.has(path6)) {
3409
+ if (event === EVENTS.ADD && this._pendingUnlinks.has(path8)) {
2555
3410
  event = EVENTS.CHANGE;
2556
- this._pendingUnlinks.delete(path6);
3411
+ this._pendingUnlinks.delete(path8);
2557
3412
  }
2558
3413
  }
2559
3414
  if (awf && (event === EVENTS.ADD || event === EVENTS.CHANGE) && this._readyEmitted) {
@@ -2571,16 +3426,16 @@ var FSWatcher = class extends EventEmitter {
2571
3426
  this.emitWithAll(event, args);
2572
3427
  }
2573
3428
  };
2574
- this._awaitWriteFinish(path6, awf.stabilityThreshold, event, awfEmit);
3429
+ this._awaitWriteFinish(path8, awf.stabilityThreshold, event, awfEmit);
2575
3430
  return this;
2576
3431
  }
2577
3432
  if (event === EVENTS.CHANGE) {
2578
- const isThrottled = !this._throttle(EVENTS.CHANGE, path6, 50);
3433
+ const isThrottled = !this._throttle(EVENTS.CHANGE, path8, 50);
2579
3434
  if (isThrottled)
2580
3435
  return this;
2581
3436
  }
2582
3437
  if (opts.alwaysStat && stats === void 0 && (event === EVENTS.ADD || event === EVENTS.ADD_DIR || event === EVENTS.CHANGE)) {
2583
- const fullPath = opts.cwd ? sysPath2.join(opts.cwd, path6) : path6;
3438
+ const fullPath = opts.cwd ? sysPath2.join(opts.cwd, path8) : path8;
2584
3439
  let stats2;
2585
3440
  try {
2586
3441
  stats2 = await stat3(fullPath);
@@ -2611,23 +3466,23 @@ var FSWatcher = class extends EventEmitter {
2611
3466
  * @param timeout duration of time to suppress duplicate actions
2612
3467
  * @returns tracking object or false if action should be suppressed
2613
3468
  */
2614
- _throttle(actionType, path6, timeout) {
3469
+ _throttle(actionType, path8, timeout) {
2615
3470
  if (!this._throttled.has(actionType)) {
2616
3471
  this._throttled.set(actionType, /* @__PURE__ */ new Map());
2617
3472
  }
2618
3473
  const action = this._throttled.get(actionType);
2619
3474
  if (!action)
2620
3475
  throw new Error("invalid throttle");
2621
- const actionPath = action.get(path6);
3476
+ const actionPath = action.get(path8);
2622
3477
  if (actionPath) {
2623
3478
  actionPath.count++;
2624
3479
  return false;
2625
3480
  }
2626
3481
  let timeoutObject;
2627
3482
  const clear = () => {
2628
- const item = action.get(path6);
3483
+ const item = action.get(path8);
2629
3484
  const count = item ? item.count : 0;
2630
- action.delete(path6);
3485
+ action.delete(path8);
2631
3486
  clearTimeout(timeoutObject);
2632
3487
  if (item)
2633
3488
  clearTimeout(item.timeoutObject);
@@ -2635,7 +3490,7 @@ var FSWatcher = class extends EventEmitter {
2635
3490
  };
2636
3491
  timeoutObject = setTimeout(clear, timeout);
2637
3492
  const thr = { timeoutObject, clear, count: 0 };
2638
- action.set(path6, thr);
3493
+ action.set(path8, thr);
2639
3494
  return thr;
2640
3495
  }
2641
3496
  _incrReadyCount() {
@@ -2649,44 +3504,44 @@ var FSWatcher = class extends EventEmitter {
2649
3504
  * @param event
2650
3505
  * @param awfEmit Callback to be called when ready for event to be emitted.
2651
3506
  */
2652
- _awaitWriteFinish(path6, threshold, event, awfEmit) {
3507
+ _awaitWriteFinish(path8, threshold, event, awfEmit) {
2653
3508
  const awf = this.options.awaitWriteFinish;
2654
3509
  if (typeof awf !== "object")
2655
3510
  return;
2656
3511
  const pollInterval = awf.pollInterval;
2657
3512
  let timeoutHandler;
2658
- let fullPath = path6;
2659
- if (this.options.cwd && !sysPath2.isAbsolute(path6)) {
2660
- fullPath = sysPath2.join(this.options.cwd, path6);
3513
+ let fullPath = path8;
3514
+ if (this.options.cwd && !sysPath2.isAbsolute(path8)) {
3515
+ fullPath = sysPath2.join(this.options.cwd, path8);
2661
3516
  }
2662
3517
  const now = /* @__PURE__ */ new Date();
2663
3518
  const writes = this._pendingWrites;
2664
3519
  function awaitWriteFinishFn(prevStat) {
2665
3520
  statcb(fullPath, (err, curStat) => {
2666
- if (err || !writes.has(path6)) {
3521
+ if (err || !writes.has(path8)) {
2667
3522
  if (err && err.code !== "ENOENT")
2668
3523
  awfEmit(err);
2669
3524
  return;
2670
3525
  }
2671
3526
  const now2 = Number(/* @__PURE__ */ new Date());
2672
3527
  if (prevStat && curStat.size !== prevStat.size) {
2673
- writes.get(path6).lastChange = now2;
3528
+ writes.get(path8).lastChange = now2;
2674
3529
  }
2675
- const pw = writes.get(path6);
3530
+ const pw = writes.get(path8);
2676
3531
  const df = now2 - pw.lastChange;
2677
3532
  if (df >= threshold) {
2678
- writes.delete(path6);
3533
+ writes.delete(path8);
2679
3534
  awfEmit(void 0, curStat);
2680
3535
  } else {
2681
3536
  timeoutHandler = setTimeout(awaitWriteFinishFn, pollInterval, curStat);
2682
3537
  }
2683
3538
  });
2684
3539
  }
2685
- if (!writes.has(path6)) {
2686
- writes.set(path6, {
3540
+ if (!writes.has(path8)) {
3541
+ writes.set(path8, {
2687
3542
  lastChange: now,
2688
3543
  cancelWait: () => {
2689
- writes.delete(path6);
3544
+ writes.delete(path8);
2690
3545
  clearTimeout(timeoutHandler);
2691
3546
  return event;
2692
3547
  }
@@ -2697,8 +3552,8 @@ var FSWatcher = class extends EventEmitter {
2697
3552
  /**
2698
3553
  * Determines whether user has asked to ignore this path.
2699
3554
  */
2700
- _isIgnored(path6, stats) {
2701
- if (this.options.atomic && DOT_RE.test(path6))
3555
+ _isIgnored(path8, stats) {
3556
+ if (this.options.atomic && DOT_RE.test(path8))
2702
3557
  return true;
2703
3558
  if (!this._userIgnored) {
2704
3559
  const { cwd } = this.options;
@@ -2708,17 +3563,17 @@ var FSWatcher = class extends EventEmitter {
2708
3563
  const list = [...ignoredPaths.map(normalizeIgnored(cwd)), ...ignored];
2709
3564
  this._userIgnored = anymatch(list, void 0);
2710
3565
  }
2711
- return this._userIgnored(path6, stats);
3566
+ return this._userIgnored(path8, stats);
2712
3567
  }
2713
- _isntIgnored(path6, stat4) {
2714
- return !this._isIgnored(path6, stat4);
3568
+ _isntIgnored(path8, stat4) {
3569
+ return !this._isIgnored(path8, stat4);
2715
3570
  }
2716
3571
  /**
2717
3572
  * Provides a set of common helpers and properties relating to symlink handling.
2718
3573
  * @param path file or directory pattern being watched
2719
3574
  */
2720
- _getWatchHelpers(path6) {
2721
- return new WatchHelper(path6, this.options.followSymlinks, this);
3575
+ _getWatchHelpers(path8) {
3576
+ return new WatchHelper(path8, this.options.followSymlinks, this);
2722
3577
  }
2723
3578
  // Directory helpers
2724
3579
  // -----------------
@@ -2750,63 +3605,63 @@ var FSWatcher = class extends EventEmitter {
2750
3605
  * @param item base path of item/directory
2751
3606
  */
2752
3607
  _remove(directory, item, isDirectory) {
2753
- const path6 = sysPath2.join(directory, item);
2754
- const fullPath = sysPath2.resolve(path6);
2755
- isDirectory = isDirectory != null ? isDirectory : this._watched.has(path6) || this._watched.has(fullPath);
2756
- if (!this._throttle("remove", path6, 100))
3608
+ const path8 = sysPath2.join(directory, item);
3609
+ const fullPath = sysPath2.resolve(path8);
3610
+ isDirectory = isDirectory != null ? isDirectory : this._watched.has(path8) || this._watched.has(fullPath);
3611
+ if (!this._throttle("remove", path8, 100))
2757
3612
  return;
2758
3613
  if (!isDirectory && this._watched.size === 1) {
2759
3614
  this.add(directory, item, true);
2760
3615
  }
2761
- const wp = this._getWatchedDir(path6);
3616
+ const wp = this._getWatchedDir(path8);
2762
3617
  const nestedDirectoryChildren = wp.getChildren();
2763
- nestedDirectoryChildren.forEach((nested) => this._remove(path6, nested));
3618
+ nestedDirectoryChildren.forEach((nested) => this._remove(path8, nested));
2764
3619
  const parent = this._getWatchedDir(directory);
2765
3620
  const wasTracked = parent.has(item);
2766
3621
  parent.remove(item);
2767
3622
  if (this._symlinkPaths.has(fullPath)) {
2768
3623
  this._symlinkPaths.delete(fullPath);
2769
3624
  }
2770
- let relPath = path6;
3625
+ let relPath = path8;
2771
3626
  if (this.options.cwd)
2772
- relPath = sysPath2.relative(this.options.cwd, path6);
3627
+ relPath = sysPath2.relative(this.options.cwd, path8);
2773
3628
  if (this.options.awaitWriteFinish && this._pendingWrites.has(relPath)) {
2774
3629
  const event = this._pendingWrites.get(relPath).cancelWait();
2775
3630
  if (event === EVENTS.ADD)
2776
3631
  return;
2777
3632
  }
2778
- this._watched.delete(path6);
3633
+ this._watched.delete(path8);
2779
3634
  this._watched.delete(fullPath);
2780
3635
  const eventName = isDirectory ? EVENTS.UNLINK_DIR : EVENTS.UNLINK;
2781
- if (wasTracked && !this._isIgnored(path6))
2782
- this._emit(eventName, path6);
2783
- this._closePath(path6);
3636
+ if (wasTracked && !this._isIgnored(path8))
3637
+ this._emit(eventName, path8);
3638
+ this._closePath(path8);
2784
3639
  }
2785
3640
  /**
2786
3641
  * Closes all watchers for a path
2787
3642
  */
2788
- _closePath(path6) {
2789
- this._closeFile(path6);
2790
- const dir = sysPath2.dirname(path6);
2791
- this._getWatchedDir(dir).remove(sysPath2.basename(path6));
3643
+ _closePath(path8) {
3644
+ this._closeFile(path8);
3645
+ const dir = sysPath2.dirname(path8);
3646
+ this._getWatchedDir(dir).remove(sysPath2.basename(path8));
2792
3647
  }
2793
3648
  /**
2794
3649
  * Closes only file-specific watchers
2795
3650
  */
2796
- _closeFile(path6) {
2797
- const closers = this._closers.get(path6);
3651
+ _closeFile(path8) {
3652
+ const closers = this._closers.get(path8);
2798
3653
  if (!closers)
2799
3654
  return;
2800
3655
  closers.forEach((closer) => closer());
2801
- this._closers.delete(path6);
3656
+ this._closers.delete(path8);
2802
3657
  }
2803
- _addPathCloser(path6, closer) {
3658
+ _addPathCloser(path8, closer) {
2804
3659
  if (!closer)
2805
3660
  return;
2806
- let list = this._closers.get(path6);
3661
+ let list = this._closers.get(path8);
2807
3662
  if (!list) {
2808
3663
  list = [];
2809
- this._closers.set(path6, list);
3664
+ this._closers.set(path8, list);
2810
3665
  }
2811
3666
  list.push(closer);
2812
3667
  }
@@ -2837,56 +3692,184 @@ var esm_default = { watch, FSWatcher };
2837
3692
 
2838
3693
  // src/watch.ts
2839
3694
  var DEBOUNCE_MS = 1200;
3695
+ var LIMIT_REFRESH_MS = 6e4;
3696
+ var FALLBACK_SCAN_MS = 5e3;
3697
+ var DEVICE_HEARTBEAT_MS = 60 * 6e4;
3698
+ var CLOUD_SYNC_INTERVAL_MS = 30 * 6e4;
3699
+ var MAX_CLOUD_WRITES_PER_DAY = 16;
3700
+ var QUOTA_RETRY_MS = 15 * 6e4;
3701
+ var QUOTA_RETRY_JITTER_MS = 5 * 6e4;
2840
3702
  async function watch2(runner) {
2841
- await safeSync(runner, "startup");
3703
+ const startupRetryAfter = await safeSync(runner, {
3704
+ reason: "startup",
3705
+ refreshLimits: true,
3706
+ forceDeviceHeartbeat: true,
3707
+ deviceHeartbeatMs: DEVICE_HEARTBEAT_MS,
3708
+ cloudSyncIntervalMs: CLOUD_SYNC_INTERVAL_MS,
3709
+ maxCloudWritesPerDay: MAX_CLOUD_WRITES_PER_DAY
3710
+ });
2842
3711
  const paths = runner.watchPaths();
2843
3712
  const watcher = esm_default.watch(paths, {
2844
3713
  ignoreInitial: true,
2845
3714
  awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
2846
- ignored: (p) => !(p.endsWith(".jsonl") || !p.includes("."))
3715
+ // Never infer "file" from dots in the absolute path: the provider roots
3716
+ // themselves are named .claude and .codex on every supported platform.
3717
+ ignored: (p, stats) => Boolean(stats?.isFile() && !isUsageFile(p))
2847
3718
  });
2848
3719
  let timer;
2849
- let pending = false;
3720
+ let pending;
2850
3721
  let running = false;
2851
- const schedule = () => {
3722
+ let scheduledAt = 0;
3723
+ let blockedUntil = startupRetryAfter ? Date.now() + startupRetryAfter : 0;
3724
+ const schedule = (request, delay = DEBOUNCE_MS) => {
3725
+ pending = mergeRequests(pending, request);
3726
+ if (running) return;
3727
+ const target = Math.max(Date.now() + delay, blockedUntil);
3728
+ if (timer && scheduledAt <= target) return;
2852
3729
  if (timer) clearTimeout(timer);
3730
+ scheduledAt = target;
2853
3731
  timer = setTimeout(async () => {
2854
- if (running) {
2855
- pending = true;
2856
- return;
2857
- }
3732
+ timer = void 0;
3733
+ scheduledAt = 0;
3734
+ const next = pending;
3735
+ pending = void 0;
3736
+ if (!next) return;
2858
3737
  running = true;
2859
- await safeSync(runner, "change");
3738
+ const retryAfterMs = await safeSync(runner, next);
2860
3739
  running = false;
3740
+ if (retryAfterMs) {
3741
+ blockedUntil = Date.now() + retryAfterMs;
3742
+ pending = mergeRequests(pending, next);
3743
+ } else {
3744
+ blockedUntil = 0;
3745
+ }
2861
3746
  if (pending) {
2862
- pending = false;
2863
- schedule();
3747
+ const queued = pending;
3748
+ pending = void 0;
3749
+ schedule(queued, 0);
2864
3750
  }
2865
- }, DEBOUNCE_MS);
3751
+ }, Math.max(0, target - Date.now()));
2866
3752
  };
2867
- watcher.on("add", schedule).on("change", schedule);
3753
+ const turnRequest = (reason) => ({
3754
+ reason,
3755
+ refreshLimits: false,
3756
+ deviceHeartbeatMs: DEVICE_HEARTBEAT_MS,
3757
+ cloudSyncIntervalMs: CLOUD_SYNC_INTERVAL_MS,
3758
+ maxCloudWritesPerDay: MAX_CLOUD_WRITES_PER_DAY
3759
+ });
3760
+ watcher.on("add", () => schedule(turnRequest("change"))).on("change", () => schedule(turnRequest("change")));
3761
+ const fallbackTimer = setInterval(() => schedule(turnRequest("fallback"), 0), FALLBACK_SCAN_MS);
3762
+ const refreshTimer = setInterval(
3763
+ () => schedule(
3764
+ {
3765
+ reason: "limits",
3766
+ refreshLimits: true,
3767
+ deviceHeartbeatMs: DEVICE_HEARTBEAT_MS,
3768
+ cloudSyncIntervalMs: CLOUD_SYNC_INTERVAL_MS,
3769
+ maxCloudWritesPerDay: MAX_CLOUD_WRITES_PER_DAY
3770
+ },
3771
+ 0
3772
+ ),
3773
+ LIMIT_REFRESH_MS
3774
+ );
2868
3775
  console.log(`Watching for usage in:
2869
3776
  ${paths.join("\n ")}
2870
3777
  Press Ctrl+C to stop.`);
2871
- await new Promise((resolve3) => {
3778
+ await new Promise((resolve4) => {
2872
3779
  process.on("SIGINT", () => {
2873
- void watcher.close().then(resolve3);
3780
+ clearInterval(refreshTimer);
3781
+ clearInterval(fallbackTimer);
3782
+ if (timer) clearTimeout(timer);
3783
+ void watcher.close().then(resolve4);
2874
3784
  });
2875
3785
  });
2876
3786
  }
2877
- async function safeSync(runner, reason) {
3787
+ async function safeSync(runner, request) {
2878
3788
  try {
2879
- const { newTurns, byProvider } = await runner.syncOnce();
2880
- if (newTurns > 0) {
3789
+ const { reason, ...options } = request;
3790
+ const { processedTurns, byProvider, published, cloudWritesToday } = await runner.syncOnce(options);
3791
+ if (processedTurns > 0) {
2881
3792
  const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ");
2882
- console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] synced ${newTurns} new turn(s) (${detail})`);
3793
+ console.log(
3794
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] processed ${processedTurns} turn(s) locally (${detail}); ` + (published ? `cloud snapshot published (${cloudWritesToday}/16 today)` : "cloud snapshot queued")
3795
+ );
2883
3796
  } else if (reason === "startup") {
2884
- console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] up to date`);
3797
+ console.log(
3798
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] local snapshot ready; ` + (published ? `cloud snapshot published (${cloudWritesToday}/16 today)` : "cloud snapshot queued")
3799
+ );
2885
3800
  }
2886
3801
  } catch (err) {
3802
+ if (err instanceof FirestoreRestError && err.status === 429) {
3803
+ const retryAfter = Math.max(
3804
+ err.retryAfterMs ?? 0,
3805
+ QUOTA_RETRY_MS + Math.floor(Math.random() * QUOTA_RETRY_JITTER_MS)
3806
+ );
3807
+ console.error(
3808
+ `Cloud sync paused: Firestore quota is exhausted; the local snapshot is safe. Retrying in ${Math.ceil(retryAfter / 6e4)}m.`
3809
+ );
3810
+ return retryAfter;
3811
+ }
2887
3812
  console.error(`Sync error: ${err.message}`);
3813
+ return 15e3;
2888
3814
  }
2889
3815
  }
3816
+ function mergeRequests(current, incoming) {
3817
+ if (!current) return incoming;
3818
+ return {
3819
+ reason: incoming.reason === "change" || current.reason === "change" ? "change" : incoming.reason,
3820
+ refreshLimits: Boolean(current.refreshLimits || incoming.refreshLimits),
3821
+ forceDeviceHeartbeat: Boolean(current.forceDeviceHeartbeat || incoming.forceDeviceHeartbeat),
3822
+ deviceHeartbeatMs: Math.min(
3823
+ current.deviceHeartbeatMs ?? DEVICE_HEARTBEAT_MS,
3824
+ incoming.deviceHeartbeatMs ?? DEVICE_HEARTBEAT_MS
3825
+ ),
3826
+ cloudSyncIntervalMs: Math.min(
3827
+ current.cloudSyncIntervalMs ?? CLOUD_SYNC_INTERVAL_MS,
3828
+ incoming.cloudSyncIntervalMs ?? CLOUD_SYNC_INTERVAL_MS
3829
+ ),
3830
+ maxCloudWritesPerDay: Math.min(
3831
+ current.maxCloudWritesPerDay ?? MAX_CLOUD_WRITES_PER_DAY,
3832
+ incoming.maxCloudWritesPerDay ?? MAX_CLOUD_WRITES_PER_DAY
3833
+ )
3834
+ };
3835
+ }
3836
+ function isUsageFile(filePath) {
3837
+ return filePath.endsWith(".jsonl") || filePath.endsWith(".sqlite") || filePath.endsWith(".sqlite-wal");
3838
+ }
3839
+
3840
+ // src/update-check.ts
3841
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
3842
+ var REGISTRY_URL = "https://registry.npmjs.org/tokelytics/latest";
3843
+ async function agentUpdateMessage(force = false) {
3844
+ try {
3845
+ const state = await loadUpdateState();
3846
+ const checkedAt = Date.parse(state.checkedAt ?? "");
3847
+ let latest = state.latestVersion;
3848
+ if (force || !Number.isFinite(checkedAt) || Date.now() - checkedAt >= CHECK_INTERVAL_MS) {
3849
+ const response = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5e3) });
3850
+ if (!response.ok) return void 0;
3851
+ const body = await response.json();
3852
+ latest = body.version;
3853
+ await saveUpdateState({
3854
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3855
+ latestVersion: latest
3856
+ });
3857
+ }
3858
+ if (latest && compareVersions(latest, AGENT_VERSION) > 0) {
3859
+ return `Tokelytics ${latest} is available. Update with: npm install -g tokelytics@latest`;
3860
+ }
3861
+ } catch {
3862
+ }
3863
+ return void 0;
3864
+ }
3865
+ function compareVersions(left, right) {
3866
+ const a = left.split(".").map((part) => Number.parseInt(part, 10) || 0);
3867
+ const b = right.split(".").map((part) => Number.parseInt(part, 10) || 0);
3868
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
3869
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0) ? 1 : -1;
3870
+ }
3871
+ return 0;
3872
+ }
2890
3873
 
2891
3874
  // src/cli.ts
2892
3875
  var USAGE = `Tokelytics agent
@@ -2894,7 +3877,7 @@ var USAGE = `Tokelytics agent
2894
3877
  Usage:
2895
3878
  tokelytics login Sign in by approving in your browser
2896
3879
  tokelytics sync Run one incremental sync to your dashboard
2897
- tokelytics watch Stream usage in realtime (filesystem watcher)
3880
+ tokelytics watch Watch usage and refresh the cloud snapshot
2898
3881
  tokelytics status Show current sign-in and device
2899
3882
  tokelytics logout Forget stored credentials
2900
3883
  `;
@@ -2910,24 +3893,39 @@ async function main(argv) {
2910
3893
  } catch (err) {
2911
3894
  console.warn(`(Couldn't register device yet \u2014 will retry on first sync: ${err.message})`);
2912
3895
  }
2913
- console.log('Done. Now run "tokelytics watch" to stream your usage.');
3896
+ console.log('Done. Now run "npx tokelytics@latest watch" to keep your dashboard updated.');
2914
3897
  return 0;
2915
3898
  }
2916
3899
  case "sync": {
2917
- const runner = await createRunner();
2918
- const { newTurns, byProvider } = await runner.syncOnce();
2919
- const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ") || "none";
2920
- console.log(`Synced ${newTurns} new turn(s) (${detail}).`);
3900
+ const update = await agentUpdateMessage();
3901
+ if (update) console.warn(update);
3902
+ const lock = await acquireWatchLock();
3903
+ try {
3904
+ const runner = await createRunner();
3905
+ const { processedTurns, processedLimits, byProvider } = await runner.syncOnce();
3906
+ const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ") || "none";
3907
+ console.log(`Processed ${processedTurns} turn(s) (${detail}); refreshed ${processedLimits} usage provider(s).`);
3908
+ } finally {
3909
+ await lock.release();
3910
+ }
2921
3911
  return 0;
2922
3912
  }
2923
3913
  case "watch": {
2924
- const runner = await createRunner();
2925
- await watch2(runner);
3914
+ const lock = await acquireWatchLock();
3915
+ try {
3916
+ const update = await agentUpdateMessage();
3917
+ if (update) console.warn(update);
3918
+ const runner = await createRunner();
3919
+ await watch2(runner);
3920
+ } finally {
3921
+ await lock.release();
3922
+ }
2926
3923
  return 0;
2927
3924
  }
2928
3925
  case "status": {
2929
3926
  const creds = await loadCredentials();
2930
3927
  const state = await loadState();
3928
+ console.log(`Agent version: ${AGENT_VERSION}`);
2931
3929
  if (!creds) {
2932
3930
  console.log('Not signed in. Run "tokelytics login".');
2933
3931
  } else {
@@ -2943,6 +3941,8 @@ async function main(argv) {
2943
3941
  } catch (err) {
2944
3942
  console.log(`Session needs refresh: ${err.message}`);
2945
3943
  }
3944
+ const update = await agentUpdateMessage(true);
3945
+ if (update) console.log(update);
2946
3946
  return 0;
2947
3947
  }
2948
3948
  case "logout": {