@usagefleet/cli 1.2.78 → 1.2.79

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
@@ -101,6 +101,11 @@ on re-install, and skipped entirely with `USAGEFLEET_HOOK=0`):
101
101
  }
102
102
  ```
103
103
 
104
+ If `USAGEFLEET_CONFIG` was set when you logged in, the installed command carries
105
+ it (`USAGEFLEET_CONFIG=<path> usagefleet guard`, or `set "..." && ...` on
106
+ Windows). That is what binds the hook to the right store, and so to the right
107
+ Anthropic account — see "Two Claude accounts on one machine" below.
108
+
104
109
  `guard` **fails open** everywhere else — no config, server down, timeout (5s),
105
110
  old server, 429 — because a tracker problem must never stop you working. Only
106
111
  whole prompts are blocked, never tool calls mid-turn, so the current turn always
@@ -149,9 +154,18 @@ CLAUDE_CONFIG_DIR=~/.claude-work \
149
154
  usagefleet login uf_...
150
155
  ```
151
156
 
152
- Each reports its own account, and the dashboard keeps their limits apart. With a
153
- relocated config dir the macOS Keychain is skipped on purpose: that item belongs
154
- to the default login.
157
+ Each reports its own account, and the dashboard keeps their limits apart. The
158
+ prompt guard follows: because `USAGEFLEET_CONFIG` was set for this `login`, the
159
+ hook written into `~/.claude-work/settings.json` carries it, so that account's
160
+ guard reads that account's token and blocks against the right subscription. With
161
+ a relocated config dir the macOS Keychain is skipped on purpose: that item
162
+ belongs to the default login.
163
+
164
+ **Only one of them can run as the background service.** The launchd label,
165
+ systemd unit and Scheduled Task name are fixed, so the second `login` rewrites
166
+ the first one's service definition rather than adding a second. Run the extra
167
+ collector yourself with the same env — `... usagefleet watch`, under your own
168
+ unit or terminal — and keep `login` for the account you want supervised.
155
169
 
156
170
  ## Background service
157
171
 
@@ -3,13 +3,7 @@ export function parsePct(v) {
3
3
  if (v == null || v === '') {
4
4
  return null;
5
5
  }
6
- const n = Number(v);
7
- if (!Number.isFinite(n)) {
8
- return null;
9
- }
10
- // Header is a percent (e.g. "37"); guard the 0–1 fraction form too.
11
- const pct = n > 0 && n <= 1 ? n * 100 : n;
12
- return Math.min(100, Math.max(0, Math.round(pct)));
6
+ return clampPct(Number(v));
13
7
  }
14
8
  export function parseReset(v) {
15
9
  if (!v) {
@@ -76,9 +70,17 @@ async function fetchOauthModelLimits(token) {
76
70
  }
77
71
  return parseOauthUsage(body);
78
72
  }
79
- /** oauth/usage values are already 0–100 percentages (unlike the 0–1 header
80
- * fractions) clamp without the fraction heuristic so 1% never reads as 100%. */
73
+ /** Both sources report 0–100 percentages: the `-utilization` headers ("37") and
74
+ * oauth/usage's `utilization`/`percent` fields. Clamping is all they need a
75
+ * 0–1 "fraction form" heuristic would read a real 1% as 100%, which reaches the
76
+ * headline number, the critical notification and guard's prompt block.
77
+ *
78
+ * Non-finite in, null out: `Infinity` (a JSON `1e999`, or a junk header) would
79
+ * otherwise clamp to a perfectly plausible 100 and block every prompt. */
81
80
  function clampPct(n) {
81
+ if (!Number.isFinite(n)) {
82
+ return null;
83
+ }
82
84
  return Math.min(100, Math.max(0, Math.round(n)));
83
85
  }
84
86
  /** Normalize a scope's model name to a header-safe key ("Fable" → "fable"). */
package/dist/guard.js CHANGED
@@ -50,7 +50,10 @@ export async function runGuard() {
50
50
  catch {
51
51
  return 0; // offline / timeout / bad JSON — fail open
52
52
  }
53
- const msg = blockMessage(view);
53
+ // A 200 whose body is literally `null` has to fail open like every other
54
+ // unexpected shape. Reading `.blocked` off it here would throw past the
55
+ // try above and out of runGuard, which is the one thing this must never do.
56
+ const msg = blockMessage(view ?? {});
54
57
  if (!msg) {
55
58
  return 0;
56
59
  }
package/dist/hook.js CHANGED
@@ -9,9 +9,22 @@ const HOOK_TIMEOUT_S = 10;
9
9
  /** Recognises a guard hook we installed (at any binary path, from any version)
10
10
  * so install is idempotent and uninstall is precise. */
11
11
  const GUARD_COMMAND = /usagefleet.*\bguard\b/;
12
- /** `/path/to/usagefleet guard`, quoted for the shell Claude Code runs it in. */
13
- export function guardCommand(program) {
14
- return program.map(p => (p.includes(' ') ? `"${p}"` : p)).join(' ');
12
+ /** `/path/to/usagefleet guard`, quoted for the shell Claude Code runs it in.
13
+ * Carries USAGEFLEET_CONFIG through when set: the documented two-subscription
14
+ * setup (apps/cli/README.md) gives each account its own store *and* its own
15
+ * Claude settings.json, so without it both hooks would read the default config
16
+ * and block against the wrong account. `platform` is a parameter so the two
17
+ * shells can be tested; cmd.exe has no inline `VAR=value cmd` form, and the
18
+ * POSIX prefix there would be read as a program name and never launch. */
19
+ export function guardCommand(program, configPath, platform = process.platform) {
20
+ const quote = (p) => (p.includes(' ') ? `"${p}"` : p);
21
+ const command = program.map(quote).join(' ');
22
+ if (!configPath) {
23
+ return command;
24
+ }
25
+ return platform === 'win32'
26
+ ? `set "USAGEFLEET_CONFIG=${configPath}" && ${command}`
27
+ : `USAGEFLEET_CONFIG=${quote(configPath)} ${command}`;
15
28
  }
16
29
  /** Drop every guard hook we ever installed, leaving the rest of the file alone. */
17
30
  export function withoutGuardHook(settings) {
@@ -96,7 +109,7 @@ export function installPromptHook(program) {
96
109
  if (process.env.USAGEFLEET_HOOK === '0') {
97
110
  return;
98
111
  }
99
- const command = guardCommand(program);
112
+ const command = guardCommand(program, process.env.USAGEFLEET_CONFIG);
100
113
  editSettings(s => withGuardHook(s, command), path => console.log(step('hook', `prompt guard · ${tilde(path)}`)));
101
114
  }
102
115
  export function uninstallPromptHook() {
package/dist/index.js CHANGED
@@ -289,11 +289,15 @@ async function main() {
289
289
  // Log-and-continue for the long-running watch daemon: a stray rejection must
290
290
  // not silently kill the background service. One-shot commands still set a
291
291
  // non-zero exit via their own error paths.
292
+ //
293
+ // stderr, not `line()`: `usagefleet guard` runs as a UserPromptSubmit hook and
294
+ // Claude Code injects a hook's stdout into the model's context, so a crash
295
+ // notice on stdout would end up in the conversation.
292
296
  process.on('unhandledRejection', reason => {
293
- line(yellow('!'), `unhandled rejection ${dim(String(reason))}`);
297
+ console.error(`${yellow('!')} unhandled rejection ${dim(String(reason))}`);
294
298
  });
295
299
  process.on('uncaughtException', err => {
296
- line(yellow('!'), `uncaught exception ${dim(err.message)}`);
300
+ console.error(`${yellow('!')} uncaught exception ${dim(err.message)}`);
297
301
  });
298
302
  const cmd = process.argv[2] ?? 'help';
299
303
  switch (cmd) {
package/dist/os.js CHANGED
@@ -10,7 +10,8 @@ export function detectOs() {
10
10
  return 'linux';
11
11
  }
12
12
  default: {
13
- return process.platform;
13
+ // freebsd/sunos/android: still a usable collector, just an unlabelled box.
14
+ return 'other';
14
15
  }
15
16
  }
16
17
  }
package/dist/parser.js CHANGED
@@ -23,8 +23,10 @@ function cacheCreation(u) {
23
23
  * provider, model, responseId, usage:{input, output, cacheRead, cacheWrite}}}`.
24
24
  * Only provider "anthropic" hits the user's Claude account — other providers
25
25
  * (openai-codex, openrouter, …) are skipped. `output` already includes reasoning
26
- * tokens (totalTokens = input + output + cacheRead + cacheWrite). */
27
- function parsePiLine(o) {
26
+ * tokens (totalTokens = input + output + cacheRead + cacheWrite).
27
+ * The line carries no working directory; `sessionCwd` comes from the file's
28
+ * session header (see piSessionCwd). */
29
+ function parsePiLine(o, sessionCwd) {
28
30
  if (o.type !== 'message') {
29
31
  return null;
30
32
  }
@@ -47,7 +49,7 @@ function parsePiLine(o) {
47
49
  return {
48
50
  cacheCreationTokens: u.cacheWrite ?? 0,
49
51
  cacheReadTokens: u.cacheRead ?? 0,
50
- cwd: null,
52
+ cwd: sessionCwd,
51
53
  gitBranch: null,
52
54
  inputTokens: u.input ?? 0,
53
55
  messageId: rid,
@@ -68,8 +70,9 @@ function parsePiLine(o) {
68
70
  * `uuid` is the per-line idempotency key the server dedups on. `source` tags which
69
71
  * app the file came from (the line itself carries no app identifier) and selects
70
72
  * the format: `pi` files use pi's own schema, everything else Claude Code's.
73
+ * `sessionCwd` is the file-level working directory, used only by `pi`.
71
74
  */
72
- export function parseLine(line, source = 'cli') {
75
+ export function parseLine(line, source = 'cli', sessionCwd = null) {
73
76
  const trimmed = line.trim();
74
77
  if (!trimmed) {
75
78
  return null;
@@ -92,7 +95,7 @@ export function parseLine(line, source = 'cli') {
92
95
  }
93
96
  const o = parsed;
94
97
  if (source === 'pi') {
95
- return parsePiLine(o);
98
+ return parsePiLine(o, sessionCwd);
96
99
  }
97
100
  if (o.type !== 'assistant') {
98
101
  return null;
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.78";
2
+ export const RELEASE_VERSION = "1.2.79";
package/dist/tailer.js CHANGED
@@ -3,6 +3,27 @@ import { parseLine } from './parser.js';
3
3
  import { dim, line, tilde, yellow } from './ui.js';
4
4
  /** Max bytes read from a single file per cycle (bounds memory on huge backlogs). */
5
5
  const MAX_READ = 16 * 1024 * 1024;
6
+ /**
7
+ * The working directory of a pi session, read from the file's first line
8
+ * (`{"type":"session",…,"cwd":"/path"}`). pi's message lines carry no cwd, and
9
+ * tailing usually resumes past the header, so it is re-read per cycle rather
10
+ * than remembered. The session dir name encodes the same path but lossily
11
+ * (`/Developer/claude-track` and `/Developer/claude/track` collide), so the
12
+ * header is the only exact source.
13
+ */
14
+ function piSessionCwd(fd) {
15
+ const head = Buffer.alloc(4096);
16
+ const read = readSync(fd, head, 0, head.length, 0);
17
+ const nl = head.indexOf(0x0a);
18
+ try {
19
+ const o = JSON.parse(head.subarray(0, nl === -1 ? read : nl).toString('utf-8'));
20
+ const cwd = o?.cwd;
21
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
6
27
  /**
7
28
  * Read new, newline-terminated lines from `filePath` starting at the previously
8
29
  * stored offset. Handles rotation/truncation (inode change or size < offset →
@@ -27,8 +48,12 @@ export function tailFile(filePath, prev, source = 'cli') {
27
48
  const length = Math.min(st.size - start, MAX_READ);
28
49
  const buf = Buffer.alloc(length);
29
50
  const fd = openSync(filePath, 'r');
51
+ let sessionCwd = null;
30
52
  try {
31
53
  readSync(fd, buf, 0, length, start);
54
+ if (source === 'pi') {
55
+ sessionCwd = piSessionCwd(fd);
56
+ }
32
57
  }
33
58
  finally {
34
59
  closeSync(fd);
@@ -52,7 +77,7 @@ export function tailFile(filePath, prev, source = 'cli') {
52
77
  const text = consumed.toString('utf-8');
53
78
  const records = [];
54
79
  for (const line of text.split('\n')) {
55
- const rec = parseLine(line, source);
80
+ const rec = parseLine(line, source, sessionCwd);
56
81
  if (rec) {
57
82
  records.push(rec);
58
83
  }
package/dist/update.js CHANGED
@@ -19,14 +19,34 @@ function npmCommand() {
19
19
  const sibling = join(dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
20
20
  return existsSync(sibling) ? sibling : 'npm';
21
21
  }
22
- /** Exit code of a finished child, or null when it could not be started. */
22
+ /** An `npm install` that never returns would hang the watch loop forever, since
23
+ * the update check is awaited inline before the next tick is scheduled: the
24
+ * daemon would still be "running" while collecting nothing, with an empty log.
25
+ * Generous enough for a slow registry on a cold cache. */
26
+ const RUN_TIMEOUT_MS = 10 * 60_000;
27
+ /** Exit code of a finished child, or null when it could not be started, was
28
+ * killed for exceeding RUN_TIMEOUT_MS, or died on a signal. */
23
29
  function run(cmd, args) {
24
30
  return new Promise(resolve => {
25
31
  // shell on Windows: node refuses to spawn a .cmd directly since the 2024
26
32
  // argument-injection fix. Every argument here is a literal or VERSION-checked.
27
33
  const child = spawn(cmd, args, { shell: process.platform === 'win32', stdio: 'ignore' });
28
- child.on('error', () => resolve(null));
29
- child.on('close', code => resolve(code));
34
+ const timer = setTimeout(() => child.kill(), RUN_TIMEOUT_MS);
35
+ // Node emits 'close' after 'error' for a failed spawn, so both handlers can
36
+ // run; `settled` makes the first one win. oxlint's promise rule sees the two
37
+ // registrations and can't see the guard between them.
38
+ let settled = false;
39
+ const finish = (code) => {
40
+ if (settled) {
41
+ return;
42
+ }
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ // oxlint-disable-next-line promise/no-multiple-resolved
46
+ resolve(code);
47
+ };
48
+ child.on('error', () => finish(null));
49
+ child.on('close', code => finish(code));
30
50
  });
31
51
  }
32
52
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.78",
3
+ "version": "1.2.79",
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",