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
@@ -215,4 +215,4 @@ function allowsSkipPermissions() {
215
215
 
216
216
  //#endregion
217
217
  export { getProvisionRoot as a, getWorkspaceRoot as c, isProvisionAllowed as d, resolveSpawnCwd as f, getProvisionHook as i, hasProvisionHook as l, getMaxAgents as n, getSpawnHook as o, setWorkspaceRoot as p, getMinFreeMb as r, getSpawnWaitMs as s, allowsSkipPermissions as t, hasSpawnHook as u };
218
- //# sourceMappingURL=workspaceConfig-HiWQzq6A.js.map
218
+ //# sourceMappingURL=workspaceConfig-CBKlgA_q.js.map
@@ -1,5 +1,5 @@
1
- import { O as listRecords } from "./subcommands-DtF31RoW.js";
2
- import { a as getProvisionRoot } from "./workspaceConfig-HiWQzq6A.js";
1
+ import { O as listRecords } from "./subcommands-ujgVZRT3.js";
2
+ import { a as getProvisionRoot } from "./workspaceConfig-CBKlgA_q.js";
3
3
  import { existsSync } from "fs";
4
4
  import { lstat, opendir } from "fs/promises";
5
5
  import path from "path";
@@ -424,4 +424,4 @@ async function cmdWs(args) {
424
424
 
425
425
  //#endregion
426
426
  export { isPathInside as a, walkWorkspaces as c, importProvisionModule as i, workspaceStatus as l, cmdWs as n, loadProvision as o, collectWorkspaces as r, resolveOperand as s, WS_JSON_SCHEMA as t };
427
- //# sourceMappingURL=ws-aKRZpI9b.js.map
427
+ //# sourceMappingURL=ws-B4ZoCy71.js.map
@@ -3,10 +3,10 @@ import "./globalPidIndex-Dq4KY2IV.js";
3
3
  import "./messageLog-BYFwioFY.js";
4
4
  import "./e2e-CUMZC53I.js";
5
5
  import "./configShared-DtHMBCl7.js";
6
- import "./subcommands-DtF31RoW.js";
6
+ import "./subcommands-ujgVZRT3.js";
7
7
  import "./notifyStore-Cbw2ZJ0X.js";
8
8
  import "./webrtcLink-DyCj380i.js";
9
- import "./remotes-iMhdZytp.js";
10
- import { a as isPathInside, c as walkWorkspaces, i as importProvisionModule, l as workspaceStatus, n as cmdWs, o as loadProvision, r as collectWorkspaces, s as resolveOperand, t as WS_JSON_SCHEMA } from "./ws-aKRZpI9b.js";
9
+ import "./remotes-DdAfueOu.js";
10
+ import { a as isPathInside, c as walkWorkspaces, i as importProvisionModule, l as workspaceStatus, n as cmdWs, o as loadProvision, r as collectWorkspaces, s as resolveOperand, t as WS_JSON_SCHEMA } from "./ws-B4ZoCy71.js";
11
11
 
12
12
  export { WS_JSON_SCHEMA, cmdWs, collectWorkspaces, isPathInside, loadProvision, walkWorkspaces, workspaceStatus };
package/lab/ui/sw.js CHANGED
@@ -175,7 +175,16 @@ self.addEventListener("fetch", (e) => {
175
175
  // If the request came from a preview iframe, proxy it to that iframe's port
176
176
  // (appPath = the request's own path); otherwise fall back to the shell handler.
177
177
  async function maybePreviewFromClient(e, req, url) {
178
- const clientId = e.clientId || e.resultingClientId;
178
+ // A top-level navigation has NO existing client — only a *reserved* one, named
179
+ // by resultingClientId. clients.get() never settles for a reserved client: it
180
+ // becomes gettable only once the navigation commits, which can't happen until
181
+ // this respondWith() settles. Awaiting it here deadlocked every navigation
182
+ // once the worker took control — the first tab (still uncontrolled) loaded
183
+ // fine, every later tab on the same origin hung blank forever. Preview
184
+ // navigations are already handled by the PREVIEW branch in the fetch listener
185
+ // above, so navigations never need the client lookup at all.
186
+ if (req.mode === "navigate") return shellFetch(req, url);
187
+ const clientId = e.clientId;
179
188
  if (clientId) {
180
189
  const client = await self.clients.get(clientId).catch(() => null);
181
190
  const cm = client && PREVIEW.exec(new URL(client.url).pathname);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.266.0",
3
+ "version": "1.267.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,389 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import {
5
+ formatSystemLine,
6
+ isSelfTree,
7
+ parsePpidFromStat,
8
+ readPpidSync,
9
+ renderTable,
10
+ repoLabel,
11
+ type PsRow,
12
+ } from "./cmdPs.ts";
13
+ import type { SystemStats, TreeStats } from "./procStats.ts";
14
+
15
+ const stats = (over: Partial<TreeStats> = {}): TreeStats => ({
16
+ pid: 1,
17
+ rss: 0,
18
+ cpuPercent: 0,
19
+ procs: 1,
20
+ ...over,
21
+ });
22
+
23
+ const row = (over: Partial<PsRow> = {}): PsRow => ({
24
+ pid: 100,
25
+ cli: "claude",
26
+ status: "idle",
27
+ cwd: "/code/snomiao/foo/tree/main",
28
+ repo: "foo",
29
+ stats: stats(),
30
+ self: false,
31
+ ...over,
32
+ });
33
+
34
+ const sys = (over: Partial<SystemStats> = {}): SystemStats => ({
35
+ load: [15.23, 12.52, 9.86],
36
+ ncpu: 8,
37
+ memTotalBytes: 16 * 1024 ** 3,
38
+ memAvailableBytes: 2 * 1024 ** 3,
39
+ swapTotalBytes: 5 * 1024 ** 3,
40
+ swapFreeBytes: 1 * 1024 ** 3,
41
+ zombies: 0,
42
+ ...over,
43
+ });
44
+
45
+ describe("repoLabel", () => {
46
+ it("names the repo, not the branch dir every worktree shares", () => {
47
+ expect(repoLabel("/code/snomiao/agent-yes/tree/main")).toBe("agent-yes");
48
+ expect(repoLabel("/code/snomiao/agent-yes/tree/feat/x")).toBe("agent-yes");
49
+ });
50
+
51
+ it("falls back to the last segment outside a tree layout", () => {
52
+ expect(repoLabel("/srv/plain-checkout")).toBe("plain-checkout");
53
+ });
54
+
55
+ it("falls back to the shortened path when there is no segment at all", () => {
56
+ // `/` and "" split into no segments; without the fallback the REPO column
57
+ // would print "undefined".
58
+ expect(repoLabel("/")).toBe("/");
59
+ expect(repoLabel("")).toBe("");
60
+ });
61
+
62
+ it("abbreviates a home-relative fallback with ~", () => {
63
+ expect(repoLabel(homedir())).toBe(path.basename(homedir()));
64
+ });
65
+
66
+ it("handles Windows-style separators too", () => {
67
+ // The registry stores whatever the wrapper recorded, so both shapes reach
68
+ // here regardless of the host we are rendering on.
69
+ expect(repoLabel("C:\\code\\snomiao\\agent-yes\\tree\\main")).toBe("agent-yes");
70
+ expect(repoLabel("C:\\srv\\plain-checkout")).toBe("plain-checkout");
71
+ });
72
+
73
+ it("does not index out of bounds on a root-level tree dir", () => {
74
+ expect(repoLabel("/tree/main")).toBe("main");
75
+ });
76
+ });
77
+
78
+ describe("formatSystemLine", () => {
79
+ it("puts the oversubscription ratio next to the raw load", () => {
80
+ // "15.23" is meaningless without the core count beside it.
81
+ expect(formatSystemLine(sys())).toContain("load 15.23 12.52 9.86 (8 cpu, 1.9x)");
82
+ });
83
+
84
+ it("reports mem as used/total", () => {
85
+ expect(formatSystemLine(sys())).toContain("mem 14.0Gi/16.0Gi");
86
+ });
87
+
88
+ it("calls out an exhausted swap in words", () => {
89
+ // Full swap is the line between "loaded" and "one alloc from the OOM killer".
90
+ expect(formatSystemLine(sys({ swapFreeBytes: 0 }))).toContain("FULL");
91
+ expect(formatSystemLine(sys())).not.toContain("FULL");
92
+ });
93
+
94
+ it("omits swap entirely when the box has none", () => {
95
+ expect(formatSystemLine(sys({ swapTotalBytes: 0 }))).not.toContain("swap");
96
+ });
97
+
98
+ it("mentions zombies only when there are some", () => {
99
+ expect(formatSystemLine(sys({ zombies: 1440 }))).toContain("zombies 1440");
100
+ expect(formatSystemLine(sys())).not.toContain("zombies");
101
+ });
102
+
103
+ it("degrades to whatever it could read", () => {
104
+ const line = formatSystemLine(
105
+ sys({ load: null, memTotalBytes: null, memAvailableBytes: null, swapTotalBytes: null }),
106
+ );
107
+ expect(line).toBe("");
108
+ });
109
+ });
110
+
111
+ describe("renderTable", () => {
112
+ const rows = [
113
+ row({ pid: 1, repo: "big", stats: stats({ rss: 1024 ** 3, cpuPercent: 53.8, procs: 17 }) }),
114
+ row({ pid: 22222, repo: "self", self: true, stats: stats({ rss: 1024 ** 2 }) }),
115
+ ];
116
+
117
+ it("marks the tree the command is running in", () => {
118
+ const out = renderTable(rows, null);
119
+ const selfLine = out.split("\n").find((l) => l.startsWith("22222")) as string;
120
+ expect(selfLine).toContain("← this session");
121
+ expect(out.split("\n").find((l) => l.startsWith("1 "))).not.toContain("← this session");
122
+ });
123
+
124
+ it("keeps every column aligned once the wide unmanaged row is present", () => {
125
+ // "unmanaged" is wider than any real CLI name — sizing without it knocked
126
+ // the whole table out of line.
127
+ const out = renderTable(rows, stats({ pid: 0, rss: 2 * 1024 ** 3, procs: 1359 }));
128
+ const lines = out.split("\n");
129
+ const rssCol = (l: string) => l.indexOf("RSS");
130
+ const header = lines[0] as string;
131
+ expect(lines.some((l) => l.includes("unmanaged"))).toBe(true);
132
+ // Every row's PROCS value must end at the same column as the header's.
133
+ const procsEnd = header.indexOf("PROCS") + "PROCS".length;
134
+ for (const l of lines.slice(1)) {
135
+ expect(l.slice(0, procsEnd).trimEnd().length).toBeLessThanOrEqual(procsEnd);
136
+ }
137
+ expect(rssCol(header)).toBeGreaterThan(0);
138
+ });
139
+
140
+ it("omits the unmanaged row when the box is fully accounted for", () => {
141
+ expect(renderTable(rows, stats({ procs: 0 }))).not.toContain("unmanaged");
142
+ });
143
+ });
144
+
145
+ // Hoisted mocks with a mutable fixture. The earlier version re-mocked inside a
146
+ // helper with vi.resetModules() per call; when a reset raced, the REAL
147
+ // listRecords ran and returned whatever agents happened to be on the box — which
148
+ // looks like a pass locally (rows appear) and an empty table on CI (no agents).
149
+ const fixture: {
150
+ records: Record<string, unknown>[];
151
+ trees: Map<number, TreeStats>;
152
+ unattributed: TreeStats;
153
+ system: SystemStats;
154
+ } = {
155
+ records: [],
156
+ trees: new Map(),
157
+ unattributed: { pid: 0, rss: 0, cpuPercent: 0, procs: 0 },
158
+ system: sys(),
159
+ };
160
+
161
+ vi.mock("./subcommands.ts", () => ({
162
+ listRecords: vi.fn(async () => fixture.records),
163
+ deriveLiveState: vi.fn(async (r: { status: string }) => ({ state: r.status })),
164
+ }));
165
+
166
+ vi.mock("./procStats.ts", async () => {
167
+ const actual = await vi.importActual<typeof import("./procStats.ts")>("./procStats.ts");
168
+ return {
169
+ ...actual,
170
+ snapshotProcs: vi.fn(async () => new Map()),
171
+ systemStats: vi.fn(async () => fixture.system),
172
+ sampleTrees: vi.fn(async () => ({
173
+ trees: fixture.trees,
174
+ unattributed: fixture.unattributed,
175
+ })),
176
+ };
177
+ });
178
+
179
+ describe("cmdPs", () => {
180
+ const rec = (over: Record<string, unknown> = {}) => ({
181
+ pid: 100,
182
+ cli: "claude",
183
+ prompt: null,
184
+ cwd: "/code/snomiao/foo/tree/main",
185
+ log_file: null,
186
+ status: "active",
187
+ exit_code: null,
188
+ exit_reason: null,
189
+ started_at: 0,
190
+ ...over,
191
+ });
192
+
193
+ async function run(
194
+ rest: string[],
195
+ opts: {
196
+ records?: Record<string, unknown>[];
197
+ trees?: Map<number, TreeStats>;
198
+ unattributed?: TreeStats;
199
+ system?: SystemStats;
200
+ } = {},
201
+ ) {
202
+ fixture.records = opts.records ?? [rec()];
203
+ fixture.trees = opts.trees ?? new Map();
204
+ fixture.unattributed = opts.unattributed ?? stats({ pid: 0, procs: 0 });
205
+ fixture.system = opts.system ?? sys();
206
+
207
+ const out: string[] = [];
208
+ const err: string[] = [];
209
+ const origOut = process.stdout.write.bind(process.stdout);
210
+ const origErr = process.stderr.write.bind(process.stderr);
211
+ (process.stdout as any).write = (s: any) => (out.push(String(s)), true);
212
+ (process.stderr as any).write = (s: any) => (err.push(String(s)), true);
213
+ try {
214
+ const { cmdPs } = await import("./cmdPs.ts");
215
+ const code = await cmdPs(rest);
216
+ return { code, out: out.join(""), err: err.join("") };
217
+ } finally {
218
+ process.stdout.write = origOut;
219
+ process.stderr.write = origErr;
220
+ }
221
+ }
222
+
223
+ /** pids in render order, from the table body. */
224
+ const pids = (out: string) =>
225
+ out
226
+ .split("\n")
227
+ .map((l) => l.trim().split(/\s+/)[0])
228
+ .filter((p) => /^\d+$/.test(p ?? ""));
229
+
230
+ it("prints the box vitals header and a row per agent", async () => {
231
+ const r = await run([], {
232
+ trees: new Map([[100, stats({ pid: 100, rss: 1024 ** 2, procs: 3 })]]),
233
+ });
234
+ expect(r.code).toBe(0);
235
+ expect(r.out).toContain("load 15.23");
236
+ expect(r.out).toContain("PID");
237
+ expect(pids(r.out)).toEqual(["100"]);
238
+ expect(r.out).toContain("foo");
239
+ });
240
+
241
+ it("says so and still exits 0 when nothing is running", async () => {
242
+ const r = await run([], { records: [] });
243
+ expect(r.code).toBe(0);
244
+ expect(r.err).toContain("no running agents");
245
+ expect(r.out).toBe("");
246
+ });
247
+
248
+ it("names the keyword that matched nothing", async () => {
249
+ const r = await run(["nope"], { records: [] });
250
+ expect(r.err).toContain('no agents matched "nope"');
251
+ });
252
+
253
+ it("--json emits the rollup with system, agents and unattributed", async () => {
254
+ const r = await run(["--json"], {
255
+ trees: new Map([[100, stats({ pid: 100, rss: 4096, cpuPercent: 12.345, procs: 2 })]]),
256
+ unattributed: stats({ pid: 0, rss: 512, cpuPercent: 1.5, procs: 9 }),
257
+ });
258
+ expect(r.code).toBe(0);
259
+ const parsed = JSON.parse(r.out);
260
+ expect(parsed.system.ncpu).toBe(8);
261
+ expect(parsed.agents).toHaveLength(1);
262
+ expect(parsed.agents[0]).toMatchObject({ pid: 100, cli: "claude", rssBytes: 4096, procs: 2 });
263
+ // Rounded to 2dp on the way out, so consumers get a stable number.
264
+ expect(parsed.agents[0].cpuPercent).toBe(12.35);
265
+ expect(parsed.unattributed).toMatchObject({ rssBytes: 512, procs: 9 });
266
+ });
267
+
268
+ it("--sort orders by the chosen resource, and --tree keeps forest order", async () => {
269
+ const records = [rec({ pid: 1 }), rec({ pid: 2 }), rec({ pid: 3 })];
270
+ const trees = new Map([
271
+ [1, stats({ pid: 1, rss: 10, cpuPercent: 90, procs: 1 })],
272
+ [2, stats({ pid: 2, rss: 30, cpuPercent: 10, procs: 7 })],
273
+ [3, stats({ pid: 3, rss: 20, cpuPercent: 50, procs: 3 })],
274
+ ]);
275
+ // rss is the default: biggest first.
276
+ expect(pids((await run([], { records, trees })).out)).toEqual(["2", "3", "1"]);
277
+ expect(pids((await run(["--sort", "cpu"], { records, trees })).out)).toEqual(["1", "3", "2"]);
278
+ expect(pids((await run(["--sort", "procs"], { records, trees })).out)).toEqual(["2", "3", "1"]);
279
+ expect(pids((await run(["--sort", "pid"], { records, trees })).out)).toEqual(["1", "2", "3"]);
280
+ // --tree opts out of sorting entirely, keeping ay ls's forest order.
281
+ expect(pids((await run(["--tree"], { records, trees })).out)).toEqual(["1", "2", "3"]);
282
+ });
283
+
284
+ it("renders a 0-usage row rather than dropping an agent the sampler missed", async () => {
285
+ const r = await run([], { trees: new Map() });
286
+ expect(pids(r.out)).toEqual(["100"]);
287
+ expect(r.out).toMatch(/100\s+claude\s+active\s+0\.0/);
288
+ });
289
+
290
+ it("--cwd scopes the listing to that directory", async () => {
291
+ // The scope is resolved to an absolute path before it reaches listRecords,
292
+ // so a relative --cwd can't silently match nothing.
293
+ const r = await run(["--cwd", "."], { trees: new Map() });
294
+ expect(r.code).toBe(0);
295
+ expect(pids(r.out)).toEqual(["100"]);
296
+ });
297
+
298
+ it("clamps a nonsense --interval up to the 100ms floor instead of sampling for 0s", async () => {
299
+ // A 0/NaN window would make every CPU% a divide-by-nothing artefact.
300
+ for (const bad of ["0", "-5", "not-a-number"]) {
301
+ const r = await run(["--interval", bad], { trees: new Map() });
302
+ expect(r.code).toBe(0);
303
+ }
304
+ });
305
+
306
+ it("--all asks for exited agents too", async () => {
307
+ const r = await run(["--all"], { trees: new Map() });
308
+ expect(r.code).toBe(0);
309
+ expect(pids(r.out)).toEqual(["100"]);
310
+ });
311
+
312
+ it("--help prints usage without doing any work", async () => {
313
+ const r = await run(["--help"]);
314
+ expect(r.code).toBe(0);
315
+ expect(r.out).toContain("Usage: ay ps");
316
+ expect(pids(r.out)).toEqual([]);
317
+ });
318
+
319
+ it("omits the header entirely when no box stat could be read", async () => {
320
+ // formatSystemLine returns "" in that case; printing a bare blank line above
321
+ // the table would look like a rendering bug.
322
+ const r = await run([], {
323
+ system: sys({
324
+ load: null,
325
+ memTotalBytes: null,
326
+ memAvailableBytes: null,
327
+ swapTotalBytes: null,
328
+ }),
329
+ });
330
+ expect(r.out.startsWith("PID")).toBe(true);
331
+ });
332
+ });
333
+
334
+ describe("isSelfTree", () => {
335
+ // The `← this session` marker: anything that later grows a kill affordance
336
+ // must not let you shoot the session issuing the command, so the ancestry walk
337
+ // has to be right. It reads /proc, but the walk itself is platform-independent.
338
+ const chain = (links: Record<number, number>) => (pid: number) => links[pid] ?? null;
339
+
340
+ it("finds the root several hops up the ancestry", () => {
341
+ expect(isSelfTree(10, 40, chain({ 40: 30, 30: 20, 20: 10 }), "linux")).toBe(true);
342
+ });
343
+
344
+ it("is true for the wrapper pid itself", () => {
345
+ expect(isSelfTree(10, 10, chain({}), "linux")).toBe(true);
346
+ });
347
+
348
+ it("is false for an unrelated tree", () => {
349
+ expect(isSelfTree(99, 40, chain({ 40: 30, 30: 1 }), "linux")).toBe(false);
350
+ });
351
+
352
+ it("gives up when the ancestry is unreadable rather than guessing", () => {
353
+ expect(isSelfTree(10, 40, () => null, "linux")).toBe(false);
354
+ });
355
+
356
+ it("terminates on a cycle instead of spinning", () => {
357
+ expect(isSelfTree(999, 40, chain({ 40: 41, 41: 40 }), "linux")).toBe(false);
358
+ });
359
+
360
+ it("returns false off Linux, where /proc does not exist", () => {
361
+ expect(isSelfTree(10, 40, chain({ 40: 10 }), "darwin")).toBe(false);
362
+ });
363
+
364
+ it("readPpidSync returns null for a pid that cannot be read", () => {
365
+ expect(readPpidSync(-1)).toBeNull();
366
+ });
367
+ });
368
+
369
+ describe("parsePpidFromStat", () => {
370
+ // /proc/<pid>/stat field 2 is the process name in parens, UNESCAPED. Splitting
371
+ // on whitespace or the first ")" mis-reads every process whose name contains
372
+ // either, so the fields after comm can only be located from the LAST ")".
373
+ it("reads the ppid of an ordinary line", () => {
374
+ expect(parsePpidFromStat("1234 (bash) S 1200 1234 1234 0 -1 4194304")).toBe(1200);
375
+ });
376
+
377
+ it("survives a process name containing spaces and parens", () => {
378
+ expect(parsePpidFromStat("1234 (foo) bar (baz) S 999 1234 0")).toBe(999);
379
+ });
380
+
381
+ it("returns null when there is no comm field to anchor on", () => {
382
+ expect(parsePpidFromStat("garbage without parens")).toBeNull();
383
+ });
384
+
385
+ it("returns null when the ppid field is missing or not a number", () => {
386
+ expect(parsePpidFromStat("1234 (bash) S")).toBeNull();
387
+ expect(parsePpidFromStat("1234 (bash) S notanumber 1234")).toBeNull();
388
+ });
389
+ });