pyre-cli 1.2.0 → 1.4.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,484 @@
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 } from './types.js';
18
+
19
+ /**
20
+ * Gather every metric category in parallel and return a
21
+ * complete {@link StatsData} snapshot.
22
+ */
23
+ export async function collectAll(opts: { detailed?: boolean; processLimit?: number } = {}): Promise<StatsData> {
24
+ const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power] = await Promise.all([
25
+ collectCpu(),
26
+ collectGpu(opts.detailed),
27
+ collectMemory(),
28
+ collectDisk(),
29
+ collectBattery(),
30
+ collectThermal(opts.detailed),
31
+ collectNetwork(),
32
+ collectProcesses(opts.processLimit ?? 10),
33
+ collectSystem(),
34
+ collectPower(),
35
+ ]);
36
+
37
+ return {
38
+ header: {
39
+ title: chalk.hex('#ff5500').bold('PYRE'),
40
+ hostname: system.hostname,
41
+ os: system.os,
42
+ uptime: system.uptime,
43
+ },
44
+ cpu,
45
+ gpu,
46
+ memory,
47
+ disk,
48
+ battery,
49
+ thermal,
50
+ network,
51
+ processes,
52
+ power,
53
+ timestamp: new Date().toISOString(),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Read power-draw metrics (CPU watts, GPU watts, combined)
59
+ * from the SMC via powermetrics. Returns `null` when no
60
+ * power data is available.
61
+ */
62
+ export async function collectPower(): Promise<PowerData | null> {
63
+ try {
64
+ const { power } = await getSmcMetrics();
65
+ if (power.cpu === undefined && power.gpu === undefined && power.combined === undefined) return null;
66
+ return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Gather GPU information from system_profiler and powermetrics.
74
+ * Returns null when GPU data is unavailable (e.g. on machines
75
+ * without a discrete GPU or when powermetrics is inaccessible).
76
+ */
77
+ export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
78
+ try {
79
+ const spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
80
+ if (!spRaw) return null;
81
+
82
+ const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
83
+ const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
84
+
85
+ const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
86
+ let memory = 0;
87
+ if (memMatch) {
88
+ const memStr = memMatch[1].trim();
89
+ const memNum = parseFloat(memStr);
90
+ if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
91
+ else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
92
+ }
93
+
94
+ let utilization = 0;
95
+ let temperature: number | undefined;
96
+ let gpuProcesses = 0;
97
+
98
+ if (detailed) {
99
+ try {
100
+ const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
101
+ const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
102
+ if (gpuPowerMatch) {
103
+ utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
104
+ }
105
+ } catch {
106
+ // ignore
107
+ }
108
+
109
+ try {
110
+ const { temps } = await getSmcMetrics();
111
+ if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
112
+ } catch {
113
+ // ignore
114
+ }
115
+
116
+ try {
117
+ const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
118
+ gpuProcesses = parseInt(psRaw) || 0;
119
+ } catch {
120
+ // ignore
121
+ }
122
+ }
123
+
124
+ return { model, memory, utilization, temperature, processes: gpuProcesses };
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
131
+ const hostname = (await run('hostname', 'Mac')).trim();
132
+ const osRaw = (await run('sw_vers -productVersion', 'Unknown')).trim();
133
+ const uptimeRaw = (await run('uptime')).trim();
134
+ const uptimeMatch = uptimeRaw.match(/up\s+(.*?),\s+\d+\s+users/);
135
+ return {
136
+ hostname,
137
+ os: `macOS ${osRaw}`,
138
+ uptime: uptimeMatch ? uptimeMatch[1] : uptimeRaw,
139
+ };
140
+ }
141
+
142
+ export async function collectCpu(): Promise<CpuData> {
143
+ const brand = (await run('sysctl -n machdep.cpu.brand_string', 'Unknown CPU')).trim();
144
+ const cores = parseInt((await run('sysctl -n hw.ncpu')).trim()) || 1;
145
+ const physicalCores = parseInt((await run('sysctl -n hw.physicalcpu')).trim()) || cores;
146
+ const frequencyHz = parseInt((await run('sysctl -n hw.cpufrequency')).trim());
147
+ let frequency = frequencyHz > 0 ? Math.round(frequencyHz / 1_000_000) : 0;
148
+
149
+ if (frequency === 0) {
150
+ // hw.cpufrequency is an Intel-era sysctl. On Apple Silicon it's either
151
+ // absent or reads back 0, so frequency was silently stuck at 0 MHz on
152
+ // every M-series Mac. powermetrics reports the actual active clock speed
153
+ // per core cluster (E/P-cluster) instead — reuse the same cached call
154
+ // already used for temperature/power rather than shelling out again.
155
+ try {
156
+ const { freq } = await getSmcMetrics();
157
+ const values = Object.values(freq);
158
+ if (values.length) {
159
+ // powermetrics reports in Hz, system profiler reports in MHz.
160
+ // We need MHz for the display.
161
+ frequency = Math.round(Math.max(...values) / 1_000_000);
162
+ }
163
+ } catch {
164
+ // ignore — frequency stays 0, meaning "unavailable" rather than wrong
165
+ }
166
+ }
167
+
168
+ let usage = 0;
169
+ try {
170
+ const top = (await run('top -l 1 -n 0')).trim();
171
+ // Real `top -l 1 -n 0` output is comma-separated and newline-terminated,
172
+ // e.g. "CPU usage: 5.26% user, 10.52% sys, 84.21% idle" — there is no
173
+ // semicolon anywhere on that line. The old regex required one to
174
+ // terminate the match, so it never matched and usage stayed at 0.
175
+ const cpuLine = top.match(/CPU usage:\s*[\d.]+%\s*user,\s*[\d.]+%\s*sys,\s*([\d.]+)%\s*idle/);
176
+ if (cpuLine) {
177
+ const idle = parseFloat(cpuLine[1]);
178
+ usage = Math.round(100 - idle);
179
+ }
180
+ } catch {
181
+ // ignore
182
+ }
183
+
184
+ const loadAvgStr = (await run('sysctl -n vm.loadavg', '0.00 0.00 0.00')).trim();
185
+ const loadAvg = loadAvgStr.match(/\{ (.+?) \}/)?.[1].split(' ').map(Number) || [0, 0, 0];
186
+
187
+ let temperature: number | undefined;
188
+ try {
189
+ const { temps } = await getSmcMetrics();
190
+ if (temps.cpu_die !== undefined) temperature = temps.cpu_die;
191
+ } catch {
192
+ // ignore
193
+ }
194
+
195
+ return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature };
196
+ }
197
+
198
+ export async function collectMemory(): Promise<MemoryData> {
199
+ const totalBytes = parseInt((await run('sysctl -n hw.memsize')).trim()) || 0;
200
+ const pageSize = parseInt((await run('sysctl -n hw.pagesize', '4096')).trim()) || 4096;
201
+
202
+ const vmStat = (await run('vm_stat')).trim();
203
+ const getPageCount = (label: string) => {
204
+ const m = vmStat.match(new RegExp(`Pages\\s+${label}:\\s+(\\d+)\\.`));
205
+ return m ? parseInt(m[1]) : 0;
206
+ };
207
+
208
+ const freePages = getPageCount('free');
209
+ const activePages = getPageCount('active');
210
+ const inactivePages = getPageCount('inactive');
211
+ const wiredPages = getPageCount('wired down');
212
+ const compressedPages = getPageCount('occupied by compressor');
213
+
214
+ const free = freePages * pageSize;
215
+ const used = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
216
+ const usagePercent = totalBytes > 0 ? Math.round((used / totalBytes) * 100) : 0;
217
+
218
+ let swapTotal = 0;
219
+ let swapUsed = 0;
220
+ let swapFree = 0;
221
+ try {
222
+ const swapRaw = (await run('sysctl -n vm.swapusage')).trim();
223
+ // Real output is "total = 3072.00M used = 1497.75M free = 1574.25M
224
+ // (encrypted)" — the parentheses wrap the trailing "(encrypted)" note,
225
+ // not the used value. The old regex required a literal "(" right before
226
+ // "used", which never appears, so this never matched and swap stayed at
227
+ // 0 forever instead of updating.
228
+ const swapMatch = swapRaw.match(
229
+ /total\s*=\s*([\d.]+)([KMGkmg])\s+used\s*=\s*([\d.]+)([KMGkmg])\s+free\s*=\s*([\d.]+)([KMGkmg])/
230
+ );
231
+ if (swapMatch) {
232
+ swapTotal = parseFloat(swapMatch[1]) * parseSuffix(swapMatch[2]);
233
+ swapUsed = parseFloat(swapMatch[3]) * parseSuffix(swapMatch[4]);
234
+ swapFree = parseFloat(swapMatch[5]) * parseSuffix(swapMatch[6]);
235
+ }
236
+ } catch {
237
+ // ignore
238
+ }
239
+
240
+ return {
241
+ total: totalBytes,
242
+ used,
243
+ free,
244
+ swapTotal,
245
+ swapUsed,
246
+ swapFree,
247
+ pageSize,
248
+ usagePercent,
249
+ };
250
+ }
251
+
252
+ export async function collectDisk(): Promise<DiskData[]> {
253
+ const raw = (await run('df -h')).trim();
254
+ const lines = raw.split('\n').slice(1);
255
+ return lines
256
+ .map(line => {
257
+ const parts = line.split(/\s+/);
258
+ return {
259
+ filesystem: parts[0],
260
+ size: parts[1],
261
+ used: parts[2],
262
+ available: parts[3],
263
+ capacity: parts[4],
264
+ mountpoint: parts[5] || parts[6] || '/',
265
+ };
266
+ })
267
+ .filter(d => d.filesystem.includes('/') || d.size.includes('G') || d.size.includes('T'));
268
+ }
269
+
270
+ export async function collectBattery(): Promise<BatteryData | null> {
271
+ try {
272
+ const raw = (await run('pmset -g batt')).trim();
273
+ if (!raw.includes('Battery Power') && !raw.includes('AC Power')) return null;
274
+
275
+ const levelMatch = raw.match(/(\d+)%/);
276
+ const stateMatch = raw.match(/;\s*(charging|discharging|charged|finishing charge|attached to charger)/i);
277
+ const timeMatch = raw.match(/:\s*([\d:]+)\s+remaining/i);
278
+ const powerPlugged = raw.includes('AC Power') || raw.includes('attached to charger');
279
+
280
+ const level = levelMatch ? parseInt(levelMatch[1]) : 0;
281
+ const state = powerPlugged ? 'charged' : (stateMatch ? stateMatch[1].toLowerCase() : 'discharging');
282
+ const timeRemaining = timeMatch ? timeMatch[1] : (powerPlugged ? '∞' : 'calculating');
283
+
284
+ let cycles: number | undefined;
285
+ let condition: string | undefined;
286
+ let maxCapacityPercent: number | undefined;
287
+
288
+ try {
289
+ // Plain text output (NOT -json) — its field names ("Cycle Count:", "Condition:")
290
+ // are what the regexes below expect. Using -json here was the original bug:
291
+ // it silently returned JSON that these text regexes could never match, so
292
+ // health always fell back to 'unknown'.
293
+ const battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
294
+
295
+ const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
296
+ if (cycleMatch) cycles = parseInt(cycleMatch[1]);
297
+
298
+ const conditionMatch = battInfo.match(/Condition:\s+(.+)/);
299
+ if (conditionMatch) condition = conditionMatch[1].trim();
300
+
301
+ const maxCapMatch = battInfo.match(/Maximum Capacity:\s+(\d+)%/);
302
+ if (maxCapMatch) {
303
+ maxCapacityPercent = parseInt(maxCapMatch[1]);
304
+ } else {
305
+ // Older/alternate output reports Full Charge Capacity vs Design Capacity
306
+ // instead of a direct percentage — derive it if both are present.
307
+ const fullMatch = battInfo.match(/Full Charge Capacity[^\d]*(\d+)/i);
308
+ const designMatch = battInfo.match(/Design Capacity[^\d]*(\d+)/i);
309
+ if (fullMatch && designMatch) {
310
+ const full = parseInt(fullMatch[1]);
311
+ const design = parseInt(designMatch[1]);
312
+ if (design > 0) maxCapacityPercent = Math.round((full / design) * 100);
313
+ }
314
+ }
315
+ } catch {
316
+ // health stays unknown
317
+ }
318
+
319
+ const healthParts: string[] = [];
320
+ if (condition) healthParts.push(condition);
321
+ if (maxCapacityPercent !== undefined) healthParts.push(`${maxCapacityPercent}% capacity`);
322
+ if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
323
+ const health = healthParts.length ? healthParts.join(', ') : 'unknown';
324
+
325
+ return {
326
+ level,
327
+ state,
328
+ timeRemaining,
329
+ health,
330
+ powerSource: powerPlugged ? 'AC' : 'Battery',
331
+ cycles,
332
+ condition,
333
+ maxCapacityPercent,
334
+ };
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ function pressureFromState(state: string): number {
341
+ const s = state.toLowerCase();
342
+ if (s.includes('critical')) return 3;
343
+ if (s.includes('serious') || s.includes('heavy')) return 2;
344
+ if (s.includes('fair') || s.includes('moderate')) return 1;
345
+ return 0; // nominal / normal / unknown
346
+ }
347
+
348
+ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
349
+ try {
350
+ const therm = (await run('pmset -g therm', '')).trim();
351
+
352
+ let state = 'Unknown';
353
+ let detail = therm || 'No pmset thermal data';
354
+
355
+ const stateMatch = therm.match(/Thermal state:\s*(.+)/i);
356
+
357
+ if (stateMatch) {
358
+ const raw = stateMatch[1].trim();
359
+ state = /0|no warning|normal/i.test(raw) ? 'Nominal' : raw;
360
+ detail = state;
361
+ } else {
362
+ const limitMatch = therm.match(
363
+ /CPU_(?:Speed|Scheduler)_Limit\s*(?:=|:)\s*([\d.]+)/i
364
+ );
365
+
366
+ if (limitMatch) {
367
+ const limit = parseFloat(limitMatch[1]);
368
+
369
+ if (limit >= 100) state = 'Nominal';
370
+ else if (limit >= 85) state = 'Fair';
371
+ else if (limit >= 50) state = 'Serious';
372
+ else state = 'Critical';
373
+
374
+ detail = `${state} (CPU limit ${limit}%)`;
375
+ } else if (/no thermal warning level has been recorded/i.test(therm)) {
376
+ // The most common real-world case: pmset hasn't recorded any warning
377
+ // and never emits a CPU_Speed_Limit line at all when nothing has ever
378
+ // throttled. That's not "unknown" — it's the system telling us
379
+ // everything is nominal — but the old code fell through to
380
+ // 'Unknown' here with no other way to recover, since the
381
+ // powermetrics fallback below almost always fails without
382
+ // passwordless sudo configured. That's the bug: thermal state read
383
+ // as "Unknown" on effectively every normal, non-throttling Mac.
384
+ state = 'Nominal';
385
+ detail = 'Nominal (no thermal warning recorded)';
386
+ }
387
+ }
388
+
389
+ const temperatures: Record<string, number | null> = {};
390
+ let cpuDieTemp: number | undefined;
391
+
392
+ // Grab the CPU die temp regardless of `detailed` — we need it both for the
393
+ // detailed sensor breakdown and as a state fallback below.
394
+ try {
395
+ const { temps } = await getSmcMetrics();
396
+ cpuDieTemp = temps.cpu_die;
397
+ if (detailed) {
398
+ if (temps.cpu_die !== undefined) temperatures['cpu_die'] = temps.cpu_die;
399
+ if (temps.gpu_die !== undefined) temperatures['gpu_die'] = temps.gpu_die;
400
+ if (temps.smc_die !== undefined) temperatures['smc_die'] = temps.smc_die;
401
+ if (!Object.keys(temps).length) {
402
+ detail += ' (sensor data needs powermetrics privileges)';
403
+ }
404
+ }
405
+ } catch {
406
+ if (detailed) detail += ' (powermetrics unavailable)';
407
+ }
408
+
409
+ // Last resort: if we still don't know the state (e.g. pmset gave us nothing
410
+ // usable) but we do have a die temperature, estimate state from that.
411
+ if (state === 'Unknown' && cpuDieTemp !== undefined) {
412
+ if (cpuDieTemp >= 100) state = 'Critical';
413
+ else if (cpuDieTemp >= 90) state = 'Serious';
414
+ else if (cpuDieTemp >= 80) state = 'Fair';
415
+ else state = 'Nominal';
416
+ detail = `${state} (estimated from ${cpuDieTemp}°C)`;
417
+ }
418
+
419
+ return {
420
+ state,
421
+ detail,
422
+ pressureLevel: pressureFromState(state),
423
+ temperatures: Object.keys(temperatures).length ? temperatures : undefined,
424
+ };
425
+ } catch {
426
+ return { state: 'Unknown', pressureLevel: 0, error: 'Thermal info unavailable on this system' };
427
+ }
428
+ }
429
+
430
+ export async function collectNetwork(): Promise<NetworkData> {
431
+ try {
432
+ const primary = (await run('route -n get default')).trim();
433
+ const ifaceMatch = primary.match(/interface:\s+(\S+)/);
434
+ const iface = ifaceMatch?.[1] || 'en0';
435
+
436
+ let ip = '0.0.0.0';
437
+ try {
438
+ const ifconfig = (await run(`ifconfig ${iface}`)).trim();
439
+ const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
440
+ if (ipMatch) ip = ipMatch[1];
441
+ } catch {
442
+ // ignore
443
+ }
444
+
445
+ const netStat = (await run('netstat -ib')).trim();
446
+ let rxBytes = 0;
447
+ let txBytes = 0;
448
+ let rxPackets = 0;
449
+ let txPackets = 0;
450
+
451
+ for (const line of netStat.split('\n')) {
452
+ if (line.startsWith(iface) || line.startsWith(iface + ':')) {
453
+ const parts = line.split(/\s+/);
454
+ if (parts.length >= 9) {
455
+ rxPackets += parseInt(parts[4]) || 0;
456
+ rxBytes += parseInt(parts[5]) || 0;
457
+ txPackets += parseInt(parts[6]) || 0;
458
+ txBytes += parseInt(parts[7]) || 0;
459
+ }
460
+ }
461
+ }
462
+
463
+ return { interface: iface, ip, rxBytes, txBytes, rxPackets, txPackets };
464
+ } catch {
465
+ return { interface: 'unknown', ip: '0.0.0.0', rxBytes: 0, txBytes: 0, rxPackets: 0, txPackets: 0 };
466
+ }
467
+ }
468
+
469
+ export async function collectProcesses(limit = 10): Promise<ProcessData[]> {
470
+ try {
471
+ const raw = (await run(`ps -Ao pid,ppid,user,pcpu,pmem,state,threads,time,comm -r | head -n ${limit + 1}`)).trim();
472
+ const lines = raw.split('\n').slice(1);
473
+ return lines
474
+ .map(line => {
475
+ const parts = line.match(/\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\d+)\s+([\d:]+)\s+(.+)/);
476
+ if (!parts) return null;
477
+ const runtimeSec = parts[8].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
478
+ 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] };
479
+ })
480
+ .filter((p): p is ProcessData => p !== null && p.pid > 0);
481
+ } catch {
482
+ return [];
483
+ }
484
+ }
@@ -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 } from './collectors.js';
14
+ export type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData } from './types.js';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared shell-execution helper for the monitors module.
3
+ *
4
+ * Wraps `child_process.execSync` with a timeout and a
5
+ * fallback return value so that a failing command never
6
+ * crashes the entire collection pipeline.
7
+ */
8
+
9
+ async function run(cmd: string, fallback: string = ''): Promise<string> {
10
+ try {
11
+ const { execSync } = await import('node:child_process');
12
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
13
+ } catch {
14
+ return fallback;
15
+ }
16
+ }
17
+
18
+ export { run };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * SMC and powermetrics sensor reader.
3
+ *
4
+ * Modern macOS requires `powermetrics` (root) to read CPU die
5
+ * temperature, GPU die temperature, SMC die temperature, and
6
+ * power draw (CPU/GPU/combined watts). This module wraps that
7
+ * call with a short-lived cache so that multiple collectors can
8
+ * reuse the same data within a single tick without invoking
9
+ * `powermetrics` repeatedly.
10
+ *
11
+ * It also provides the `parseSuffix` helper used when decoding
12
+ * `vm.swapusage` output.
13
+ */
14
+ import { run } from './run.js';
15
+
16
+ export interface SmcMetrics {
17
+ temps: Record<string, number>;
18
+ power: { cpu?: number; gpu?: number; combined?: number };
19
+ freq: Record<string, number>;
20
+ }
21
+
22
+ let smcCache: { data: SmcMetrics; ts: number } | null = null;
23
+
24
+ /**
25
+ * Query `powermetrics` for SMC temperatures, power draw, and
26
+ * CPU frequency. Results are cached for 1.5 s so that
27
+ * concurrent collectors (CPU temp, power, thermal) share a
28
+ * single invocation.
29
+ *
30
+ * Falls back to an empty object on any failure (missing
31
+ * sudoers entry, unavailable binary, etc.) so the rest of the
32
+ * application degrades gracefully.
33
+ */
34
+ export async function getSmcMetrics(): Promise<SmcMetrics> {
35
+ const now = Date.now();
36
+ if (smcCache && now - smcCache.ts < 1500) return smcCache.data;
37
+
38
+ const result: SmcMetrics = { temps: {}, power: {}, freq: {} };
39
+ try {
40
+ // `sudo -n` (non-interactive) so we never hang waiting on a
41
+ // password prompt that can't be answered from a piped child
42
+ // process; if the user hasn't set up passwordless sudo for
43
+ // powermetrics, this just fails fast and we fall back to the
44
+ // thermal-state string instead of a temperature.
45
+ const pm = (
46
+ await run(
47
+ 'sudo -n powermetrics --samplers smc,cpu_power -n 1 --format text 2>/dev/null',
48
+ ''
49
+ )
50
+ ).trim();
51
+
52
+ if (pm) {
53
+ const cpuTemp = pm.match(/CPU die temperature:\s*([\d.]+)/i);
54
+ if (cpuTemp) result.temps['cpu_die'] = parseFloat(cpuTemp[1]);
55
+
56
+ const gpuTemp = pm.match(/GPU die temperature:\s*([\d.]+)/i);
57
+ if (gpuTemp) result.temps['gpu_die'] = parseFloat(gpuTemp[1]);
58
+
59
+ const smcTemp = pm.match(/SMC die temperature:\s*([\d.]+)/i);
60
+ if (smcTemp) result.temps['smc_die'] = parseFloat(smcTemp[1]);
61
+
62
+ const cpuPower = pm.match(/CPU Power:\s*([\d.]+)\s*mW/i);
63
+ if (cpuPower) result.power.cpu = parseFloat(cpuPower[1]) / 1000;
64
+
65
+ const gpuPower = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
66
+ if (gpuPower) result.power.gpu = parseFloat(gpuPower[1]) / 1000;
67
+
68
+ const combinedPower = pm.match(/Combined Power \(CPU \+ GPU.*?\):\s*([\d.]+)\s*mW/i);
69
+ if (combinedPower) result.power.combined = parseFloat(combinedPower[1]) / 1000;
70
+
71
+ // Apple Silicon reports active clock speed per core cluster
72
+ // rather than a single number — grab whichever cluster(s)
73
+ // this chip has.
74
+ const eFreq = pm.match(/E-Cluster HW active frequency:\s*([\d.]+)/i);
75
+ if (eFreq) result.freq['e_cluster'] = parseFloat(eFreq[1]);
76
+
77
+ const pFreq = pm.match(/P-Cluster HW active frequency:\s*([\d.]+)/i);
78
+ if (pFreq) result.freq['p_cluster'] = parseFloat(pFreq[1]);
79
+
80
+ // Some single-cluster/older chips report just "CPU HW active frequency".
81
+ const cpuFreq = pm.match(/CPU HW active frequency:\s*([\d.]+)/i);
82
+ if (cpuFreq) result.freq['cpu'] = parseFloat(cpuFreq[1]);
83
+ }
84
+ } catch {
85
+ // powermetrics unavailable/unauthorized — leave temps/power/freq empty,
86
+ // the rest of the app degrades gracefully (thermal state string still
87
+ // shows, and CPU frequency falls back to 0/"unavailable").
88
+ }
89
+
90
+ smcCache = { data: result, ts: now };
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Parse a size suffix (K, M, G) from `vm.swapusage` output into
96
+ * its byte multiplier. Case-insensitive; defaults to 1 (no
97
+ * suffix).
98
+ */
99
+ export function parseSuffix(s: string): number {
100
+ return s.toUpperCase() === 'K'
101
+ ? 1024
102
+ : s.toUpperCase() === 'M'
103
+ ? 1024 * 1024
104
+ : s.toUpperCase() === 'G'
105
+ ? 1024 * 1024 * 1024
106
+ : 1;
107
+ }