agent-yes 1.266.0 → 1.267.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 (52) hide show
  1. package/dist/{SUPPORTED_CLIS-AFV5pjSx.js → SUPPORTED_CLIS-BByfWaoz.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-DRVuSo48.js → SUPPORTED_CLIS-Bob214Yb.js} +3 -3
  3. package/dist/{agentShare-qdFSp9Uq.js → agentShare-TGChdMCD.js} +3 -3
  4. package/dist/{callback-DfNFdZin.js → callback-B2thylEC.js} +3 -3
  5. package/dist/{callback-Da7MRLh6.js → callback-D93yB1xz.js} +3 -3
  6. package/dist/cli.js +11 -11
  7. package/dist/cmdPs-D7NlwNFV.js +577 -0
  8. package/dist/{cwdConflictWarn-CtQhl-vF.js → cwdConflictWarn-BpweoAiy.js} +1 -1
  9. package/dist/{cwdPassthroughHint-apaX_O7p.js → cwdPassthroughHint-BlRuOGo_.js} +1 -1
  10. package/dist/{expose-BXn6My6A.js → expose-DyiZ5AU0.js} +1 -1
  11. package/dist/{forkNested-CDfWPiHw.js → forkNested-B_4c-TQk.js} +1 -1
  12. package/dist/{hist-BpocAe1N.js → hist-BoZPJ2ah.js} +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/{initMsg-C-w4A_eF.js → initMsg-BKqWDYFk.js} +1 -1
  15. package/dist/{initMsg-BjRseolL.js → initMsg-D8cmdY76.js} +1 -1
  16. package/dist/{nodeRuntime-CRoEMWkp.js → nodeRuntime-QihGFQbI.js} +1 -1
  17. package/dist/{notifyDaemon-BFzqcdN1.js → notifyDaemon-B-vCwFi3.js} +3 -3
  18. package/dist/{openBrowser-DhQ9pJRi.js → openBrowser-Cs46NiH6.js} +1 -1
  19. package/dist/{parentLink-B798ASCW.js → parentLink-CQrn998L.js} +1 -1
  20. package/dist/{parentLink-C0cIRjfL.js → parentLink-CTgZZCgw.js} +1 -1
  21. package/dist/{parentPingLoop-BIKxzbPH.js → parentPingLoop-CVD0sREv.js} +4 -4
  22. package/dist/{remotes-iMhdZytp.js → remotes-DdAfueOu.js} +2 -2
  23. package/dist/{remotes-Dg_JscKk.js → remotes-M8FbM92D.js} +1 -1
  24. package/dist/{rustBinary-JGNROWfY.js → rustBinary-Bf9514x8.js} +2 -2
  25. package/dist/{schedule-DbLpjgl5.js → schedule-fgKVzax1.js} +6 -6
  26. package/dist/{serve-ucVXOci1.js → serve-DM5-VJPX.js} +26 -26
  27. package/dist/{setup-De7uYRZb.js → setup-DNuxXwYS.js} +4 -4
  28. package/dist/{spawnGate-BvaZeHE7.js → spawnGate-CZlMs6uV.js} +1 -1
  29. package/dist/{spawnGate-CEXVmTtv.js → spawnGate-DN4wNXHq.js} +2 -2
  30. package/dist/{subcommands-C04exkIM.js → subcommands-3rBreZdT.js} +2 -2
  31. package/dist/{subcommands-DtF31RoW.js → subcommands-ujgVZRT3.js} +24 -24
  32. package/dist/{systemPathLink-7TFPwJna.js → systemPathLink-BHv1I6wh.js} +1 -1
  33. package/dist/{terminal-Dm32SXU-.js → terminal-jJ2YDBDS.js} +2 -2
  34. package/dist/{todoCli-c_R7D7Y-.js → todoCli-BYGZ7_C5.js} +1 -1
  35. package/dist/{tray-DZnGjJBK.js → tray-CzGs9-ch.js} +1 -1
  36. package/dist/trayApp-CPDWlxfV.js +5 -0
  37. package/dist/{trayApp-CfkPG0wy.js → trayApp-DbV2E8dZ.js} +2 -2
  38. package/dist/{ts-CGtoPER5.js → ts-CQIqcW7_.js} +5 -5
  39. package/dist/{versionChecker-CJgW9DHY.js → versionChecker-D49xkrMv.js} +2 -2
  40. package/dist/{webrtcRemote-BDg0jjTs.js → webrtcRemote-Bl8eowma.js} +1 -1
  41. package/dist/{widget-DXtaX3QR.js → widget-CRXwzRP7.js} +3 -3
  42. package/dist/{workspaceConfig-HiWQzq6A.js → workspaceConfig-CBKlgA_q.js} +1 -1
  43. package/dist/{ws-aKRZpI9b.js → ws-B4ZoCy71.js} +3 -3
  44. package/dist/{ws-CqlS5JhN.js → ws-DomYQT_I.js} +3 -3
  45. package/lab/ui/sw.js +10 -1
  46. package/package.json +1 -1
  47. package/ts/cmdPs.spec.ts +389 -0
  48. package/ts/cmdPs.ts +343 -0
  49. package/ts/procStats.spec.ts +506 -0
  50. package/ts/procStats.ts +484 -0
  51. package/ts/subcommands.ts +9 -4
  52. package/dist/trayApp-CZbwLK9t.js +0 -5
@@ -0,0 +1,506 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildChildIndex,
4
+ defaultListPids,
5
+ defaultReadPsTable,
6
+ defaultReadStat,
7
+ defaultReadSys,
8
+ derivePageSize,
9
+ descendantsOf,
10
+ humanBytes,
11
+ parseMeminfo,
12
+ parseProcStat,
13
+ parsePsTable,
14
+ parsePsTime,
15
+ sampleTrees,
16
+ snapshotProcs,
17
+ systemStats,
18
+ type ProcSample,
19
+ } from "./procStats.ts";
20
+
21
+ /**
22
+ * Build the post-comm portion of a /proc/<pid>/stat line. Fields 3.. are:
23
+ * state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt
24
+ * utime stime cutime cstime priority nice num_threads itrealvalue starttime
25
+ * vsize rss — so state=0, ppid=1, utime=11, stime=12, starttime=19, rss=21.
26
+ */
27
+ function statLine(opts: {
28
+ pid: number;
29
+ ppid: number;
30
+ utime?: number;
31
+ stime?: number;
32
+ rssPages?: number;
33
+ state?: string;
34
+ comm?: string;
35
+ startTime?: string;
36
+ }): string {
37
+ const f = Array(22).fill("0");
38
+ f[0] = opts.state ?? "S";
39
+ f[1] = String(opts.ppid);
40
+ f[11] = String(opts.utime ?? 0);
41
+ f[12] = String(opts.stime ?? 0);
42
+ f[19] = opts.startTime ?? "5000";
43
+ f[21] = String(opts.rssPages ?? 0);
44
+ return `${opts.pid} (${opts.comm ?? "claude"}) ${f.join(" ")}`;
45
+ }
46
+
47
+ describe("parseProcStat", () => {
48
+ it("reads ppid, cpu and rss from a normal stat line", () => {
49
+ const s = parseProcStat(
50
+ 42,
51
+ statLine({ pid: 42, ppid: 7, utime: 150, stime: 50, rssPages: 10 }),
52
+ );
53
+ expect(s).toEqual({
54
+ pid: 42,
55
+ ppid: 7,
56
+ rss: 10 * 4096,
57
+ cpuSeconds: 2, // (150 + 50) ticks / 100 USER_HZ
58
+ state: "S",
59
+ startToken: "5000",
60
+ });
61
+ });
62
+
63
+ it("scales rss by the measured page size, not a hardcoded 4 KiB", () => {
64
+ const s = parseProcStat(1, statLine({ pid: 1, ppid: 0, rssPages: 10 }), 16384);
65
+ expect(s?.rss).toBe(10 * 16384);
66
+ });
67
+
68
+ it("survives a comm containing spaces and parentheses", () => {
69
+ // The reason we parse after the LAST ')': naive splitting mis-indexes every
70
+ // field past the comm.
71
+ const s = parseProcStat(9, statLine({ pid: 9, ppid: 3, comm: "my prog (v2)", rssPages: 2 }));
72
+ expect(s?.ppid).toBe(3);
73
+ expect(s?.rss).toBe(2 * 4096);
74
+ });
75
+
76
+ it("returns null on a line with no comm terminator", () => {
77
+ expect(parseProcStat(1, "garbage without paren")).toBeNull();
78
+ });
79
+
80
+ it("returns null when the numeric fields are unparseable", () => {
81
+ expect(parseProcStat(1, "1 (x) S notanumber")).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe("parsePsTime", () => {
86
+ it("parses mm:ss", () => {
87
+ expect(parsePsTime("01:30")).toBe(90);
88
+ });
89
+ it("parses hh:mm:ss", () => {
90
+ expect(parsePsTime("02:00:00")).toBe(7200);
91
+ });
92
+ it("parses dd-hh:mm:ss", () => {
93
+ expect(parsePsTime("1-00:00:00")).toBe(86400);
94
+ });
95
+ it("returns 0 for junk", () => {
96
+ expect(parsePsTime("-")).toBe(0);
97
+ });
98
+ });
99
+
100
+ describe("parsePsTable", () => {
101
+ it("normalizes ps KiB into bytes so both backends agree", () => {
102
+ const procs = parsePsTable(" 10 1 2048 00:10 S\n 11 10 512 00:00 Z\n");
103
+ expect(procs.get(10)).toEqual({
104
+ pid: 10,
105
+ ppid: 1,
106
+ rss: 2048 * 1024,
107
+ cpuSeconds: 10,
108
+ state: "S",
109
+ // No usable start token from `ps` — the pid-reuse guard is Linux-only.
110
+ startToken: "",
111
+ });
112
+ expect(procs.get(11)?.state).toBe("Z");
113
+ });
114
+
115
+ it("skips short/garbage lines", () => {
116
+ expect(parsePsTable("\nnonsense\n 1 2 3 4 S\n").size).toBe(1);
117
+ });
118
+ });
119
+
120
+ describe("descendantsOf", () => {
121
+ const procs = new Map<number, ProcSample>(
122
+ (
123
+ [
124
+ [1, 0],
125
+ [10, 1],
126
+ [11, 10],
127
+ [12, 11], // depth 3 under 10
128
+ [20, 1],
129
+ ] as [number, number][]
130
+ ).map(([pid, ppid]) => [
131
+ pid,
132
+ { pid, ppid, rss: 0, cpuSeconds: 0, state: "S", startToken: `t${pid}` },
133
+ ]),
134
+ );
135
+ const kids = buildChildIndex(procs);
136
+
137
+ it("walks the FULL closure, not just direct children", () => {
138
+ expect([...descendantsOf(10, kids)].sort((a, b) => a - b)).toEqual([10, 11, 12]);
139
+ });
140
+
141
+ it("excludes pids already claimed by an earlier root", () => {
142
+ expect([...descendantsOf(10, kids, new Set([11]))].sort((a, b) => a - b)).toEqual([10]);
143
+ });
144
+
145
+ it("terminates on a parent cycle instead of blowing the stack", () => {
146
+ const cyclic = new Map<number, ProcSample>([
147
+ [1, { pid: 1, ppid: 2, rss: 0, cpuSeconds: 0, state: "S", startToken: "t1" }],
148
+ [2, { pid: 2, ppid: 1, rss: 0, cpuSeconds: 0, state: "S", startToken: "t2" }],
149
+ ]);
150
+ expect(descendantsOf(1, buildChildIndex(cyclic)).size).toBe(2);
151
+ });
152
+ });
153
+
154
+ /** Two scripted /proc snapshots, returned on successive calls. */
155
+ function scriptedDeps(rounds: string[][]) {
156
+ let call = 0;
157
+ const pidsOf = (lines: string[]) => lines.map((l) => Number(l.split(" ")[0]));
158
+ return {
159
+ platform: "linux",
160
+ listPids: async () => {
161
+ const r = rounds[Math.min(call, rounds.length - 1)] as string[];
162
+ return pidsOf(r);
163
+ },
164
+ readStat: async (pid: number) => {
165
+ const r = rounds[Math.min(call, rounds.length - 1)] as string[];
166
+ const hit = r.find((l) => Number(l.split(" ")[0]) === pid) ?? null;
167
+ // Advance only after the last pid of a round has been read.
168
+ if (pid === pidsOf(r)[r.length - 1]) call++;
169
+ return hit;
170
+ },
171
+ };
172
+ }
173
+
174
+ describe("sampleTrees", () => {
175
+ it("rolls a subtree up and derives CPU% from the delta, not lifetime total", async () => {
176
+ // Root 10 has child 11. Between samples the pair burns 0.5s of CPU over a
177
+ // 0.5s window => 100% of one core, even though lifetime CPU is far larger.
178
+ const first = [
179
+ statLine({ pid: 10, ppid: 1, utime: 10000, rssPages: 100 }),
180
+ statLine({ pid: 11, ppid: 10, utime: 10000, rssPages: 100 }),
181
+ ];
182
+ const second = [
183
+ statLine({ pid: 10, ppid: 1, utime: 10025, rssPages: 100 }),
184
+ statLine({ pid: 11, ppid: 10, utime: 10025, rssPages: 100 }),
185
+ ];
186
+ const { trees } = await sampleTrees([10], {
187
+ windowMs: 500,
188
+ deps: scriptedDeps([first, second]),
189
+ });
190
+ const t = trees.get(10);
191
+ expect(t?.procs).toBe(2);
192
+ expect(t?.rss).toBe(200 * 4096);
193
+ // 0.5 CPU-seconds over ~0.5s wall. Generous bound: the sleep can overshoot
194
+ // on a loaded box, which only ever lowers the percentage.
195
+ expect(t?.cpuPercent).toBeGreaterThan(60);
196
+ expect(t?.cpuPercent).toBeLessThanOrEqual(110);
197
+ });
198
+
199
+ it("does not credit a process that appeared mid-window with its whole history", async () => {
200
+ // 11 shows up only in the second sample carrying 100s of cumulative CPU.
201
+ // Counting that would spike the row to nonsense.
202
+ const first = [statLine({ pid: 10, ppid: 1 })];
203
+ const second = [statLine({ pid: 10, ppid: 1 }), statLine({ pid: 11, ppid: 10, utime: 10000 })];
204
+ const { trees } = await sampleTrees([10], {
205
+ windowMs: 100,
206
+ deps: scriptedDeps([first, second]),
207
+ });
208
+ expect(trees.get(10)?.procs).toBe(2);
209
+ expect(trees.get(10)?.cpuPercent).toBe(0);
210
+ });
211
+
212
+ it("ignores the baseline when a pid was REUSED between samples", async () => {
213
+ // Same pid, different process (start time changed) carrying a much smaller
214
+ // counter. Subtracting across the two is meaningless; Math.max would clamp
215
+ // to 0 here, but the reverse case (reused pid with a LARGER counter) would
216
+ // otherwise report a huge bogus spike.
217
+ const first = [statLine({ pid: 10, ppid: 1, utime: 100, startTime: "1000" })];
218
+ const second = [statLine({ pid: 10, ppid: 1, utime: 9999, startTime: "8888" })];
219
+ const { trees } = await sampleTrees([10], {
220
+ windowMs: 100,
221
+ deps: scriptedDeps([first, second]),
222
+ });
223
+ expect(trees.get(10)?.cpuPercent).toBe(0);
224
+ });
225
+
226
+ it("still uses the baseline when the start token is unavailable", async () => {
227
+ // The ps fallback yields "" for both samples — "no opinion" must not be
228
+ // treated as a mismatch, or CPU% would be permanently 0 off Linux.
229
+ let call = 0;
230
+ const deps = {
231
+ platform: "darwin",
232
+ readPsTable: async () => (call++ === 0 ? "10 1 100 00:00 S\n" : "10 1 100 00:01 S\n"),
233
+ };
234
+ const { trees } = await sampleTrees([10], { windowMs: 100, deps });
235
+ expect(trees.get(10)?.cpuPercent).toBeGreaterThan(0);
236
+ });
237
+
238
+ it("gives a nested root its own subtree when claimed deepest-first", async () => {
239
+ // 10 -> 11 -> 12, with both 10 and 11 listed as agent roots.
240
+ const snap = [
241
+ statLine({ pid: 10, ppid: 1, rssPages: 10 }),
242
+ statLine({ pid: 11, ppid: 10, rssPages: 10 }),
243
+ statLine({ pid: 12, ppid: 11, rssPages: 10 }),
244
+ ];
245
+ const { trees } = await sampleTrees([11, 10], {
246
+ windowMs: 100,
247
+ deps: scriptedDeps([snap, snap]),
248
+ });
249
+ // Child claims 11+12; parent keeps only itself — no double counting, and
250
+ // crucially the child is not rendered as an empty 0-proc row.
251
+ expect(trees.get(11)?.procs).toBe(2);
252
+ expect(trees.get(10)?.procs).toBe(1);
253
+ });
254
+
255
+ it("reports everything unclaimed as the unattributed row", async () => {
256
+ const snap = [
257
+ statLine({ pid: 10, ppid: 1, rssPages: 10 }),
258
+ statLine({ pid: 99, ppid: 1, rssPages: 5 }),
259
+ ];
260
+ const { unattributed } = await sampleTrees([10], {
261
+ windowMs: 100,
262
+ deps: scriptedDeps([snap, snap]),
263
+ });
264
+ expect(unattributed.procs).toBe(1);
265
+ expect(unattributed.rss).toBe(5 * 4096);
266
+ });
267
+ });
268
+
269
+ describe("snapshotProcs", () => {
270
+ it("skips pids that vanish between listing and reading", async () => {
271
+ const procs = await snapshotProcs({
272
+ platform: "linux",
273
+ listPids: async () => [1, 2],
274
+ readStat: async (pid) => (pid === 1 ? statLine({ pid: 1, ppid: 0 }) : null),
275
+ });
276
+ expect([...procs.keys()]).toEqual([1]);
277
+ });
278
+
279
+ it("falls back to the ps table off Linux", async () => {
280
+ const procs = await snapshotProcs({
281
+ platform: "darwin",
282
+ readPsTable: async () => " 5 1 1024 00:02 S\n",
283
+ });
284
+ expect(procs.get(5)?.rss).toBe(1024 * 1024);
285
+ });
286
+ });
287
+
288
+ describe("derivePageSize", () => {
289
+ it("recovers a 16 KiB page size from our own VmRSS vs rss-in-pages", () => {
290
+ // 100 pages reported as 1600 kB => 16384 bytes per page.
291
+ expect(derivePageSize(statLine({ pid: 1, ppid: 0, rssPages: 100 }), "VmRSS: 1600 kB")).toBe(
292
+ 16384,
293
+ );
294
+ });
295
+
296
+ it("recovers the ordinary 4 KiB case", () => {
297
+ expect(derivePageSize(statLine({ pid: 1, ppid: 0, rssPages: 100 }), "VmRSS: 400 kB")).toBe(
298
+ 4096,
299
+ );
300
+ });
301
+
302
+ it("falls back to 4 KiB when either side is missing or zero", () => {
303
+ expect(derivePageSize(statLine({ pid: 1, ppid: 0, rssPages: 0 }), "VmRSS: 400 kB")).toBe(4096);
304
+ expect(derivePageSize(statLine({ pid: 1, ppid: 0, rssPages: 100 }), "no vmrss here")).toBe(
305
+ 4096,
306
+ );
307
+ expect(derivePageSize("garbage", "VmRSS: 400 kB")).toBe(4096);
308
+ });
309
+ });
310
+
311
+ describe("parseMeminfo", () => {
312
+ it("converts kB entries to bytes", () => {
313
+ const m = parseMeminfo("MemTotal: 16316360 kB\nMemAvailable: 2500000 kB\n");
314
+ expect(m.get("MemTotal")).toBe(16316360 * 1024);
315
+ expect(m.get("MemAvailable")).toBe(2500000 * 1024);
316
+ });
317
+ });
318
+
319
+ describe("systemStats", () => {
320
+ it("reads load/mem and counts zombies from the snapshot", async () => {
321
+ const procs = new Map<number, ProcSample>([
322
+ [1, { pid: 1, ppid: 0, rss: 0, cpuSeconds: 0, state: "S", startToken: "t1" }],
323
+ [2, { pid: 2, ppid: 1, rss: 0, cpuSeconds: 0, state: "Z", startToken: "t2" }],
324
+ [3, { pid: 3, ppid: 1, rss: 0, cpuSeconds: 0, state: "Z", startToken: "t3" }],
325
+ ]);
326
+ const sys = await systemStats(procs, {
327
+ readSys: async (name) =>
328
+ name === "loadavg"
329
+ ? "15.23 12.52 9.86 5/2000 12345\n"
330
+ : "MemTotal: 100 kB\nMemAvailable: 40 kB\nSwapTotal: 50 kB\nSwapFree: 0 kB\n",
331
+ });
332
+ expect(sys.load).toEqual([15.23, 12.52, 9.86]);
333
+ expect(sys.zombies).toBe(2);
334
+ expect(sys.memAvailableBytes).toBe(40 * 1024);
335
+ expect(sys.swapFreeBytes).toBe(0);
336
+ });
337
+
338
+ it("falls back to node:os when /proc is unreadable, so the header is not blank", async () => {
339
+ // Off Linux there is no /proc at all; without this fallback macOS/BSD would
340
+ // render an empty system line.
341
+ const sys = await systemStats(new Map(), { readSys: async () => null });
342
+ expect(sys.memTotalBytes).toBeGreaterThan(0);
343
+ expect(sys.memAvailableBytes).toBeGreaterThan(0);
344
+ // node:os has no swap breakdown — that stays unknown rather than guessed.
345
+ expect(sys.swapTotalBytes).toBeNull();
346
+ if (process.platform !== "win32") expect(sys.load).not.toBeNull();
347
+ });
348
+ });
349
+
350
+ describe("humanBytes", () => {
351
+ it("keeps a decimal below 100 so Gi totals match free -h", () => {
352
+ expect(humanBytes(15.6 * 1024 ** 3)).toBe("15.6Gi");
353
+ });
354
+ it("rounds at three significant digits", () => {
355
+ expect(humanBytes(376 * 1024 ** 2)).toBe("376Mi");
356
+ });
357
+ it("renders unknown as a dash", () => {
358
+ expect(humanBytes(null)).toBe("-");
359
+ });
360
+ });
361
+
362
+ describe("real host adapters", () => {
363
+ // Every other test here injects fake readers, so the DEFAULT ones — /proc on
364
+ // Linux, the `ps` fallback elsewhere — were never executed. These call them for
365
+ // real (read-only): the only check that the adapters work on the machine the
366
+ // code ships to.
367
+ //
368
+ // Windows has neither /proc nor a POSIX `ps`, so there is nothing to read
369
+ // there. The contract on that platform is "degrade to empty", not "throw", and
370
+ // that is what the win32 branch asserts — `ay ps` must stay a no-op rather
371
+ // than a crash.
372
+ const posix = process.platform !== "win32";
373
+
374
+ it.runIf(posix)("snapshotProcs sees this very process on the real host", async () => {
375
+ const procs = await snapshotProcs();
376
+ expect(procs.size).toBeGreaterThan(0);
377
+ const self = procs.get(process.pid);
378
+ expect(self).toBeDefined();
379
+ expect(self!.rss).toBeGreaterThan(0);
380
+ });
381
+
382
+ it.runIf(!posix)("snapshotProcs degrades to empty on Windows instead of throwing", async () => {
383
+ await expect(snapshotProcs()).resolves.toBeInstanceOf(Map);
384
+ });
385
+
386
+ it("systemStats reads whatever the real host exposes, without throwing", async () => {
387
+ const sys = await systemStats(await snapshotProcs());
388
+ // Any single field can be unreadable depending on platform/sandbox; the
389
+ // contract is that it returns a well-formed record either way.
390
+ expect(sys.memTotalBytes === null || sys.memTotalBytes > 0).toBe(true);
391
+ expect(sys.ncpu === null || sys.ncpu > 0).toBe(true);
392
+ expect(sys.zombies).toBeGreaterThanOrEqual(0);
393
+ });
394
+
395
+ it.runIf(posix)("sampleTrees attributes this process's own tree", async () => {
396
+ const { trees, unattributed } = await sampleTrees([process.pid], { windowMs: 100 });
397
+ const mine = trees.get(process.pid);
398
+ expect(mine).toBeDefined();
399
+ expect(mine!.procs).toBeGreaterThanOrEqual(1);
400
+ expect(unattributed.procs).toBeGreaterThanOrEqual(0);
401
+ });
402
+
403
+ it.runIf(!posix)("sampleTrees still yields a zero row per root on Windows", async () => {
404
+ // There is nothing to sample there, but every requested root MUST come back
405
+ // with an entry: cmdPs renders `trees.get(pid) ?? zero`, and a missing entry
406
+ // would drop the agent from the table entirely rather than showing it at 0.
407
+ const { trees, unattributed } = await sampleTrees([process.pid], { windowMs: 100 });
408
+ expect(trees.size).toBe(1);
409
+ const mine = trees.get(process.pid)!;
410
+ expect(mine.procs).toBe(0);
411
+ expect(mine.rss).toBe(0);
412
+ expect(unattributed.procs).toBe(0);
413
+ });
414
+ });
415
+
416
+ describe("systemStats degradation", () => {
417
+ // Injected readers, so every branch runs on every platform — the real-adapter
418
+ // block above can only exercise whichever shape the host happens to have.
419
+ const noProcs = new Map<number, ProcSample>();
420
+ const proc = (over: Partial<ProcSample> = {}): ProcSample => ({
421
+ pid: 1,
422
+ ppid: 0,
423
+ rss: 0,
424
+ utime: 0,
425
+ stime: 0,
426
+ state: "S",
427
+ ...over,
428
+ });
429
+
430
+ it("prefers /proc loadavg and meminfo when both parse", async () => {
431
+ const sys = await systemStats(noProcs, {
432
+ readSys: async (name) =>
433
+ name === "loadavg"
434
+ ? "1.50 2.50 3.50 2/1234 5678"
435
+ : "MemTotal: 16000 kB\nMemAvailable: 4000 kB\nSwapTotal: 2000 kB\nSwapFree: 500 kB\n",
436
+ });
437
+ expect(sys.load).toEqual([1.5, 2.5, 3.5]);
438
+ expect(sys.memTotalBytes).toBe(16000 * 1024);
439
+ expect(sys.memAvailableBytes).toBe(4000 * 1024);
440
+ expect(sys.swapTotalBytes).toBe(2000 * 1024);
441
+ expect(sys.swapFreeBytes).toBe(500 * 1024);
442
+ });
443
+
444
+ it("falls back to node:os when /proc is unreadable", async () => {
445
+ const sys = await systemStats(noProcs, { readSys: async () => null });
446
+ // No /proc means no swap breakdown anywhere — the header just omits it.
447
+ expect(sys.swapTotalBytes).toBeNull();
448
+ expect(sys.swapFreeBytes).toBeNull();
449
+ expect(sys.memTotalBytes).toBeGreaterThan(0);
450
+ expect(sys.ncpu).toBeGreaterThan(0);
451
+ });
452
+
453
+ it("rejects a truncated loadavg rather than reporting NaN", async () => {
454
+ const sys = await systemStats(noProcs, {
455
+ readSys: async (name) => (name === "loadavg" ? "1.50 2.50" : null),
456
+ });
457
+ // Two fields is not a load average; it must not become [1.5, 2.5, NaN].
458
+ expect(sys.load === null || sys.load.every(Number.isFinite)).toBe(true);
459
+ });
460
+
461
+ it("rejects a non-numeric loadavg", async () => {
462
+ const sys = await systemStats(noProcs, {
463
+ readSys: async (name) => (name === "loadavg" ? "x y z" : null),
464
+ });
465
+ expect(sys.load === null || sys.load.every(Number.isFinite)).toBe(true);
466
+ });
467
+
468
+ it("counts zombies from the snapshot, not from /proc", async () => {
469
+ const procs = new Map<number, ProcSample>([
470
+ [1, proc({ pid: 1, state: "Z" })],
471
+ [2, proc({ pid: 2, state: "S" })],
472
+ [3, proc({ pid: 3, state: "Z" })],
473
+ ]);
474
+ const sys = await systemStats(procs, { readSys: async () => null });
475
+ expect(sys.zombies).toBe(2);
476
+ });
477
+ });
478
+
479
+ describe("default adapter failure paths", () => {
480
+ // Each adapter has a catch that turns "this host has no such thing" into an
481
+ // empty result instead of an exception — that is what lets `ay ps` degrade
482
+ // rather than crash off Linux. Reachable on every platform by asking for
483
+ // something that cannot exist.
484
+ it("defaultReadStat returns null for an impossible pid", async () => {
485
+ await expect(defaultReadStat(-1)).resolves.toBeNull();
486
+ });
487
+
488
+ it("defaultReadSys returns null for a file that is not there", async () => {
489
+ await expect(defaultReadSys("definitely-not-a-proc-file")).resolves.toBeNull();
490
+ });
491
+
492
+ it("defaultListPids returns a list or an empty array, never throws", async () => {
493
+ // Linux: the real pid list. Elsewhere: readdir("/proc") fails and the catch
494
+ // hands back [] so snapshotProcs can fall through to the `ps` path.
495
+ const pids = await defaultListPids();
496
+ expect(Array.isArray(pids)).toBe(true);
497
+ if (process.platform === "linux") expect(pids.length).toBeGreaterThan(0);
498
+ else expect(pids).toEqual([]);
499
+ });
500
+
501
+ it("defaultReadPsTable returns text where ps exists and null where it does not", async () => {
502
+ const table = await defaultReadPsTable();
503
+ if (process.platform === "win32") expect(table).toBeNull();
504
+ else expect(typeof table).toBe("string");
505
+ });
506
+ });