@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/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
+ }
@@ -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
+ }