@usagefleet/cli 1.2.78 → 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 +23 -9
- package/dist/claude-limits.js +79 -31
- package/dist/guard.js +4 -1
- package/dist/hook.js +17 -4
- package/dist/index.js +18 -8
- package/dist/notifier.js +7 -4
- package/dist/os.js +2 -1
- package/dist/parser.js +23 -13
- package/dist/release.js +1 -1
- package/dist/service.js +17 -8
- package/dist/tailer.js +26 -1
- package/dist/ui.js +3 -2
- package/dist/update.js +23 -3
- package/package.json +1 -1
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`)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
leave the machine — only the percentages
|
|
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.
|
|
@@ -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
|
|
@@ -122,7 +127,7 @@ Env vars override the file:
|
|
|
122
127
|
| `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
|
|
123
128
|
| `USAGEFLEET_PI` | override the pi sessions dirs, comma-separated; `off` to skip |
|
|
124
129
|
| `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
|
|
125
|
-
| `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit
|
|
130
|
+
| `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
|
|
126
131
|
| `USAGEFLEET_NOTIFY` | desktop notifications, on by default |
|
|
127
132
|
| `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert (default `80,95`) |
|
|
128
133
|
| `USAGEFLEET_BATCH` | records per upload (default 100, server caps at 1000) |
|
|
@@ -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.
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
package/dist/claude-limits.js
CHANGED
|
@@ -3,13 +3,7 @@ export function parsePct(v) {
|
|
|
3
3
|
if (v == null || v === '') {
|
|
4
4
|
return null;
|
|
5
5
|
}
|
|
6
|
-
|
|
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) {
|
|
@@ -53,12 +47,12 @@ export function parseLimitsHeaders(source, get, names = []) {
|
|
|
53
47
|
}
|
|
54
48
|
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
55
49
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
60
54
|
*/
|
|
61
|
-
async function
|
|
55
|
+
async function fetchOauthUsage(token) {
|
|
62
56
|
const res = await fetch(OAUTH_USAGE_URL, {
|
|
63
57
|
headers: {
|
|
64
58
|
'anthropic-beta': 'oauth-2025-04-20',
|
|
@@ -68,18 +62,57 @@ async function fetchOauthModelLimits(token) {
|
|
|
68
62
|
signal: AbortSignal.timeout(15_000),
|
|
69
63
|
});
|
|
70
64
|
if (!res.ok) {
|
|
71
|
-
return
|
|
65
|
+
return null;
|
|
72
66
|
}
|
|
73
67
|
const body = await res.json().catch(() => null);
|
|
74
68
|
if (process.env.USAGEFLEET_DEBUG_HEADERS) {
|
|
75
69
|
console.error(`[debug] oauth/usage: ${JSON.stringify(body)}`);
|
|
76
70
|
}
|
|
77
|
-
return
|
|
71
|
+
return body;
|
|
78
72
|
}
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
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
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Both sources report 0–100 percentages: the `-utilization` headers ("37") and
|
|
101
|
+
* oauth/usage's `utilization`/`percent` fields. Clamping is all they need — a
|
|
102
|
+
* 0–1 "fraction form" heuristic would read a real 1% as 100%, which reaches the
|
|
103
|
+
* headline number, the critical notification and guard's prompt block.
|
|
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
|
+
*
|
|
109
|
+
* Non-finite in, null out: `Infinity` (a JSON `1e999`, or a junk header) would
|
|
110
|
+
* otherwise clamp to a perfectly plausible 100 and block every prompt. */
|
|
81
111
|
function clampPct(n) {
|
|
82
|
-
|
|
112
|
+
if (!Number.isFinite(n)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return Math.min(100, Math.max(0, Math.round(n * 10) / 10));
|
|
83
116
|
}
|
|
84
117
|
/** Normalize a scope's model name to a header-safe key ("Fable" → "fable"). */
|
|
85
118
|
function modelKeyOf(name) {
|
|
@@ -151,12 +184,38 @@ export function parseOauthUsage(body) {
|
|
|
151
184
|
}
|
|
152
185
|
return out;
|
|
153
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
|
+
}
|
|
154
197
|
/**
|
|
155
|
-
* Read the account's real rate-limit utilization.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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).
|
|
158
206
|
*/
|
|
159
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
|
+
}
|
|
160
219
|
const headers = {
|
|
161
220
|
'anthropic-version': '2023-06-01',
|
|
162
221
|
'content-type': 'application/json',
|
|
@@ -202,16 +261,5 @@ export async function fetchLimits(creds) {
|
|
|
202
261
|
if (!res.ok && !gotHeaders) {
|
|
203
262
|
throw new Error(`limits unavailable: HTTP ${res.status} with no rate-limit headers`);
|
|
204
263
|
}
|
|
205
|
-
// Subscription logins: merge in the per-model caps from the OAuth usage
|
|
206
|
-
// endpoint (the ping headers never include them). Best-effort — keep the
|
|
207
|
-
// header-derived report on any failure.
|
|
208
|
-
if (creds.source === 'sub' && report.modelLimits.length === 0) {
|
|
209
|
-
try {
|
|
210
|
-
report.modelLimits = await fetchOauthModelLimits(creds.token);
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
/* endpoint unavailable — report account-wide limits only */
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
264
|
return report;
|
|
217
265
|
}
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
@@ -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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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)'],
|
|
@@ -289,11 +295,15 @@ async function main() {
|
|
|
289
295
|
// Log-and-continue for the long-running watch daemon: a stray rejection must
|
|
290
296
|
// not silently kill the background service. One-shot commands still set a
|
|
291
297
|
// non-zero exit via their own error paths.
|
|
298
|
+
//
|
|
299
|
+
// stderr, not `line()`: `usagefleet guard` runs as a UserPromptSubmit hook and
|
|
300
|
+
// Claude Code injects a hook's stdout into the model's context, so a crash
|
|
301
|
+
// notice on stdout would end up in the conversation.
|
|
292
302
|
process.on('unhandledRejection', reason => {
|
|
293
|
-
|
|
303
|
+
console.error(`${yellow('!')} unhandled rejection ${dim(String(reason))}`);
|
|
294
304
|
});
|
|
295
305
|
process.on('uncaughtException', err => {
|
|
296
|
-
|
|
306
|
+
console.error(`${yellow('!')} uncaught exception ${dim(err.message)}`);
|
|
297
307
|
});
|
|
298
308
|
const cmd = process.argv[2] ?? 'help';
|
|
299
309
|
switch (cmd) {
|
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', `${
|
|
93
|
-
log('ok', `notified · 5h at ${
|
|
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', `${
|
|
97
|
-
log('ok', `notified · weekly at ${
|
|
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/os.js
CHANGED
package/dist/parser.js
CHANGED
|
@@ -9,22 +9,24 @@ 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return
|
|
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}}}`.
|
|
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
|
-
|
|
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
|
}
|
|
@@ -45,9 +47,13 @@ function parsePiLine(o) {
|
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
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,
|
|
48
54
|
cacheCreationTokens: u.cacheWrite ?? 0,
|
|
49
55
|
cacheReadTokens: u.cacheRead ?? 0,
|
|
50
|
-
cwd:
|
|
56
|
+
cwd: sessionCwd,
|
|
51
57
|
gitBranch: null,
|
|
52
58
|
inputTokens: u.input ?? 0,
|
|
53
59
|
messageId: rid,
|
|
@@ -68,8 +74,9 @@ function parsePiLine(o) {
|
|
|
68
74
|
* `uuid` is the per-line idempotency key the server dedups on. `source` tags which
|
|
69
75
|
* app the file came from (the line itself carries no app identifier) and selects
|
|
70
76
|
* the format: `pi` files use pi's own schema, everything else Claude Code's.
|
|
77
|
+
* `sessionCwd` is the file-level working directory, used only by `pi`.
|
|
71
78
|
*/
|
|
72
|
-
export function parseLine(line, source = 'cli') {
|
|
79
|
+
export function parseLine(line, source = 'cli', sessionCwd = null) {
|
|
73
80
|
const trimmed = line.trim();
|
|
74
81
|
if (!trimmed) {
|
|
75
82
|
return null;
|
|
@@ -92,7 +99,7 @@ export function parseLine(line, source = 'cli') {
|
|
|
92
99
|
}
|
|
93
100
|
const o = parsed;
|
|
94
101
|
if (source === 'pi') {
|
|
95
|
-
return parsePiLine(o);
|
|
102
|
+
return parsePiLine(o, sessionCwd);
|
|
96
103
|
}
|
|
97
104
|
if (o.type !== 'assistant') {
|
|
98
105
|
return null;
|
|
@@ -106,8 +113,11 @@ export function parseLine(line, source = 'cli') {
|
|
|
106
113
|
return null;
|
|
107
114
|
}
|
|
108
115
|
const u = message.usage;
|
|
116
|
+
const cache = cacheCreation(u);
|
|
109
117
|
return {
|
|
110
|
-
|
|
118
|
+
cacheCreation1h: cache.oneHour,
|
|
119
|
+
cacheCreation5m: cache.five,
|
|
120
|
+
cacheCreationTokens: cache.total,
|
|
111
121
|
cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
112
122
|
cwd: str(o.cwd),
|
|
113
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.
|
|
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
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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/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/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
|
|
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/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
|
-
/**
|
|
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
|
-
|
|
29
|
-
|
|
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
|
/**
|