pyre-cli 7.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  * all layout-related code is co-located.
11
11
  */
12
12
  import chalk from 'chalk';
13
- import { sparkline, multiRowSparkline, brailleGraph } from '../sparkline.js';
13
+ import { sparkline, multiRowSparkline, brailleGraph, multiRowBrailleGraph } from '../sparkline.js';
14
14
  import type { History } from '../history.js';
15
15
  import type { StatsData, VisibleItems, TableOptions, AnomalyAlert } from './types.js';
16
16
  import { THEMES, type ThemeName, type ThemeColors } from './themes.js';
@@ -730,7 +730,7 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
730
730
 
731
731
  const cpuHistory = opts.history?.cpuUsage || [];
732
732
  if (cpuHistory.length > 1) {
733
- const graphRows = multiRowSparkline(cpuHistory, 3, { min: 0, max: 100 });
733
+ const graphRows = multiRowBrailleGraph(cpuHistory, 3, { min: 0, max: 100 });
734
734
  for (const gr of graphRows) {
735
735
  leftLines.push(theme.cpu(fitVisible(gr, leftW)));
736
736
  }
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import readline from 'node:readline';
13
13
  import { Command } from 'commander';
14
14
  import chalk from 'chalk';
15
15
  import { collectAll } from './monitors/index.js';
16
- import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdown, formatBytes } from './formatters/index.js';
16
+ import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, getDashboardHtml, formatMarkdown, formatBytes } from './formatters/index.js';
17
17
  import { startLive, stopLive } from './live/index.js';
18
18
  import { showSplash } from './splash.js';
19
19
  import { P2PServer, P2PClient } from './p2p/index.js';
@@ -67,6 +67,9 @@ program
67
67
  .option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
68
68
  .option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
69
69
  .option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
70
+ .option('--since <range>', 'Time range for anomalies digest (e.g. 1d, 7d, yesterday)')
71
+ .option('--plain', 'Output in plain accessible text without ANSI colors or line-drawing characters')
72
+ .option('--a11y', 'Alias for --plain')
70
73
  .option('--webhook-url <url>', 'URL to POST to on alert')
71
74
  .option('--alert-cmd <cmd>', 'Command to execute on alert')
72
75
  .option('--port <port>', 'Port number for web server mode', '3000');
@@ -75,6 +78,10 @@ program.parse(process.argv);
75
78
 
76
79
  const opts = program.opts();
77
80
 
81
+ if (opts.plain || opts.a11y) {
82
+ chalk.level = 0;
83
+ }
84
+
78
85
  function isExportMode() {
79
86
  return opts.json || opts.csv || opts.tsv || opts.html || opts.md;
80
87
  }
@@ -145,6 +152,61 @@ async function runServerCommand(): Promise<void> {
145
152
  async function main() {
146
153
  const cmd = program.args[0];
147
154
 
155
+ if (cmd === 'anomalies') {
156
+ const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
157
+ runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
158
+ return;
159
+ }
160
+
161
+ if (cmd === 'extensions') {
162
+ const { printExtensionsReport } = await import('./extensions.js');
163
+ await printExtensionsReport();
164
+ return;
165
+ }
166
+
167
+ if (cmd === 'brew') {
168
+ const { printBrewHealthReport } = await import('./brewHealth.js');
169
+ await printBrewHealthReport();
170
+ return;
171
+ }
172
+
173
+ if (cmd === 'update') {
174
+ const { printUpdateReport } = await import('./updateCheck.js');
175
+ await printUpdateReport(pkg.version);
176
+ return;
177
+ }
178
+
179
+ if (cmd === 'profile') {
180
+ const sub = program.args[1];
181
+ const name = program.args[2];
182
+ const { saveProfile, loadProfile, listProfiles } = await import('./state/config.js');
183
+ if (sub === 'save' && name) {
184
+ saveProfile(name);
185
+ console.log(chalk.green(` ✔ Profile '${name}' saved successfully.`));
186
+ } else if (sub === 'load' && name) {
187
+ try {
188
+ loadProfile(name);
189
+ console.log(chalk.green(` ✔ Profile '${name}' loaded successfully.`));
190
+ } catch (err: any) {
191
+ console.log(chalk.red(` ✖ ${err.message}`));
192
+ }
193
+ } else if (sub === 'list') {
194
+ const profiles = listProfiles();
195
+ console.log(chalk.bold('\n pyre Profiles:\n'));
196
+ if (profiles.length === 0) {
197
+ console.log(chalk.dim(' No saved profiles found. Save one with `pyre profile save <name>`.'));
198
+ } else {
199
+ for (const p of profiles) {
200
+ console.log(` • ${p}`);
201
+ }
202
+ }
203
+ console.log();
204
+ } else {
205
+ console.log('Usage: pyre profile <save|load|list> [name]');
206
+ }
207
+ return;
208
+ }
209
+
148
210
  if (cmd === 'history') {
149
211
  const daysIdx = program.args.indexOf('--days');
150
212
  const days = daysIdx !== -1 ? parseInt(program.args[daysIdx + 1], 10) || 7 : 7;
@@ -178,6 +240,11 @@ async function main() {
178
240
  return;
179
241
  }
180
242
 
243
+ if (cmd === 'ui') {
244
+ await runUiCommand();
245
+ return;
246
+ }
247
+
181
248
  if (cmd === 'bench') {
182
249
  const benchCmd = program.args.slice(1).join(' ');
183
250
  if (!benchCmd) {
@@ -212,6 +279,25 @@ async function main() {
212
279
  return;
213
280
  }
214
281
 
282
+ if (cmd === 'doctor') {
283
+ const { runDoctor, printDoctorReport } = await import('./doctor.js');
284
+ const checks = await runDoctor();
285
+ printDoctorReport(checks);
286
+ return;
287
+ }
288
+
289
+ if (cmd === 'info') {
290
+ await runInfoCommand();
291
+ return;
292
+ }
293
+
294
+ if (cmd === 'completions') {
295
+ const { printCompletions } = await import('./completions.js');
296
+ const shell = program.args[1] || 'zsh';
297
+ printCompletions(shell);
298
+ return;
299
+ }
300
+
215
301
  if (cmd === 'fleet') {
216
302
  const hosts = program.args.slice(1);
217
303
  if (hosts.length === 0) {
@@ -492,8 +578,12 @@ async function runP2PConnect(): Promise<void> {
492
578
 
493
579
  async function runInfoCommand(): Promise<void> {
494
580
  const data = await collectAll({ detailed: true });
581
+ const { getDisplayInfo, getTimeMachineStatus } = await import('./monitors/collectors.js');
582
+ const displays = await getDisplayInfo();
583
+ const tmStatus = await getTimeMachineStatus();
584
+
495
585
  const lines: string[] = [];
496
- lines.push(chalk.bold(' pyre info'));
586
+ lines.push(chalk.bold(' pyre info — Mac System Information'));
497
587
  lines.push('');
498
588
  lines.push(` Hostname: ${data.header.hostname}`);
499
589
  lines.push(` OS: ${data.header.os}`);
@@ -501,13 +591,34 @@ async function runInfoCommand(): Promise<void> {
501
591
  lines.push(` CPU: ${data.cpu.brand}`);
502
592
  lines.push(` Cores: ${data.cpu.physicalCores}/${data.cpu.cores} (phys/log)`);
503
593
  lines.push(` Frequency: ${data.cpu.frequency} MHz`);
504
- lines.push(` Memory: ${formatBytes(data.memory.total)}`);
594
+ lines.push(` Memory: ${formatBytes(data.memory.total)}${data.memory.pressureLevel ? ` (Pressure: ${data.memory.pressureLevel})` : ''}`);
595
+ if (data.memory.wiredBytes !== undefined && data.memory.compressedBytes !== undefined) {
596
+ lines.push(` Wired: ${formatBytes(data.memory.wiredBytes)} | Compressed: ${formatBytes(data.memory.compressedBytes)}`);
597
+ }
505
598
  if (data.gpu) lines.push(` GPU: ${data.gpu.model} (${formatBytes(data.gpu.memory)})`);
506
599
  if (data.battery) {
507
600
  lines.push(` Battery: ${data.battery.level}% (${data.battery.state})`);
508
601
  lines.push(` Health: ${data.battery.health}`);
509
602
  }
510
603
  lines.push(` Thermal: ${data.thermal.state}`);
604
+
605
+ if (displays.length > 0) {
606
+ lines.push('');
607
+ lines.push(chalk.cyan.bold(' 🖥 Displays:'));
608
+ for (const d of displays) {
609
+ lines.push(` • ${d.name}: ${d.resolution}${d.isMain ? ' (Main)' : ''}${d.connectionType ? ` [${d.connectionType}]` : ''}`);
610
+ }
611
+ }
612
+
613
+ lines.push('');
614
+ lines.push(chalk.cyan.bold(' ⏱ Time Machine Backup:'));
615
+ if (!tmStatus.configured) {
616
+ lines.push(' • Not configured');
617
+ } else {
618
+ lines.push(` • Status: ${tmStatus.running ? chalk.yellow(`Backup in progress (${tmStatus.percent || 0}%)`) : chalk.green('Idle')}`);
619
+ lines.push(` • Latest Backup: ${tmStatus.latestBackup}`);
620
+ }
621
+
511
622
  console.log(lines.join('\n'));
512
623
  }
513
624
 
@@ -541,9 +652,9 @@ async function runSshCommand(host: string): Promise<void> {
541
652
  });
542
653
  }
543
654
 
544
- async function runWebCommand(): Promise<void> {
655
+ async function runWebCommand(): Promise<number> {
545
656
  const http = await import('node:http');
546
- const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
657
+ const port = parseInt(opts.port || process.env.PORT || '3000', 10) || (opts.port === '0' ? 0 : 3000);
547
658
 
548
659
  const server = http.createServer(async (req, res) => {
549
660
  const url = req.url || '/';
@@ -585,17 +696,14 @@ async function runWebCommand(): Promise<void> {
585
696
  // 4. Static/Dynamic HTML live dashboard & API snapshot endpoints
586
697
  if (url === '/' || url === '/index.html') {
587
698
  res.writeHead(200, { 'Content-Type': 'text/html' });
588
- const data = await collectAll({ detailed: true });
589
- const htmlContent = formatHtml(data);
590
- res.end(htmlContent);
699
+ res.end(getDashboardHtml());
591
700
  } else if (url === '/setup') {
592
701
  res.writeHead(200, { 'Content-Type': 'text/html' });
593
702
  const indexPath = path.join(process.cwd(), 'index.html');
594
703
  if (fs.existsSync(indexPath)) {
595
704
  res.end(fs.readFileSync(indexPath, 'utf-8'));
596
705
  } else {
597
- const data = await collectAll({ detailed: true });
598
- res.end(formatHtml(data));
706
+ res.end(getDashboardHtml());
599
707
  }
600
708
  } else if (url === '/api' || url === '/api/stats' || url === '/data') {
601
709
  const data = await collectAll({ detailed: true });
@@ -625,20 +733,145 @@ async function runWebCommand(): Promise<void> {
625
733
 
626
734
  const ip = detectLocalIP() || 'localhost';
627
735
 
628
- server.listen(port, '0.0.0.0', () => {
629
- console.log(chalk.bold(`\n pyre web server running on port ${port}`));
630
- console.log(chalk.dim(` Local URL: http://localhost:${port}/`));
631
- console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${port}/ (Accessible on same Wi-Fi / LAN)`));
632
- console.log(chalk.dim(` API: http://${ip}:${port}/api`));
633
- console.log(chalk.dim(` SSE Stream: http://${ip}:${port}/api/stream`));
634
- console.log(chalk.yellow(`\n Troubleshooting connection from phone/other devices:`));
635
- console.log(chalk.dim(` 1. macOS Firewall: System Settings -> Network -> Firewall -> Allow incoming node/pyre connections`));
636
- console.log(chalk.dim(` 2. Wi-Fi Router: Ensure "AP Isolation" / "Guest Mode" is OFF on your Wi-Fi router`));
637
- console.log(chalk.dim(` 3. Verify phone is connected to the exact same Wi-Fi network\n`));
736
+ return new Promise((resolve) => {
737
+ server.listen(port, '0.0.0.0', () => {
738
+ const address = server.address();
739
+ const actualPort = typeof address === 'object' && address ? address.port : port;
740
+
741
+ console.log(chalk.bold(`\n pyre web server running on port ${actualPort}`));
742
+ console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
743
+ console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
744
+ console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
745
+ console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
746
+
747
+ resolve(actualPort);
748
+ });
749
+
750
+ process.once('SIGINT', () => {
751
+ server.close();
752
+ process.exit(0);
753
+ });
754
+ });
755
+ }
756
+
757
+ async function runUiCommand(): Promise<void> {
758
+ // Use a random ephemeral port to avoid collisions
759
+ opts.port = '0';
760
+ const port = await runWebCommand();
761
+
762
+ const swiftScript = `
763
+ import Cocoa
764
+ import WebKit
765
+
766
+ // WKWebView subclass that allows the window to be dragged from any pixel
767
+ class DraggableWebView: WKWebView {
768
+ override var mouseDownCanMoveWindow: Bool { return true }
769
+ }
770
+
771
+ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
772
+ var window: NSWindow!
773
+ var webView: DraggableWebView!
774
+ var dataTimer: Timer?
775
+ let serverPort = ${port}
776
+
777
+ func applicationDidFinishLaunching(_ n: Notification) {
778
+ // Window
779
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
780
+ let W = min(1180.0, screen.width - 80)
781
+ let H = min(820.0, screen.height - 80)
782
+ let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
783
+ y: screen.minY + (screen.height - H) / 2,
784
+ width: W, height: H)
785
+
786
+ window = NSWindow(contentRect: rect,
787
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
788
+ backing: .buffered, defer: false)
789
+ window.title = "Pyre"
790
+ window.minSize = NSSize(width: 800, height: 600)
791
+ window.appearance = NSAppearance(named: .darkAqua)
792
+ window.backgroundColor = NSColor(white: 0, alpha: 1)
793
+ window.isReleasedWhenClosed = false
794
+ window.delegate = self
795
+
796
+ // WebView
797
+ let cfg = WKWebViewConfiguration()
798
+ webView = DraggableWebView(frame: window.contentView!.bounds, configuration: cfg)
799
+ webView.autoresizingMask = [.width, .height]
800
+ webView.setValue(NSColor(white: 0, alpha: 1),
801
+ forKey: "backgroundColor")
802
+
803
+ // Load the dashboard HTML from the local server
804
+ var req = URLRequest(url: URL(string: "http://localhost:\\(serverPort)/")!)
805
+ req.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
806
+ webView.load(req)
807
+
808
+ window.contentView?.addSubview(webView)
809
+ window.makeKeyAndOrderFront(nil)
810
+ NSApp.activate(ignoringOtherApps: true)
811
+
812
+ // Start native data timer (URLSession has full network access — no sandbox issues)
813
+ // We wait 1.5 s for the page to load, then start pushing data every 2 s.
814
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
815
+ self?.fetchAndPush()
816
+ self?.dataTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
817
+ self?.fetchAndPush()
818
+ }
819
+ }
820
+ }
821
+
822
+ // URLSession fetch (runs in the main app process — not sandboxed like WebContent)
823
+ func fetchAndPush() {
824
+ guard let url = URL(string: "http://localhost:\\(serverPort)/api/stats") else { return }
825
+ URLSession.shared.dataTask(with: url) { [weak self] data, _, err in
826
+ if let err = err {
827
+ print("Swift UI Fetch Error: \\(err)")
828
+ return
829
+ }
830
+ guard let data = data else {
831
+ print("Swift UI Fetch Error: No data")
832
+ return
833
+ }
834
+ let b64 = data.base64EncodedString()
835
+ let js = "if(window.__pyreUpdate){window.__pyreUpdate(decodeURIComponent(escape(atob('\\(b64)'))))}"
836
+ DispatchQueue.main.async {
837
+ self?.webView.evaluateJavaScript(js) { res, err in
838
+ if let err = err {
839
+ print("Swift UI JS Eval Error: \\(err)")
840
+ }
841
+ }
842
+ }
843
+ }.resume()
844
+ }
845
+
846
+ func windowWillClose(_ n: Notification) {
847
+ dataTimer?.invalidate()
848
+ NSApplication.shared.terminate(self)
849
+ }
850
+ func applicationShouldTerminateAfterLastWindowClosed(_ s: NSApplication) -> Bool { true }
851
+ }
852
+
853
+ let app = NSApplication.shared
854
+ app.setActivationPolicy(.regular)
855
+ let delegate = AppDelegate()
856
+ app.delegate = delegate
857
+ app.run()
858
+ `;
859
+
860
+
861
+ const scriptPath = path.join(os.tmpdir(), 'pyre-ui.swift');
862
+ fs.writeFileSync(scriptPath, swiftScript);
863
+
864
+ const { spawn } = await import('node:child_process');
865
+ console.log(chalk.cyan(`\n Launching native UI window...`));
866
+ const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
867
+
868
+ ui.on('close', () => {
869
+ console.log(chalk.dim('UI window closed, shutting down server...'));
870
+ process.exit(0);
638
871
  });
639
872
 
640
873
  process.once('SIGINT', () => {
641
- server.close();
874
+ ui.kill();
642
875
  process.exit(0);
643
876
  });
644
877
  }
@@ -650,12 +883,16 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
650
883
 
651
884
  if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
652
885
  const stream = fs.createWriteStream(file, { flags: 'a' });
653
- stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
886
+ stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state,power_watts\n');
654
887
 
655
888
  console.log(chalk.bold(`\n pyre bench`));
656
889
  console.log(chalk.dim(` Command: ${benchCmd}`));
657
890
  console.log(chalk.dim(` Log: ${file}\n`));
658
891
 
892
+ const startTime = Date.now();
893
+ let accumulatedWattSeconds = 0;
894
+ let powerSamplesCount = 0;
895
+
659
896
  const child = spawn('sh', ['-c', benchCmd]);
660
897
  child.stdout.on('data', d => process.stdout.write(d));
661
898
  child.stderr.on('data', d => process.stdout.write(d));
@@ -668,23 +905,40 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
668
905
  const rxPackets = data.network.rxPackets ?? 0;
669
906
  const txPackets = data.network.txPackets ?? 0;
670
907
  const connections = data.network.connections ?? 0;
671
- stream.write(`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${rxPackets},${txPackets},${connections},${data.thermal.state}\n`);
908
+ const watts = data.power?.combinedWatts ?? data.power?.cpuWatts;
909
+ if (watts !== undefined && watts > 0) {
910
+ accumulatedWattSeconds += watts * interval;
911
+ powerSamplesCount++;
912
+ }
913
+ stream.write(`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${rxPackets},${txPackets},${connections},${data.thermal.state},${watts ?? ''}\n`);
672
914
  } catch {
673
915
  // skip bad tick
674
916
  }
675
917
  }, interval * 1000);
676
918
 
677
- child.on('close', () => {
919
+ const printSummary = () => {
678
920
  clearInterval(handle);
679
921
  stream.end();
680
- console.log(chalk.green(`\n Bench complete. Log saved to ${file}`));
922
+ const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
923
+ console.log(chalk.green(`\n ✔ Bench complete (${durationSec.toFixed(2)}s). Log saved to ${file}`));
924
+
925
+ if (powerSamplesCount > 0 && accumulatedWattSeconds > 0) {
926
+ const kWh = accumulatedWattSeconds / (3600 * 1000);
927
+ const rate = config.electricityRateKwH ?? 0.15;
928
+ const cost = kWh * rate;
929
+ console.log(chalk.cyan(` ⚡ Energy Consumption: ${kWh.toFixed(6)} kWh | Est. Cost: $${cost.toFixed(6)} (@ $${rate}/kWh)`));
930
+ }
931
+ };
932
+
933
+ child.on('close', () => {
934
+ printSummary();
681
935
  process.exit(0);
682
936
  });
683
937
 
684
938
  process.once('SIGINT', () => {
685
939
  clearInterval(handle);
686
940
  child.kill('SIGTERM');
687
- stream.end();
941
+ printSummary();
688
942
  process.exit(0);
689
943
  });
690
944
  }
package/src/live/input.ts CHANGED
@@ -207,12 +207,24 @@ function handleInputModeKey(str: string, key: readline.Key) {
207
207
  }
208
208
  }
209
209
 
210
+ let confirmKillPid: number | null = null;
211
+
210
212
  function killProcess(pidStr: string, signal: string = 'SIGTERM') {
211
213
  const pid = parseInt(pidStr, 10);
212
214
  if (!pid || pid <= 0) {
213
215
  setStatus(`Invalid PID: ${pidStr}`);
214
216
  return;
215
217
  }
218
+
219
+ const isProtected = pid === 1 || pid === process.pid || pid === process.ppid;
220
+ if (isProtected && confirmKillPid !== pid) {
221
+ confirmKillPid = pid;
222
+ const name = pid === 1 ? 'launchd (system init)' : (pid === process.pid ? 'pyre itself' : 'parent process');
223
+ setStatus(`⚠️ PROTECTED PID ${pid} (${name})! Re-enter PID ${pid} to confirm termination.`, 8000);
224
+ return;
225
+ }
226
+
227
+ confirmKillPid = null;
216
228
  try {
217
229
  process.kill(pid, signal as NodeJS.Signals);
218
230
  setStatus(`Sent ${signal} to PID ${pid}`);
@@ -466,6 +466,14 @@ export async function collectMemory(): Promise<MemoryData> {
466
466
  const wiredPages = getPageCount('wired down');
467
467
  const compressedPages = getPageCount('occupied by compressor');
468
468
 
469
+ const purgeablePages = getPageCount('purgeable');
470
+ const getStatCount = (label: string) => {
471
+ const m = vmStat.match(new RegExp(`${label}:\\s+(\\d+)\\.`));
472
+ return m ? parseInt(m[1]) : 0;
473
+ };
474
+ const swapIns = getStatCount('Swapins') || getStatCount('Pageins');
475
+ const swapOuts = getStatCount('Swapouts') || getStatCount('Pageouts');
476
+
469
477
  const free = freePages * pageSize;
470
478
  const used = (activePages + inactivePages + wiredPages + compressedPages) * pageSize;
471
479
  const usagePercent = totalBytes > 0 ? Math.round((used / totalBytes) * 100) : 0;
@@ -475,11 +483,6 @@ export async function collectMemory(): Promise<MemoryData> {
475
483
  let swapFree = 0;
476
484
  try {
477
485
  const swapRaw = (await run('sysctl -n vm.swapusage')).trim();
478
- // Real output is "total = 3072.00M used = 1497.75M free = 1574.25M
479
- // (encrypted)" — the parentheses wrap the trailing "(encrypted)" note,
480
- // not the used value. The old regex required a literal "(" right before
481
- // "used", which never appears, so this never matched and swap stayed at
482
- // 0 forever instead of updating.
483
486
  const swapMatch = swapRaw.match(
484
487
  /total\s*=\s*([\d.]+)([KMGkmg])\s+used\s*=\s*([\d.]+)([KMGkmg])\s+free\s*=\s*([\d.]+)([KMGkmg])/
485
488
  );
@@ -492,6 +495,18 @@ export async function collectMemory(): Promise<MemoryData> {
492
495
  // ignore
493
496
  }
494
497
 
498
+ let pressureLevel = 'Normal';
499
+ try {
500
+ const pressVal = (await run('sysctl -n kern.memorystatus_vm_pressure_level')).trim();
501
+ const pressNum = parseInt(pressVal, 10);
502
+ if (pressNum === 2) pressureLevel = 'Warning';
503
+ else if (pressNum >= 4) pressureLevel = 'Critical';
504
+ else if (!isNaN(pressNum)) pressureLevel = 'Normal';
505
+ else pressureLevel = pressVal || 'Normal';
506
+ } catch {
507
+ pressureLevel = 'Normal';
508
+ }
509
+
495
510
  return {
496
511
  total: totalBytes,
497
512
  used,
@@ -501,6 +516,12 @@ export async function collectMemory(): Promise<MemoryData> {
501
516
  swapFree,
502
517
  pageSize,
503
518
  usagePercent,
519
+ wiredBytes: wiredPages * pageSize,
520
+ compressedBytes: compressedPages * pageSize,
521
+ purgeableBytes: purgeablePages * pageSize,
522
+ swapIns,
523
+ swapOuts,
524
+ pressureLevel,
504
525
  };
505
526
  }
506
527
 
@@ -855,7 +876,91 @@ export async function collectTasks(limit = 12): Promise<TaskData[]> {
855
876
  return { pid: parseInt(parts[1]), user: parts[2], cpu: parseFloat(parts[3]), mem: parseFloat(parts[4]), state: parts[5], runtime: runtimeSec, command: parts[7] };
856
877
  })
857
878
  .filter((t): t is TaskData => t !== null && t.pid > 0);
879
+ } catch {
880
+ return [];
881
+ }
882
+ }
883
+
884
+ export interface DisplayInfo {
885
+ name: string;
886
+ resolution: string;
887
+ connectionType?: string;
888
+ isMain?: boolean;
889
+ }
890
+
891
+ export async function getDisplayInfo(): Promise<DisplayInfo[]> {
892
+ try {
893
+ const raw = await run('system_profiler SPDisplaysDataType 2>&1', '');
894
+ const displays: DisplayInfo[] = [];
895
+ const lines = raw.split('\n');
896
+ let currentName = '';
897
+ let currentRes = '';
898
+ let isMain = false;
899
+ let connType = '';
900
+
901
+ for (const line of lines) {
902
+ const trimmed = line.trim();
903
+ if (trimmed.endsWith(':') && !trimmed.startsWith('Chipset') && !trimmed.startsWith('Displays') && !trimmed.startsWith('Graphics')) {
904
+ if (currentName && currentRes) {
905
+ displays.push({ name: currentName, resolution: currentRes, isMain, connectionType: connType || undefined });
906
+ currentRes = '';
907
+ isMain = false;
908
+ connType = '';
909
+ }
910
+ currentName = trimmed.slice(0, -1);
911
+ } else if (trimmed.startsWith('Resolution:')) {
912
+ currentRes = trimmed.replace('Resolution:', '').trim();
913
+ } else if (trimmed.startsWith('Main Display: Yes')) {
914
+ isMain = true;
915
+ } else if (trimmed.startsWith('Connection Type:')) {
916
+ connType = trimmed.replace('Connection Type:', '').trim();
917
+ }
918
+ }
919
+ if (currentName && currentRes) {
920
+ displays.push({ name: currentName, resolution: currentRes, isMain, connectionType: connType || undefined });
921
+ }
922
+ return displays;
858
923
  } catch {
859
924
  return [];
860
925
  }
926
+ }
927
+
928
+ export interface TimeMachineStatus {
929
+ configured: boolean;
930
+ running: boolean;
931
+ percent?: number;
932
+ latestBackup?: string;
933
+ }
934
+
935
+ export async function getTimeMachineStatus(): Promise<TimeMachineStatus> {
936
+ try {
937
+ const statusRaw = await run('tmutil status 2>&1', '');
938
+ if (statusRaw.includes('No Time Machine destination') || statusRaw.includes('not configured')) {
939
+ return { configured: false, running: false };
940
+ }
941
+
942
+ const running = statusRaw.includes('Running = 1;');
943
+ let percent: number | undefined = undefined;
944
+ const pctMatch = statusRaw.match(/Percent\s*=\s*"?(0\.\d+|\d+)"?/i);
945
+ if (pctMatch) {
946
+ percent = Math.round(parseFloat(pctMatch[1]) * 100);
947
+ }
948
+
949
+ let latestBackup = 'Unknown';
950
+ try {
951
+ const latestRaw = await run('tmutil latestbackup 2>&1', '');
952
+ if (latestRaw.trim() && !latestRaw.includes('error') && !latestRaw.includes('No backups')) {
953
+ const basename = latestRaw.trim().split('/').pop() || latestRaw.trim();
954
+ latestBackup = basename.replace('.previous', '').replace('.backup', '');
955
+ } else {
956
+ latestBackup = 'No backup found / Unmounted';
957
+ }
958
+ } catch {
959
+ latestBackup = 'Not mounted';
960
+ }
961
+
962
+ return { configured: true, running, percent, latestBackup };
963
+ } catch {
964
+ return { configured: false, running: false };
965
+ }
861
966
  }
@@ -80,6 +80,12 @@ export interface MemoryData {
80
80
  swapFree: number;
81
81
  pageSize: number;
82
82
  usagePercent: number;
83
+ wiredBytes?: number;
84
+ compressedBytes?: number;
85
+ purgeableBytes?: number;
86
+ swapIns?: number;
87
+ swapOuts?: number;
88
+ pressureLevel?: string;
83
89
  }
84
90
 
85
91
  export interface ThermalData {