tokentracker-cli 0.5.101 → 0.6.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.
@@ -2952,10 +2952,13 @@ function resolveHermesDbPath() {
2952
2952
  return path.join(home, ".hermes", "state.db");
2953
2953
  }
2954
2954
 
2955
- function readHermesSessions(dbPath, sinceEpoch) {
2955
+ function readHermesSessions(dbPath, lastCompletedEpoch) {
2956
2956
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
2957
- const since = Number.isFinite(sinceEpoch) && sinceEpoch > 0 ? sinceEpoch : 0;
2958
- const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE started_at > ${since} AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
2957
+ const since = Number.isFinite(lastCompletedEpoch) && lastCompletedEpoch > 0 ? lastCompletedEpoch : 0;
2958
+ // Fetch sessions that started after the cursor, OR sessions that are still
2959
+ // in-progress (ended_at IS NULL). Hermes updates token counts in real-time,
2960
+ // so an active session keeps growing and must be re-read on every sync.
2961
+ const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE (started_at > ${since} OR ended_at IS NULL) AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
2959
2962
  let raw;
2960
2963
  try {
2961
2964
  raw = cp.execFileSync("sqlite3", ["-json", dbPath, sql], {
@@ -2979,17 +2982,25 @@ function readHermesSessions(dbPath, sinceEpoch) {
2979
2982
  async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }) {
2980
2983
  await ensureDir(path.dirname(queuePath));
2981
2984
  const hermesState = cursors.hermes && typeof cursors.hermes === "object" ? cursors.hermes : {};
2982
- const lastStartedAt =
2983
- typeof hermesState.lastStartedAt === "number" ? hermesState.lastStartedAt : 0;
2985
+
2986
+ // Only advance past sessions that have fully ended. Active sessions
2987
+ // (ended_at IS NULL) must be re-read every sync because Hermes updates
2988
+ // their token counts in real-time after each turn.
2989
+ const lastCompletedStartedAt =
2990
+ typeof hermesState.lastCompletedStartedAt === "number" ? hermesState.lastCompletedStartedAt : 0;
2991
+
2992
+ // Per-session snapshot from the previous sync: { [sessionId]: { in, out, cacheRead, cacheWrite, reasoning } }
2993
+ const prevSnapshots = (hermesState.snapshots && typeof hermesState.snapshots === "object")
2994
+ ? hermesState.snapshots : {};
2984
2995
 
2985
2996
  const resolvedDbPath = dbPath || resolveHermesDbPath();
2986
2997
  if (!fssync.existsSync(resolvedDbPath)) {
2987
2998
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
2988
2999
  }
2989
3000
 
2990
- const rows = readHermesSessions(resolvedDbPath, lastStartedAt);
3001
+ const rows = readHermesSessions(resolvedDbPath, lastCompletedStartedAt);
2991
3002
  if (rows.length === 0) {
2992
- cursors.hermes = { ...hermesState, lastStartedAt, updatedAt: new Date().toISOString() };
3003
+ cursors.hermes = { ...hermesState, lastCompletedStartedAt, updatedAt: new Date().toISOString() };
2993
3004
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
2994
3005
  }
2995
3006
 
@@ -2997,7 +3008,8 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
2997
3008
  const touchedBuckets = new Set();
2998
3009
  const cb = typeof onProgress === "function" ? onProgress : null;
2999
3010
  let eventsAggregated = 0;
3000
- let maxStartedAt = lastStartedAt;
3011
+ let maxCompletedStartedAt = lastCompletedStartedAt;
3012
+ const nextSnapshots = {};
3001
3013
 
3002
3014
  for (let i = 0; i < rows.length; i++) {
3003
3015
  const row = rows[i];
@@ -3008,6 +3020,28 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
3008
3020
  const reasoning = toNonNegativeInt(row.reasoning_tokens);
3009
3021
  if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && reasoning === 0) continue;
3010
3022
 
3023
+ // Save current snapshot for next sync
3024
+ nextSnapshots[row.id] = { in: inputTokens, out: outputTokens, cacheRead, cacheWrite, reasoning };
3025
+
3026
+ // Compute delta from previous snapshot (if any) so that we only count
3027
+ // new tokens since the last sync. First time we see a session the
3028
+ // previous snapshot is absent, so the full amount is the delta.
3029
+ const prev = prevSnapshots[row.id];
3030
+ let dInput = inputTokens;
3031
+ let dOutput = outputTokens;
3032
+ let dCacheRead = cacheRead;
3033
+ let dCacheWrite = cacheWrite;
3034
+ let dReasoning = reasoning;
3035
+ if (prev) {
3036
+ dInput = Math.max(0, inputTokens - (prev.in || 0));
3037
+ dOutput = Math.max(0, outputTokens - (prev.out || 0));
3038
+ dCacheRead = Math.max(0, cacheRead - (prev.cacheRead || 0));
3039
+ dCacheWrite = Math.max(0, cacheWrite - (prev.cacheWrite || 0));
3040
+ dReasoning = Math.max(0, reasoning - (prev.reasoning || 0));
3041
+ }
3042
+ // Skip if delta is zero (session unchanged since last sync)
3043
+ if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3044
+
3011
3045
  // Prefer ended_at for bucket placement; fall back to started_at
3012
3046
  const epochSec = row.ended_at || row.started_at;
3013
3047
  if (!epochSec || !Number.isFinite(epochSec)) continue;
@@ -3018,12 +3052,12 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
3018
3052
  const model = normalizeModelInput(row.model) || "hermes-agent";
3019
3053
 
3020
3054
  const delta = {
3021
- input_tokens: inputTokens,
3022
- cached_input_tokens: cacheRead,
3023
- cache_creation_input_tokens: cacheWrite,
3024
- output_tokens: outputTokens,
3025
- reasoning_output_tokens: reasoning,
3026
- total_tokens: inputTokens + outputTokens + cacheRead + cacheWrite + reasoning,
3055
+ input_tokens: dInput,
3056
+ cached_input_tokens: dCacheRead,
3057
+ cache_creation_input_tokens: dCacheWrite,
3058
+ output_tokens: dOutput,
3059
+ reasoning_output_tokens: dReasoning,
3060
+ total_tokens: dInput + dOutput + dCacheRead + dCacheWrite + dReasoning,
3027
3061
  conversation_count: toNonNegativeInt(row.message_count) || 1,
3028
3062
  };
3029
3063
 
@@ -3032,7 +3066,10 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
3032
3066
  touchedBuckets.add(bucketKey("hermes", model, bucketStart));
3033
3067
  eventsAggregated++;
3034
3068
 
3035
- if (row.started_at > maxStartedAt) maxStartedAt = row.started_at;
3069
+ // Only advance cursor past sessions that have ended
3070
+ if (row.ended_at && row.started_at > maxCompletedStartedAt) {
3071
+ maxCompletedStartedAt = row.started_at;
3072
+ }
3036
3073
 
3037
3074
  if (cb) {
3038
3075
  cb({
@@ -3049,7 +3086,13 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
3049
3086
  const updatedAt = new Date().toISOString();
3050
3087
  hourlyState.updatedAt = updatedAt;
3051
3088
  cursors.hourly = hourlyState;
3052
- cursors.hermes = { ...hermesState, lastStartedAt: maxStartedAt, updatedAt };
3089
+ cursors.hermes = {
3090
+ ...hermesState,
3091
+ lastStartedAt: maxCompletedStartedAt, // keep for backward compat
3092
+ lastCompletedStartedAt: maxCompletedStartedAt,
3093
+ snapshots: nextSnapshots,
3094
+ updatedAt,
3095
+ };
3053
3096
 
3054
3097
  return { recordsProcessed: rows.length, eventsAggregated, bucketsQueued };
3055
3098
  }
@@ -341,8 +341,49 @@ async function readCodexAccessToken({ home, env } = {}) {
341
341
  }
342
342
  }
343
343
 
344
+ // Returns the access token + ChatGPT account id + plan type + auth.json path so callers can
345
+ // also trigger a refresh if the tokens are stale (issue #52: stale tokens → wham 401 →
346
+ // "Fetch failed" error after ~7-8 days of not running `codex`).
347
+ async function readCodexAuthBundle({ home, env } = {}) {
348
+ try {
349
+ const codexHome = resolveCodexHome({ home, env });
350
+ const authPath = path.join(codexHome, "auth.json");
351
+ const auth = await readJson(authPath);
352
+ if (!auth || typeof auth !== "object") return null;
353
+
354
+ const accessToken = normalizeString(auth?.tokens?.access_token);
355
+ if (!accessToken) return null;
356
+
357
+ const accessPayload = decodeJwtPayload(auth?.tokens?.access_token);
358
+ const idPayload = decodeJwtPayload(auth?.tokens?.id_token);
359
+ const accountId =
360
+ normalizeString(auth?.tokens?.account_id) ||
361
+ normalizeString(extractOpenAiAuthNamespace(accessPayload)?.chatgpt_account_id) ||
362
+ normalizeString(extractOpenAiAuthNamespace(idPayload)?.chatgpt_account_id) ||
363
+ null;
364
+
365
+ const accessInfo = extractChatgptSubscriptionFromPayload(accessPayload);
366
+ const idInfo = extractChatgptSubscriptionFromPayload(idPayload);
367
+ const merged = mergeSubscription(accessInfo, idInfo);
368
+ const planType = merged?.planType ? merged.planType.toLowerCase() : null;
369
+
370
+ return {
371
+ accessToken,
372
+ accountId,
373
+ planType,
374
+ refreshToken: normalizeString(auth?.tokens?.refresh_token) || null,
375
+ lastRefresh: normalizeString(auth?.last_refresh) || null,
376
+ authPath,
377
+ authJson: auth,
378
+ };
379
+ } catch (_e) {
380
+ return null;
381
+ }
382
+ }
383
+
344
384
  module.exports = {
345
385
  collectLocalSubscriptions,
346
386
  readClaudeCodeAccessToken,
347
387
  readCodexAccessToken,
388
+ readCodexAuthBundle,
348
389
  };
@@ -5,7 +5,16 @@ const path = require("node:path");
5
5
  const http = require("node:http");
6
6
  const https = require("node:https");
7
7
 
8
- const { readClaudeCodeAccessToken, readCodexAccessToken } = require("./subscriptions");
8
+ const {
9
+ readClaudeCodeAccessToken,
10
+ readCodexAccessToken,
11
+ readCodexAuthBundle,
12
+ } = require("./subscriptions");
13
+ const {
14
+ isTokenStale,
15
+ refreshCodexTokens,
16
+ persistRefreshedAuth,
17
+ } = require("./codex-token-refresh");
9
18
  const {
10
19
  isCursorInstalled,
11
20
  extractCursorSessionToken,
@@ -122,23 +131,72 @@ async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttem
122
131
  }
123
132
  }
124
133
 
125
- async function fetchCodexUsageLimits(accessToken, { fetchImpl = fetch } = {}) {
134
+ // Classify a wham window by `limit_window_seconds` rather than its slot name.
135
+ // 18000s = 5h session window. 604800s = 7d weekly window. Free-tier accounts only get a
136
+ // weekly window, often delivered in the `primary_window` slot — naive position-based
137
+ // reading mislabels it as "5h". Aligned with steipete/CodexBar's rate-window normalizer.
138
+ const CODEX_SESSION_WINDOW_SECONDS = 18000;
139
+ const CODEX_WEEKLY_WINDOW_SECONDS = 604800;
140
+
141
+ function classifyCodexWindow(window) {
142
+ if (!window || typeof window !== "object") return null;
143
+ const seconds = Number(window.limit_window_seconds);
144
+ if (!Number.isFinite(seconds)) return null;
145
+ if (seconds === CODEX_SESSION_WINDOW_SECONDS) return "session";
146
+ if (seconds === CODEX_WEEKLY_WINDOW_SECONDS) return "weekly";
147
+ return null;
148
+ }
149
+
150
+ function normalizeCodexRateWindows(rateLimit) {
151
+ const candidates = [rateLimit?.primary_window, rateLimit?.secondary_window].filter(
152
+ (w) => w && typeof w === "object",
153
+ );
154
+ let session = null;
155
+ let weekly = null;
156
+ for (const w of candidates) {
157
+ const kind = classifyCodexWindow(w);
158
+ if (kind === "session" && !session) session = w;
159
+ else if (kind === "weekly" && !weekly) weekly = w;
160
+ }
161
+ // Fall back to positional read only if classification failed for both — preserves data
162
+ // from unexpected window durations rather than dropping it silently.
163
+ if (!session && !weekly && candidates.length > 0) {
164
+ return {
165
+ primary_window: candidates[0] ?? null,
166
+ secondary_window: candidates[1] ?? null,
167
+ };
168
+ }
169
+ return { primary_window: session, secondary_window: weekly };
170
+ }
171
+
172
+ async function fetchCodexUsageLimits(
173
+ accessToken,
174
+ { fetchImpl = fetch, accountId = null } = {},
175
+ ) {
176
+ const headers = {
177
+ Authorization: `Bearer ${accessToken}`,
178
+ Accept: "application/json",
179
+ };
180
+ // The wham endpoint rejects some plan tiers without an explicit account id — match
181
+ // CodexBar's request shape so free / multi-account users don't see opaque 4xx.
182
+ if (accountId) {
183
+ headers["ChatGPT-Account-Id"] = accountId;
184
+ }
185
+
126
186
  const res = await fetchImpl("https://chatgpt.com/backend-api/wham/usage", {
127
187
  method: "GET",
128
- headers: {
129
- Authorization: `Bearer ${accessToken}`,
130
- Accept: "application/json",
131
- },
188
+ headers,
132
189
  });
190
+ // 401/403/404 from wham means "no usage data available for this auth state" — render
191
+ // a neutral empty state instead of a red "Fetch failed" error.
192
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
193
+ return { primary_window: null, secondary_window: null };
194
+ }
133
195
  if (!res.ok) {
134
196
  throw new Error(`Codex API returned ${res.status}`);
135
197
  }
136
198
  const body = await res.json();
137
- const rateLimit = body.rate_limit || {};
138
- return {
139
- primary_window: rateLimit.primary_window ?? null,
140
- secondary_window: rateLimit.secondary_window ?? null,
141
- };
199
+ return normalizeCodexRateWindows(body.rate_limit || {});
142
200
  }
143
201
 
144
202
  function cursorPercentFromCentsUsedLimit(usedRaw, limitRaw) {
@@ -1471,11 +1529,45 @@ async function getUsageLimits({
1471
1529
  return cache.data;
1472
1530
  }
1473
1531
 
1474
- const [claudeToken, codexToken] = await Promise.all([
1532
+ const [claudeToken, codexAuth] = await Promise.all([
1475
1533
  Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner })),
1476
- readCodexAccessToken({ home, env }),
1534
+ readCodexAuthBundle({ home, env }),
1477
1535
  ]);
1478
1536
 
1537
+ // Proactively refresh Codex tokens that are >8 days stale, mirroring CodexBar's
1538
+ // CodexTokenRefresher.swift. Without this, users who logged in once and didn't run
1539
+ // `codex` for >a week get wham 401 → "Fetch failed" (issue #52). Best-effort: any
1540
+ // refresh failure falls through to using the existing (possibly stale) token, then the
1541
+ // 4xx graceful path in fetchCodexUsageLimits surfaces a neutral state instead of red.
1542
+ let refreshError = null;
1543
+ let codexAuthRefreshed = codexAuth;
1544
+ if (codexAuth && isTokenStale(codexAuth.lastRefresh) && codexAuth.refreshToken) {
1545
+ try {
1546
+ const newTokens = await refreshCodexTokens({
1547
+ refreshToken: codexAuth.refreshToken,
1548
+ fetchImpl,
1549
+ });
1550
+ const updatedAuth = await persistRefreshedAuth(
1551
+ codexAuth.authPath,
1552
+ codexAuth.authJson,
1553
+ newTokens,
1554
+ );
1555
+ codexAuthRefreshed = {
1556
+ ...codexAuth,
1557
+ accessToken: newTokens.access_token,
1558
+ refreshToken: newTokens.refresh_token,
1559
+ lastRefresh: updatedAuth.last_refresh,
1560
+ authJson: updatedAuth,
1561
+ };
1562
+ } catch (err) {
1563
+ refreshError = err;
1564
+ }
1565
+ }
1566
+
1567
+ const codexToken = codexAuthRefreshed?.accessToken || null;
1568
+ const codexAccountId = codexAuthRefreshed?.accountId || null;
1569
+ const codexPlanType = codexAuthRefreshed?.planType || null;
1570
+
1479
1571
  const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
1480
1572
  const [claudeResult, codexResult, cursor, kimi, gemini, kiro, antigravity, copilot] = await Promise.all([
1481
1573
  claudeToken
@@ -1485,7 +1577,11 @@ async function getUsageLimits({
1485
1577
  )
1486
1578
  : Promise.resolve(null),
1487
1579
  codexToken
1488
- ? withProviderTimeout(fetchCodexUsageLimits(codexToken, { fetchImpl: providerFetch }), "Codex", providerTimeoutMs).then(
1580
+ ? withProviderTimeout(
1581
+ fetchCodexUsageLimits(codexToken, { fetchImpl: providerFetch, accountId: codexAccountId }),
1582
+ "Codex",
1583
+ providerTimeoutMs,
1584
+ ).then(
1489
1585
  (value) => ({ status: "fulfilled", value }),
1490
1586
  (reason) => ({ status: "rejected", reason }),
1491
1587
  )
@@ -1521,12 +1617,21 @@ async function getUsageLimits({
1521
1617
  let codex;
1522
1618
  if (!codexToken) {
1523
1619
  codex = { configured: false };
1620
+ } else if (refreshError && refreshError.code === "REFRESH_TOKEN_EXPIRED") {
1621
+ // Refresh token is dead — the user must re-run `codex` to log in again. Surface a
1622
+ // specific, actionable message rather than the generic "Fetch failed".
1623
+ codex = {
1624
+ configured: true,
1625
+ error: refreshError.message,
1626
+ auth_action_required: "reauth",
1627
+ };
1524
1628
  } else if (!codexResult || codexResult.status === "rejected") {
1525
1629
  codex = { configured: true, error: codexResult?.reason?.message || "Unknown error" };
1526
1630
  } else {
1527
1631
  codex = {
1528
1632
  configured: true,
1529
1633
  error: null,
1634
+ plan_type: codexPlanType || null,
1530
1635
  primary_window: codexResult.value.primary_window,
1531
1636
  secondary_window: codexResult.value.secondary_window,
1532
1637
  };