pyre-cli 10.4.0 → 10.6.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 (34) hide show
  1. package/dist/{blender-6ZX5SPYC.js → blender-OXH3PVYM.js} +3 -3
  2. package/dist/{brewHealth-VFW33QB3.js → brewHealth-JE4EZ63L.js} +1 -1
  3. package/dist/{buildCmd-E7KUCFYG.js → buildCmd-R6RY3XZR.js} +3 -3
  4. package/dist/{checkCmd-QLYTP77Y.js → checkCmd-KF6G77SC.js} +5 -5
  5. package/dist/{chunk-RKPGPVTO.js → chunk-2SUFLPIH.js} +5 -5
  6. package/dist/chunk-735X2GPS.js +88 -0
  7. package/dist/{chunk-QEH4LR6P.js → chunk-D3HWHU3X.js} +4 -4
  8. package/dist/{chunk-FYCBGUTW.js → chunk-GPSU54LZ.js} +2 -2
  9. package/dist/{chunk-NMGQVRCA.js → chunk-KLANOKZF.js} +2 -2
  10. package/dist/{chunk-OCL7P7N7.js → chunk-YXZ4XREM.js} +195 -111
  11. package/dist/{collectors-V7SCIIDH.js → collectors-DQEVTONN.js} +5 -5
  12. package/dist/{doctor-O4DIAMHK.js → doctor-SCHRYF6D.js} +2 -2
  13. package/dist/{extensions-QQEV7U3D.js → extensions-BWXMZVMN.js} +1 -1
  14. package/dist/{flightRecorder-GJIAQOXC.js → flightRecorder-J4TESP5M.js} +5 -5
  15. package/dist/index.js +55 -28
  16. package/dist/monitors/platform/windows-ui.ps1 +14 -4
  17. package/dist/{netUsageCmd-JP6YKBPF.js → netUsageCmd-2QVRC6FX.js} +5 -5
  18. package/dist/{optimizeCmd-WZKLFYZU.js → optimizeCmd-ZF7U3DXR.js} +2 -2
  19. package/dist/{pipeCmd-CPK6FVKB.js → pipeCmd-XBWAYRRU.js} +5 -5
  20. package/dist/{profileProcCmd-5Y3733E7.js → profileProcCmd-33YB4RXZ.js} +1 -1
  21. package/dist/{prometheusCmd-G2CSEZRQ.js → prometheusCmd-6PXZG73L.js} +5 -5
  22. package/dist/{run-UJQYT2TO.js → run-744KZ7CE.js} +1 -1
  23. package/dist/{serveCmd-KDPWLVQI.js → serveCmd-R2Y7O3XC.js} +5 -5
  24. package/dist/{smartCmd-2BJJ37GV.js → smartCmd-C33WRXA2.js} +1 -1
  25. package/dist/{statuslineCmd-EUKYRR2G.js → statuslineCmd-QPBGXC2G.js} +5 -5
  26. package/dist/{topoCmd-RTXQJ6XA.js → topoCmd-RVWFSPHE.js} +5 -5
  27. package/dist/{watchdogCmd-XM6OPTKM.js → watchdogCmd-346TXWWH.js} +6 -6
  28. package/package.json +1 -1
  29. package/src/index.ts +31 -2
  30. package/src/monitors/platform/windows-ui.ps1 +14 -4
  31. package/src/monitors/platform/windows.ts +225 -102
  32. package/src/monitors/run.ts +83 -7
  33. package/src/platform.test.ts +24 -0
  34. package/dist/chunk-MZO5QKUR.js +0 -32
@@ -10,7 +10,28 @@
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import fs from 'node:fs';
13
- import { run, runPowerShell } from '../run.js';
13
+ import { run, runPowerShell as rawRunPowerShell } from '../run.js';
14
+
15
+ /**
16
+ * Cache policy per query (stale-while-revalidate, see run.ts). Each powershell.exe launch is
17
+ * expensive, so static facts are cached for minutes and volatile ones for a few seconds.
18
+ * Order matters: the first matching rule wins.
19
+ */
20
+ const PS_TTL_RULES: Array<[RegExp, number]> = [
21
+ [/Win32_Battery|BatteryCycleCount|BatteryStatus/, 10_000],
22
+ [/Win32_Processor|Win32_VideoController|HardwareInformation|Win32_OperatingSystem/, 10 * 60_000],
23
+ [/GPUEngine/, 3_000],
24
+ [/LibreHardwareMonitor|OpenHardwareMonitor|MSAcpi_ThermalZone|Thermal Zone Information/, 20_000],
25
+ [/Win32_PageFileUsage|Win32_LogicalDisk/, 30_000],
26
+ [/Get-NetIPConfiguration/, 60_000],
27
+ [/Get-NetTCPConnection/, 5_000],
28
+ [/Get-Process\b/, 5_000],
29
+ ];
30
+
31
+ function runPowerShell(script: string, fallback = '', timeout = 10000): Promise<string> {
32
+ const rule = PS_TTL_RULES.find(([re]) => re.test(script));
33
+ return rawRunPowerShell(script, fallback, timeout, rule ? rule[1] : 0);
34
+ }
14
35
  import type {
15
36
  CpuData,
16
37
  MemoryData,
@@ -226,48 +247,24 @@ export async function collectWindowsCpu(prevCpuTimesRef: { current: { total: num
226
247
  }
227
248
 
228
249
  export async function getWindowsCpuTemperature(): Promise<number | undefined> {
229
- // 1. Try LibreHardwareMonitor or OpenHardwareMonitor WMI (widely supported on AMD Ryzen and Intel desktops)
230
- try {
231
- const lhmRaw = await runPowerShell(
232
- `Get-CimInstance -Namespace root/LibreHardwareMonitor -ClassName Sensor -Filter "SensorType = 'Temperature' and Name like '%CPU%'" -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Value`,
233
- ''
234
- );
235
- const lhmVal = safeParseFloat(lhmRaw);
236
- if (lhmVal >= 15 && lhmVal <= 115) return Math.round(lhmVal * 10) / 10;
237
- } catch {}
238
-
239
- try {
240
- const ohmRaw = await runPowerShell(
241
- `Get-CimInstance -Namespace root/OpenHardwareMonitor -ClassName Sensor -Filter "SensorType = 'Temperature' and Name like '%CPU%'" -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Value`,
242
- ''
243
- );
244
- const ohmVal = safeParseFloat(ohmRaw);
245
- if (ohmVal >= 15 && ohmVal <= 115) return Math.round(ohmVal * 10) / 10;
246
- } catch {}
247
-
248
- // 2. Try PowerShell CIM MSAcpi_ThermalZoneTemperature (modern Windows 10/11 replacement for wmic)
249
- try {
250
- const cimRaw = await runPowerShell(
251
- `(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty CurrentTemperature)`,
252
- ''
253
- );
254
- const kelvinTenths = safeParseInt(cimRaw);
255
- if (kelvinTenths > 2732 && kelvinTenths < 4200) {
256
- const c = Math.round(((kelvinTenths - 2732) / 10) * 10) / 10;
257
- if (c >= 15 && c <= 115) return c;
258
- }
259
- } catch {}
250
+ // One PowerShell launch that tries, in order: LibreHardwareMonitor / OpenHardwareMonitor WMI
251
+ // (AMD Ryzen + Intel desktops), ACPI thermal zone (needs admin on many machines), then the
252
+ // locale-independent thermal zone performance class. Outputs Celsius or nothing.
253
+ const script = [
254
+ `$t = $null`,
255
+ `foreach ($ns in 'root/LibreHardwareMonitor','root/OpenHardwareMonitor') {`,
256
+ ` if ($t) { break }`,
257
+ ` try { $v = Get-CimInstance -Namespace $ns -ClassName Sensor -Filter "SensorType = 'Temperature' and Name like '%CPU%'" -ErrorAction Stop | Select-Object -First 1 -ExpandProperty Value; if ($v) { $t = [double]$v } } catch {}`,
258
+ `}`,
259
+ `if (-not $t) { try { $k = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction Stop | Select-Object -First 1 -ExpandProperty CurrentTemperature; if ($k -gt 2732) { $t = ($k - 2732) / 10 } } catch {} }`,
260
+ `if (-not $t) { try { $k = Get-CimInstance -ClassName Win32_PerfFormattedData_Counters_ThermalZoneInformation -ErrorAction Stop | Where-Object { $_.Temperature -gt 273 -and $_.Temperature -lt 400 } | Select-Object -First 1 -ExpandProperty Temperature; if ($k) { $t = $k - 273.15 } } catch {} }`,
261
+ `if ($t) { [string]([math]::Round($t, 1)) }`,
262
+ ].join('\n');
260
263
 
261
- // 3. Try Windows Performance Counter
262
264
  try {
263
- const raw = await runPowerShell(
264
- `(Get-Counter '\\Thermal Zone Information(*)\\*' -ErrorAction SilentlyContinue).CounterSamples | Where-Object { $_.CookedValue -gt 200 -and $_.CookedValue -lt 400 } | Select-Object -First 1 -ExpandProperty CookedValue`,
265
- ''
266
- );
267
- const tempK = safeParseFloat(raw);
268
- if (tempK > 200 && tempK < 400) {
269
- return Math.round((tempK - 273.15) * 10) / 10;
270
- }
265
+ const raw = await runPowerShell(script, '');
266
+ const val = safeParseFloat(raw.replace(',', '.'));
267
+ if (val >= 15 && val <= 115) return Math.round(val * 10) / 10;
271
268
  } catch {}
272
269
 
273
270
  return undefined;
@@ -358,18 +355,54 @@ export async function collectWindowsDisk(): Promise<DiskData[]> {
358
355
  // fallback
359
356
  }
360
357
 
361
- return [
362
- {
363
- filesystem: 'C:',
364
- size: '500G',
365
- used: '150G',
366
- available: '350G',
367
- capacity: '30%',
368
- mountpoint: 'C:',
369
- readBytesSec: 0,
370
- writeBytesSec: 0,
371
- },
372
- ];
358
+ // Fallback: real numbers from the filesystem API (no PowerShell needed)
359
+ const fallback: DiskData[] = [];
360
+ for (let code = 67; code <= 90; code++) { // C..Z
361
+ const drive = `${String.fromCharCode(code)}:`;
362
+ try {
363
+ const st = (fs as any).statfsSync?.(`${drive}\\`);
364
+ if (!st) break;
365
+ const totalBytes = Number(st.blocks) * Number(st.bsize);
366
+ const freeBytes = Number(st.bavail) * Number(st.bsize);
367
+ if (totalBytes > 0) {
368
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
369
+ fallback.push({
370
+ filesystem: drive,
371
+ size: formatBytesStr(totalBytes),
372
+ used: formatBytesStr(usedBytes),
373
+ available: formatBytesStr(freeBytes),
374
+ capacity: `${Math.round((usedBytes / totalBytes) * 100)}%`,
375
+ mountpoint: drive,
376
+ readBytesSec: 0,
377
+ writeBytesSec: 0,
378
+ });
379
+ }
380
+ } catch {
381
+ // drive letter not present
382
+ }
383
+ }
384
+ if (fallback.length === 0) {
385
+ try {
386
+ const st = (fs as any).statfsSync?.(os.homedir());
387
+ const totalBytes = st ? Number(st.blocks) * Number(st.bsize) : 0;
388
+ const freeBytes = st ? Number(st.bavail) * Number(st.bsize) : 0;
389
+ if (totalBytes > 0) {
390
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
391
+ const drive = path.parse(os.homedir()).root.replace(/[\\/]+$/, '') || 'C:';
392
+ fallback.push({
393
+ filesystem: drive,
394
+ size: formatBytesStr(totalBytes),
395
+ used: formatBytesStr(usedBytes),
396
+ available: formatBytesStr(freeBytes),
397
+ capacity: `${Math.round((usedBytes / totalBytes) * 100)}%`,
398
+ mountpoint: drive,
399
+ readBytesSec: 0,
400
+ writeBytesSec: 0,
401
+ });
402
+ }
403
+ } catch {}
404
+ }
405
+ return fallback;
373
406
  }
374
407
 
375
408
  function formatBytesStr(bytes: number): string {
@@ -381,39 +414,96 @@ function formatBytesStr(bytes: number): string {
381
414
 
382
415
  // ─── Battery ───────────────────────────────────────────────────────────────────
383
416
 
417
+ export interface WindowsBatteryRaw {
418
+ level: number;
419
+ statusCode: number;
420
+ runTimeMinutes: number;
421
+ winDischargeRate: number;
422
+ cycles: number;
423
+ fullChargeMWh: number;
424
+ designMWh: number;
425
+ rateMilliwatts: number;
426
+ }
427
+
428
+ export function parseWindowsBatteryOutput(raw: string): WindowsBatteryRaw | null {
429
+ const line = raw.split('\n').map(l => l.trim()).find(l => l.includes('|'));
430
+ if (!line) return null;
431
+ const p = line.split('|');
432
+ return {
433
+ level: safeParseInt(p[0], 0),
434
+ statusCode: safeParseInt(p[1], 1),
435
+ runTimeMinutes: safeParseInt(p[2], 0),
436
+ winDischargeRate: safeParseInt(p[3], 0),
437
+ cycles: safeParseInt(p[4], 0),
438
+ fullChargeMWh: safeParseInt(p[5], 0),
439
+ designMWh: safeParseInt(p[6], 0),
440
+ rateMilliwatts: safeParseInt(p[7], 0),
441
+ };
442
+ }
443
+
384
444
  export async function collectWindowsBattery(): Promise<BatteryData | null> {
385
445
  try {
386
- const raw = await runPowerShell(
387
- `$bat = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue; if ($bat) { "$($bat.EstimatedChargeRemaining)|$($bat.BatteryStatus)|$($bat.EstimatedRunTime)|$($bat.DischargeRate)" } else { '' }`,
388
- ''
389
- );
390
- if (!raw) return null;
391
-
392
- const parts = raw.split('|');
393
- const level = safeParseInt(parts[0], 0);
394
- const statusCode = safeParseInt(parts[1], 1);
395
- const runTimeMinutes = safeParseInt(parts[2], 0);
396
- const dischargeRate = safeParseInt(parts[3], 0);
446
+ // Win32_Battery gives level/status; the root/wmi classes (supported by most modern laptops)
447
+ // give cycle count, design vs. full-charge capacity and instantaneous charge/discharge rate.
448
+ // Everything is fetched in ONE PowerShell launch. Output: level|status|runtime|discharge|cycles|full|design|rate
449
+ const script = [
450
+ `$b = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1`,
451
+ `if ($b) {`,
452
+ ` $cyc = (Get-CimInstance -Namespace root/wmi -ClassName BatteryCycleCount -ErrorAction SilentlyContinue | Select-Object -First 1).CycleCount`,
453
+ ` $full = (Get-CimInstance -Namespace root/wmi -ClassName BatteryFullChargedCapacity -ErrorAction SilentlyContinue | Select-Object -First 1).FullChargedCapacity`,
454
+ ` $des = (Get-CimInstance -Namespace root/wmi -ClassName BatteryStaticData -ErrorAction SilentlyContinue | Select-Object -First 1).DesignedCapacity`,
455
+ ` $st = Get-CimInstance -Namespace root/wmi -ClassName BatteryStatus -ErrorAction SilentlyContinue | Select-Object -First 1`,
456
+ ` $rate = 0; if ($st) { if ($st.Charging) { $rate = $st.ChargeRate } elseif ($st.Discharging) { $rate = $st.DischargeRate } }`,
457
+ ` "$($b.EstimatedChargeRemaining)|$($b.BatteryStatus)|$($b.EstimatedRunTime)|$($b.DischargeRate)|$cyc|$full|$des|$rate"`,
458
+ `}`,
459
+ ].join('\n');
460
+
461
+ const raw = await runPowerShell(script, '');
462
+ const b = parseWindowsBatteryOutput(raw);
463
+ if (!b) return null;
464
+
465
+ // BatteryStatus: 1 discharging, 2 on AC, 3 fully charged, 4 low, 5 critical, 6-9 charging, 11 partially charged
466
+ const charging = [2, 6, 7, 8, 9].includes(b.statusCode);
467
+ const onAc = [2, 3, 6, 7, 8, 9, 11].includes(b.statusCode);
468
+ const powerSource = onAc ? 'AC' : 'Battery';
469
+ const state = b.statusCode === 3 || (onAc && b.level >= 100) ? 'charged' : charging ? 'charging' : 'discharging';
397
470
 
398
- const powerSource = statusCode === 2 || statusCode === 3 || statusCode === 6 ? 'AC' : 'Battery';
399
- const state = statusCode === 3 ? 'charged' : statusCode === 6 || statusCode === 2 ? 'charging' : 'discharging';
400
471
  let timeRemaining = 'calculating';
401
472
  if (state === 'charged') timeRemaining = '∞';
402
- else if (runTimeMinutes && runTimeMinutes > 0 && runTimeMinutes < 70000) {
403
- const h = Math.floor(runTimeMinutes / 60);
404
- const m = runTimeMinutes % 60;
473
+ else if (state === 'discharging' && b.runTimeMinutes > 0 && b.runTimeMinutes < 70000) {
474
+ const h = Math.floor(b.runTimeMinutes / 60);
475
+ const m = b.runTimeMinutes % 60;
405
476
  timeRemaining = `${h}:${m.toString().padStart(2, '0')}`;
406
477
  }
407
478
 
408
- const powerWatts = dischargeRate > 0 ? Math.round((dischargeRate / 1000) * 100) / 100 : undefined;
479
+ // Wattage: prefer instantaneous rate from root/wmi (mW), fall back to Win32_Battery.DischargeRate
480
+ const rateMw = Math.abs(b.rateMilliwatts) > 0 && Math.abs(b.rateMilliwatts) < 400_000 ? Math.abs(b.rateMilliwatts) : b.winDischargeRate;
481
+ const powerWatts = rateMw > 0 ? Math.round((rateMw / 1000) * 100) / 100 : undefined;
482
+
483
+ // Health = full-charge capacity / design capacity. Cycle count of 0 means "not reported".
484
+ let maxCapacityPercent: number | undefined;
485
+ if (b.designMWh > 0 && b.fullChargeMWh > 0) {
486
+ maxCapacityPercent = Math.min(100, Math.round((b.fullChargeMWh / b.designMWh) * 100));
487
+ }
488
+ const cycles = b.cycles > 0 && b.cycles < 100_000 ? b.cycles : undefined;
489
+
490
+ let health = 'Good';
491
+ let condition = 'Normal';
492
+ if (maxCapacityPercent !== undefined) {
493
+ if (maxCapacityPercent < 60) { health = 'Poor'; condition = 'Service Recommended'; }
494
+ else if (maxCapacityPercent < 80) { health = 'Fair'; condition = 'Reduced Capacity'; }
495
+ }
409
496
 
410
497
  return {
411
- level,
498
+ level: b.level,
412
499
  state,
413
500
  timeRemaining,
414
- health: 'Good',
501
+ health,
415
502
  powerSource,
416
503
  powerWatts,
504
+ cycles,
505
+ condition,
506
+ maxCapacityPercent,
417
507
  };
418
508
  } catch {
419
509
  return null;
@@ -562,7 +652,15 @@ export async function collectWindowsPower(): Promise<PowerData | null> {
562
652
 
563
653
  // ─── GPU Telemetry Across Intel, AMD, NVIDIA ─────────────────────────────────
564
654
 
655
+ let nvidiaSmiPathCache: { path: string | null; checkedAt: number } | null = null;
656
+ const NVIDIA_SMI_NEGATIVE_TTL_MS = 5 * 60 * 1000;
657
+
565
658
  async function findNvidiaSmiPath(): Promise<string> {
659
+ if (nvidiaSmiPathCache) {
660
+ if (nvidiaSmiPathCache.path) return nvidiaSmiPathCache.path;
661
+ // Not found previously: only re-probe occasionally (e.g. driver installed while running)
662
+ if (Date.now() - nvidiaSmiPathCache.checkedAt < NVIDIA_SMI_NEGATIVE_TTL_MS) return 'nvidia-smi';
663
+ }
566
664
  const commonPaths = [
567
665
  'nvidia-smi',
568
666
  path.join(process.env.ProgramFiles || 'C:\\Program Files', 'NVIDIA Corporation', 'NVSMI', 'nvidia-smi.exe'),
@@ -571,14 +669,19 @@ async function findNvidiaSmiPath(): Promise<string> {
571
669
  for (const p of commonPaths) {
572
670
  try {
573
671
  const test = await run(`"${p}" -L 2>nul`, '');
574
- if (test.includes('GPU 0:')) return p;
672
+ if (test.includes('GPU 0:')) {
673
+ nvidiaSmiPathCache = { path: p, checkedAt: Date.now() };
674
+ return p;
675
+ }
575
676
  } catch {}
576
677
  }
678
+ nvidiaSmiPathCache = { path: null, checkedAt: Date.now() };
577
679
  return 'nvidia-smi';
578
680
  }
579
681
 
580
682
  async function runNvidiaSmiQuery(query: string): Promise<string> {
581
683
  const smiPath = await findNvidiaSmiPath();
684
+ if (nvidiaSmiPathCache && nvidiaSmiPathCache.path === null) return '';
582
685
  return (await run(`"${smiPath}" --query-gpu=${query} --format=csv,noheader,nounits 2>nul`, '')).trim();
583
686
  }
584
687
 
@@ -656,50 +759,60 @@ export function parseWindowsVideoControllers(csvRaw: string): WindowsVideoContro
656
759
  return controllers;
657
760
  }
658
761
 
762
+ function isVirtualGpuName(name: string): boolean {
763
+ return /microsoft basic|remote display|hyper-v|virtual|vmware|virtualbox|parsec|citrix|indirect display|displaylink/i.test(name);
764
+ }
765
+
766
+ function isDiscreteGpuName(name: string): boolean {
767
+ const n = name.toLowerCase();
768
+ if (isVirtualGpuName(n)) return false;
769
+ return (
770
+ /\b(rtx|gtx|geforce|quadro|titan|tesla)\b/.test(n) ||
771
+ /\bnvidia\b/.test(n) ||
772
+ /radeon\s+(rx|pro|vii|r[5-9]|hd)\b/.test(n) ||
773
+ /\bfirepro\b/.test(n) ||
774
+ (/\barc\b/.test(n) && /\b(a\d{3}|b\d{3}|pro)\b/.test(n))
775
+ );
776
+ }
777
+
659
778
  export function prioritizeGpuControllers(controllers: WindowsVideoControllerInfo[]): WindowsVideoControllerInfo[] {
660
- // Sort discrete cards (NVIDIA, AMD Radeon RX, Intel Arc) before integrated adapters (Intel UHD, AMD Radeon(TM) Graphics)
779
+ // Order: discrete (NVIDIA, AMD Radeon RX/Pro, Intel Arc) > integrated (Intel UHD/Iris/Xe, AMD Radeon(TM) Graphics) > virtual/basic adapters
780
+ const rank = (c: WindowsVideoControllerInfo) =>
781
+ isVirtualGpuName(c.name) ? 2 : isDiscreteGpuName(c.name) ? 0 : 1;
661
782
  return [...controllers].sort((a, b) => {
662
- const aName = a.name.toLowerCase();
663
- const bName = b.name.toLowerCase();
664
- const aIsDiscrete = aName.includes('rtx') || aName.includes('gtx') || aName.includes('geforce') ||
665
- aName.includes('radeon rx') || aName.includes('arc');
666
- const bIsDiscrete = bName.includes('rtx') || bName.includes('gtx') || bName.includes('geforce') ||
667
- bName.includes('radeon rx') || bName.includes('arc');
668
- if (aIsDiscrete && !bIsDiscrete) return -1;
669
- if (!aIsDiscrete && bIsDiscrete) return 1;
783
+ const ra = rank(a);
784
+ const rb = rank(b);
785
+ if (ra !== rb) return ra - rb;
670
786
  return b.adapterRam - a.adapterRam;
671
787
  });
672
788
  }
673
789
 
674
- export async function getWindowsGpu64BitVram(): Promise<number> {
790
+ export async function getWindowsGpu64BitVram(adapterName?: string): Promise<number> {
675
791
  // Win32_VideoController clamps AdapterRAM to 4GB (UInt32).
676
- // Read exact 64-bit VRAM from Registry Display Class hardware information:
792
+ // Read exact 64-bit VRAM from the Display Class registry keys. The value is literally named
793
+ // 'HardwareInformation.qwMemorySize' (dot is part of the name), so it must be quoted.
794
+ // Match by DriverDesc so hybrid systems report the VRAM of the selected adapter.
677
795
  try {
796
+ const safeName = (adapterName || '').replace(/'/g, "''");
678
797
  const raw = await runPowerShell(
679
- `Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\0*' -ErrorAction SilentlyContinue | Where-Object { $_.HardwareInformation.qwMemorySize -gt 0 } | Select-Object -First 1 -ExpandProperty HardwareInformation.qwMemorySize`,
680
- ''
681
- );
682
- const bytes = safeParseInt(raw, 0);
683
- if (bytes > 0) return bytes;
684
- } catch {}
685
-
686
- // Fallback to Performance Counters Dedicated Usage + Free
687
- try {
688
- const rawDedicated = await runPowerShell(
689
- `(Get-Counter '\\GPU Adapter Memory(*)\\Dedicated Usage' -ErrorAction SilentlyContinue).CounterSamples | Measure-Object -Property CookedValue -Sum | Select-Object -ExpandProperty Sum`,
798
+ `$name = '${safeName}'; $items = Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\0*' -ErrorAction SilentlyContinue | Where-Object { $_.'HardwareInformation.qwMemorySize' -gt 0 }; $pick = $items | Where-Object { $name -and $_.DriverDesc -eq $name } | Select-Object -First 1; if (-not $pick) { $pick = $items | Sort-Object -Property 'HardwareInformation.qwMemorySize' -Descending | Select-Object -First 1 }; if ($pick) { [string][uint64]$pick.'HardwareInformation.qwMemorySize' }`,
690
799
  ''
691
800
  );
692
- const bytes = safeParseFloat(rawDedicated);
801
+ const bytes = safeParseFloat(raw.trim());
693
802
  if (bytes > 0) return bytes;
694
803
  } catch {}
695
804
 
696
805
  return 0;
697
806
  }
698
807
 
808
+ /**
809
+ * 3D engine utilization via CIM performance classes. Unlike Get-Counter paths these class/property
810
+ * names are not localized, so this works on non-English Windows. Works for NVIDIA, AMD and Intel (WDDM 2.0+).
811
+ */
699
812
  export async function getWindowsGpu3dUtilization(): Promise<{ utilization: number; processCount: number }> {
700
813
  try {
701
814
  const raw = await runPowerShell(
702
- `$samples = (Get-Counter '\\GPU Engine(*engtype_3D*)\\Utilization Percentage' -ErrorAction SilentlyContinue).CounterSamples; if ($samples) { $activePids = $samples | Where-Object { $_.CookedValue -gt 0 } | ForEach-Object { if ($_.InstanceName -match 'pid_(\\d+)') { $matches[1] } } | Select-Object -Unique; $total = ($samples | Measure-Object -Property CookedValue -Sum).Sum; "$total|$($activePids.Count)" } else { '0|0' }`,
815
+ `$samples = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine -ErrorAction SilentlyContinue | Where-Object { $_.Name -like '*engtype_3D*' }; if ($samples) { $activePids = $samples | Where-Object { $_.UtilizationPercentage -gt 0 } | ForEach-Object { if ($_.Name -match 'pid_(\\d+)') { $matches[1] } } | Select-Object -Unique; $total = ($samples | Measure-Object -Property UtilizationPercentage -Sum).Sum; "$total|$(@($activePids).Count)" } else { '0|0' }`,
703
816
  '0|0'
704
817
  );
705
818
  const [utilStr, procCountStr] = raw.split('|');
@@ -729,7 +842,7 @@ export async function collectWindowsGpu(): Promise<GpuData | null> {
729
842
  const bestController = prioritized[0];
730
843
 
731
844
  // Read 64-bit VRAM (avoids 4GB uint32 clamp on RX 7900, Arc A770, etc.)
732
- let vramBytes = await getWindowsGpu64BitVram();
845
+ let vramBytes = await getWindowsGpu64BitVram(bestController.name);
733
846
  if (vramBytes === 0 && bestController.adapterRam > 0) {
734
847
  vramBytes = bestController.adapterRam;
735
848
  }
@@ -1090,7 +1203,20 @@ export function parseWindowsProcessCsv(
1090
1203
  return procs;
1091
1204
  }
1092
1205
 
1206
+ let procListMemo: { ts: number; promise: Promise<ProcessData[]> } | null = null;
1207
+ const PROC_MEMO_MS = 1500;
1208
+
1093
1209
  export async function collectWindowsProcesses(limit?: number): Promise<ProcessData[]> {
1210
+ const now0 = Date.now();
1211
+ if (!procListMemo || now0 - procListMemo.ts > PROC_MEMO_MS) {
1212
+ procListMemo = { ts: now0, promise: queryWindowsProcesses() };
1213
+ }
1214
+ const procs = await procListMemo.promise;
1215
+ if (limit && limit > 0) return procs.slice(0, limit);
1216
+ return procs;
1217
+ }
1218
+
1219
+ async function queryWindowsProcesses(): Promise<ProcessData[]> {
1094
1220
  try {
1095
1221
  // Explicitly query ParentProcessId for process tree view (`pyre --tree`)
1096
1222
  const raw = await runPowerShell(
@@ -1099,10 +1225,7 @@ export async function collectWindowsProcesses(limit?: number): Promise<ProcessDa
1099
1225
  );
1100
1226
  const now = Date.now();
1101
1227
  const totalMem = os.totalmem();
1102
- const procs = parseWindowsProcessCsv(raw, totalMem, winProcCache, now);
1103
-
1104
- if (limit && limit > 0) return procs.slice(0, limit);
1105
- return procs;
1228
+ return parseWindowsProcessCsv(raw, totalMem, winProcCache, now);
1106
1229
  } catch {
1107
1230
  return [];
1108
1231
  }
@@ -29,18 +29,94 @@ async function run(cmd: string, fallback: string = '', timeout = 3000): Promise<
29
29
  }
30
30
  }
31
31
 
32
+ // ─── PowerShell execution (Windows) ──────────────────────────────────────────
33
+ // Every powershell.exe launch costs 0.3–1.5s of cold start. A single collection tick used to
34
+ // spawn ~15 of them at once; under that contention the 4s timeouts fired and results silently
35
+ // became empty (no battery, no GPU, ...). So we: (1) cap concurrent processes, (2) start the
36
+ // timeout when the process actually starts, (3) dedupe identical in-flight scripts and
37
+ // (4) allow stale-while-revalidate caching for data that does not change every tick.
38
+
39
+ const MAX_CONCURRENT_PS = 3;
40
+ let activePs = 0;
41
+ const psWaiters: Array<() => void> = [];
42
+
43
+ async function acquirePsSlot(): Promise<void> {
44
+ if (activePs < MAX_CONCURRENT_PS) {
45
+ activePs++;
46
+ return;
47
+ }
48
+ await new Promise<void>(resolve => psWaiters.push(resolve));
49
+ }
50
+
51
+ function releasePsSlot(): void {
52
+ const next = psWaiters.shift();
53
+ if (next) next(); // hand the slot over directly
54
+ else activePs--;
55
+ }
56
+
57
+ interface PsCacheEntry {
58
+ value: string;
59
+ ts: number;
60
+ inflight?: Promise<string | null>;
61
+ }
62
+ const psCache = new Map<string, PsCacheEntry>();
63
+ const psInflight = new Map<string, Promise<string | null>>();
64
+
65
+ /** Returns stdout on success (possibly ''), or null when the process failed / timed out. */
66
+ async function execPowerShell(script: string, timeout: number): Promise<string | null> {
67
+ const existing = psInflight.get(script);
68
+ if (existing) return existing;
69
+
70
+ const p = (async () => {
71
+ await acquirePsSlot();
72
+ try {
73
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
74
+ const cmd = `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
75
+ return (await execAsync(cmd, { encoding: 'utf8', timeout, windowsHide: true, maxBuffer: 16 * 1024 * 1024 })).stdout.trim();
76
+ } catch {
77
+ return null;
78
+ } finally {
79
+ releasePsSlot();
80
+ }
81
+ })();
82
+ psInflight.set(script, p);
83
+ p.finally(() => psInflight.delete(script)).catch(() => {});
84
+ return p;
85
+ }
86
+
32
87
  /**
33
88
  * Execute a PowerShell snippet safely on Windows, returning trimmed stdout or fallback.
89
+ *
90
+ * @param cacheTtlMs when > 0 the result is cached; stale values are returned immediately while
91
+ * a refresh runs in the background (failures never overwrite good data).
34
92
  */
35
- async function runPowerShell(script: string, fallback: string = '', timeout = 4000): Promise<string> {
93
+ async function runPowerShell(script: string, fallback: string = '', timeout = 10000, cacheTtlMs = 0): Promise<string> {
36
94
  if (!isWindows) return fallback;
37
- try {
38
- const encoded = Buffer.from(script, 'utf16le').toString('base64');
39
- const cmd = `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
40
- return (await execAsync(cmd, { encoding: 'utf8', timeout, windowsHide: true })).stdout.trim();
41
- } catch {
42
- return fallback;
95
+
96
+ if (cacheTtlMs > 0) {
97
+ const now = Date.now();
98
+ const entry = psCache.get(script);
99
+ if (entry) {
100
+ if (now - entry.ts >= cacheTtlMs && !entry.inflight) {
101
+ entry.inflight = execPowerShell(script, timeout).then(v => {
102
+ if (v !== null) {
103
+ entry.value = v;
104
+ entry.ts = Date.now();
105
+ }
106
+ return v;
107
+ }).finally(() => { entry.inflight = undefined; });
108
+ entry.inflight.catch(() => {});
109
+ }
110
+ return entry.value;
111
+ }
112
+ const value = await execPowerShell(script, timeout);
113
+ if (value === null) return fallback;
114
+ psCache.set(script, { value, ts: Date.now() });
115
+ return value;
43
116
  }
117
+
118
+ const value = await execPowerShell(script, timeout);
119
+ return value === null ? fallback : value;
44
120
  }
45
121
 
46
122
  export { run, runPowerShell };
@@ -119,6 +119,30 @@ describe('Intel, AMD & NVIDIA Hardware Telemetry', () => {
119
119
  assert.strictEqual(prioritized[0].name, 'Intel(R) Arc(TM) A770 Graphics');
120
120
  });
121
121
 
122
+ it('should parse Windows battery cycles, capacity and rate output', async () => {
123
+ const { parseWindowsBatteryOutput } = await import('./monitors/platform/windows.js');
124
+ const b = parseWindowsBatteryOutput('87|1|312||214|41200|50000|11500\r\n');
125
+ assert.ok(b);
126
+ assert.strictEqual(b.level, 87);
127
+ assert.strictEqual(b.cycles, 214);
128
+ assert.strictEqual(b.fullChargeMWh, 41200);
129
+ assert.strictEqual(b.designMWh, 50000);
130
+ assert.strictEqual(b.rateMilliwatts, 11500);
131
+ assert.strictEqual(parseWindowsBatteryOutput(''), null);
132
+ });
133
+
134
+
135
+ it('should rank hybrid laptop adapters: discrete > integrated > virtual', async () => {
136
+ const { prioritizeGpuControllers } = await import('./monitors/platform/windows.js');
137
+ const mk = (name: string, adapterRam = 0) => ({ name, adapterRam, driverVersion: '', videoProcessor: '', pnpDeviceId: '' });
138
+ const order = (names: string[]) => prioritizeGpuControllers(names.map(n => mk(n))).map(c => c.name);
139
+
140
+ assert.strictEqual(order(['Microsoft Basic Display Adapter', 'Intel(R) Iris(R) Xe Graphics', 'NVIDIA GeForce RTX 4060 Laptop GPU'])[0], 'NVIDIA GeForce RTX 4060 Laptop GPU');
141
+ assert.strictEqual(order(['Intel(R) UHD Graphics', 'Microsoft Remote Display Adapter'])[0], 'Intel(R) UHD Graphics');
142
+ assert.strictEqual(order(['AMD Radeon(TM) Graphics', 'AMD Radeon Pro W7800'])[0], 'AMD Radeon Pro W7800');
143
+ });
144
+
145
+
122
146
  it('should detect CPU vendors and estimate architecture TDP accurately', async () => {
123
147
  const { detectProcessorVendor, estimateCpuTdp } = await import('./monitors/platform/windows.js');
124
148
  // AMD tests
@@ -1,32 +0,0 @@
1
- // src/monitors/run.ts
2
- import { exec } from "child_process";
3
- import { promisify } from "util";
4
- var execAsync = promisify(exec);
5
- var isWindows = process.platform === "win32";
6
- async function run(cmd, fallback = "", timeout = 3e3) {
7
- try {
8
- let sanitizedCmd = cmd;
9
- if (isWindows) {
10
- sanitizedCmd = sanitizedCmd.replace(/2>\s*\/dev\/null/g, "2>nul");
11
- sanitizedCmd = sanitizedCmd.replace(/\/dev\/null/g, "nul");
12
- }
13
- return (await execAsync(sanitizedCmd, { encoding: "utf8", timeout, windowsHide: true })).stdout.trim();
14
- } catch {
15
- return fallback;
16
- }
17
- }
18
- async function runPowerShell(script, fallback = "", timeout = 4e3) {
19
- if (!isWindows) return fallback;
20
- try {
21
- const encoded = Buffer.from(script, "utf16le").toString("base64");
22
- const cmd = `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
23
- return (await execAsync(cmd, { encoding: "utf8", timeout, windowsHide: true })).stdout.trim();
24
- } catch {
25
- return fallback;
26
- }
27
- }
28
-
29
- export {
30
- run,
31
- runPowerShell
32
- };