metwatch 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -17,8 +17,8 @@
17
17
  - **CPU panel** — overall usage percentage + per-core bars, color-coded by load
18
18
  - **Memory panel** — RAM used / free / cached / total with visual gauge
19
19
  - **Disk panel** — per-mount usage bars with read/write IO rates
20
- - **Network panel** — realtime RX/TX throughput graph (last 30 s) + per-interface stats
21
- - **Process table** — all system processes sorted by CPU or memory; dual-mode (All / Watched)
20
+ - **Network panel** — realtime RX/TX throughput sparkline (last 60 s) + per-interface stats
21
+ - **Process table** — all system processes sorted by CPU or memory; dual-mode (All / Watched); viewport-capped (no terminal scroll)
22
22
  - **Managed processes** — launch scripts with `mw start`, get auto-restart with exponential back-off
23
23
  - **Runtime metrics** — Node.js / Bun heap, RSS, event-loop lag and GC stats via Chrome DevTools Protocol
24
24
  - **Log streaming** — stdout/stderr of managed processes buffered and displayed in the Logs panel
@@ -252,7 +252,7 @@ MetWatch reads `metwatch.config.json` from the current working directory at star
252
252
  | **CPU** | always on | Overall CPU% + per-core bars colored by load |
253
253
  | **Memory** | always on | RAM used / free / cached / total with gauge |
254
254
  | **Disk** | `d` | Per-mount usage bars + read/write IO rates (MB/s) |
255
- | **Network** | `n` | Per-interface RX/TX rates + 30-second throughput graph |
255
+ | **Network** | `n` | Per-interface RX/TX rates + 60-second throughput sparkline |
256
256
  | **Runtime** | `R` | Heap, RSS, event-loop lag, GC stats for managed processes |
257
257
  | **Processes** | `p` | Scrollable process table — All or Watched mode |
258
258
  | **Logs** | `l` (focus) | Buffered stdout/stderr of managed processes |
@@ -311,9 +311,9 @@ Contributions are welcome! Here is how to get started:
311
311
  ### Code conventions (summary)
312
312
 
313
313
  - **TypeScript strict mode** — no `any`, no `@ts-ignore` without a comment
314
- - **Factory functions** over classes — use `create*()` returning plain objects
315
- - **Event bus** — every `bus.on()` must store and call its unsubscribe in `destroy()`
316
- - **Widgets are stateless renderers** — no domain data, only UI state
314
+ - **Factory functions** over classes — managers use `create*()` returning plain objects
315
+ - **React components** — widgets are `.tsx` files using `useState`/`useEffect` hooks; `useInput` for keyboard handling
316
+ - **Event bus** — every `bus.on()` must store and call its unsubscribe in the `useEffect` cleanup
317
317
  - **Layer rules** — widgets never import from `services/` directly; all data via bus + state
318
318
  - **No `.then()` chains** — use `async/await` everywhere
319
319
 
package/index.ts CHANGED
@@ -3,40 +3,38 @@
3
3
  //
4
4
  // Initialization order:
5
5
  // 1. Load config
6
- // 2. Create blessed screen
7
- // 3. Create launcher + log-manager + runtime-manager (if managed procs)
8
- // 4. Wire launcher runtime-manager (register inspector on start)
9
- // 5. Build layout (widgets register bus subscriptions)
10
- // 6. Start metrics + process managers
11
- // 7. Launcher starts all managed processes
12
- // 8. Register global keybindings + signal handlers
13
- // 9. First screen render
6
+ // 2. Create launcher + log-manager + runtime-manager (if managed procs)
7
+ // 3. Wire launcher runtime-manager (register inspector on start)
8
+ // 4. Start metrics + process managers
9
+ // 5. Launcher starts all managed processes
10
+ // 6. Render ink/React app
11
+ // 7. Register signal handlers
14
12
  //
15
13
  // Teardown (quit):
16
14
  // 1. Stop polling managers + runtime-manager
17
15
  // 2. Stop all managed processes
18
- // 3. Destroy layout (widgets unsubscribe)
19
- // 4. Destroy screen (restore terminal)
20
- // 5. process.exit(0)
16
+ // 3. Unmount ink app
17
+ // 4. process.exit(0)
21
18
  // ---------------------------------------------------------------------------
22
19
 
23
20
  import { readFileSync, existsSync } from 'fs';
24
21
  import { resolve } from 'path';
25
-
26
- import { createScreen, destroyScreen } from './src/ui/screen.ts';
27
- import { buildLayout } from './src/ui/layout.ts';
28
- import { createMetricsManager } from './src/core/metrics-manager.ts';
29
- import { createProcessManager } from './src/core/process-manager.ts';
30
- import { createLauncher } from './src/core/launcher.ts';
31
- import { createLogManager } from './src/core/log-manager.ts';
32
- import { createRuntimeManager } from './src/core/runtime-manager.ts';
33
- import { bus } from './src/core/event-bus.ts';
22
+ import { render } from 'ink';
23
+ import React from 'react';
24
+
25
+ import { createMetricsManager } from './src/core/metrics-manager.ts';
26
+ import { createProcessManager } from './src/core/process-manager.ts';
27
+ import { createLauncher } from './src/core/launcher.ts';
28
+ import { createLogManager } from './src/core/log-manager.ts';
29
+ import { createRuntimeManager } from './src/core/runtime-manager.ts';
30
+ import { bus } from './src/core/event-bus.ts';
34
31
  import {
35
32
  DEFAULT_CONFIG,
36
33
  type MetWatchConfig,
37
34
  type ResolvedConfig,
38
35
  } from './src/types/config.types.ts';
39
- import type { ManagedProcessDef } from './src/types/managed-process.types.ts';
36
+ import type { ManagedProcessDef } from './src/types/managed-process.types.ts';
37
+ import { App } from './src/ui/App.tsx';
40
38
 
41
39
  // ── Config loading ──────────────────────────────────────────────────────────
42
40
 
@@ -84,53 +82,60 @@ export async function main(extraDefs: ManagedProcessDef[] = []): Promise<void> {
84
82
  const logManager = hasManaged ? createLogManager(config.logScrollback ?? 500) : null;
85
83
  const runtimeManager = hasManaged ? createRuntimeManager(config.refreshInterval) : null;
86
84
 
87
- // Wire launcher events to runtime-manager so we get inspector connections
88
- // automatically whenever a managed process starts or stops.
89
- const unsubStarted = hasManaged ? bus.on('managed:started', ({ id, pid }) => {
90
- // The launcher appends --inspect=0; the WS URL is reported on stderr.
91
- // We receive it via the log:line event below.
92
- void id; void pid;
93
- }) : () => undefined;
94
-
95
- // Parse inspector URL from log lines (Node/Bun print it to stderr on start)
85
+ // Wire launcher events to runtime-manager
96
86
  const inspectorUrls = new Map<string, string>();
97
- const unsubLogLine = hasManaged ? bus.on('log:line', ({ id, stream, line }) => {
98
- if (stream !== 'stderr') return;
99
- // e.g.: "Debugger listening on ws://127.0.0.1:9229/uuid"
100
- const m = line.match(/Debugger listening on (ws:\/\/[^\s]+)/);
101
- if (m && m[1] && !inspectorUrls.has(id)) {
102
- inspectorUrls.set(id, m[1]);
103
- // Find the pid from the latest managed:started event via launcher
104
- const proc = launcher?.get(id);
105
- if (proc?.pid) {
106
- runtimeManager?.register(id, proc.pid, m[1]);
87
+ const managedCount = mergedDefs.length;
88
+ let unsubLogLine: (() => void) | null = null;
89
+
90
+ if (hasManaged) {
91
+ unsubLogLine = bus.on('log:line', ({ id, stream, line }) => {
92
+ if (stream !== 'stderr') return;
93
+ if (inspectorUrls.has(id)) return;
94
+ const m = line.match(/Debugger listening on (ws:\/\/[^\s]+)/);
95
+ if (m && m[1]) {
96
+ inspectorUrls.set(id, m[1]);
97
+ const proc = launcher?.get(id);
98
+ if (proc?.pid) {
99
+ runtimeManager?.register(id, proc.pid, m[1]);
100
+ }
101
+ if (inspectorUrls.size >= managedCount && unsubLogLine) {
102
+ unsubLogLine();
103
+ unsubLogLine = null;
104
+ }
107
105
  }
108
- }
109
- }) : () => undefined;
106
+ });
107
+ }
110
108
 
111
- const unsubStopped = hasManaged ? bus.on('managed:stopped', ({ id }) => {
109
+ const unsubStopped = hasManaged ? bus.on('managed:stopped', ({ id }) => {
112
110
  runtimeManager?.unregister(id);
113
111
  inspectorUrls.delete(id);
114
112
  }) : () => undefined;
115
113
 
116
- const unsubCrashed = hasManaged ? bus.on('managed:crashed', ({ id }) => {
114
+ const unsubCrashed = hasManaged ? bus.on('managed:crashed', ({ id }) => {
117
115
  runtimeManager?.unregister(id);
118
116
  inspectorUrls.delete(id);
119
117
  }) : () => undefined;
120
118
 
121
- const screen = createScreen();
122
- const layout = buildLayout({ screen, config, launcher, logManager });
123
-
124
119
  const metricsManager = createMetricsManager({ intervalMs: config.refreshInterval });
125
120
  const processManager = createProcessManager(config);
126
121
 
127
122
  metricsManager.start();
128
123
  processManager.start();
129
124
 
130
- // Start managed processes after widgets are ready
131
125
  launcher?.startAll();
132
126
 
133
- // ── Global keybindings ────────────────────────────────────────────────────
127
+ // ── Error handling ────────────────────────────────────────────────────────
128
+
129
+ const unsubError = bus.on('app:error', ({ source, error }) => {
130
+ process.stderr.write(`[MetWatch] error [${source}]: ${error.message}\n`);
131
+ });
132
+
133
+ process.on('unhandledRejection', (reason) => {
134
+ const msg = reason instanceof Error ? reason.message : String(reason);
135
+ process.stderr.write(`[MetWatch] unhandledRejection: ${msg}\n`);
136
+ });
137
+
138
+ // ── Teardown ──────────────────────────────────────────────────────────────
134
139
 
135
140
  function quit(): void {
136
141
  bus.emit('ui:quit', undefined);
@@ -139,33 +144,26 @@ export async function main(extraDefs: ManagedProcessDef[] = []): Promise<void> {
139
144
  runtimeManager?.stop();
140
145
  launcher?.stopAll();
141
146
  logManager?.destroy();
142
- unsubStarted();
143
- unsubLogLine();
147
+ unsubLogLine?.();
144
148
  unsubStopped();
145
149
  unsubCrashed();
146
- layout.destroy();
147
- destroyScreen();
150
+ unsubError();
151
+ unmount();
148
152
  process.exit(0);
149
153
  }
150
154
 
151
- screen.key(['q', 'C-c'], quit);
152
-
153
- // ── Error handling ────────────────────────────────────────────────────────
154
-
155
- bus.on('app:error', ({ source, error }) => {
156
- void source;
157
- void error;
158
- });
159
-
160
- // ── First render ──────────────────────────────────────────────────────────
155
+ // ── Render ────────────────────────────────────────────────────────────────
161
156
 
162
- screen.render();
157
+ const { unmount } = render(
158
+ React.createElement(App, { config, launcher, logManager, onQuit: quit }),
159
+ { exitOnCtrlC: false }
160
+ );
163
161
 
164
162
  // ── Signal handling ───────────────────────────────────────────────────────
165
163
 
166
164
  process.on('SIGTERM', quit);
167
165
  process.on('uncaughtException', (err) => {
168
- destroyScreen();
166
+ unmount();
169
167
  console.error('[MetWatch] Uncaught exception:', err);
170
168
  process.exit(1);
171
169
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metwatch",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Terminal process monitoring & management tool — like htop + PM2 in your terminal",
5
5
  "module": "index.ts",
6
6
  "main": "index.ts",
@@ -35,16 +35,16 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "latest",
38
- "@types/node": "^25.8.0"
38
+ "@types/node": "^25.8.0",
39
+ "@types/react": "^18.3.23"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "typescript": "^5"
42
43
  },
43
44
  "dependencies": {
44
- "blessed": "^0.1.81",
45
- "blessed-contrib": "^4.11.0",
46
- "chalk": "^5.6.2",
45
+ "ink": "^5.1.0",
47
46
  "pidusage": "^4.0.1",
47
+ "react": "^18.3.1",
48
48
  "systeminformation": "^5.31.6"
49
49
  }
50
50
  }
@@ -22,6 +22,7 @@ interface ProcessManagerHandle {
22
22
 
23
23
  export function createProcessManager(config: ResolvedConfig): ProcessManagerHandle {
24
24
  let timer: ReturnType<typeof setInterval> | null = null;
25
+ let startTimer: ReturnType<typeof setTimeout> | null = null;
25
26
  let running = false;
26
27
 
27
28
  // Listen for kill requests from the UI
@@ -61,18 +62,21 @@ export function createProcessManager(config: ResolvedConfig): ProcessManagerHand
61
62
  function start(): void {
62
63
  if (running) return;
63
64
  running = true;
64
- void tick();
65
- timer = setInterval(() => void tick(), config.refreshInterval);
65
+ // Stagger 200ms behind metrics-manager to avoid simultaneous event-loop saturation.
66
+ // Use the metrics interval so process polls never coincide with metrics polls.
67
+ startTimer = setTimeout(() => {
68
+ startTimer = null;
69
+ void tick();
70
+ timer = setInterval(() => void tick(), config.refreshInterval * 2);
71
+ }, 200);
66
72
  }
67
73
 
68
74
  function stop(): void {
69
75
  if (!running) return;
70
76
  running = false;
71
77
  unsubKill();
72
- if (timer !== null) {
73
- clearInterval(timer);
74
- timer = null;
75
- }
78
+ if (startTimer !== null) { clearTimeout(startTimer); startTimer = null; }
79
+ if (timer !== null) { clearInterval(timer); timer = null; }
76
80
  }
77
81
 
78
82
  return { start, stop };
@@ -119,13 +119,21 @@ function cdpCall<T>(
119
119
  ): Promise<T> {
120
120
  return new Promise((resolve, reject) => {
121
121
  const id = session.id++;
122
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
122
123
  session.pending.set(id, {
123
- resolve: resolve as (v: unknown) => void,
124
- reject,
124
+ resolve: (v) => {
125
+ if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; }
126
+ (resolve as (v: unknown) => void)(v);
127
+ },
128
+ reject: (e) => {
129
+ if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; }
130
+ reject(e);
131
+ },
125
132
  });
126
133
  session.ws.send(JSON.stringify({ id, method, params }));
127
134
  // Timeout individual calls to avoid hanging
128
- setTimeout(() => {
135
+ timeoutHandle = setTimeout(() => {
136
+ timeoutHandle = null;
129
137
  if (session.pending.has(id)) {
130
138
  session.pending.delete(id);
131
139
  reject(new Error(`CDP timeout: ${method}`));
@@ -228,6 +236,7 @@ async function pollProcess(
228
236
  export function createRuntimeManager(intervalMs: number): RuntimeManagerHandle {
229
237
  const sessions = new Map<string, ProcessSession>();
230
238
  let timer: ReturnType<typeof setInterval> | null = null;
239
+ let warmupTimer: ReturnType<typeof setTimeout> | null = null;
231
240
 
232
241
  async function tick(): Promise<void> {
233
242
  for (const ps of sessions.values()) {
@@ -241,9 +250,9 @@ export function createRuntimeManager(intervalMs: number): RuntimeManagerHandle {
241
250
  }
242
251
 
243
252
  function startTimer(): void {
244
- if (timer !== null) return;
245
- // Slight delay on first poll to give the inspector time to warm up
246
- setTimeout(() => {
253
+ if (timer !== null || warmupTimer !== null) return;
254
+ warmupTimer = setTimeout(() => {
255
+ warmupTimer = null;
247
256
  void tick();
248
257
  timer = setInterval(() => void tick(), intervalMs);
249
258
  }, 1500);
@@ -281,6 +290,7 @@ export function createRuntimeManager(intervalMs: number): RuntimeManagerHandle {
281
290
  }
282
291
 
283
292
  function stop(): void {
293
+ if (warmupTimer !== null) { clearTimeout(warmupTimer); warmupTimer = null; }
284
294
  if (timer !== null) { clearInterval(timer); timer = null; }
285
295
  for (const ps of sessions.values()) {
286
296
  try { ps.session?.ws.close(); } catch { /* ignore */ }
@@ -3,9 +3,9 @@
3
3
  //
4
4
  // Wraps systeminformation network APIs to produce normalized NetworkMetrics.
5
5
  //
6
- // Two si calls per tick:
7
- // si.networkInterfaces() static info (IP addresses, operstate)
8
- // si.networkStats() → cumulative byte/packet counters (delta-based)
6
+ // Per-tick call: si.networkStats() cumulative byte/packet counters (delta-based)
7
+ // Cached call: si.networkInterfaces() static info refreshed every 30 s or on
8
+ // the first call. IP addresses and operstate almost never change.
9
9
  //
10
10
  // Rates are calculated as deltas between successive calls — same approach as
11
11
  // disk.service.ts. First call returns zero rates.
@@ -30,6 +30,28 @@ interface IfaceSnapshot {
30
30
 
31
31
  const _prev = new Map<string, IfaceSnapshot>();
32
32
 
33
+ // ── Static interface cache ────────────────────────────────────────────────────
34
+ // networkInterfaces() enumerates all NICs including virtual ones — expensive.
35
+ // Cache for 30 s; refresh on expiry or when a previously-unknown iface appears.
36
+
37
+ const IFACE_CACHE_TTL = 30_000;
38
+ let _ifaceCache: Map<string, { ip4: string; ip6: string; operstate: string }> = new Map();
39
+ let _ifaceCacheTs = 0;
40
+
41
+ async function getStaticIfaceMap(): Promise<Map<string, { ip4: string; ip6: string; operstate: string }>> {
42
+ const now = Date.now();
43
+ if (now - _ifaceCacheTs < IFACE_CACHE_TTL) return _ifaceCache;
44
+
45
+ const raw = await si.networkInterfaces();
46
+ const arr = Array.isArray(raw) ? raw : [raw];
47
+ _ifaceCache = new Map(arr.map(i => [
48
+ i.iface,
49
+ { ip4: i.ip4 ?? '', ip6: i.ip6 ?? '', operstate: i.operstate ?? '' },
50
+ ]));
51
+ _ifaceCacheTs = now;
52
+ return _ifaceCache;
53
+ }
54
+
33
55
  // ── Helpers ───────────────────────────────────────────────────────────────────
34
56
 
35
57
  function operstate(s: string | undefined): 'up' | 'down' | 'unknown' {
@@ -38,20 +60,20 @@ function operstate(s: string | undefined): 'up' | 'down' | 'unknown' {
38
60
  return 'unknown';
39
61
  }
40
62
 
63
+ // ── Collator for sorting (avoids constructing one inside the comparator) ──────
64
+ const _collator = new Intl.Collator(undefined, { sensitivity: 'base' });
65
+
41
66
  // ── Fetch ─────────────────────────────────────────────────────────────────────
42
67
 
43
68
  export async function fetchNetworkMetrics(): Promise<NetworkMetrics> {
44
69
  const now = Date.now();
45
70
 
46
- const [ifaces, stats] = await Promise.all([
47
- si.networkInterfaces(),
71
+ // Stats every tick; static info from cache (refreshed every 30 s)
72
+ const [stats, staticInfo] = await Promise.all([
48
73
  si.networkStats('*'),
74
+ getStaticIfaceMap(),
49
75
  ]);
50
76
 
51
- // Build a map of static info keyed by iface name
52
- const ifaceArray = Array.isArray(ifaces) ? ifaces : [ifaces];
53
- const staticInfo = new Map(ifaceArray.map(i => [i.iface, i]));
54
-
55
77
  const statsArray = Array.isArray(stats) ? stats : [stats];
56
78
 
57
79
  const interfaces: NetworkInterface[] = [];
@@ -65,18 +87,10 @@ export async function fetchNetworkMetrics(): Promise<NetworkMetrics> {
65
87
  const prev = _prev.get(iface);
66
88
  const dtSec = prev ? Math.max(0.001, (now - prev.ts) / 1000) : 1;
67
89
 
68
- const rxBPS = prev
69
- ? Math.max(0, ((s.rx_bytes ?? 0) - prev.rx_bytes) / dtSec)
70
- : 0;
71
- const txBPS = prev
72
- ? Math.max(0, ((s.tx_bytes ?? 0) - prev.tx_bytes) / dtSec)
73
- : 0;
74
- const rxPPS = prev
75
- ? Math.max(0, ((s.rx_sec ?? 0) - prev.rx_sec) / dtSec)
76
- : 0;
77
- const txPPS = prev
78
- ? Math.max(0, ((s.tx_sec ?? 0) - prev.tx_sec) / dtSec)
79
- : 0;
90
+ const rxBPS = prev ? Math.max(0, ((s.rx_bytes ?? 0) - prev.rx_bytes) / dtSec) : 0;
91
+ const txBPS = prev ? Math.max(0, ((s.tx_bytes ?? 0) - prev.tx_bytes) / dtSec) : 0;
92
+ const rxPPS = prev ? Math.max(0, ((s.rx_sec ?? 0) - prev.rx_sec) / dtSec) : 0;
93
+ const txPPS = prev ? Math.max(0, ((s.tx_sec ?? 0) - prev.tx_sec) / dtSec) : 0;
80
94
 
81
95
  _prev.set(iface, {
82
96
  rx_bytes: s.rx_bytes ?? 0,
@@ -114,12 +128,12 @@ export async function fetchNetworkMetrics(): Promise<NetworkMetrics> {
114
128
  });
115
129
  }
116
130
 
117
- // Sort: up interfaces first, then by name
131
+ // Sort: up interfaces first, then by name using cached collator
118
132
  interfaces.sort((a, b) => {
119
133
  if (a.operstate !== b.operstate) {
120
134
  return a.operstate === 'up' ? -1 : 1;
121
135
  }
122
- return a.iface.localeCompare(b.iface);
136
+ return _collator.compare(a.iface, b.iface);
123
137
  });
124
138
 
125
139
  return {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import si from 'systeminformation';
10
10
  import type { ProcessInfo, ProcessList, ProcessStatus } from '../types/process.types.ts';
11
+ import { getMemoryMetrics } from '../core/state-manager.ts';
11
12
 
12
13
  function mapStatus(raw: string): ProcessStatus {
13
14
  switch (raw.toLowerCase()) {
@@ -20,14 +21,46 @@ function mapStatus(raw: string): ProcessStatus {
20
21
  }
21
22
  }
22
23
 
24
+ /**
25
+ * Partial selection sort — O(n * limit) but avoids copying the entire list
26
+ * and sorting it when limit << n. For limit=50, n=1000 → 50k comparisons vs
27
+ * 10k for a full sort but saves the O(n) spread allocation.
28
+ * For larger limits a min-heap approach would be O(n log limit); given our
29
+ * default maxProcesses=50 the simple selection is fast enough and GC-friendly.
30
+ */
31
+ function topKByCpu<T extends { cpu: number }>(list: T[], k: number): T[] {
32
+ const n = list.length;
33
+ const count = Math.min(k, n);
34
+ // Work on indices to avoid object allocations
35
+ const result: T[] = [];
36
+
37
+ // Track which indices have already been picked
38
+ const used = new Uint8Array(n);
39
+
40
+ for (let i = 0; i < count; i++) {
41
+ let bestIdx = -1;
42
+ let bestCpu = -1;
43
+ for (let j = 0; j < n; j++) {
44
+ if (!used[j] && list[j]!.cpu > bestCpu) {
45
+ bestCpu = list[j]!.cpu;
46
+ bestIdx = j;
47
+ }
48
+ }
49
+ if (bestIdx === -1) break;
50
+ used[bestIdx] = 1;
51
+ result.push(list[bestIdx]!);
52
+ }
53
+
54
+ return result;
55
+ }
56
+
23
57
  export async function fetchProcessList(limit = 100): Promise<ProcessList> {
24
58
  const { list } = await si.processes();
25
59
 
26
- // Sort by CPU descending before slicing so we surface the most active
27
- const sorted = [...list].sort((a, b) => b.cpu - a.cpu);
28
- const top = sorted.slice(0, limit);
60
+ // Top-K by CPU avoids full O(n log n) sort + O(n) spread for large process lists.
61
+ const top = topKByCpu(list, limit);
29
62
 
30
- const totalMem = (await si.mem()).total;
63
+ const totalMem = getMemoryMetrics()?.total ?? (await si.mem()).total;
31
64
 
32
65
  return top.map((p): ProcessInfo => ({
33
66
  pid: p.pid,
@@ -39,7 +72,10 @@ export async function fetchProcessList(limit = 100): Promise<ProcessList> {
39
72
  ? Math.round(((p.memRss ?? 0) / totalMem) * 1000) / 10
40
73
  : 0,
41
74
  status: mapStatus(p.state ?? ''),
42
- startedAt: p.started ? new Date(p.started).getTime() : null,
75
+ // Avoid Date construction: use numeric check first, then Date.parse as fallback.
76
+ startedAt: p.started
77
+ ? (typeof p.started === 'number' ? p.started : Date.parse(p.started as string) || null)
78
+ : null,
43
79
  ppid: p.parentPid ?? null,
44
80
  // ── Extended (Phase 2) ──────────────────────────────────────────────────
45
81
  user: (p as unknown as { user?: string }).user ?? '',