@vimoxshah/tokenflow 1.1.0 → 1.1.1
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 +84 -0
- package/README.md +50 -1
- package/bin/tokenflow.js +222 -9
- package/package.json +2 -1
- 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
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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watcher's single-instance lock.
|
|
3
|
+
*
|
|
4
|
+
* A pidfile alone cannot answer "is my watcher running?". PID numbers restart
|
|
5
|
+
* at boot and the kernel reuses them, so a pidfile that outlives a reboot
|
|
6
|
+
* eventually names somebody else's process. That is not hypothetical: a lock
|
|
7
|
+
* left at pid 810 was inherited by `/usr/libexec/mobilerepaird` after a
|
|
8
|
+
* restart, `kill(810, 0)` kept succeeding, and every `tokenflow watch` — from
|
|
9
|
+
* the launch agent and from the menu bar's play button alike — refused to
|
|
10
|
+
* start with "a watcher is already running" for days. The data silently went
|
|
11
|
+
* stale behind a lock held by a phantom.
|
|
12
|
+
*
|
|
13
|
+
* So the lock records an IDENTITY, not just a number:
|
|
14
|
+
*
|
|
15
|
+
* pid the process to signal
|
|
16
|
+
* boot epoch ms of the boot the pid was issued by
|
|
17
|
+
* startedAt when the watcher took the lock (human-readable diagnostics)
|
|
18
|
+
*
|
|
19
|
+
* A lock is live only when the pid is alive AND its boot stamp matches this
|
|
20
|
+
* boot. A pidfile from an earlier boot is stale by construction, whoever holds
|
|
21
|
+
* that number now.
|
|
22
|
+
*
|
|
23
|
+
* Legacy bare-number pidfiles carry no boot stamp, so they fall back to asking
|
|
24
|
+
* the OS who owns the number: a command line without "tokenflow" in it is
|
|
25
|
+
* somebody else's process and the lock is stale.
|
|
26
|
+
*
|
|
27
|
+
* This module owns the lock so that both the watcher and the read-only status
|
|
28
|
+
* surfaces can consult it without importing each other.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
32
|
+
import { execFileSync } from 'node:child_process';
|
|
33
|
+
import { paths } from './config.js';
|
|
34
|
+
|
|
35
|
+
export const LOCK_VERSION = 2;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Boot stamps are derived from uptime, which the OS reports in whole seconds,
|
|
39
|
+
* so two readings inside one boot can differ by a second or two. Anything
|
|
40
|
+
* inside this window is the same boot; a reboot moves the stamp by at least
|
|
41
|
+
* the previous session's uptime.
|
|
42
|
+
*/
|
|
43
|
+
const BOOT_TOLERANCE_MS = 30000;
|
|
44
|
+
|
|
45
|
+
/** Epoch ms of the last boot. Stable to ~1s for the life of the boot. */
|
|
46
|
+
export function bootTimeMs() {
|
|
47
|
+
return Date.now() - os.uptime() * 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {{pid:number, boot:number|null, startedAt:string|null, legacy:boolean}} WatchLock
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Read the lock file. Accepts both the JSON form and the legacy bare number. */
|
|
55
|
+
export function readLock() {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(paths().watchPid, 'utf8').trim();
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!raw) return null;
|
|
63
|
+
if (raw.startsWith('{')) {
|
|
64
|
+
try {
|
|
65
|
+
const o = JSON.parse(raw);
|
|
66
|
+
const pid = Number(o.pid);
|
|
67
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
68
|
+
const boot = Number(o.boot);
|
|
69
|
+
return {
|
|
70
|
+
pid,
|
|
71
|
+
boot: Number.isFinite(boot) ? boot : null,
|
|
72
|
+
startedAt: typeof o.startedAt === 'string' ? o.startedAt : null,
|
|
73
|
+
legacy: false,
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const pid = Number(raw);
|
|
80
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
81
|
+
return { pid, boot: null, startedAt: null, legacy: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Just the pid, for callers that only want to signal it. */
|
|
85
|
+
export function readLockPid() {
|
|
86
|
+
return readLock()?.pid ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Does SOME process hold this pid? (EPERM = alive, owned by someone else.) */
|
|
90
|
+
export function processAlive(pid) {
|
|
91
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return err.code !== 'ESRCH';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The command line behind a pid, or null when the OS will not say.
|
|
102
|
+
* Cheap enough for lock checks; never called in a loop.
|
|
103
|
+
*/
|
|
104
|
+
export function processCommand(pid) {
|
|
105
|
+
try {
|
|
106
|
+
if (process.platform === 'linux') {
|
|
107
|
+
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
108
|
+
return raw.replace(/\0/g, ' ').trim() || null;
|
|
109
|
+
}
|
|
110
|
+
const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
timeout: 2000,
|
|
113
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
114
|
+
});
|
|
115
|
+
return out.trim() || null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this lock held by a live watcher of OURS?
|
|
123
|
+
*
|
|
124
|
+
* @param {WatchLock|null} lock
|
|
125
|
+
*/
|
|
126
|
+
export function lockIsLive(lock) {
|
|
127
|
+
if (!lock || !processAlive(lock.pid)) return false;
|
|
128
|
+
if (lock.boot !== null) {
|
|
129
|
+
return Math.abs(lock.boot - bootTimeMs()) <= BOOT_TOLERANCE_MS;
|
|
130
|
+
}
|
|
131
|
+
// No boot stamp to check: ask who owns the number instead. When the OS
|
|
132
|
+
// will not say, keep the lock — refusing to start is safer than two
|
|
133
|
+
// watchers racing on one store.
|
|
134
|
+
const cmd = processCommand(lock.pid);
|
|
135
|
+
if (cmd === null) return true;
|
|
136
|
+
return /tokenflow/i.test(cmd);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Write this process in as the lock holder. */
|
|
140
|
+
export function writeLock() {
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
paths().watchPid,
|
|
143
|
+
`${JSON.stringify({
|
|
144
|
+
v: LOCK_VERSION,
|
|
145
|
+
pid: process.pid,
|
|
146
|
+
boot: Math.round(bootTimeMs()),
|
|
147
|
+
startedAt: new Date().toISOString(),
|
|
148
|
+
})}\n`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Remove the lock file. */
|
|
153
|
+
export function clearLock() {
|
|
154
|
+
try {
|
|
155
|
+
fs.unlinkSync(paths().watchPid);
|
|
156
|
+
return true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false; // already gone — that is fine
|
|
159
|
+
}
|
|
160
|
+
}
|