pyre-cli 9.5.2 → 10.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 (86) hide show
  1. package/README.md +73 -16
  2. package/dist/alertChannels-TPSQIJ7S.js +8 -0
  3. package/dist/{blender-7UT5J7IP.js → blender-FGH3ASEQ.js} +2 -2
  4. package/dist/brewHealth-P2GW5Y5C.js +176 -0
  5. package/dist/buildCmd-CDPZ2ZFA.js +38 -0
  6. package/dist/{checkCmd-T7O4KXXA.js → checkCmd-RKW6QGDR.js} +9 -11
  7. package/dist/{chunk-CXL4GYGQ.js → chunk-53O2RGSL.js} +44 -17
  8. package/dist/{chunk-3TE7MJLS.js → chunk-6UHZP4QP.js} +36 -10
  9. package/dist/chunk-GHRDW646.js +2506 -0
  10. package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
  11. package/dist/chunk-M73HCQD2.js +95 -0
  12. package/dist/chunk-MZO5QKUR.js +32 -0
  13. package/dist/{chunk-25I533OF.js → chunk-VIACN2RU.js} +1 -1
  14. package/dist/chunk-Z2YV5HKV.js +87 -0
  15. package/dist/chunk-ZLVIVFSO.js +110 -0
  16. package/dist/{collectors-JB4OGUEF.js → collectors-H43PN4PS.js} +6 -3
  17. package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
  18. package/dist/{config-KS5ZZ3VN.js → config-GYGJXU2Q.js} +9 -1
  19. package/dist/doctor-GYHFTNZQ.js +387 -0
  20. package/dist/explainCmd-MF6PIMLW.js +222 -0
  21. package/dist/extensions-XJGHG7X3.js +134 -0
  22. package/dist/index.js +460 -88
  23. package/dist/ioreport.py +72 -0
  24. package/dist/netUsageCmd-WKBTW2YG.js +90 -0
  25. package/dist/{pipeCmd-TYLJHQ5B.js → pipeCmd-7REDBLBU.js} +4 -3
  26. package/dist/profileProcCmd-N4H6MCBT.js +107 -0
  27. package/dist/prometheus-5BL5PVLC.js +6 -0
  28. package/dist/prometheusCmd-PL64BLN5.js +93 -0
  29. package/dist/replayCmd-Z3PYXY7V.js +164 -0
  30. package/dist/run-HOGQDDTA.js +8 -0
  31. package/dist/serveCmd-2H5VLF3P.js +153 -0
  32. package/dist/service-Q74BBILO.js +181 -0
  33. package/dist/smartCmd-ODWKSFNG.js +166 -0
  34. package/dist/syncCmd-A2W3QB3S.js +126 -0
  35. package/dist/topoCmd-DU2HHZNH.js +148 -0
  36. package/dist/watchdogCmd-UF53JPZB.js +161 -0
  37. package/dist/{wizard-Z3RGA3VB.js → wizard-TD7BGX6O.js} +1 -1
  38. package/install.ps1 +29 -0
  39. package/install.sh +10 -9
  40. package/package.json +7 -3
  41. package/src/ai/models.ts +138 -0
  42. package/src/alertChannels.ts +119 -0
  43. package/src/brewHealth.ts +157 -40
  44. package/src/buildCmd.ts +48 -0
  45. package/src/checkCmd.ts +5 -5
  46. package/src/completions.ts +35 -0
  47. package/src/doctor.ts +235 -7
  48. package/src/easy_features.test.ts +40 -0
  49. package/src/explainCmd.ts +261 -0
  50. package/src/extensions.ts +71 -7
  51. package/src/formatters/render.ts +34 -10
  52. package/src/formatters/types.ts +2 -0
  53. package/src/index.ts +251 -36
  54. package/src/live/input.ts +60 -0
  55. package/src/live/render.ts +67 -0
  56. package/src/live/state.ts +5 -0
  57. package/src/live/types.ts +1 -1
  58. package/src/monitors/batteryPredictor.ts +143 -0
  59. package/src/monitors/buildTracker.ts +121 -0
  60. package/src/monitors/collectors.ts +342 -178
  61. package/src/monitors/ioreport.py +72 -0
  62. package/src/monitors/platform/linux.ts +756 -0
  63. package/src/monitors/platform/windows.ts +519 -0
  64. package/src/monitors/run.ts +27 -3
  65. package/src/monitors/smc.ts +21 -1
  66. package/src/monitors/types.ts +15 -0
  67. package/src/netUsageCmd.ts +121 -0
  68. package/src/platform.test.ts +99 -0
  69. package/src/plugins/index.ts +143 -0
  70. package/src/profileProcCmd.ts +133 -0
  71. package/src/prometheusCmd.ts +107 -0
  72. package/src/replayCmd.ts +186 -0
  73. package/src/serveCmd.ts +180 -0
  74. package/src/service.ts +125 -7
  75. package/src/smartCmd.ts +205 -0
  76. package/src/state/config.ts +58 -17
  77. package/src/syncCmd.ts +166 -0
  78. package/src/topoCmd.ts +176 -0
  79. package/src/watchdogCmd.ts +198 -0
  80. package/src/xbar.ts +22 -11
  81. package/dist/brewHealth-F2BXF5M6.js +0 -74
  82. package/dist/chunk-FH5WDJBF.js +0 -15
  83. package/dist/chunk-N4WJVF5M.js +0 -1184
  84. package/dist/doctor-UV7TWIHX.js +0 -184
  85. package/dist/extensions-DVA5YLGH.js +0 -74
  86. package/dist/service-WTS3NBQU.js +0 -73
@@ -13,11 +13,44 @@
13
13
  */
14
14
  import chalk from 'chalk';
15
15
  import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import fs from 'node:fs';
16
19
  import { run } from './run.js';
17
20
  import { getSmcMetrics, parseSuffix } from './smc.js';
18
21
  import { resolveIp } from './dns_cache.js';
19
22
  import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess, TaskData, ContainerData, NetworkConnection, BlenderRenderData, ProtocolStats, ConnectionStateStats, RemoteHostInfo } from './types.js';
20
23
  import { collectBlenderRenders } from './blender.js';
24
+ import {
25
+ collectLinuxSystem,
26
+ collectLinuxCpu,
27
+ collectLinuxMemory,
28
+ collectLinuxDisk,
29
+ collectLinuxBattery,
30
+ collectLinuxThermal,
31
+ collectLinuxPower,
32
+ collectLinuxGpu,
33
+ collectLinuxNetwork,
34
+ collectLinuxPackets,
35
+ collectLinuxProcesses,
36
+ collectLinuxTasks,
37
+ getLinuxDisplayInfo,
38
+ } from './platform/linux.js';
39
+ import {
40
+ collectWindowsSystem,
41
+ collectWindowsCpu,
42
+ collectWindowsMemory,
43
+ collectWindowsDisk,
44
+ collectWindowsBattery,
45
+ collectWindowsThermal,
46
+ collectWindowsPower,
47
+ collectWindowsGpu,
48
+ collectWindowsNetwork,
49
+ collectWindowsPackets,
50
+ collectWindowsProcesses,
51
+ collectWindowsTasks,
52
+ getWindowsDisplayInfo,
53
+ } from './platform/windows.js';
21
54
 
22
55
  const SP_TTL_MS = 10_000;
23
56
  const NETSTAT_TTL_MS = 1000;
@@ -34,6 +67,31 @@ const sysctlCache = new Map<string, { value: string; ts: number }>();
34
67
 
35
68
  let prevNetSample: { rxBytes: number; txBytes: number; rxPackets: number; txPackets: number; ts: number } | null = null;
36
69
 
70
+ let cachedIoReportPath: string | null | undefined = undefined;
71
+ function resolveIoReportScript(): string | null {
72
+ if (cachedIoReportPath !== undefined) return cachedIoReportPath;
73
+ try {
74
+ const dir = path.dirname(fileURLToPath(import.meta.url));
75
+ const candidates = [
76
+ path.join(dir, 'ioreport.py'),
77
+ path.join(dir, '..', 'src', 'monitors', 'ioreport.py'),
78
+ path.join(dir, 'monitors', 'ioreport.py'),
79
+ path.join(process.cwd(), 'src', 'monitors', 'ioreport.py'),
80
+ path.join(process.cwd(), 'dist', 'ioreport.py'),
81
+ ];
82
+ for (const c of candidates) {
83
+ if (fs.existsSync(c)) {
84
+ cachedIoReportPath = c;
85
+ return c;
86
+ }
87
+ }
88
+ } catch {
89
+ // ignore
90
+ }
91
+ cachedIoReportPath = null;
92
+ return null;
93
+ }
94
+
37
95
  async function cachedSysctl(key: string, fallback: string): Promise<string> {
38
96
  const now = Date.now();
39
97
  const hit = sysctlCache.get(key);
@@ -81,7 +139,7 @@ const MIN_ESTIMATE_WINDOW_MS = 30 * 1000;
81
139
 
82
140
  let prevBattery: { samples: { level: number; ts: number }[] } = { samples: [] };
83
141
 
84
- function estimateBatteryLife(level: number, state: string): { estimatedTimeToEmpty?: string; dischargeRatePerHour?: number; powerWatts?: number } {
142
+ function estimateBatteryLife(level: number, state: string): { estimatedTimeToEmpty?: string; dischargeRatePerHour?: number } {
85
143
  const now = Date.now();
86
144
 
87
145
  if (state === 'charged' || state === 'charging') {
@@ -117,21 +175,18 @@ function estimateBatteryLife(level: number, state: string): { estimatedTimeToEmp
117
175
  const m = Math.round((hoursToEmpty - h) * 60);
118
176
  const estimatedTimeToEmpty = hoursToEmpty > 0 ? `${h}h ${m}m` : 'calculating';
119
177
 
120
- // Rough wattage from discharge rate: %/hour * (typical battery capacity
121
- // in Wh) / 100. This is only used as a last-resort estimate — collectPower()
122
- // now prefers real ioreg-derived wattage when available (see below).
123
- const ASSUMED_CAPACITY_WH = 55;
124
- const powerWatts = Math.round(dischargeRate * (ASSUMED_CAPACITY_WH / 100) * 100) / 100;
125
-
126
- return { estimatedTimeToEmpty, dischargeRatePerHour: Math.round(dischargeRate * 10) / 10, powerWatts };
178
+ return { estimatedTimeToEmpty, dischargeRatePerHour: Math.round(dischargeRate * 10) / 10 };
127
179
  }
128
180
 
181
+ import { collectBuilds } from './buildTracker.js';
182
+ import { computeSmartBatteryPrediction } from './batteryPredictor.js';
183
+
129
184
  /**
130
185
  * Gather every metric category in parallel and return a
131
186
  * complete {@link StatsData} snapshot.
132
187
  */
133
188
  export async function collectAll(opts: { detailed?: boolean; processLimit?: number } = {}): Promise<StatsData> {
134
- const [cpu, gpu, memory, disk, battery, thermal, network, processes, system, power, packets, tasks, containers, blenderRenders] = await Promise.all([
189
+ const [cpu, gpu, memory, disk, rawBattery, thermal, network, processes, system, power, packets, tasks, containers, blenderRenders, activeBuilds] = await Promise.all([
135
190
  collectCpu(),
136
191
  collectGpu(opts.detailed),
137
192
  collectMemory(),
@@ -146,8 +201,14 @@ export async function collectAll(opts: { detailed?: boolean; processLimit?: numb
146
201
  collectTasks(),
147
202
  collectContainers(),
148
203
  collectBlenderRenders(),
204
+ collectBuilds(),
149
205
  ]);
150
206
 
207
+ const battery = rawBattery ? {
208
+ ...rawBattery,
209
+ smartPrediction: computeSmartBatteryPrediction(rawBattery, power) || undefined,
210
+ } : null;
211
+
151
212
  return {
152
213
  header: {
153
214
  title: chalk.hex('#ff5500').bold('PYRE'),
@@ -169,6 +230,7 @@ export async function collectAll(opts: { detailed?: boolean; processLimit?: numb
169
230
  tasks,
170
231
  containers,
171
232
  blenderRenders,
233
+ activeBuilds,
172
234
  };
173
235
  }
174
236
 
@@ -201,57 +263,65 @@ export async function collectContainers(): Promise<ContainerData[]> {
201
263
  * power data is available.
202
264
  */
203
265
  export async function collectPower(): Promise<PowerData | null> {
204
- try {
205
- const { power } = await getSmcMetrics();
206
- if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
207
- return { cpuWatts: power.cpu, gpuWatts: power.gpu, aneWatts: 0, combinedWatts: power.combined };
208
- }
209
- } catch {
210
- // ignore
211
- }
266
+ if (process.platform === 'linux') return collectLinuxPower();
267
+ if (process.platform === 'win32') return collectWindowsPower();
268
+ try {
269
+ const { power } = await getSmcMetrics();
270
+ if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
271
+ return { cpuWatts: power.cpu, gpuWatts: power.gpu, aneWatts: 0, combinedWatts: power.combined };
272
+ }
273
+ } catch {
274
+ // ignore
275
+ }
212
276
 
213
- // `pmset -g batt` never actually prints a "mW" figure on any macOS
214
- // version — that regex could never match, so this fallback was dead
215
- // code and collectPower() silently returned null whenever powermetrics
216
- // wasn't available (which is most machines without passwordless sudo
217
- // configured). ioreg's AppleSmartBattery entry exposes instantaneous
218
- // amperage (mA, signed: negative while discharging) and voltage (mV)
219
- // without any privileges, so we can derive real wattage from that.
220
- try {
221
- const ioreg = (await run('ioreg -rn AppleSmartBattery 2>/dev/null')).trim();
222
- const ampMatch = ioreg.match(/"InstantAmperage"\s*=\s*(-?\d+)/);
223
- const voltMatch = ioreg.match(/"Voltage"\s*=\s*(\d+)/);
224
- if (ampMatch && voltMatch) {
225
- // Two representations show up in the wild: Intel Macs often print a
226
- // literal minus sign ("-1200") — no unwrap needed. Apple Silicon
227
- // instead wraps a negative reading into an *unsigned 64-bit* field
228
- // (e.g. real -1200mA prints as 18446744073709550416). The previous
229
- // 32-bit unwrap (`- 0x100000000`) didn't touch that at all, and
230
- // `parseInt` can't even represent a number that large precisely, so
231
- // it silently mangled into a huge garbage value → absurd wattage.
232
- // Parse as BigInt and only unwrap when there's no explicit sign.
233
- let ampsBig = BigInt(ampMatch[1]);
234
- if (!ampMatch[1].startsWith('-')) {
235
- const UINT64_MAX_PLUS_1 = 1n << 64n;
236
- const INT64_MAX = (1n << 63n) - 1n;
237
- if (ampsBig > INT64_MAX) ampsBig -= UINT64_MAX_PLUS_1;
238
- }
239
- const amps = Number(ampsBig);
240
- const volts = parseInt(voltMatch[1], 10) / 1000;
241
- const watts = Math.abs((amps / 1000) * volts);
242
- // Sanity clamp — a real Mac never draws anywhere near this much;
243
- // treat an out-of-range reading as "no usable data" rather than
244
- // display nonsense.
245
- if (Number.isFinite(watts) && watts < 500) {
246
- return { combinedWatts: Math.round(watts * 100) / 100 };
247
- }
248
- }
249
- } catch {
250
- // ignore
251
- }
277
+ // Apple Silicon IOReport (reads Energy Model channels GPU Energy, CPU Energy, ANE without root)
278
+ const scriptPath = resolveIoReportScript();
279
+ if (scriptPath && process.platform === 'darwin') {
280
+ try {
281
+ const pyOut = (await run(`python3 "${scriptPath}" 2>/dev/null`, '')).trim();
282
+ if (pyOut.startsWith('{') && pyOut.endsWith('}')) {
283
+ const parsed = JSON.parse(pyOut);
284
+ if (typeof parsed.cpuWatts === 'number' || typeof parsed.gpuWatts === 'number') {
285
+ return {
286
+ cpuWatts: parsed.cpuWatts,
287
+ gpuWatts: parsed.gpuWatts,
288
+ aneWatts: parsed.aneWatts ?? 0,
289
+ combinedWatts: parsed.combinedWatts ?? (Math.round(((parsed.cpuWatts ?? 0) + (parsed.gpuWatts ?? 0) + (parsed.aneWatts ?? 0)) * 100) / 100),
290
+ };
291
+ }
292
+ }
293
+ } catch {
294
+ // ignore
295
+ }
296
+ }
252
297
 
253
- return null;
254
- }
298
+ // Fallback: ioreg's AppleSmartBattery entry exposes instantaneous
299
+ // amperage (mA, signed: negative while discharging) and voltage (mV)
300
+ // without any privileges, so we can derive real wattage from that.
301
+ try {
302
+ const ioreg = (await run('ioreg -rn AppleSmartBattery 2>/dev/null')).trim();
303
+ const ampMatch = ioreg.match(/"InstantAmperage"\s*=\s*(-?\d+)/);
304
+ const voltMatch = ioreg.match(/"Voltage"\s*=\s*(\d+)/);
305
+ if (ampMatch && voltMatch) {
306
+ let ampsBig = BigInt(ampMatch[1]);
307
+ if (!ampMatch[1].startsWith('-')) {
308
+ const UINT64_MAX_PLUS_1 = 1n << 64n;
309
+ const INT64_MAX = (1n << 63n) - 1n;
310
+ if (ampsBig > INT64_MAX) ampsBig -= UINT64_MAX_PLUS_1;
311
+ }
312
+ const amps = Number(ampsBig);
313
+ const volts = parseInt(voltMatch[1], 10) / 1000;
314
+ const watts = Math.abs((amps / 1000) * volts);
315
+ if (Number.isFinite(watts) && watts < 500) {
316
+ return { combinedWatts: Math.round(watts * 100) / 100 };
317
+ }
318
+ }
319
+ } catch {
320
+ // ignore
321
+ }
322
+
323
+ return null;
324
+ }
255
325
 
256
326
  /**
257
327
  * Gather GPU information from system_profiler and powermetrics.
@@ -263,6 +333,8 @@ export async function collectPower(): Promise<PowerData | null> {
263
333
  * Returns null when GPU hardware is unavailable.
264
334
  */
265
335
  export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
336
+ if (process.platform === 'linux') return collectLinuxGpu();
337
+ if (process.platform === 'win32') return collectWindowsGpu();
266
338
  let spRaw = '';
267
339
  const now = Date.now();
268
340
  if (spGpuCache && now - spGpuCache.ts < SP_TTL_MS) {
@@ -401,7 +473,9 @@ export async function collectPower(): Promise<PowerData | null> {
401
473
  return { model, memory, utilization, temperature, processes: gpuProcesses };
402
474
  }
403
475
 
404
- async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
476
+ export async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
477
+ if (process.platform === 'linux') return collectLinuxSystem();
478
+ if (process.platform === 'win32') return collectWindowsSystem();
405
479
  const hostname = (await run('hostname', 'Mac')).trim();
406
480
  const osRaw = (await run('sw_vers -productVersion', 'Unknown')).trim();
407
481
  const uptimeRaw = (await run('uptime')).trim();
@@ -414,6 +488,17 @@ async function collectSystem(): Promise<{ hostname: string; os: string; uptime:
414
488
  }
415
489
 
416
490
  export async function collectCpu(): Promise<CpuData> {
491
+ const prevCpuTimesRef = { current: prevCpuTimes };
492
+ if (process.platform === 'linux') {
493
+ const res = await collectLinuxCpu(prevCpuTimesRef);
494
+ prevCpuTimes = prevCpuTimesRef.current;
495
+ return res;
496
+ }
497
+ if (process.platform === 'win32') {
498
+ const res = await collectWindowsCpu(prevCpuTimesRef);
499
+ prevCpuTimes = prevCpuTimesRef.current;
500
+ return res;
501
+ }
417
502
  const brand = (await cachedSysctl('machdep.cpu.brand_string', 'Unknown CPU')).trim();
418
503
  const cores = parseInt(await cachedSysctl('hw.ncpu', '1')) || 1;
419
504
  const physicalCores = parseInt(await cachedSysctl('hw.physicalcpu', String(cores))) || cores;
@@ -533,6 +618,8 @@ export async function collectCpu(): Promise<CpuData> {
533
618
  }
534
619
 
535
620
  export async function collectMemory(): Promise<MemoryData> {
621
+ if (process.platform === 'linux') return collectLinuxMemory();
622
+ if (process.platform === 'win32') return collectWindowsMemory();
536
623
  const totalBytes = parseInt(await cachedSysctl('hw.memsize', '0')) || 0;
537
624
  const pageSize = parseInt(await cachedSysctl('hw.pagesize', '4096')) || 4096;
538
625
 
@@ -607,7 +694,7 @@ export async function collectMemory(): Promise<MemoryData> {
607
694
  };
608
695
  }
609
696
 
610
- let prevDiskIoSample: { ts: number } | null = null;
697
+ let prevDiskIoSample: { totalBytes: number; ts: number } | null = null;
611
698
  let cachedDiskIo: { readBytesSec: number; writeBytesSec: number; ts: number } | null = null;
612
699
 
613
700
  async function getDiskIoRates(): Promise<{ readBytesSec: number; writeBytesSec: number }> {
@@ -616,22 +703,23 @@ async function getDiskIoRates(): Promise<{ readBytesSec: number; writeBytesSec:
616
703
  return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
617
704
  }
618
705
  try {
619
- const raw = (await run('iostat -d -c 2 1 2', '', 3000)).trim();
620
- const blocks = raw.split(/\n\s*\n/).filter(Boolean);
621
- const targetBlock = blocks.length > 1 ? blocks[1] : blocks[0];
622
- if (targetBlock) {
623
- const lines = targetBlock.trim().split('\n');
624
- if (lines.length >= 2) {
625
- const dataLine = lines[lines.length - 1].trim();
626
- const parts = dataLine.split(/\s+/);
627
- if (parts.length >= 3) {
628
- const mbSec = parseFloat(parts[2]);
629
- if (!isNaN(mbSec)) {
630
- const bytesSec = Math.round(mbSec * 1024 * 1024);
631
- // Splitting combined throughput evenly/estimating if read/write distinction unavailable directly from summary iostat
632
- cachedDiskIo = { readBytesSec: Math.round(bytesSec * 0.6), writeBytesSec: Math.round(bytesSec * 0.4), ts: now };
633
- return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
706
+ const raw = (await run('iostat -Id -c 1', '', 1000)).trim();
707
+ const lines = raw.split('\n');
708
+ if (lines.length >= 3) {
709
+ const dataLine = lines[lines.length - 1].trim();
710
+ const parts = dataLine.split(/\s+/);
711
+ if (parts.length >= 3) {
712
+ const totalMb = parseFloat(parts[2]);
713
+ if (!isNaN(totalMb)) {
714
+ const totalBytes = totalMb * 1024 * 1024;
715
+ let bytesSec = 0;
716
+ if (prevDiskIoSample && prevDiskIoSample.ts > 0) {
717
+ const dt = Math.max((now - prevDiskIoSample.ts) / 1000, 0.001);
718
+ bytesSec = Math.max(0, (totalBytes - prevDiskIoSample.totalBytes) / dt);
634
719
  }
720
+ prevDiskIoSample = { totalBytes, ts: now };
721
+ cachedDiskIo = { readBytesSec: Math.round(bytesSec * 0.6), writeBytesSec: Math.round(bytesSec * 0.4), ts: now };
722
+ return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
635
723
  }
636
724
  }
637
725
  }
@@ -642,6 +730,8 @@ async function getDiskIoRates(): Promise<{ readBytesSec: number; writeBytesSec:
642
730
  }
643
731
 
644
732
  export async function collectDisk(): Promise<DiskData[]> {
733
+ if (process.platform === 'linux') return collectLinuxDisk();
734
+ if (process.platform === 'win32') return collectWindowsDisk();
645
735
  const [rawDf, ioRates] = await Promise.all([
646
736
  run('df -h'),
647
737
  getDiskIoRates(),
@@ -665,6 +755,8 @@ export async function collectDisk(): Promise<DiskData[]> {
665
755
  }
666
756
 
667
757
  export async function collectBattery(): Promise<BatteryData | null> {
758
+ if (process.platform === 'linux') return collectLinuxBattery();
759
+ if (process.platform === 'win32') return collectWindowsBattery();
668
760
  try {
669
761
  const raw = (await run('pmset -g batt')).trim();
670
762
  if (!raw.includes('Battery Power') && !raw.includes('AC Power')) return null;
@@ -734,6 +826,31 @@ export async function collectBattery(): Promise<BatteryData | null> {
734
826
 
735
827
  const estimate = estimateBatteryLife(level, state);
736
828
 
829
+ let powerWatts: number | undefined;
830
+ if (!powerPlugged && state === 'discharging') {
831
+ try {
832
+ const ioreg = (await run('ioreg -rn AppleSmartBattery 2>/dev/null')).trim();
833
+ const ampMatch = ioreg.match(/"InstantAmperage"\s*=\s*(-?\d+)/);
834
+ const voltMatch = ioreg.match(/"Voltage"\s*=\s*(\d+)/);
835
+ if (ampMatch && voltMatch) {
836
+ let ampsBig = BigInt(ampMatch[1]);
837
+ if (!ampMatch[1].startsWith('-')) {
838
+ const UINT64_MAX_PLUS_1 = 1n << 64n;
839
+ const INT64_MAX = (1n << 63n) - 1n;
840
+ if (ampsBig > INT64_MAX) ampsBig -= UINT64_MAX_PLUS_1;
841
+ }
842
+ const amps = Number(ampsBig);
843
+ const volts = parseInt(voltMatch[1], 10) / 1000;
844
+ const watts = Math.abs((amps / 1000) * volts);
845
+ if (Number.isFinite(watts) && watts < 500) {
846
+ powerWatts = Math.round(watts * 100) / 100;
847
+ }
848
+ }
849
+ } catch {
850
+ // ignore
851
+ }
852
+ }
853
+
737
854
  return {
738
855
  level,
739
856
  state,
@@ -744,6 +861,7 @@ export async function collectBattery(): Promise<BatteryData | null> {
744
861
  condition,
745
862
  maxCapacityPercent,
746
863
  friendlySummary,
864
+ powerWatts,
747
865
  ...estimate,
748
866
  };
749
867
  } catch {
@@ -760,6 +878,8 @@ function pressureFromState(state: string): number {
760
878
  }
761
879
 
762
880
  export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
881
+ if (process.platform === 'linux') return collectLinuxThermal();
882
+ if (process.platform === 'win32') return collectWindowsThermal();
763
883
  try {
764
884
  const therm = (await run('pmset -g therm', '')).trim();
765
885
 
@@ -828,11 +948,21 @@ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
828
948
  detail = `${state} (estimated from ${cpuDieTemp}°C)`;
829
949
  }
830
950
 
951
+ // Collect fan speeds from SMC (same cached call as temps above)
952
+ let fans: import('./types.js').FanData[] = [];
953
+ try {
954
+ const smcData = await getSmcMetrics();
955
+ fans = smcData.fans;
956
+ } catch {
957
+ // ignore — fans unavailable without sudo
958
+ }
959
+
831
960
  return {
832
961
  state,
833
962
  detail,
834
963
  pressureLevel: pressureFromState(state),
835
964
  temperatures: Object.keys(temperatures).length ? temperatures : undefined,
965
+ fans: fans.length > 0 ? fans : undefined,
836
966
  };
837
967
  } catch {
838
968
  return { state: 'Unknown', pressureLevel: 0, error: 'Thermal info unavailable on this system' };
@@ -840,6 +970,8 @@ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
840
970
  }
841
971
 
842
972
  export async function collectNetwork(): Promise<NetworkData> {
973
+ if (process.platform === 'linux') return collectLinuxNetwork();
974
+ if (process.platform === 'win32') return collectWindowsNetwork();
843
975
  try {
844
976
  const { iface, ip } = await cachedRouteIface();
845
977
  const netStat = await cachedNetstatIb();
@@ -1024,6 +1156,8 @@ async function getTopRemoteHosts(): Promise<RemoteHostInfo[]> {
1024
1156
  }
1025
1157
 
1026
1158
  export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
1159
+ if (process.platform === 'linux') return collectLinuxProcesses(limit);
1160
+ if (process.platform === 'win32') return collectWindowsProcesses(limit);
1027
1161
  try {
1028
1162
  const isMac = process.platform === 'darwin';
1029
1163
  const sortArg = isMac ? '-r' : '--sort=-pcpu';
@@ -1045,109 +1179,132 @@ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
1045
1179
  }
1046
1180
  }
1047
1181
 
1182
+ function parseBytes(str: string): number {
1183
+ if (!str) return 0;
1184
+ const match = str.trim().match(/([\d.]+)\s*([KMGTPE]?)i?B?/i);
1185
+ if (!match) return 0;
1186
+ let val = parseFloat(match[1]);
1187
+ const unit = match[2].toUpperCase();
1188
+ if (unit === 'K') val *= 1024;
1189
+ else if (unit === 'M') val *= 1024 * 1024;
1190
+ else if (unit === 'G') val *= 1024 * 1024 * 1024;
1191
+ else if (unit === 'T') val *= 1024 * 1024 * 1024 * 1024;
1192
+ return Math.round(val);
1193
+ }
1194
+
1048
1195
  export async function collectPackets(): Promise<PacketData | null> {
1049
- try {
1050
- const netStat = await cachedNetstatIb();
1051
- const lines = netStat.split('\n').slice(1);
1052
- let totalRxPackets = 0;
1053
- let totalTxPackets = 0;
1054
- let totalRxBytes = 0;
1055
- let totalTxBytes = 0;
1056
- const ifaceStats: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[] = [];
1057
-
1058
- for (const line of lines) {
1059
- const parts = line.split(/\s+/);
1060
- if (parts.length >= 11 && parts[0] && !parts[0].startsWith('Name')) {
1061
- const iface = parts[0];
1062
- const rxPackets = parseInt(parts[4]) || 0;
1063
- const rxBytes = parseInt(parts[5]) || 0;
1064
- const txPackets = parseInt(parts[6]) || 0;
1065
- const txBytes = parseInt(parts[7]) || 0;
1066
- totalRxPackets += rxPackets;
1067
- totalTxPackets += txPackets;
1068
- totalRxBytes += rxBytes;
1069
- totalTxBytes += txBytes;
1070
- if (iface && !iface.includes('lo')) {
1071
- ifaceStats.push({ iface, rxPackets, txPackets, rxBytes, txBytes });
1072
- }
1073
- }
1074
- }
1075
-
1076
- let connections = 0;
1077
- let allProcesses: NetworkProcess[] = [];
1078
-
1079
- try {
1080
- const nettop = (await run('nettop -L 1 -J bytes_in,bytes_out,state 2>/dev/null', '', 2000)).trim();
1081
- const rows = nettop.split('\n');
1082
-
1083
- let currentProc: NetworkProcess | null = null;
1084
-
1085
- for (const row of rows) {
1086
- if (!row || row.startsWith(',state,')) continue;
1087
-
1088
- const cols = row.split(',');
1089
- const nameCol = cols[0];
1090
-
1091
- // If it doesn't contain <->, it's a process row
1092
- if (!nameCol.includes('<->')) {
1093
- if (currentProc) {
1094
- if (currentProc.connections && currentProc.connections.length > 0) {
1095
- allProcesses.push(currentProc);
1096
- }
1097
- }
1098
-
1099
- // Extract PID and Name (e.g., "syslogd.369")
1100
- const lastDot = nameCol.lastIndexOf('.');
1101
- let pid = 0;
1102
- let command = nameCol;
1103
- if (lastDot > 0) {
1104
- const possiblePid = parseInt(nameCol.substring(lastDot + 1));
1105
- if (!isNaN(possiblePid)) {
1106
- pid = possiblePid;
1107
- command = nameCol.substring(0, lastDot);
1108
- }
1109
- }
1110
-
1111
- const rx = parseInt(cols[2]) || 0;
1112
- const tx = parseInt(cols[3]) || 0;
1113
-
1114
- currentProc = { pid, command, rxBytes: rx, txBytes: tx, connections: [] };
1115
- } else {
1116
- // Connection row
1117
- if (currentProc) {
1118
- const state = cols[1];
1119
- const rx = parseInt(cols[2]) || 0;
1120
- const tx = parseInt(cols[3]) || 0;
1121
- if (state === 'Established') connections++;
1122
-
1123
- // Parse endpoints (e.g. "tcp4 172.16.0.2:49844<->17.242.13.5:5223")
1124
- const spaceIdx = nameCol.indexOf(' ');
1125
- const protocol = spaceIdx > 0 ? nameCol.substring(0, spaceIdx) : '';
1126
- const endpoints = spaceIdx > 0 ? nameCol.substring(spaceIdx + 1) : nameCol;
1127
- const arrowIdx = endpoints.indexOf('<->');
1128
- let local = '';
1129
- let remote = '';
1130
- if (arrowIdx > 0) {
1131
- local = endpoints.substring(0, arrowIdx);
1132
- remote = endpoints.substring(arrowIdx + 3);
1133
-
1134
- // Attempt DNS resolution on remote IP
1135
- remote = resolveIp(remote);
1136
- } else {
1137
- local = endpoints;
1138
- }
1139
-
1140
- // Ignore purely local communication if you want, but for now include all
1141
- currentProc.connections!.push({ protocol, local, remote, state, rxBytes: rx, txBytes: tx });
1142
- }
1143
- }
1144
- }
1145
- if (currentProc && currentProc.connections && currentProc.connections.length > 0) {
1146
- allProcesses.push(currentProc);
1147
- }
1148
- } catch {
1149
- allProcesses = [];
1150
- }
1196
+ if (process.platform === 'linux') return collectLinuxPackets();
1197
+ if (process.platform === 'win32') return collectWindowsPackets();
1198
+ try {
1199
+ const netStat = await cachedNetstatIb();
1200
+ const lines = netStat.split('\n').slice(1);
1201
+ let totalRxPackets = 0;
1202
+ let totalTxPackets = 0;
1203
+ let totalRxBytes = 0;
1204
+ let totalTxBytes = 0;
1205
+ const ifaceStats: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[] = [];
1206
+
1207
+ for (const line of lines) {
1208
+ const parts = line.split(/\s+/);
1209
+ if (parts.length >= 11 && parts[0] && !parts[0].startsWith('Name')) {
1210
+ const iface = parts[0];
1211
+ const rxPackets = parseInt(parts[4]) || 0;
1212
+ const rxBytes = parseInt(parts[5]) || 0;
1213
+ const txPackets = parseInt(parts[6]) || 0;
1214
+ const txBytes = parseInt(parts[7]) || 0;
1215
+ totalRxPackets += rxPackets;
1216
+ totalTxPackets += txPackets;
1217
+ totalRxBytes += rxBytes;
1218
+ totalTxBytes += txBytes;
1219
+ if (iface && !iface.includes('lo')) {
1220
+ ifaceStats.push({ iface, rxPackets, txPackets, rxBytes, txBytes });
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ let connections = 0;
1226
+ let allProcesses: NetworkProcess[] = [];
1227
+
1228
+ try {
1229
+ const nettop = (await run('script -q /dev/null nettop -l 1 -J bytes_in,bytes_out,state 2>/dev/null', '', 2000)).trim();
1230
+ const rows = nettop.split('\n');
1231
+
1232
+ let currentProc: NetworkProcess | null = null;
1233
+
1234
+ for (const row of rows) {
1235
+ if (!row || row.includes('bytes_in')) continue;
1236
+
1237
+ const parts = row.trimStart().split(/\s{2,}/);
1238
+ const nameCol = parts[0];
1239
+
1240
+ // If it doesn't contain <->, it's a process row
1241
+ if (!nameCol.includes('<->')) {
1242
+ if (currentProc) {
1243
+ if (currentProc.connections && currentProc.connections.length > 0) {
1244
+ allProcesses.push(currentProc);
1245
+ }
1246
+ }
1247
+
1248
+ // Extract PID and Name (e.g., "syslogd.369")
1249
+ const lastDot = nameCol.lastIndexOf('.');
1250
+ let pid = 0;
1251
+ let command = nameCol;
1252
+ if (lastDot > 0) {
1253
+ const possiblePid = parseInt(nameCol.substring(lastDot + 1));
1254
+ if (!isNaN(possiblePid)) {
1255
+ pid = possiblePid;
1256
+ command = nameCol.substring(0, lastDot);
1257
+ }
1258
+ }
1259
+
1260
+ const rx = parseBytes(parts[1]);
1261
+ const tx = parseBytes(parts[2]);
1262
+
1263
+ currentProc = { pid, command, rxBytes: rx, txBytes: tx, connections: [] };
1264
+ } else {
1265
+ // Connection row
1266
+ if (currentProc) {
1267
+ let state = '';
1268
+ let rx = 0;
1269
+ let tx = 0;
1270
+ if (parts.length >= 4) {
1271
+ state = parts[1];
1272
+ rx = parseBytes(parts[2]);
1273
+ tx = parseBytes(parts[3]);
1274
+ } else if (parts.length === 3) {
1275
+ rx = parseBytes(parts[1]);
1276
+ tx = parseBytes(parts[2]);
1277
+ }
1278
+ if (state === 'Established') connections++;
1279
+
1280
+ // Parse endpoints (e.g. "tcp4 172.16.0.2:49844<->17.242.13.5:5223")
1281
+ const spaceIdx = nameCol.indexOf(' ');
1282
+ const protocol = spaceIdx > 0 ? nameCol.substring(0, spaceIdx) : '';
1283
+ const endpoints = spaceIdx > 0 ? nameCol.substring(spaceIdx + 1) : nameCol;
1284
+ const arrowIdx = endpoints.indexOf('<->');
1285
+ let local = '';
1286
+ let remote = '';
1287
+ if (arrowIdx > 0) {
1288
+ local = endpoints.substring(0, arrowIdx);
1289
+ remote = endpoints.substring(arrowIdx + 3);
1290
+
1291
+ // Attempt DNS resolution on remote IP
1292
+ remote = resolveIp(remote);
1293
+ } else {
1294
+ local = endpoints;
1295
+ }
1296
+
1297
+ // Ignore purely local communication if you want, but for now include all
1298
+ currentProc.connections!.push({ protocol, local, remote, state, rxBytes: rx, txBytes: tx });
1299
+ }
1300
+ }
1301
+ }
1302
+ if (currentProc && currentProc.connections && currentProc.connections.length > 0) {
1303
+ allProcesses.push(currentProc);
1304
+ }
1305
+ } catch {
1306
+ allProcesses = [];
1307
+ }
1151
1308
 
1152
1309
  // Sort processes by total bandwidth (tx + rx) to get top processes
1153
1310
  const topProcesses = [...allProcesses].sort((a, b) => (b.rxBytes + b.txBytes) - (a.rxBytes + a.txBytes));
@@ -1176,7 +1333,9 @@ export async function collectPackets(): Promise<PacketData | null> {
1176
1333
  }
1177
1334
 
1178
1335
  export async function collectTasks(limit = 12): Promise<TaskData[]> {
1179
- try {
1336
+ if (process.platform === 'linux') return collectLinuxTasks(limit);
1337
+ if (process.platform === 'win32') return collectWindowsTasks(limit);
1338
+ try {
1180
1339
  const isMac = process.platform === 'darwin';
1181
1340
  const sortArg = isMac ? '-r' : '--sort=-pcpu';
1182
1341
  const raw = (await run(`ps -eo pid,user,pcpu,pmem,state,time,comm ${sortArg} | head -n ${limit + 1}`)).trim();
@@ -1202,6 +1361,8 @@ export interface DisplayInfo {
1202
1361
  }
1203
1362
 
1204
1363
  export async function getDisplayInfo(): Promise<DisplayInfo[]> {
1364
+ if (process.platform === 'linux') return getLinuxDisplayInfo();
1365
+ if (process.platform === 'win32') return getWindowsDisplayInfo();
1205
1366
  try {
1206
1367
  const raw = await run('system_profiler SPDisplaysDataType 2>&1', '');
1207
1368
  const displays: DisplayInfo[] = [];
@@ -1246,6 +1407,9 @@ export interface TimeMachineStatus {
1246
1407
  }
1247
1408
 
1248
1409
  export async function getTimeMachineStatus(): Promise<TimeMachineStatus> {
1410
+ if (process.platform !== 'darwin') {
1411
+ return { configured: false, running: false };
1412
+ }
1249
1413
  try {
1250
1414
  const statusRaw = await run('tmutil status 2>&1', '');
1251
1415
  if (statusRaw.includes('No Time Machine destination') || statusRaw.includes('not configured')) {