@ipv9/tokentracker-cli 0.39.43 → 0.39.45

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 (43) hide show
  1. package/README.md +18 -10
  2. package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-2-TQg7P7.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BOc6XtOK.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-C2tIb68P.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-BsuLQ9co.js} +1 -1
  7. package/dashboard/dist/assets/LocalOnlyNotice-C8R9KLef.js +1 -0
  8. package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-BpfseoI7.js} +1 -1
  9. package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-Ctk8zze5.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-BKZlFyFs.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-CQXM8qGU.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-4EMm0eBX.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C2sdX5g6.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-CLiuEcQZ.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-BDkp93DX.js} +1 -1
  16. package/dashboard/dist/assets/{download-CTwO-YeA.js → download-DZ6SoCSn.js} +1 -1
  17. package/dashboard/dist/assets/{format-4chvNBjF.js → format-CaW9kvsA.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
  19. package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-DgyymGht.js} +16 -3
  20. package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
  21. package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-D6C7Fba3.js} +1 -1
  22. package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-BKAzGuxw.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-BVr6Ajuu.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +5 -3
  27. package/src/commands/doctor.js +8 -0
  28. package/src/commands/init.js +1 -1
  29. package/src/commands/sync.js +67 -0
  30. package/src/lib/doctor.js +227 -1
  31. package/src/lib/local-api.js +385 -113
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/src/lib/process-list.js +91 -0
  34. package/src/lib/queue-compact.js +220 -0
  35. package/src/lib/rollout.js +81 -14
  36. package/src/lib/single-flight.js +59 -0
  37. package/src/lib/skills-manager.js +2 -2
  38. package/src/lib/transcript-suppression.js +133 -0
  39. package/src/lib/usage-limits.js +99 -22
  40. package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
  41. package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
  42. package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
  43. package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
@@ -0,0 +1,133 @@
1
+ const { listProcessLines, parseProcessLine } = require("./process-list");
2
+
3
+ // Detects Claude Code CLI processes started with `--no-session-persistence`.
4
+ //
5
+ // Why this exists: every Claude figure TokenTracker reports is parsed out of the
6
+ // session transcripts under `~/.claude/projects`. That flag tells the CLI to
7
+ // write no transcript at all, so those calls are unobservable here — they cost
8
+ // real tokens and contribute zero. Before this check, the only visible symptom
9
+ // was a source quietly reporting less than it should, which is indistinguishable
10
+ // from "the user worked less today".
11
+ //
12
+ // This is a *live* signal, not a historical one: it answers "is something
13
+ // running right now that I cannot see", which is why it needs no baseline, no
14
+ // threshold, and no per-source history.
15
+ //
16
+ // PRIVACY (CONTRIBUTING.md): the command line is read and discarded inside this
17
+ // call. What leaves this module is a count, a coarse reason, and the model ids —
18
+ // never a pid, an argv string, an environment value, or a file path. Model ids
19
+ // are already first-class tracked data (every queue row carries one), and they
20
+ // are the one field that tells a user *which* stream is unobservable, so they
21
+ // are included deliberately; they are also length- and charset-clamped below so
22
+ // that no arbitrary command-line text can ride out through this field.
23
+
24
+ const SUPPRESSION_FLAG = "--no-session-persistence";
25
+ const CLAUDE_BINARY_NAME = "claude";
26
+ const MODEL_FLAG = /(?:^|\s)--model[=\s]+([A-Za-z0-9._:-]{1,64})(?=\s|$)/;
27
+ const SUPPRESSION_FLAG_PATTERN = /(?:^|\s)--no-session-persistence(?:=|\s|$)/;
28
+ const DEFAULT_TTL_MS = 30_000;
29
+
30
+ let cache = null;
31
+
32
+ function resetTranscriptSuppressionCache() {
33
+ cache = null;
34
+ }
35
+
36
+ // argv[0] must be the `claude` binary. Matching on the flag alone would also
37
+ // match any shell, editor, or grep whose own command line happens to contain
38
+ // the string — including the process asking this question.
39
+ //
40
+ // The executable region is everything before the first `--flag`, not the first
41
+ // whitespace-delimited token: `ps` gives one flat string, so a binary installed
42
+ // under a path containing a space ("/Users/me/My Tools/claude") would otherwise
43
+ // be read as argv[0] = "/Users/me/My" and missed. A miss is the failure mode
44
+ // this whole check exists to prevent, so it is worth the wider window.
45
+ function isClaudeInvocation(command) {
46
+ const executableRegion = String(command || "").split(/\s+--/)[0] || "";
47
+ const base = executableRegion.trim().split(/[/\\]/).pop() || "";
48
+ return base.replace(/\.exe$/i, "").toLowerCase() === CLAUDE_BINARY_NAME;
49
+ }
50
+
51
+ function hasSuppressionFlag(command) {
52
+ return SUPPRESSION_FLAG_PATTERN.test(String(command || ""));
53
+ }
54
+
55
+ function extractModel(command) {
56
+ const match = String(command || "").match(MODEL_FLAG);
57
+ return match?.[1] || null;
58
+ }
59
+
60
+ // Pure: takes `ps` output lines, returns the deduplicated model ids of every
61
+ // suppressed Claude process. Deliberately returns models rather than processes —
62
+ // there is no pid in the return value for anything downstream to leak.
63
+ function findSuppressedModels(lines = []) {
64
+ const models = new Set();
65
+ let count = 0;
66
+
67
+ for (const line of lines) {
68
+ const parsed = parseProcessLine(line);
69
+ if (!parsed) continue;
70
+ if (!isClaudeInvocation(parsed.command)) continue;
71
+ if (!hasSuppressionFlag(parsed.command)) continue;
72
+ count += 1;
73
+ const model = extractModel(parsed.command);
74
+ if (model) models.add(model);
75
+ }
76
+
77
+ return { count, models: [...models].sort() };
78
+ }
79
+
80
+ // Returns { supported, checked, count, models, reason, checked_at }.
81
+ //
82
+ // `checked: false` is never reported as a clean result by callers. "I could not
83
+ // look" and "I looked and found nothing" are different answers, and collapsing
84
+ // them is how a monitor starts lying.
85
+ function detectTranscriptSuppression({
86
+ commandRunner,
87
+ platform = process.platform,
88
+ now = () => new Date(),
89
+ ttlMs = DEFAULT_TTL_MS,
90
+ useCache = true,
91
+ } = {}) {
92
+ const at = now();
93
+ const atMs = at.getTime();
94
+
95
+ if (useCache && cache && atMs - cache.atMs < ttlMs) {
96
+ return cache.value;
97
+ }
98
+
99
+ const listed = listProcessLines({ commandRunner, platform });
100
+
101
+ let value;
102
+ if (!listed.ok) {
103
+ value = {
104
+ supported: listed.supported,
105
+ checked: false,
106
+ count: 0,
107
+ models: [],
108
+ reason: listed.reason,
109
+ checked_at: at.toISOString(),
110
+ };
111
+ } else {
112
+ const { count, models } = findSuppressedModels(listed.lines);
113
+ value = {
114
+ supported: true,
115
+ checked: true,
116
+ count,
117
+ models,
118
+ reason: null,
119
+ checked_at: at.toISOString(),
120
+ };
121
+ }
122
+
123
+ if (useCache) cache = { atMs, value };
124
+ return value;
125
+ }
126
+
127
+ module.exports = {
128
+ DEFAULT_TTL_MS,
129
+ SUPPRESSION_FLAG,
130
+ detectTranscriptSuppression,
131
+ findSuppressedModels,
132
+ resetTranscriptSuppressionCache,
133
+ };
@@ -21,12 +21,21 @@ const {
21
21
  extractCursorSessionToken,
22
22
  fetchCursorUsageSummary,
23
23
  } = require("./cursor-config");
24
+ const { PS_ARGS, PS_BINARY, parseProcessLine } = require("./process-list");
25
+ const { createSingleFlight } = require("./single-flight");
24
26
 
25
27
  // 2-minute in-memory cache
26
28
  let cache = { data: null, fetchedAt: 0 };
29
+ // One slot per module instance, not a shared singleton: `delete require.cache[…]`
30
+ // is how the tests get a clean cache, and a shared instance would survive it.
31
+ const runUsageLimitsFetch = createSingleFlight();
27
32
  const CACHE_TTL_MS = 2 * 60 * 1000;
28
33
  const DEFAULT_PROVIDER_TIMEOUT_MS = 15_000;
29
34
  const ANTIGRAVITY_LIMITS_CACHE_FILE = "usage-limits-cache.json";
35
+ // Trust marker, not a user identity: only cache blocks written after the
36
+ // current-user process-scan fix may be served. Missing/unknown markers fail
37
+ // closed so a legacy multi-user cache cannot disclose another account.
38
+ const ANTIGRAVITY_CACHE_PROCESS_SCOPE = "current-user-v1";
30
39
  const ANTIGRAVITY_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
31
40
  const ANTIGRAVITY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
32
41
  const CLAUDE_LIMITS_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -218,10 +227,23 @@ async function fetchCodexUsageLimits(
218
227
  method: "GET",
219
228
  headers,
220
229
  });
221
- // 401/403/404 from wham means "no usage data available for this auth state" — render
222
- // a neutral empty state instead of a red "Fetch failed" error.
230
+ // 401/403/404 from wham means "no usage data available for this auth state" —
231
+ // a free or multi-account user, or a token that went stale. #52 asked for a
232
+ // neutral state here rather than a red "Fetch failed".
233
+ //
234
+ // It got NO state. With no error and no windows, LimitChips falls through to
235
+ // `windows.length === 0 -> return null` and the chip disappears entirely,
236
+ // which is the outcome #105 calls worse than never having had a chip: the user
237
+ // has been trained to look there and now reads absence as "plenty left".
238
+ //
239
+ // `notice` is the third state the UI was missing. Visible and subtle, so #52's
240
+ // intent survives without #105's failure mode.
223
241
  if (res.status === 401 || res.status === 403 || res.status === 404) {
224
- return { primary_window: null, secondary_window: null };
242
+ return {
243
+ primary_window: null,
244
+ secondary_window: null,
245
+ notice: "No usage data for this sign-in. Run `codex` to sign in again.",
246
+ };
225
247
  }
226
248
  if (!res.ok) {
227
249
  throw new Error(`Codex API returned ${res.status}`);
@@ -973,11 +995,22 @@ async function fetchGeminiLimits({ home, env, fetchImpl = fetch, commandRunner }
973
995
 
974
996
  function runCommand(commandRunner, command, args, options = {}) {
975
997
  const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
976
- return runner(command, args, {
998
+ const result = runner(command, args, {
977
999
  encoding: "utf8",
978
1000
  maxBuffer: 10 * 1024 * 1024,
979
1001
  ...options,
980
1002
  });
1003
+ // Every consumer reads spawnSync-style fields immediately. An async runner
1004
+ // is therefore a contract error, not supported concurrency. Attach a handler
1005
+ // immediately, then return a synchronous failure shape so the Promise cannot
1006
+ // escape as unhandled process state.
1007
+ if (result && typeof result.then === "function") {
1008
+ Promise.resolve(result).catch(() => {});
1009
+ const error = new TypeError("commandRunner must return synchronously");
1010
+ error.code = "COMMAND_RUNNER_ASYNC_UNSUPPORTED";
1011
+ return { status: 1, stdout: "", stderr: error.message, error };
1012
+ }
1013
+ return result;
981
1014
  }
982
1015
 
983
1016
  function whichBinary(binary, { commandRunner } = {}) {
@@ -1319,17 +1352,6 @@ function fetchKiroLimits({ commandRunner, now = new Date() } = {}) {
1319
1352
  }
1320
1353
  }
1321
1354
 
1322
- function parseProcessLine(line) {
1323
- const match = String(line || "")
1324
- .trim()
1325
- .match(/^(\d+)\s+(.*)$/);
1326
- if (!match) return null;
1327
- return {
1328
- pid: Number(match[1]),
1329
- command: match[2],
1330
- };
1331
- }
1332
-
1333
1355
  function isAntigravityCommandLine(command) {
1334
1356
  const lower = String(command || "").toLowerCase();
1335
1357
  return lower.includes("language_server")
@@ -1347,10 +1369,34 @@ function extractCommandFlag(command, flag) {
1347
1369
  return match?.[1] || null;
1348
1370
  }
1349
1371
 
1372
+ // Scans for the local Antigravity language server and reads its `--csrf_token`
1373
+ // out of the command line, so that the quota request below can authenticate to
1374
+ // it.
1375
+ //
1376
+ // The scan is scoped to the current user, and must stay that way. Under the
1377
+ // previous `-ax` this walked every account on the box and attached to whichever
1378
+ // Antigravity matched first. The token itself never reached an HTTP response —
1379
+ // `processInfo` is read field by field and never spread into a return value —
1380
+ // but what the token *fetches* does: `normalizeAntigravityResponse` returns
1381
+ // `account_email` and `account_plan`, `finalize` spreads them into the result,
1382
+ // and `getUsageLimits` serves that at `/functions/tokentracker-usage-limits`.
1383
+ // On a shared host this meant showing another person's email, plan and quota as
1384
+ // the local user's, and `writeAntigravityLimitsCache` persisted it to disk,
1385
+ // where the `!configured` branch would keep serving it after their process
1386
+ // exited.
1387
+ //
1388
+ // Sharing PS_ARGS with process-list.js is deliberate: two scans that must both
1389
+ // stay own-user should not be able to drift apart.
1350
1390
  function detectAntigravityProcess({ commandRunner } = {}) {
1351
- const result = runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], {
1391
+ const result = runCommand(commandRunner, PS_BINARY, PS_ARGS, {
1352
1392
  timeout: 4000,
1353
1393
  });
1394
+ if (result?.error?.code === "COMMAND_RUNNER_ASYNC_UNSUPPORTED") {
1395
+ return {
1396
+ configured: true,
1397
+ error: result.error?.message || "Antigravity process detection failed.",
1398
+ };
1399
+ }
1354
1400
  const lines = String(result?.stdout || "").split("\n");
1355
1401
 
1356
1402
  let sawProcess = false;
@@ -1422,6 +1468,7 @@ function hasAntigravityWindow(limits) {
1422
1468
  }
1423
1469
 
1424
1470
  function normalizeAntigravityCachedLimits(raw, { nowMs = Date.now() } = {}) {
1471
+ if (raw?.process_scope !== ANTIGRAVITY_CACHE_PROCESS_SCOPE) return null;
1425
1472
  const cachedAtMs = parseTimeMs(raw?.cached_at);
1426
1473
  if (!Number.isFinite(cachedAtMs)) return null;
1427
1474
  if (cachedAtMs > nowMs + 60_000) return null;
@@ -1450,6 +1497,7 @@ function writeAntigravityLimitsCache(limits, { home, nowMs = Date.now() } = {})
1450
1497
  if (!limits?.configured || limits.error || !hasAntigravityWindow(limits)) return;
1451
1498
  const payload = {
1452
1499
  antigravity: {
1500
+ process_scope: ANTIGRAVITY_CACHE_PROCESS_SCOPE,
1453
1501
  account_email: limits.account_email || null,
1454
1502
  account_plan: limits.account_plan || null,
1455
1503
  primary_window: limits.primary_window || null,
@@ -1886,7 +1934,20 @@ function withPlanLabel(obj, raw, brand) {
1886
1934
  return { ...obj, plan_label: normalizePlanLabel(raw, brand) };
1887
1935
  }
1888
1936
 
1889
- async function getUsageLimits({
1937
+ async function getUsageLimits(options = {}) {
1938
+ const nowMs = Date.now();
1939
+ if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
1940
+ return cache.data;
1941
+ }
1942
+ // Past the cache there is exactly one thing to do, and it is expensive: sweep
1943
+ // every configured provider. Concurrent tabs, route mounts and revalidations
1944
+ // land here together on a cold cache, and a forced refresh clears the cache
1945
+ // first (local-api.js, the `refresh` param) — so "no cached data" is a common
1946
+ // state for several callers at once, not a rare one. They share one sweep.
1947
+ return runUsageLimitsFetch(() => fetchUsageLimits(options));
1948
+ }
1949
+
1950
+ async function fetchUsageLimits({
1890
1951
  home,
1891
1952
  env,
1892
1953
  platform,
@@ -1898,9 +1959,10 @@ async function getUsageLimits({
1898
1959
  providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS,
1899
1960
  } = {}) {
1900
1961
  const nowMs = Date.now();
1901
- if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
1902
- return cache.data;
1903
- }
1962
+ // The stale-Codex refresh is part of the same provider sweep and must share
1963
+ // its abort bound. Creating this before the serial prelude prevents one raw
1964
+ // OAuth fetch from holding the process-wide single-flight slot forever.
1965
+ const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
1904
1966
 
1905
1967
  const [claudeToken, claudeSubscription, codexAuth] = await Promise.all([
1906
1968
  Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner, home })),
@@ -1920,7 +1982,7 @@ async function getUsageLimits({
1920
1982
  try {
1921
1983
  const newTokens = await refreshCodexTokens({
1922
1984
  refreshToken: codexAuth.refreshToken,
1923
- fetchImpl,
1985
+ fetchImpl: providerFetch,
1924
1986
  });
1925
1987
  const updatedAuth = await persistRefreshedAuth(
1926
1988
  codexAuth.authPath,
@@ -1943,7 +2005,6 @@ async function getUsageLimits({
1943
2005
  const codexAccountId = codexAuthRefreshed?.accountId || null;
1944
2006
  const codexPlanType = codexAuthRefreshed?.planType || null;
1945
2007
 
1946
- const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
1947
2008
  const [claudeResult, codexResult, cursor, kimi, zai, gemini, kiro, antigravity, copilot] = await Promise.all([
1948
2009
  claudeToken
1949
2010
  ? withProviderTimeout(fetchClaudeUsageLimits(claudeToken, { fetchImpl: providerFetch, maxAttempts: 1 }), "Claude", providerTimeoutMs).then(
@@ -2013,6 +2074,10 @@ async function getUsageLimits({
2013
2074
  codex = {
2014
2075
  configured: true,
2015
2076
  error: null,
2077
+ // A 4xx from wham produces no windows and no error. Carrying its `notice`
2078
+ // through is what keeps the chip on screen instead of falling through to
2079
+ // LimitChips' `windows.length === 0 -> return null`.
2080
+ notice: codexResult.value.notice || null,
2016
2081
  plan_type: codexPlanType || null,
2017
2082
  primary_window: codexResult.value.primary_window,
2018
2083
  secondary_window: codexResult.value.secondary_window,
@@ -2040,8 +2105,20 @@ function resetUsageLimitsCache() {
2040
2105
  cache = { data: null, fetchedAt: 0 };
2041
2106
  }
2042
2107
 
2108
+ // The force protocol for the quota cache, in one place because two handlers
2109
+ // speak it: the CLI local API and the Vite dev middleware. They used to carry
2110
+ // their own copy of the same comparison, which is how one of them quietly stops
2111
+ // honouring a spelling — and a force that is silently downgraded to a cached
2112
+ // read looks exactly like a working one. `1` is what the dashboard client sends
2113
+ // (dashboard/src/lib/api.ts); `true` is what a human types into the URL bar.
2114
+ // Deliberately exact: no trimming, no case folding, no truthiness.
2115
+ function isForcedRefresh(value) {
2116
+ return value === "1" || value === "true";
2117
+ }
2118
+
2043
2119
  module.exports = {
2044
2120
  getUsageLimits,
2121
+ isForcedRefresh,
2045
2122
  normalizePlanLabel,
2046
2123
  resetUsageLimitsCache,
2047
2124
  extractGeminiOauthClientCredentials,