pyre-cli 7.1.0 → 8.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.
@@ -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,14 +67,45 @@ 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');
73
76
 
77
+ program.addHelpText('after', \`
78
+ Commands:
79
+ live Interactive live dashboard
80
+ ui Launch the native macOS UI dashboard
81
+ web Serve an auto-refreshing live web portal
82
+ ssh <host> Stream live stats from a remote Mac over SSH
83
+ fleet <host1> [host2]... Multi-host live dashboard monitoring multiple Macs
84
+ bench <cmd> Log CPU, memory, network, power draw, and estimate kWh cost
85
+ anomalies Compute z-score anomalies and print plain-language digest
86
+ doctor Run system diagnostics
87
+ extensions System Extensions inspector
88
+ brew Homebrew health panel
89
+ update Check for pyre-cli updates
90
+ profile <save|load|list> Atomic configuration profile management
91
+ config <show|reset> View or reset persistent configuration file
92
+ history Graph historical resource trends from CSV logs
93
+ diff <file1> <file2> Compare two saved snapshot files side-by-side
94
+ info Concise hardware summary
95
+ completions <shell> Generate shell auto-completion scripts
96
+ xbar Generate an xbar / SwiftBar menu bar plugin script
97
+ p2p <server|connect> Start a P2P server or connect to one
98
+ server Print commands for starting P2P server/client
99
+ \`);
100
+
74
101
  program.parse(process.argv);
75
102
 
76
103
  const opts = program.opts();
77
104
 
105
+ if (opts.plain || opts.a11y) {
106
+ chalk.level = 0;
107
+ }
108
+
78
109
  function isExportMode() {
79
110
  return opts.json || opts.csv || opts.tsv || opts.html || opts.md;
80
111
  }
@@ -145,6 +176,61 @@ async function runServerCommand(): Promise<void> {
145
176
  async function main() {
146
177
  const cmd = program.args[0];
147
178
 
179
+ if (cmd === 'anomalies') {
180
+ const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
181
+ runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
182
+ return;
183
+ }
184
+
185
+ if (cmd === 'extensions') {
186
+ const { printExtensionsReport } = await import('./extensions.js');
187
+ await printExtensionsReport();
188
+ return;
189
+ }
190
+
191
+ if (cmd === 'brew') {
192
+ const { printBrewHealthReport } = await import('./brewHealth.js');
193
+ await printBrewHealthReport();
194
+ return;
195
+ }
196
+
197
+ if (cmd === 'update') {
198
+ const { printUpdateReport } = await import('./updateCheck.js');
199
+ await printUpdateReport(pkg.version);
200
+ return;
201
+ }
202
+
203
+ if (cmd === 'profile') {
204
+ const sub = program.args[1];
205
+ const name = program.args[2];
206
+ const { saveProfile, loadProfile, listProfiles } = await import('./state/config.js');
207
+ if (sub === 'save' && name) {
208
+ saveProfile(name);
209
+ console.log(chalk.green(` ✔ Profile '${name}' saved successfully.`));
210
+ } else if (sub === 'load' && name) {
211
+ try {
212
+ loadProfile(name);
213
+ console.log(chalk.green(` ✔ Profile '${name}' loaded successfully.`));
214
+ } catch (err: any) {
215
+ console.log(chalk.red(` ✖ ${err.message}`));
216
+ }
217
+ } else if (sub === 'list') {
218
+ const profiles = listProfiles();
219
+ console.log(chalk.bold('\n pyre Profiles:\n'));
220
+ if (profiles.length === 0) {
221
+ console.log(chalk.dim(' No saved profiles found. Save one with `pyre profile save <name>`.'));
222
+ } else {
223
+ for (const p of profiles) {
224
+ console.log(` • ${p}`);
225
+ }
226
+ }
227
+ console.log();
228
+ } else {
229
+ console.log('Usage: pyre profile <save|load|list> [name]');
230
+ }
231
+ return;
232
+ }
233
+
148
234
  if (cmd === 'history') {
149
235
  const daysIdx = program.args.indexOf('--days');
150
236
  const days = daysIdx !== -1 ? parseInt(program.args[daysIdx + 1], 10) || 7 : 7;
@@ -178,6 +264,11 @@ async function main() {
178
264
  return;
179
265
  }
180
266
 
267
+ if (cmd === 'ui') {
268
+ await runUiCommand();
269
+ return;
270
+ }
271
+
181
272
  if (cmd === 'bench') {
182
273
  const benchCmd = program.args.slice(1).join(' ');
183
274
  if (!benchCmd) {
@@ -212,6 +303,25 @@ async function main() {
212
303
  return;
213
304
  }
214
305
 
306
+ if (cmd === 'doctor') {
307
+ const { runDoctor, printDoctorReport } = await import('./doctor.js');
308
+ const checks = await runDoctor();
309
+ printDoctorReport(checks);
310
+ return;
311
+ }
312
+
313
+ if (cmd === 'info') {
314
+ await runInfoCommand();
315
+ return;
316
+ }
317
+
318
+ if (cmd === 'completions') {
319
+ const { printCompletions } = await import('./completions.js');
320
+ const shell = program.args[1] || 'zsh';
321
+ printCompletions(shell);
322
+ return;
323
+ }
324
+
215
325
  if (cmd === 'fleet') {
216
326
  const hosts = program.args.slice(1);
217
327
  if (hosts.length === 0) {
@@ -492,8 +602,12 @@ async function runP2PConnect(): Promise<void> {
492
602
 
493
603
  async function runInfoCommand(): Promise<void> {
494
604
  const data = await collectAll({ detailed: true });
605
+ const { getDisplayInfo, getTimeMachineStatus } = await import('./monitors/collectors.js');
606
+ const displays = await getDisplayInfo();
607
+ const tmStatus = await getTimeMachineStatus();
608
+
495
609
  const lines: string[] = [];
496
- lines.push(chalk.bold(' pyre info'));
610
+ lines.push(chalk.bold(' pyre info — Mac System Information'));
497
611
  lines.push('');
498
612
  lines.push(` Hostname: ${data.header.hostname}`);
499
613
  lines.push(` OS: ${data.header.os}`);
@@ -501,13 +615,34 @@ async function runInfoCommand(): Promise<void> {
501
615
  lines.push(` CPU: ${data.cpu.brand}`);
502
616
  lines.push(` Cores: ${data.cpu.physicalCores}/${data.cpu.cores} (phys/log)`);
503
617
  lines.push(` Frequency: ${data.cpu.frequency} MHz`);
504
- lines.push(` Memory: ${formatBytes(data.memory.total)}`);
618
+ lines.push(` Memory: ${formatBytes(data.memory.total)}${data.memory.pressureLevel ? ` (Pressure: ${data.memory.pressureLevel})` : ''}`);
619
+ if (data.memory.wiredBytes !== undefined && data.memory.compressedBytes !== undefined) {
620
+ lines.push(` Wired: ${formatBytes(data.memory.wiredBytes)} | Compressed: ${formatBytes(data.memory.compressedBytes)}`);
621
+ }
505
622
  if (data.gpu) lines.push(` GPU: ${data.gpu.model} (${formatBytes(data.gpu.memory)})`);
506
623
  if (data.battery) {
507
624
  lines.push(` Battery: ${data.battery.level}% (${data.battery.state})`);
508
625
  lines.push(` Health: ${data.battery.health}`);
509
626
  }
510
627
  lines.push(` Thermal: ${data.thermal.state}`);
628
+
629
+ if (displays.length > 0) {
630
+ lines.push('');
631
+ lines.push(chalk.cyan.bold(' 🖥 Displays:'));
632
+ for (const d of displays) {
633
+ lines.push(` • ${d.name}: ${d.resolution}${d.isMain ? ' (Main)' : ''}${d.connectionType ? ` [${d.connectionType}]` : ''}`);
634
+ }
635
+ }
636
+
637
+ lines.push('');
638
+ lines.push(chalk.cyan.bold(' ⏱ Time Machine Backup:'));
639
+ if (!tmStatus.configured) {
640
+ lines.push(' • Not configured');
641
+ } else {
642
+ lines.push(` • Status: ${tmStatus.running ? chalk.yellow(`Backup in progress (${tmStatus.percent || 0}%)`) : chalk.green('Idle')}`);
643
+ lines.push(` • Latest Backup: ${tmStatus.latestBackup}`);
644
+ }
645
+
511
646
  console.log(lines.join('\n'));
512
647
  }
513
648
 
@@ -541,9 +676,9 @@ async function runSshCommand(host: string): Promise<void> {
541
676
  });
542
677
  }
543
678
 
544
- async function runWebCommand(): Promise<void> {
679
+ async function runWebCommand(): Promise<number> {
545
680
  const http = await import('node:http');
546
- const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
681
+ const port = parseInt(opts.port || process.env.PORT || '3000', 10) || (opts.port === '0' ? 0 : 3000);
547
682
 
548
683
  const server = http.createServer(async (req, res) => {
549
684
  const url = req.url || '/';
@@ -585,17 +720,14 @@ async function runWebCommand(): Promise<void> {
585
720
  // 4. Static/Dynamic HTML live dashboard & API snapshot endpoints
586
721
  if (url === '/' || url === '/index.html') {
587
722
  res.writeHead(200, { 'Content-Type': 'text/html' });
588
- const data = await collectAll({ detailed: true });
589
- const htmlContent = formatHtml(data);
590
- res.end(htmlContent);
723
+ res.end(getDashboardHtml());
591
724
  } else if (url === '/setup') {
592
725
  res.writeHead(200, { 'Content-Type': 'text/html' });
593
726
  const indexPath = path.join(process.cwd(), 'index.html');
594
727
  if (fs.existsSync(indexPath)) {
595
728
  res.end(fs.readFileSync(indexPath, 'utf-8'));
596
729
  } else {
597
- const data = await collectAll({ detailed: true });
598
- res.end(formatHtml(data));
730
+ res.end(getDashboardHtml());
599
731
  }
600
732
  } else if (url === '/api' || url === '/api/stats' || url === '/data') {
601
733
  const data = await collectAll({ detailed: true });
@@ -625,20 +757,145 @@ async function runWebCommand(): Promise<void> {
625
757
 
626
758
  const ip = detectLocalIP() || 'localhost';
627
759
 
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`));
760
+ return new Promise((resolve) => {
761
+ server.listen(port, '0.0.0.0', () => {
762
+ const address = server.address();
763
+ const actualPort = typeof address === 'object' && address ? address.port : port;
764
+
765
+ console.log(chalk.bold(`\n pyre web server running on port ${actualPort}`));
766
+ console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
767
+ console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
768
+ console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
769
+ console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
770
+
771
+ resolve(actualPort);
772
+ });
773
+
774
+ process.once('SIGINT', () => {
775
+ server.close();
776
+ process.exit(0);
777
+ });
778
+ });
779
+ }
780
+
781
+ async function runUiCommand(): Promise<void> {
782
+ // Use a random ephemeral port to avoid collisions
783
+ opts.port = '0';
784
+ const port = await runWebCommand();
785
+
786
+ const swiftScript = `
787
+ import Cocoa
788
+ import WebKit
789
+
790
+ // WKWebView subclass that allows the window to be dragged from any pixel
791
+ class DraggableWebView: WKWebView {
792
+ override var mouseDownCanMoveWindow: Bool { return true }
793
+ }
794
+
795
+ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
796
+ var window: NSWindow!
797
+ var webView: DraggableWebView!
798
+ var dataTimer: Timer?
799
+ let serverPort = ${port}
800
+
801
+ func applicationDidFinishLaunching(_ n: Notification) {
802
+ // Window
803
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
804
+ let W = min(1180.0, screen.width - 80)
805
+ let H = min(820.0, screen.height - 80)
806
+ let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
807
+ y: screen.minY + (screen.height - H) / 2,
808
+ width: W, height: H)
809
+
810
+ window = NSWindow(contentRect: rect,
811
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
812
+ backing: .buffered, defer: false)
813
+ window.title = "Pyre"
814
+ window.minSize = NSSize(width: 800, height: 600)
815
+ window.appearance = NSAppearance(named: .darkAqua)
816
+ window.backgroundColor = NSColor(white: 0, alpha: 1)
817
+ window.isReleasedWhenClosed = false
818
+ window.delegate = self
819
+
820
+ // WebView
821
+ let cfg = WKWebViewConfiguration()
822
+ webView = DraggableWebView(frame: window.contentView!.bounds, configuration: cfg)
823
+ webView.autoresizingMask = [.width, .height]
824
+ webView.setValue(NSColor(white: 0, alpha: 1),
825
+ forKey: "backgroundColor")
826
+
827
+ // Load the dashboard HTML from the local server
828
+ var req = URLRequest(url: URL(string: "http://localhost:\\(serverPort)/")!)
829
+ req.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
830
+ webView.load(req)
831
+
832
+ window.contentView?.addSubview(webView)
833
+ window.makeKeyAndOrderFront(nil)
834
+ NSApp.activate(ignoringOtherApps: true)
835
+
836
+ // Start native data timer (URLSession has full network access — no sandbox issues)
837
+ // We wait 1.5 s for the page to load, then start pushing data every 2 s.
838
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
839
+ self?.fetchAndPush()
840
+ self?.dataTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
841
+ self?.fetchAndPush()
842
+ }
843
+ }
844
+ }
845
+
846
+ // URLSession fetch (runs in the main app process — not sandboxed like WebContent)
847
+ func fetchAndPush() {
848
+ guard let url = URL(string: "http://localhost:\\(serverPort)/api/stats") else { return }
849
+ URLSession.shared.dataTask(with: url) { [weak self] data, _, err in
850
+ if let err = err {
851
+ print("Swift UI Fetch Error: \\(err)")
852
+ return
853
+ }
854
+ guard let data = data else {
855
+ print("Swift UI Fetch Error: No data")
856
+ return
857
+ }
858
+ let b64 = data.base64EncodedString()
859
+ let js = "if(window.__pyreUpdate){window.__pyreUpdate(decodeURIComponent(escape(atob('\\(b64)'))))}"
860
+ DispatchQueue.main.async {
861
+ self?.webView.evaluateJavaScript(js) { res, err in
862
+ if let err = err {
863
+ print("Swift UI JS Eval Error: \\(err)")
864
+ }
865
+ }
866
+ }
867
+ }.resume()
868
+ }
869
+
870
+ func windowWillClose(_ n: Notification) {
871
+ dataTimer?.invalidate()
872
+ NSApplication.shared.terminate(self)
873
+ }
874
+ func applicationShouldTerminateAfterLastWindowClosed(_ s: NSApplication) -> Bool { true }
875
+ }
876
+
877
+ let app = NSApplication.shared
878
+ app.setActivationPolicy(.regular)
879
+ let delegate = AppDelegate()
880
+ app.delegate = delegate
881
+ app.run()
882
+ `;
883
+
884
+
885
+ const scriptPath = path.join(os.tmpdir(), 'pyre-ui.swift');
886
+ fs.writeFileSync(scriptPath, swiftScript);
887
+
888
+ const { spawn } = await import('node:child_process');
889
+ console.log(chalk.cyan(`\n Launching native UI window...`));
890
+ const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
891
+
892
+ ui.on('close', () => {
893
+ console.log(chalk.dim('UI window closed, shutting down server...'));
894
+ process.exit(0);
638
895
  });
639
896
 
640
897
  process.once('SIGINT', () => {
641
- server.close();
898
+ ui.kill();
642
899
  process.exit(0);
643
900
  });
644
901
  }
@@ -650,12 +907,16 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
650
907
 
651
908
  if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
652
909
  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');
910
+ 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
911
 
655
912
  console.log(chalk.bold(`\n pyre bench`));
656
913
  console.log(chalk.dim(` Command: ${benchCmd}`));
657
914
  console.log(chalk.dim(` Log: ${file}\n`));
658
915
 
916
+ const startTime = Date.now();
917
+ let accumulatedWattSeconds = 0;
918
+ let powerSamplesCount = 0;
919
+
659
920
  const child = spawn('sh', ['-c', benchCmd]);
660
921
  child.stdout.on('data', d => process.stdout.write(d));
661
922
  child.stderr.on('data', d => process.stdout.write(d));
@@ -668,23 +929,40 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
668
929
  const rxPackets = data.network.rxPackets ?? 0;
669
930
  const txPackets = data.network.txPackets ?? 0;
670
931
  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`);
932
+ const watts = data.power?.combinedWatts ?? data.power?.cpuWatts;
933
+ if (watts !== undefined && watts > 0) {
934
+ accumulatedWattSeconds += watts * interval;
935
+ powerSamplesCount++;
936
+ }
937
+ 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
938
  } catch {
673
939
  // skip bad tick
674
940
  }
675
941
  }, interval * 1000);
676
942
 
677
- child.on('close', () => {
943
+ const printSummary = () => {
678
944
  clearInterval(handle);
679
945
  stream.end();
680
- console.log(chalk.green(`\n Bench complete. Log saved to ${file}`));
946
+ const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
947
+ console.log(chalk.green(`\n ✔ Bench complete (${durationSec.toFixed(2)}s). Log saved to ${file}`));
948
+
949
+ if (powerSamplesCount > 0 && accumulatedWattSeconds > 0) {
950
+ const kWh = accumulatedWattSeconds / (3600 * 1000);
951
+ const rate = config.electricityRateKwH ?? 0.15;
952
+ const cost = kWh * rate;
953
+ console.log(chalk.cyan(` ⚡ Energy Consumption: ${kWh.toFixed(6)} kWh | Est. Cost: $${cost.toFixed(6)} (@ $${rate}/kWh)`));
954
+ }
955
+ };
956
+
957
+ child.on('close', () => {
958
+ printSummary();
681
959
  process.exit(0);
682
960
  });
683
961
 
684
962
  process.once('SIGINT', () => {
685
963
  clearInterval(handle);
686
964
  child.kill('SIGTERM');
687
- stream.end();
965
+ printSummary();
688
966
  process.exit(0);
689
967
  });
690
968
  }
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 {