@vimoxshah/tokenflow 1.1.0 → 1.1.2
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/CHANGELOG.md +132 -0
- package/README.md +50 -1
- package/bin/tokenflow.js +222 -9
- package/package.json +2 -1
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +55 -6
- package/src/analytics/anomalies.js +17 -0
- package/src/commands/diagnostics.js +14 -1
- package/src/core/live-status.js +14 -3
- package/src/core/store.js +51 -0
- package/src/core/sync.js +9 -1
- package/src/core/team.js +162 -0
- package/src/core/watch-agent.js +226 -0
- package/src/core/watch-lock.js +160 -0
- package/src/core/watch.js +29 -40
- package/src/providers/hermes/index.js +41 -4
- package/src/server/server.js +29 -0
|
@@ -4,8 +4,24 @@
|
|
|
4
4
|
# scripts/build-menubar-app.sh [output-dir]
|
|
5
5
|
#
|
|
6
6
|
# Compiles menubar/TokenFlow/main.swift with swiftc (Xcode Command Line Tools)
|
|
7
|
-
# into a minimal .app bundle
|
|
8
|
-
#
|
|
7
|
+
# into a minimal .app bundle.
|
|
8
|
+
#
|
|
9
|
+
# The CLI is ALWAYS bundled into Contents/Resources/cli, packed with `npm pack`
|
|
10
|
+
# so the copy inside the app is byte-for-byte the published package rather than
|
|
11
|
+
# a hand-picked subset of the working tree. The app drives that copy, which is
|
|
12
|
+
# the only one guaranteed to match the binary it ships beside: the app and the
|
|
13
|
+
# CLI share a contract (the status file, the watcher lock format, /api/ping),
|
|
14
|
+
# and an unrelated CLI version next door is a mismatch nobody can reason about.
|
|
15
|
+
#
|
|
16
|
+
# Two build flavours:
|
|
17
|
+
#
|
|
18
|
+
# local (default) also embeds this clone's absolute node + CLI paths, so
|
|
19
|
+
# a developer's installed app drives the checkout they
|
|
20
|
+
# are editing.
|
|
21
|
+
# TOKENFLOW_PORTABLE=1 embeds NO absolute paths. Anything built for
|
|
22
|
+
# distribution must use this: a release built on CI
|
|
23
|
+
# otherwise ships /Users/runner/... in its Info.plist,
|
|
24
|
+
# which exists on no user's machine.
|
|
9
25
|
set -euo pipefail
|
|
10
26
|
|
|
11
27
|
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
|
@@ -25,14 +41,40 @@ command -v swiftc >/dev/null 2>&1 || {
|
|
|
25
41
|
NODE_BIN="$(command -v node)"
|
|
26
42
|
CLI_JS="$REPO/bin/tokenflow.js"
|
|
27
43
|
[ -f "$CLI_JS" ] || { echo "error: $CLI_JS missing" >&2; exit 1; }
|
|
44
|
+
PORTABLE="${TOKENFLOW_PORTABLE:-0}"
|
|
28
45
|
|
|
29
46
|
TMP="$(mktemp -d)"
|
|
30
47
|
trap 'rm -rf "$TMP"' EXIT
|
|
31
48
|
|
|
49
|
+
rm -rf "$APP"
|
|
32
50
|
mkdir -p "$APP/Contents/MacOS"
|
|
33
51
|
mkdir -p "$APP/Contents/Resources"
|
|
34
52
|
cp "$REPO/menubar/TokenFlow/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns"
|
|
35
53
|
|
|
54
|
+
# ---- bundle the CLI ---------------------------------------------------------
|
|
55
|
+
# `npm pack` rather than copying bin/ and src/: the tarball is what npm
|
|
56
|
+
# publishes, filtered by package.json "files", so the app can never ship a file
|
|
57
|
+
# the package does not.
|
|
58
|
+
echo "packing the CLI into the bundle"
|
|
59
|
+
( cd "$REPO" && npm pack --silent --pack-destination "$TMP" >/dev/null )
|
|
60
|
+
TGZ="$(ls "$TMP"/*.tgz | head -1)"
|
|
61
|
+
[ -f "$TGZ" ] || { echo "error: npm pack produced no tarball" >&2; exit 1; }
|
|
62
|
+
mkdir -p "$APP/Contents/Resources/cli"
|
|
63
|
+
tar -xzf "$TGZ" -C "$APP/Contents/Resources/cli"
|
|
64
|
+
BUNDLED_CLI="$APP/Contents/Resources/cli/package/bin/tokenflow.js"
|
|
65
|
+
[ -f "$BUNDLED_CLI" ] || { echo "error: bundled CLI missing at $BUNDLED_CLI" >&2; exit 1; }
|
|
66
|
+
|
|
67
|
+
# Keep only what the CLI actually executes. docs/, skills/, examples/ and
|
|
68
|
+
# scripts/ are never read at runtime — they appear in printed hints and nothing
|
|
69
|
+
# opens them — and they are four fifths of the tarball. Whatever remains still
|
|
70
|
+
# came from `npm pack`, so the bundle is a subset of the published package and
|
|
71
|
+
# never a file npm does not ship.
|
|
72
|
+
( cd "$APP/Contents/Resources/cli/package" \
|
|
73
|
+
&& rm -rf docs skills examples scripts \
|
|
74
|
+
README.md CONTRIBUTING.md SECURITY.md CHANGELOG.md "Refresh & Open Dashboard.command" )
|
|
75
|
+
[ -f "$BUNDLED_CLI" ] || { echo "error: pruning removed the CLI" >&2; exit 1; }
|
|
76
|
+
[ -d "$APP/Contents/Resources/cli/package/src" ] || { echo "error: pruning removed src/" >&2; exit 1; }
|
|
77
|
+
|
|
36
78
|
cat > "$APP/Contents/Info.plist" <<PLIST
|
|
37
79
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
38
80
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -50,12 +92,19 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
|
|
50
92
|
<key>LSUIElement</key> <true/>
|
|
51
93
|
<key>NSHighResolutionCapable</key> <true/>
|
|
52
94
|
<key>NSHumanReadableCopyright</key> <string>MIT — local-first, nothing leaves your machine.</string>
|
|
53
|
-
<key>TokenFlowNodePath</key> <string>$NODE_BIN</string>
|
|
54
|
-
<key>TokenFlowCLIPath</key> <string>$CLI_JS</string>
|
|
55
95
|
</dict>
|
|
56
96
|
</plist>
|
|
57
97
|
PLIST
|
|
58
98
|
|
|
99
|
+
# A distributable build embeds no machine-specific path. A local one does, so
|
|
100
|
+
# the developer's installed app drives the clone they are working in.
|
|
101
|
+
if [ "$PORTABLE" != "1" ]; then
|
|
102
|
+
/usr/libexec/PlistBuddy \
|
|
103
|
+
-c "Add :TokenFlowNodePath string $NODE_BIN" \
|
|
104
|
+
-c "Add :TokenFlowCLIPath string $CLI_JS" \
|
|
105
|
+
"$APP/Contents/Info.plist" >/dev/null
|
|
106
|
+
fi
|
|
107
|
+
|
|
59
108
|
echo "compiling with $(swiftc --version | head -1)"
|
|
60
109
|
swiftc -O -swift-version 5 \
|
|
61
110
|
-o "$APP/Contents/MacOS/TokenFlow" \
|
|
@@ -63,5 +112,5 @@ swiftc -O -swift-version 5 \
|
|
|
63
112
|
|
|
64
113
|
codesign --force --sign - "$APP" >/dev/null 2>&1 || true
|
|
65
114
|
|
|
66
|
-
SIZE=$(du -
|
|
67
|
-
echo "built: $APP ($SIZE)"
|
|
115
|
+
SIZE=$(du -sh "$APP" | cut -f1 | tr -d ' ')
|
|
116
|
+
echo "built: $APP ($SIZE)$([ "$PORTABLE" = "1" ] && echo ' · portable, no embedded paths')"
|
|
@@ -16,6 +16,18 @@
|
|
|
16
16
|
const MAD_SCALE = 1.4826;
|
|
17
17
|
const SPIKE_Z = 3.5; // Iglewicz–Hoaglin threshold for a modified z-score
|
|
18
18
|
const HIGH_Z = 6; // well past that: call it high severity
|
|
19
|
+
/**
|
|
20
|
+
* Far past "high": magnitude at this scale outranks recency in the list.
|
|
21
|
+
*
|
|
22
|
+
* Severity saturates at `high` around z=6, and the ordering below then falls
|
|
23
|
+
* back to date, newest first. That buried a real one: an adapter bug inflated
|
|
24
|
+
* one day to 240× its 60-day median, scoring z=171, and it ranked THIRD behind
|
|
25
|
+
* two request spikes of z=6.5 and z=11.2 from later in the week. The menu bar
|
|
26
|
+
* shows the top two alerts, so for a week the loudest signal the product had
|
|
27
|
+
* was the one thing it did not show. A z of 171 and a z of 6 are not the same
|
|
28
|
+
* news, whichever happened more recently.
|
|
29
|
+
*/
|
|
30
|
+
const EXTREME_Z = 25;
|
|
19
31
|
const BASELINE_WINDOW = 60;
|
|
20
32
|
const MIN_BASELINE = 10;
|
|
21
33
|
|
|
@@ -167,9 +179,14 @@ export function detectAnomalies(daily, opt = {}) {
|
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
const cap = opt.limit ?? 12;
|
|
182
|
+
// severity, then extremes by magnitude, then recency. Ordinary alerts keep
|
|
183
|
+
// reading as a feed; an outlier of a different order never gets buried in it.
|
|
184
|
+
const extreme = (a) => ((a.z ?? 0) >= EXTREME_Z ? 0 : 1);
|
|
170
185
|
return out
|
|
171
186
|
.sort((a, b) =>
|
|
172
187
|
SEV_ORDER[a.severity] - SEV_ORDER[b.severity]
|
|
188
|
+
|| extreme(a) - extreme(b)
|
|
189
|
+
|| (extreme(a) === 0 ? (b.z ?? 0) - (a.z ?? 0) : 0)
|
|
173
190
|
|| (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)
|
|
174
191
|
|| (b.z ?? 0) - (a.z ?? 0))
|
|
175
192
|
.slice(0, cap);
|
|
@@ -10,6 +10,19 @@ import os from 'node:os';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { loadConfig, paths } from '../core/config.js';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The shipped package.json is the only honest source for the version. A
|
|
15
|
+
* hardcoded fallback goes stale the moment anyone forgets it at release time,
|
|
16
|
+
* and then every diagnostics report quietly names the wrong release.
|
|
17
|
+
*/
|
|
18
|
+
function packageVersion() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
|
|
21
|
+
} catch {
|
|
22
|
+
return 'unknown';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* @param {{includePaths?: boolean}} opt
|
|
15
28
|
* @returns {object} diagnostics snapshot (plain JSON-able)
|
|
@@ -26,7 +39,7 @@ export function collect(opt = {}) {
|
|
|
26
39
|
};
|
|
27
40
|
|
|
28
41
|
return {
|
|
29
|
-
version: process.env.npm_package_version ||
|
|
42
|
+
version: process.env.npm_package_version || packageVersion(),
|
|
30
43
|
node: process.version,
|
|
31
44
|
platform: `${os.platform()} ${os.arch()} ${os.release()}`,
|
|
32
45
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
package/src/core/live-status.js
CHANGED
|
@@ -22,6 +22,7 @@ import { loadConfig, paths, ensureDirs } from './config.js';
|
|
|
22
22
|
import { readJson } from './store.js';
|
|
23
23
|
import { compact, usd, countdown } from './units.js';
|
|
24
24
|
import { detectMilestones } from '../analytics/milestones.js';
|
|
25
|
+
import { lockIsLive, readLock } from './watch-lock.js';
|
|
25
26
|
|
|
26
27
|
// Formatting adapters over the shared units.js formatters (which the browser
|
|
27
28
|
// bundle also uses): null means "nothing to show", never "—", never 0.
|
|
@@ -324,8 +325,10 @@ export function withComputedFreshness(status, nowMs = Date.now()) {
|
|
|
324
325
|
* The cache window derives from the watcher's own cadence (interval + slack),
|
|
325
326
|
* because a snapshot written 90 seconds into a 120-second cycle is exactly as
|
|
326
327
|
* current as the product promised — not stale. When a fallback compute does
|
|
327
|
-
* happen, daemon identity (pid, cycles
|
|
328
|
-
*
|
|
328
|
+
* happen, daemon identity (pid, cycles) is carried over from the cached file
|
|
329
|
+
* so a slow poll never makes the UI claim no watcher is running — but only
|
|
330
|
+
* while the watcher lock is actually live, so a dead daemon's identity does
|
|
331
|
+
* not linger either.
|
|
329
332
|
*/
|
|
330
333
|
export function currentStatus(opt = {}) {
|
|
331
334
|
const cached = readLiveStatus();
|
|
@@ -337,7 +340,15 @@ export function currentStatus(opt = {}) {
|
|
|
337
340
|
if (age <= maxAgeMs) return { status: withComputedFreshness(cached), fromWatch: true };
|
|
338
341
|
}
|
|
339
342
|
const fresh = buildLiveStatus({ config: cfg });
|
|
340
|
-
|
|
343
|
+
// Carry daemon identity over ONLY while THAT daemon is really there. A
|
|
344
|
+
// watcher block outlives the process that wrote it, and repeating it after
|
|
345
|
+
// the watcher died is how a paused TokenFlow came to look live. Matching the
|
|
346
|
+
// pid against the lock holder also stops a restarted watcher from being
|
|
347
|
+
// described with its predecessor's pid and cycle count.
|
|
348
|
+
const lock = readLock();
|
|
349
|
+
if (cached?.watcher && lockIsLive(lock) && cached.watcher.pid === lock.pid) {
|
|
350
|
+
fresh.watcher = cached.watcher;
|
|
351
|
+
}
|
|
341
352
|
if (!fresh.lastCycle && cached?.lastCycle) fresh.lastCycle = cached.lastCycle;
|
|
342
353
|
if (!fresh.lastError && cached?.lastError) fresh.lastError = cached.lastError;
|
|
343
354
|
return { status: fresh, fromWatch: false };
|
package/src/core/store.js
CHANGED
|
@@ -442,6 +442,57 @@ export function compactShards(store) {
|
|
|
442
442
|
return { kept, dropped, shards };
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Physically remove one source's records from every shard.
|
|
447
|
+
*
|
|
448
|
+
* The stale-generation mechanism cannot do this job: it supersedes records
|
|
449
|
+
* per source FILE, and cursor-based sources (SQLite adapters) have no file to
|
|
450
|
+
* supersede. Re-ingesting such a source after fixing its adapter therefore
|
|
451
|
+
* needs its old records dropped outright, which is what `reset --source` does.
|
|
452
|
+
*
|
|
453
|
+
* @returns {{kept:number, dropped:number, shards:number}}
|
|
454
|
+
*/
|
|
455
|
+
export function dropSourceRecords(store, sourceId) {
|
|
456
|
+
let kept = 0;
|
|
457
|
+
let dropped = 0;
|
|
458
|
+
let shards = 0;
|
|
459
|
+
for (const shard of store.listShards()) {
|
|
460
|
+
const src = path.join(store.p.records, shard);
|
|
461
|
+
const tmp = src + '.drop';
|
|
462
|
+
let buf = '';
|
|
463
|
+
let shardDropped = 0;
|
|
464
|
+
const out = fs.openSync(tmp, 'w');
|
|
465
|
+
try {
|
|
466
|
+
readLines(src, (line) => {
|
|
467
|
+
let o;
|
|
468
|
+
try { o = JSON.parse(line); } catch { return; }
|
|
469
|
+
if (o.so === sourceId) { dropped++; shardDropped++; return; }
|
|
470
|
+
kept++;
|
|
471
|
+
buf += line + '\n';
|
|
472
|
+
if (buf.length > 1 << 20) { fs.writeSync(out, buf); buf = ''; }
|
|
473
|
+
});
|
|
474
|
+
if (buf) fs.writeSync(out, buf);
|
|
475
|
+
} finally {
|
|
476
|
+
fs.closeSync(out);
|
|
477
|
+
}
|
|
478
|
+
if (!shardDropped) {
|
|
479
|
+
// Nothing to change here — leave the shard untouched rather than
|
|
480
|
+
// rewriting it byte-for-byte and disturbing its mtime.
|
|
481
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* leave the temp file */ }
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
fs.renameSync(tmp, src);
|
|
486
|
+
} catch (err) {
|
|
487
|
+
if (!['EPERM', 'EXDEV', 'EACCES', 'ENOTSUP'].includes(err.code)) throw err;
|
|
488
|
+
fs.writeFileSync(src, fs.readFileSync(tmp));
|
|
489
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* leave the temp file */ }
|
|
490
|
+
}
|
|
491
|
+
shards++;
|
|
492
|
+
}
|
|
493
|
+
return { kept, dropped, shards };
|
|
494
|
+
}
|
|
495
|
+
|
|
445
496
|
export function fileId(sourceId, key) {
|
|
446
497
|
return hashId(sourceId, key);
|
|
447
498
|
}
|
package/src/core/sync.js
CHANGED
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
* enabled: false # ← default; nothing leaves the machine
|
|
13
13
|
* dir: ~/Sync/TokenFlow # shared folder both machines can see
|
|
14
14
|
* machineName: MacBook Pro # friendly label shown in aggregated views
|
|
15
|
+
* developerName: Vimox # OPTIONAL — only when the team explicitly
|
|
16
|
+
* # opts into per-developer visibility (P4-B)
|
|
15
17
|
*
|
|
16
18
|
* What is transmitted (per day, per provider/model):
|
|
17
19
|
* date, tokens in/out/cache, requests, estimated cost, machineId
|
|
20
|
+
* + developerName ONLY if you set it yourself (team mode, opt-in)
|
|
18
21
|
* What is NEVER transmitted: prompts, code, file paths beyond the machine
|
|
19
22
|
* label you chose, credentials.
|
|
20
23
|
*
|
|
@@ -90,10 +93,15 @@ export function push(opt = {}) {
|
|
|
90
93
|
|
|
91
94
|
const id = machineId();
|
|
92
95
|
const name = sanitizeName(cfg.sync.machineName || os.hostname().split('.')[0]);
|
|
96
|
+
// Developer identity is included ONLY when the user explicitly set
|
|
97
|
+
// sync.developerName in their own config. Absent field = anonymous machine.
|
|
98
|
+
const dev = cfg.sync.developerName ? sanitizeName(cfg.sync.developerName) : null;
|
|
93
99
|
const lines = [...byDay.values()]
|
|
94
100
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
95
101
|
.map((d) => JSON.stringify({
|
|
96
|
-
machineId: id, machineName: name,
|
|
102
|
+
machineId: id, machineName: name,
|
|
103
|
+
...(dev ? { developer: dev } : {}),
|
|
104
|
+
date: d.date,
|
|
97
105
|
inputTokens: d.input, outputTokens: d.output,
|
|
98
106
|
requests: d.requests, estCostUsd: Math.round(d.estCost * 10000) / 10000,
|
|
99
107
|
exportedAt: new Date().toISOString(),
|
package/src/core/team.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P4 Team dashboard — Option B (per-developer rows), built on file sync.
|
|
3
|
+
*
|
|
4
|
+
* PRIVACY CONTRACT (explicit opt-in, per person):
|
|
5
|
+
* A record carries a `developer` name ONLY if that person set
|
|
6
|
+
* sync.developerName in their own config. Machines without it stay
|
|
7
|
+
* anonymous ("machine-a1b2") and are EXCLUDED from per-developer rows —
|
|
8
|
+
* they only contribute to team totals if includeAnonymous is set.
|
|
9
|
+
* Nobody can be de-anonymized by another member; the name is chosen
|
|
10
|
+
* (or withheld) by each developer locally.
|
|
11
|
+
*
|
|
12
|
+
* Aggregation reads the SAME shared sync folder as multi-machine mode:
|
|
13
|
+
* one JSONL per machine of {date, tokens, requests, estCostUsd,
|
|
14
|
+
* machineId, machineName, developer?}. No server, no new backend.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} dir resolved sync directory
|
|
21
|
+
* @param {{from?: string|null, to?: string|null, includeAnonymous?: boolean}} opt
|
|
22
|
+
* @returns {object|null} team rollup or null when nothing readable
|
|
23
|
+
*/
|
|
24
|
+
export function aggregate(dir, opt = {}) {
|
|
25
|
+
if (!fs.existsSync(dir)) return null;
|
|
26
|
+
|
|
27
|
+
const from = opt.from || null;
|
|
28
|
+
const to = opt.to || null;
|
|
29
|
+
const includeAnonymous = !!opt.includeAnonymous;
|
|
30
|
+
|
|
31
|
+
// Per-developer and per-machine accumulators.
|
|
32
|
+
const devs = new Map(); // developer → totals + daily map
|
|
33
|
+
const machines = new Map(); // machineName/id → totals (for the roster)
|
|
34
|
+
const anon = { tokens: 0, requests: 0, cost: 0 }; // records w/o developer
|
|
35
|
+
let days = new Map(); // date → {tokens, requests, cost} for trend
|
|
36
|
+
|
|
37
|
+
for (const f of fs.readdirSync(dir)) {
|
|
38
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
39
|
+
for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
let r;
|
|
42
|
+
try { r = JSON.parse(line); } catch { continue; } // tolerate partial syncs
|
|
43
|
+
if (from && r.date < from) continue;
|
|
44
|
+
if (to && r.date > to) continue;
|
|
45
|
+
|
|
46
|
+
const tokens = (r.inputTokens || 0) + (r.outputTokens || 0);
|
|
47
|
+
const req = r.requests || 0;
|
|
48
|
+
const cost = r.estCostUsd || 0;
|
|
49
|
+
const mKey = r.machineName || r.machineId || f.replace(/\.jsonl$/, '');
|
|
50
|
+
const dev = typeof r.developer === 'string' && r.developer.trim()
|
|
51
|
+
? r.developer.trim() : null;
|
|
52
|
+
|
|
53
|
+
let m = machines.get(mKey);
|
|
54
|
+
if (!m) { m = { machine: mKey, developer: dev, tokens: 0, requests: 0, cost: 0, days: new Set() }; machines.set(mKey, m); }
|
|
55
|
+
m.tokens += tokens; m.requests += req; m.cost += cost; m.days.add(r.date);
|
|
56
|
+
|
|
57
|
+
if (dev) {
|
|
58
|
+
let d = devs.get(dev);
|
|
59
|
+
if (!d) { d = { developer: dev, tokens: 0, requests: 0, cost: 0, days: new Map(), machines: new Set() }; devs.set(dev, d); }
|
|
60
|
+
d.tokens += tokens; d.requests += req; d.cost += cost;
|
|
61
|
+
d.machines.add(mKey);
|
|
62
|
+
const dayTot = d.days.get(r.date) || { tokens: 0 };
|
|
63
|
+
dayTot.tokens += tokens; d.days.set(r.date, dayTot);
|
|
64
|
+
} else {
|
|
65
|
+
anon.tokens += tokens; anon.requests += req; anon.cost += cost;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const t = days.get(r.date) || { tokens: 0, requests: 0, cost: 0 };
|
|
69
|
+
t.tokens += tokens; t.requests += req; t.cost += cost;
|
|
70
|
+
days.set(r.date, t);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!machines.size) return null;
|
|
75
|
+
|
|
76
|
+
const developers = [...devs.values()]
|
|
77
|
+
.map((d) => ({
|
|
78
|
+
developer: d.developer,
|
|
79
|
+
requests: d.requests,
|
|
80
|
+
tokens: d.tokens,
|
|
81
|
+
estCostUsd: Math.round(d.cost * 100) / 100,
|
|
82
|
+
activeDays: d.days.size,
|
|
83
|
+
avgTokensPerDay: d.days.size ? Math.round(d.tokens / d.days.size) : null,
|
|
84
|
+
machines: [...d.machines],
|
|
85
|
+
}))
|
|
86
|
+
.sort((a, b) => b.estCostUsd - a.estCostUsd || b.tokens - a.tokens);
|
|
87
|
+
|
|
88
|
+
const maxCost = developers[0]?.estCostUsd || 0;
|
|
89
|
+
const totalTokens = [...days.values()].reduce((s, d) => s + d.tokens, 0);
|
|
90
|
+
const totalRequests = [...days.values()].reduce((s, d) => s + d.requests, 0);
|
|
91
|
+
const totalCost = [...days.values()].reduce((s, d) => s + d.cost, 0);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
window: { from, to },
|
|
95
|
+
totals: {
|
|
96
|
+
requests: totalRequests,
|
|
97
|
+
tokens: totalTokens,
|
|
98
|
+
estCostUsd: Math.round(totalCost * 100) / 100,
|
|
99
|
+
activeMachines: machines.size,
|
|
100
|
+
namedDevelopers: developers.length,
|
|
101
|
+
anonymousTokens: anon.tokens,
|
|
102
|
+
},
|
|
103
|
+
// Per-developer share bars are rendered from these percentages.
|
|
104
|
+
shares: developers.map((d) => ({
|
|
105
|
+
...d,
|
|
106
|
+
pctOfCost: totalCost > 0 ? Math.round((d.estCostUsd / totalCost) * 1000) / 10 : null,
|
|
107
|
+
bar: maxCost > 0 ? Math.max(1, Math.round((d.estCostUsd / maxCost) * 24)) : 1,
|
|
108
|
+
})),
|
|
109
|
+
roster: [...machines.values()]
|
|
110
|
+
.sort((a, b) => b.cost - a.cost)
|
|
111
|
+
.map((m) => ({ ...m, estCostUsd: Math.round(m.cost * 100) / 100 })),
|
|
112
|
+
anonymous: includeAnonymous || !developers.length ? { ...anon } : undefined,
|
|
113
|
+
trendDays: [...days.entries()]
|
|
114
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
115
|
+
.slice(-28)
|
|
116
|
+
.map(([date, v]) => ({ date, ...v })),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Plain-text rendering for the CLI. */
|
|
121
|
+
export function renderText(t) {
|
|
122
|
+
if (!t) return 'No team data found in the sync folder.';
|
|
123
|
+
const L = [];
|
|
124
|
+
const win = t.window.from || t.window.to
|
|
125
|
+
? `${t.window.from || '…'} → ${t.window.to || '…'}`
|
|
126
|
+
: 'all time';
|
|
127
|
+
L.push(`Team AI usage — ${win}`);
|
|
128
|
+
L.push('');
|
|
129
|
+
L.push(` Total requests ${t.totals.requests.toLocaleString('en-US')}`);
|
|
130
|
+
L.push(` Total tokens ${fmt(t.totals.tokens)}`);
|
|
131
|
+
L.push(` Estimated cost $${t.totals.estCostUsd.toFixed(2)} (estimated from local price table)`);
|
|
132
|
+
L.push(` Active machines ${t.totals.activeMachines} named developers: ${t.totals.namedDevelopers}`);
|
|
133
|
+
L.push('');
|
|
134
|
+
L.push('Per developer');
|
|
135
|
+
if (!t.shares.length) {
|
|
136
|
+
L.push(' (no records carry a developer name — nobody has set sync.developerName)');
|
|
137
|
+
}
|
|
138
|
+
for (const d of t.shares) {
|
|
139
|
+
const bar = '█'.repeat(d.bar);
|
|
140
|
+
const pct = d.pctOfCost != null ? ` ${d.pctOfCost}%` : '';
|
|
141
|
+
L.push(` ${d.developer.padEnd(14).slice(0, 14)} ${bar.padEnd(25)} ${fmt(d.tokens).padStart(8)} tok $${d.estCostUsd.toFixed(2).padStart(9)}${pct}`);
|
|
142
|
+
}
|
|
143
|
+
if (t.anonymous && (t.anonymous.tokens > 0)) {
|
|
144
|
+
L.push(` ${'(anonymous)'.padEnd(14)} ${fmt(t.anonymous.tokens).padStart(8)} tok excluded from per-dev rows`);
|
|
145
|
+
}
|
|
146
|
+
if (!t.shares.length && !(t.anonymous && t.anonymous.tokens > 0)) L.push(' (empty window)');
|
|
147
|
+
L.push('');
|
|
148
|
+
L.push('Roster (per machine)');
|
|
149
|
+
for (const m of t.roster) {
|
|
150
|
+
L.push(` ${String(m.machine).padEnd(16).slice(0, 16)} ${String(m.developer || '—').padEnd(12).slice(0, 12)} ${fmt(m.tokens).padStart(8)} tok $${m.estCostUsd.toFixed(2).padStart(9)} ${m.days.size}d`);
|
|
151
|
+
}
|
|
152
|
+
L.push('');
|
|
153
|
+
L.push('Names appear here only for developers who chose to publish theirs (sync.developerName).');
|
|
154
|
+
return L.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fmt(n) {
|
|
158
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
159
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
160
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
161
|
+
return String(n);
|
|
162
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watcher's login agent — launchd-supervised `tokenflow watch`.
|
|
3
|
+
*
|
|
4
|
+
* "Live" needs a resident process, and until now nothing installed one. The
|
|
5
|
+
* play button could start a watcher for the length of the session, and that
|
|
6
|
+
* was all: nothing survived a reboot, so a machine woke up with stale data and
|
|
7
|
+
* a paused menu bar. Every user who wanted live data had to hand-roll a
|
|
8
|
+
* LaunchAgent, and a hand-rolled one is where two real defects came from.
|
|
9
|
+
*
|
|
10
|
+
* ## KeepAlive is not a boolean here
|
|
11
|
+
*
|
|
12
|
+
* `KeepAlive: true` restarts the job whatever happens — including after a
|
|
13
|
+
* clean exit. On a supervised watcher that silently defeats the stop button:
|
|
14
|
+
* `tokenflow watch --stop` sends SIGTERM, the watcher releases its lock and
|
|
15
|
+
* exits 0, and launchd starts it again about two seconds later. Measured, not
|
|
16
|
+
* assumed. So the rule is `KeepAlive: { SuccessfulExit: false }` — a crash
|
|
17
|
+
* comes back, a deliberate stop stays stopped until the next login.
|
|
18
|
+
*
|
|
19
|
+
* ## ThrottleInterval is a safety belt, not a tuning knob
|
|
20
|
+
*
|
|
21
|
+
* A watcher that cannot take the lock exits 1, which under the rule above is a
|
|
22
|
+
* restart. If something ever holds the lock persistently, that is an infinite
|
|
23
|
+
* respawn loop writing to the log every time — exactly what filled one user's
|
|
24
|
+
* watch.log with 2.2 MB of the same refusal. 60 seconds keeps a stuck state
|
|
25
|
+
* quiet enough to diagnose.
|
|
26
|
+
*
|
|
27
|
+
* ## One watcher, one agent
|
|
28
|
+
*
|
|
29
|
+
* Two agents both running `tokenflow watch` is the same trap: the loser exits
|
|
30
|
+
* 1 forever. Installing therefore hunts down any OTHER agent that runs a
|
|
31
|
+
* tokenflow watcher and removes it — by reading what each plist actually runs,
|
|
32
|
+
* not by trusting a label, because a hand-rolled one can be called anything.
|
|
33
|
+
* Leaving the file on disk is not enough either: launchd reloads it at the next
|
|
34
|
+
* login and the race comes back.
|
|
35
|
+
*/
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import os from 'node:os';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
|
|
41
|
+
export const LABEL = 'app.tokenflow.watch';
|
|
42
|
+
|
|
43
|
+
/** launchd restarts a crash after this many seconds, never faster. */
|
|
44
|
+
const THROTTLE_SECONDS = 60;
|
|
45
|
+
|
|
46
|
+
export function agentsDir() {
|
|
47
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function plistPath() {
|
|
51
|
+
return path.join(agentsDir(), `${LABEL}.plist`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function supported() {
|
|
55
|
+
return process.platform === 'darwin';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function homeDir() {
|
|
59
|
+
return process.env.TOKENFLOW_HOME || path.join(os.homedir(), '.tokenflow');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The CLI this module was loaded from: src/core/watch-agent.js → ../../bin */
|
|
63
|
+
export function cliPath() {
|
|
64
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
65
|
+
return path.resolve(here, '..', '..', 'bin', 'tokenflow.js');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function xml(s) {
|
|
69
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function renderPlist({ nodeBin, cli, home, workingDir }) {
|
|
73
|
+
// A launchd plist always carries POSIX paths, so build them with path.posix
|
|
74
|
+
// rather than the host's separator. Rendering is then identical everywhere,
|
|
75
|
+
// which is what makes it testable on a machine that could never run it.
|
|
76
|
+
const bin = path.posix.dirname(nodeBin);
|
|
77
|
+
const PATH = [bin, '/usr/bin', '/bin', '/usr/sbin', '/sbin', '/usr/local/bin', '/opt/homebrew/bin'].join(':');
|
|
78
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
79
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
80
|
+
<plist version="1.0">
|
|
81
|
+
<dict>
|
|
82
|
+
<key>Label</key><string>${LABEL}</string>
|
|
83
|
+
<key>ProgramArguments</key>
|
|
84
|
+
<array>
|
|
85
|
+
<string>${xml(nodeBin)}</string>
|
|
86
|
+
<string>${xml(cli)}</string>
|
|
87
|
+
<string>watch</string>
|
|
88
|
+
</array>
|
|
89
|
+
<key>RunAtLoad</key><true/>
|
|
90
|
+
<!-- A crash restarts; a clean stop stays stopped. See watch-agent.js. -->
|
|
91
|
+
<key>KeepAlive</key>
|
|
92
|
+
<dict><key>SuccessfulExit</key><false/></dict>
|
|
93
|
+
<key>ThrottleInterval</key><integer>${THROTTLE_SECONDS}</integer>
|
|
94
|
+
<key>WorkingDirectory</key><string>${xml(workingDir)}</string>
|
|
95
|
+
<key>StandardOutPath</key><string>${xml(path.posix.join(home, 'watch.log'))}</string>
|
|
96
|
+
<key>StandardErrorPath</key><string>${xml(path.posix.join(home, 'watch.log'))}</string>
|
|
97
|
+
<key>EnvironmentVariables</key>
|
|
98
|
+
<dict>
|
|
99
|
+
<key>TOKENFLOW_HOME</key><string>${xml(home)}</string>
|
|
100
|
+
<key>PATH</key><string>${xml(PATH)}</string>
|
|
101
|
+
</dict>
|
|
102
|
+
</dict>
|
|
103
|
+
</plist>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every OTHER launch agent that runs a tokenflow watcher.
|
|
109
|
+
*
|
|
110
|
+
* Reads what each plist RUNS rather than matching a label: a hand-rolled agent
|
|
111
|
+
* can be named anything, and one that keeps respawning against our lock is
|
|
112
|
+
* indistinguishable from a broken install.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} [dir]
|
|
115
|
+
* @returns {{label:string, file:string}[]}
|
|
116
|
+
*/
|
|
117
|
+
export function findForeignAgents(dir = agentsDir()) {
|
|
118
|
+
let names;
|
|
119
|
+
try {
|
|
120
|
+
names = fs.readdirSync(dir).filter((f) => f.endsWith('.plist'));
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const name of names) {
|
|
126
|
+
if (name === `${LABEL}.plist`) continue;
|
|
127
|
+
const file = path.join(dir, name);
|
|
128
|
+
let text;
|
|
129
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
130
|
+
// Runs a tokenflow CLI, and runs it with the `watch` subcommand.
|
|
131
|
+
const runsCli = /<string>[^<]*\/(?:tokenflow\.js|tokenflow)<\/string>/.test(text);
|
|
132
|
+
const runsWatch = /<string>\s*watch\s*<\/string>/.test(text);
|
|
133
|
+
if (!runsCli || !runsWatch) continue;
|
|
134
|
+
const label = /<key>Label<\/key>\s*<string>([^<]+)<\/string>/.exec(text)?.[1] || name.replace(/\.plist$/, '');
|
|
135
|
+
out.push({ label, file });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function launchctl(args, { quiet = true } = {}) {
|
|
141
|
+
try {
|
|
142
|
+
execFileSync('launchctl', args, { stdio: quiet ? 'ignore' : 'inherit' });
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function domain() {
|
|
150
|
+
return `gui/${process.getuid?.() ?? ''}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Load a plist, preferring the modern API and falling back to the legacy one. */
|
|
154
|
+
function bootstrap(file) {
|
|
155
|
+
if (launchctl(['bootstrap', domain(), file])) return true;
|
|
156
|
+
return launchctl(['load', file]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function bootout(label, file) {
|
|
160
|
+
const byLabel = launchctl(['bootout', `${domain()}/${label}`]);
|
|
161
|
+
const byFile = launchctl(['unload', file]);
|
|
162
|
+
return byLabel || byFile;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Install (or reinstall) the agent and start it.
|
|
167
|
+
* @returns {{plist:string, removed:{label:string,file:string}[], started:boolean}}
|
|
168
|
+
*/
|
|
169
|
+
export function install() {
|
|
170
|
+
if (!supported()) {
|
|
171
|
+
throw Object.assign(new Error('a launch agent needs macOS'), {
|
|
172
|
+
hint: 'On Linux, run `tokenflow watch` from a systemd --user unit, or `tokenflow watch --once` from cron.',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const home = homeDir();
|
|
176
|
+
const cli = cliPath();
|
|
177
|
+
if (!fs.existsSync(cli)) throw new Error(`cannot find the CLI at ${cli}`);
|
|
178
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
179
|
+
fs.mkdirSync(home, { recursive: true });
|
|
180
|
+
|
|
181
|
+
// Any other watcher agent would fight this one for the lock forever. Unload
|
|
182
|
+
// it AND delete its plist — an unloaded file returns at the next login.
|
|
183
|
+
const removed = findForeignAgents();
|
|
184
|
+
for (const a of removed) {
|
|
185
|
+
bootout(a.label, a.file);
|
|
186
|
+
try { fs.unlinkSync(a.file); } catch { /* already gone */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const file = plistPath();
|
|
190
|
+
fs.writeFileSync(file, renderPlist({
|
|
191
|
+
nodeBin: process.execPath,
|
|
192
|
+
cli,
|
|
193
|
+
home,
|
|
194
|
+
workingDir: path.resolve(path.dirname(cli), '..'),
|
|
195
|
+
}));
|
|
196
|
+
bootout(LABEL, file); // a reinstall must replace, not duplicate
|
|
197
|
+
const started = bootstrap(file);
|
|
198
|
+
return { plist: file, removed, started };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function uninstall() {
|
|
202
|
+
const file = plistPath();
|
|
203
|
+
const had = fs.existsSync(file);
|
|
204
|
+
bootout(LABEL, file);
|
|
205
|
+
if (had) {
|
|
206
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
207
|
+
}
|
|
208
|
+
return { removed: had, plist: file };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** @returns {{supported:boolean, installed:boolean, loaded:boolean, plist:string, foreign:{label:string,file:string}[]}} */
|
|
212
|
+
export function status() {
|
|
213
|
+
const file = plistPath();
|
|
214
|
+
let loaded = false;
|
|
215
|
+
try {
|
|
216
|
+
const out = execFileSync('launchctl', ['list'], { encoding: 'utf8' });
|
|
217
|
+
loaded = out.split('\n').some((l) => l.trim().endsWith(LABEL));
|
|
218
|
+
} catch { /* launchctl unavailable */ }
|
|
219
|
+
return {
|
|
220
|
+
supported: supported(),
|
|
221
|
+
installed: fs.existsSync(file),
|
|
222
|
+
loaded,
|
|
223
|
+
plist: file,
|
|
224
|
+
foreign: findForeignAgents(),
|
|
225
|
+
};
|
|
226
|
+
}
|