pyre-cli 10.5.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 +176 -76
  32. package/src/monitors/run.ts +83 -7
  33. package/src/platform.test.ts +13 -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;
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';
391
470
 
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);
397
-
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;
@@ -1113,7 +1203,20 @@ export function parseWindowsProcessCsv(
1113
1203
  return procs;
1114
1204
  }
1115
1205
 
1206
+ let procListMemo: { ts: number; promise: Promise<ProcessData[]> } | null = null;
1207
+ const PROC_MEMO_MS = 1500;
1208
+
1116
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[]> {
1117
1220
  try {
1118
1221
  // Explicitly query ParentProcessId for process tree view (`pyre --tree`)
1119
1222
  const raw = await runPowerShell(
@@ -1122,10 +1225,7 @@ export async function collectWindowsProcesses(limit?: number): Promise<ProcessDa
1122
1225
  );
1123
1226
  const now = Date.now();
1124
1227
  const totalMem = os.totalmem();
1125
- const procs = parseWindowsProcessCsv(raw, totalMem, winProcCache, now);
1126
-
1127
- if (limit && limit > 0) return procs.slice(0, limit);
1128
- return procs;
1228
+ return parseWindowsProcessCsv(raw, totalMem, winProcCache, now);
1129
1229
  } catch {
1130
1230
  return [];
1131
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,19 @@ 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
+
122
135
  it('should rank hybrid laptop adapters: discrete > integrated > virtual', async () => {
123
136
  const { prioritizeGpuControllers } = await import('./monitors/platform/windows.js');
124
137
  const mk = (name: string, adapterRam = 0) => ({ name, adapterRam, driverVersion: '', videoProcessor: '', pnpDeviceId: '' });
@@ -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
- };