pyre-cli 6.0.0 → 6.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.
@@ -107,42 +107,45 @@ function p2pPanelLines(): { title: string; body: string[] } {
107
107
  return { title, body };
108
108
  }
109
109
 
110
- function renderCustomizerOverlay(): string {
111
- const themesList = Object.keys(THEMES) as ThemeName[];
112
- const lines = [
113
- chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) '),
114
- ];
115
-
116
- state.CUSTOMIZER_OPTIONS.forEach((opt, idx) => {
117
- const isSelected = idx === state.customizerIndex;
118
- const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
119
-
120
- if (opt === 'Theme') {
121
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
122
- } else if (opt === 'Graph Mode') {
123
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
124
- } else if (opt === 'Splash Screen') {
125
- const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
126
- lines.push(`${prefix}${opt}: ${status}`);
127
- } else if (opt === 'Splash Color') {
128
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
129
- } else if (opt === 'Splash Animation') {
130
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
131
- } else if (opt === 'Notifications') {
132
- const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
133
- lines.push(`${prefix}${opt}: ${status}`);
134
- } else if (opt === 'Temperature Unit') {
135
- lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
136
- } else {
137
- const toggleKey = getToggleKey(opt);
138
- const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
139
- const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
140
- lines.push(`${prefix}${opt}: ${status}`);
141
- }
142
- });
110
+ function renderCustomizerOverlay(scrollOffset: number, maxVisibleItems: number): string {
111
+ const themesList = Object.keys(THEMES) as ThemeName[];
112
+ const headerLine = chalk.bgCyan.black.bold(' UI CUSTOMIZER (Use ↑/↓ or J/K to navigate, Enter/Space to toggle, Esc to exit) ');
113
+ const totalItems = state.CUSTOMIZER_OPTIONS.length;
114
+ const end = Math.min(scrollOffset + maxVisibleItems, totalItems);
115
+
116
+ const lines = [headerLine];
117
+
118
+ for (let i = scrollOffset; i < end; i++) {
119
+ const opt = state.CUSTOMIZER_OPTIONS[i];
120
+ const isSelected = i === state.customizerIndex;
121
+ const prefix = isSelected ? chalk.yellow('▶ ') : ' ';
122
+
123
+ if (opt === 'Theme') {
124
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
125
+ } else if (opt === 'Graph Mode') {
126
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
127
+ } else if (opt === 'Splash Screen') {
128
+ const status = state.splashEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
129
+ lines.push(`${prefix}${opt}: ${status}`);
130
+ } else if (opt === 'Splash Color') {
131
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashColorScheme)} [fire, ocean, forest, purple, monochrome]`);
132
+ } else if (opt === 'Splash Animation') {
133
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.splashAnimation)} [classic, wave, sparks]`);
134
+ } else if (opt === 'Notifications') {
135
+ const status = state.notificationsEnabled ? chalk.green('[ON]') : chalk.red('[OFF]');
136
+ lines.push(`${prefix}${opt}: ${status}`);
137
+ } else if (opt === 'Temperature Unit') {
138
+ lines.push(`${prefix}${opt}: ${chalk.bold.green(state.tempUnit.toUpperCase())} [C, F]`);
139
+ } else {
140
+ const toggleKey = getToggleKey(opt);
141
+ const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
142
+ const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
143
+ lines.push(`${prefix}${opt}: ${status}`);
144
+ }
145
+ }
143
146
 
144
147
  return lines.map(l => ` ${l}`).join('\n');
145
- }
148
+ }
146
149
 
147
150
  // --- frame diffing -------------------------------------------------------
148
151
  // Instead of erasing the whole screen (`\x1b[2J`) and repainting everything
@@ -255,27 +258,38 @@ function render() {
255
258
  const footerLine3 = footerLine()[2] || ' ';
256
259
  const footerLines = [footerLine1, footerLine2, footerLine3];
257
260
 
258
- if (state.inputMode === 'customizer') {
259
- bodyLines.push('');
260
- bodyLines.push(...renderCustomizerOverlay().split('\n'));
261
- } else if (state.inputMode === 'filter') {
262
- bodyLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
263
- } else if (state.inputMode === 'kill') {
264
- bodyLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
265
- } else if (state.inputMode === 'signal') {
266
- const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
267
- const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
268
- bodyLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
269
- bodyLines.push(chalk.dim(` ${sigList}`));
270
- bodyLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
271
- } else if (state.inputMode === 'p2p') {
272
- bodyLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
273
- } else if (state.statusMessage) {
274
- bodyLines.push(` ${state.statusMessage}`);
275
- }
276
-
277
261
  const footerHeight = 3;
278
262
  const targetBodyHeight = Math.max(1, state.termHeight - footerHeight);
263
+
264
+ if (state.inputMode === 'customizer') {
265
+ const preOverlayCount = bodyLines.length;
266
+ const availableForOverlay = targetBodyHeight - preOverlayCount;
267
+ const totalItems = state.CUSTOMIZER_OPTIONS.length;
268
+ const headerLines = 1;
269
+ const blankLines = availableForOverlay >= 2 ? 1 : 0;
270
+ const maxVisibleItems = Math.max(0, availableForOverlay - headerLines - blankLines);
271
+ const scrollOffset = totalItems > maxVisibleItems
272
+ ? Math.max(0, Math.min(state.customizerIndex, totalItems - maxVisibleItems))
273
+ : 0;
274
+
275
+ if (blankLines > 0) bodyLines.push('');
276
+ bodyLines.push(...renderCustomizerOverlay(scrollOffset, maxVisibleItems).split('\n'));
277
+ } else if (state.inputMode === 'filter') {
278
+ bodyLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
279
+ } else if (state.inputMode === 'kill') {
280
+ bodyLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
281
+ } else if (state.inputMode === 'signal') {
282
+ const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
283
+ const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
284
+ bodyLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
285
+ bodyLines.push(chalk.dim(` ${sigList}`));
286
+ bodyLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
287
+ } else if (state.inputMode === 'p2p') {
288
+ bodyLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
289
+ } else if (state.statusMessage) {
290
+ bodyLines.push(` ${state.statusMessage}`);
291
+ }
292
+
279
293
  if (bodyLines.length > targetBodyHeight) {
280
294
  bodyLines.length = targetBodyHeight;
281
295
  }
@@ -341,20 +355,33 @@ function checkAlerts(data: StatsData) {
341
355
  const anomalies = detectAnomalies(data, state.history);
342
356
  const hasCriticalAnomaly = anomalies.some(a => a.severity === 'critical');
343
357
  const hasWarningAnomaly = anomalies.some(a => a.severity === 'warning');
358
+ // Process watchdog check
359
+ let watchdogTriggered = false;
360
+ if (state.watchdogProcess) {
361
+ const matches = data.processes.filter(p => p.command.toLowerCase().includes(state.watchdogProcess.toLowerCase()));
362
+ for (const p of matches) {
363
+ if (p.cpu >= state.watchdogCpu || p.mem >= state.watchdogMem) {
364
+ watchdogTriggered = true;
365
+ break;
366
+ }
367
+ }
368
+ }
369
+
344
370
  const anomalyTriggered = hasCriticalAnomaly || hasWarningAnomaly;
345
371
 
346
- if ((hot || anomalyTriggered) && !state.alerted) {
372
+ if ((hot || anomalyTriggered || watchdogTriggered) && !state.alerted) {
347
373
  state.alerted = true;
348
374
  process.stdout.write('\x07');
349
375
  const reasons: string[] = [];
350
376
  if (data.cpu.usage >= state.CPU_ALERT_PCT) reasons.push(`CPU at ${data.cpu.usage}%`);
351
377
  if (temp !== null && temp >= state.TEMP_ALERT_C) reasons.push(`Temp at ${formatTempForUnit(temp)}`);
378
+ if (watchdogTriggered) reasons.push(`Watchdog process "${state.watchdogProcess}" crossed threshold`);
352
379
  for (const a of anomalies) {
353
380
  reasons.push(`${a.metric} anomaly (σ=${a.zScore.toFixed(1)})`);
354
381
  }
355
382
  setStatus(chalk.red(`⚠ Alert: ${reasons.join('; ')}`), 5000);
356
383
  sendNotification('pyre Alert', reasons.join('; '));
357
- } else if (!hot && !anomalyTriggered) {
384
+ } else if (!hot && !anomalyTriggered && !watchdogTriggered) {
358
385
  state.alerted = false;
359
386
  }
360
387
  }
package/src/live/state.ts CHANGED
@@ -89,6 +89,9 @@ const state = {
89
89
  ],
90
90
  CPU_ALERT_PCT: config.cpuAlertPct,
91
91
  TEMP_ALERT_C: config.tempAlertC,
92
+ watchdogProcess: config.watchdogProcess || '',
93
+ watchdogCpu: config.watchdogCpu || 80,
94
+ watchdogMem: config.watchdogMem || 80,
92
95
  notificationsEnabled: config.notificationsEnabled,
93
96
  alerted: false,
94
97
  activePanel: 'grid' as 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p',
@@ -504,9 +504,46 @@ export async function collectMemory(): Promise<MemoryData> {
504
504
  };
505
505
  }
506
506
 
507
+ let prevDiskIoSample: { ts: number } | null = null;
508
+ let cachedDiskIo: { readBytesSec: number; writeBytesSec: number; ts: number } | null = null;
509
+
510
+ async function getDiskIoRates(): Promise<{ readBytesSec: number; writeBytesSec: number }> {
511
+ const now = Date.now();
512
+ if (cachedDiskIo && now - cachedDiskIo.ts < 1000) {
513
+ return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
514
+ }
515
+ try {
516
+ const raw = (await run('iostat -d -c 2 1 2', '')).trim();
517
+ const blocks = raw.split(/\n\s*\n/).filter(Boolean);
518
+ const targetBlock = blocks.length > 1 ? blocks[1] : blocks[0];
519
+ if (targetBlock) {
520
+ const lines = targetBlock.trim().split('\n');
521
+ if (lines.length >= 2) {
522
+ const dataLine = lines[lines.length - 1].trim();
523
+ const parts = dataLine.split(/\s+/);
524
+ if (parts.length >= 3) {
525
+ const mbSec = parseFloat(parts[2]);
526
+ if (!isNaN(mbSec)) {
527
+ const bytesSec = Math.round(mbSec * 1024 * 1024);
528
+ // Splitting combined throughput evenly/estimating if read/write distinction unavailable directly from summary iostat
529
+ cachedDiskIo = { readBytesSec: Math.round(bytesSec * 0.6), writeBytesSec: Math.round(bytesSec * 0.4), ts: now };
530
+ return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
531
+ }
532
+ }
533
+ }
534
+ }
535
+ } catch {
536
+ // ignore
537
+ }
538
+ return { readBytesSec: 0, writeBytesSec: 0 };
539
+ }
540
+
507
541
  export async function collectDisk(): Promise<DiskData[]> {
508
- const raw = (await run('df -h')).trim();
509
- const lines = raw.split('\n').slice(1);
542
+ const [rawDf, ioRates] = await Promise.all([
543
+ run('df -h'),
544
+ getDiskIoRates(),
545
+ ]);
546
+ const lines = rawDf.trim().split('\n').slice(1);
510
547
  return lines
511
548
  .map(line => {
512
549
  const parts = line.split(/\s+/);
@@ -517,6 +554,8 @@ export async function collectDisk(): Promise<DiskData[]> {
517
554
  available: parts[3],
518
555
  capacity: parts[4],
519
556
  mountpoint: parts[5] || parts[6] || '/',
557
+ readBytesSec: ioRates.readBytesSec,
558
+ writeBytesSec: ioRates.writeBytesSec,
520
559
  };
521
560
  })
522
561
  .filter(d => d.filesystem.includes('/') || d.size.includes('G') || d.size.includes('T'));
@@ -112,6 +112,8 @@ export interface DiskData {
112
112
  available: string;
113
113
  capacity: string;
114
114
  mountpoint: string;
115
+ readBytesSec?: number;
116
+ writeBytesSec?: number;
115
117
  }
116
118
 
117
119
  export interface NetworkData {
@@ -34,7 +34,9 @@ export interface PyreConfig {
34
34
  p2pPassword?: string;
35
35
  splashEnabled?: boolean;
36
36
  splashColorScheme?: string;
37
- splashAnimation?: string;
37
+ watchdogProcess?: string;
38
+ watchdogCpu?: number;
39
+ watchdogMem?: number;
38
40
  visiblePanels?: {
39
41
  cpu?: boolean;
40
42
  mem?: boolean;
@@ -64,6 +66,9 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
64
66
  mouseEnabled: true,
65
67
  cpuAlertPct: 90,
66
68
  tempAlertC: 95,
69
+ watchdogProcess: '',
70
+ watchdogCpu: 80,
71
+ watchdogMem: 80,
67
72
  notificationsEnabled: true,
68
73
  p2pPort: 9876,
69
74
  p2pPassword: 'pyre',