@vimoxshah/tokenflow 1.1.0

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.
Files changed (101) hide show
  1. package/CONTRIBUTING.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/Refresh & Open Dashboard.command +22 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/tokenflow.js +1342 -0
  7. package/docs/architecture.md +193 -0
  8. package/docs/cli.md +390 -0
  9. package/docs/configuration.md +281 -0
  10. package/docs/creating-provider.md +262 -0
  11. package/docs/data-model.md +213 -0
  12. package/docs/getting-started.md +266 -0
  13. package/docs/live-mode.md +199 -0
  14. package/docs/media/architecture-hero.svg +86 -0
  15. package/docs/media/cost-editorial-dark.png +0 -0
  16. package/docs/media/health-terminal-light.png +0 -0
  17. package/docs/media/menubar-dark.png +0 -0
  18. package/docs/media/menubar-light.png +0 -0
  19. package/docs/media/models-terminal-dark.png +0 -0
  20. package/docs/media/overview-aurora-dark.png +0 -0
  21. package/docs/media/time-aurora-light.png +0 -0
  22. package/docs/providers.md +309 -0
  23. package/docs/skill.md +64 -0
  24. package/docs/troubleshooting.md +207 -0
  25. package/examples/config.example.yaml +92 -0
  26. package/examples/demo-data/README.md +38 -0
  27. package/examples/demo-data/sample-usage.csv +11 -0
  28. package/package.json +74 -0
  29. package/scripts/build-dmg.sh +33 -0
  30. package/scripts/build-menubar-app.sh +67 -0
  31. package/scripts/lint.js +111 -0
  32. package/scripts/validate-install.js +140 -0
  33. package/skills/tokenflow/SKILL.md +392 -0
  34. package/skills/tokenflow/examples/config.yaml +92 -0
  35. package/skills/tokenflow/examples/generic-mapping.json +26 -0
  36. package/skills/tokenflow/examples/session-transcript.md +191 -0
  37. package/skills/tokenflow/providers/adapter-template.js +135 -0
  38. package/skills/tokenflow/providers/detection-matrix.md +142 -0
  39. package/skills/tokenflow/schemas/config.schema.json +107 -0
  40. package/skills/tokenflow/schemas/normalized-record.json +63 -0
  41. package/src/analytics/aggregate.js +247 -0
  42. package/src/analytics/anomalies.js +222 -0
  43. package/src/analytics/capacity.js +278 -0
  44. package/src/analytics/comparison.js +96 -0
  45. package/src/analytics/dimensions.js +230 -0
  46. package/src/analytics/efficiency.js +138 -0
  47. package/src/analytics/forecast.js +202 -0
  48. package/src/analytics/index.js +327 -0
  49. package/src/analytics/insights.js +283 -0
  50. package/src/analytics/milestones.js +91 -0
  51. package/src/analytics/peak.js +106 -0
  52. package/src/analytics/productivity.js +166 -0
  53. package/src/analytics/token-usage.js +267 -0
  54. package/src/commands/diagnostics.js +88 -0
  55. package/src/commands/digest.js +155 -0
  56. package/src/commands/models-compare.js +96 -0
  57. package/src/core/budget.js +142 -0
  58. package/src/core/bundle.js +191 -0
  59. package/src/core/config.js +202 -0
  60. package/src/core/delivery.js +109 -0
  61. package/src/core/geo.js +99 -0
  62. package/src/core/ingest.js +457 -0
  63. package/src/core/interface-map.js +55 -0
  64. package/src/core/jsonl.js +124 -0
  65. package/src/core/live-status.js +417 -0
  66. package/src/core/model-map.js +157 -0
  67. package/src/core/notify.js +83 -0
  68. package/src/core/pricing.js +288 -0
  69. package/src/core/prompt-analytics.js +127 -0
  70. package/src/core/registry.js +107 -0
  71. package/src/core/restore.js +261 -0
  72. package/src/core/schedule.js +120 -0
  73. package/src/core/schema.js +316 -0
  74. package/src/core/sqlite.js +96 -0
  75. package/src/core/store.js +493 -0
  76. package/src/core/sync.js +151 -0
  77. package/src/core/units.js +147 -0
  78. package/src/core/validate.js +123 -0
  79. package/src/core/watch.js +287 -0
  80. package/src/core/yaml.js +209 -0
  81. package/src/export/bundler.js +107 -0
  82. package/src/export/csv.js +100 -0
  83. package/src/export/html-snapshot.js +101 -0
  84. package/src/export/menubar.js +158 -0
  85. package/src/index.js +18 -0
  86. package/src/providers/anthropic/index.js +294 -0
  87. package/src/providers/cline/index.js +120 -0
  88. package/src/providers/cursor/index.js +143 -0
  89. package/src/providers/generic/index.js +268 -0
  90. package/src/providers/git/index.js +188 -0
  91. package/src/providers/headroom/index.js +114 -0
  92. package/src/providers/hermes/index.js +299 -0
  93. package/src/providers/mock/index.js +117 -0
  94. package/src/providers/openai/index.js +370 -0
  95. package/src/providers/opencode/index.js +245 -0
  96. package/src/sdk.js +46 -0
  97. package/src/server/server.js +264 -0
  98. package/src/ui/app.js +2473 -0
  99. package/src/ui/charts.js +925 -0
  100. package/src/ui/index.html +42 -0
  101. package/src/ui/styles.css +644 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Number / date formatting shared by the CLI, the API and the browser bundle.
3
+ * Kept dependency-free and side-effect-free so it can be inlined into the
4
+ * static HTML snapshot verbatim.
5
+ */
6
+
7
+ const UNITS = [
8
+ { v: 1e12, s: 'T' },
9
+ { v: 1e9, s: 'B' },
10
+ { v: 1e6, s: 'M' },
11
+ { v: 1e3, s: 'K' },
12
+ ];
13
+
14
+ /**
15
+ * Compact token formatting. Handles billions/trillions without losing
16
+ * readability and never renders a raw 12-digit integer.
17
+ * 1_742_000_000 -> "1.74B"
18
+ * @param {number|null|undefined} n
19
+ * @param {{digits?:number, na?:string}} [opt]
20
+ */
21
+ export function compact(n, opt = {}) {
22
+ const na = opt.na ?? '—';
23
+ if (n === null || n === undefined || Number.isNaN(n)) return na;
24
+ const neg = n < 0;
25
+ const a = Math.abs(n);
26
+ if (a < 1000) {
27
+ const s = Number.isInteger(a) ? String(a) : a.toFixed(opt.digits ?? 1);
28
+ return (neg ? '-' : '') + s;
29
+ }
30
+ for (const u of UNITS) {
31
+ if (a >= u.v) {
32
+ const q = a / u.v;
33
+ // 3 significant figures reads best across K/M/B/T
34
+ const d = opt.digits !== undefined ? opt.digits : q >= 100 ? 0 : q >= 10 ? 1 : 2;
35
+ // Trim trailing zeros so 2.50T reads as 2.5T and 1.00B as 1B.
36
+ const q2 = q.toFixed(d).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
37
+ return (neg ? '-' : '') + q2 + u.s;
38
+ }
39
+ }
40
+ return String(n);
41
+ }
42
+
43
+ /** Thousands-separated integer, for table cells and tooltips. */
44
+ export function int(n, na = '—') {
45
+ if (n === null || n === undefined || Number.isNaN(n)) return na;
46
+ return Math.round(n).toLocaleString('en-US');
47
+ }
48
+
49
+ /** @param {number|null|undefined} n */
50
+ export function usd(n, na = '—') {
51
+ if (n === null || n === undefined || Number.isNaN(n)) return na;
52
+ const a = Math.abs(n);
53
+ if (a === 0) return '$0.00';
54
+ if (a < 0.01) return '$' + n.toFixed(4);
55
+ if (a < 1000) return '$' + n.toFixed(2);
56
+ if (a < 1e6) return '$' + (n / 1e3).toFixed(1) + 'K';
57
+ return '$' + (n / 1e6).toFixed(2) + 'M';
58
+ }
59
+
60
+ /** @param {number|null|undefined} f fraction 0..1 */
61
+ export function pct(f, digits = 1, na = '—') {
62
+ if (f === null || f === undefined || Number.isNaN(f) || !Number.isFinite(f)) return na;
63
+ return (f * 100).toFixed(digits) + '%';
64
+ }
65
+
66
+ /** Signed percentage change, for comparison mode and trend chips. */
67
+ export function delta(cur, prev, digits = 0) {
68
+ if (prev === null || prev === undefined || cur === null || cur === undefined) return null;
69
+ if (prev === 0) return cur === 0 ? 0 : null; // undefined growth from zero base
70
+ return (cur - prev) / prev;
71
+ }
72
+
73
+ export function signedPct(f, digits = 0) {
74
+ if (f === null || f === undefined || !Number.isFinite(f)) return '—';
75
+ const s = f > 0 ? '+' : '';
76
+ return s + (f * 100).toFixed(digits) + '%';
77
+ }
78
+
79
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
80
+ export const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
81
+
82
+ /** "2026-08-12" -> "Aug 12" */
83
+ export function shortDate(iso) {
84
+ if (!iso) return '—';
85
+ const [y, m, d] = iso.split('-').map(Number);
86
+ return `${MONTHS[m - 1]} ${d}`;
87
+ }
88
+
89
+ /** "2026-08-12" -> "Aug 12, 2026" */
90
+ export function longDate(iso) {
91
+ if (!iso) return '—';
92
+ const [y, m, d] = iso.split('-').map(Number);
93
+ return `${MONTHS[m - 1]} ${d}, ${y}`;
94
+ }
95
+
96
+ /** Hour label for axis ticks: 0 -> "00", 14 -> "14". */
97
+ export function hourLabel(h) {
98
+ return String(h).padStart(2, '0');
99
+ }
100
+
101
+ /** "10:00 – 13:00" from an inclusive hour window. */
102
+ export function hourWindow(a, b) {
103
+ return `${hourLabel(a)}:00 – ${hourLabel((b + 1) % 24)}:00`;
104
+ }
105
+
106
+ export function humanDuration(ms) {
107
+ if (ms === null || ms === undefined) return '—';
108
+ const s = Math.round(ms / 1000);
109
+ if (s < 60) return s + 's';
110
+ const m = Math.floor(s / 60);
111
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
112
+ const h = Math.floor(m / 60);
113
+ if (h < 24) return h + 'h ' + (m % 60) + 'm';
114
+ return Math.floor(h / 24) + 'd ' + (h % 24) + 'h';
115
+ }
116
+
117
+ export function relativeTime(iso, now = Date.now()) {
118
+ if (!iso) return 'never';
119
+ const diff = now - new Date(iso).getTime();
120
+ if (diff < 0) return 'just now';
121
+ const s = Math.floor(diff / 1000);
122
+ if (s < 45) return 'just now';
123
+ if (s < 90) return '1 min ago';
124
+ const m = Math.floor(s / 60);
125
+ if (m < 60) return `${m} min ago`;
126
+ const h = Math.floor(m / 60);
127
+ if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`;
128
+ const d = Math.floor(h / 24);
129
+ if (d < 30) return `${d} day${d === 1 ? '' : 's'} ago`;
130
+ return longDate(iso.slice(0, 10));
131
+ }
132
+
133
+ /**
134
+ * "2h 13m" / "3d 4h" / "45s" from milliseconds — empty unit tails are dropped
135
+ * ("5h", not "5h 0m"). Null-safe: unknown stays null rather than pretending.
136
+ */
137
+ export function countdown(ms) {
138
+ if (ms === null || ms === undefined || !Number.isFinite(ms)) return null;
139
+ const s = Math.max(0, Math.round(ms / 1000));
140
+ if (s < 60) return `${s}s`;
141
+ const m = Math.floor(s / 60);
142
+ if (m < 60) return `${m}m`;
143
+ const h = Math.floor(m / 60);
144
+ if (h < 48) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
145
+ const d = Math.floor(h / 24);
146
+ return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
147
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Validation for normalized records and for provider definitions.
3
+ * Used by the adapter tests, by `tokenflow validate`, and by the generic
4
+ * importer before it accepts a mapped file.
5
+ */
6
+ import { BILLABLE_TOKEN_FIELDS, BREAKDOWN_TOKEN_FIELDS, INTERFACE, MEASUREMENT, computeTotal } from './schema.js';
7
+
8
+ const IFACES = new Set(Object.values(INTERFACE));
9
+ const MEAS = new Set(Object.values(MEASUREMENT));
10
+
11
+ /**
12
+ * @param {object} r
13
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
14
+ */
15
+ export function validateUsage(r) {
16
+ const errors = [];
17
+ const warnings = [];
18
+
19
+ if (!r || typeof r !== 'object') return { ok: false, errors: ['record is not an object'], warnings };
20
+
21
+ if (!r.timestamp || Number.isNaN(new Date(r.timestamp).getTime())) errors.push('timestamp missing or unparseable');
22
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(r.date || '')) errors.push('date must be YYYY-MM-DD');
23
+ if (!(Number.isInteger(r.hour) && r.hour >= 0 && r.hour <= 23)) errors.push('hour must be 0-23');
24
+ if (!(Number.isInteger(r.dow) && r.dow >= 0 && r.dow <= 6)) errors.push('dow must be 0-6 (0=Mon)');
25
+ if (!r.provider) errors.push('provider missing');
26
+ if (!r.model) errors.push('model missing');
27
+ if (!IFACES.has(r.interface)) errors.push(`interface "${r.interface}" is not one of ${[...IFACES].join('|')}`);
28
+ if (!MEAS.has(r.measurement)) errors.push(`measurement "${r.measurement}" is invalid`);
29
+ if (!r.source) errors.push('source missing');
30
+ if (!r.id) errors.push('id missing');
31
+
32
+ for (const f of [...BILLABLE_TOKEN_FIELDS, ...BREAKDOWN_TOKEN_FIELDS]) {
33
+ const v = r[f];
34
+ if (v === null) continue;
35
+ if (typeof v !== 'number' || !Number.isFinite(v)) errors.push(`${f} must be a finite number or null`);
36
+ else if (v < 0) errors.push(`${f} must not be negative`);
37
+ }
38
+
39
+ // Subset invariants — the whole point of separating breakdown fields.
40
+ if (r.reasoning_tokens !== null && r.output_tokens !== null && r.reasoning_tokens > r.output_tokens) {
41
+ errors.push('reasoning_tokens exceeds output_tokens (it must be a subset)');
42
+ }
43
+ if (r.cache_refresh_tokens !== null && r.cache_write_tokens !== null && r.cache_refresh_tokens > r.cache_write_tokens) {
44
+ errors.push('cache_refresh_tokens exceeds cache_write_tokens (it must be a subset)');
45
+ }
46
+
47
+ const t = computeTotal(r);
48
+ if (r.total_tokens !== t.total) errors.push(`total_tokens (${r.total_tokens}) != sum of billable fields (${t.total})`);
49
+ if (!!r.total_is_partial !== t.partial) errors.push('total_is_partial disagrees with the field availability');
50
+
51
+ if (r.measurement === MEASUREMENT.PRIMARY && t.total === null) {
52
+ warnings.push('primary record with no token data at all — consider measurement "activity"');
53
+ }
54
+ if (r.estimated_cost !== null && !r.cost_basis) errors.push('estimated_cost present but cost_basis is null');
55
+ if (r.cost_basis && r.estimated_cost === null) errors.push('cost_basis present but estimated_cost is null');
56
+
57
+ return { ok: errors.length === 0, errors, warnings };
58
+ }
59
+
60
+ /** @param {object} p provider definition */
61
+ export function validateProvider(p) {
62
+ const errors = [];
63
+ if (!p || typeof p !== 'object') return { ok: false, errors: ['provider is not an object'] };
64
+ if (!p.id || !/^[a-z0-9][a-z0-9-]*$/.test(p.id)) errors.push('id must be a lowercase slug');
65
+ if (!p.name) errors.push('name is required');
66
+ if (typeof p.detect !== 'function') errors.push('detect() is required');
67
+ const hasIngest = typeof p.ingestFile === 'function' || typeof p.fetchUsage === 'function';
68
+ if (!hasIngest) errors.push('either ingestFile() or fetchUsage() is required');
69
+ if (typeof p.ingestFile === 'function' && typeof p.discover !== 'function') {
70
+ errors.push('ingestFile() requires discover() to enumerate source files');
71
+ }
72
+ if (p.measurement && !MEAS.has(p.measurement)) errors.push(`measurement "${p.measurement}" is invalid`);
73
+ return { ok: errors.length === 0, errors };
74
+ }
75
+
76
+ /** Aggregate data-quality report used by the Data Health indicator. */
77
+ export function summarizeQuality(cube, sessions, state) {
78
+ const D = cube.dims.length;
79
+ const mi = (n) => D + cube.measures.indexOf(n);
80
+ let req = 0, naIn = 0, naOut = 0, naCr = 0, naCw = 0, tokens = 0;
81
+ const providers = new Set(), models = new Set(), clients = new Set(), ifaces = new Set();
82
+ let minD = null, maxD = null;
83
+ for (const r of cube.rows) {
84
+ req += r[mi('req')];
85
+ naIn += r[mi('naIn')]; naOut += r[mi('naOut')]; naCr += r[mi('naCr')]; naCw += r[mi('naCw')];
86
+ tokens += r[mi('in')] + r[mi('out')] + r[mi('cr')] + r[mi('cw')];
87
+ providers.add(r[3]); models.add(r[4]); clients.add(r[6]); ifaces.add(r[7]);
88
+ if (minD === null || r[0] < minD) minD = r[0];
89
+ if (maxD === null || r[0] > maxD) maxD = r[0];
90
+ }
91
+ const cells = req * 4;
92
+ const missing = cells ? (naIn + naOut + naCr + naCw) / cells : 0;
93
+ const files = Object.values(state.sources || {}).reduce((a, s) => a + Object.keys(s.files || {}).length, 0);
94
+ let grade = 'Excellent';
95
+ if (missing > 0.4) grade = 'Fair';
96
+ else if (missing > 0.12) grade = 'Good';
97
+ if (req === 0) grade = 'No data';
98
+ return {
99
+ grade,
100
+ records: req,
101
+ tokens,
102
+ sessions: Object.keys(sessions.rows || {}).length,
103
+ sourceFiles: files,
104
+ coverage: { from: minD, to: maxD },
105
+ providers: providers.size,
106
+ models: models.size,
107
+ clients: clients.size,
108
+ interfaces: ifaces.size,
109
+ missingTokenFieldRate: missing,
110
+ missingByField: {
111
+ input_tokens: req ? naIn / req : 0,
112
+ output_tokens: req ? naOut / req : 0,
113
+ cache_read_tokens: req ? naCr / req : 0,
114
+ cache_write_tokens: req ? naCw / req : 0,
115
+ },
116
+ // Structural dedup: identical bytes are never read twice, so the duplicate
117
+ // count is a fact about the ingest model, not an estimate.
118
+ duplicateRecords: 0,
119
+ malformedLines: state.counters?.malformed ?? 0,
120
+ staleGenerations: (state.stale || []).length,
121
+ lastRefresh: state.lastRefresh,
122
+ };
123
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The watch daemon — `tokenflow watch`.
3
+ *
4
+ * A single background process that keeps TokenFlow live: incremental refresh
5
+ * on an interval, a live status snapshot after every cycle, and (opt-in) OS
6
+ * notifications when a limit crosses its threshold or a high-severity anomaly
7
+ * appears.
8
+ *
9
+ * Design constraints this file takes seriously:
10
+ *
11
+ * Single instance. A pidfile guards against two watchers racing on the same
12
+ * store; a stale pidfile from a crashed run is detected and replaced.
13
+ * Failure isolation. One bad provider cannot stop the loop: refresh errors
14
+ * are recorded into the status file and back off exponentially instead.
15
+ * Sleep/wake. The loop reschedules from wall-clock reality every tick, so a
16
+ * laptop that slept for three hours simply refreshes once on wake rather
17
+ * than trying to "catch up" with a burst of cycles.
18
+ * Nothing leaves the machine. Refresh reads local logs; notifications go to
19
+ * the local OS; the status file stays in $TOKENFLOW_HOME.
20
+ */
21
+ import fs from 'node:fs';
22
+ import { loadConfig, paths, ensureDirs } from './config.js';
23
+ import { refresh } from './ingest.js';
24
+ import { listProviders } from './registry.js';
25
+ import { buildLiveStatus, writeLiveStatus, readLiveStatus } from './live-status.js';
26
+ import { notify as osNotify } from './notify.js';
27
+
28
+ const MAX_BACKOFF_MS = 15 * 60 * 1000;
29
+
30
+ /** Exponential backoff between cycles: base × 2^failures, capped. */
31
+ export function computeDelayMs(baseMs, consecutiveFailures) {
32
+ if (!Number.isFinite(baseMs) || baseMs <= 0) return MAX_BACKOFF_MS;
33
+ const f = Math.max(0, Math.floor(consecutiveFailures || 0));
34
+ return Math.min(baseMs * 2 ** f, MAX_BACKOFF_MS);
35
+ }
36
+
37
+ /**
38
+ * Compare two live-status snapshots and return the alert-worthy transitions.
39
+ *
40
+ * Pure function — the daemon's notification policy lives here so it can be
41
+ * tested without spawning anything:
42
+ * - a configured limit moving ok→warn / warn→exceeded / ok→exceeded
43
+ * - a limit recovering back to ok
44
+ * - high-severity anomalies dated TODAY that were not in the previous
45
+ * snapshot. Historical highs are data, not news: a first cycle must not
46
+ * replay last week's spikes as notifications.
47
+ */
48
+ export function detectTransitions(prev, next) {
49
+ const out = [];
50
+ if (!next) return out;
51
+ const today = String(next.generatedAt || '').slice(0, 10);
52
+ const prevLimits = new Map((prev?.capacity?.states || []).map((s) => [s.id, s.status]));
53
+ for (const s of next.capacity?.states || []) {
54
+ const before = prevLimits.get(s.id);
55
+ if (before === s.status) continue;
56
+ if (s.status === 'exceeded') {
57
+ out.push({
58
+ kind: 'limit', id: s.id,
59
+ title: `Limit exceeded: ${s.label}`,
60
+ body: `${Math.round((s.pctUsed ?? 1) * 100)}% of ${s.scope} ${s.metric}. Resets in ${humanize(s.resetsInMs)}.`,
61
+ });
62
+ } else if (s.status === 'warn' && before !== undefined && before !== 'warn') {
63
+ out.push({
64
+ kind: 'limit', id: s.id,
65
+ title: `Approaching limit: ${s.label}`,
66
+ body: `${Math.round(s.pctUsed * 100)}% of ${s.scope} ${s.metric} used.`,
67
+ });
68
+ } else if (s.status === 'ok' && (before === 'warn' || before === 'exceeded')) {
69
+ out.push({ kind: 'recovered', id: s.id, title: `Limit recovered: ${s.label}`, body: 'Window reset or usage revised.' });
70
+ }
71
+ }
72
+ // Anomalies alert only when they happened today AND we have not announced
73
+ // them before. A rebuilt status file therefore stays quiet about history.
74
+ const prevAnomalies = new Set((prev?.anomalies || []).map((a) => a.id));
75
+ for (const a of next.anomalies || []) {
76
+ if (a.severity !== 'high') continue;
77
+ if (!today || a.date !== today) continue;
78
+ if (prevAnomalies.has(a.id)) continue;
79
+ out.push({ kind: 'anomaly', id: a.id, title: `Unusual ${(a.type || 'event').replace(/_/g, ' ')}`, body: a.detail });
80
+ }
81
+ // Milestones celebrate only on their first sighting too — the same record
82
+ // broken twice is one party, not two.
83
+ const prevMilestones = new Set((prev?.milestones || []).map((m) => m.id));
84
+ for (const m of next.milestones || []) {
85
+ if (!today || !m.id.endsWith(`:${today}`)) continue;
86
+ if (prevMilestones.has(m.id)) continue;
87
+ out.push({ kind: 'milestone', id: m.id, title: `${m.icon} ${m.title}`, body: m.detail });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function humanize(ms) {
93
+ if (!Number.isFinite(ms)) return '?';
94
+ const m = Math.round(ms / 60000);
95
+ if (m < 60) return `${m}m`;
96
+ const h = Math.floor(m / 60);
97
+ if (h < 48) return `${h}h ${m % 60}m`;
98
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
99
+ }
100
+
101
+ // ------------------------------------------------------------- instance ----
102
+
103
+ function readLockPid() {
104
+ try {
105
+ const pid = Number(fs.readFileSync(paths().watchPid, 'utf8').trim());
106
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function processAlive(pid) {
113
+ try {
114
+ process.kill(pid, 0);
115
+ return true;
116
+ } catch (err) {
117
+ // ESRCH = no such process; EPERM = alive but owned by someone else.
118
+ return err.code !== 'ESRCH';
119
+ }
120
+ }
121
+ export { processAlive };
122
+
123
+ /** Set while THIS process runs a watcher loop — guards same-process double start. */
124
+ let ownedHere = false;
125
+
126
+ /**
127
+ * Guard the single-instance rule. Throws with a readable hint when another
128
+ * watcher already owns this store (including an earlier startWatch in this
129
+ * very process); silently replaces a dead one left by a crash.
130
+ */
131
+ export function acquireWatchLock() {
132
+ if (ownedHere) {
133
+ throw new Error('a watcher is already running in this process');
134
+ }
135
+ ensureDirs();
136
+ const existing = readLockPid();
137
+ if (existing && processAlive(existing)) {
138
+ throw Object.assign(new Error(`a watcher is already running (pid ${existing})`), {
139
+ hint: '`tokenflow watch --status` shows it; `tokenflow watch --stop` stops it.',
140
+ });
141
+ }
142
+ fs.writeFileSync(paths().watchPid, String(process.pid));
143
+ ownedHere = true;
144
+ }
145
+
146
+ export function releaseWatchLock() {
147
+ const p = paths().watchPid;
148
+ const pid = readLockPid();
149
+ if (pid === process.pid) {
150
+ try { fs.unlinkSync(p); } catch { /* already gone — that is fine */ }
151
+ }
152
+ ownedHere = false;
153
+ }
154
+
155
+ /** Is a watcher running right now (and does it own the lock)? */
156
+ export function watchIsRunning() {
157
+ const pid = readLockPid();
158
+ return !!(pid && processAlive(pid));
159
+ }
160
+
161
+ export function stopWatch() {
162
+ const pid = readLockPid();
163
+ if (!pid || !processAlive(pid)) {
164
+ try { fs.unlinkSync(paths().watchPid); } catch { /* nothing to clean */ }
165
+ return { stopped: false, reason: 'not running' };
166
+ }
167
+ try {
168
+ process.kill(pid, 'SIGTERM');
169
+ return { stopped: true, pid };
170
+ } catch (err) {
171
+ return { stopped: false, reason: err.message };
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------- cycle ----
176
+
177
+ let inCycle = false;
178
+
179
+ /**
180
+ * One pass: refresh → build status → notify transitions → persist.
181
+ *
182
+ * `daemon` identifies the caller: a long-running watcher (startWatch) passes
183
+ * `{ intervalSeconds }` so the snapshot can say "running"; one-shot callers
184
+ * (`watch --once`, tests, CI) pass nothing and the snapshot honestly reports
185
+ * that no watcher is running.
186
+ */
187
+ export async function runCycle(opt = {}) {
188
+ if (inCycle) return { skipped: true }; // coalesce overlapping triggers
189
+ inCycle = true;
190
+ const t0 = Date.now();
191
+ try {
192
+ const prev = readLiveStatus();
193
+ const report = await refresh({
194
+ // An explicit registry/providers override keeps tests and embedded uses
195
+ // hermetic: `registry: [], providers: []` touches no source at all.
196
+ registry: opt.registry || listProviders(),
197
+ providers: opt.providers,
198
+ deadlineMs: opt.refreshBudgetMs ?? 45000,
199
+ onProgress: opt.onProgress,
200
+ });
201
+
202
+ const status = buildLiveStatus({ config: opt.config });
203
+ status.watcher = opt.daemon
204
+ ? {
205
+ ...(prev?.watcher || {}),
206
+ pid: process.pid,
207
+ mode: 'daemon',
208
+ intervalSeconds: opt.daemon.intervalSeconds ?? null,
209
+ startedAt: prev?.watcher?.startedAt || new Date().toISOString(),
210
+ cycles: (prev?.watcher?.cycles || 0) + 1,
211
+ lastCycleAt: new Date().toISOString(),
212
+ lastCycleMs: Date.now() - t0,
213
+ lastRefreshReport: {
214
+ done: report.done,
215
+ newRecords: report.newRecords,
216
+ providers: (report.providers || []).map((p) => ({ id: p.id, status: p.status, records: p.records })),
217
+ },
218
+ }
219
+ : null;
220
+
221
+ const transitions = detectTransitions(prev, status);
222
+ status.lastCycle = { at: status.generatedAt, durationMs: Date.now() - t0, newRecords: report.newRecords };
223
+ writeLiveStatus(status);
224
+
225
+ if (opt.notifications && transitions.length) {
226
+ for (const tr of transitions.slice(0, 4)) await osNotify(tr);
227
+ }
228
+ return { skipped: false, transitions, report, durationMs: Date.now() - t0 };
229
+ } finally {
230
+ inCycle = false;
231
+ }
232
+ }
233
+
234
+ /** Record a failed cycle into the status file without losing the last good data. */
235
+ export function recordCycleError(err) {
236
+ const cur = readLiveStatus() || {};
237
+ cur.lastError = { message: String(err.message || err).slice(0, 300), at: new Date().toISOString() };
238
+ try {
239
+ writeLiveStatus(cur);
240
+ } catch {
241
+ /* disk-level failure: nothing more a watcher can do here */
242
+ }
243
+ }
244
+
245
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
246
+
247
+ /**
248
+ * Run the daemon until SIGINT/SIGTERM.
249
+ *
250
+ * @param {{intervalSeconds?:number, notifications?:boolean, config?:object,
251
+ * onCycle?:Function, maxCycles?:number}} opt
252
+ * `maxCycles` exists for tests/CI only — a production watcher never stops
253
+ * on its own.
254
+ */
255
+ export async function startWatch(opt = {}) {
256
+ const config = opt.config || loadConfig();
257
+ const intervalSeconds = Number(
258
+ opt.intervalSeconds ?? config.watch?.intervalSeconds ?? 120,
259
+ );
260
+ const notifications = opt.notifications ?? !!config.watch?.notifications;
261
+ acquireWatchLock();
262
+
263
+ let failures = 0;
264
+ let cycles = 0;
265
+ const baseMs = Math.max(10, intervalSeconds) * 1000;
266
+
267
+ while (true) {
268
+ try {
269
+ const r = await runCycle({
270
+ config,
271
+ daemon: { intervalSeconds },
272
+ notifications,
273
+ });
274
+ failures = 0;
275
+ cycles++;
276
+ opt.onCycle?.(r);
277
+ } catch (err) {
278
+ failures++;
279
+ recordCycleError(err);
280
+ opt.onCycle?.({ error: err.message });
281
+ }
282
+ if (opt.maxCycles && cycles >= opt.maxCycles) break;
283
+ // Reschedule from now, never from a schedule built at startup: a machine
284
+ // that slept through six intervals wakes to exactly one refresh.
285
+ await sleep(computeDelayMs(baseMs, failures));
286
+ }
287
+ }