tokentracker-cli 0.70.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.de.md +2 -1
  2. package/README.ja.md +2 -1
  3. package/README.ko.md +2 -1
  4. package/README.md +2 -1
  5. package/README.zh-CN.md +2 -1
  6. package/dashboard/dist/assets/{ActivityHeatmap-HkjamKhQ.js → ActivityHeatmap-xXPEf7cL.js} +1 -1
  7. package/dashboard/dist/assets/{Card-BAu_q1P8.js → Card-SvNODc3B.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-Cvb5lxum.js → CommunityStatsModal-DQVjsjuv.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-Ckc0m2pF.js → ConfirmModal-BaWPDQpw.js} +1 -1
  10. package/dashboard/dist/assets/{DashboardPage-CdTpYDO0.js → DashboardPage-Byj7sLJ0.js} +1 -1
  11. package/dashboard/dist/assets/{DevicePage-BIQ25ul5.js → DevicePage-mpqMx8oV.js} +1 -1
  12. package/dashboard/dist/assets/{DialogClose-DiOWMlOM.js → DialogClose-CWzNkEbl.js} +1 -1
  13. package/dashboard/dist/assets/DialogDescription-D15uXWxt.js +1 -0
  14. package/dashboard/dist/assets/{DialogTitle-BrR7t4i0.js → DialogTitle-DAobFTUO.js} +1 -1
  15. package/dashboard/dist/assets/{FadeIn-CkFXh9qu.js → FadeIn-D9ysa5Ls.js} +1 -1
  16. package/dashboard/dist/assets/{HeaderGithubStar-BqKaM3ph.js → HeaderGithubStar-ByPo4mzv.js} +1 -1
  17. package/dashboard/dist/assets/{IpCheckPage-k4RZKPiL.js → IpCheckPage-D5O_V9Ng.js} +1 -1
  18. package/dashboard/dist/assets/{LandingPage-Duj0yl_C.js → LandingPage-NXu1Hrnf.js} +1 -1
  19. package/dashboard/dist/assets/{LeaderboardAvatar-dILRiEW5.js → LeaderboardAvatar-DR9Gca2Q.js} +1 -1
  20. package/dashboard/dist/assets/{LeaderboardPage-yWly0uWz.js → LeaderboardPage-B5OEb0j0.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardProfileModal-DyBDRAKE.js → LeaderboardProfileModal-BP1VXIGY.js} +1 -1
  22. package/dashboard/dist/assets/{LeaderboardProfilePage-CpXppYzc.js → LeaderboardProfilePage-CkU4G9rM.js} +1 -1
  23. package/dashboard/dist/assets/{LimitsPage-BG86Jtvl.js → LimitsPage-PvibsSfB.js} +1 -1
  24. package/dashboard/dist/assets/{LocalOnlyNotice-CYCe0N_S.js → LocalOnlyNotice-BPkaxkkB.js} +1 -1
  25. package/dashboard/dist/assets/{LoginPage-D9XhznZH.js → LoginPage-B4EzkwXW.js} +1 -1
  26. package/dashboard/dist/assets/{PopoverPopup-C8iZhys6.js → PopoverPopup-MwcAIRI1.js} +1 -1
  27. package/dashboard/dist/assets/{ResetPasswordPage-B8mXGzFe.js → ResetPasswordPage-Cjobkt-M.js} +1 -1
  28. package/dashboard/dist/assets/{Select-Wgv6XKdi.js → Select-ZtaYokvF.js} +1 -1
  29. package/dashboard/dist/assets/{SelectItemText-rxW6xT66.js → SelectItemText-CdrhC29S.js} +1 -1
  30. package/dashboard/dist/assets/{SettingsPage-DRpIQZJx.js → SettingsPage-DI0HkBW_.js} +1 -1
  31. package/dashboard/dist/assets/{SkillsPage-Bl_FJwQu.js → SkillsPage-CWFTNZQ0.js} +1 -1
  32. package/dashboard/dist/assets/{WidgetsPage-B0AlwiRQ.js → WidgetsPage-CEP1Ew6X.js} +1 -1
  33. package/dashboard/dist/assets/{WrappedPage-C-ojLydF.js → WrappedPage-fiNsURtg.js} +1 -1
  34. package/dashboard/dist/assets/{agent-logos-wKEmyIg8.js → agent-logos-B8iedQIH.js} +1 -1
  35. package/dashboard/dist/assets/{arrow-up-right-BwT8ykRA.js → arrow-up-right-Cjb3EZGs.js} +1 -1
  36. package/dashboard/dist/assets/check-D1HYoTs4.js +1 -0
  37. package/dashboard/dist/assets/{copy-DvlBby5m.js → copy-DlDViZxV.js} +1 -1
  38. package/dashboard/dist/assets/{download-C7MsK41l.js → download-C5qoAiwD.js} +1 -1
  39. package/dashboard/dist/assets/{format-tokens-CtXNYfM2.js → format-tokens-e55_685N.js} +1 -1
  40. package/dashboard/dist/assets/{info-DXSmyEHB.js → info-BWJddEKJ.js} +1 -1
  41. package/dashboard/dist/assets/{main-DPyNBrFb.js → main-CjFvb-Kv.js} +2 -2
  42. package/dashboard/dist/assets/{use-community-stats-BC4Va1tk.js → use-community-stats-Ds7fV_8y.js} +1 -1
  43. package/dashboard/dist/assets/{use-limits-display-prefs-BL-NCgDn.js → use-limits-display-prefs-CWumBLAN.js} +1 -1
  44. package/dashboard/dist/assets/{use-native-settings-D0x_4uCT.js → use-native-settings-D5oSEEA_.js} +1 -1
  45. package/dashboard/dist/assets/{use-ordered-list-DEi9FKn9.js → use-ordered-list-DFEm87T2.js} +1 -1
  46. package/dashboard/dist/assets/{use-quality-per-dollar-pref-BIf1mWJK.js → use-quality-per-dollar-pref-D5bAGtCm.js} +1 -1
  47. package/dashboard/dist/assets/{use-usage-limits-CosJF8AC.js → use-usage-limits-D5q4cnNd.js} +1 -1
  48. package/dashboard/dist/assets/{useCurrency-4cyeApuY.js → useCurrency-CHAjKv53.js} +1 -1
  49. package/dashboard/dist/assets/{useScrollLock-D0Td7eQX.js → useScrollLock-DDedroUH.js} +1 -1
  50. package/dashboard/dist/index.html +1 -1
  51. package/dashboard/dist/share.html +1 -1
  52. package/package.json +1 -1
  53. package/src/commands/serve.js +13 -0
  54. package/src/commands/sync.js +8 -0
  55. package/src/lib/pricing/seed-snapshot.json +1 -1
  56. package/src/lib/telemetry.js +192 -0
  57. package/src/lib/usage-limits.js +80 -14
  58. package/dashboard/dist/assets/DialogDescription-DNyMczI_.js +0 -1
  59. package/dashboard/dist/assets/check-gQkfPY06.js +0 -1
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Anonymous daily heartbeat for counting active installs.
5
+ *
6
+ * Privacy contract (documented in README "Privacy" section — keep in sync):
7
+ * the payload carries exactly four fields — a one-way sha256 hash of the
8
+ * machine id, the app version, `process.platform`, and the app shell
9
+ * (cli / macos / windows). No token counts, no model names, no usernames,
10
+ * no paths. Opt out with TOKENTRACKER_NO_TELEMETRY=1, the DO_NOT_TRACK
11
+ * standard, or `"telemetry": false` in config.json.
12
+ */
13
+
14
+ const path = require("node:path");
15
+ const crypto = require("node:crypto");
16
+
17
+ const pkg = require("../../package.json");
18
+ const { readJson, writeJson } = require("./fs");
19
+ const { getOrCreateMachineId } = require("./machine-id");
20
+ const { resolveRuntimeConfig } = require("./runtime-config");
21
+ const {
22
+ decideAutoUpload,
23
+ recordUploadSuccess,
24
+ recordUploadFailure,
25
+ } = require("./upload-throttle");
26
+
27
+ const HEARTBEAT_FUNCTION_SLUG = "tokentracker-telemetry";
28
+ const HEARTBEAT_STATE_FILE = "telemetry.heartbeat.json";
29
+ const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
30
+ // The hash namespace is versioned so the anonymous id can be rotated fleet-wide
31
+ // by bumping the string. sha256(namespace + machineId) cannot be joined back to
32
+ // the cloud device rows (those store the machineId itself).
33
+ const MACHINE_HASH_NAMESPACE = "tokentracker-telemetry-v1:";
34
+ // Generous because some networks need 3-4s just for the TLS handshake to the
35
+ // cloud host; sync awaits this at most once per day so the worst-case stall
36
+ // is bounded and rare.
37
+ const DEFAULT_TIMEOUT_MS = 10_000;
38
+ const VALID_SHELLS = new Set(["cli", "macos", "windows"]);
39
+
40
+ const THROTTLE_CONFIG = {
41
+ intervalMs: HEARTBEAT_INTERVAL_MS,
42
+ jitterMsMax: 60_000,
43
+ backoffInitialMs: 60 * 60_000, // failures retry hourly at most
44
+ backoffMaxMs: HEARTBEAT_INTERVAL_MS,
45
+ };
46
+
47
+ function isTelemetryDisabled({ env = process.env, config = {} } = {}) {
48
+ if (isTruthyFlag(env?.TOKENTRACKER_NO_TELEMETRY)) return true;
49
+ if (isTruthyFlag(env?.DO_NOT_TRACK)) return true;
50
+ if (config && config.telemetry === false) return true;
51
+ return false;
52
+ }
53
+
54
+ function isTruthyFlag(value) {
55
+ if (typeof value !== "string") return false;
56
+ const v = value.trim().toLowerCase();
57
+ return v === "1" || v === "true";
58
+ }
59
+
60
+ function hashMachineId(machineId) {
61
+ return crypto
62
+ .createHash("sha256")
63
+ .update(`${MACHINE_HASH_NAMESPACE}${machineId}`)
64
+ .digest("hex");
65
+ }
66
+
67
+ function resolveShell(env = process.env) {
68
+ const raw = typeof env?.TOKENTRACKER_APP_SHELL === "string"
69
+ ? env.TOKENTRACKER_APP_SHELL.trim().toLowerCase()
70
+ : "";
71
+ return VALID_SHELLS.has(raw) ? raw : "cli";
72
+ }
73
+
74
+ function buildHeartbeatPayload({ machineId, version = pkg.version, platform = process.platform, env = process.env }) {
75
+ return {
76
+ machine_hash: hashMachineId(machineId),
77
+ app_version: String(version || "").slice(0, 32),
78
+ platform: String(platform || "unknown").slice(0, 16),
79
+ shell: resolveShell(env),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Send the daily heartbeat if allowed. Never throws; every failure path is a
85
+ * silent no-op so serve/sync flow is never affected. Returns a small status
86
+ * object for tests and debug logging.
87
+ */
88
+ async function maybeSendHeartbeat({
89
+ trackerDir,
90
+ fetchImpl = fetch,
91
+ nowMs = Date.now(),
92
+ env = process.env,
93
+ timeoutMs = DEFAULT_TIMEOUT_MS,
94
+ version,
95
+ } = {}) {
96
+ try {
97
+ if (!trackerDir) return { sent: false, reason: "no-tracker-dir" };
98
+
99
+ // Never phone home from the node test runner: many tests drive cmdSync /
100
+ // cmdServe directly from sandboxed temp homes, and their fresh throttle
101
+ // state would let a real POST through to production on every run. Tests
102
+ // that exercise this module inject their own env without this marker.
103
+ if (env?.NODE_TEST_CONTEXT !== undefined) {
104
+ return { sent: false, reason: "test-env" };
105
+ }
106
+
107
+ const configPath = path.join(trackerDir, "config.json");
108
+ const config = (await readJson(configPath)) || {};
109
+ if (isTelemetryDisabled({ env, config })) {
110
+ return { sent: false, reason: "opt-out" };
111
+ }
112
+
113
+ const statePath = path.join(trackerDir, HEARTBEAT_STATE_FILE);
114
+ const state = await readJson(statePath);
115
+ const decision = decideAutoUpload({
116
+ nowMs,
117
+ pendingBytes: 1, // heartbeats always have "one ping pending"
118
+ state,
119
+ config: THROTTLE_CONFIG,
120
+ });
121
+ if (!decision.allowed) {
122
+ return { sent: false, reason: "throttled", blockedUntilMs: decision.blockedUntilMs };
123
+ }
124
+
125
+ const machineId = getOrCreateMachineId(path.join(trackerDir, "queue.jsonl"));
126
+ if (!machineId) return { sent: false, reason: "no-machine-id" };
127
+
128
+ const runtime = resolveRuntimeConfig({ config, env });
129
+ const payload = buildHeartbeatPayload({ machineId, version, env });
130
+
131
+ const controller = new AbortController();
132
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
133
+ let response;
134
+ try {
135
+ response = await fetchImpl(`${runtime.baseUrl}/functions/${HEARTBEAT_FUNCTION_SLUG}`, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ apikey: runtime.anonKey,
140
+ },
141
+ body: JSON.stringify(payload),
142
+ signal: controller.signal,
143
+ });
144
+ } finally {
145
+ clearTimeout(timer);
146
+ }
147
+
148
+ if (!response || !response.ok) {
149
+ const failedState = recordUploadFailure({
150
+ nowMs,
151
+ state,
152
+ error: { message: `heartbeat http ${response?.status || "error"}`, status: response?.status },
153
+ config: THROTTLE_CONFIG,
154
+ });
155
+ await writeJson(statePath, failedState);
156
+ return { sent: false, reason: "http-error", status: response?.status };
157
+ }
158
+
159
+ const nextState = recordUploadSuccess({ nowMs, state, config: THROTTLE_CONFIG });
160
+ await writeJson(statePath, nextState);
161
+ return { sent: true, reason: "sent" };
162
+ } catch (e) {
163
+ // Includes abort/network errors: record backoff if we can, stay silent.
164
+ try {
165
+ const statePath = path.join(trackerDir, HEARTBEAT_STATE_FILE);
166
+ const state = await readJson(statePath);
167
+ await writeJson(
168
+ statePath,
169
+ recordUploadFailure({
170
+ nowMs,
171
+ state,
172
+ error: { message: String(e?.message || e) },
173
+ config: THROTTLE_CONFIG,
174
+ }),
175
+ );
176
+ } catch {
177
+ // Even state persistence failed — nothing else to do.
178
+ }
179
+ return { sent: false, reason: "error" };
180
+ }
181
+ }
182
+
183
+ module.exports = {
184
+ HEARTBEAT_FUNCTION_SLUG,
185
+ HEARTBEAT_STATE_FILE,
186
+ HEARTBEAT_INTERVAL_MS,
187
+ isTelemetryDisabled,
188
+ hashMachineId,
189
+ resolveShell,
190
+ buildHeartbeatPayload,
191
+ maybeSendHeartbeat,
192
+ };
@@ -28,9 +28,15 @@ const { fetchZcodeLimits } = require("./zcode-limits");
28
28
  const { fetchOpencodeGoLimits } = require("./opencode-go-limits");
29
29
  const { readSqliteJsonRows } = require("./sqlite-reader");
30
30
 
31
- // 2-minute in-memory cache
32
- let cache = { data: null, fetchedAt: 0 };
31
+ // 2-minute in-memory cache. It also expires early at the earliest upcoming window
32
+ // reset in the cached data (see cacheExpiresAtMs), floored so a provider reporting
33
+ // a reset "right now" can't turn every poll into a full upstream round.
34
+ let cache = { data: null, expiresAtMs: 0 };
33
35
  const CACHE_TTL_MS = 2 * 60 * 1000;
36
+ // Must stay below the macOS app's post-reset re-fetch grace (10s in
37
+ // DashboardViewModel.resetBoundaryGrace), or that targeted refresh would be
38
+ // served this cache's pre-reset snapshot and miss the rollover.
39
+ const CACHE_MIN_TTL_MS = 5 * 1000;
34
40
  const DEFAULT_PROVIDER_TIMEOUT_MS = 15_000;
35
41
  const ANTIGRAVITY_LIMITS_CACHE_FILE = "usage-limits-cache.json";
36
42
  const ANTIGRAVITY_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1921,24 +1927,48 @@ function normalizeClaudeCachedLimits(
1921
1927
  return hasClaudeWindow(cached) ? cached : null;
1922
1928
  }
1923
1929
 
1930
+ function readClaudeLimitsCacheRaw({ home } = {}) {
1931
+ try {
1932
+ const parsed = JSON.parse(fs.readFileSync(resolveClaudeLimitsCachePath({ home }), "utf8"));
1933
+ return parsed?.claude ?? null;
1934
+ } catch (_error) {
1935
+ return null;
1936
+ }
1937
+ }
1938
+
1924
1939
  function readClaudeLimitsCache({
1925
1940
  home,
1926
1941
  nowMs = Date.now(),
1927
1942
  maxAgeMs = CLAUDE_LIMITS_CACHE_MAX_AGE_MS,
1928
1943
  stale = true,
1929
1944
  } = {}) {
1930
- const cachePath = resolveClaudeLimitsCachePath({ home });
1931
- try {
1932
- const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
1933
- return normalizeClaudeCachedLimits(parsed?.claude, { nowMs, maxAgeMs, stale });
1934
- } catch (_error) {
1935
- return null;
1936
- }
1945
+ return normalizeClaudeCachedLimits(readClaudeLimitsCacheRaw({ home }), { nowMs, maxAgeMs, stale });
1946
+ }
1947
+
1948
+ // A cached snapshot stops being "fresh" the moment any of its windows crosses the
1949
+ // reset boundary it was written with: serving it past that point hides the rollover
1950
+ // from reset detection (the menu bar confetti) for up to the fresh TTL. Only resets
1951
+ // that were still ahead at write time count — a provider stamping already-past reset
1952
+ // times must not force a live call on every poll.
1953
+ function claudeCacheCrossedReset(raw, { nowMs } = {}) {
1954
+ const cachedAtMs = parseTimeMs(raw?.cached_at);
1955
+ if (!Number.isFinite(cachedAtMs)) return false;
1956
+ const windows = [
1957
+ raw?.five_hour,
1958
+ raw?.seven_day,
1959
+ raw?.seven_day_opus,
1960
+ ...(Array.isArray(raw?.weekly_scoped) ? raw.weekly_scoped : []),
1961
+ ];
1962
+ return windows.some((window) => {
1963
+ const resetMs = parseTimeMs(window?.resets_at);
1964
+ return resetMs !== null && resetMs > cachedAtMs && resetMs <= nowMs;
1965
+ });
1937
1966
  }
1938
1967
 
1939
1968
  function readFreshClaudeLimitsCache({ home, nowMs = Date.now() } = {}) {
1940
- return readClaudeLimitsCache({
1941
- home,
1969
+ const raw = readClaudeLimitsCacheRaw({ home });
1970
+ if (!raw || claudeCacheCrossedReset(raw, { nowMs })) return null;
1971
+ return normalizeClaudeCachedLimits(raw, {
1942
1972
  nowMs,
1943
1973
  maxAgeMs: CLAUDE_LIMITS_CACHE_FRESH_TTL_MS,
1944
1974
  stale: false,
@@ -2564,9 +2594,44 @@ function withPlanLabel(obj, raw, brand) {
2564
2594
  // in-flight fetch and returns its result.
2565
2595
  let inFlightFetch = null;
2566
2596
 
2597
+ // Codex stamps reset_at as unix seconds; every other provider (and Claude's
2598
+ // resets_at) uses ISO strings. Numbers that look like epoch milliseconds are
2599
+ // accepted as-is so a future provider can't silently regress this.
2600
+ function resetValueToMs(value) {
2601
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
2602
+ return value < 1e12 ? value * 1000 : value;
2603
+ }
2604
+ return parseTimeMs(value);
2605
+ }
2606
+
2607
+ // Walk the aggregated response and collect every window reset timestamp,
2608
+ // whatever the provider-specific nesting looks like.
2609
+ function collectResetTimesMs(node, out) {
2610
+ if (!node || typeof node !== "object") return;
2611
+ for (const [key, value] of Object.entries(node)) {
2612
+ if (key === "reset_at" || key === "resets_at") {
2613
+ const ms = resetValueToMs(value);
2614
+ if (ms !== null) out.push(ms);
2615
+ } else if (value && typeof value === "object") {
2616
+ collectResetTimesMs(value, out);
2617
+ }
2618
+ }
2619
+ }
2620
+
2621
+ // The moment any window rolls over, cached data stops being current — reset
2622
+ // detection (the menu bar confetti) would otherwise lag by up to the full TTL.
2623
+ function cacheExpiresAtMs(data, fetchedAtMs) {
2624
+ const times = [];
2625
+ collectResetTimesMs(data, times);
2626
+ const upcoming = times.filter((t) => t > fetchedAtMs);
2627
+ const ttlExpiry = fetchedAtMs + CACHE_TTL_MS;
2628
+ if (upcoming.length === 0) return ttlExpiry;
2629
+ return Math.min(ttlExpiry, Math.max(Math.min(...upcoming), fetchedAtMs + CACHE_MIN_TTL_MS));
2630
+ }
2631
+
2567
2632
  async function getUsageLimits(options = {}) {
2568
2633
  const nowMs = Date.now();
2569
- if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
2634
+ if (cache.data && nowMs < cache.expiresAtMs) {
2570
2635
  return cache.data;
2571
2636
  }
2572
2637
  if (inFlightFetch) {
@@ -2775,18 +2840,19 @@ async function fetchUsageLimitsUncached({
2775
2840
  opencodeGo: withPlanLabel(opencodeGo, opencodeGo?.plan_label, "OpenCode Go"),
2776
2841
  };
2777
2842
 
2778
- cache = { data, fetchedAt: nowMs };
2843
+ cache = { data, expiresAtMs: cacheExpiresAtMs(data, nowMs) };
2779
2844
  return data;
2780
2845
  }
2781
2846
 
2782
2847
  function resetUsageLimitsCache() {
2783
- cache = { data: null, fetchedAt: 0 };
2848
+ cache = { data: null, expiresAtMs: 0 };
2784
2849
  }
2785
2850
 
2786
2851
  module.exports = {
2787
2852
  getUsageLimits,
2788
2853
  normalizePlanLabel,
2789
2854
  resetUsageLimitsCache,
2855
+ cacheExpiresAtMs,
2790
2856
  runCommand,
2791
2857
  extractGeminiOauthClientCredentials,
2792
2858
  loadKimiCredentials,
@@ -1 +0,0 @@
1
- import{r as n,b9 as a}from"./main-DPyNBrFb.js";import{u as p}from"./DialogTitle-BrR7t4i0.js";import{u as c}from"./useScrollLock-D0Td7eQX.js";const x=n.forwardRef(function(e,r){const{render:l,className:u,style:m,id:s,...t}=e,{store:i}=p(),o=c(s);return i.useSyncedValueWithCleanup("descriptionElementId",o),a("p",e,{ref:r,props:[{id:o},t]})});export{x as D};
@@ -1 +0,0 @@
1
- import{c}from"./main-DPyNBrFb.js";const e=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],t=c("check",e);export{t as C};