pyre-cli 1.3.0 → 2.0.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.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * System-metric collectors.
3
+ *
4
+ * Each `collect*` function queries a single category of
5
+ * macOS system data via shell commands and returns a
6
+ * strongly-typed result. `collectAll` orchestrates them
7
+ * in parallel and assembles the final {@link StatsData}
8
+ * object.
9
+ *
10
+ * All commands are wrapped in {@link run} so that a
11
+ * failure in one category never prevents the others from
12
+ * returning their best-effort values.
13
+ */
14
+ import chalk from 'chalk';
15
+ import { run } from './run.js';
16
+ import { getSmcMetrics, parseSuffix } from './smc.js';
17
+ import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess } from './types.js';
18
+
19
+ // Retain samples over a *time* window rather than a fixed tick count. At the
20
+ // default 2s refresh interval the old 10-sample window only spanned ~20s,
21
+ // but battery level only moves in whole-percent steps that typically take
22
+ // several minutes to change — so `discharged` was almost always 0 and the
23
+ // estimate never populated. A 15-minute time window plus a minimum elapsed
24
+ // time before we trust the numbers gives enough resolution to see a real
25
+ // drop while still updating reasonably often.
26
+ const BATTERY_WINDOW_MS = 15 * 60 * 1000;
27
+ const MIN_ESTIMATE_WINDOW_MS = 30 * 1000;
28
+
29
+ let prevBattery: { samples: { level: number; ts: number }[] } = { samples: [] };
30
+
31
+ function estimateBatteryLife(level: number, state: string): { estimatedTimeToEmpty?: string; dischargeRatePerHour?: number; powerWatts?: number } {
32
+ const now = Date.now();
33
+
34
+ if (state === 'charged' || state === 'charging') {
35
+ // Reset the window on a state change so a stale pre-charge sample
36
+ // doesn't get blended with post-charge readings once discharging
37
+ // resumes.
38
+ prevBattery.samples = [];
39
+ return {};
40
+ }
41
+
42
+ prevBattery.samples.push({ level, ts: now });
43
+ prevBattery.samples = prevBattery.samples.filter(s => now - s.ts <= BATTERY_WINDOW_MS);
44
+
45
+ const first = prevBattery.samples[0];
46
+ const elapsedMs = now - first.ts;
47
+ if (elapsedMs < MIN_ESTIMATE_WINDOW_MS) {
48
+ return {};
49
+ }
50
+
51
+ const dtHours = Math.max(elapsedMs / 1000 / 3600, 0.0001);
52
+ const discharged = first.level - level;
53
+ if (discharged <= 0) {
54
+ // No measurable drop yet within the window — report "calculating"
55
+ // rather than silently omitting the field, so the UI shows progress
56
+ // instead of a blank card.
57
+ return { estimatedTimeToEmpty: 'calculating' };
58
+ }
59
+
60
+ const dischargeRate = discharged / dtHours; // %/hour
61
+ const remainingLevel = Math.max(level, 0);
62
+ const hoursToEmpty = remainingLevel / dischargeRate;
63
+ const h = Math.floor(hoursToEmpty);
64
+ const m = Math.round((hoursToEmpty - h) * 60);
65
+ const estimatedTimeToEmpty = hoursToEmpty > 0 ? `${h}h ${m}m` : 'calculating';
66
+
67
+ // Rough wattage from discharge rate: %/hour * (typical battery capacity
68
+ // in Wh) / 100. This is only used as a last-resort estimate — collectPower()
69
+ // now prefers real ioreg-derived wattage when available (see below).
70
+ const ASSUMED_CAPACITY_WH = 55;
71
+ const powerWatts = Math.round(dischargeRate * (ASSUMED_CAPACITY_WH / 100) * 100) / 100;
72
+
73
+ return { estimatedTimeToEmpty, dischargeRatePerHour: Math.round(dischargeRate * 10) / 10, powerWatts };
74
+ }
75
+
76
+ /**
77
+ * Gather every metric category in parallel and return a
78
+ * complete {@link StatsData} snapshot.
79
+ */
80
+ export async function collectAll(opts: { detailed?: boolean; processLimit?: number } = {}): Promise<StatsData> {
81
+ const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power, packets, tasks] = await Promise.all([
82
+ collectCpu(),
83
+ collectGpu(opts.detailed),
84
+ collectMemory(),
85
+ collectDisk(),
86
+ collectBattery(),
87
+ collectThermal(opts.detailed),
88
+ collectNetwork(),
89
+ collectProcesses(opts.processLimit),
90
+ collectSystem(),
91
+ collectPower(),
92
+ collectPackets(),
93
+ collectTasks(),
94
+ ]);
95
+
96
+ return {
97
+ header: {
98
+ title: chalk.hex('#ff5500').bold('PYRE'),
99
+ hostname: system.hostname,
100
+ os: system.os,
101
+ uptime: system.uptime,
102
+ },
103
+ cpu,
104
+ gpu,
105
+ memory,
106
+ disk,
107
+ battery,
108
+ thermal,
109
+ network,
110
+ processes,
111
+ power,
112
+ timestamp: new Date().toISOString(),
113
+ packets,
114
+ tasks,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Read power-draw metrics (CPU watts, GPU watts, combined)
120
+ * from the SMC via powermetrics. Returns `null` when no
121
+ * power data is available.
122
+ */
123
+ export async function collectPower(): Promise<PowerData | null> {
124
+ try {
125
+ const { power } = await getSmcMetrics();
126
+ if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
127
+ return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
128
+ }
129
+ } catch {
130
+ // ignore
131
+ }
132
+
133
+ // `pmset -g batt` never actually prints a "mW" figure on any macOS
134
+ // version — that regex could never match, so this fallback was dead
135
+ // code and collectPower() silently returned null whenever powermetrics
136
+ // wasn't available (which is most machines without passwordless sudo
137
+ // configured). ioreg's AppleSmartBattery entry exposes instantaneous
138
+ // amperage (mA, signed: negative while discharging) and voltage (mV)
139
+ // without any privileges, so we can derive real wattage from that.
140
+ try {
141
+ const ioreg = (await run('ioreg -rn AppleSmartBattery 2>/dev/null')).trim();
142
+ const ampMatch = ioreg.match(/"InstantAmperage"\s*=\s*(-?\d+)/);
143
+ const voltMatch = ioreg.match(/"Voltage"\s*=\s*(\d+)/);
144
+ if (ampMatch && voltMatch) {
145
+ // Two representations show up in the wild: Intel Macs often print a
146
+ // literal minus sign ("-1200") — no unwrap needed. Apple Silicon
147
+ // instead wraps a negative reading into an *unsigned 64-bit* field
148
+ // (e.g. real -1200mA prints as 18446744073709550416). The previous
149
+ // 32-bit unwrap (`- 0x100000000`) didn't touch that at all, and
150
+ // `parseInt` can't even represent a number that large precisely, so
151
+ // it silently mangled into a huge garbage value → absurd wattage.
152
+ // Parse as BigInt and only unwrap when there's no explicit sign.
153
+ let ampsBig = BigInt(ampMatch[1]);
154
+ if (!ampMatch[1].startsWith('-')) {
155
+ const UINT64_MAX_PLUS_1 = 1n << 64n;
156
+ const INT64_MAX = (1n << 63n) - 1n;
157
+ if (ampsBig > INT64_MAX) ampsBig -= UINT64_MAX_PLUS_1;
158
+ }
159
+ const amps = Number(ampsBig);
160
+ const volts = parseInt(voltMatch[1], 10) / 1000;
161
+ const watts = Math.abs((amps / 1000) * volts);
162
+ // Sanity clamp — a real Mac never draws anywhere near this much;
163
+ // treat an out-of-range reading as "no usable data" rather than
164
+ // display nonsense.
165
+ if (Number.isFinite(watts) && watts < 500) {
166
+ return { combinedWatts: Math.round(watts * 100) / 100 };
167
+ }
168
+ }
169
+ } catch {
170
+ // ignore
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Gather GPU information from system_profiler and powermetrics.
178
+ * Returns null when GPU data is unavailable (e.g. on machines
179
+ * without a discrete GPU or when powermetrics is inaccessible).
180
+ */
181
+ export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
182
+ try {
183
+ const spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
184
+ if (!spRaw) return null;
185
+
186
+ const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
187
+ const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
188
+
189
+ const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
190
+ let memory = 0;
191
+ if (memMatch) {
192
+ const memStr = memMatch[1].trim();
193
+ const memNum = parseFloat(memStr);
194
+ if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
195
+ else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
196
+ }
197
+
198
+ let utilization = 0;
199
+ let temperature: number | undefined;
200
+ let gpuProcesses = 0;
201
+
202
+ if (detailed) {
203
+ try {
204
+ const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
205
+ const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
206
+ if (gpuPowerMatch) {
207
+ utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
208
+ }
209
+ } catch {
210
+ // ignore
211
+ }
212
+
213
+ try {
214
+ const { temps } = await getSmcMetrics();
215
+ if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
216
+ } catch {
217
+ // ignore
218
+ }
219
+
220
+ try {
221
+ const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
222
+ gpuProcesses = parseInt(psRaw) || 0;
223
+ } catch {
224
+ // ignore
225
+ }
226
+ }
227
+
228
+ return { model, memory, utilization, temperature, processes: gpuProcesses };
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
235
+ const hostname = (await run('hostname', 'Mac')).trim();
236
+ const osRaw = (await run('sw_vers -productVersion', 'Unknown')).trim();
237
+ const uptimeRaw = (await run('uptime')).trim();
238
+ const uptimeMatch = uptimeRaw.match(/up\s+(.*?),\s+\d+\s+users/);
239
+ return {
240
+ hostname,
241
+ os: `macOS ${osRaw}`,
242
+ uptime: uptimeMatch ? uptimeMatch[1] : uptimeRaw,
243
+ };
244
+ }
245
+
246
+ export async function collectCpu(): Promise<CpuData> {
247
+ const brand = (await run('sysctl -n machdep.cpu.brand_string', 'Unknown CPU')).trim();
248
+ const cores = parseInt((await run('sysctl -n hw.ncpu')).trim()) || 1;
249
+ const physicalCores = parseInt((await run('sysctl -n hw.physicalcpu')).trim()) || cores;
250
+ const frequencyHz = parseInt((await run('sysctl -n hw.cpufrequency')).trim());
251
+ let frequency = frequencyHz > 0 ? Math.round(frequencyHz / 1_000_000) : 0;
252
+
253
+ if (frequency === 0) {
254
+ // hw.cpufrequency is an Intel-era sysctl. On Apple Silicon it's either
255
+ // absent or reads back 0, so frequency was silently stuck at 0 MHz on
256
+ // every M-series Mac. powermetrics reports the actual active clock speed
257
+ // per core cluster (E/P-cluster) instead — reuse the same cached call
258
+ // already used for temperature/power rather than shelling out again.
259
+ try {
260
+ const { freq } = await getSmcMetrics();
261
+ const values = Object.values(freq);
262
+ if (values.length) {
263
+ // powermetrics reports in Hz, system profiler reports in MHz.
264
+ // We need MHz for the display.
265
+ frequency = Math.round(Math.max(...values) / 1_000_000);
266
+ }
267
+ } catch {
268
+ // ignore — frequency stays 0, meaning "unavailable" rather than wrong
269
+ }
270
+ }
271
+
272
+ let usage = 0;
273
+ try {
274
+ const top = (await run('top -l 1 -n 0')).trim();
275
+ // Real `top -l 1 -n 0` output is comma-separated and newline-terminated,
276
+ // e.g. "CPU usage: 5.26% user, 10.52% sys, 84.21% idle" — there is no
277
+ // semicolon anywhere on that line. The old regex required one to
278
+ // terminate the match, so it never matched and usage stayed at 0.
279
+ const cpuLine = top.match(/CPU usage:\s*[\d.]+%\s*user,\s*[\d.]+%\s*sys,\s*([\d.]+)%\s*idle/);
280
+ if (cpuLine) {
281
+ const idle = parseFloat(cpuLine[1]);
282
+ usage = Math.round(100 - idle);
283
+ }
284
+ } catch {
285
+ // ignore
286
+ }
287
+
288
+ const loadAvgStr = (await run('sysctl -n vm.loadavg', '0.00 0.00 0.00')).trim();
289
+ const loadAvg = loadAvgStr.match(/\{ (.+?) \}/)?.[1].split(' ').map(Number) || [0, 0, 0];
290
+
291
+ let temperature: number | undefined;
292
+ try {
293
+ const { temps } = await getSmcMetrics();
294
+ if (temps.cpu_die !== undefined) temperature = temps.cpu_die;
295
+ } catch {
296
+ // ignore
297
+ }
298
+
299
+ return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature };
300
+ }
301
+
302
+ export async function collectMemory(): Promise<MemoryData> {
303
+ const totalBytes = parseInt((await run('sysctl -n hw.memsize')).trim()) || 0;
304
+ const pageSize = parseInt((await run('sysctl -n hw.pagesize', '4096')).trim()) || 4096;
305
+
306
+ const vmStat = (await run('vm_stat')).trim();
307
+ const getPageCount = (label: string) => {
308
+ const m = vmStat.match(new RegExp(`Pages\\s+${label}:\\s+(\\d+)\\.`));
309
+ return m ? parseInt(m[1]) : 0;
310
+ };
311
+
312
+ const freePages = getPageCount('free');
313
+ const activePages = getPageCount('active');
314
+ const inactivePages = getPageCount('inactive');
315
+ const wiredPages = getPageCount('wired down');
316
+ const compressedPages = getPageCount('occupied by compressor');
317
+
318
+ const free = freePages * pageSize;
319
+ const used = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
320
+ const usagePercent = totalBytes > 0 ? Math.round((used / totalBytes) * 100) : 0;
321
+
322
+ let swapTotal = 0;
323
+ let swapUsed = 0;
324
+ let swapFree = 0;
325
+ try {
326
+ const swapRaw = (await run('sysctl -n vm.swapusage')).trim();
327
+ // Real output is "total = 3072.00M used = 1497.75M free = 1574.25M
328
+ // (encrypted)" — the parentheses wrap the trailing "(encrypted)" note,
329
+ // not the used value. The old regex required a literal "(" right before
330
+ // "used", which never appears, so this never matched and swap stayed at
331
+ // 0 forever instead of updating.
332
+ const swapMatch = swapRaw.match(
333
+ /total\s*=\s*([\d.]+)([KMGkmg])\s+used\s*=\s*([\d.]+)([KMGkmg])\s+free\s*=\s*([\d.]+)([KMGkmg])/
334
+ );
335
+ if (swapMatch) {
336
+ swapTotal = parseFloat(swapMatch[1]) * parseSuffix(swapMatch[2]);
337
+ swapUsed = parseFloat(swapMatch[3]) * parseSuffix(swapMatch[4]);
338
+ swapFree = parseFloat(swapMatch[5]) * parseSuffix(swapMatch[6]);
339
+ }
340
+ } catch {
341
+ // ignore
342
+ }
343
+
344
+ return {
345
+ total: totalBytes,
346
+ used,
347
+ free,
348
+ swapTotal,
349
+ swapUsed,
350
+ swapFree,
351
+ pageSize,
352
+ usagePercent,
353
+ };
354
+ }
355
+
356
+ export async function collectDisk(): Promise<DiskData[]> {
357
+ const raw = (await run('df -h')).trim();
358
+ const lines = raw.split('\n').slice(1);
359
+ return lines
360
+ .map(line => {
361
+ const parts = line.split(/\s+/);
362
+ return {
363
+ filesystem: parts[0],
364
+ size: parts[1],
365
+ used: parts[2],
366
+ available: parts[3],
367
+ capacity: parts[4],
368
+ mountpoint: parts[5] || parts[6] || '/',
369
+ };
370
+ })
371
+ .filter(d => d.filesystem.includes('/') || d.size.includes('G') || d.size.includes('T'));
372
+ }
373
+
374
+ export async function collectBattery(): Promise<BatteryData | null> {
375
+ try {
376
+ const raw = (await run('pmset -g batt')).trim();
377
+ if (!raw.includes('Battery Power') && !raw.includes('AC Power')) return null;
378
+
379
+ const levelMatch = raw.match(/(\d+)%/);
380
+ const stateMatch = raw.match(/;\s*(charging|discharging|charged|finishing charge|attached to charger)/i);
381
+ const timeMatch = raw.match(/:\s*([\d:]+)\s+remaining/i) || raw.match(/(\d+:\d+)\s+remaining/i);
382
+ const powerPlugged = raw.includes('AC Power') || raw.includes('attached to charger');
383
+
384
+ const level = levelMatch ? parseInt(levelMatch[1]) : 0;
385
+ const state = powerPlugged ? 'charged' : (stateMatch ? stateMatch[1].toLowerCase() : 'discharging');
386
+ const timeRemaining = timeMatch ? timeMatch[1] : (powerPlugged ? '∞' : 'calculating');
387
+
388
+ let cycles: number | undefined;
389
+ let condition: string | undefined;
390
+ let maxCapacityPercent: number | undefined;
391
+
392
+ try {
393
+ const battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
394
+
395
+ const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
396
+ if (cycleMatch) cycles = parseInt(cycleMatch[1]);
397
+
398
+ const conditionMatch = battInfo.match(/Condition:\s+(.+)/);
399
+ if (conditionMatch) condition = conditionMatch[1].trim();
400
+
401
+ const maxCapMatch = battInfo.match(/Maximum Capacity:\s+(\d+)%/);
402
+ if (maxCapMatch) {
403
+ maxCapacityPercent = parseInt(maxCapMatch[1]);
404
+ } else {
405
+ const fullMatch = battInfo.match(/Full Charge Capacity[^\d]*(\d+)/i);
406
+ const designMatch = battInfo.match(/Design Capacity[^\d]*(\d+)/i);
407
+ if (fullMatch && designMatch) {
408
+ const full = parseInt(fullMatch[1]);
409
+ const design = parseInt(designMatch[1]);
410
+ if (design > 0) maxCapacityPercent = Math.round((full / design) * 100);
411
+ }
412
+ }
413
+ } catch {
414
+ // health stays unknown
415
+ }
416
+
417
+ const healthParts: string[] = [];
418
+ if (condition) healthParts.push(condition);
419
+ if (maxCapacityPercent !== undefined) healthParts.push(`${maxCapacityPercent}% capacity`);
420
+ if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
421
+ const health = healthParts.length ? healthParts.join(', ') : 'unknown';
422
+
423
+ const estimate = estimateBatteryLife(level, state);
424
+
425
+ return {
426
+ level,
427
+ state,
428
+ timeRemaining,
429
+ health,
430
+ powerSource: powerPlugged ? 'AC' : 'Battery',
431
+ cycles,
432
+ condition,
433
+ maxCapacityPercent,
434
+ ...estimate,
435
+ };
436
+ } catch {
437
+ return null;
438
+ }
439
+ }
440
+
441
+ function pressureFromState(state: string): number {
442
+ const s = state.toLowerCase();
443
+ if (s.includes('critical')) return 3;
444
+ if (s.includes('serious') || s.includes('heavy')) return 2;
445
+ if (s.includes('fair') || s.includes('moderate')) return 1;
446
+ return 0; // nominal / normal / unknown
447
+ }
448
+
449
+ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
450
+ try {
451
+ const therm = (await run('pmset -g therm', '')).trim();
452
+
453
+ let state = 'Unknown';
454
+ let detail = therm || 'No pmset thermal data';
455
+
456
+ const stateMatch = therm.match(/Thermal state:\s*(.+)/i);
457
+
458
+ if (stateMatch) {
459
+ const raw = stateMatch[1].trim();
460
+ state = /0|no warning|normal/i.test(raw) ? 'Nominal' : raw;
461
+ detail = state;
462
+ } else {
463
+ const limitMatch = therm.match(
464
+ /CPU_(?:Speed|Scheduler)_Limit\s*(?:=|:)\s*([\d.]+)/i
465
+ );
466
+
467
+ if (limitMatch) {
468
+ const limit = parseFloat(limitMatch[1]);
469
+
470
+ if (limit >= 100) state = 'Nominal';
471
+ else if (limit >= 85) state = 'Fair';
472
+ else if (limit >= 50) state = 'Serious';
473
+ else state = 'Critical';
474
+
475
+ detail = `${state} (CPU limit ${limit}%)`;
476
+ } else if (/no thermal warning level has been recorded/i.test(therm)) {
477
+ // The most common real-world case: pmset hasn't recorded any warning
478
+ // and never emits a CPU_Speed_Limit line at all when nothing has ever
479
+ // throttled. That's not "unknown" — it's the system telling us
480
+ // everything is nominal — but the old code fell through to
481
+ // 'Unknown' here with no other way to recover, since the
482
+ // powermetrics fallback below almost always fails without
483
+ // passwordless sudo configured. That's the bug: thermal state read
484
+ // as "Unknown" on effectively every normal, non-throttling Mac.
485
+ state = 'Nominal';
486
+ detail = 'Nominal (no thermal warning recorded)';
487
+ }
488
+ }
489
+
490
+ const temperatures: Record<string, number | null> = {};
491
+ let cpuDieTemp: number | undefined;
492
+
493
+ // Grab the CPU die temp regardless of `detailed` — we need it both for the
494
+ // detailed sensor breakdown and as a state fallback below.
495
+ try {
496
+ const { temps } = await getSmcMetrics();
497
+ cpuDieTemp = temps.cpu_die;
498
+ if (detailed) {
499
+ if (temps.cpu_die !== undefined) temperatures['cpu_die'] = temps.cpu_die;
500
+ if (temps.gpu_die !== undefined) temperatures['gpu_die'] = temps.gpu_die;
501
+ if (temps.smc_die !== undefined) temperatures['smc_die'] = temps.smc_die;
502
+ if (!Object.keys(temps).length) {
503
+ detail += ' (sensor data needs powermetrics privileges)';
504
+ }
505
+ }
506
+ } catch {
507
+ if (detailed) detail += ' (powermetrics unavailable)';
508
+ }
509
+
510
+ // Last resort: if we still don't know the state (e.g. pmset gave us nothing
511
+ // usable) but we do have a die temperature, estimate state from that.
512
+ if (state === 'Unknown' && cpuDieTemp !== undefined) {
513
+ if (cpuDieTemp >= 100) state = 'Critical';
514
+ else if (cpuDieTemp >= 90) state = 'Serious';
515
+ else if (cpuDieTemp >= 80) state = 'Fair';
516
+ else state = 'Nominal';
517
+ detail = `${state} (estimated from ${cpuDieTemp}°C)`;
518
+ }
519
+
520
+ return {
521
+ state,
522
+ detail,
523
+ pressureLevel: pressureFromState(state),
524
+ temperatures: Object.keys(temperatures).length ? temperatures : undefined,
525
+ };
526
+ } catch {
527
+ return { state: 'Unknown', pressureLevel: 0, error: 'Thermal info unavailable on this system' };
528
+ }
529
+ }
530
+
531
+ export async function collectNetwork(): Promise<NetworkData> {
532
+ try {
533
+ const primary = (await run('route -n get default')).trim();
534
+ const ifaceMatch = primary.match(/interface:\s+(\S+)/);
535
+ const iface = ifaceMatch?.[1] || 'en0';
536
+
537
+ let ip = '0.0.0.0';
538
+ try {
539
+ const ifconfig = (await run(`ifconfig ${iface}`)).trim();
540
+ const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
541
+ if (ipMatch) ip = ipMatch[1];
542
+ } catch {
543
+ // ignore
544
+ }
545
+
546
+ const netStat = (await run('netstat -ib')).trim();
547
+ let rxBytes = 0;
548
+ let txBytes = 0;
549
+ let rxPackets = 0;
550
+ let txPackets = 0;
551
+
552
+ for (const line of netStat.split('\n')) {
553
+ if (line.startsWith(iface) || line.startsWith(iface + ':')) {
554
+ const parts = line.split(/\s+/);
555
+ if (parts.length >= 9) {
556
+ rxPackets += parseInt(parts[4]) || 0;
557
+ rxBytes += parseInt(parts[5]) || 0;
558
+ txPackets += parseInt(parts[6]) || 0;
559
+ txBytes += parseInt(parts[7]) || 0;
560
+ }
561
+ }
562
+ }
563
+
564
+ return { interface: iface, ip, rxBytes, txBytes, rxPackets, txPackets };
565
+ } catch {
566
+ return { interface: 'unknown', ip: '0.0.0.0', rxBytes: 0, txBytes: 0, rxPackets: 0, txPackets: 0 };
567
+ }
568
+ }
569
+
570
+ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
571
+ try {
572
+ const isMac = process.platform === 'darwin';
573
+ const sortArg = isMac ? '-r' : '--sort=-pcpu';
574
+ const threadsArg = isMac ? 'threads' : 'nlwp';
575
+ const headClause = limit && limit > 0 ? ` | head -n ${limit + 1}` : '';
576
+ const raw = (await run(`ps -eo pid,ppid,user,pcpu,pmem,state,${threadsArg},time,comm ${sortArg}${headClause}`)).trim();
577
+ const lines = raw.split('\n').slice(1);
578
+ return lines
579
+ .map(line => {
580
+ const parts = line.match(/\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\d+)\s+([\d:]+)\s+(.+)/);
581
+ if (!parts) return null;
582
+ const runtimeSec = parts[8].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
583
+ return { pid: parseInt(parts[1]), ppid: parseInt(parts[2]), user: parts[3], cpu: parseFloat(parts[4]), mem: parseFloat(parts[5]), state: parts[6], threads: parseInt(parts[7]), runtime: runtimeSec, command: parts[9] };
584
+ })
585
+ .filter((p): p is ProcessData => p !== null && p.pid > 0);
586
+ } catch {
587
+ return [];
588
+ }
589
+ }
590
+
591
+ export async function collectPackets(): Promise<PacketData | null> {
592
+ try {
593
+ const netStat = (await run('netstat -ib')).trim();
594
+ const lines = netStat.split('\n').slice(1);
595
+ let totalRxPackets = 0;
596
+ let totalTxPackets = 0;
597
+ let totalRxBytes = 0;
598
+ let totalTxBytes = 0;
599
+ const ifaceStats: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[] = [];
600
+
601
+ for (const line of lines) {
602
+ const parts = line.split(/\s+/);
603
+ if (parts.length >= 11 && parts[0] && !parts[0].startsWith('Name')) {
604
+ const iface = parts[0];
605
+ const rxPackets = parseInt(parts[4]) || 0;
606
+ const rxBytes = parseInt(parts[5]) || 0;
607
+ const txPackets = parseInt(parts[6]) || 0;
608
+ const txBytes = parseInt(parts[7]) || 0;
609
+ totalRxPackets += rxPackets;
610
+ totalTxPackets += txPackets;
611
+ totalRxBytes += rxBytes;
612
+ totalTxBytes += txBytes;
613
+ if (iface && !iface.includes('lo')) {
614
+ ifaceStats.push({ iface, rxPackets, txPackets, rxBytes, txBytes });
615
+ }
616
+ }
617
+ }
618
+
619
+ let connections = 0;
620
+ try {
621
+ const tcpConns = (await run('netstat -an | grep ESTABLISHED | wc -l', '0')).trim();
622
+ connections = parseInt(tcpConns) || 0;
623
+ } catch {
624
+ connections = 0;
625
+ }
626
+
627
+ let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
628
+ let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
629
+ try {
630
+ const lsof = (await run('lsof -i -n -P -r 1 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
631
+ const lsofLines = lsof.split('\n');
632
+ topProcesses = lsofLines.map(line => {
633
+ const parts = line.trim().split(/\s+/);
634
+ if (parts.length >= 3) {
635
+ return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
636
+ }
637
+ return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
638
+ }).filter(p => p.pid > 0);
639
+
640
+ const allLsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
641
+ const allLsofLines = allLsof.split('\n');
642
+ allProcesses = allLsofLines.map(line => {
643
+ const parts = line.trim().split(/\s+/);
644
+ if (parts.length >= 3) {
645
+ return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
646
+ }
647
+ return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
648
+ }).filter(p => p.pid > 0);
649
+ } catch {
650
+ topProcesses = [];
651
+ allProcesses = [];
652
+ }
653
+
654
+ return {
655
+ totalPackets: totalRxPackets + totalTxPackets,
656
+ rxPackets: totalRxPackets,
657
+ txPackets: totalTxPackets,
658
+ rxRate: 0,
659
+ txRate: 0,
660
+ connections,
661
+ topProcesses,
662
+ interfaces: ifaceStats,
663
+ allProcesses,
664
+ };
665
+ } catch {
666
+ return null;
667
+ }
668
+ }
669
+
670
+ export async function collectTasks(limit = 12): Promise<TaskData[]> {
671
+ try {
672
+ const isMac = process.platform === 'darwin';
673
+ const sortArg = isMac ? '-r' : '--sort=-pcpu';
674
+ const raw = (await run(`ps -eo pid,user,pcpu,pmem,state,time,comm ${sortArg} | head -n ${limit + 1}`)).trim();
675
+ const lines = raw.split('\n').slice(1);
676
+ return lines
677
+ .map(line => {
678
+ const parts = line.match(/\s*(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+([\d:]+)\s+(.+)/);
679
+ if (!parts) return null;
680
+ const runtimeSec = parts[6].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
681
+ return { pid: parseInt(parts[1]), user: parts[2], cpu: parseFloat(parts[3]), mem: parseFloat(parts[4]), state: parts[5], runtime: runtimeSec, command: parts[7] };
682
+ })
683
+ .filter((t): t is TaskData => t !== null && t.pid > 0);
684
+ } catch {
685
+ return [];
686
+ }
687
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * pyre monitors module.
3
+ *
4
+ * Provides {@link collectAll} to gather a complete system-metric
5
+ * snapshot, and {@link collectPower} for power-draw data alone.
6
+ *
7
+ * The module is organised into three sub-files:
8
+ * - `types.ts` — all TypeScript interfaces
9
+ * - `smc.ts` — powermetrics/SMC sensor reading with caching
10
+ * - `collectors.ts` — individual metric collectors and the orchestrator
11
+ * - `run.ts` — shared shell-execution helper
12
+ */
13
+ export { collectAll, collectPower, collectPackets, collectTasks } from './collectors.js';
14
+ export type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess, TaskData } from './types.js';