paseo-plugin-machine-status 0.1.1

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.
@@ -0,0 +1,312 @@
1
+ import os from "node:os";
2
+ import type { MetricsSchema } from "../shared/contracts";
3
+ import { run, tryRun } from "./exec";
4
+ import type { z } from "zod";
5
+
6
+ type Metrics = z.output<typeof MetricsSchema>;
7
+ type HistorySample = Metrics["history"][number];
8
+ type Power = Metrics["power"];
9
+ type Thermal = Metrics["thermal"];
10
+ type Disk = Metrics["disk"];
11
+ type Swap = Metrics["swap"];
12
+
13
+ /* -------------------------------------------------------------------------- */
14
+ /* Parsers — pure functions over raw command output, covered by tests/ */
15
+ /* -------------------------------------------------------------------------- */
16
+
17
+ export interface VmStat {
18
+ pageSize: number;
19
+ pages: Record<string, number>;
20
+ }
21
+
22
+ /**
23
+ * `vm_stat` prints "Key: 12345." lines, with a few keys wrapped in quotes.
24
+ * The page size only appears in the header, and it is 16 KiB on Apple Silicon
25
+ * rather than the 4 KiB most examples assume.
26
+ */
27
+ export function parseVmStat(raw: string): VmStat {
28
+ const header = raw.match(/page size of (\d+) bytes/);
29
+ const pageSize = header ? Number(header[1]) : 4096;
30
+ const pages: Record<string, number> = {};
31
+ for (const line of raw.split("\n")) {
32
+ const match = line.match(/^"?([^":]+)"?:\s+(\d+)\.?\s*$/);
33
+ if (!match) continue;
34
+ pages[match[1].trim().toLowerCase()] = Number(match[2]);
35
+ }
36
+ return { pageSize, pages };
37
+ }
38
+
39
+ const SIZE_MULTIPLIER: Record<string, number> = {
40
+ "": 1,
41
+ B: 1,
42
+ K: 1024,
43
+ M: 1024 ** 2,
44
+ G: 1024 ** 3,
45
+ T: 1024 ** 4,
46
+ };
47
+
48
+ /** Reads `vm.swapusage: total = 2048.00M used = 1187.94M free = 860.06M`. */
49
+ export function parseSwapUsage(raw: string): Swap {
50
+ const field = (key: string): number => {
51
+ const match = raw.match(new RegExp(`${key}\\s*=\\s*([\\d.]+)\\s*([BKMGT]?)`, "i"));
52
+ if (!match) return 0;
53
+ return Number(match[1]) * (SIZE_MULTIPLIER[match[2].toUpperCase()] ?? 1);
54
+ };
55
+ return { totalBytes: field("total"), usedBytes: field("used"), freeBytes: field("free") };
56
+ }
57
+
58
+ /**
59
+ * Reads `df -k -P` output. POSIX mode is required: without it macOS wraps long
60
+ * device names onto a second line and the columns no longer line up.
61
+ * The percentage comes from df's Capacity column because on APFS the container
62
+ * is shared between volumes and used/total does not describe usable space.
63
+ */
64
+ export function parseDf(raw: string): Disk | null {
65
+ for (const line of raw.split("\n").slice(1)) {
66
+ const match = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.*\S)\s*$/);
67
+ if (!match) continue;
68
+ return {
69
+ mountedOn: match[6],
70
+ totalBytes: Number(match[2]) * 1024,
71
+ usedBytes: Number(match[3]) * 1024,
72
+ freeBytes: Number(match[4]) * 1024,
73
+ usedPercent: Number(match[5]),
74
+ };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Reads `pmset -g batt`. The state segment sits between the percentage and the
81
+ * trailing "present:" marker, and can itself contain a semicolon
82
+ * ("AC attached; not charging"), so it is captured as a whole.
83
+ */
84
+ export function parseBattery(raw: string): Power {
85
+ const drawing = raw.match(/Now drawing from '([^']+)'/i);
86
+ const source: Power["source"] = drawing
87
+ ? /ac\s*power/i.test(drawing[1])
88
+ ? "ac"
89
+ : "battery"
90
+ : "unknown";
91
+
92
+ const percentMatch = raw.match(/(\d+)%/);
93
+ const stateMatch = raw.match(/\d+%;\s*(.+?)(?:\s+present:.*)?$/m);
94
+ const timeMatch = raw.match(/(\d+):(\d{2})\s+remaining/);
95
+
96
+ return {
97
+ source,
98
+ percent: percentMatch ? Number(percentMatch[1]) : null,
99
+ state: stateMatch ? stateMatch[1].trim() : null,
100
+ timeRemainingMinutes: timeMatch ? Number(timeMatch[1]) * 60 + Number(timeMatch[2]) : null,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Reads `pmset -g therm`. A healthy machine reports nothing at all
106
+ * ("No thermal warning level has been recorded"), which is reported as
107
+ * "unknown" rather than pretending the reading was a nominal one.
108
+ */
109
+ export function parseThermal(raw: string): Thermal {
110
+ const limitMatch = raw.match(/CPU_Speed_Limit\s*=\s*(\d+)/i);
111
+ if (!limitMatch) return { level: "unknown", cpuSpeedLimitPercent: null };
112
+ const cpuSpeedLimitPercent = Number(limitMatch[1]);
113
+ const level: Thermal["level"] =
114
+ cpuSpeedLimitPercent >= 100
115
+ ? "nominal"
116
+ : cpuSpeedLimitPercent >= 75
117
+ ? "fair"
118
+ : cpuSpeedLimitPercent >= 50
119
+ ? "serious"
120
+ : "critical";
121
+ return { level, cpuSpeedLimitPercent };
122
+ }
123
+
124
+ /** Reads `memory_pressure -Q`, macOS's own free-memory figure. */
125
+ export function parseMemoryPressure(raw: string): number | null {
126
+ const match = raw.match(/free percentage:\s*(\d+)%/i);
127
+ return match ? Number(match[1]) : null;
128
+ }
129
+
130
+ export function buildMemory(
131
+ vmStat: VmStat,
132
+ totalBytes: number,
133
+ freePercent: number | null,
134
+ ): Metrics["memory"] {
135
+ const pageBytes = (key: string): number => (vmStat.pages[key] ?? 0) * vmStat.pageSize;
136
+ const appBytes = Math.max(0, pageBytes("anonymous pages") - pageBytes("pages purgeable"));
137
+ const wiredBytes = pageBytes("pages wired down");
138
+ const compressedBytes = pageBytes("pages occupied by compressor");
139
+ return {
140
+ totalBytes,
141
+ appBytes,
142
+ wiredBytes,
143
+ compressedBytes,
144
+ cachedBytes: pageBytes("file-backed pages"),
145
+ usedBytes: appBytes + wiredBytes + compressedBytes,
146
+ freePercent,
147
+ pressurePercent: freePercent === null ? null : 100 - freePercent,
148
+ };
149
+ }
150
+
151
+ /* -------------------------------------------------------------------------- */
152
+ /* Rolling history */
153
+ /* -------------------------------------------------------------------------- */
154
+
155
+ export const HISTORY_LIMIT = 90;
156
+
157
+ /**
158
+ * History accumulates as a side effect of being read. There is deliberately no
159
+ * background timer: a plugin that samples forever would keep the machine awake
160
+ * to report on how busy the machine is. The cost is that history only covers the
161
+ * time the panel was open, which is why the chart caption reports the real span
162
+ * between its first and last sample instead of claiming a fixed window.
163
+ */
164
+ const history: HistorySample[] = [];
165
+
166
+ export function appendHistory(sample: HistorySample, limit = HISTORY_LIMIT): HistorySample[] {
167
+ history.push(sample);
168
+ if (history.length > limit) history.splice(0, history.length - limit);
169
+ return history;
170
+ }
171
+
172
+ export function resetHistory(): void {
173
+ history.length = 0;
174
+ }
175
+
176
+ export type Extent = {
177
+ min: number;
178
+ max: number;
179
+ avg: number;
180
+ last: number;
181
+ };
182
+
183
+ function extentOf(values: readonly number[]): Extent | null {
184
+ if (values.length === 0) return null;
185
+ return {
186
+ min: Math.min(...values),
187
+ max: Math.max(...values),
188
+ avg: values.reduce((sum, value) => sum + value, 0) / values.length,
189
+ last: values[values.length - 1],
190
+ };
191
+ }
192
+
193
+ export type HistorySummary = {
194
+ samples: number;
195
+ fromAt: string | null;
196
+ toAt: string | null;
197
+ spanSeconds: number;
198
+ cpuLoadPercent: Extent | null;
199
+ pressurePercent: Extent | null;
200
+ memoryUsedBytes: Extent | null;
201
+ swapUsedBytes: Extent | null;
202
+ };
203
+
204
+ /**
205
+ * Reduces a series to what a reader needs to judge it. min/max/avg against the
206
+ * current value is what separates "spiking now" from "pinned for minutes" — the
207
+ * distinction a single snapshot cannot make.
208
+ */
209
+ export function summarizeHistory(samples: readonly HistorySample[]): HistorySummary {
210
+ const first = samples[0];
211
+ const last = samples[samples.length - 1];
212
+ const span =
213
+ first && last ? Math.max(0, Math.round((Date.parse(last.at) - Date.parse(first.at)) / 1000)) : 0;
214
+
215
+ return {
216
+ samples: samples.length,
217
+ fromAt: first?.at ?? null,
218
+ toAt: last?.at ?? null,
219
+ spanSeconds: span,
220
+ cpuLoadPercent: extentOf(samples.map((sample) => sample.cpuLoadPercent)),
221
+ pressurePercent: extentOf(
222
+ samples.filter((sample) => sample.pressurePercent !== null).map((sample) => sample.pressurePercent as number),
223
+ ),
224
+ memoryUsedBytes: extentOf(
225
+ samples.map((sample) => sample.appBytes + sample.wiredBytes + sample.compressedBytes),
226
+ ),
227
+ swapUsedBytes: extentOf(samples.map((sample) => sample.swapUsedBytes)),
228
+ };
229
+ }
230
+
231
+ /** Evenly spaced picks, always including the first and last sample. */
232
+ export function downsample<T>(samples: readonly T[], count: number): T[] {
233
+ if (count <= 0) return [];
234
+ if (samples.length <= count) return [...samples];
235
+ if (count === 1) return [samples[samples.length - 1]];
236
+ const step = (samples.length - 1) / (count - 1);
237
+ return Array.from({ length: count }, (_, index) => samples[Math.round(index * step)]);
238
+ }
239
+
240
+ export function toHistorySample(metrics: Omit<Metrics, "history">): HistorySample {
241
+ return {
242
+ at: metrics.collectedAt,
243
+ cpuLoadPercent: metrics.cpu.loadPercent,
244
+ pressurePercent: metrics.memory.pressurePercent,
245
+ appBytes: metrics.memory.appBytes,
246
+ wiredBytes: metrics.memory.wiredBytes,
247
+ compressedBytes: metrics.memory.compressedBytes,
248
+ swapUsedBytes: metrics.swap.usedBytes,
249
+ };
250
+ }
251
+
252
+ /* -------------------------------------------------------------------------- */
253
+ /* Collector */
254
+ /* -------------------------------------------------------------------------- */
255
+
256
+ const EMPTY_DISK: Disk = {
257
+ mountedOn: "/",
258
+ totalBytes: 0,
259
+ usedBytes: 0,
260
+ freeBytes: 0,
261
+ usedPercent: 0,
262
+ };
263
+
264
+ export async function readMetricsHandler(): Promise<Metrics> {
265
+ const snapshot = await collect();
266
+ return { ...snapshot, history: [...appendHistory(toHistorySample(snapshot))] };
267
+ }
268
+
269
+ async function collect(): Promise<Omit<Metrics, "history">> {
270
+ if (process.platform !== "darwin") {
271
+ throw new Error(
272
+ `machine-status collects metrics through macOS tools; this daemon runs on ${process.platform}.`,
273
+ );
274
+ }
275
+
276
+ const [vmStatRaw, swapRaw, dfRaw, batteryRaw, thermalRaw, pressureRaw] = await Promise.all([
277
+ run("/usr/bin/vm_stat", []),
278
+ run("/usr/sbin/sysctl", ["vm.swapusage"]),
279
+ run("/bin/df", ["-k", "-P", "/"]),
280
+ tryRun("/usr/bin/pmset", ["-g", "batt"]),
281
+ tryRun("/usr/bin/pmset", ["-g", "therm"]),
282
+ tryRun("/usr/bin/memory_pressure", ["-Q"]),
283
+ ]);
284
+
285
+ const cores = os.cpus().length || 1;
286
+ const [loadAvg1, loadAvg5, loadAvg15] = os.loadavg();
287
+
288
+ return {
289
+ collectedAt: new Date().toISOString(),
290
+ hostname: os.hostname(),
291
+ platform: `${os.type()} ${os.release()} (${os.arch()})`,
292
+ uptimeSeconds: os.uptime(),
293
+ cpu: {
294
+ cores,
295
+ loadAvg1,
296
+ loadAvg5,
297
+ loadAvg15,
298
+ loadPercent: (loadAvg1 / cores) * 100,
299
+ },
300
+ memory: buildMemory(
301
+ parseVmStat(vmStatRaw),
302
+ os.totalmem(),
303
+ pressureRaw ? parseMemoryPressure(pressureRaw) : null,
304
+ ),
305
+ swap: parseSwapUsage(swapRaw),
306
+ disk: parseDf(dfRaw) ?? EMPTY_DISK,
307
+ power: batteryRaw
308
+ ? parseBattery(batteryRaw)
309
+ : { source: "unknown", percent: null, state: null, timeRemainingMinutes: null },
310
+ thermal: thermalRaw ? parseThermal(thermalRaw) : { level: "unknown", cpuSpeedLimitPercent: null },
311
+ };
312
+ }
@@ -0,0 +1,165 @@
1
+ import path from "node:path";
2
+ import type { z } from "zod";
3
+ import type { killProcess, listProcesses, ProcessSchema } from "../shared/contracts";
4
+ import { describeError, run } from "./exec";
5
+
6
+ type ProcessEntry = z.output<typeof ProcessSchema>;
7
+ type ListInput = z.output<typeof listProcesses.input>;
8
+ type ListOutput = z.output<typeof listProcesses.output>;
9
+ type KillInput = z.output<typeof killProcess.input>;
10
+ type KillOutput = z.output<typeof killProcess.output>;
11
+
12
+ export interface RawProcess {
13
+ pid: number;
14
+ ppid: number;
15
+ cpuPercent: number;
16
+ memoryBytes: number;
17
+ command: string;
18
+ }
19
+
20
+ /**
21
+ * Reads `ps -Ao pid=,ppid=,pcpu=,rss=,comm=`. The command column is last and
22
+ * keeps its spaces ("Paseo Daemon", "/Applications/...app/Contents/MacOS/..."),
23
+ * so only the four leading numeric columns are split off and the remainder is
24
+ * taken whole.
25
+ */
26
+ export function parsePsTable(raw: string): RawProcess[] {
27
+ const items: RawProcess[] = [];
28
+ for (const line of raw.split("\n")) {
29
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+?)\s*$/);
30
+ if (!match) continue;
31
+ items.push({
32
+ pid: Number(match[1]),
33
+ ppid: Number(match[2]),
34
+ cpuPercent: Number(match[3]),
35
+ memoryBytes: Number(match[4]) * 1024,
36
+ command: match[5],
37
+ });
38
+ }
39
+ return items;
40
+ }
41
+
42
+ /**
43
+ * Walks the parent chain from `pid` upwards, including `pid` itself. The visited
44
+ * set doubles as cycle protection: a reparented process can otherwise produce a
45
+ * loop in a snapshot taken while the table was changing.
46
+ */
47
+ export function ancestorsOf(pid: number, table: readonly RawProcess[]): Set<number> {
48
+ const byPid = new Map(table.map((entry) => [entry.pid, entry]));
49
+ const chain = new Set<number>();
50
+ let current = byPid.get(pid);
51
+ while (current && !chain.has(current.pid)) {
52
+ chain.add(current.pid);
53
+ current = byPid.get(current.ppid);
54
+ }
55
+ return chain;
56
+ }
57
+
58
+ /**
59
+ * PIDs this plugin must never signal. The plugin runs as a child of the Paseo
60
+ * daemon, so its own ancestor chain is exactly the set of processes whose death
61
+ * would take Paseo — and this plugin — down with it.
62
+ */
63
+ export function guardedPids(table: readonly RawProcess[], selfPid: number): Set<number> {
64
+ const guarded = ancestorsOf(selfPid, table);
65
+ guarded.add(0);
66
+ guarded.add(1);
67
+ return guarded;
68
+ }
69
+
70
+ /**
71
+ * `ps -o etime=` prints elapsed time as `MM:SS`, `HH:MM:SS` or `DD-HH:MM:SS`.
72
+ * The distinction decides whether a busy process is a passing scan or something
73
+ * that has been pinned at full CPU for a day.
74
+ */
75
+ export function parseElapsedSeconds(value: string): number | null {
76
+ const match = value.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
77
+ if (!match) return null;
78
+ const [, days, hours, minutes, seconds] = match;
79
+ return (
80
+ Number(days ?? 0) * 86_400 +
81
+ Number(hours ?? 0) * 3_600 +
82
+ Number(minutes) * 60 +
83
+ Number(seconds)
84
+ );
85
+ }
86
+
87
+ export function processName(command: string): string {
88
+ const base = path.basename(command);
89
+ return base.length > 0 ? base : command;
90
+ }
91
+
92
+ export function toEntries(table: readonly RawProcess[], guarded: ReadonlySet<number>): ProcessEntry[] {
93
+ return table.map((entry) => ({
94
+ ...entry,
95
+ name: processName(entry.command),
96
+ guarded: guarded.has(entry.pid),
97
+ }));
98
+ }
99
+
100
+ export function sortEntries(entries: ProcessEntry[], sortBy: "cpu" | "memory"): ProcessEntry[] {
101
+ return [...entries].sort((left, right) =>
102
+ sortBy === "cpu"
103
+ ? right.cpuPercent - left.cpuPercent
104
+ : right.memoryBytes - left.memoryBytes,
105
+ );
106
+ }
107
+
108
+ async function readTable(): Promise<RawProcess[]> {
109
+ return parsePsTable(await run("/bin/ps", ["-Ao", "pid=,ppid=,pcpu=,rss=,comm="], 10_000));
110
+ }
111
+
112
+ function isAlive(pid: number): boolean {
113
+ try {
114
+ // Signal 0 performs the permission and existence check without delivering anything.
115
+ process.kill(pid, 0);
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
123
+
124
+ export async function listProcessesHandler({ sortBy, limit }: ListInput): Promise<ListOutput> {
125
+ const table = await readTable();
126
+ const entries = toEntries(table, guardedPids(table, process.pid));
127
+ return {
128
+ collectedAt: new Date().toISOString(),
129
+ items: sortEntries(entries, sortBy).slice(0, limit),
130
+ };
131
+ }
132
+
133
+ export async function killProcessHandler({ pid, signal }: KillInput): Promise<KillOutput> {
134
+ // The guard is recomputed from a fresh table on every call. The client's view
135
+ // may be stale, and a PID it believes is safe may have been reused since.
136
+ const table = await readTable();
137
+ const guarded = guardedPids(table, process.pid);
138
+ const target = table.find((entry) => entry.pid === pid);
139
+
140
+ const refuse = (error: string): KillOutput => ({
141
+ ok: false,
142
+ pid,
143
+ signal,
144
+ stillRunning: target !== undefined,
145
+ error,
146
+ });
147
+
148
+ if (guarded.has(pid)) {
149
+ return refuse("refused: this process is Paseo itself or one of its parents");
150
+ }
151
+ if (!target) {
152
+ return refuse("no such process");
153
+ }
154
+
155
+ try {
156
+ process.kill(pid, signal === "KILL" ? "SIGKILL" : "SIGTERM");
157
+ } catch (error) {
158
+ console.error(`[machine-status] failed to signal ${pid}`, error);
159
+ return refuse(describeError(error));
160
+ }
161
+
162
+ // SIGTERM is a request. Report honestly whether the process actually went away.
163
+ await wait(400);
164
+ return { ok: true, pid, signal, stillRunning: isAlive(pid), error: null };
165
+ }