@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.
@@ -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, embedding the absolute paths of this clone's
8
- # node binary and CLI so the app can drive refresh/watch actions.
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 -h "$APP" | cut -f1 | tr -d ' ')
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 || '1.1.0',
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,
@@ -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, last error) is carried over from the
328
- * cached file: a slow poll must never make the UI claim no watcher is running.
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
- if (cached?.watcher) fresh.watcher = cached.watcher;
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, date: d.date,
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(),
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }