@usagefleet/cli 1.2.151 → 1.2.153
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 +2 -1
- package/dist/UsageFleet.app/Contents/Info.plist +26 -0
- package/dist/UsageFleet.app/Contents/MacOS/usagefleet-notifier +0 -0
- package/dist/UsageFleet.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/UsageFleet.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/collector.js +36 -13
- package/dist/index.js +2 -1
- package/dist/notifier.js +14 -5
- package/dist/notify.js +38 -2
- package/dist/paths.js +20 -1
- package/dist/release.js +1 -1
- package/dist/scanner.js +42 -8
- package/dist/service.js +7 -2
- package/dist/store.js +7 -1
- package/dist/tailer.js +54 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,8 @@ directory, git branch. Prompts, responses and file contents are never read.
|
|
|
97
97
|
|
|
98
98
|
The first time a window crosses a threshold (default `80` and `95`) you get a
|
|
99
99
|
desktop notification — once per threshold per window, re-armed when the window
|
|
100
|
-
resets. macOS
|
|
100
|
+
resets. macOS shows it from a small bundled UsageFleet app (allow it once when
|
|
101
|
+
macOS asks; a click opens the dashboard), Linux uses `notify-send` (falling back to
|
|
101
102
|
`kdialog --passivepopup`; install `libnotify-bin` if neither exists), Windows a
|
|
102
103
|
WinRT toast via `powershell.exe`.
|
|
103
104
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>dev.usagefleet.notifier</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>UsageFleet</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>UsageFleet</string>
|
|
11
|
+
<key>CFBundleExecutable</key>
|
|
12
|
+
<string>usagefleet-notifier</string>
|
|
13
|
+
<key>CFBundleIconFile</key>
|
|
14
|
+
<string>AppIcon</string>
|
|
15
|
+
<key>CFBundlePackageType</key>
|
|
16
|
+
<string>APPL</string>
|
|
17
|
+
<key>CFBundleShortVersionString</key>
|
|
18
|
+
<string>1.0</string>
|
|
19
|
+
<key>CFBundleVersion</key>
|
|
20
|
+
<string>1</string>
|
|
21
|
+
<key>LSMinimumSystemVersion</key>
|
|
22
|
+
<string>11.0</string>
|
|
23
|
+
<key>LSUIElement</key>
|
|
24
|
+
<true/>
|
|
25
|
+
</dict>
|
|
26
|
+
</plist>
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
Qsu2PdOnquUAUG4LLkSUe3XpdqA=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
12
|
+
<key>files2</key>
|
|
13
|
+
<dict>
|
|
14
|
+
<key>Resources/AppIcon.icns</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>hash2</key>
|
|
17
|
+
<data>
|
|
18
|
+
yxmneUbIT7WvXoxaR+DXAKCF/pbzjexThOZ9bg8UK3E=
|
|
19
|
+
</data>
|
|
20
|
+
</dict>
|
|
21
|
+
</dict>
|
|
22
|
+
<key>rules</key>
|
|
23
|
+
<dict>
|
|
24
|
+
<key>^Resources/</key>
|
|
25
|
+
<true/>
|
|
26
|
+
<key>^Resources/.*\.lproj/</key>
|
|
27
|
+
<dict>
|
|
28
|
+
<key>optional</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>weight</key>
|
|
31
|
+
<real>1000</real>
|
|
32
|
+
</dict>
|
|
33
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
34
|
+
<dict>
|
|
35
|
+
<key>omit</key>
|
|
36
|
+
<true/>
|
|
37
|
+
<key>weight</key>
|
|
38
|
+
<real>1100</real>
|
|
39
|
+
</dict>
|
|
40
|
+
<key>^Resources/Base\.lproj/</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>weight</key>
|
|
43
|
+
<real>1010</real>
|
|
44
|
+
</dict>
|
|
45
|
+
<key>^version.plist$</key>
|
|
46
|
+
<true/>
|
|
47
|
+
</dict>
|
|
48
|
+
<key>rules2</key>
|
|
49
|
+
<dict>
|
|
50
|
+
<key>.*\.dSYM($|/)</key>
|
|
51
|
+
<dict>
|
|
52
|
+
<key>weight</key>
|
|
53
|
+
<real>11</real>
|
|
54
|
+
</dict>
|
|
55
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
56
|
+
<dict>
|
|
57
|
+
<key>omit</key>
|
|
58
|
+
<true/>
|
|
59
|
+
<key>weight</key>
|
|
60
|
+
<real>2000</real>
|
|
61
|
+
</dict>
|
|
62
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
63
|
+
<dict>
|
|
64
|
+
<key>nested</key>
|
|
65
|
+
<true/>
|
|
66
|
+
<key>weight</key>
|
|
67
|
+
<real>10</real>
|
|
68
|
+
</dict>
|
|
69
|
+
<key>^.*</key>
|
|
70
|
+
<true/>
|
|
71
|
+
<key>^Info\.plist$</key>
|
|
72
|
+
<dict>
|
|
73
|
+
<key>omit</key>
|
|
74
|
+
<true/>
|
|
75
|
+
<key>weight</key>
|
|
76
|
+
<real>20</real>
|
|
77
|
+
</dict>
|
|
78
|
+
<key>^PkgInfo$</key>
|
|
79
|
+
<dict>
|
|
80
|
+
<key>omit</key>
|
|
81
|
+
<true/>
|
|
82
|
+
<key>weight</key>
|
|
83
|
+
<real>20</real>
|
|
84
|
+
</dict>
|
|
85
|
+
<key>^Resources/</key>
|
|
86
|
+
<dict>
|
|
87
|
+
<key>weight</key>
|
|
88
|
+
<real>20</real>
|
|
89
|
+
</dict>
|
|
90
|
+
<key>^Resources/.*\.lproj/</key>
|
|
91
|
+
<dict>
|
|
92
|
+
<key>optional</key>
|
|
93
|
+
<true/>
|
|
94
|
+
<key>weight</key>
|
|
95
|
+
<real>1000</real>
|
|
96
|
+
</dict>
|
|
97
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
98
|
+
<dict>
|
|
99
|
+
<key>omit</key>
|
|
100
|
+
<true/>
|
|
101
|
+
<key>weight</key>
|
|
102
|
+
<real>1100</real>
|
|
103
|
+
</dict>
|
|
104
|
+
<key>^Resources/Base\.lproj/</key>
|
|
105
|
+
<dict>
|
|
106
|
+
<key>weight</key>
|
|
107
|
+
<real>1010</real>
|
|
108
|
+
</dict>
|
|
109
|
+
<key>^[^/]+$</key>
|
|
110
|
+
<dict>
|
|
111
|
+
<key>nested</key>
|
|
112
|
+
<true/>
|
|
113
|
+
<key>weight</key>
|
|
114
|
+
<real>10</real>
|
|
115
|
+
</dict>
|
|
116
|
+
<key>^embedded\.provisionprofile$</key>
|
|
117
|
+
<dict>
|
|
118
|
+
<key>weight</key>
|
|
119
|
+
<real>20</real>
|
|
120
|
+
</dict>
|
|
121
|
+
<key>^version\.plist$</key>
|
|
122
|
+
<dict>
|
|
123
|
+
<key>weight</key>
|
|
124
|
+
<real>20</real>
|
|
125
|
+
</dict>
|
|
126
|
+
</dict>
|
|
127
|
+
</dict>
|
|
128
|
+
</plist>
|
package/dist/collector.js
CHANGED
|
@@ -18,6 +18,8 @@ import { fetchGroupLimits, postLimits, uploadBatch } from './uploader.js';
|
|
|
18
18
|
* uuids) and other JSONL — restricting to this subtree mirrors Claude Code and
|
|
19
19
|
* avoids re-uploading every desktop record twice. */
|
|
20
20
|
const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
|
|
21
|
+
/** `status` shows the cached reading's age, so it stays within this of the last check. */
|
|
22
|
+
const LIMITS_CACHE_REFRESH_MS = 10 * 60_000;
|
|
21
23
|
/**
|
|
22
24
|
* One full scan: tail every JSONL file from its stored offset, upload new usage
|
|
23
25
|
* records (chunked to batchSize), and commit each file's offset only after all
|
|
@@ -25,7 +27,7 @@ const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
|
|
|
25
27
|
*/
|
|
26
28
|
export async function runOnce(cfg, log = () => {
|
|
27
29
|
/* empty */
|
|
28
|
-
}) {
|
|
30
|
+
}, dirCache = new Map()) {
|
|
29
31
|
const { state } = readStore(cfg.storePath);
|
|
30
32
|
// Each scan root is tagged with the app that owns it. Claude Code's projects
|
|
31
33
|
// dir is scanned whole; the Claude Desktop sessions root is filtered to its
|
|
@@ -41,7 +43,7 @@ export async function runOnce(cfg, log = () => {
|
|
|
41
43
|
}
|
|
42
44
|
const files = [];
|
|
43
45
|
for (const root of roots) {
|
|
44
|
-
for (const fp of listJsonlFiles(root.dir)) {
|
|
46
|
+
for (const fp of listJsonlFiles(root.dir, dirCache)) {
|
|
45
47
|
if (root.onlyProjects && !fp.includes(PROJECTS_SUBPATH)) {
|
|
46
48
|
continue;
|
|
47
49
|
}
|
|
@@ -75,7 +77,7 @@ export async function runOnce(cfg, log = () => {
|
|
|
75
77
|
if (!tail || tail.consumedBytes === 0) {
|
|
76
78
|
continue;
|
|
77
79
|
}
|
|
78
|
-
if (tail.
|
|
80
|
+
if (tail.segments.length === 0) {
|
|
79
81
|
// Consumed only non-usage lines — safe to advance immediately.
|
|
80
82
|
state.files[fp] = tail.nextState;
|
|
81
83
|
advanced = true;
|
|
@@ -83,8 +85,8 @@ export async function runOnce(cfg, log = () => {
|
|
|
83
85
|
}
|
|
84
86
|
// sendChunk absorbs "invalid" by bisecting, so only auth/plan/transient escape.
|
|
85
87
|
let outcome = 'ok';
|
|
86
|
-
for (
|
|
87
|
-
outcome = await sendChunk(
|
|
88
|
+
for (const chunk of chunks(tail.segments, step)) {
|
|
89
|
+
outcome = await sendChunk(chunk.records, chunk.accountExtId, cfg, result, log);
|
|
88
90
|
if (outcome !== 'ok') {
|
|
89
91
|
break;
|
|
90
92
|
}
|
|
@@ -151,9 +153,9 @@ export async function runOnce(cfg, log = () => {
|
|
|
151
153
|
* anything that survives that many splits is not record-shaped.
|
|
152
154
|
*/
|
|
153
155
|
const MAX_DROPPED_PER_CHUNK = 2;
|
|
154
|
-
async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped + MAX_DROPPED_PER_CHUNK) {
|
|
156
|
+
async function sendChunk(records, accountExtId, cfg, result, log, dropCeiling = result.dropped + MAX_DROPPED_PER_CHUNK) {
|
|
155
157
|
const res = await uploadBatch({
|
|
156
|
-
accountExtId: detectClaudeAccount()?.extId,
|
|
158
|
+
accountExtId: accountExtId ?? detectClaudeAccount()?.extId,
|
|
157
159
|
collectorVersion: RELEASE_VERSION,
|
|
158
160
|
hostname: hostname(),
|
|
159
161
|
os: detectOs(),
|
|
@@ -185,21 +187,34 @@ async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped
|
|
|
185
187
|
return 'ok';
|
|
186
188
|
}
|
|
187
189
|
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;
|
|
190
|
+
const head = await sendChunk(records.slice(0, mid), accountExtId, cfg, result, log, dropCeiling);
|
|
191
|
+
return head === 'ok' ? sendChunk(records.slice(mid), accountExtId, cfg, result, log, dropCeiling) : head;
|
|
192
|
+
}
|
|
193
|
+
/** Upload-sized slices of each segment; a chunk never spans two accounts. */
|
|
194
|
+
function chunks(segments, step) {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const { accountExtId, records } of segments) {
|
|
197
|
+
for (let i = 0; i < records.length; i += step) {
|
|
198
|
+
out.push({ accountExtId, records: records.slice(i, i + step) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
190
202
|
}
|
|
191
203
|
/**
|
|
192
204
|
* Drop offsets for logs that no longer exist on disk, so a long-lived install
|
|
193
205
|
* does not grow its state file by one entry per Claude session forever. Only
|
|
194
206
|
* paths absent from this cycle's scan are stat'd, and only a real ENOENT prunes
|
|
195
|
-
* — a root that is merely unconfigured right now keeps its offsets.
|
|
207
|
+
* — a root that is merely unconfigured right now keeps its offsets. A path that
|
|
208
|
+
* still exists goes too when its inode is one this scan reached under another
|
|
209
|
+
* path: a root now read through a symlink (~/.pi -> .dotfiles/.pi).
|
|
196
210
|
* Returns whether anything was removed.
|
|
197
211
|
*/
|
|
198
212
|
function pruneMissingFiles(state, scanned) {
|
|
199
213
|
const seen = new Set(scanned.map(f => f.fp));
|
|
214
|
+
const seenInodes = new Set(scanned.map(f => state.files[f.fp]?.inode));
|
|
200
215
|
let removed = false;
|
|
201
|
-
for (const fp of Object.
|
|
202
|
-
if (seen.has(fp) || existsSync(fp)) {
|
|
216
|
+
for (const [fp, file] of Object.entries(state.files)) {
|
|
217
|
+
if (seen.has(fp) || (existsSync(fp) && !seenInodes.has(file.inode))) {
|
|
203
218
|
continue;
|
|
204
219
|
}
|
|
205
220
|
// oxlint-disable-next-line typescript/no-dynamic-delete -- state.files is a JSON blob keyed by path
|
|
@@ -272,8 +287,16 @@ export async function reportLimitsOnce(cfg, log = () => {
|
|
|
272
287
|
log('warn', 'limits upload failed · retrying next cycle');
|
|
273
288
|
}
|
|
274
289
|
// Cache the reading so `status` can show current usage without spending
|
|
275
|
-
// another billable API call.
|
|
290
|
+
// another billable API call. An unchanged reading refreshes its timestamp only
|
|
291
|
+
// every LIMITS_CACHE_REFRESH_MS: each save rewrites the whole store.
|
|
276
292
|
updateStore(cfg.storePath, store => {
|
|
293
|
+
const cached = store.limits;
|
|
294
|
+
const unchanged = cached?.fiveHourPct === report.fiveHourPct &&
|
|
295
|
+
cached.sevenDayPct === report.sevenDayPct &&
|
|
296
|
+
cached.source === report.source;
|
|
297
|
+
if (unchanged && Date.now() - Date.parse(cached.at) < LIMITS_CACHE_REFRESH_MS) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
277
300
|
store.limits = {
|
|
278
301
|
at: new Date().toISOString(),
|
|
279
302
|
fiveHourPct: report.fiveHourPct,
|
package/dist/index.js
CHANGED
|
@@ -96,13 +96,14 @@ async function cmdWatch() {
|
|
|
96
96
|
let stopping = false;
|
|
97
97
|
let timer = null;
|
|
98
98
|
let running = false;
|
|
99
|
+
const dirCache = new Map();
|
|
99
100
|
const tick = async () => {
|
|
100
101
|
if (stopping) {
|
|
101
102
|
return;
|
|
102
103
|
}
|
|
103
104
|
running = true;
|
|
104
105
|
try {
|
|
105
|
-
const r = await runOnce(cfg, stream);
|
|
106
|
+
const r = await runOnce(cfg, stream, dirCache);
|
|
106
107
|
// Dropped records are real data loss, so they must show up even in a
|
|
107
108
|
// cycle that uploaded nothing.
|
|
108
109
|
if (r.sent > 0 || r.dropped > 0) {
|
package/dist/notifier.js
CHANGED
|
@@ -18,17 +18,19 @@ export function loadNotifyConfig(env = process.env, file = readStore()) {
|
|
|
18
18
|
* Decide whether a window crosses a not-yet-notified threshold. Pure — no IO.
|
|
19
19
|
* Returns the threshold to fire (or null) and the next persisted state.
|
|
20
20
|
*
|
|
21
|
-
* A window rollover (resetsAt
|
|
21
|
+
* A window rollover (resetsAt moves) resets the high-water mark first, so the
|
|
22
22
|
* first crossing in a new window always re-notifies. If utilization later drops
|
|
23
23
|
* below the mark within the SAME window (e.g. a server correction), the mark is
|
|
24
24
|
* lowered so a subsequent re-cross notifies again.
|
|
25
25
|
*/
|
|
26
26
|
export function evaluateWindow(prev, pct, resetsAt, thresholds) {
|
|
27
|
-
const rolledOver = !prev || prev.resetsAt
|
|
27
|
+
const rolledOver = !prev || !sameWindow(prev.resetsAt, resetsAt);
|
|
28
28
|
const lastBucket = rolledOver ? 0 : prev.lastBucket;
|
|
29
|
+
// Within a window the first resetsAt stays, so the jitter alone never rewrites the store.
|
|
30
|
+
const windowResetsAt = rolledOver ? resetsAt : prev.resetsAt;
|
|
29
31
|
if (pct == null) {
|
|
30
32
|
// No reading this cycle — keep the mark, just track the (possibly new) window.
|
|
31
|
-
return { fire: null, next: { lastBucket, resetsAt } };
|
|
33
|
+
return { fire: null, next: { lastBucket, resetsAt: windowResetsAt } };
|
|
32
34
|
}
|
|
33
35
|
// Highest threshold the current pct has reached (thresholds are ascending).
|
|
34
36
|
let top = 0;
|
|
@@ -38,10 +40,17 @@ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
if (top > lastBucket) {
|
|
41
|
-
return { fire: top, next: { lastBucket: top, resetsAt } };
|
|
43
|
+
return { fire: top, next: { lastBucket: top, resetsAt: windowResetsAt } };
|
|
42
44
|
}
|
|
43
45
|
// top <= lastBucket: the mark follows pct down (or holds); nothing fires.
|
|
44
|
-
return { fire: null, next: { lastBucket: top, resetsAt } };
|
|
46
|
+
return { fire: null, next: { lastBucket: top, resetsAt: windowResetsAt } };
|
|
47
|
+
}
|
|
48
|
+
// oauth/usage jitters resets_at by milliseconds per request; a real rollover moves it by hours.
|
|
49
|
+
function sameWindow(a, b) {
|
|
50
|
+
if (a === null || b === null) {
|
|
51
|
+
return a === b;
|
|
52
|
+
}
|
|
53
|
+
return Math.abs(Date.parse(a) - Date.parse(b)) < 60_000;
|
|
45
54
|
}
|
|
46
55
|
/** Relative "resets in 12m" / "resets in 2h" suffix, or "" if unknown/past. */
|
|
47
56
|
function resetSuffix(resetsAt) {
|
package/dist/notify.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { cpSync, existsSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ENDPOINT } from './config.js';
|
|
6
|
+
import { notifierAppPath } from './service.js';
|
|
7
|
+
const NOTIFIER_EXE = join('Contents', 'MacOS', 'usagefleet-notifier');
|
|
2
8
|
/** Collapse every whitespace run (including the raw newlines AppleScript string
|
|
3
9
|
* literals forbid) to a single space, leaving hyphens and other punctuation
|
|
4
10
|
* intact. */
|
|
@@ -26,6 +32,36 @@ function spawnQuiet(cmd, args, onError) {
|
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
function notifyMac(title, message) {
|
|
35
|
+
// macos/build.sh puts the app next to this file in the npm release; dev runs have none.
|
|
36
|
+
const bundled = fileURLToPath(new URL('UsageFleet.app', import.meta.url));
|
|
37
|
+
if (!existsSync(bundled)) {
|
|
38
|
+
notifyOsascript(title, message);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const app = notifierAppPath();
|
|
42
|
+
installNotifierApp(bundled, app);
|
|
43
|
+
const child = spawn(join(app, NOTIFIER_EXE), [oneLine(title), oneLine(message), `${ENDPOINT}/dashboard`], {
|
|
44
|
+
// Own process group: launchd kills the collector's group when it restarts, and the
|
|
45
|
+
// first-run permission prompt keeps the app alive until the user answers it.
|
|
46
|
+
detached: true,
|
|
47
|
+
stdio: 'ignore',
|
|
48
|
+
});
|
|
49
|
+
child.on('error', () => notifyOsascript(title, message));
|
|
50
|
+
child.unref();
|
|
51
|
+
}
|
|
52
|
+
/** Copies the app to one fixed path, so macOS sees a single UsageFleet app whatever
|
|
53
|
+
* Node install runs the CLI. Replaces it only when the build changed. */
|
|
54
|
+
export function installNotifierApp(from, to) {
|
|
55
|
+
const exe = join(to, NOTIFIER_EXE);
|
|
56
|
+
if (existsSync(exe) && readFileSync(exe).equals(readFileSync(join(from, NOTIFIER_EXE)))) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const tmp = `${to}.${process.pid}.tmp`;
|
|
60
|
+
cpSync(from, tmp, { recursive: true });
|
|
61
|
+
rmSync(to, { recursive: true, force: true });
|
|
62
|
+
renameSync(tmp, to);
|
|
63
|
+
}
|
|
64
|
+
function notifyOsascript(title, message) {
|
|
29
65
|
// execFile (no shell) — the only interpolation surface is the AppleScript
|
|
30
66
|
// string, which osaEscape neutralizes.
|
|
31
67
|
const script = `display notification "${osaEscape(message)}" with title "${osaEscape(title)}"`;
|
|
@@ -64,7 +100,7 @@ function notifyWindows(title, message) {
|
|
|
64
100
|
/**
|
|
65
101
|
* Show a desktop notification. Best-effort and non-blocking: it never throws and
|
|
66
102
|
* never blocks the caller. Supported platforms:
|
|
67
|
-
* - macOS: `osascript`
|
|
103
|
+
* - macOS: the bundled UsageFleet.app, or `osascript` when it is missing.
|
|
68
104
|
* - Linux: `notify-send` (KDE Plasma + other freedesktop daemons), falling
|
|
69
105
|
* back to `kdialog --passivepopup` on KDE.
|
|
70
106
|
* - Windows: WinRT toast via `powershell.exe` -> Action Center.
|
package/dist/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
// USAGEFLEET_PROJECTS is resolved in config.ts's precedence chain, not here —
|
|
@@ -42,7 +43,25 @@ export function defaultPiSessionsDirs() {
|
|
|
42
43
|
if (agent) {
|
|
43
44
|
dirs.push(join(agent, 'sessions'));
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
// pi exports its dir resolved (~/.pi -> .dotfiles/.pi gives the .dotfiles path), so one
|
|
47
|
+
// root can arrive twice. The first path wins: it is the one the service, without pi's env, scans.
|
|
48
|
+
const unique = new Map();
|
|
49
|
+
for (const dir of dirs) {
|
|
50
|
+
const real = realDir(dir);
|
|
51
|
+
if (!unique.has(real)) {
|
|
52
|
+
unique.set(real, dir);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...unique.values()];
|
|
56
|
+
}
|
|
57
|
+
function realDir(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(dir);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Missing roots are normal (pi never ran here) and scan to nothing.
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
46
65
|
}
|
|
47
66
|
/** pi's agent dir, the root for its sessions and extensions.
|
|
48
67
|
* PI_CODING_AGENT_DIR is pi's own relocation knob; unlike the service-side
|
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.153";
|
package/dist/scanner.js
CHANGED
|
@@ -1,24 +1,58 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
// Linux stamps mtimes with a jiffy-coarse clock, HFS+ by the second, FAT by two: a change
|
|
4
|
+
// right after a read can carry the listing's mtime. Only a folder quiet this long is trusted.
|
|
5
|
+
const QUIET_MS = 10_000;
|
|
3
6
|
/** Recursively list all *.jsonl files under a directory. Returns [] if the
|
|
4
|
-
* directory is missing.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
* directory is missing. A folder whose mtime has not moved reuses its listing
|
|
8
|
+
* from `cache`: adding or removing an entry moves it, appending to a log does not. */
|
|
9
|
+
export function listJsonlFiles(dir, cache = new Map()) {
|
|
10
|
+
const listing = listDir(dir, cache);
|
|
11
|
+
const out = [...listing.files];
|
|
12
|
+
for (const sub of listing.dirs) {
|
|
13
|
+
out.push(...listJsonlFiles(sub, cache));
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
function listDir(dir, cache) {
|
|
18
|
+
const cached = cache.get(dir);
|
|
19
|
+
const readAt = Date.now();
|
|
20
|
+
let mtimeMs;
|
|
7
21
|
let entries;
|
|
8
22
|
try {
|
|
23
|
+
mtimeMs = statSync(dir).mtimeMs;
|
|
24
|
+
if (cached?.mtimeMs === mtimeMs) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
9
27
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
10
28
|
}
|
|
11
29
|
catch {
|
|
12
|
-
|
|
30
|
+
forget(cache, dir);
|
|
31
|
+
return { dirs: [], files: [], mtimeMs: null };
|
|
13
32
|
}
|
|
33
|
+
const listing = { dirs: [], files: [], mtimeMs: readAt - mtimeMs > QUIET_MS ? mtimeMs : null };
|
|
14
34
|
for (const e of entries) {
|
|
15
35
|
const full = join(dir, e.name);
|
|
16
36
|
if (e.isDirectory()) {
|
|
17
|
-
|
|
37
|
+
listing.dirs.push(full);
|
|
18
38
|
}
|
|
19
39
|
else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
20
|
-
|
|
40
|
+
listing.files.push(full);
|
|
21
41
|
}
|
|
22
42
|
}
|
|
23
|
-
|
|
43
|
+
const kept = new Set(listing.dirs);
|
|
44
|
+
for (const sub of cached?.dirs ?? []) {
|
|
45
|
+
if (!kept.has(sub)) {
|
|
46
|
+
forget(cache, sub);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
cache.set(dir, listing);
|
|
50
|
+
return listing;
|
|
51
|
+
}
|
|
52
|
+
function forget(cache, dir) {
|
|
53
|
+
const listing = cache.get(dir);
|
|
54
|
+
cache.delete(dir);
|
|
55
|
+
for (const sub of listing?.dirs ?? []) {
|
|
56
|
+
forget(cache, sub);
|
|
57
|
+
}
|
|
24
58
|
}
|
package/dist/service.js
CHANGED
|
@@ -15,7 +15,7 @@ const TASK = 'usagefleet';
|
|
|
15
15
|
* service that lost it would silently report the default account instead. */
|
|
16
16
|
const EXTRA_PASSTHROUGH_ENV = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR']);
|
|
17
17
|
/** Per-user dir for the collector's own runtime files: the Windows launcher and
|
|
18
|
-
* its log, plus the binary copy that pre-npm releases left there. */
|
|
18
|
+
* its log, the macOS notifier app, plus the binary copy that pre-npm releases left there. */
|
|
19
19
|
function stableBinDir() {
|
|
20
20
|
if (process.platform === 'darwin') {
|
|
21
21
|
return join(homedir(), 'Library', 'Application Support', 'usagefleet');
|
|
@@ -39,6 +39,10 @@ function macLogDir() {
|
|
|
39
39
|
function windowsVbsPath() {
|
|
40
40
|
return join(stableBinDir(), 'usagefleet-watch.vbs');
|
|
41
41
|
}
|
|
42
|
+
/** One fixed path for the macOS notifier, whatever Node install runs the CLI. */
|
|
43
|
+
export function notifierAppPath() {
|
|
44
|
+
return join(stableBinDir(), 'UsageFleet.app');
|
|
45
|
+
}
|
|
42
46
|
/** Where releases before the npm switch parked their copy of the binary. Only
|
|
43
47
|
* cleanup touches it now. */
|
|
44
48
|
function stableBinPath() {
|
|
@@ -553,7 +557,8 @@ export function uninstall() {
|
|
|
553
557
|
}
|
|
554
558
|
}
|
|
555
559
|
removeStableBin();
|
|
556
|
-
|
|
560
|
+
rmSync(notifierAppPath(), { force: true, recursive: true });
|
|
561
|
+
console.log(step('removed', 'launchd agent · notification app'));
|
|
557
562
|
console.log(row('leftover', `${tilde(path)} · delete to fully clean up`));
|
|
558
563
|
return;
|
|
559
564
|
}
|
package/dist/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { writeFileAtomic } from './atomic-write.js';
|
|
@@ -108,7 +108,13 @@ export function readStore(path = storePath()) {
|
|
|
108
108
|
*/
|
|
109
109
|
export function updateStore(path, mutate) {
|
|
110
110
|
const store = readStore(path);
|
|
111
|
+
const before = JSON.stringify(store);
|
|
111
112
|
mutate(store);
|
|
113
|
+
// The offsets make the store ~0.5 MB, so an unchanged one is not rewritten. A missing
|
|
114
|
+
// file always is: it pins the deviceId normalize() just generated and folds the legacy files.
|
|
115
|
+
if (existsSync(path) && JSON.stringify(store) === before) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
112
118
|
mkdirSync(dirname(path), { recursive: true });
|
|
113
119
|
// 0600: the file holds the device token.
|
|
114
120
|
writeFileAtomic(path, `${JSON.stringify(store, null, 2)}\n`, 0o600);
|
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
|
|
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,
|
|
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
|
-
|
|
88
|
+
segments: [],
|
|
74
89
|
};
|
|
75
90
|
}
|
|
76
|
-
return { consumedBytes: 0, nextState: base,
|
|
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
|
|
80
|
-
// 16 MB read peaked a first-run backlog at ~490 MB RSS.
|
|
81
|
-
const
|
|
82
|
-
let
|
|
83
|
-
|
|
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
|
|
87
|
-
if (
|
|
88
|
-
|
|
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: {
|
|
96
|
-
|
|
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
|
+
}
|