caproom 0.3.1 → 0.5.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 +119 -15
- package/bin/caproom +406 -32
- package/bin/caproom-mcp.js +264 -0
- package/bin/caproom.js +21 -0
- package/bin/caproom.ps1 +471 -0
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/caproom)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
Prevent RAM OOM for long-running terminal coding agents, builds, and background jobs — memory caps plus idle-process parking, for macOS
|
|
6
|
+
Prevent RAM OOM for long-running terminal coding agents, builds, and background jobs — memory caps plus idle-process parking, for macOS, Linux, and Windows.
|
|
7
7
|
|
|
8
8
|
## Why
|
|
9
9
|
|
|
@@ -15,10 +15,10 @@ macOS has no reliable way to cap a process's memory from userspace. A runaway pr
|
|
|
15
15
|
caproom --limit <mb> -- <command> [args...]
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Two backends
|
|
18
|
+
Two backends:
|
|
19
19
|
|
|
20
|
-
1. **
|
|
21
|
-
2. **
|
|
20
|
+
1. **Host-native polling watchdog** (`ps` RSS + `SIGKILL`) — the **default** on macOS/Linux. Runs in your real environment: same PATH, auth, native binaries, tty. Measures the **whole process tree** each poll (agents keep their memory in children — MCP servers, bundler daemons, headless browsers — while the parent's own RSS stays flat). Has a small race window bounded by `--interval` (default 200ms).
|
|
21
|
+
2. **Docker cgroup** (`--memory`) — opt-in with `--docker`. Hard cap, real kernel enforcement, zero race window — at the cost of running inside a Linux container (see caveats below). Fails loudly if the daemon isn't reachable rather than silently switching backends.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -31,17 +31,14 @@ or clone and symlink `bin/caproom` onto your `PATH`.
|
|
|
31
31
|
## Usage
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
# cap a build at 2GB
|
|
34
|
+
# cap a build at 2GB (host-native watchdog, the default)
|
|
35
35
|
caproom --limit 2048 -- npm run build
|
|
36
36
|
|
|
37
37
|
# cap an AI coding agent run at 512MB
|
|
38
38
|
caproom --limit 512 -- claude -p "refactor this module"
|
|
39
39
|
|
|
40
|
-
#
|
|
41
|
-
caproom --limit
|
|
42
|
-
|
|
43
|
-
# use a different docker image for the docker backend (default: node:22-slim)
|
|
44
|
-
caproom --limit 4096 --image python:3.12-slim -- python train.py
|
|
40
|
+
# opt in to the Docker cgroup backend for a zero-race hard cap
|
|
41
|
+
caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
|
|
45
42
|
```
|
|
46
43
|
|
|
47
44
|
### Flags
|
|
@@ -49,15 +46,44 @@ caproom --limit 4096 --image python:3.12-slim -- python train.py
|
|
|
49
46
|
| Flag | Default | Meaning |
|
|
50
47
|
|---|---|---|
|
|
51
48
|
| `--limit <mb>` | `4096` | memory cap in MB |
|
|
52
|
-
| `--image <name>` | `node:22-slim` | docker image used by the docker backend |
|
|
53
49
|
| `--interval <sec>` | `0.2` | watchdog poll interval |
|
|
54
50
|
| `--grace <sec>` | `5` | seconds to wait after `SIGTERM` before `SIGKILL`, watchdog backend only — gives the process a chance to flush/save state before a hard kill |
|
|
55
|
-
| `--
|
|
51
|
+
| `--docker` | off | opt in to the Docker cgroup backend instead of the default host-native watchdog |
|
|
52
|
+
| `--image <name>` | `node:22-slim` | docker image used by the `--docker` backend |
|
|
53
|
+
| `--force-watchdog` | — | legacy no-op; the watchdog IS the default. Accepted so existing scripts and `init` snippets keep working |
|
|
56
54
|
|
|
57
55
|
Env var overrides: `CAPROOM_LIMIT_MB`, `CAPROOM_IMAGE`, `CAPROOM_INTERVAL`, `CAPROOM_GRACE`.
|
|
58
56
|
|
|
59
57
|
On cap breach, the watchdog backend sends `SIGTERM` first and waits `--grace` seconds before `SIGKILL`. If the process exits cleanly during the grace window, `caproom` propagates its real exit code; only a hard `SIGKILL` (process ignored `SIGTERM`, or grace ran out) reports `137` (same convention as Docker's own OOM-kill exit code, which the docker backend always uses on breach since Docker itself sends the kill).
|
|
60
58
|
|
|
59
|
+
## Backends compared
|
|
60
|
+
|
|
61
|
+
The three enforcement mechanisms measure different quantities and cover children differently. Read this before reusing a `--limit` number across platforms or backends. The watchdog is the POSIX default; Docker is opt-in — caproom prefers to run your command unmodified in its real environment and *miss* an exotic memory spike over breaking a working workflow with container drift:
|
|
62
|
+
|
|
63
|
+
| | Docker cgroup | Windows Job Object | Watchdog (POSIX) | Watchdog (Windows) |
|
|
64
|
+
|---|---|---|---|---|
|
|
65
|
+
| Measures | cgroup memory | **committed virtual memory** | RSS of the process tree | working set of the process tree |
|
|
66
|
+
| Children counted | yes — whole container | yes — auto-inherited at spawn | yes — tree walked each poll | yes — tree walked each poll |
|
|
67
|
+
| Enforcement | kernel OOM-kill | allocation fails in-process | TERM → grace → KILL | hard kill (`taskkill /T /F`) |
|
|
68
|
+
| Race window | none | none | bounded by `--interval` | bounded by `--interval` |
|
|
69
|
+
| Interactive/streaming output | degraded — no TTY (`-i` only) | full — streamed live | full — child inherits the tty | streamed live via temp-file tail-follow (~50ms cadence) |
|
|
70
|
+
|
|
71
|
+
**Committed vs RSS**: Node/V8 runtimes commit far more virtual memory than they touch, so a limit tuned against RSS on macOS will bite much earlier under the Job Object backend. Tune per platform.
|
|
72
|
+
|
|
73
|
+
### Docker backend caveats
|
|
74
|
+
|
|
75
|
+
Opt in with `--docker`. The command then runs inside `node:22-slim` with `$PWD` mounted at `/work` — a Linux container, not your host shell:
|
|
76
|
+
|
|
77
|
+
- Native modules built for macOS (`esbuild`, `swc`, `sharp`) fail with exec-format errors inside the container.
|
|
78
|
+
- Host toolchain, env vars, git credentials, and `~/.ssh` are not present.
|
|
79
|
+
- The image pins Node 22 regardless of your project's version (`--image` to override).
|
|
80
|
+
- No TTY is allocated, so interactive/TUI programs degrade; Docker Desktop's file-share layer slows large builds on macOS.
|
|
81
|
+
|
|
82
|
+
For capping an AI agent session you want to *interact* with, use the default watchdog: same host environment, streaming output, no container drift. Reach for `--docker` when you need the zero-race kernel guarantee and the command is container-safe.
|
|
83
|
+
|
|
84
|
+
Orphan safety differs too. The watchdog TERMs the whole measured tree and SIGKILLs grace-period survivors from a breach-time snapshot — but a process that detaches before being observed escapes. Inside the Docker backend, the kernel's cgroup OOM handling acts on every task in the container: nothing outlives it, though the OOM killer picks victims by badness (it may kill your hog rather than the whole container — either way the capped workload ends and `caproom` exits non-zero). The Windows Job Object kills the whole job atomically on breach.
|
|
85
|
+
|
|
86
|
+
|
|
61
87
|
## init — auto-cap a command on every launch
|
|
62
88
|
|
|
63
89
|
For a command you always want capped (e.g. an AI coding agent), don't type the wrapper every time — bake it into your shell so a new terminal tab is capped automatically:
|
|
@@ -68,6 +94,31 @@ caproom init claude --limit 6144 --grace 10 >> ~/.zshrc && source ~/.zshrc
|
|
|
68
94
|
|
|
69
95
|
This appends a shell function that wraps `claude` through the watchdog backend (host-native — no Docker isolation, so the wrapped command keeps its normal filesystem/auth/PATH access) and an alias so plain `claude` picks it up. Per-shell override without editing the rc file: `CAPROOM_LIMIT_MB=8192 claude ...`. Works for any command, not just `claude` — `caproom init npm --limit 2048` wraps `npm` the same way.
|
|
70
96
|
|
|
97
|
+
### `caproom top` — process-tree inventory for agents
|
|
98
|
+
|
|
99
|
+
Read-only snapshot of every process tree you own, sorted by tree RSS. `--json` output is a **stable contract**: `schema` version field, additive changes only.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
caproom top # human table
|
|
103
|
+
caproom top --json # machine output
|
|
104
|
+
caproom top --json --pid 45057 # one subtree only
|
|
105
|
+
caproom top --json --park-min-mb 1024 # park-candidate threshold (default 512MB)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "schema": 1, "ts": 1755950000, "limit_mb_default": 4096,
|
|
110
|
+
"processes": [
|
|
111
|
+
{ "pid": 45057,
|
|
112
|
+
"cmd": "node /tmp/hog.mjs",
|
|
113
|
+
"tree_rss_kb": 455136,
|
|
114
|
+
"tree_pids": [45057, 45060],
|
|
115
|
+
"state": "running" | "parked" | "zombie",
|
|
116
|
+
"park_candidate": true,
|
|
117
|
+
"reason": "root sleeping + tree_rss 455136KB >= 524288KB park threshold" } ] }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
One row per **tree root**; members are listed in `tree_pids`. `park_candidate` is a heuristic (`state == running`, root sleeping/idle, tree RSS ≥ threshold) with the rule spelled out in `reason` so the agent never re-derives it — override freely using the raw fields. The intended loop: poll `top --json` → decide → `park <pid>` / `wake <pid>`. Note `park` makes pages *eligible* for reclaim; see the caveat under park/wake below before treating it as freed RAM.
|
|
121
|
+
|
|
71
122
|
## park / wake — reclaim idle memory without killing
|
|
72
123
|
|
|
73
124
|
Long-running agent sessions accumulate subprocesses that go idle but stay resident — old file watchers, finished tool-call children, stale servers. Killing them loses state; leaving them wastes RAM. `caproom park` freezes instead:
|
|
@@ -85,17 +136,70 @@ Verified empirically on macOS: a parked process's RSS dropped ~90% (345MB → 37
|
|
|
85
136
|
|
|
86
137
|
No daemon, no tracking file, no dependency — just `SIGSTOP`/`SIGCONT` wrapped in a CLI. Any script or agent can call `caproom park <pid>` / `caproom wake <pid>` directly.
|
|
87
138
|
|
|
88
|
-
**Caveat**: a parked process does zero work while stopped — no CPU, no I/O, no timers firing. Only park something actually idle (a background watcher, a finished subprocess kept around for reuse) — never park the process an agent is actively waiting on a response from, or you'll hang the agent, not save it memory.
|
|
139
|
+
**Caveat**: a parked process does zero work while stopped — no CPU, no I/O, no timers firing. Only park something actually idle (a background watcher, a finished subprocess kept around for reuse) — never park the process an agent is actively waiting on a response from, or you'll hang the agent, not save it memory. Also: SIGSTOP only makes pages *eligible* for reclaim — the kernel compresses/evicts them lazily under real memory pressure. Park an idle 2GB agent on a quiet machine and it may stay ~2GB resident for hours. Park is insurance against OOM, not immediate RAM return.
|
|
140
|
+
|
|
141
|
+
### `caproom top` / `caproom watch` — agent interface
|
|
142
|
+
|
|
143
|
+
`caproom top --json` (above) is read-only discovery with a stable schema. `caproom watch` turns it into a daemon:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# observer: report tree-RSS breaches, touch nothing
|
|
147
|
+
caproom watch --threshold-mb 2000 --json <pid>
|
|
148
|
+
|
|
149
|
+
# arm auto-park: freeze breaching trees (SIGSTOP every pid in the snapshot)
|
|
150
|
+
caproom watch --threshold-mb 2000 --auto-park --json <pid>
|
|
151
|
+
|
|
152
|
+
# also restore automatically when system free memory recovers
|
|
153
|
+
caproom watch --threshold-mb 2000 --auto-park --auto-wake-free-pct 15 <pid>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Naming the pid IS the per-process opt-in — there is no system-wide mode, since stopping an unchosen process risks freezing it mid-write. Events are NDJSON on stdout (`started`, `breach`/`parked`, `recovered`, `woke`, `all-exited`). Auto-park freezes the whole measured tree, tracks exactly what *it* stopped, never re-parks within one breach episode (woken trees stay awake unless RSS drops back under threshold), and `--auto-wake-free-pct` undoes only watch's own parks.
|
|
157
|
+
|
|
158
|
+
Typical loop: `top --json` finds candidates → `watch --auto-park` babysits them during heavy builds → explicit or automatic wake restores them after.
|
|
159
|
+
|
|
160
|
+
## MCP server — native agent access
|
|
161
|
+
|
|
162
|
+
`npm i -g caproom` also installs `caproom-mcp`, a zero-dependency MCP server (stdio) exposing the agent interface as tools:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{ "mcpServers": { "caproom": { "command": "caproom-mcp" } } }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Tools: `top` (tree inventory, stable schema), `park`/`wake`, `watch_start`/`watch_events`/`watch_stop` (daemon lifecycle, NDJSON events), and `run` (execute a command under a cap, returns a KILLED-BY-CAP verdict at exit 137). Same gating as the CLI: watch requires explicit pids; auto-park is opt-in per watcher.
|
|
89
169
|
|
|
90
170
|
## What it never touches
|
|
91
171
|
|
|
92
172
|
caproom only watches OS-level RSS and sends signals (`SIGTERM`/`SIGKILL`/`SIGSTOP`/`SIGCONT`). The watchdog backend runs the wrapped command as a direct child with stdin/stdout/stderr passed straight through — no pipe, no buffering, no interception. The Docker backend passes stdio through the same way (`docker run -i`). caproom never reads, modifies, or truncates anything the wrapped process reads or writes — including an AI agent's own conversation/context stream. It manages RAM headroom only, nothing else.
|
|
93
173
|
|
|
174
|
+
## Windows
|
|
175
|
+
|
|
176
|
+
Windows uses a separate PowerShell backend, selected automatically. Same commands, but the semantics differ in three ways worth knowing before you reuse a `--limit` number across platforms.
|
|
177
|
+
|
|
178
|
+
**The cap is a Job Object** (`JOB_OBJECT_LIMIT_PROCESS_MEMORY`), enforced by the kernel at allocation time. Two things it does better than the POSIX watchdog: there is no poll-interval race window, and child processes are covered automatically — a process associated with a job passes that association to anything it spawns, so the whole tree is capped, not just the direct child.
|
|
179
|
+
|
|
180
|
+
**`--limit` means committed memory on Windows, RSS on macOS/Linux.** These are different quantities. The same number will bite at a different point, so tune it per platform rather than assuming it transfers.
|
|
181
|
+
|
|
182
|
+
**No grace period.** Windows console apps have no `SIGTERM` equivalent. Under the Job Object backend nothing is killed at all — the allocation just fails inside the process. Under the watchdog fallback, a breach kills the whole tree (`taskkill /T /F`) with no chance to flush state. `--grace` is accepted and ignored.
|
|
183
|
+
|
|
184
|
+
**Watchdog output streams, but through temp files.** stdout/stderr are captured to files and tail-followed (~50ms cadence) so logs and CI steps show progress live. Full-screen TUI redraws are not pixel-perfect over this path; plain streaming output (agents in non-interactive mode, builds) works normally.
|
|
185
|
+
|
|
186
|
+
**`park` does not suspend on Windows.** It calls `EmptyWorkingSet`, which trims the process's working set to the pagefile immediately and on demand — no waiting for system memory pressure, and **the process keeps running**. The macOS caveat about never parking a process an agent is waiting on does not apply here. `caproom wake` is therefore a no-op on Windows; trimmed pages fault back in on next access.
|
|
187
|
+
|
|
188
|
+
`init` emits a PowerShell function plus `Set-Alias` for your `$PROFILE`:
|
|
189
|
+
|
|
190
|
+
```powershell
|
|
191
|
+
caproom init claude --limit 6144 >> $PROFILE
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Docker backend is not wired up on Windows — the Job Object path already gives kernel enforcement, so there is nothing for it to add.
|
|
195
|
+
|
|
94
196
|
## Limitations
|
|
95
197
|
|
|
96
198
|
- Docker backend mounts `$PWD` into the container at `/work` and runs there — paths outside `$PWD` aren't visible to the command.
|
|
97
|
-
- Watchdog
|
|
98
|
-
-
|
|
199
|
+
- Watchdog backends have a real (if small) race window; for a hard guarantee, opt into the Docker backend (`--docker`) on POSIX, or use the Job Object backend on Windows.
|
|
200
|
+
- The watchdog's tree walk follows live parent→child edges. A child that *daemonizes* (double-fork, reparented to init/launchd) leaves the tree and escapes the cap — as does any process spawned after its parent chain broke, or during the kill grace window. On breach the watchdog signals every pid in the measured tree and SIGKILLs survivors of the grace period from a breach-time snapshot, so children cannot outlive the root — but processes that detach *before* being observed are missed by design. The Windows Job Object backend does not have this gap. This is a deliberate trade: caproom prefers to **miss** memory outside the tracked lineage rather than risk interfering with processes the user didn't ask it to manage.
|
|
201
|
+
- On Windows, `Get-CimInstance` per poll makes the watchdog heavier than a plain RSS read; keep `--interval` at 0.2s or above there.
|
|
202
|
+
- On Windows, the Job Object holds only the wrapped command and its descendants — never caproom itself — so the full `--limit` reaches your workload. (Cost: a millisecond-scale window after spawn before assignment lands, where the child is not yet counted.)
|
|
99
203
|
|
|
100
204
|
## Contributing
|
|
101
205
|
|