@usagefleet/cli 1.2.79 → 1.2.80

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.
package/README.md CHANGED
@@ -63,11 +63,11 @@ shell once. `uninstall` removes both again, and self-update keeps them current.
63
63
  is at-least-once — the server dedups on `uuid`.
64
64
  - **Your real limit %** — the collector uses the Claude login already on the
65
65
  machine (subscription OAuth from `claude`: macOS login Keychain, elsewhere
66
- `<config dir>/.credentials.json`; falling back to `ANTHROPIC_API_KEY`), sends a
67
- 1-token ping to the Messages API, and reads Anthropic's
68
- `anthropic-ratelimit-unified-5h/7d-utilization` headers. Credentials never
69
- leave the machine — only the percentages do. `usagefleet status` shows which
70
- login was found.
66
+ `<config dir>/.credentials.json`; falling back to `ANTHROPIC_API_KEY`) and
67
+ reads the same 5h/weekly percentages Claude's own `/usage` screen shows —
68
+ from the free OAuth usage endpoint for subscriptions, or a 1-token Messages
69
+ ping for API keys. Credentials never leave the machine — only the percentages
70
+ do. `usagefleet status` shows which login was found.
71
71
 
72
72
  Uploaded per record: token counts, model, session id, hostname, working
73
73
  directory, git branch. Prompts, responses and file contents are never read.
@@ -127,7 +127,7 @@ Env vars override the file:
127
127
  | `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
128
128
  | `USAGEFLEET_PI` | override the pi sessions dirs, comma-separated; `off` to skip |
129
129
  | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
130
- | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit pings (default 300, so the 1-token ping isn't every cycle) |
130
+ | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
131
131
  | `USAGEFLEET_NOTIFY` | desktop notifications, on by default |
132
132
  | `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert (default `80,95`) |
133
133
  | `USAGEFLEET_BATCH` | records per upload (default 100, server caps at 1000) |
@@ -47,12 +47,12 @@ export function parseLimitsHeaders(source, get, names = []) {
47
47
  }
48
48
  const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
49
49
  /**
50
- * Per-model limits for subscription logins. The Messages ping only returns the
51
- * account-wide 5h/7d headers; the per-model caps Claude's own UI shows (e.g.
52
- * "Fable · 24% used") come from the OAuth usage endpoint Claude Code queries
53
- * for /usage. Undocumented — parse defensively and return [] on any surprise.
50
+ * The OAuth usage endpoint Claude Code queries for /usage the source of both
51
+ * the per-model caps its UI shows ("Fable · 24% used") and the account-wide
52
+ * numbers we trust over the response headers. Undocumented parse defensively
53
+ * and return null on any surprise.
54
54
  */
55
- async function fetchOauthModelLimits(token) {
55
+ async function fetchOauthUsage(token) {
56
56
  const res = await fetch(OAUTH_USAGE_URL, {
57
57
  headers: {
58
58
  'anthropic-beta': 'oauth-2025-04-20',
@@ -62,26 +62,57 @@ async function fetchOauthModelLimits(token) {
62
62
  signal: AbortSignal.timeout(15_000),
63
63
  });
64
64
  if (!res.ok) {
65
- return [];
65
+ return null;
66
66
  }
67
67
  const body = await res.json().catch(() => null);
68
68
  if (process.env.USAGEFLEET_DEBUG_HEADERS) {
69
69
  console.error(`[debug] oauth/usage: ${JSON.stringify(body)}`);
70
70
  }
71
- return parseOauthUsage(body);
71
+ return body;
72
+ }
73
+ /**
74
+ * Account-wide 5h/7d numbers from an oauth/usage payload: percentages
75
+ * (`{utilization: 14}`), exactly what Claude's /usage screen shows.
76
+ *
77
+ * These beat the `-utilization` response headers, which now carry a 0–1
78
+ * fraction (`0.13` for 13% used). Missing fields stay null so the header
79
+ * values survive.
80
+ */
81
+ export function parseOauthAccount(body) {
82
+ const root = (typeof body === 'object' && body !== null ? body : {});
83
+ const window = (key) => {
84
+ const raw = root[key];
85
+ const entry = (typeof raw === 'object' && raw !== null ? raw : {});
86
+ return {
87
+ pct: typeof entry.utilization === 'number' ? clampPct(entry.utilization) : null,
88
+ resetsAt: typeof entry.resets_at === 'string' ? parseReset(entry.resets_at) : null,
89
+ };
90
+ };
91
+ const five = window('five_hour');
92
+ const seven = window('seven_day');
93
+ return {
94
+ fiveHourPct: five.pct,
95
+ fiveHourResetsAt: five.resetsAt,
96
+ sevenDayPct: seven.pct,
97
+ sevenDayResetsAt: seven.resetsAt,
98
+ };
72
99
  }
73
100
  /** Both sources report 0–100 percentages: the `-utilization` headers ("37") and
74
101
  * oauth/usage's `utilization`/`percent` fields. Clamping is all they need — a
75
102
  * 0–1 "fraction form" heuristic would read a real 1% as 100%, which reaches the
76
103
  * headline number, the critical notification and guard's prompt block.
77
104
  *
105
+ * One decimal is kept (not rounded to whole): the server multiplies the group
106
+ * split by the group count, so integer quantization would amplify to whole
107
+ * points on the dashboard.
108
+ *
78
109
  * Non-finite in, null out: `Infinity` (a JSON `1e999`, or a junk header) would
79
110
  * otherwise clamp to a perfectly plausible 100 and block every prompt. */
80
111
  function clampPct(n) {
81
112
  if (!Number.isFinite(n)) {
82
113
  return null;
83
114
  }
84
- return Math.min(100, Math.max(0, Math.round(n)));
115
+ return Math.min(100, Math.max(0, Math.round(n * 10) / 10));
85
116
  }
86
117
  /** Normalize a scope's model name to a header-safe key ("Fable" → "fable"). */
87
118
  function modelKeyOf(name) {
@@ -153,12 +184,38 @@ export function parseOauthUsage(body) {
153
184
  }
154
185
  return out;
155
186
  }
187
+ /** Assemble a full LimitsReport from an oauth/usage payload, or null when the
188
+ * payload carries no account-wide percentage (endpoint down, shape changed) —
189
+ * the caller then falls back to the header ping. */
190
+ export function oauthLimitsReport(body) {
191
+ const account = parseOauthAccount(body);
192
+ if (account.fiveHourPct == null && account.sevenDayPct == null) {
193
+ return null;
194
+ }
195
+ return { ...account, modelLimits: parseOauthUsage(body), source: 'sub' };
196
+ }
156
197
  /**
157
- * Read the account's real rate-limit utilization. Sends a 1-token ping to the
158
- * Messages API; Anthropic returns the unified 5h/7d utilization in response
159
- * headers (same approach as Claude-Usage-Tracker's OAuth path).
198
+ * Read the account's real rate-limit utilization.
199
+ *
200
+ * Subscription logins ask the free OAuth usage endpoint first — it returns the
201
+ * exact numbers Claude's own /usage screen shows (account-wide AND per-model)
202
+ * and costs no tokens, so it can be polled often. Only when it yields nothing
203
+ * (or for API keys, which can't call it) does this fall back to a 1-token ping
204
+ * to the Messages API, whose response headers carry the unified utilization
205
+ * (same approach as Claude-Usage-Tracker's OAuth path).
160
206
  */
161
207
  export async function fetchLimits(creds) {
208
+ if (creds.source === 'sub') {
209
+ try {
210
+ const report = oauthLimitsReport(await fetchOauthUsage(creds.token));
211
+ if (report) {
212
+ return report;
213
+ }
214
+ }
215
+ catch {
216
+ /* fall through to the header ping */
217
+ }
218
+ }
162
219
  const headers = {
163
220
  'anthropic-version': '2023-06-01',
164
221
  'content-type': 'application/json',
@@ -204,16 +261,5 @@ export async function fetchLimits(creds) {
204
261
  if (!res.ok && !gotHeaders) {
205
262
  throw new Error(`limits unavailable: HTTP ${res.status} with no rate-limit headers`);
206
263
  }
207
- // Subscription logins: merge in the per-model caps from the OAuth usage
208
- // endpoint (the ping headers never include them). Best-effort — keep the
209
- // header-derived report on any failure.
210
- if (creds.source === 'sub' && report.modelLimits.length === 0) {
211
- try {
212
- report.modelLimits = await fetchOauthModelLimits(creds.token);
213
- }
214
- catch {
215
- /* endpoint unavailable — report account-wide limits only */
216
- }
217
- }
218
264
  return report;
219
265
  }
package/dist/index.js CHANGED
@@ -70,11 +70,14 @@ async function cmdWatch() {
70
70
  const cfg = loadConfig();
71
71
  const raw = Number(flag('interval') ?? process.env.USAGEFLEET_INTERVAL ?? 15);
72
72
  const interval = Math.max(1, Number.isFinite(raw) && raw > 0 ? raw : 15) * 1000;
73
- // The limits ping hits the real Messages API (1 billable token) — don't run it
74
- // every usage-scan tick. Report at most once per USAGEFLEET_LIMITS_INTERVAL
75
- // seconds (default 300), decoupled from the much faster usage poll.
76
- const rawLimits = Number(process.env.USAGEFLEET_LIMITS_INTERVAL ?? 300);
77
- const limitsInterval = Math.max(interval / 1000, Number.isFinite(rawLimits) && rawLimits > 0 ? rawLimits : 300) * 1000;
73
+ // Limits reporting is decoupled from the much faster usage poll. Default
74
+ // interval depends on how the reading is fetched: subscription logins use the
75
+ // free oauth/usage endpoint (60s keeps the dashboard split fresh at zero token
76
+ // cost), API keys pay a 1-token Messages ping per reading (300s). An explicit
77
+ // USAGEFLEET_LIMITS_INTERVAL overrides both.
78
+ const rawLimits = Number(process.env.USAGEFLEET_LIMITS_INTERVAL);
79
+ const explicitLimits = Number.isFinite(rawLimits) && rawLimits > 0 ? rawLimits * 1000 : null;
80
+ let limitsInterval = explicitLimits ?? 60_000;
78
81
  let lastLimitsAt = 0;
79
82
  // Self-update: once at startup, then every USAGEFLEET_UPDATE_INTERVAL seconds
80
83
  // (default 6h — a release lands on a device the same day, not the next).
@@ -112,6 +115,9 @@ async function cmdWatch() {
112
115
  if (limits) {
113
116
  line(note, limitsSummary(limits));
114
117
  }
118
+ if (explicitLimits === null) {
119
+ limitsInterval = limits?.source === 'api' ? 300_000 : 60_000;
120
+ }
115
121
  }
116
122
  }
117
123
  catch (error) {
@@ -248,7 +254,7 @@ function cmdConfig() {
248
254
  ['USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
249
255
  ['USAGEFLEET_PI', 'override pi session dirs, comma-separated'],
250
256
  ['USAGEFLEET_INTERVAL', 'watch interval seconds (default 15)'],
251
- ['USAGEFLEET_LIMITS_INTERVAL', 'seconds between limits pings (default 300)'],
257
+ ['USAGEFLEET_LIMITS_INTERVAL', 'seconds between limits reports (default 60; 300 on API keys)'],
252
258
  ['USAGEFLEET_BATCH', 'records per upload (default 100, max 1000)'],
253
259
  ['USAGEFLEET_NOTIFY', 'desktop notifications (0 disables)'],
254
260
  ['USAGEFLEET_NOTIFY_THRESHOLDS', 'comma list of % alerts (default 80,95)'],
package/dist/notifier.js CHANGED
@@ -88,13 +88,16 @@ export function maybeNotify(report, cfg = loadNotifyConfig(), log = () => {
88
88
  const state = readStore(path).notify;
89
89
  const five = evaluateWindow(state.fiveHour, report.fiveHourPct, report.fiveHourResetsAt, cfg.thresholds);
90
90
  const seven = evaluateWindow(state.sevenDay, report.sevenDayPct, report.sevenDayResetsAt, cfg.thresholds);
91
+ // Readings can carry a decimal; notification copy rounds to whole.
92
+ const fivePct = Math.round(report.fiveHourPct ?? 0);
93
+ const sevenPct = Math.round(report.sevenDayPct ?? 0);
91
94
  if (five.fire != null) {
92
- sendNotification('Claude usage · 5-hour limit', `${report.fiveHourPct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: urgencyFor(five.fire) });
93
- log('ok', `notified · 5h at ${report.fiveHourPct}% · crossed ${five.fire}%`);
95
+ sendNotification('Claude usage · 5-hour limit', `${fivePct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: urgencyFor(five.fire) });
96
+ log('ok', `notified · 5h at ${fivePct}% · crossed ${five.fire}%`);
94
97
  }
95
98
  if (seven.fire != null) {
96
- sendNotification('Claude usage · weekly limit', `${report.sevenDayPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: urgencyFor(seven.fire) });
97
- log('ok', `notified · weekly at ${report.sevenDayPct}% · crossed ${seven.fire}%`);
99
+ sendNotification('Claude usage · weekly limit', `${sevenPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: urgencyFor(seven.fire) });
100
+ log('ok', `notified · weekly at ${sevenPct}% · crossed ${seven.fire}%`);
98
101
  }
99
102
  updateStore(path, store => {
100
103
  store.notify = { fiveHour: five.next, sevenDay: seven.next };
package/dist/parser.js CHANGED
@@ -9,15 +9,15 @@ function validTimestamp(v) {
9
9
  }
10
10
  return new Date().toISOString();
11
11
  }
12
+ /** Cache-write tokens, with the per-TTL breakdown when the log carries it.
13
+ * 5m and 1h writes are priced differently (1.25× vs 2× input), so the server
14
+ * wants the split — null means "the log predates the breakdown", not zero. */
12
15
  function cacheCreation(u) {
13
- if (typeof u.cache_creation_input_tokens === 'number') {
14
- return u.cache_creation_input_tokens;
15
- }
16
16
  const c = u.cache_creation;
17
- if (c) {
18
- return (c.ephemeral_5m_input_tokens ?? 0) + (c.ephemeral_1h_input_tokens ?? 0);
19
- }
20
- return 0;
17
+ const five = typeof c?.ephemeral_5m_input_tokens === 'number' ? c.ephemeral_5m_input_tokens : null;
18
+ const oneHour = typeof c?.ephemeral_1h_input_tokens === 'number' ? c.ephemeral_1h_input_tokens : null;
19
+ const total = typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : (five ?? 0) + (oneHour ?? 0);
20
+ return { five, oneHour, total };
21
21
  }
22
22
  /** pi agent session line: `{type:"message", id, timestamp, message:{role:"assistant",
23
23
  * provider, model, responseId, usage:{input, output, cacheRead, cacheWrite}}}`.
@@ -47,6 +47,10 @@ function parsePiLine(o, sessionCwd) {
47
47
  return null;
48
48
  }
49
49
  return {
50
+ // pi's usage line does not say which TTL the cache write used — leave the
51
+ // breakdown unknown so the server prices it by the user's TTL setting.
52
+ cacheCreation1h: null,
53
+ cacheCreation5m: null,
50
54
  cacheCreationTokens: u.cacheWrite ?? 0,
51
55
  cacheReadTokens: u.cacheRead ?? 0,
52
56
  cwd: sessionCwd,
@@ -109,8 +113,11 @@ export function parseLine(line, source = 'cli', sessionCwd = null) {
109
113
  return null;
110
114
  }
111
115
  const u = message.usage;
116
+ const cache = cacheCreation(u);
112
117
  return {
113
- cacheCreationTokens: cacheCreation(u),
118
+ cacheCreation1h: cache.oneHour,
119
+ cacheCreation5m: cache.five,
120
+ cacheCreationTokens: cache.total,
114
121
  cacheReadTokens: u.cache_read_input_tokens ?? 0,
115
122
  cwd: str(o.cwd),
116
123
  gitBranch: str(o.gitBranch),
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.79";
2
+ export const RELEASE_VERSION = "1.2.80";
package/dist/service.js CHANGED
@@ -309,27 +309,36 @@ ${envXml}
309
309
  catch {
310
310
  /* not loaded yet — fine */
311
311
  }
312
+ let loaded = false;
312
313
  try {
313
314
  execFileSync('launchctl', ['bootstrap', domain, path], {
314
315
  stdio: 'inherit',
315
316
  });
317
+ loaded = true;
316
318
  }
317
319
  catch {
318
320
  try {
319
321
  execFileSync('launchctl', ['load', path], { stdio: 'inherit' });
322
+ loaded = true;
320
323
  }
321
324
  catch {
322
325
  /* report below; user can load manually */
323
326
  }
324
327
  }
325
- // Force a (re)start so an update takes effect immediately, not on next respawn.
326
- try {
327
- execFileSync('launchctl', ['kickstart', '-k', `${domain}/${LABEL}`], {
328
- stdio: 'ignore',
329
- });
330
- }
331
- catch {
332
- /* best-effort */
328
+ // Only when the (re)load failed i.e. the old job is still resident, so this
329
+ // is the one thing that can swap it. On a job launchd just started via
330
+ // RunAtLoad, `kickstart -k` kills that instance and the respawn waits out
331
+ // ThrottleInterval: a guaranteed 30s hole where `status` right after an
332
+ // update reads "stopped".
333
+ if (!loaded) {
334
+ try {
335
+ execFileSync('launchctl', ['kickstart', '-k', `${domain}/${LABEL}`], {
336
+ stdio: 'ignore',
337
+ });
338
+ }
339
+ catch {
340
+ /* best-effort */
341
+ }
333
342
  }
334
343
  console.log(step('service', 'launchd · starts at login'));
335
344
  console.log(row('logs', tilde(macLogDir())));
package/dist/ui.js CHANGED
@@ -86,9 +86,10 @@ export function line(glyph, text) {
86
86
  }
87
87
  /** Neutral stream glyph, for messages that are neither good nor bad news. */
88
88
  export const note = dim('·');
89
- /** Percentage as a fixed-width string, so successive log lines line up. */
89
+ /** Percentage as a fixed-width string, so successive log lines line up.
90
+ * Readings can carry a decimal; display rounds to whole. */
90
91
  export function pct(value) {
91
- return `${value ?? '?'}%`.padStart(4);
92
+ return `${value === null ? '?' : Math.round(value)}%`.padStart(4);
92
93
  }
93
94
  /** Usage bar, coloured by how close the window is to its limit.
94
95
  * Unknown usage renders as an empty bar rather than a missing column. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.79",
3
+ "version": "1.2.80",
4
4
  "description": "Tails Claude Code, Claude Desktop, and pi agent JSONL logs and reports token usage to a UsageFleet server.",
5
5
  "keywords": [
6
6
  "claude",