@usagefleet/cli 1.2.150 → 1.2.152

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.
@@ -32,3 +32,28 @@ export function detectClaudeAccount(path = claudeStatePath()) {
32
32
  return null;
33
33
  }
34
34
  }
35
+ const OAUTH_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
36
+ /** The accountUuid Anthropic says owns this OAuth token, or null when it cannot
37
+ * say. Null fails open to the file's identity: the endpoint is undocumented, and
38
+ * losing it must not stop every collector from reporting limits. */
39
+ export async function tokenOwner(token) {
40
+ try {
41
+ const res = await fetch(OAUTH_PROFILE_URL, {
42
+ headers: {
43
+ 'anthropic-beta': 'oauth-2025-04-20',
44
+ authorization: `Bearer ${token}`,
45
+ 'user-agent': 'claude-code/2.1.5 (usagefleet)',
46
+ },
47
+ signal: AbortSignal.timeout(15_000),
48
+ });
49
+ if (!res.ok) {
50
+ return null;
51
+ }
52
+ const body = (await res.json());
53
+ const uuid = body?.account?.uuid;
54
+ return typeof uuid === 'string' && uuid !== '' ? uuid.slice(0, 100) : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
package/dist/collector.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { sep } from 'node:path';
4
- import { detectClaudeAccount } from './claude-account.js';
4
+ import { detectClaudeAccount, tokenOwner } from './claude-account.js';
5
5
  import { detectClaudeCreds, macKeychainDenied } from './claude-creds.js';
6
6
  import { fetchLimits } from './claude-limits.js';
7
7
  import { ENDPOINT } from './config.js';
@@ -75,7 +75,7 @@ export async function runOnce(cfg, log = () => {
75
75
  if (!tail || tail.consumedBytes === 0) {
76
76
  continue;
77
77
  }
78
- if (tail.records.length === 0) {
78
+ if (tail.segments.length === 0) {
79
79
  // Consumed only non-usage lines — safe to advance immediately.
80
80
  state.files[fp] = tail.nextState;
81
81
  advanced = true;
@@ -83,8 +83,8 @@ export async function runOnce(cfg, log = () => {
83
83
  }
84
84
  // sendChunk absorbs "invalid" by bisecting, so only auth/plan/transient escape.
85
85
  let outcome = 'ok';
86
- for (let i = 0; i < tail.records.length; i += step) {
87
- outcome = await sendChunk(tail.records.slice(i, i + step), cfg, result, log);
86
+ for (const chunk of chunks(tail.segments, step)) {
87
+ outcome = await sendChunk(chunk.records, chunk.accountExtId, cfg, result, log);
88
88
  if (outcome !== 'ok') {
89
89
  break;
90
90
  }
@@ -151,9 +151,9 @@ export async function runOnce(cfg, log = () => {
151
151
  * anything that survives that many splits is not record-shaped.
152
152
  */
153
153
  const MAX_DROPPED_PER_CHUNK = 2;
154
- async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped + MAX_DROPPED_PER_CHUNK) {
154
+ async function sendChunk(records, accountExtId, cfg, result, log, dropCeiling = result.dropped + MAX_DROPPED_PER_CHUNK) {
155
155
  const res = await uploadBatch({
156
- accountExtId: detectClaudeAccount()?.extId,
156
+ accountExtId: accountExtId ?? detectClaudeAccount()?.extId,
157
157
  collectorVersion: RELEASE_VERSION,
158
158
  hostname: hostname(),
159
159
  os: detectOs(),
@@ -185,8 +185,18 @@ async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped
185
185
  return 'ok';
186
186
  }
187
187
  const mid = Math.ceil(records.length / 2);
188
- const head = await sendChunk(records.slice(0, mid), cfg, result, log, dropCeiling);
189
- return head === 'ok' ? sendChunk(records.slice(mid), cfg, result, log, dropCeiling) : head;
188
+ const head = await sendChunk(records.slice(0, mid), accountExtId, cfg, result, log, dropCeiling);
189
+ return head === 'ok' ? sendChunk(records.slice(mid), accountExtId, cfg, result, log, dropCeiling) : head;
190
+ }
191
+ /** Upload-sized slices of each segment; a chunk never spans two accounts. */
192
+ function chunks(segments, step) {
193
+ const out = [];
194
+ for (const { accountExtId, records } of segments) {
195
+ for (let i = 0; i < records.length; i += step) {
196
+ out.push({ accountExtId, records: records.slice(i, i + step) });
197
+ }
198
+ }
199
+ return out;
190
200
  }
191
201
  /**
192
202
  * Drop offsets for logs that no longer exist on disk, so a long-lived install
@@ -251,6 +261,13 @@ export async function reportLimitsOnce(cfg, log = () => {
251
261
  // two subscriptions gets two independent budgets instead of one row both
252
262
  // machines overwrite. Subscription logins only — an API key has no account.
253
263
  const account = report.source === 'sub' ? detectClaudeAccount() : null;
264
+ // Mid-/login the token and ~/.claude.json name two accounts; see docs/collector.md.
265
+ // ponytail: one profile call per limits cycle, cache it per token if Anthropic starts 429ing.
266
+ const owner = account ? await tokenOwner(creds.token) : null;
267
+ if (account && owner !== null && owner !== account.extId) {
268
+ log('warn', 'limits skipped · claude login is mid-switch between accounts · retrying next cycle');
269
+ return null;
270
+ }
254
271
  const outcome = await postLimits({ ...report, account }, cfg);
255
272
  if (outcome === 'plan') {
256
273
  log('warn', planWall());
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.150";
2
+ export const RELEASE_VERSION = "1.2.152";
package/dist/tailer.js CHANGED
@@ -5,6 +5,8 @@ import { dim, line, tilde, yellow } from './ui.js';
5
5
  const MAX_READ = 16 * 1024 * 1024;
6
6
  // Both parsers need a `usage` object, and about 3/4 of log bytes are lines without one.
7
7
  const USAGE_KEY = Buffer.from('"usage"');
8
+ // Unescaped quotes: the same text quoted inside a message is `\"customType\"` and never matches.
9
+ const ACCOUNT_KEY = Buffer.from('"customType":"claude-account"');
8
10
  /**
9
11
  * The working directory of a pi session, read from the file's first line
10
12
  * (`{"type":"session",…,"cwd":"/path"}`). pi's message lines carry no cwd, and
@@ -26,6 +28,18 @@ function piSessionCwd(fd) {
26
28
  return null;
27
29
  }
28
30
  }
31
+ /** The account a pi session pinned itself to, from the dotfiles claude-accounts extension's
32
+ * `{"type":"custom","customType":"claude-account","data":{"email","uuid"}}` line. Every
33
+ * later line in the file ran on it, until the next such line. */
34
+ function piSessionAccount(line) {
35
+ try {
36
+ const uuid = JSON.parse(line)?.data?.uuid;
37
+ return typeof uuid === 'string' && uuid !== '' ? uuid.slice(0, 100) : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
29
43
  /**
30
44
  * Read new, newline-terminated lines from `filePath` starting at the previously
31
45
  * stored offset. Handles rotation/truncation (inode change or size < offset →
@@ -41,9 +55,10 @@ export function tailFile(filePath, prev, source = 'cli') {
41
55
  }
42
56
  const rotated = prev !== undefined && (st.ino !== prev.inode || st.size < prev.offset);
43
57
  const start = rotated || prev === undefined ? 0 : prev.offset;
44
- const base = { inode: Number(st.ino), offset: start };
58
+ const startAccount = rotated ? undefined : prev?.accountExtId;
59
+ const base = { accountExtId: startAccount, inode: Number(st.ino), offset: start };
45
60
  if (st.size <= start) {
46
- return { consumedBytes: 0, nextState: base, records: [] };
61
+ return { consumedBytes: 0, nextState: base, segments: [] };
47
62
  }
48
63
  // Cap the per-cycle read so a huge backlog can't OOM; the next cycle resumes
49
64
  // from the committed offset.
@@ -70,29 +85,53 @@ export function tailFile(filePath, prev, source = 'cli') {
70
85
  return {
71
86
  consumedBytes: length,
72
87
  nextState: { ...base, offset: start + length },
73
- records: [],
88
+ segments: [],
74
89
  };
75
90
  }
76
- return { consumedBytes: 0, nextState: base, records: [] };
91
+ return { consumedBytes: 0, nextState: base, segments: [] };
77
92
  }
78
93
  const consumed = buf.subarray(0, lastNl + 1);
79
- // Decode only the lines that can hold usage: decoding and splitting the whole
80
- // 16 MB read peaked a first-run backlog at ~490 MB RSS.
81
- const records = [];
82
- let at = consumed.indexOf(USAGE_KEY);
83
- while (at !== -1) {
94
+ // Decode only the lines that can hold usage or a pi account switch: decoding and
95
+ // splitting the whole 16 MB read peaked a first-run backlog at ~490 MB RSS.
96
+ const segments = [];
97
+ let account = startAccount;
98
+ let usageAt = consumed.indexOf(USAGE_KEY);
99
+ let accountAt = source === 'pi' ? consumed.indexOf(ACCOUNT_KEY) : -1;
100
+ while (usageAt !== -1 || accountAt !== -1) {
101
+ const isAccountLine = accountAt !== -1 && (usageAt === -1 || accountAt < usageAt);
102
+ const at = isAccountLine ? accountAt : usageAt;
84
103
  const lineStart = consumed.lastIndexOf(0x0a, at) + 1;
85
104
  const lineEnd = consumed.indexOf(0x0a, at);
86
- const rec = parseLine(consumed.toString('utf-8', lineStart, lineEnd), source, sessionCwd);
87
- if (rec) {
88
- records.push(rec);
105
+ const text = consumed.toString('utf-8', lineStart, lineEnd);
106
+ if (isAccountLine) {
107
+ account = piSessionAccount(text);
108
+ }
109
+ else {
110
+ const rec = parseLine(text, source, sessionCwd);
111
+ if (rec) {
112
+ addRecord(segments, account, rec);
113
+ }
114
+ }
115
+ if (usageAt !== -1 && usageAt < lineEnd) {
116
+ usageAt = consumed.indexOf(USAGE_KEY, lineEnd);
117
+ }
118
+ if (accountAt !== -1 && accountAt < lineEnd) {
119
+ accountAt = consumed.indexOf(ACCOUNT_KEY, lineEnd);
89
120
  }
90
- at = consumed.indexOf(USAGE_KEY, lineEnd);
91
121
  }
92
122
  const consumedBytes = consumed.length;
93
123
  return {
94
124
  consumedBytes,
95
- nextState: { ...base, offset: start + consumedBytes },
96
- records,
125
+ nextState: { accountExtId: account, inode: base.inode, offset: start + consumedBytes },
126
+ segments,
97
127
  };
98
128
  }
129
+ function addRecord(segments, accountExtId, record) {
130
+ const last = segments.at(-1);
131
+ if (last && last.accountExtId === accountExtId) {
132
+ last.records.push(record);
133
+ }
134
+ else {
135
+ segments.push({ accountExtId, records: [record] });
136
+ }
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.150",
3
+ "version": "1.2.152",
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",