pyre-cli 9.5.2 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +73 -16
  2. package/dist/alertChannels-TPSQIJ7S.js +8 -0
  3. package/dist/{blender-7UT5J7IP.js → blender-FGH3ASEQ.js} +2 -2
  4. package/dist/brewHealth-P2GW5Y5C.js +176 -0
  5. package/dist/buildCmd-CDPZ2ZFA.js +38 -0
  6. package/dist/{checkCmd-T7O4KXXA.js → checkCmd-RKW6QGDR.js} +9 -11
  7. package/dist/{chunk-CXL4GYGQ.js → chunk-53O2RGSL.js} +44 -17
  8. package/dist/{chunk-3TE7MJLS.js → chunk-6UHZP4QP.js} +36 -10
  9. package/dist/chunk-GHRDW646.js +2506 -0
  10. package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
  11. package/dist/chunk-M73HCQD2.js +95 -0
  12. package/dist/chunk-MZO5QKUR.js +32 -0
  13. package/dist/{chunk-25I533OF.js → chunk-VIACN2RU.js} +1 -1
  14. package/dist/chunk-Z2YV5HKV.js +87 -0
  15. package/dist/chunk-ZLVIVFSO.js +110 -0
  16. package/dist/{collectors-JB4OGUEF.js → collectors-H43PN4PS.js} +6 -3
  17. package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
  18. package/dist/{config-KS5ZZ3VN.js → config-GYGJXU2Q.js} +9 -1
  19. package/dist/doctor-GYHFTNZQ.js +387 -0
  20. package/dist/explainCmd-MF6PIMLW.js +222 -0
  21. package/dist/extensions-XJGHG7X3.js +134 -0
  22. package/dist/index.js +460 -88
  23. package/dist/ioreport.py +72 -0
  24. package/dist/netUsageCmd-WKBTW2YG.js +90 -0
  25. package/dist/{pipeCmd-TYLJHQ5B.js → pipeCmd-7REDBLBU.js} +4 -3
  26. package/dist/profileProcCmd-N4H6MCBT.js +107 -0
  27. package/dist/prometheus-5BL5PVLC.js +6 -0
  28. package/dist/prometheusCmd-PL64BLN5.js +93 -0
  29. package/dist/replayCmd-Z3PYXY7V.js +164 -0
  30. package/dist/run-HOGQDDTA.js +8 -0
  31. package/dist/serveCmd-2H5VLF3P.js +153 -0
  32. package/dist/service-Q74BBILO.js +181 -0
  33. package/dist/smartCmd-ODWKSFNG.js +166 -0
  34. package/dist/syncCmd-A2W3QB3S.js +126 -0
  35. package/dist/topoCmd-DU2HHZNH.js +148 -0
  36. package/dist/watchdogCmd-UF53JPZB.js +161 -0
  37. package/dist/{wizard-Z3RGA3VB.js → wizard-TD7BGX6O.js} +1 -1
  38. package/install.ps1 +29 -0
  39. package/install.sh +10 -9
  40. package/package.json +7 -3
  41. package/src/ai/models.ts +138 -0
  42. package/src/alertChannels.ts +119 -0
  43. package/src/brewHealth.ts +157 -40
  44. package/src/buildCmd.ts +48 -0
  45. package/src/checkCmd.ts +5 -5
  46. package/src/completions.ts +35 -0
  47. package/src/doctor.ts +235 -7
  48. package/src/easy_features.test.ts +40 -0
  49. package/src/explainCmd.ts +261 -0
  50. package/src/extensions.ts +71 -7
  51. package/src/formatters/render.ts +34 -10
  52. package/src/formatters/types.ts +2 -0
  53. package/src/index.ts +251 -36
  54. package/src/live/input.ts +60 -0
  55. package/src/live/render.ts +67 -0
  56. package/src/live/state.ts +5 -0
  57. package/src/live/types.ts +1 -1
  58. package/src/monitors/batteryPredictor.ts +143 -0
  59. package/src/monitors/buildTracker.ts +121 -0
  60. package/src/monitors/collectors.ts +342 -178
  61. package/src/monitors/ioreport.py +72 -0
  62. package/src/monitors/platform/linux.ts +756 -0
  63. package/src/monitors/platform/windows.ts +519 -0
  64. package/src/monitors/run.ts +27 -3
  65. package/src/monitors/smc.ts +21 -1
  66. package/src/monitors/types.ts +15 -0
  67. package/src/netUsageCmd.ts +121 -0
  68. package/src/platform.test.ts +99 -0
  69. package/src/plugins/index.ts +143 -0
  70. package/src/profileProcCmd.ts +133 -0
  71. package/src/prometheusCmd.ts +107 -0
  72. package/src/replayCmd.ts +186 -0
  73. package/src/serveCmd.ts +180 -0
  74. package/src/service.ts +125 -7
  75. package/src/smartCmd.ts +205 -0
  76. package/src/state/config.ts +58 -17
  77. package/src/syncCmd.ts +166 -0
  78. package/src/topoCmd.ts +176 -0
  79. package/src/watchdogCmd.ts +198 -0
  80. package/src/xbar.ts +22 -11
  81. package/dist/brewHealth-F2BXF5M6.js +0 -74
  82. package/dist/chunk-FH5WDJBF.js +0 -15
  83. package/dist/chunk-N4WJVF5M.js +0 -1184
  84. package/dist/doctor-UV7TWIHX.js +0 -184
  85. package/dist/extensions-DVA5YLGH.js +0 -74
  86. package/dist/service-WTS3NBQU.js +0 -73
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pyre - Mac system monitoring CLI
4
- * Monitors temps, cpu, memory, disk, battery, and system stats.
3
+ * pyre - Cross-platform system monitoring CLI
4
+ * Monitors temps, cpu, memory, disk, battery, and system stats on macOS, Linux, and Windows.
5
5
  * Live mode is a full interactive dashboard: graphs, pause/resume,
6
6
  * on-demand snapshot export, and continuous CSV logging.
7
7
  * P2P mode enables live data streaming between two systems over TCP.
@@ -18,7 +18,7 @@ import { startLive, stopLive } from './live/index.js';
18
18
  import { showSplash } from './splash.js';
19
19
  import { P2PServer, P2PClient } from './p2p/index.js';
20
20
  import { runDoctor, printDoctorReport } from './doctor.js';
21
- import { generateZshCompletions, generateBashCompletions, generateFishCompletions } from './completions.js';
21
+ import { generateZshCompletions, generateBashCompletions, generateFishCompletions, generatePowerShellCompletions } from './completions.js';
22
22
  import { runHistoryCommand } from './historyCmd.js';
23
23
  import { runDiffCommand } from './diffCmd.js';
24
24
  import { readConfig, CONFIG_FILE } from './state/config.js';
@@ -34,7 +34,7 @@ const program = new Command();
34
34
  program
35
35
  .name('pyre')
36
36
  .version(pkg.version)
37
- .description('Mac system monitoring CLI: interactive live dashboard, stats, graphs, export, packet monitor, battery predictor')
37
+ .description('Cross-platform system monitoring CLI: interactive live dashboard, stats, graphs, export, packet monitor, battery predictor')
38
38
  .option('-j, --json', 'Output as JSON')
39
39
  .option('--html', 'Output as HTML')
40
40
  .option('--md', 'Output as Markdown')
@@ -79,7 +79,23 @@ program
79
79
  .option('--no-wizard', 'Skip first-run interactive setup wizard')
80
80
  .option('--install', 'Install pyre web as a background launchd agent')
81
81
  .option('--uninstall', 'Uninstall pyre web launchd agent')
82
- .option('--port <port>', 'Port number for web server mode', '3000');
82
+ .option('--port <port>', 'Port number for web server mode', '3000')
83
+ .option('--prometheus-port <port>', 'Port number for Prometheus exporter (default: 9090)', '9090')
84
+ .option('--speed <n>', 'Playback speed multiplier for pyre replay (default: 1)', '1')
85
+ .option('--llm-backend <backend>', 'AI backend: builtin (local zero-setup), ollama, or openai (default: builtin)', 'builtin')
86
+ .option('--llm-model <model>', 'AI model name for pyre explain (default: expert-rules-v1 / llama3.2 / gpt-4o-mini)')
87
+ .option('--api-key <key>', 'API key for OpenAI-compatible LLM backend (or set OPENAI_API_KEY env)')
88
+ .option('--alert-slack <url>', 'Slack webhook URL for alert notifications')
89
+ .option('--alert-discord <url>', 'Discord webhook URL for alert notifications')
90
+ .option('--alert-pushover-token <token>', 'Pushover application token for alert notifications')
91
+ .option('--alert-pushover-user <user>', 'Pushover user key for alert notifications')
92
+ .option('--alert-ntfy <url>', 'ntfy.sh topic URL for alert notifications (e.g. https://ntfy.sh/my-topic)')
93
+ .option('--watchdog-config <file>', 'Path to custom watchdog JSON configuration rules')
94
+ .option('--dry-run', 'Simulate actions in watchdog mode without executing kills')
95
+ .option('--top <n>', 'Number of items to display in netusage command (default: 15)')
96
+ .option('--raw', 'Print unparsed output for profiler')
97
+ .option('--sync-dir <dir>', 'Custom directory path for cloud profile/config sync')
98
+ .option('--scan', 'Perform active network sweep on LAN neighbors for topology map');
83
99
 
84
100
  program.addHelpText('after', `
85
101
  Commands:
@@ -87,27 +103,39 @@ program.addHelpText('after', `
87
103
  check One-line plain-English health summary
88
104
  pipe Continuous newline-delimited JSON stream for scripting
89
105
  stress Synthetic CPU/GPU load generator
90
- ui Launch the native macOS UI dashboard
106
+ ui Launch the live UI dashboard window
91
107
  web Serve an auto-refreshing live web portal
92
- ssh <host> Stream live stats from a remote Mac over SSH
93
- fleet <host1> [host2]... Multi-host live dashboard monitoring multiple Macs
108
+ ssh <host> Stream live stats from a remote host over SSH
109
+ fleet <host1> [host2]... Multi-host live dashboard monitoring multiple hosts
94
110
  bench <cmd> Log CPU, memory, network, power draw, and estimate kWh cost
95
111
  benchmark Run a 1-minute CPU benchmark calculating digits of PI
96
112
  anomalies Compute z-score anomalies and print plain-language digest
97
113
  doctor Run system diagnostics
98
- extensions System Extensions inspector
99
- brew Homebrew health panel
114
+ extensions System Extensions / Driver inspector
115
+ brew Package manager health panel (brew/apt/pacman/winget)
100
116
  update Check for pyre-cli updates
101
117
  profile <save|load|list> Atomic configuration profile management
102
118
  config <show|reset> View or reset persistent configuration file
103
119
  history Graph historical resource trends from CSV logs
104
120
  diff <file1> <file2> Compare two saved snapshot files side-by-side
105
121
  info Concise hardware summary
106
- completions <shell> Generate shell auto-completion scripts
122
+ completions <shell> Generate shell auto-completion scripts (zsh, bash, fish, powershell)
107
123
  xbar Generate an xbar / SwiftBar menu bar plugin script
108
124
  p2p <server|connect> Start a P2P server or connect to one
109
125
  server Print commands for starting P2P server/client
110
126
  blender Track active Blender render jobs and their progress
127
+ prometheus Start a Prometheus metrics exporter on localhost
128
+ smart S.M.A.R.T. disk health report (requires smartmontools)
129
+ replay <file> Play back a pyre CSV log file in the TUI
130
+ explain AI-powered plain-English anomaly explanation (Ollama/OpenAI)
131
+ alert test Send a test notification to all configured alert channels
132
+ netusage Real-time per-application network usage breakdown
133
+ serve Lightweight structured JSON REST API server
134
+ profile-proc <pid> CPU call-tree profiler and hot function sampler
135
+ watchdog Persistent autonomous monitoring daemon and rules engine
136
+ build Active compiler and build system tracker
137
+ topo Network topology mapper and pyre peer auto-discovery
138
+ sync <push|pull|status> Synchronize config profiles across Macs via iCloud/Drive
111
139
  `);
112
140
 
113
141
  program.parse(process.argv);
@@ -260,6 +288,158 @@ async function main() {
260
288
  return;
261
289
  }
262
290
 
291
+ if (cmd === 'prometheus') {
292
+ const { runPrometheusServer } = await import('./prometheusCmd.js');
293
+ const prometheusPort = parseInt(opts.prometheusPort, 10) || 9090;
294
+ const intervalSec = parseFloat(opts.interval) || 15;
295
+ await runPrometheusServer({ port: prometheusPort, interval: intervalSec, detailed: opts.detailed });
296
+ return;
297
+ }
298
+
299
+ if (cmd === 'smart') {
300
+ const { runSmartCommand } = await import('./smartCmd.js');
301
+ await runSmartCommand();
302
+ return;
303
+ }
304
+
305
+ if (cmd === 'replay') {
306
+ const file = program.args[1];
307
+ if (!file) {
308
+ console.error(chalk.red(' ✖ Usage: pyre replay <logfile.csv>'));
309
+ process.exit(1);
310
+ }
311
+ const { runReplayCommand } = await import('./replayCmd.js');
312
+ await runReplayCommand({
313
+ file,
314
+ speed: parseFloat(opts.speed) || 1,
315
+ plain: opts.plain || opts.a11y,
316
+ });
317
+ return;
318
+ }
319
+
320
+ if (cmd === 'explain') {
321
+ const { runExplainCommand } = await import('./explainCmd.js');
322
+ const backend = opts.llmBackend === 'openai' ? 'openai' : opts.llmBackend === 'ollama' ? 'ollama' : 'builtin';
323
+ await runExplainCommand({
324
+ since: opts.since || '7d',
325
+ backend,
326
+ model: opts.llmModel,
327
+ apiKey: opts.apiKey,
328
+ dir: opts.exportDir,
329
+ });
330
+ return;
331
+ }
332
+
333
+ if (cmd === 'alert') {
334
+ const sub = program.args[1];
335
+ if (sub === 'test') {
336
+ const { sendTestAlert } = await import('./alertChannels.js');
337
+ const { writeConfig } = await import('./state/config.js');
338
+ // Persist any channel flags passed on CLI before sending test
339
+ if (opts.alertSlack || opts.alertDiscord || opts.alertPushoverToken || opts.alertPushoverUser || opts.alertNtfy) {
340
+ writeConfig({
341
+ slackAlertUrl: opts.alertSlack || config.slackAlertUrl,
342
+ discordAlertUrl: opts.alertDiscord || config.discordAlertUrl,
343
+ pushoverToken: opts.alertPushoverToken || config.pushoverToken,
344
+ pushoverUser: opts.alertPushoverUser || config.pushoverUser,
345
+ ntfyUrl: opts.alertNtfy || config.ntfyUrl,
346
+ });
347
+ }
348
+ const testConfig = {
349
+ slackUrl: opts.alertSlack || config.slackAlertUrl || undefined,
350
+ discordUrl: opts.alertDiscord || config.discordAlertUrl || undefined,
351
+ pushoverToken: opts.alertPushoverToken || config.pushoverToken || undefined,
352
+ pushoverUser: opts.alertPushoverUser || config.pushoverUser || undefined,
353
+ ntfyUrl: opts.alertNtfy || config.ntfyUrl || undefined,
354
+ };
355
+ const hasAny = Object.values(testConfig).some(v => !!v);
356
+ if (!hasAny) {
357
+ console.log(chalk.yellow('\n ⚠ No alert channels configured.'));
358
+ console.log(chalk.dim(' Use --alert-slack <url>, --alert-discord <url>, --alert-ntfy <url>, etc.\n'));
359
+ return;
360
+ }
361
+ console.log(chalk.bold('\n 🔥 pyre alert test — firing test notification...\n'));
362
+ await sendTestAlert(testConfig);
363
+ console.log(chalk.green(' ✔ Test alert sent to all configured channels.\n'));
364
+ } else {
365
+ console.log(chalk.yellow(' Usage: pyre alert test'));
366
+ }
367
+ return;
368
+ }
369
+
370
+ if (cmd === 'netusage') {
371
+ const { runNetUsageCommand } = await import('./netUsageCmd.js');
372
+ await runNetUsageCommand({
373
+ top: opts.top ? parseInt(opts.top, 10) : 15,
374
+ sort: (opts.sort === 'rx' || opts.sort === 'tx') ? opts.sort : 'total',
375
+ json: !!opts.json,
376
+ });
377
+ return;
378
+ }
379
+
380
+ if (cmd === 'serve') {
381
+ const { runServeCommand } = await import('./serveCmd.js');
382
+ const port = parseInt(opts.port, 10) || 8080;
383
+ await runServeCommand({
384
+ port,
385
+ apiKey: opts.apiKey,
386
+ detailed: opts.detailed,
387
+ });
388
+ return;
389
+ }
390
+
391
+ if (cmd === 'profile-proc') {
392
+ const targetPid = parseInt(program.args[1], 10);
393
+ if (isNaN(targetPid)) {
394
+ console.error(chalk.red(' ✖ Usage: pyre profile-proc <pid> [--duration <sec>]'));
395
+ process.exit(1);
396
+ }
397
+ const { runProfileProcCommand } = await import('./profileProcCmd.js');
398
+ const durationSec = parseFloat(opts.duration) || 5;
399
+ await runProfileProcCommand({
400
+ pid: targetPid,
401
+ durationSec,
402
+ raw: !!opts.raw,
403
+ });
404
+ return;
405
+ }
406
+
407
+ if (cmd === 'watchdog') {
408
+ const { runWatchdogCommand } = await import('./watchdogCmd.js');
409
+ await runWatchdogCommand({
410
+ configFile: opts.watchdogConfig,
411
+ dryRun: !!opts.dryRun,
412
+ interval: parseFloat(opts.interval) || 2,
413
+ });
414
+ return;
415
+ }
416
+
417
+ if (cmd === 'build') {
418
+ const { runBuildCommand } = await import('./buildCmd.js');
419
+ await runBuildCommand({
420
+ json: !!opts.json,
421
+ });
422
+ return;
423
+ }
424
+
425
+ if (cmd === 'topo') {
426
+ const { runTopoCommand } = await import('./topoCmd.js');
427
+ await runTopoCommand({
428
+ scan: !!opts.scan,
429
+ json: !!opts.json,
430
+ });
431
+ return;
432
+ }
433
+
434
+ if (cmd === 'sync') {
435
+ const subcommand = program.args[1] || 'status';
436
+ const { runSyncCommand } = await import('./syncCmd.js');
437
+ await runSyncCommand(subcommand, {
438
+ syncDir: opts.syncDir,
439
+ });
440
+ return;
441
+ }
442
+
263
443
  if (cmd === 'brew') {
264
444
  const { printBrewHealthReport } = await import('./brewHealth.js');
265
445
  await printBrewHealthReport();
@@ -426,6 +606,12 @@ async function main() {
426
606
  if (opts.webhookUrl) config.webhookUrl = opts.webhookUrl;
427
607
  if (opts.alertCmd) config.alertCmd = opts.alertCmd;
428
608
  if (opts.exportDir) config.exportDir = opts.exportDir;
609
+ // Persist new alert channel options to config if provided
610
+ if (opts.alertSlack) config.slackAlertUrl = opts.alertSlack;
611
+ if (opts.alertDiscord) config.discordAlertUrl = opts.alertDiscord;
612
+ if (opts.alertPushoverToken) config.pushoverToken = opts.alertPushoverToken;
613
+ if (opts.alertPushoverUser) config.pushoverUser = opts.alertPushoverUser;
614
+ if (opts.alertNtfy) config.ntfyUrl = opts.alertNtfy;
429
615
  const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
430
616
 
431
617
  if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
@@ -696,7 +882,7 @@ async function runInfoCommand(): Promise<void> {
696
882
  const tmStatus = await getTimeMachineStatus();
697
883
 
698
884
  const lines: string[] = [];
699
- lines.push(chalk.bold(' pyre info — Mac System Information'));
885
+ lines.push(chalk.bold(` pyre info — System Information (${data.header.os})`));
700
886
  lines.push('');
701
887
  lines.push(` Hostname: ${data.header.hostname}`);
702
888
  lines.push(` OS: ${data.header.os}`);
@@ -723,13 +909,15 @@ async function runInfoCommand(): Promise<void> {
723
909
  }
724
910
  }
725
911
 
726
- lines.push('');
727
- lines.push(chalk.cyan.bold(' ⏱ Time Machine Backup:'));
728
- if (!tmStatus.configured) {
729
- lines.push(' • Not configured');
730
- } else {
731
- lines.push(` • Status: ${tmStatus.running ? chalk.yellow(`Backup in progress (${tmStatus.percent || 0}%)`) : chalk.green('Idle')}`);
732
- lines.push(` • Latest Backup: ${tmStatus.latestBackup}`);
912
+ if (process.platform === 'darwin') {
913
+ lines.push('');
914
+ lines.push(chalk.cyan.bold(' ⏱ Time Machine Backup:'));
915
+ if (!tmStatus.configured) {
916
+ lines.push(' • Not configured');
917
+ } else {
918
+ lines.push(` • Status: ${tmStatus.running ? chalk.yellow(`Backup in progress (${tmStatus.percent || 0}%)`) : chalk.green('Idle')}`);
919
+ lines.push(` • Latest Backup: ${tmStatus.latestBackup}`);
920
+ }
733
921
  }
734
922
 
735
923
  console.log(lines.join('\n'));
@@ -877,12 +1065,29 @@ async function runWebCommand(): Promise<number> {
877
1065
  });
878
1066
  }
879
1067
 
1068
+ async function openBrowserUrl(url: string): Promise<void> {
1069
+ const { spawn } = await import('node:child_process');
1070
+ const platform = process.platform;
1071
+ if (platform === 'win32') {
1072
+ spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' });
1073
+ } else if (platform === 'darwin') {
1074
+ spawn('open', [url], { detached: true, stdio: 'ignore' });
1075
+ } else {
1076
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
1077
+ }
1078
+ }
1079
+
880
1080
  async function runUiCommand(): Promise<void> {
881
1081
  // Use a random ephemeral port to avoid collisions
882
1082
  opts.port = '0';
883
1083
  const port = await runWebCommand();
884
-
885
- const swiftScript = `
1084
+ const url = `http://localhost:${port}`;
1085
+
1086
+ if (process.platform === 'darwin') {
1087
+ const { run } = await import('./monitors/run.js');
1088
+ const hasSwift = (await run('which swift 2>/dev/null', '')).trim().length > 0;
1089
+ if (hasSwift) {
1090
+ const swiftScript = `
886
1091
  import Cocoa
887
1092
  import WebKit
888
1093
 
@@ -985,23 +1190,31 @@ app.delegate = delegate
985
1190
  app.run()
986
1191
  `;
987
1192
 
1193
+ const scriptPath = path.join(os.tmpdir(), 'pyre-ui.swift');
1194
+ fs.writeFileSync(scriptPath, swiftScript);
988
1195
 
989
- const scriptPath = path.join(os.tmpdir(), 'pyre-ui.swift');
990
- fs.writeFileSync(scriptPath, swiftScript);
1196
+ const { spawn } = await import('node:child_process');
1197
+ console.log(chalk.cyan(`\n Launching native UI window...`));
1198
+ const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
1199
+
1200
+ ui.on('close', () => {
1201
+ console.log(chalk.dim('UI window closed, shutting down server...'));
1202
+ process.exit(0);
1203
+ });
991
1204
 
992
- const { spawn } = await import('node:child_process');
993
- console.log(chalk.cyan(`\n Launching native UI window...`));
994
- const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
995
-
996
- ui.on('close', () => {
997
- console.log(chalk.dim('UI window closed, shutting down server...'));
998
- process.exit(0);
999
- });
1205
+ process.once('SIGINT', () => {
1206
+ ui.kill();
1207
+ process.exit(0);
1208
+ });
1209
+ return;
1210
+ }
1211
+ }
1000
1212
 
1001
- process.once('SIGINT', () => {
1002
- ui.kill();
1003
- process.exit(0);
1004
- });
1213
+ // Cross-platform browser / app-mode launch for Linux, Windows, or macOS without Swift
1214
+ console.log(chalk.cyan(`\n Opening live UI dashboard at ${url}...`));
1215
+ await openBrowserUrl(url);
1216
+ console.log(chalk.dim(' Press Ctrl+C to stop server.\n'));
1217
+ await new Promise(() => {});
1005
1218
  }
1006
1219
 
1007
1220
  async function runBenchCommand(benchCmd: string): Promise<void> {
@@ -1021,7 +1234,9 @@ async function runBenchCommand(benchCmd: string): Promise<void> {
1021
1234
  let accumulatedWattSeconds = 0;
1022
1235
  let powerSamplesCount = 0;
1023
1236
 
1024
- const child = spawn('sh', ['-c', benchCmd]);
1237
+ const child = process.platform === 'win32'
1238
+ ? spawn('cmd.exe', ['/c', benchCmd], { stdio: 'inherit' })
1239
+ : spawn('sh', ['-c', benchCmd]);
1025
1240
  child.stdout.on('data', d => process.stdout.write(d));
1026
1241
  child.stderr.on('data', d => process.stdout.write(d));
1027
1242
 
package/src/live/input.ts CHANGED
@@ -16,6 +16,8 @@ import { render, footerLine, checkAlerts, invalidateTableCache, invalidateFrame
16
16
  import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode, ActivePanel } from './types.js';
17
17
  import { startP2PServer } from '../p2p/index.js';
18
18
  import { writeConfig } from '../state/config.js';
19
+ import { AVAILABLE_AI_MODELS } from '../ai/models.js';
20
+ import { loadPlugins, executePlugins } from '../plugins/index.js';
19
21
 
20
22
  function persistConfig() {
21
23
  writeConfig({
@@ -39,6 +41,8 @@ import { writeConfig } from '../state/config.js';
39
41
  visiblePanels: { ...state.visiblePanels },
40
42
  notificationsEnabled: state.notificationsEnabled,
41
43
  dockerModeConfirmed: state.dockerModeConfirmed,
44
+ aiModel: state.aiModel,
45
+ aiBackend: state.aiBackend,
42
46
  });
43
47
  }
44
48
 
@@ -96,6 +100,25 @@ function handleInputModeKey(str: string, key: readline.Key) {
96
100
  } else if (selected === 'Temperature Unit') {
97
101
  state.tempUnit = state.tempUnit === 'c' ? 'f' : 'c';
98
102
  setStatus(`Temperature unit: ${state.tempUnit.toUpperCase()}`);
103
+ } else if (selected === 'AI Model') {
104
+ const curIdx = AVAILABLE_AI_MODELS.findIndex(m => m.id === state.aiModel);
105
+ const nextIdx = (curIdx + 1) % AVAILABLE_AI_MODELS.length;
106
+ state.aiModel = AVAILABLE_AI_MODELS[nextIdx].id;
107
+ state.aiBackend = AVAILABLE_AI_MODELS[nextIdx].backend;
108
+ setStatus(`AI Model: ${AVAILABLE_AI_MODELS[nextIdx].name}`);
109
+ } else if (selected === 'Grid Panel Order') {
110
+ // Rotate panelLayout
111
+ const layouts = [
112
+ ['mem', 'disk', 'net'],
113
+ ['disk', 'mem', 'net'],
114
+ ['net', 'mem', 'disk'],
115
+ ['mem', 'net', 'disk'],
116
+ ];
117
+ const currentStr = state.panelLayout.join(',');
118
+ const currentIdx = layouts.findIndex(l => l.join(',') === currentStr);
119
+ const nextIdx = (currentIdx + 1) % layouts.length;
120
+ state.panelLayout = layouts[nextIdx];
121
+ setStatus(`Panel layout: ${state.panelLayout.join(' → ')}`);
99
122
  } else {
100
123
  const toggleKey = getToggleKey(selected);
101
124
  if (toggleKey) {
@@ -225,6 +248,37 @@ function handleInputModeKey(str: string, key: readline.Key) {
225
248
  return;
226
249
  }
227
250
 
251
+ if (state.inputMode === 'ai-model') {
252
+ if (isEscKey(key, str)) {
253
+ state.inputMode = null;
254
+ render();
255
+ return;
256
+ }
257
+ if (isUpKey(key, str) || str === 'k') {
258
+ state.modelSelectionIndex = (state.modelSelectionIndex - 1 + AVAILABLE_AI_MODELS.length) % AVAILABLE_AI_MODELS.length;
259
+ render();
260
+ return;
261
+ }
262
+ if (isDownKey(key, str) || str === 'j') {
263
+ state.modelSelectionIndex = (state.modelSelectionIndex + 1) % AVAILABLE_AI_MODELS.length;
264
+ render();
265
+ return;
266
+ }
267
+ if (isEnterKey(key, str) || str === ' ') {
268
+ const chosen = AVAILABLE_AI_MODELS[state.modelSelectionIndex];
269
+ state.aiModel = chosen.id;
270
+ state.aiBackend = chosen.backend;
271
+ state.inputMode = null;
272
+ setStatus(`Active AI Model: ${chosen.name}`, 4000);
273
+ invalidateTableCache();
274
+ invalidateFrame();
275
+ persistConfig();
276
+ render();
277
+ return;
278
+ }
279
+ return;
280
+ }
281
+
228
282
  if (state.inputMode === 'signal') {
229
283
  if (isUpKey(key, str)) {
230
284
  const idx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
@@ -488,6 +542,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
488
542
  state.history.reset();
489
543
  state.history.setMaxLen(Math.max(20, Math.min(200, state.termWidth - 30)));
490
544
 
545
+ await loadPlugins().catch(() => {});
491
546
  const warmupPromise = doWarmup();
492
547
 
493
548
  if (splashPromise) {
@@ -626,6 +681,11 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
626
681
  state.customizerIndex = 0;
627
682
  render();
628
683
  break;
684
+ case 'm':
685
+ state.inputMode = 'ai-model';
686
+ state.modelSelectionIndex = Math.max(0, AVAILABLE_AI_MODELS.findIndex(m => m.id === state.aiModel));
687
+ render();
688
+ break;
629
689
  case 'p':
630
690
  state.paused = !state.paused;
631
691
  setStatus(state.paused ? 'Paused' : 'Resumed');
@@ -15,6 +15,9 @@ import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel,
15
15
  import { detectAnomalies } from '../anomalies.js';
16
16
  import { state, setStatus, SIGNAL_OPTIONS, getToggleKey, MENU_OPTIONS } from './state.js';
17
17
  import type { StatsData } from '../monitors/index.js';
18
+ import { sendAlert, type AlertChannelConfig } from '../alertChannels.js';
19
+ import { readConfig } from '../state/config.js';
20
+ import { AVAILABLE_AI_MODELS } from '../ai/models.js';
18
21
 
19
22
  function formatTempForUnit(c: number): string {
20
23
  if (state.tempUnit === 'f') {
@@ -61,6 +64,7 @@ function tableCacheParams(): string {
61
64
  state.history.version,
62
65
  JSON.stringify(state.visiblePanels),
63
66
  JSON.stringify(state.panelLayout),
67
+ state.aiModel,
64
68
  ].join('|');
65
69
  }
66
70
 
@@ -142,6 +146,11 @@ function renderCustomizerOverlay(scrollOffset: number, maxVisibleItems: number):
142
146
  lines.push(`${prefix}${opt}: ${status}`);
143
147
  } else if (opt === 'Temperature Unit') {
144
148
  lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
149
+ } else if (opt === 'AI Model') {
150
+ const curModel = AVAILABLE_AI_MODELS.find(m => m.id === state.aiModel) || AVAILABLE_AI_MODELS[0];
151
+ lines.push(`${prefix}${opt}: ${chalk.bold.cyan(curModel.name)} ${chalk.dim(`[${curModel.badge}]`)}`);
152
+ } else if (opt === 'Grid Panel Order') {
153
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.panelLayout.join(' → '))}`);
145
154
  } else {
146
155
  const toggleKey = getToggleKey(opt);
147
156
  const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
@@ -220,6 +229,10 @@ function render() {
220
229
  writeFrame(renderDockerAlert(cols, rows));
221
230
  return;
222
231
  }
232
+ if (state.inputMode === 'ai-model') {
233
+ writeFrame(renderAiModelSelector(cols, rows));
234
+ return;
235
+ }
223
236
  const lines: string[] = [];
224
237
  const theme = THEMES[state.currentTheme] || THEMES.default;
225
238
 
@@ -262,6 +275,7 @@ function render() {
262
275
  selectedProcessIndex: state.processSelectionIndex,
263
276
  trackedPid: state.trackedPid,
264
277
  inspectProcess: state.inspectingProcess,
278
+ aiModel: state.aiModel,
265
279
  });
266
280
  _cachedTableData = state.lastData;
267
281
  _cachedTableParams = cacheParams;
@@ -377,6 +391,7 @@ function footerLine(): string[] {
377
391
  ['f', state.exportFormat],
378
392
  ['+/-', `${state.interval}s`],
379
393
  ['r', state.p2pServerRunning ? 'stop p2p' : 'start p2p'],
394
+ ['m', `ai:${state.aiModel.split('-')[0]}`],
380
395
  ];
381
396
  const fmt = (list: [string, string][]) => list.map(([k, label]) => `${chalk.hex('#50fa7b').bold(k)} ${chalk.dim(label)}`).join(chalk.dim(' | '));
382
397
  const str1 = fmt(row1);
@@ -387,6 +402,7 @@ function footerLine(): string[] {
387
402
  if (state.logging) badges.push(chalk.red.bold('● REC'));
388
403
  if (state.activePanel !== 'grid') badges.push(chalk.cyan.bold(`◉ ${state.activePanel.toUpperCase()}`));
389
404
  if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
405
+ badges.push(chalk.magenta.bold(`AI:${state.aiModel}`));
390
406
  const badgeStr = badges.join(' ') || ' ';
391
407
 
392
408
  return [str1, str2, badgeStr];
@@ -458,6 +474,29 @@ function checkAlerts(data: StatsData) {
458
474
  if (state.alertCmd) {
459
475
  exec(state.alertCmd, { env: { ...process.env, PYRE_ALERT: alertMsg } }, () => {});
460
476
  }
477
+
478
+ // Multi-channel alert fan-out (Slack, Discord, Pushover, ntfy)
479
+ const cfg = readConfig();
480
+ const channelConfig: AlertChannelConfig = {
481
+ slackUrl: cfg.slackAlertUrl || undefined,
482
+ discordUrl: cfg.discordAlertUrl || undefined,
483
+ pushoverToken: cfg.pushoverToken || undefined,
484
+ pushoverUser: cfg.pushoverUser || undefined,
485
+ ntfyUrl: cfg.ntfyUrl || undefined,
486
+ };
487
+ const hasChannels = Object.values(channelConfig).some(v => !!v);
488
+ if (hasChannels) {
489
+ const severity = hasCriticalAnomaly ? 'critical' : 'warning';
490
+ sendAlert({
491
+ metric: reasons[0]?.split(' ')[0] ?? 'System',
492
+ value: data.cpu.usage,
493
+ threshold: state.CPU_ALERT_PCT,
494
+ unit: '%',
495
+ host: data.header.hostname,
496
+ timestamp: new Date().toISOString(),
497
+ severity,
498
+ }, channelConfig).catch(() => {});
499
+ }
461
500
  } else if (!hot && !anomalyTriggered && !watchdogTriggered) {
462
501
  state.alerted = false;
463
502
  }
@@ -595,4 +634,32 @@ function renderDockerAlert(cols: number, rows: number): string[] {
595
634
  return out;
596
635
  }
597
636
 
637
+ function renderAiModelSelector(cols: number, rows: number): string[] {
638
+ const out: string[] = [];
639
+ const content: string[] = [
640
+ chalk.bold.hex('#ff6004')('Choose AI Anomaly Explanation Model'),
641
+ chalk.dim('Select your preferred diagnosis engine. Offline models run entirely in-process.'),
642
+ '',
643
+ ];
644
+
645
+ for (let i = 0; i < AVAILABLE_AI_MODELS.length; i++) {
646
+ const m = AVAILABLE_AI_MODELS[i];
647
+ const isSelected = i === state.modelSelectionIndex;
648
+ const isCurrent = m.id === state.aiModel;
649
+ const prefix = isSelected ? chalk.cyan.bold(' ▶ ') : ' ';
650
+ const curBadge = isCurrent ? chalk.bgGreen.black.bold(' ACTIVE ') : '';
651
+ const badgeStr = m.offline ? chalk.green(`[${m.badge}]`) : chalk.yellow(`[${m.badge}]`);
652
+
653
+ const nameStr = isSelected ? chalk.bgCyan.black.bold(` ${m.name} `) : chalk.bold.white(m.name);
654
+ content.push(`${prefix}${nameStr} ${badgeStr} ${curBadge}`);
655
+ content.push(chalk.dim(` ${m.description}`));
656
+ content.push('');
657
+ }
658
+
659
+ content.push(chalk.dim('Use ↑/↓ or J/K to navigate · Enter/Space to select · Esc to cancel'));
660
+
661
+ out.push(...panel('AI MODEL SELECTOR', content, cols, chalk.cyan, chalk.dim, Math.max(1, rows - 2)));
662
+ return out;
663
+ }
664
+
598
665
  export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };
package/src/live/state.ts CHANGED
@@ -85,6 +85,8 @@ const state = {
85
85
  'Splash Animation',
86
86
  'Notifications',
87
87
  'Temperature Unit',
88
+ 'AI Model',
89
+ 'Grid Panel Order',
88
90
  'Toggle CPU',
89
91
  'Toggle Memory',
90
92
  'Toggle GPU',
@@ -145,6 +147,9 @@ const state = {
145
147
  isDocker: isDockerEnv,
146
148
  dockerModeConfirmed: config.dockerModeConfirmed || false,
147
149
  dockerSelectionIndex: 0,
150
+ aiModel: config.aiModel || 'expert-rules-v1',
151
+ aiBackend: config.aiBackend || 'builtin',
152
+ modelSelectionIndex: 0,
148
153
  };
149
154
 
150
155
  function setStatus(msg: string, ms = 3000) {
package/src/live/types.ts CHANGED
@@ -20,7 +20,7 @@ export interface LiveOptions {
20
20
 
21
21
  export type ExportFormat = 'json' | 'csv' | 'tsv' | 'html' | 'md';
22
22
 
23
- type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p' | 'menu' | 'readme' | 'credits' | 'docker-alert';
23
+ type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p' | 'menu' | 'readme' | 'credits' | 'docker-alert' | 'ai-model';
24
24
 
25
25
  type SortMode = 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
26
26