@usagefleet/cli 1.2.79 → 1.2.81

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
@@ -16,9 +16,10 @@ usagefleet login uf_xxx
16
16
  ```
17
17
 
18
18
  Same two commands on macOS, Linux and Windows (in PowerShell chain them with
19
- `;` — 5.1 has no `&&`). `login` pairs the device, sets the collector to start
20
- with your session and writes `~/.config/usagefleet/config.json` (mode `600`).
21
- The dashboard fills in within a minute.
19
+ `;` — 5.1 has no `&&`). `login` stores the token (the server checks it on the
20
+ first report, not at login), sets the collector to start with your session and
21
+ writes `~/.config/usagefleet/config.json` (mode `600`). The dashboard fills in
22
+ within a minute.
22
23
 
23
24
  `login` takes the token and nothing else. The collector reports to
24
25
  `usagefleet.com` and there is no way to redirect it: the request carries your
@@ -63,11 +64,11 @@ shell once. `uninstall` removes both again, and self-update keeps them current.
63
64
  is at-least-once — the server dedups on `uuid`.
64
65
  - **Your real limit %** — the collector uses the Claude login already on the
65
66
  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.
67
+ `<config dir>/.credentials.json`; falling back to `ANTHROPIC_API_KEY`) and
68
+ reads the same 5h/weekly percentages Claude's own `/usage` screen shows —
69
+ from the free OAuth usage endpoint for subscriptions, or a 1-token Messages
70
+ ping for API keys. Credentials never leave the machine — only the percentages
71
+ do. `usagefleet status` shows which login was found.
71
72
 
72
73
  Uploaded per record: token counts, model, session id, hostname, working
73
74
  directory, git branch. Prompts, responses and file contents are never read.
@@ -87,7 +88,9 @@ WinRT toast via `powershell.exe`.
87
88
 
88
89
  A group can be set to **refuse new prompts** once it has burned its budget slice
89
90
  (1/N of the account limit) for a window — a switch per window on the Groups
90
- page, both off by default. `usagefleet login` registers a Claude Code `UserPromptSubmit` hook in
91
+ page, both off by default. Each device also has its own blocking toggle on the
92
+ Devices page: switched off, that machine is never refused, whatever its group
93
+ says. `usagefleet login` registers a Claude Code `UserPromptSubmit` hook in
91
94
  `~/.claude/settings.json` (removed by `uninstall`, refreshed rather than stacked
92
95
  on re-install, and skipped entirely with `USAGEFLEET_HOOK=0`):
93
96
 
@@ -127,7 +130,7 @@ Env vars override the file:
127
130
  | `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
128
131
  | `USAGEFLEET_PI` | override the pi sessions dirs, comma-separated; `off` to skip |
129
132
  | `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) |
133
+ | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
131
134
  | `USAGEFLEET_NOTIFY` | desktop notifications, on by default |
132
135
  | `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert (default `80,95`) |
133
136
  | `USAGEFLEET_BATCH` | records per upload (default 100, server caps at 1000) |
@@ -56,7 +56,9 @@ function persist(blob, from) {
56
56
  return;
57
57
  }
58
58
  // The password must go in argv: `security`'s stdin prompt reads at most 128
59
- // bytes and would silently store a truncated (unparseable) blob.
59
+ // bytes and would silently store a truncated (unparseable) blob. Argv is
60
+ // briefly visible in the process list, but only to the same user — who can
61
+ // read the Keychain item anyway. Accepted.
60
62
  execFileSync('security', ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', userInfo().username, '-w', json], { stdio: 'ignore' });
61
63
  // Trust nothing: a partial write here means a broken Claude Code login.
62
64
  const stored = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], {
@@ -21,7 +21,9 @@ export function parseReset(v) {
21
21
  /** Per-model utilization header: `anthropic-ratelimit-unified-<window>-<model>-utilization`
22
22
  * (the account-wide headers have no `<model>` segment and don't match). */
23
23
  const MODEL_UTIL_RE = /^anthropic-ratelimit-unified-(\d+[hdwm])-([a-z0-9][a-z0-9_.-]*)-utilization$/;
24
- export function parseLimitsHeaders(source, get, names = []) {
24
+ // Always reports source 'api': subscription logins never touch the header
25
+ // path (see fetchLimits), so headers can only come from an API-key ping.
26
+ export function parseLimitsHeaders(get, names = []) {
25
27
  const modelLimits = [];
26
28
  for (const raw of names) {
27
29
  const m = raw.toLowerCase().match(MODEL_UTIL_RE);
@@ -42,17 +44,19 @@ export function parseLimitsHeaders(source, get, names = []) {
42
44
  modelLimits,
43
45
  sevenDayPct: parsePct(get('anthropic-ratelimit-unified-7d-utilization')),
44
46
  sevenDayResetsAt: parseReset(get('anthropic-ratelimit-unified-7d-reset')),
45
- source,
47
+ source: 'api',
46
48
  };
47
49
  }
48
50
  const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
49
51
  /**
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.
52
+ * The OAuth usage endpoint Claude Code queries for /usage the source of both
53
+ * the per-model caps its UI shows ("Fable · 24% used") and the account-wide
54
+ * numbers, the ONLY limits source for subscription logins. Undocumented
55
+ * parse defensively, but keep failure causes apart: an HTTP error names its
56
+ * status (401/403 reads as "token expired, open Claude Code to refresh",
57
+ * anything else as an outage to wait out), junk JSON returns null.
54
58
  */
55
- async function fetchOauthModelLimits(token) {
59
+ async function fetchOauthUsage(token) {
56
60
  const res = await fetch(OAUTH_USAGE_URL, {
57
61
  headers: {
58
62
  'anthropic-beta': 'oauth-2025-04-20',
@@ -62,26 +66,59 @@ async function fetchOauthModelLimits(token) {
62
66
  signal: AbortSignal.timeout(15_000),
63
67
  });
64
68
  if (!res.ok) {
65
- return [];
69
+ const hint = res.status === 401 || res.status === 403
70
+ ? 'Claude OAuth token expired or revoked · open Claude Code to refresh it'
71
+ : 'transient? retrying next cycle';
72
+ throw new Error(`oauth/usage HTTP ${res.status} · ${hint}`);
66
73
  }
67
74
  const body = await res.json().catch(() => null);
68
75
  if (process.env.USAGEFLEET_DEBUG_HEADERS) {
69
76
  console.error(`[debug] oauth/usage: ${JSON.stringify(body)}`);
70
77
  }
71
- return parseOauthUsage(body);
78
+ return body;
79
+ }
80
+ /**
81
+ * Account-wide 5h/7d numbers from an oauth/usage payload: percentages
82
+ * (`{utilization: 14}`), exactly what Claude's /usage screen shows — unlike
83
+ * the `-utilization` response headers, which now carry a 0–1 fraction on sub
84
+ * accounts. Missing fields stay null so `oauthLimitsReport` can tell an empty
85
+ * payload from a real one and skip the cycle.
86
+ */
87
+ export function parseOauthAccount(body) {
88
+ const root = (typeof body === 'object' && body !== null ? body : {});
89
+ const window = (key) => {
90
+ const raw = root[key];
91
+ const entry = (typeof raw === 'object' && raw !== null ? raw : {});
92
+ return {
93
+ pct: typeof entry.utilization === 'number' ? clampPct(entry.utilization) : null,
94
+ resetsAt: typeof entry.resets_at === 'string' ? parseReset(entry.resets_at) : null,
95
+ };
96
+ };
97
+ const five = window('five_hour');
98
+ const seven = window('seven_day');
99
+ return {
100
+ fiveHourPct: five.pct,
101
+ fiveHourResetsAt: five.resetsAt,
102
+ sevenDayPct: seven.pct,
103
+ sevenDayResetsAt: seven.resetsAt,
104
+ };
72
105
  }
73
106
  /** Both sources report 0–100 percentages: the `-utilization` headers ("37") and
74
107
  * oauth/usage's `utilization`/`percent` fields. Clamping is all they need — a
75
108
  * 0–1 "fraction form" heuristic would read a real 1% as 100%, which reaches the
76
109
  * headline number, the critical notification and guard's prompt block.
77
110
  *
111
+ * One decimal is kept (not rounded to whole): the server multiplies the group
112
+ * split by the group count, so integer quantization would amplify to whole
113
+ * points on the dashboard.
114
+ *
78
115
  * Non-finite in, null out: `Infinity` (a JSON `1e999`, or a junk header) would
79
116
  * otherwise clamp to a perfectly plausible 100 and block every prompt. */
80
117
  function clampPct(n) {
81
118
  if (!Number.isFinite(n)) {
82
119
  return null;
83
120
  }
84
- return Math.min(100, Math.max(0, Math.round(n)));
121
+ return Math.min(100, Math.max(0, Math.round(n * 10) / 10));
85
122
  }
86
123
  /** Normalize a scope's model name to a header-safe key ("Fable" → "fable"). */
87
124
  function modelKeyOf(name) {
@@ -94,7 +131,7 @@ function modelKeyOf(name) {
94
131
  * `{kind: "weekly_scoped", group: "weekly", percent, resets_at,
95
132
  * scope: {model: {display_name: "Fable"}}}`) are exactly the per-model bars
96
133
  * Claude's own UI renders. Account-wide entries have `scope: null` and are
97
- * skipped (the header ping covers them).
134
+ * skipped (`parseOauthAccount` reads those from `five_hour`/`seven_day`).
98
135
  *
99
136
  * Fallback: legacy top-level `seven_day_<model>` objects with a `utilization`
100
137
  * number (all null on current accounts, but cheap to keep).
@@ -120,7 +157,7 @@ export function parseOauthUsage(body) {
120
157
  if (!name || typeof l.percent !== 'number') {
121
158
  continue;
122
159
  }
123
- const window = l.group === 'session' ? '5h' : l.group === 'weekly' ? '7d' : '7d';
160
+ const window = l.group === 'session' ? '5h' : '7d';
124
161
  const key = modelKeyOf(name);
125
162
  out.push({
126
163
  model: key,
@@ -153,23 +190,39 @@ export function parseOauthUsage(body) {
153
190
  }
154
191
  return out;
155
192
  }
193
+ /** Assemble a full LimitsReport from an oauth/usage payload, or null when the
194
+ * payload carries no account-wide percentage (endpoint down, shape changed) —
195
+ * the caller then skips this cycle rather than report degraded numbers. */
196
+ export function oauthLimitsReport(body) {
197
+ const account = parseOauthAccount(body);
198
+ if (account.fiveHourPct == null && account.sevenDayPct == null) {
199
+ return null;
200
+ }
201
+ return { ...account, modelLimits: parseOauthUsage(body), source: 'sub' };
202
+ }
156
203
  /**
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).
204
+ * Read the account's real rate-limit utilization.
205
+ *
206
+ * Subscription logins use the free OAuth usage endpoint, and ONLY it — the
207
+ * exact numbers Claude's own /usage screen shows (account-wide AND per-model),
208
+ * no tokens spent. When it yields nothing, this throws instead of falling back
209
+ * to the Messages-API ping: on sub accounts those `-utilization` headers now
210
+ * carry a 0–1 fraction that parsePct reads 100× low, and one such POST would
211
+ * overwrite the server's last-good percentages for the whole account. Skipping
212
+ * the cycle keeps last-good everywhere (the guard has its own staleness rule).
213
+ *
214
+ * API keys can't call oauth/usage; their headers still read 0–100, so they
215
+ * keep the 1-token header ping (same approach as Claude-Usage-Tracker).
160
216
  */
161
217
  export async function fetchLimits(creds) {
162
- const headers = {
163
- 'anthropic-version': '2023-06-01',
164
- 'content-type': 'application/json',
165
- };
166
218
  if (creds.source === 'sub') {
167
- headers['authorization'] = `Bearer ${creds.token}`;
168
- headers['anthropic-beta'] = 'oauth-2025-04-20';
169
- headers['user-agent'] = 'claude-code/2.1.5 (usagefleet)';
170
- }
171
- else {
172
- headers['x-api-key'] = creds.token;
219
+ // fetchOauthUsage already threw on an HTTP error with its status; reaching
220
+ // here with no report means a 200 whose body carried no percentages.
221
+ const report = oauthLimitsReport(await fetchOauthUsage(creds.token));
222
+ if (!report) {
223
+ throw new Error('oauth/usage answered without percentages · shape changed? keeping last-good');
224
+ }
225
+ return report;
173
226
  }
174
227
  const res = await fetch(MESSAGES_URL, {
175
228
  body: JSON.stringify({
@@ -177,13 +230,17 @@ export async function fetchLimits(creds) {
177
230
  messages: [{ role: 'user', content: 'hi' }],
178
231
  model: 'claude-haiku-4-5-20251001',
179
232
  }),
180
- headers,
233
+ headers: {
234
+ 'anthropic-version': '2023-06-01',
235
+ 'content-type': 'application/json',
236
+ 'x-api-key': creds.token,
237
+ },
181
238
  method: 'POST',
182
239
  signal: AbortSignal.timeout(15_000),
183
240
  });
184
241
  // The unified rate-limit headers are (historically) present on success AND
185
- // error responses — this OAuth/header-scraping path against the public
186
- // Messages endpoint is undocumented and may break without notice. If a
242
+ // error responses — this header-scraping path against the public Messages
243
+ // endpoint is undocumented and may break without notice. If a
187
244
  // rejected response ALSO lacks the headers, the feature is unavailable; throw
188
245
  // so the caller logs it instead of POSTing an all-null report silently.
189
246
  // Diagnostic: dump every rate-limit header so unrecognized per-model names
@@ -195,7 +252,7 @@ export async function fetchLimits(creds) {
195
252
  }
196
253
  }
197
254
  }
198
- const report = parseLimitsHeaders(creds.source, n => res.headers.get(n), res.headers.keys());
255
+ const report = parseLimitsHeaders(n => res.headers.get(n), res.headers.keys());
199
256
  const gotHeaders = report.fiveHourPct != null ||
200
257
  report.sevenDayPct != null ||
201
258
  report.fiveHourResetsAt != null ||
@@ -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/collector.js CHANGED
@@ -213,9 +213,10 @@ function planWall() {
213
213
  return `device outside your plan's device limit · free a slot or upgrade at ${ENDPOINT}/devices · nothing is lost, uploads resume once it fits`;
214
214
  }
215
215
  /**
216
- * Auto-detect the local Claude login, read the real 5h/weekly utilization from
217
- * Anthropic's rate-limit headers, and report it to the server. Best-effort —
218
- * returns null (and logs) when no login is found or the request fails.
216
+ * Auto-detect the local Claude login, read the real 5h/weekly utilization
217
+ * (oauth/usage for subscription logins, rate-limit headers for API keys), and
218
+ * report it to the server. Best-effort — returns null (and logs) when no login
219
+ * is found or the request fails.
219
220
  */
220
221
  export async function reportLimitsOnce(cfg, log = () => {
221
222
  /* empty */
package/dist/config.js CHANGED
@@ -41,8 +41,8 @@ export function resolvePiDirs(env, fromFile) {
41
41
  }
42
42
  return [...new Set(raw.map(d => d.trim()).filter(d => d.length > 0))];
43
43
  }
44
- /** Optional scan root (USAGEFLEET_DESKTOP / USAGEFLEET_PI): env "off"/"0"
45
- * disables, env or config-file path overrides, else the auto-detected default. */
44
+ /** Optional scan root (USAGEFLEET_DESKTOP): env "off"/"0" disables, env or
45
+ * config-file path overrides, else the auto-detected default. */
46
46
  function resolveOptionalDir(env, fromFile, fallback) {
47
47
  if (env === '0' || env?.toLowerCase() === 'off') {
48
48
  return null;
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
@@ -46,10 +46,8 @@ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
46
46
  if (top > lastBucket) {
47
47
  return { fire: top, next: { lastBucket: top, resetsAt } };
48
48
  }
49
- if (top < lastBucket) {
50
- return { fire: null, next: { lastBucket: top, resetsAt } };
51
- }
52
- return { fire: null, next: { lastBucket, resetsAt } };
49
+ // top <= lastBucket: the mark follows pct down (or holds); nothing fires.
50
+ return { fire: null, next: { lastBucket: top, resetsAt } };
53
51
  }
54
52
  /** Relative "resets in 12m" / "resets in 2h" suffix, or "" if unknown/past. */
55
53
  function resetSuffix(resetsAt) {
@@ -88,13 +86,16 @@ export function maybeNotify(report, cfg = loadNotifyConfig(), log = () => {
88
86
  const state = readStore(path).notify;
89
87
  const five = evaluateWindow(state.fiveHour, report.fiveHourPct, report.fiveHourResetsAt, cfg.thresholds);
90
88
  const seven = evaluateWindow(state.sevenDay, report.sevenDayPct, report.sevenDayResetsAt, cfg.thresholds);
89
+ // Readings can carry a decimal; notification copy rounds to whole.
90
+ const fivePct = Math.round(report.fiveHourPct ?? 0);
91
+ const sevenPct = Math.round(report.sevenDayPct ?? 0);
91
92
  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}%`);
93
+ sendNotification('Claude usage · 5-hour limit', `${fivePct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: urgencyFor(five.fire) });
94
+ log('ok', `notified · 5h at ${fivePct}% · crossed ${five.fire}%`);
94
95
  }
95
96
  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}%`);
97
+ sendNotification('Claude usage · weekly limit', `${sevenPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: urgencyFor(seven.fire) });
98
+ log('ok', `notified · weekly at ${sevenPct}% · crossed ${seven.fire}%`);
98
99
  }
99
100
  updateStore(path, store => {
100
101
  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/paths.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
+ // USAGEFLEET_PROJECTS is resolved in config.ts's precedence chain, not here —
4
+ // a second env read (with `??` vs `||` drift) once made an empty env var
5
+ // silently scan nothing.
3
6
  export function defaultProjectsDir() {
4
- return process.env.USAGEFLEET_PROJECTS ?? join(homedir(), '.claude', 'projects');
7
+ return join(homedir(), '.claude', 'projects');
5
8
  }
6
9
  /** Claude Desktop's Electron userData dir, per-OS. Mirrors the app's own
7
10
  * `app.getPath("userData")` (= platform appData + the "Claude" product name),
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.81";
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.81",
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",