@usagefleet/cli 1.2.172 → 1.2.174

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
@@ -97,15 +97,28 @@ directory, git branch. Prompts, responses and file contents are never read.
97
97
 
98
98
  ## Desktop notifications
99
99
 
100
- The first time a window crosses a threshold (default `80` and `95`) you get a
101
- desktop notification — once per threshold per window, re-armed when the window
102
- resets. macOS shows it from a small bundled UsageFleet app (allow it once when
100
+ The first time a window crosses a threshold you get a desktop notification —
101
+ once per threshold per window, re-armed when the window resets or usage falls
102
+ 5 points under the last alert. Which thresholds is the alert **mode**:
103
+
104
+ | Mode | Alerts at (% of the window) |
105
+ |------|-----------------------------|
106
+ | `off` | never |
107
+ | `quiet` | 95, 100 |
108
+ | `standard` (default) | 80, 95 |
109
+ | `steady` | 50, 60, 70, 80, 90, 95, 100 |
110
+ | `critical` | 80, 90, 95, 96, 97, 98, 99, then every 0.1 (99.1, 99.2 … 99.9), 100 |
111
+
112
+ Pick one per device on the **Devices** page, or pin it on the machine with
113
+ `USAGEFLEET_NOTIFY_MODE=critical` / `"notifyMode": "critical"` in the config
114
+ file (the local value wins over the Devices page). macOS shows it from a small bundled UsageFleet app (allow it once when
103
115
  macOS asks; a click opens the dashboard), Linux uses `notify-send` (falling back to
104
116
  `kdialog --passivepopup`; install `libnotify-bin` if neither exists), Windows a
105
117
  WinRT toast via `powershell.exe`.
106
118
 
107
- `usagefleet notify-test` confirms it works. Tune with
108
- `USAGEFLEET_NOTIFY_THRESHOLDS="50,80,95"`, disable with `USAGEFLEET_NOTIFY=0`.
119
+ `usagefleet notify-test` confirms it works and prints the thresholds in effect.
120
+ For a list of your own use `USAGEFLEET_NOTIFY_THRESHOLDS="50,80,99.5"` (it
121
+ replaces the mode's list), disable everything with `USAGEFLEET_NOTIFY=0`.
109
122
 
110
123
  ## Blocking prompts over the limit
111
124
 
@@ -165,7 +178,8 @@ it (`usagefleet config` prints this same table):
165
178
  | `interval` | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
166
179
  | `limitsInterval` | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
167
180
  | `notifications` | `USAGEFLEET_NOTIFY` | desktop notifications, on by default (`false`/`0` disables) |
168
- | `notifyThresholds` | `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert — array in the file, comma list in env (default `80,95`) |
181
+ | `notifyMode` | `USAGEFLEET_NOTIFY_MODE` | alert preset: `off`, `quiet`, `standard`, `steady`, `critical` (default: the Devices page value, else `standard`) |
182
+ | `notifyThresholds` | `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert, replacing the mode's list — array in the file, comma list in env, one decimal kept |
169
183
  | `batch` | `USAGEFLEET_BATCH` | records per upload (default 100, server caps at 1000) |
170
184
  | `update` | `USAGEFLEET_UPDATE` | `false`/`0` turns the self-update check off |
171
185
  | `updateInterval` | `USAGEFLEET_UPDATE_INTERVAL` | seconds between update checks (default `21600` = 6h) |
package/dist/collector.js CHANGED
@@ -6,7 +6,7 @@ import { detectClaudeCreds, macKeychainDenied } from './claude-creds.js';
6
6
  import { fetchLimits } from './claude-limits.js';
7
7
  import { ENDPOINT } from './config.js';
8
8
  import { listJcodeSessions, readJcodeSession } from './jcode.js';
9
- import { maybeNotify } from './notifier.js';
9
+ import { loadNotifyConfig, maybeNotify } from './notifier.js';
10
10
  import { detectOs } from './os.js';
11
11
  import { RELEASE_VERSION } from './release.js';
12
12
  import { listJsonlFiles } from './scanner.js';
@@ -461,7 +461,9 @@ async function reportClaudeCodeLimits(cfg, log) {
461
461
  if (groupLimits) {
462
462
  maybeNotify(groupLimits.group === null
463
463
  ? report
464
- : { ...report, fiveHourPct: groupLimits.sessionPct, sevenDayPct: groupLimits.weeklyPct }, undefined, log);
464
+ : { ...report, fiveHourPct: groupLimits.sessionPct, sevenDayPct: groupLimits.weeklyPct },
465
+ // The device's mode from the Devices page rides along on the same answer.
466
+ loadNotifyConfig(undefined, undefined, groupLimits.notifyMode), log);
465
467
  }
466
468
  return { ...report, account };
467
469
  }
package/dist/index.js CHANGED
@@ -166,7 +166,7 @@ async function cmdWatch() {
166
166
  function cmdNotifyTest() {
167
167
  const cfg = loadNotifyConfig();
168
168
  if (!cfg.enabled) {
169
- console.log(warn('notify', 'disabled · unset USAGEFLEET_NOTIFY=0 / `"notifications": false` to enable'));
169
+ console.log(warn('notify', 'disabled · mode off, or USAGEFLEET_NOTIFY=0 / `"notifications": false` · see `usagefleet config`'));
170
170
  return;
171
171
  }
172
172
  sendNotification('usagefleet', 'Test notification — desktop alerts are working.', {
@@ -294,7 +294,8 @@ function cmdConfig() {
294
294
  ],
295
295
  ['batch', 'USAGEFLEET_BATCH', 'records per upload (default 100, max 1000)'],
296
296
  ['notifications', 'USAGEFLEET_NOTIFY', 'desktop notifications (false/0 disables)'],
297
- ['notifyThresholds', 'USAGEFLEET_NOTIFY_THRESHOLDS', '% alerts · array; env comma list (default 80,95)'],
297
+ ['notifyMode', 'USAGEFLEET_NOTIFY_MODE', 'alert preset: off | quiet | standard | steady | critical'],
298
+ ['notifyThresholds', 'USAGEFLEET_NOTIFY_THRESHOLDS', '% alerts · array; env comma list (replaces the preset)'],
298
299
  ['hook', 'USAGEFLEET_HOOK', 'register the prompt guard on install (false/0 skips)'],
299
300
  ['update', 'USAGEFLEET_UPDATE', 'self-update while watching (false/0 disables)'],
300
301
  ['updateInterval', 'USAGEFLEET_UPDATE_INTERVAL', 'seconds between update checks (default 21600)'],
package/dist/notifier.js CHANGED
@@ -1,17 +1,41 @@
1
1
  import { flagOff } from './config.js';
2
2
  import { sendNotification } from './notify.js';
3
3
  import { readStore, storePath, updateStore } from './store.js';
4
- const DEFAULT_THRESHOLDS = [80, 95];
4
+ /** Thresholds per mode. `critical` thins out near the wall on purpose: the last
5
+ * few points are where a window is actually lost, so they get a tenth-of-a-point
6
+ * step. Mirrored by the Devices page (apps/web/src/lib/notify-modes.ts). */
7
+ export const NOTIFY_MODES = {
8
+ critical: [80, 90, 95, 96, 97, 98, 99, 99.1, 99.2, 99.3, 99.4, 99.5, 99.6, 99.7, 99.8, 99.9, 100],
9
+ off: [],
10
+ quiet: [95, 100],
11
+ standard: [80, 95],
12
+ steady: [50, 60, 70, 80, 90, 95, 100],
13
+ };
14
+ const DEFAULT_MODE = 'standard';
15
+ /** Points under the mark pct must fall before the mark is lowered and re-armed. */
16
+ const REARM_MARGIN = 5;
17
+ /** Utilization % from which an alert is urgent (`critical` on Linux). */
18
+ const DANGER_ZONE = 95;
19
+ /** Case-insensitive; anything that is not a mode name is null, so a typo in one
20
+ * layer falls through to the next instead of silencing every alert. */
21
+ export function parseNotifyMode(value) {
22
+ const mode = typeof value === 'string' ? value.trim().toLowerCase() : '';
23
+ return Object.hasOwn(NOTIFY_MODES, mode) ? mode : null;
24
+ }
5
25
  /** Resolve notify config, env first then the config file. Enabled by default;
6
26
  * disable with USAGEFLEET_NOTIFY=0 (also: false/off/no) or `"notifications":
7
- * false`. Thresholds from USAGEFLEET_NOTIFY_THRESHOLDS as a comma list
8
- * (e.g. "50,80,95") or a `notifyThresholds` array. */
9
- export function loadNotifyConfig(env = process.env, file = readStore()) {
10
- const enabled = !flagOff(env.USAGEFLEET_NOTIFY, file.notifications);
27
+ * false`. The preset comes from USAGEFLEET_NOTIFY_MODE, the `notifyMode` file
28
+ * key, then `serverMode` (the one set for this device on the Devices page);
29
+ * an explicit list from USAGEFLEET_NOTIFY_THRESHOLDS (comma list, e.g.
30
+ * "50,80,95") or a `notifyThresholds` array replaces the preset's thresholds. */
31
+ export function loadNotifyConfig(env = process.env, file = readStore(), serverMode = null) {
32
+ const mode = parseNotifyMode(env.USAGEFLEET_NOTIFY_MODE) ?? parseNotifyMode(file.notifyMode) ?? serverMode ?? DEFAULT_MODE;
33
+ const enabled = !flagOff(env.USAGEFLEET_NOTIFY, file.notifications) && mode !== 'off';
11
34
  const raw = env.USAGEFLEET_NOTIFY_THRESHOLDS;
12
35
  const candidates = raw?.trim() ? raw.split(',').map(s => Number(s.trim())) : (file.notifyThresholds ?? []);
13
- const parsed = candidates.map(Math.round).filter(n => Number.isFinite(n) && n > 0 && n <= 100);
14
- const thresholds = parsed.length > 0 ? [...new Set(parsed)].toSorted((a, b) => a - b) : DEFAULT_THRESHOLDS;
36
+ // One decimal, like the readings themselves: 99.1 must stay 99.1.
37
+ const parsed = candidates.map(n => Math.round(n * 10) / 10).filter(n => Number.isFinite(n) && n > 0 && n <= 100);
38
+ const thresholds = parsed.length > 0 ? [...new Set(parsed)].toSorted((a, b) => a - b) : NOTIFY_MODES[mode];
15
39
  return { enabled, thresholds };
16
40
  }
17
41
  /**
@@ -20,8 +44,10 @@ export function loadNotifyConfig(env = process.env, file = readStore()) {
20
44
  *
21
45
  * A window rollover (resetsAt moves) resets the high-water mark first, so the
22
46
  * first crossing in a new window always re-notifies. If utilization later drops
23
- * below the mark within the SAME window (e.g. a server correction), the mark is
24
- * lowered so a subsequent re-cross notifies again.
47
+ * well below the mark within the SAME window (e.g. a server correction), the mark
48
+ * is lowered so a subsequent re-cross notifies again. A small dip does not move
49
+ * it: a group's slice is derived and wobbles between readings, and a mark that
50
+ * followed every wobble would re-alert each time pct straddled a threshold.
25
51
  */
26
52
  export function evaluateWindow(prev, pct, resetsAt, thresholds) {
27
53
  const rolledOver = !prev || !sameWindow(prev.resetsAt, resetsAt);
@@ -30,7 +56,7 @@ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
30
56
  const windowResetsAt = rolledOver ? resetsAt : prev.resetsAt;
31
57
  if (pct == null) {
32
58
  // No reading this cycle — keep the mark, just track the (possibly new) window.
33
- return { fire: null, next: { lastBucket, resetsAt: windowResetsAt } };
59
+ return { fire: null, urgent: false, next: { lastBucket, resetsAt: windowResetsAt } };
34
60
  }
35
61
  // Highest threshold the current pct has reached (thresholds are ascending).
36
62
  let top = 0;
@@ -40,10 +66,15 @@ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
40
66
  }
41
67
  }
42
68
  if (top > lastBucket) {
43
- return { fire: top, next: { lastBucket: top, resetsAt: windowResetsAt } };
69
+ // `critical` is sticky on most Linux daemons, so only entering the danger zone and
70
+ // the wall get it; a dense tail (99.1, 99.2, ...) must not pile up.
71
+ const urgent = (lastBucket < DANGER_ZONE && top >= DANGER_ZONE) || top >= 100;
72
+ return { fire: top, urgent, next: { lastBucket: top, resetsAt: windowResetsAt } };
44
73
  }
45
- // top <= lastBucket: the mark follows pct down (or holds); nothing fires.
46
- return { fire: null, next: { lastBucket: top, resetsAt: windowResetsAt } };
74
+ // top <= lastBucket: nothing fires. The mark holds through a wobble and follows
75
+ // pct down only on a real drop.
76
+ const mark = pct < lastBucket - REARM_MARGIN ? top : lastBucket;
77
+ return { fire: null, urgent: false, next: { lastBucket: mark, resetsAt: windowResetsAt } };
47
78
  }
48
79
  // oauth/usage jitters resets_at by milliseconds per request; a real rollover moves it by hours.
49
80
  function sameWindow(a, b) {
@@ -71,9 +102,6 @@ function resetSuffix(resetsAt) {
71
102
  }
72
103
  return ` · resets in ${Math.round(h / 24)}d`;
73
104
  }
74
- function urgencyFor(bucket) {
75
- return bucket >= 95 ? 'critical' : 'normal';
76
- }
77
105
  /**
78
106
  * Notify on freshly-crossed 5h/weekly thresholds, deduped across runs via the
79
107
  * store's `notify` section. Best-effort and self-contained: it owns its state
@@ -96,11 +124,11 @@ export function maybeNotify(report, cfg = loadNotifyConfig(), log = () => {
96
124
  const fivePct = fmt(report.fiveHourPct ?? 0);
97
125
  const sevenPct = fmt(report.sevenDayPct ?? 0);
98
126
  if (five.fire != null) {
99
- sendNotification('Claude usage · 5-hour limit', `${fivePct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: urgencyFor(five.fire) });
127
+ sendNotification('Claude usage · 5-hour limit', `${fivePct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: five.urgent ? 'critical' : 'normal' });
100
128
  log('ok', `notified · 5h at ${fivePct}% · crossed ${five.fire}%`);
101
129
  }
102
130
  if (seven.fire != null) {
103
- sendNotification('Claude usage · weekly limit', `${sevenPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: urgencyFor(seven.fire) });
131
+ sendNotification('Claude usage · weekly limit', `${sevenPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: seven.urgent ? 'critical' : 'normal' });
104
132
  log('ok', `notified · weekly at ${sevenPct}% · crossed ${seven.fire}%`);
105
133
  }
106
134
  updateStore(path, store => {
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.172";
2
+ export const RELEASE_VERSION = "1.2.174";
package/dist/store.js CHANGED
@@ -61,6 +61,7 @@ function normalize(raw) {
61
61
  fiveHour: { ...freshWindow(), ...raw.notify?.fiveHour },
62
62
  sevenDay: { ...freshWindow(), ...raw.notify?.sevenDay },
63
63
  },
64
+ notifyMode: raw.notifyMode,
64
65
  notifyThresholds: raw.notifyThresholds,
65
66
  piDir: raw.piDir,
66
67
  pjDir: raw.pjDir,
package/dist/update.js CHANGED
@@ -115,7 +115,9 @@ function run(cmd, args) {
115
115
  return new Promise(resolve => {
116
116
  // shell on Windows: node refuses to spawn a .cmd directly since the 2024
117
117
  // argument-injection fix. Every argument here is a literal or VERSION-checked.
118
- const child = spawn(cmd, args, { shell: process.platform === 'win32', stdio: 'ignore', env: pathWithNode() });
118
+ // cmd.exe splits an unquoted `C:\Program Files\nodejs\npm.cmd` at the space.
119
+ const shell = process.platform === 'win32';
120
+ const child = spawn(shell ? `"${cmd}"` : cmd, args, { shell, stdio: 'ignore', env: pathWithNode() });
119
121
  const timer = setTimeout(() => child.kill(), RUN_TIMEOUT_MS);
120
122
  // Node emits 'close' after 'error' for a failed spawn, so both handlers can
121
123
  // run; `settled` makes the first one win. oxlint's promise rule sees the two
package/dist/uploader.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ENDPOINT, tokenFor } from './config.js';
2
+ import { parseNotifyMode } from './notifier.js';
2
3
  const MAX_ATTEMPTS = 6;
3
4
  const REQUEST_TIMEOUT_MS = 15_000;
4
5
  function sleep(ms) {
@@ -115,7 +116,12 @@ export async function fetchGroupLimits(cfg, accountExtId) {
115
116
  if (typeof body?.sessionPct !== 'number' || typeof body.weeklyPct !== 'number') {
116
117
  return null;
117
118
  }
118
- return { group: body.group ?? null, sessionPct: body.sessionPct, weeklyPct: body.weeklyPct };
119
+ return {
120
+ group: body.group ?? null,
121
+ notifyMode: parseNotifyMode(body.notifyMode),
122
+ sessionPct: body.sessionPct,
123
+ weeklyPct: body.weeklyPct,
124
+ };
119
125
  }
120
126
  catch {
121
127
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.172",
3
+ "version": "1.2.174",
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",