pi-deadman 1.0.0 → 1.1.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
@@ -4,105 +4,35 @@ Dead man's switch for AI coding agents.
4
4
 
5
5
  Monitors macOS memory pressure, gates heavy operations, and auto-kills runaway processes before your system locks up.
6
6
 
7
- ## The Problem
8
-
9
- AI coding agents run builds, tests, installs, and browser automation with no awareness of system memory state. On an 8 GB Mac, this regularly pushes the system into swap thrashing — minutes of lag, system freezes, sometimes requiring a hard reboot.
10
-
11
7
  ## Install
12
8
 
13
9
  ```bash
14
- pi install git:github.com/gaherwar/pi-deadman
10
+ pi install npm:pi-deadman
15
11
  ```
16
12
 
17
- On first run, pi-deadman calibrates a baseline for your machine (~10 seconds of canary tests). After that, it runs in the background — no configuration needed.
13
+ On first run, it calibrates a baseline for your machine (~10 seconds). After that, it runs in the background — no configuration needed.
18
14
 
19
15
  **macOS only.** Silent no-op on other platforms.
20
16
 
21
17
  ## How It Works
22
18
 
23
- ### Pre-Execution Gate
24
-
25
- Every `bash` tool call goes through a tier × zone matrix:
26
-
27
- | | GREEN | YELLOW | ORANGE | RED |
28
- |---|---|---|---|---|
29
- | **Tier 0–1** (read-only: ls, cat, grep) | ✅ | ✅ | ✅ | ❌ |
30
- | **Tier 2** (medium: test, server, spawn) | ✅ | ✅ | ✅ | ❌ |
31
- | **Tier 3** (heavy: npm install, docker, build) | ✅ | ✅ | ❌ | ❌ |
32
- | **Tier 4** (destructive: kill, pkill, rm -rf) | ✅ | ✅ | ❌ | ❌ |
33
-
34
- When blocked in **ORANGE**, you choose: run anyway or free memory first.
35
- When blocked in **RED**, you must kill a process to proceed — or force-run after the first attempt.
36
-
37
- ### Background Monitor
38
-
39
- Polls system health at adaptive intervals (5s in GREEN → 1s in RED):
40
-
41
- - **Canary test** — micro-ops (array sort, Map ops, regex, JSON parse, Buffer alloc) timed via `performance.now()`. Slowdown ratio vs baseline determines zone.
42
- - **System signals** — swap usage, swap in/out rates, compression ratio, memorystatus level via `sysctl` and `vm_stat`.
43
- - **Process snapshots** — footprint (true memory) via `proc_pid_rusage` Python helper, stored in a ring buffer of 10 snapshots.
44
-
45
- ### Watchdog (Confirmed RED)
19
+ **Zones** Polls system health and classifies into GREEN, YELLOW, ORANGE, or RED based on memory pressure signals.
46
20
 
47
- When the system enters confirmed RED (3 consecutive RED polls), the watchdog auto-kills processes across **all pi sessions**. Processes with < 50 MB footprint are never kill candidates.
21
+ **Gate** Every `bash` tool call is checked against the current zone. Light commands always pass; heavy operations (builds, installs, docker) are blocked in ORANGE and RED.
48
22
 
49
- | Priority | Signal | Catches |
50
- |---|---|---|
51
- | 1. **Growing** | ≥100 MB delta across 3+ of 10 snapshots | Memory leaks, sawtooth patterns |
52
- | 2. **Swarm** | ≥3 same-name processes, combined ≥500 MB | Worker pools (vitest ×7, webpack workers) |
53
- | 3. **Heavy & young** | Age < 10 min AND footprint ≥ 200 MB | Burst allocators |
54
- | 4. **Newest** | Appeared after last stable non-RED state | Temporal correlation (largest only) |
55
- | 5. **No match** | Block commands, wait | Pressure from outside pi's tree |
56
-
57
- Two execution paths:
58
- - **Slow poll** — full canary + signals + footprint worker. Populates snapshot ring buffer.
59
- - **Fast watchdog** — independent 2s loop using `ps` (~17ms). Parses fresh `etime` for age. Walks children of ALL pi instances (cross-session). Acts even when the slow poll is stuck during system thrashing.
23
+ **Watchdog** In confirmed RED, automatically identifies and kills the process most likely causing the pressure. Runs independently so it works even when the system is thrashing.
60
24
 
61
25
  ## Commands
62
26
 
63
27
  | Command | Description |
64
28
  |---|---|
65
- | `/deadman` | Show current zone, memory stats, recent kills |
66
-
67
- ## Files
68
-
69
- ```
70
- pi-deadman/
71
- ├── extensions/
72
- │ ├── index.ts — Entry point: tool_call gate, /deadman command
73
- │ ├── monitor.ts — Background polling, adaptive intervals, watchdog
74
- │ ├── canary.ts — Performance micro-ops timing
75
- │ ├── signals.ts — macOS kernel metrics (sysctl, vm_stat)
76
- │ ├── zones.ts — Zone classification (GREEN/YELLOW/ORANGE/RED)
77
- │ ├── calibration.ts — Baseline persistence
78
- │ ├── keywords.ts — Command tier classification (25+ keywords, 5 tiers)
79
- │ ├── processes.ts — System-wide process list (footprint.py)
80
- │ ├── watchdog.ts — Kill target selection
81
- │ ├── tree.ts — Snapshot diffing, growth detection, swarm detection
82
- │ ├── worker.ts — Persistent Python worker for fast footprint queries
83
- │ └── logging.ts — JSONL structured logs (GC after 3 days)
84
- ├── helpers/
85
- │ ├── footprint.py — proc_pid_rusage footprint extraction
86
- │ └── footprint_worker.py — Persistent worker process
87
- └── __tests__/ — 202 tests across 13 files
88
- ```
89
-
90
- ## Logs
91
-
92
- Stored in `~/.pi/deadman/logs/`:
93
-
94
- - `system.jsonl` — zone, canary, swap, signals per poll
95
- - `decisions.jsonl` — pass/block/kill per tool call
96
- - `processes.jsonl` — top process snapshots
97
- - `tool_impact.jsonl` — swap delta per command
29
+ | `/deadman` | Show current zone and memory stats |
98
30
 
99
31
  ## Development
100
32
 
101
33
  ```bash
102
- cd pi-deadman
103
34
  npm install
104
- npm test # 202 tests across 13 files
105
- npx vitest --watch # watch mode
35
+ npm test
106
36
  ```
107
37
 
108
38
  ## License
package/package.json CHANGED
@@ -1,24 +1,32 @@
1
1
  {
2
2
  "name": "pi-deadman",
3
- "version": "1.0.0",
4
- "description": "Dead man's switch for AI coding agents — monitors macOS memory pressure, gates heavy operations, and auto-kills runaway processes before your system locks up.",
3
+ "version": "1.1.0",
4
+ "description": "Dead man's switch for AI coding agents — monitors memory pressure, gates heavy operations, and kills runaway processes.",
5
5
  "type": "module",
6
- "keywords": ["pi-package", "memory", "macos", "watchdog", "system-health", "deadman"],
6
+ "keywords": [
7
+ "pi-package",
8
+ "memory",
9
+ "macos",
10
+ "watchdog",
11
+ "system-health",
12
+ "deadman"
13
+ ],
7
14
  "pi": {
8
- "extensions": ["./extensions/index.ts"]
15
+ "extensions": [
16
+ "./extensions/index.ts"
17
+ ]
9
18
  },
10
19
  "scripts": {
11
20
  "test": "vitest run",
12
21
  "test:watch": "vitest"
13
22
  },
14
23
  "devDependencies": {
15
- "vitest": "^3.0.0",
16
24
  "@types/node": "^22.0.0",
17
- "typescript": "^5.7.0"
25
+ "typescript": "^5.7.0",
26
+ "vitest": "^3.0.0"
18
27
  },
19
- "dependencies": {
20
- "@sinclair/typebox": "^0.34.0"
21
- },
22
- "os": ["darwin"],
28
+ "os": [
29
+ "darwin"
30
+ ],
23
31
  "license": "MIT"
24
32
  }
@@ -1,73 +0,0 @@
1
- // __tests__/calibration.test.ts
2
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
- import { saveBaseline, loadBaseline, type Baseline, DEFAULT_BASELINE_MS } from "../extensions/calibration";
4
- import * as fs from "node:fs";
5
- import * as path from "node:path";
6
- import * as os from "node:os";
7
-
8
- describe("Baseline persistence", () => {
9
- let tmpDir: string;
10
- let baselinePath: string;
11
-
12
- beforeEach(() => {
13
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deadman-test-"));
14
- baselinePath = path.join(tmpDir, "baseline.json");
15
- });
16
-
17
- afterEach(() => {
18
- fs.rmSync(tmpDir, { recursive: true, force: true });
19
- });
20
-
21
- it("saves and loads a baseline", () => {
22
- const baseline: Baseline = {
23
- canary_ms: 7.5,
24
- calibrated_at: "2026-02-28T10:00:00.000Z",
25
- source: "calibrated",
26
- };
27
- saveBaseline(baseline, baselinePath);
28
- const loaded = loadBaseline(baselinePath);
29
- expect(loaded).toEqual(baseline);
30
- });
31
-
32
- it("returns null for missing file", () => {
33
- expect(loadBaseline("/nonexistent/path/baseline.json")).toBeNull();
34
- });
35
-
36
- it("returns null for corrupted JSON", () => {
37
- fs.writeFileSync(baselinePath, "not json{{{");
38
- expect(loadBaseline(baselinePath)).toBeNull();
39
- });
40
-
41
- it("returns null for incomplete data", () => {
42
- fs.writeFileSync(baselinePath, JSON.stringify({ canary_ms: 5 }));
43
- expect(loadBaseline(baselinePath)).toBeNull();
44
- });
45
-
46
- it("creates parent directories if needed", () => {
47
- const deepPath = path.join(tmpDir, "a", "b", "c", "baseline.json");
48
- const baseline: Baseline = {
49
- canary_ms: 8.0,
50
- calibrated_at: "2026-02-28T10:00:00.000Z",
51
- source: "calibrated",
52
- };
53
- saveBaseline(baseline, deepPath);
54
- const loaded = loadBaseline(deepPath);
55
- expect(loaded).toEqual(baseline);
56
- });
57
-
58
- it("default baseline backward-compatible with missing source field", () => {
59
- fs.writeFileSync(baselinePath, JSON.stringify({
60
- canary_ms: 9.0,
61
- calibrated_at: "2026-02-28T10:00:00.000Z",
62
- }));
63
- const loaded = loadBaseline(baselinePath);
64
- expect(loaded).not.toBeNull();
65
- expect(loaded!.source).toBe("calibrated");
66
- });
67
- });
68
-
69
- describe("DEFAULT_BASELINE_MS", () => {
70
- it("is 10ms (conservative default)", () => {
71
- expect(DEFAULT_BASELINE_MS).toBe(10.0);
72
- });
73
- });
@@ -1,68 +0,0 @@
1
- // __tests__/canary.test.ts
2
- import { describe, it, expect } from "vitest";
3
- import { runCanary, type CanaryResult } from "../extensions/canary";
4
-
5
- describe("runCanary", () => {
6
- it("returns a CanaryResult with all 5 sub-timings", async () => {
7
- const result = await runCanary();
8
- expect(result).toHaveProperty("sysctl_ms");
9
- expect(result).toHaveProperty("spawn_ms");
10
- expect(result).toHaveProperty("read_ms");
11
- expect(result).toHaveProperty("dir_ms");
12
- expect(result).toHaveProperty("alloc_ms");
13
- expect(result).toHaveProperty("total_ms");
14
- });
15
-
16
- it("all timings are positive numbers", async () => {
17
- const result = await runCanary();
18
- expect(result.sysctl_ms).toBeGreaterThan(0);
19
- expect(result.spawn_ms).toBeGreaterThan(0);
20
- expect(result.read_ms).toBeGreaterThan(0);
21
- expect(result.dir_ms).toBeGreaterThan(0);
22
- expect(result.alloc_ms).toBeGreaterThan(0);
23
- expect(result.total_ms).toBeGreaterThan(0);
24
- });
25
-
26
- it("total_ms equals sum of 5 sub-timings", async () => {
27
- const result = await runCanary();
28
- const sum = result.sysctl_ms + result.spawn_ms + result.read_ms + result.dir_ms + result.alloc_ms;
29
- expect(result.total_ms).toBeCloseTo(sum, 1);
30
- });
31
-
32
- it("completes in under 500ms on a healthy system", async () => {
33
- const result = await runCanary();
34
- expect(result.total_ms).toBeLessThan(500);
35
- });
36
-
37
- it("sysctl_ms reads kern.ostype successfully", async () => {
38
- const result = await runCanary();
39
- expect(result.sysctl_ms).toBeLessThan(50);
40
- });
41
-
42
- it("spawn_ms spawns a real process", async () => {
43
- const result = await runCanary();
44
- expect(result.spawn_ms).toBeLessThan(100);
45
- });
46
-
47
- it("read_ms reads a real file", async () => {
48
- const result = await runCanary();
49
- expect(result.read_ms).toBeLessThan(50);
50
- });
51
-
52
- it("dir_ms scans a real directory", async () => {
53
- const result = await runCanary();
54
- expect(result.dir_ms).toBeLessThan(50);
55
- });
56
-
57
- it("alloc_ms allocates and fills memory", async () => {
58
- const result = await runCanary();
59
- expect(result.alloc_ms).toBeLessThan(50);
60
- });
61
-
62
- it("is deterministic-ish — two runs produce similar results", async () => {
63
- const r1 = await runCanary();
64
- const r2 = await runCanary();
65
- expect(r2.total_ms).toBeLessThan(r1.total_ms * 5);
66
- expect(r1.total_ms).toBeLessThan(r2.total_ms * 5);
67
- });
68
- });
@@ -1,188 +0,0 @@
1
- // __tests__/fast-watchdog.test.ts — integration test for the fast watchdog loop
2
- // Spawns a real child process, seeds growth evidence, forces RED, verifies kill
3
-
4
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
- import { Monitor } from "../extensions/monitor";
6
- import { Zone } from "../extensions/zones";
7
- import { spawn, type ChildProcess } from "node:child_process";
8
- import * as fs from "node:fs";
9
- import * as path from "node:path";
10
- import * as os from "node:os";
11
-
12
- describe("Fast Watchdog Integration", () => {
13
- let tmpDir: string;
14
- let baselinePath: string;
15
- let logDir: string;
16
- let monitor: Monitor;
17
- let childProc: ChildProcess | null = null;
18
-
19
- beforeEach(() => {
20
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deadman-watchdog-int-"));
21
- baselinePath = path.join(tmpDir, "baseline.json");
22
- logDir = path.join(tmpDir, "logs");
23
-
24
- fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
25
- fs.writeFileSync(baselinePath, JSON.stringify({
26
- canary_ms: 5.0,
27
- calibrated_at: new Date().toISOString(),
28
- source: "test",
29
- }));
30
-
31
- monitor = new Monitor({ baselinePath, logDir });
32
- });
33
-
34
- afterEach(() => {
35
- monitor.stop();
36
- if (childProc && !childProc.killed) {
37
- childProc.kill("SIGKILL");
38
- }
39
- fs.rmSync(tmpDir, { recursive: true, force: true });
40
- });
41
-
42
- /**
43
- * Seed the watchdog's snapshot history with fake growth data for a PID.
44
- * This simulates the monitor having observed the process growing over time.
45
- */
46
- function seedGrowthHistory(pid: number, name: string) {
47
- const state = monitor.getWatchdogState();
48
- state.snapshotHistory = [
49
- [{ pid, name, footprint_mb: 200, age_seconds: 10 }],
50
- [{ pid, name, footprint_mb: 400, age_seconds: 15 }],
51
- [{ pid, name, footprint_mb: 600, age_seconds: 20 }],
52
- [{ pid, name, footprint_mb: 800, age_seconds: 25 }],
53
- ];
54
- }
55
-
56
- it("kills a growing child process within 6 seconds of confirmed RED", async () => {
57
- childProc = spawn("sleep", ["60"], { stdio: "ignore" });
58
- const childPid = childProc.pid!;
59
- expect(childPid).toBeGreaterThan(0);
60
-
61
- const kills: any[] = [];
62
- monitor.onAutoKill((decision) => {
63
- kills.push(decision);
64
- });
65
-
66
- monitor.start();
67
- await new Promise(r => setTimeout(r, 1500));
68
-
69
- // Seed growth evidence for the child — simulates monitor having tracked it
70
- seedGrowthHistory(childPid, "sleep");
71
-
72
- // Force RED confirmed
73
- monitor._forceZone(Zone.RED, true);
74
-
75
- // Wait up to 6 seconds for the fast watchdog to fire
76
- const deadline = Date.now() + 6000;
77
- while (Date.now() < deadline) {
78
- await new Promise(r => setTimeout(r, 200));
79
- try {
80
- process.kill(childPid, 0);
81
- } catch {
82
- break;
83
- }
84
- }
85
-
86
- let alive = true;
87
- try {
88
- process.kill(childPid, 0);
89
- } catch {
90
- alive = false;
91
- }
92
-
93
- expect(alive).toBe(false);
94
- expect(kills.length).toBeGreaterThanOrEqual(1);
95
- expect(kills[0].targets.some((t: any) => t.pid === childPid)).toBe(true);
96
-
97
- // Verify decision log
98
- const decisionsPath = path.join(logDir, "decisions.jsonl");
99
- expect(fs.existsSync(decisionsPath)).toBe(true);
100
- const entries = fs.readFileSync(decisionsPath, "utf-8").trim().split("\n").map(l => JSON.parse(l));
101
- const killEntry = entries.find((e: any) => e.action === "auto_kill_fast");
102
- expect(killEntry).toBeDefined();
103
- expect(killEntry.targets.some((t: any) => t.pid === childPid)).toBe(true);
104
- });
105
-
106
- it("does NOT kill when zone is GREEN even with growth evidence", async () => {
107
- childProc = spawn("sleep", ["60"], { stdio: "ignore" });
108
- const childPid = childProc.pid!;
109
-
110
- const kills: any[] = [];
111
- monitor.onAutoKill((decision) => {
112
- kills.push(decision);
113
- });
114
-
115
- monitor.start();
116
- await new Promise(r => setTimeout(r, 200));
117
-
118
- seedGrowthHistory(childPid, "sleep");
119
-
120
- // Zone stays GREEN
121
- expect(monitor.currentZone).toBe(Zone.GREEN);
122
-
123
- await new Promise(r => setTimeout(r, 3000));
124
-
125
- let alive = true;
126
- try {
127
- process.kill(childPid, 0);
128
- } catch {
129
- alive = false;
130
- }
131
- expect(alive).toBe(true);
132
- expect(kills).toHaveLength(0);
133
- });
134
-
135
- it("kills newest process in RED even without growth evidence", async () => {
136
- // The "newest" filter catches processes that appeared after the last
137
- // non-RED snapshot. Since the child spawned during warm-up (when system
138
- // was GREEN), forcing RED should trigger "newest" detection.
139
- // We seed a cached snapshot with non-zero footprint so the process
140
- // passes the footprint_mb > 0 guard (0 MB processes are infrastructure noise).
141
- childProc = spawn("sleep", ["60"], { stdio: "ignore" });
142
- const childPid = childProc.pid!;
143
-
144
- const kills: any[] = [];
145
- monitor.onAutoKill((decision) => {
146
- kills.push(decision);
147
- });
148
-
149
- monitor.start();
150
- await new Promise(r => setTimeout(r, 1500));
151
-
152
- // Seed a single snapshot with meaningful footprint (no growth pattern,
153
- // just proves the process exists with real memory usage).
154
- // Also set lastNonRedTimestamp explicitly — with hysteresis, the warm-up
155
- // period (1.5s) isn't long enough for 3 consecutive non-RED polls.
156
- const state = monitor.getWatchdogState();
157
- state.snapshotHistory = [
158
- [{ pid: childPid, name: "sleep", footprint_mb: 150, age_seconds: 3 }],
159
- ];
160
- state.lastNonRedTimestamp = Date.now() / 1000 - 5; // system was healthy 5s ago
161
-
162
- monitor._forceZone(Zone.RED, true);
163
-
164
- // Wait for the fast watchdog to fire
165
- const deadline = Date.now() + 6000;
166
- while (Date.now() < deadline) {
167
- await new Promise(r => setTimeout(r, 200));
168
- try {
169
- process.kill(childPid, 0);
170
- } catch {
171
- break;
172
- }
173
- }
174
-
175
- let alive = true;
176
- try {
177
- process.kill(childPid, 0);
178
- } catch {
179
- alive = false;
180
- }
181
- expect(alive).toBe(false);
182
- expect(kills.length).toBeGreaterThanOrEqual(1);
183
- });
184
-
185
- // Cooldown is tested at the unit level in watchdog.test.ts.
186
- // Integration testing cooldown with real processes is timing-sensitive
187
- // and flaky — the unit test covers the logic reliably.
188
- });
@@ -1,103 +0,0 @@
1
- // __tests__/index.test.ts
2
- import { describe, it, expect } from "vitest";
3
- import {
4
- buildBlockReason,
5
- getQuickMemorySnapshot,
6
- buildProcessOptions,
7
- } from "../extensions/index";
8
- import { Zone } from "../extensions/zones";
9
- import type { SnapshotProcess } from "../extensions/tree";
10
-
11
- describe("buildBlockReason", () => {
12
- it("includes zone in the message", () => {
13
- const reason = buildBlockReason(Zone.RED, "npm install", 3);
14
- expect(reason).toContain("RED");
15
- });
16
-
17
- it("includes the command in the message", () => {
18
- const reason = buildBlockReason(Zone.ORANGE, "docker build .", 3);
19
- expect(reason).toContain("docker build .");
20
- });
21
-
22
- it("includes tier context for ORANGE + tier 3", () => {
23
- const reason = buildBlockReason(Zone.ORANGE, "npm run build", 3);
24
- expect(reason).toContain("heavy");
25
- });
26
-
27
- it("RED blocks everything — message reflects that", () => {
28
- const reason = buildBlockReason(Zone.RED, "cat file.txt", 0);
29
- expect(reason).toContain("RED");
30
- expect(reason).toContain("critical");
31
- });
32
-
33
- it("ORANGE message suggests freeing memory", () => {
34
- const reason = buildBlockReason(Zone.ORANGE, "npm install", 3);
35
- expect(reason).toContain("Free memory");
36
- });
37
-
38
- it("RED message mentions close applications", () => {
39
- const reason = buildBlockReason(Zone.RED, "ls", 1);
40
- expect(reason).toContain("close applications");
41
- });
42
- });
43
-
44
- describe("getQuickMemorySnapshot", () => {
45
- it("returns swap_used_mb and memorystatus_level", async () => {
46
- const snap = await getQuickMemorySnapshot();
47
- expect(snap).toHaveProperty("swap_used_mb");
48
- expect(snap).toHaveProperty("memorystatus_level");
49
- expect(typeof snap.swap_used_mb).toBe("number");
50
- expect(typeof snap.memorystatus_level).toBe("number");
51
- });
52
- });
53
-
54
- describe("buildProcessOptions", () => {
55
- it("returns pi children first, sorted by footprint descending", () => {
56
- const piChildren: SnapshotProcess[] = [
57
- { pid: 1, name: "node", footprint_mb: 100, age_seconds: 10 },
58
- { pid: 2, name: "python3", footprint_mb: 300, age_seconds: 20 },
59
- { pid: 3, name: "bash", footprint_mb: 50, age_seconds: 5 },
60
- ];
61
- const options = buildProcessOptions(piChildren, 5);
62
- // Should be sorted: python3 (300), node (100), bash (50)
63
- expect(options.length).toBeGreaterThanOrEqual(3);
64
- expect(options[0]).toContain("python3");
65
- expect(options[0]).toContain("300");
66
- expect(options[1]).toContain("node");
67
- });
68
-
69
- it("limits to top N", () => {
70
- const piChildren: SnapshotProcess[] = [
71
- { pid: 1, name: "a", footprint_mb: 100, age_seconds: 10 },
72
- { pid: 2, name: "b", footprint_mb: 200, age_seconds: 20 },
73
- { pid: 3, name: "c", footprint_mb: 300, age_seconds: 30 },
74
- ];
75
- const options = buildProcessOptions(piChildren, 2);
76
- // 2 process options + "Kill an external app"
77
- expect(options.filter(o => !o.startsWith("Kill an external"))).toHaveLength(2);
78
- });
79
-
80
- it("includes 'Kill an external app' as last option", () => {
81
- const piChildren: SnapshotProcess[] = [
82
- { pid: 1, name: "node", footprint_mb: 100, age_seconds: 10 },
83
- ];
84
- const options = buildProcessOptions(piChildren, 5);
85
- expect(options[options.length - 1]).toContain("external");
86
- });
87
-
88
- it("shows only 'Kill an external app' when no pi children", () => {
89
- const options = buildProcessOptions([], 5);
90
- expect(options).toHaveLength(1);
91
- expect(options[0]).toContain("external");
92
- });
93
-
94
- it("uses natural language descriptions with name, MB, and age", () => {
95
- const piChildren: SnapshotProcess[] = [
96
- { pid: 1, name: "npm", footprint_mb: 340, age_seconds: 12 },
97
- ];
98
- const options = buildProcessOptions(piChildren, 5);
99
- expect(options[0]).toContain("npm");
100
- expect(options[0]).toContain("340");
101
- expect(options[0]).toContain("12s");
102
- });
103
- });