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,260 @@
1
+ import type { z } from "zod";
2
+ import { describeError, run, runStreams, tryRun } from "./exec";
3
+ import type {
4
+ controlContainer,
5
+ controlEngine,
6
+ pruneDocker,
7
+ readContainerLogs,
8
+ readDocker,
9
+ removeImage,
10
+ DockerContainer,
11
+ DockerEngine,
12
+ DockerImage,
13
+ DockerUsageRow,
14
+ } from "../shared/docker";
15
+
16
+ type ReadOutput = z.output<typeof readDocker.output>;
17
+ type ControlInput = z.output<typeof controlContainer.input>;
18
+ type PruneInput = z.output<typeof pruneDocker.input>;
19
+ type EngineInput = z.output<typeof controlEngine.input>;
20
+ type LogsInput = z.output<typeof readContainerLogs.input>;
21
+ type ImageInput = z.output<typeof removeImage.input>;
22
+
23
+ const DOCKER = "/usr/local/bin/docker";
24
+ const PS_FORMAT = "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}";
25
+
26
+ /**
27
+ * Docker reports sizes in SI units in `system df` (`5.874GB`) and binary units in
28
+ * `stats` (`31.39MiB`). Both appear, so both are read.
29
+ */
30
+ const UNITS: Record<string, number> = {
31
+ B: 1,
32
+ KB: 1000,
33
+ MB: 1000 ** 2,
34
+ GB: 1000 ** 3,
35
+ TB: 1000 ** 4,
36
+ KIB: 1024,
37
+ MIB: 1024 ** 2,
38
+ GIB: 1024 ** 3,
39
+ TIB: 1024 ** 4,
40
+ };
41
+
42
+ export function parseDockerSize(value: string): number {
43
+ const match = /^\s*([\d.]+)\s*([A-Za-z]*)\s*$/.exec(value);
44
+ if (!match) return 0;
45
+ return Number(match[1]) * (UNITS[match[2].toUpperCase()] ?? 1);
46
+ }
47
+
48
+ export function parseContainers(raw: string): DockerContainer[] {
49
+ const containers: DockerContainer[] = [];
50
+ for (const line of raw.split("\n")) {
51
+ const [id, name, image, state, status, ports] = line.split("\t");
52
+ if (!id || !name) continue;
53
+ const published = new Set<number>();
54
+ // "0.0.0.0:5653->5432/tcp, [::]:5653->5432/tcp" — the published side is what
55
+ // is reachable from this machine.
56
+ for (const match of (ports ?? "").matchAll(/:(\d+)->/g)) published.add(Number(match[1]));
57
+ containers.push({
58
+ id,
59
+ name,
60
+ image: image ?? "",
61
+ state: state ?? "",
62
+ status: status ?? "",
63
+ ports: [...published].sort((left, right) => left - right),
64
+ running: state === "running",
65
+ });
66
+ }
67
+ return containers;
68
+ }
69
+
70
+ const VOLUME_LABEL = "Local Volumes";
71
+
72
+ /**
73
+ * Reads `docker system df`. Volumes are marked unsafe to reclaim: they are where
74
+ * container data lives, and the biggest reclaimable number on a dev machine is
75
+ * usually a database someone still wants.
76
+ */
77
+ export function parseSystemDf(raw: string): DockerUsageRow[] {
78
+ const rows: DockerUsageRow[] = [];
79
+ for (const line of raw.split("\n").slice(1)) {
80
+ const match = /^(\S+(?: \S+)?)\s+(\d+)\s+(\d+)\s+([\d.]+\s*\w+)\s+([\d.]+\s*\w+)/.exec(line.trim());
81
+ if (!match) continue;
82
+ const label = match[1];
83
+ rows.push({
84
+ label,
85
+ total: Number(match[2]),
86
+ active: Number(match[3]),
87
+ sizeBytes: parseDockerSize(match[4]),
88
+ reclaimableBytes: parseDockerSize(match[5]),
89
+ safeToReclaim: label !== VOLUME_LABEL,
90
+ });
91
+ }
92
+ return rows;
93
+ }
94
+
95
+ export function parseImages(raw: string): DockerImage[] {
96
+ const images: DockerImage[] = [];
97
+ for (const line of raw.split("\n")) {
98
+ const [id, reference, size, created] = line.split("\t");
99
+ if (!id || !reference) continue;
100
+ images.push({
101
+ id,
102
+ reference,
103
+ sizeBytes: parseDockerSize(size ?? "0B"),
104
+ created: created ?? "",
105
+ // An untagged image is a leftover layer set, which is what prune targets.
106
+ dangling: reference.startsWith("<none>"),
107
+ });
108
+ }
109
+ return images;
110
+ }
111
+
112
+ /** The desktop apps this panel knows how to launch, in the order it prefers them. */
113
+ const ENGINE_APPS = ["Docker", "OrbStack", "Rancher Desktop"];
114
+
115
+ async function installedApp(): Promise<string | null> {
116
+ for (const app of ENGINE_APPS) {
117
+ if (await tryRun("/bin/test", ["-d", `/Applications/${app}.app`], 2_000).then((r) => r !== null)) {
118
+ return app;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Distinguishes "no engine installed" from "installed but not running". Only the
126
+ * second one is worth offering a Start button for.
127
+ */
128
+ export async function readEngine(): Promise<DockerEngine> {
129
+ const version = await tryRun(DOCKER, ["version", "--format", "{{.Server.Version}}"], 5_000);
130
+ const app = await installedApp();
131
+ if (version !== null && version.trim().length > 0) {
132
+ return { state: "running", version: version.trim(), app };
133
+ }
134
+ const cli = await tryRun("/bin/test", ["-x", DOCKER], 2_000);
135
+ if (app === null && cli === null) return { state: "missing", version: null, app: null };
136
+ return { state: "stopped", version: null, app };
137
+ }
138
+
139
+ export async function readDockerHandler(): Promise<ReadOutput> {
140
+ const collectedAt = new Date().toISOString();
141
+ const engine = await readEngine();
142
+
143
+ if (engine.state !== "running") {
144
+ return {
145
+ engine,
146
+ available: false,
147
+ reason:
148
+ engine.state === "missing"
149
+ ? "No container engine is installed on this machine."
150
+ : `${engine.app ?? "The container engine"} is installed but not running.`,
151
+ collectedAt,
152
+ containers: [],
153
+ images: [],
154
+ usage: [],
155
+ notes: [],
156
+ };
157
+ }
158
+
159
+ const [psRaw, dfRaw, imagesRaw] = await Promise.all([
160
+ tryRun(DOCKER, ["ps", "-a", "--format", PS_FORMAT], 10_000),
161
+ tryRun(DOCKER, ["system", "df"], 15_000),
162
+ tryRun(DOCKER, ["images", "--format", "{{.ID}}\t{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}"], 10_000),
163
+ ]);
164
+
165
+ const containers = parseContainers(psRaw ?? "");
166
+ const usage = parseSystemDf(dfRaw ?? "");
167
+ const notes: string[] = [];
168
+
169
+ const volumes = usage.find((row) => row.label === VOLUME_LABEL);
170
+ if (volumes && volumes.reclaimableBytes > 0) {
171
+ notes.push(
172
+ "Unused volumes are counted as reclaimable by Docker, but they hold container data — this panel never offers to remove them.",
173
+ );
174
+ }
175
+ if (dfRaw === null) notes.push("Disk usage was not readable.");
176
+
177
+ return {
178
+ engine,
179
+ available: true,
180
+ reason: null,
181
+ collectedAt,
182
+ containers,
183
+ images: parseImages(imagesRaw ?? ""),
184
+ usage,
185
+ notes,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Starting the engine is a launch, not a daemon command: Docker Desktop owns its
191
+ * own VM and comes up over tens of seconds, so this returns as soon as the app
192
+ * has been asked and the panel polls until the daemon answers.
193
+ */
194
+ export async function controlEngineHandler({ action }: EngineInput) {
195
+ const app = (await installedApp()) ?? "Docker";
196
+ try {
197
+ if (action === "start") {
198
+ await run("/usr/bin/open", ["-a", app], 15_000);
199
+ } else {
200
+ await run("/usr/bin/osascript", ["-e", `quit app "${app}"`], 20_000);
201
+ }
202
+ return { ok: true, error: null };
203
+ } catch (error) {
204
+ console.error(`[machine-status] docker engine ${action} failed`, error);
205
+ return { ok: false, error: describeError(error) };
206
+ }
207
+ }
208
+
209
+ export async function readContainerLogsHandler({ id, lines }: LogsInput) {
210
+ try {
211
+ const { stdout, stderr } = await runStreams(
212
+ DOCKER,
213
+ ["logs", "--tail", String(lines), "--timestamps", id],
214
+ 20_000,
215
+ );
216
+ return { ok: true, text: `${stdout}${stderr}`.trimEnd(), error: null };
217
+ } catch (error) {
218
+ return { ok: false, text: "", error: describeError(error) };
219
+ }
220
+ }
221
+
222
+ export async function removeImageHandler({ id }: ImageInput) {
223
+ try {
224
+ await run(DOCKER, ["rmi", id], 60_000);
225
+ return { ok: true, error: null };
226
+ } catch (error) {
227
+ console.error(`[machine-status] docker rmi failed for ${id}`, error);
228
+ return { ok: false, error: describeError(error) };
229
+ }
230
+ }
231
+
232
+ export async function controlContainerHandler({ id, action }: ControlInput) {
233
+ try {
234
+ // `remove` is only ever reached through the confirmation menu in the panel.
235
+ const args = action === "remove" ? ["rm", id] : [action, id];
236
+ await run(DOCKER, args, 60_000);
237
+ return { ok: true, error: null };
238
+ } catch (error) {
239
+ console.error(`[machine-status] docker ${action} failed for ${id}`, error);
240
+ return { ok: false, error: describeError(error) };
241
+ }
242
+ }
243
+
244
+ /** Matches the "Total reclaimed space: 1.234GB" line Docker prints after a prune. */
245
+ export function parseReclaimed(raw: string): number {
246
+ const match = /Total reclaimed space:\s*([\d.]+\s*\w+)/i.exec(raw);
247
+ return match ? parseDockerSize(match[1]) : 0;
248
+ }
249
+
250
+ export async function pruneDockerHandler({ target }: PruneInput) {
251
+ // Images are pruned dangling-only. `-a` would delete every image not currently
252
+ // in use by a container, which on a dev machine means re-pulling gigabytes.
253
+ const args = target === "containers" ? ["container", "prune", "-f"] : ["image", "prune", "-f"];
254
+ try {
255
+ return { ok: true, reclaimedBytes: parseReclaimed(await run(DOCKER, args, 120_000)), error: null };
256
+ } catch (error) {
257
+ console.error(`[machine-status] docker prune ${target} failed`, error);
258
+ return { ok: false, reclaimedBytes: 0, error: describeError(error) };
259
+ }
260
+ }
package/server/exec.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ /**
7
+ * Runs a binary directly, never through a shell. Paths and PIDs flow into these
8
+ * calls, and argv-style invocation removes any chance of shell interpretation.
9
+ */
10
+ export async function run(file: string, args: string[], timeoutMs = 5_000): Promise<string> {
11
+ const { stdout } = await execFileAsync(file, args, {
12
+ timeout: timeoutMs,
13
+ maxBuffer: 16 * 1024 * 1024,
14
+ encoding: "utf8",
15
+ });
16
+ return stdout;
17
+ }
18
+
19
+ /** Same as `run`, but a missing binary or non-zero exit resolves to null instead of throwing. */
20
+ export async function tryRun(file: string, args: string[], timeoutMs = 5_000): Promise<string | null> {
21
+ try {
22
+ return await run(file, args, timeoutMs);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function describeError(error: unknown): string {
29
+ if (error instanceof Error) return error.message;
30
+ return String(error);
31
+ }
32
+
33
+ /**
34
+ * Returns stdout even when the command exits non-zero. `du` walks unreadable
35
+ * subdirectories, warns on stderr, exits 1, and still prints a usable total.
36
+ */
37
+ export async function runTolerant(file: string, args: string[], timeoutMs = 5_000): Promise<string> {
38
+ try {
39
+ return await run(file, args, timeoutMs);
40
+ } catch (error) {
41
+ const stdout = (error as { stdout?: unknown }).stdout;
42
+ return typeof stdout === "string" ? stdout : "";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Returns both streams. `docker logs` writes container stdout and stderr to the
48
+ * matching stream of its own process, so reading only stdout loses the output of
49
+ * every container that logs to stderr — which most of them do.
50
+ */
51
+ export async function runStreams(
52
+ file: string,
53
+ args: string[],
54
+ timeoutMs = 5_000,
55
+ ): Promise<{ stdout: string; stderr: string }> {
56
+ const { stdout, stderr } = await execFileAsync(file, args, {
57
+ timeout: timeoutMs,
58
+ maxBuffer: 16 * 1024 * 1024,
59
+ encoding: "utf8",
60
+ });
61
+ return { stdout, stderr };
62
+ }
@@ -0,0 +1,188 @@
1
+ import type { Explanation } from "../shared/explain";
2
+
3
+ /**
4
+ * Turns gathered evidence into a verdict.
5
+ *
6
+ * Runtime-neutral and pure: every rule here is a statement about evidence the
7
+ * panel already measured, so all of it is testable without a machine. Nothing in
8
+ * this file guesses from a name alone — the name only ever adds description to a
9
+ * conclusion that structure and timing already reached.
10
+ */
11
+
12
+ export interface ProcessEvidence {
13
+ name: string;
14
+ pid: number;
15
+ command: string;
16
+ cpuPercent: number;
17
+ memoryBytes: number;
18
+ elapsedSeconds: number | null;
19
+ elapsed: string | null;
20
+ cwd: string | null;
21
+ parents: { pid: number; name: string }[];
22
+ children: { pid: number; name: string; cpuPercent: number }[];
23
+ ports: { port: number; protocol: string; scope: string }[];
24
+ isAppleSystemBinary: boolean;
25
+ machine: {
26
+ cores: number;
27
+ loadPercent: number;
28
+ pressurePercent: number | null;
29
+ swapUsedBytes: number;
30
+ swapTotalBytes: number;
31
+ };
32
+ }
33
+
34
+ const GB = 1024 ** 3;
35
+ const MB = 1024 * 1024;
36
+
37
+ const size = (bytes: number): string =>
38
+ bytes >= GB ? `${(bytes / GB).toFixed(1)} GB` : `${Math.round(bytes / MB)} MB`;
39
+
40
+ /** Documented behaviour of macOS components people actually see at the top of a CPU list. */
41
+ const KNOWN: Record<string, { what: string; trigger?: string }> = {
42
+ mds_stores: { what: "Spotlight's index store", trigger: "indexing after a lot of files changed" },
43
+ mds: { what: "the Spotlight metadata server", trigger: "indexing work" },
44
+ mdworker_shared: { what: "a Spotlight indexing worker", trigger: "indexing work" },
45
+ mdsync: { what: "a Spotlight index sync worker", trigger: "indexing work" },
46
+ kernel_task: { what: "the kernel's own task, which also absorbs CPU deliberately to cool the machine" },
47
+ WindowServer: { what: "the compositor every window on screen draws through" },
48
+ loginwindow: { what: "the manager of your login session" },
49
+ backupd: { what: "Time Machine", trigger: "a scheduled or manual backup" },
50
+ photoanalysisd: { what: "Photos' library analysis", trigger: "new photos to scan for faces and scenes" },
51
+ bird: { what: "iCloud Drive's file sync" },
52
+ cloudd: { what: "iCloud sync" },
53
+ syspolicyd: { what: "Gatekeeper's policy service", trigger: "first launch of a newly downloaded app" },
54
+ trustd: { what: "certificate validation" },
55
+ coreaudiod: { what: "the system audio server" },
56
+ XprotectService: { what: "XProtect's scanning service", trigger: "a scheduled malware check" },
57
+ "com.apple.Virtualization.VirtualMachine": { what: "a virtual machine, usually the one Docker runs Linux in" },
58
+ };
59
+
60
+ const DEV_RUNTIMES = new Set([
61
+ "node", "bun", "deno", "esbuild", "vite", "next-server", "turbo", "tsx", "webpack",
62
+ "python", "python3", "ruby", "java", "go", "cargo", "php",
63
+ ]);
64
+
65
+ /** Recognises the per-family remediator binaries without pretending to know each family. */
66
+ export function describeKnown(name: string): { what: string; trigger?: string } | null {
67
+ const remediator = /^XProtectRemediator(.+)$/.exec(name);
68
+ if (remediator) {
69
+ return {
70
+ what: `XProtect Remediator, Apple's built-in malware remediation, running its ${remediator[1]} check`,
71
+ trigger: "a scheduled scan, or one queued after a definition update",
72
+ };
73
+ }
74
+ return KNOWN[name] ?? null;
75
+ }
76
+
77
+ function kindOf(evidence: ProcessEvidence): Explanation["kind"] {
78
+ // Structural first: anything under the Paseo daemon was started by an agent,
79
+ // whatever it happens to be called.
80
+ if (evidence.parents.some((parent) => parent.name === "Paseo Daemon")) return "agent-spawned";
81
+ if (evidence.isAppleSystemBinary) return "apple-system";
82
+ if (/^com\.docker/.test(evidence.name)) return "container";
83
+ if (DEV_RUNTIMES.has(evidence.name) || evidence.ports.some((port) => port.scope === "local")) {
84
+ return "dev-runtime";
85
+ }
86
+ if (evidence.command.startsWith("/Applications/")) return "user-app";
87
+ return "unknown";
88
+ }
89
+
90
+ function stateOf(evidence: ProcessEvidence): Explanation["state"] {
91
+ const young = evidence.elapsedSeconds !== null && evidence.elapsedSeconds < 180;
92
+ if (young && evidence.cpuPercent > 40) return "just-started";
93
+ if (evidence.cpuPercent > 50 && !young) return "sustained-load";
94
+ if (evidence.memoryBytes > GB && evidence.cpuPercent < 5) return "memory-heavy";
95
+ return "steady";
96
+ }
97
+
98
+ export function classify(evidence: ProcessEvidence): Explanation {
99
+ const kind = kindOf(evidence);
100
+ const state = stateOf(evidence);
101
+ const known = describeKnown(evidence.name);
102
+ const findings: string[] = [];
103
+ const advice: string[] = [];
104
+
105
+ if (known) {
106
+ findings.push(`${evidence.name} is ${known.what}.${known.trigger ? ` It runs on ${known.trigger}.` : ""}`);
107
+ } else if (kind === "apple-system") {
108
+ findings.push(`It ships with macOS — the binary lives under a system path — but this panel has no description for it.`);
109
+ }
110
+
111
+ if (evidence.elapsed) {
112
+ findings.push(
113
+ state === "just-started"
114
+ ? `It started ${evidence.elapsed} ago, so this burst is it starting up or working through a task, not a process that has hung.`
115
+ : `It has been running for ${evidence.elapsed}.`,
116
+ );
117
+ }
118
+
119
+ if (evidence.parents.length > 0) {
120
+ findings.push(`It was started by ${evidence.parents[0].name}.`);
121
+ }
122
+ if (evidence.children.length > 0) {
123
+ findings.push(
124
+ `It has ${evidence.children.length} child process${evidence.children.length === 1 ? "" : "es"}, so quitting it takes them too.`,
125
+ );
126
+ }
127
+ if (evidence.ports.length > 0) {
128
+ findings.push(`It is serving ${evidence.ports.map((port) => `:${port.port}`).join(", ")}.`);
129
+ }
130
+
131
+ findings.push(`Right now: ${evidence.cpuPercent.toFixed(1)}% CPU and ${size(evidence.memoryBytes)} of memory.`);
132
+
133
+ let headline: string;
134
+ let concern: Explanation["concern"] = "none";
135
+
136
+ if (state === "just-started" && kind === "apple-system") {
137
+ headline = `${evidence.name} is doing scheduled system work and will finish on its own.`;
138
+ advice.push("Wait. It is seconds old, and killing it only makes macOS start it again later.");
139
+ } else if (state === "just-started") {
140
+ headline = `${evidence.name} has only just started — this is startup work, not a stuck process.`;
141
+ advice.push("Give it a minute and look again before doing anything.");
142
+ } else if (state === "sustained-load" && kind === "apple-system") {
143
+ headline = `${evidence.name} has been busy for a while; it is a macOS component, not something to kill.`;
144
+ concern = "watch";
145
+ advice.push(
146
+ known?.trigger
147
+ ? `Look for what triggered it — usually ${known.trigger}.`
148
+ : "Look for what triggered it; system components work when something asked them to.",
149
+ "If it is Spotlight, excluding large build directories from indexing stops the recurrence.",
150
+ );
151
+ } else if (state === "sustained-load") {
152
+ headline = `${evidence.name} has held ${evidence.cpuPercent.toFixed(0)}% CPU well past startup.`;
153
+ concern = evidence.cpuPercent > 80 ? "act" : "watch";
154
+ if (kind === "agent-spawned") {
155
+ advice.push("It belongs to one of your coding agents — closing that session releases it.");
156
+ }
157
+ if (evidence.ports.length > 0) {
158
+ advice.push(`It is serving ${evidence.ports.map((port) => `:${port.port}`).join(", ")}; stop that dev server if you are not using it.`);
159
+ }
160
+ advice.push("Quit it the normal way first; terminate it from this list only if that does not work.");
161
+ } else if (state === "memory-heavy") {
162
+ headline = `${evidence.name} is idle but holding ${size(evidence.memoryBytes)}.`;
163
+ concern = evidence.memoryBytes > 2 * GB ? "watch" : "none";
164
+ advice.push(`Quitting it frees ${size(evidence.memoryBytes)}.`);
165
+ if (kind === "container") advice.push("Stopping Docker releases the whole VM, not just one container.");
166
+ } else {
167
+ headline = `${evidence.name} is behaving normally.`;
168
+ advice.push("Nothing to do.");
169
+ }
170
+
171
+ // Machine-level context changes what the advice is worth.
172
+ const swapPercent =
173
+ evidence.machine.swapTotalBytes > 0
174
+ ? (evidence.machine.swapUsedBytes / evidence.machine.swapTotalBytes) * 100
175
+ : 0;
176
+ if (swapPercent > 50 && state !== "just-started") {
177
+ findings.push(
178
+ `The machine is swapping (${size(evidence.machine.swapUsedBytes)} in use), so memory is the tighter constraint right now, not CPU.`,
179
+ );
180
+ }
181
+ if (evidence.machine.loadPercent < 60 && state === "sustained-load") {
182
+ findings.push(
183
+ `The machine overall is not saturated (${evidence.machine.loadPercent.toFixed(0)}% of ${evidence.machine.cores} cores), so this one process is the outlier rather than a general overload.`,
184
+ );
185
+ }
186
+
187
+ return { headline, kind, state, concern, findings, advice };
188
+ }
@@ -0,0 +1,95 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
4
+ import type { z } from "zod";
5
+ import { describeError, run, tryRun } from "./exec";
6
+ import type { explainProcess } from "../shared/explain";
7
+ import { classify, type ProcessEvidence } from "./explain-rules";
8
+ import { readMetricsHandler } from "./metrics";
9
+ import { ancestorsOf, parseElapsedSeconds, parsePsTable, processName, type RawProcess } from "./processes";
10
+ import { readServicesHandler } from "./services";
11
+
12
+ type Output = z.output<typeof explainProcess.output>;
13
+
14
+ const MB = 1024 * 1024;
15
+ const GB = 1024 ** 3;
16
+
17
+ async function cwdOf(pid: number): Promise<string | null> {
18
+ const raw = await tryRun("/usr/sbin/lsof", ["-a", "-d", "cwd", "-p", String(pid), "-Fn"], 4_000);
19
+ return raw?.split("\n").find((line) => line.startsWith("n"))?.slice(1) ?? null;
20
+ }
21
+
22
+ function childrenOf(pid: number, table: readonly RawProcess[]): RawProcess[] {
23
+ return table.filter((entry) => entry.ppid === pid);
24
+ }
25
+
26
+ export async function explainProcessHandler(
27
+ { pid }: { pid: number },
28
+ context: PluginHandlerContext,
29
+ ): Promise<Output> {
30
+ const table = parsePsTable(await run("/bin/ps", ["-Ao", "pid=,ppid=,pcpu=,rss=,comm="], 10_000));
31
+ const self = table.find((entry) => entry.pid === pid);
32
+
33
+ if (!self) {
34
+ return {
35
+ found: false,
36
+ name: `PID ${pid}`,
37
+ pid,
38
+ explanation: {
39
+ headline: "This process no longer exists — it exited while you were looking at it.",
40
+ kind: "unknown",
41
+ state: "steady",
42
+ concern: "none",
43
+ findings: ["A process that short-lived is almost always scheduled work rather than a fault."],
44
+ advice: ["Nothing to do. Refresh the list to see what is running now."],
45
+ },
46
+ };
47
+ }
48
+
49
+ const [elapsedRaw, cwd, metrics, services] = await Promise.all([
50
+ tryRun("/bin/ps", ["-o", "etime=", "-p", String(pid)], 3_000),
51
+ cwdOf(pid),
52
+ readMetricsHandler(),
53
+ readServicesHandler({}, context),
54
+ ]);
55
+
56
+ const children = childrenOf(pid, table);
57
+ const childPids = new Set([pid, ...children.map((child) => child.pid)]);
58
+
59
+ const evidence: ProcessEvidence = {
60
+ name: processName(self.command),
61
+ pid,
62
+ command: self.command,
63
+ cpuPercent: self.cpuPercent,
64
+ memoryBytes: self.memoryBytes,
65
+ elapsed: elapsedRaw?.trim() ?? null,
66
+ elapsedSeconds: elapsedRaw ? parseElapsedSeconds(elapsedRaw) : null,
67
+ cwd,
68
+ parents: [...ancestorsOf(pid, table)]
69
+ .filter((entry) => entry !== pid)
70
+ .map((entry) => ({ pid: entry, name: processName(table.find((row) => row.pid === entry)?.command ?? "") })),
71
+ children: children.map((child) => ({
72
+ pid: child.pid,
73
+ name: processName(child.command),
74
+ cpuPercent: child.cpuPercent,
75
+ })),
76
+ ports: services.ports
77
+ .filter((port) => port.pid !== null && childPids.has(port.pid))
78
+ .map((port) => ({ port: port.port, protocol: port.protocol, scope: port.scope })),
79
+ isAppleSystemBinary: /^\/(System|usr\/libexec|usr\/sbin|Library\/Apple)\//.test(self.command),
80
+ machine: {
81
+ cores: metrics.cpu.cores,
82
+ loadPercent: metrics.cpu.loadPercent,
83
+ pressurePercent: metrics.memory.pressurePercent,
84
+ swapUsedBytes: metrics.swap.usedBytes,
85
+ swapTotalBytes: metrics.swap.totalBytes,
86
+ },
87
+ };
88
+
89
+ return {
90
+ found: true,
91
+ name: evidence.name,
92
+ pid,
93
+ explanation: classify(evidence),
94
+ };
95
+ }