caproom 0.2.0 → 0.4.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
@@ -3,7 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/caproom.svg)](https://www.npmjs.com/package/caproom)
4
4
  [![license](https://img.shields.io/npm/l/caproom.svg)](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/Linux.
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, auto-selected:
18
+ Two backends:
19
19
 
20
- 1. **Docker cgroup** (`--memory`) — used when Docker is installed and running. Hard cap, real kernel enforcement, zero race window.
21
- 2. **Polling watchdog** (`ps` RSS + `SIGKILL`) — fallback when Docker isn't available. No dependencies, works anywhere `ps` exists. Has a small race window bounded by `--interval` (default 200ms) a process can spike briefly past the cap between polls before being killed.
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
- # force the watchdog even if Docker is available
41
- caproom --limit 1024 --force-watchdog -- ./some-script.sh
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,52 @@ 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
- | `--force-watchdog` | off | use the polling watchdog even if Docker is available |
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
+
85
+ ## init — auto-cap a command on every launch
86
+
87
+ 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:
88
+
89
+ ```bash
90
+ caproom init claude --limit 6144 --grace 10 >> ~/.zshrc && source ~/.zshrc
91
+ ```
92
+
93
+ 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.
94
+
61
95
  ## park / wake — reclaim idle memory without killing
62
96
 
63
97
  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:
@@ -77,11 +111,39 @@ No daemon, no tracking file, no dependency — just `SIGSTOP`/`SIGCONT` wrapped
77
111
 
78
112
  **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.
79
113
 
114
+ ## What it never touches
115
+
116
+ 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.
117
+
118
+ ## Windows
119
+
120
+ 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.
121
+
122
+ **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.
123
+
124
+ **`--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.
125
+
126
+ **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.
127
+
128
+ **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.
129
+
130
+ **`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.
131
+
132
+ `init` emits a PowerShell function plus `Set-Alias` for your `$PROFILE`:
133
+
134
+ ```powershell
135
+ caproom init claude --limit 6144 >> $PROFILE
136
+ ```
137
+
138
+ Docker backend is not wired up on Windows — the Job Object path already gives kernel enforcement, so there is nothing for it to add.
139
+
80
140
  ## Limitations
81
141
 
82
142
  - Docker backend mounts `$PWD` into the container at `/work` and runs there — paths outside `$PWD` aren't visible to the command.
83
- - Watchdog backend has a real (if small) race window; for a hard guarantee, use the Docker backend.
84
- - Neither backend can cap a process that immediately forks and hides children under a different watched PID tree in unusual ways the watchdog only tracks the direct child.
143
+ - 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.
144
+ - 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. 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.
145
+ - On Windows, `Get-CimInstance` per poll makes the watchdog heavier than a plain RSS read; keep `--interval` at 0.2s or above there.
146
+ - 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.)
85
147
 
86
148
  ## Contributing
87
149
 
package/bin/caproom CHANGED
@@ -3,28 +3,37 @@
3
3
  # on macOS/Linux. macOS has no working RLIMIT_AS/DATA/RSS or launchd RSS
4
4
  # enforcement (verified empirically — both are no-ops on modern macOS), so
5
5
  # this uses whichever real enforcement mechanism is available:
6
- # 1. Docker cgroup (--memory) hard cap, zero race window. Used when
7
- # `docker` is installed and the daemon is running.
8
- # 2. Polling watchdog (ps RSS + SIGKILL) — fallback, no dependencies,
9
- # works everywhere, has a small race window (poll interval).
6
+ # 1. Host-native polling watchdog (process-tree RSS + SIGKILL) the
7
+ # DEFAULT. Runs in your real environment: same PATH, auth, native
8
+ # binaries, tty. Small race window bounded by --interval.
9
+ # 2. Docker cgroup (--memory) opt-in via --docker. Hard cap, zero race
10
+ # window, but runs the command inside a Linux container (native-module
11
+ # and toolchain drift; see README). Fails loudly if the daemon is down.
10
12
  set -euo pipefail
11
13
 
12
14
  usage() {
13
- cat >&2 << 'EOF'
15
+ local stream=/dev/stderr
16
+ local code=1
17
+ if [[ "${1:-}" == "help" ]]; then stream=/dev/stdout; code=0; fi
18
+ cat >"$stream" << 'EOF'
14
19
  usage: caproom [--limit <mb>] [--image <docker-image>] [--interval <sec>] -- <command> [args...]
15
20
  caproom park <pid>
16
21
  caproom wake <pid>
17
22
  caproom status <pid>
23
+ caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
24
+ caproom init <command> [--limit <mb>] [--grace <sec>]
18
25
 
19
26
  --limit <mb> memory cap in MB (default: 4096)
20
- --image <name> docker image to run the command in, when using the docker
21
- backend (default: node:22-slim)
22
- --interval <sec> watchdog poll interval in seconds, fallback backend only
23
- (default: 0.2)
27
+ --interval <sec> watchdog poll interval in seconds (default: 0.2)
24
28
  --grace <sec> seconds to wait after SIGTERM before SIGKILL, watchdog
25
29
  backend only (default: 5) — gives the process a chance
26
30
  to flush/save state before a hard kill
27
- --force-watchdog force the polling watchdog even if Docker is available
31
+ --docker opt in to the Docker cgroup backend instead of the default
32
+ host-native watchdog (needs the daemon running)
33
+ --image <name> docker image used by the --docker backend
34
+ (default: node:22-slim)
35
+ --force-watchdog no-op; the host-native watchdog IS the default — kept so
36
+ existing scripts and 'init' snippets keep working
28
37
 
29
38
  park / wake — freeze an idle process so the kernel can reclaim/compress its
30
39
  memory without killing it. For a long-running agent sitting on stale
@@ -35,15 +44,57 @@ becomes eligible for compression under system memory pressure. `caproom wake
35
44
  Any agent can call these directly — they're just SIGSTOP/SIGCONT, no daemon,
36
45
  no tracking file required.
37
46
 
47
+ guard — watch SYSTEM-WIDE free memory (not any single process) and auto-park
48
+ tracked pids (SIGSTOP) when free mem drops below --threshold percent, before
49
+ the kernel OOM-killer has to pick a victim. Use it when unrelated heavy
50
+ processes (e.g. a GPU inference job in one terminal, a TTS job in another)
51
+ share a box and neither is individually over any --limit cap. Foreground,
52
+ blocking; exits once all watched pids have exited. Does not auto-wake —
53
+ `caproom wake <pid>` when memory pressure clears:
54
+
55
+ caproom guard --threshold 10 --interval 5 -- 12345 12346
56
+
57
+ init <command> — print a shell snippet that auto-caps <command> on every
58
+ invocation, so a new terminal tab is capped with no extra typing. Append the
59
+ output to your shell rc (~/.zshrc, ~/.bashrc):
60
+
61
+ caproom init claude >> ~/.zshrc && source ~/.zshrc
62
+
38
63
  env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_IMAGE, CAPROOM_INTERVAL, CAPROOM_GRACE
39
64
 
40
65
  examples:
41
66
  caproom --limit 2048 -- npm run build
42
67
  caproom --limit 512 -- claude --dangerously-skip-permissions -p "task"
68
+ caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
43
69
  caproom park 12345
44
70
  caproom wake 12345
71
+ caproom init claude --limit 6144 --grace 10
72
+ EOF
73
+ exit "$code"
74
+ }
75
+
76
+ cmd_init() {
77
+ local target="${1:-}"
78
+ [[ -z "$target" ]] && { echo "usage: caproom init <command> [--limit <mb>] [--grace <sec>]" >&2; exit 1; }
79
+ shift
80
+ local limit=4096
81
+ local grace=5
82
+ while [[ $# -gt 0 ]]; do
83
+ case "$1" in
84
+ --limit) limit="$2"; shift 2 ;;
85
+ --grace) grace="$2"; shift 2 ;;
86
+ *) echo "caproom: unknown init flag $1" >&2; exit 1 ;;
87
+ esac
88
+ done
89
+ local fn="${target}_capped"
90
+ cat << EOF
91
+ # caproom: auto-cap '$target' — added by 'caproom init $target'
92
+ # override per-shell: CAPROOM_LIMIT_MB=8192 $target ...
93
+ $fn() {
94
+ command caproom --limit "\${CAPROOM_LIMIT_MB:-$limit}" --force-watchdog --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
95
+ }
96
+ alias $target=$fn
45
97
  EOF
46
- exit 1
47
98
  }
48
99
 
49
100
  cmd_park() {
@@ -68,17 +119,77 @@ cmd_status() {
68
119
  ps -o pid,stat,rss,etime,command -p "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
69
120
  }
70
121
 
122
+ mem_free_pct() {
123
+ if [[ "$(uname)" == "Darwin" ]]; then
124
+ local page_size free inactive total_bytes avail_bytes
125
+ page_size=$(vm_stat | awk '/page size of/ {print $8}')
126
+ free=$(vm_stat | awk '/Pages free/ {gsub("\\.","",$3); print $3}')
127
+ inactive=$(vm_stat | awk '/Pages inactive/ {gsub("\\.","",$3); print $3}')
128
+ total_bytes=$(sysctl -n hw.memsize)
129
+ avail_bytes=$(( (free + inactive) * page_size ))
130
+ echo $(( avail_bytes * 100 / total_bytes ))
131
+ else
132
+ local avail_kb total_kb
133
+ avail_kb=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
134
+ total_kb=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
135
+ echo $(( avail_kb * 100 / total_kb ))
136
+ fi
137
+ }
138
+
139
+ cmd_guard() {
140
+ local threshold=10
141
+ local interval=5
142
+ local pids=()
143
+ while [[ $# -gt 0 ]]; do
144
+ case "$1" in
145
+ --threshold) threshold="$2"; shift 2 ;;
146
+ --interval) interval="$2"; shift 2 ;;
147
+ --) shift ;;
148
+ *) pids+=("$1"); shift ;;
149
+ esac
150
+ done
151
+ [[ ${#pids[@]} -eq 0 ]] && { echo "usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>" >&2; exit 1; }
152
+ echo "caproom: guarding ${#pids[@]} pid(s), park when system free mem < ${threshold}% (poll ${interval}s)" >&2
153
+ local parked=()
154
+ while :; do
155
+ local alive=()
156
+ local pid
157
+ for pid in "${pids[@]}"; do
158
+ kill -0 "$pid" 2>/dev/null && alive+=("$pid")
159
+ done
160
+ if [[ ${#alive[@]} -eq 0 ]]; then
161
+ echo "caproom: guard: all watched pids exited" >&2
162
+ exit 0
163
+ fi
164
+ pids=("${alive[@]}")
165
+ local pct
166
+ pct=$(mem_free_pct)
167
+ if [[ "$pct" -lt "$threshold" ]]; then
168
+ for pid in "${pids[@]}"; do
169
+ if [[ ! " ${parked[*]:-} " == *" $pid "* ]]; then
170
+ echo "caproom: system free mem ${pct}% < ${threshold}% threshold — about to blow, parking pid $pid (SIGSTOP)" >&2
171
+ kill -STOP "$pid" 2>/dev/null && parked+=("$pid")
172
+ fi
173
+ done
174
+ fi
175
+ sleep "$interval"
176
+ done
177
+ }
178
+
71
179
  case "${1:-}" in
72
180
  park) shift; cmd_park "$@"; exit 0 ;;
73
181
  wake) shift; cmd_wake "$@"; exit 0 ;;
74
182
  status) shift; cmd_status "$@"; exit 0 ;;
183
+ guard) shift; cmd_guard "$@"; exit 0 ;;
184
+ init) shift; cmd_init "$@"; exit 0 ;;
185
+ help|-h|--help) usage help ;;
75
186
  esac
76
187
 
77
188
  LIMIT_MB="${CAPROOM_LIMIT_MB:-4096}"
78
189
  IMAGE="${CAPROOM_IMAGE:-node:22-slim}"
79
190
  INTERVAL="${CAPROOM_INTERVAL:-0.2}"
80
191
  GRACE="${CAPROOM_GRACE:-5}"
81
- FORCE_WATCHDOG=0
192
+ USE_DOCKER=0
82
193
 
83
194
  while [[ $# -gt 0 ]]; do
84
195
  case "$1" in
@@ -86,19 +197,18 @@ while [[ $# -gt 0 ]]; do
86
197
  --image) IMAGE="$2"; shift 2 ;;
87
198
  --interval) INTERVAL="$2"; shift 2 ;;
88
199
  --grace) GRACE="$2"; shift 2 ;;
89
- --force-watchdog) FORCE_WATCHDOG=1; shift ;;
200
+ --docker) USE_DOCKER=1; shift ;;
201
+ # legacy no-op: the watchdog IS the default now; accepted so old
202
+ # scripts and init snippets keep working
203
+ --force-watchdog) shift ;;
90
204
  --) shift; break ;;
91
- -h|--help) usage ;;
205
+ -h|--help) usage help ;;
92
206
  *) break ;;
93
207
  esac
94
208
  done
95
209
 
96
210
  [[ $# -eq 0 ]] && usage
97
211
 
98
- docker_available() {
99
- [[ "$FORCE_WATCHDOG" -eq 0 ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
100
- }
101
-
102
212
  run_docker() {
103
213
  echo "caproom: docker cgroup backend, limit=${LIMIT_MB}m image=${IMAGE}" >&2
104
214
  exec docker run --rm -i \
@@ -106,17 +216,52 @@ run_docker() {
106
216
  -v "$PWD:/work" -w /work "$IMAGE" "$@"
107
217
  }
108
218
 
219
+ sum_tree_rss_kb() {
220
+ # Sum RSS across the whole descendant tree of $1. Agents keep their memory
221
+ # in children (MCP servers, bundler daemons, headless browsers) while the
222
+ # parent's own RSS stays flat — a top-pid-only check never fires on them.
223
+ # One ps snapshot per poll; BFS over pid->ppid edges. Plain indexed arrays
224
+ # only (no declare -A) so macOS's stock bash 3.2 works.
225
+ local root="$1"
226
+ local -a pids=() ppids=() kb=() queue=()
227
+ local pid ppid k total=0 cur i j
228
+ while read -r pid ppid k; do
229
+ [[ -n "$pid" ]] || continue
230
+ pids+=("$pid"); ppids+=("$ppid"); kb+=("$k")
231
+ done < <(ps -eo pid=,ppid=,rss=)
232
+
233
+ queue=("$root")
234
+ while [[ ${#queue[@]} -gt 0 ]]; do
235
+ cur="${queue[0]}"
236
+ if [[ ${#queue[@]} -gt 1 ]]; then queue=("${queue[@]:1}"); else queue=(); fi
237
+ for ((i = 0; i < ${#pids[@]}; i++)); do
238
+ if [[ "${pids[$i]}" == "$cur" && -n "${kb[$i]}" ]]; then
239
+ total=$(( total + kb[$i] ))
240
+ kb[$i]=""
241
+ for ((j = 0; j < ${#ppids[@]}; j++)); do
242
+ if [[ "${ppids[$j]}" == "$cur" ]]; then
243
+ queue+=("${pids[$j]}")
244
+ ppids[$j]=""
245
+ fi
246
+ done
247
+ break
248
+ fi
249
+ done
250
+ done
251
+ echo "$total"
252
+ }
253
+
109
254
  run_watchdog() {
110
- echo "caproom: watchdog backend (docker unavailable), limit=${LIMIT_MB}m poll=${INTERVAL}s" >&2
255
+ echo "caproom: watchdog backend (host-native), limit=${LIMIT_MB}m poll=${INTERVAL}s (process-tree RSS)" >&2
111
256
  local limit_kb=$(( LIMIT_MB * 1024 ))
112
257
  "$@" &
113
258
  local pid=$!
114
259
  local exit_code=0
115
260
  while kill -0 "$pid" 2>/dev/null; do
116
- local rss_kb
117
- rss_kb=$(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ')
118
- if [[ -n "$rss_kb" && "$rss_kb" -gt "$limit_kb" ]]; then
119
- echo "caproom: pid $pid RSS ${rss_kb}KB exceeded ${limit_kb}KB cap — sending SIGTERM (grace ${GRACE}s)" >&2
261
+ local tree_kb
262
+ tree_kb=$(sum_tree_rss_kb "$pid")
263
+ if [[ -n "$tree_kb" && "$tree_kb" -gt "$limit_kb" ]]; then
264
+ echo "caproom: pid $pid tree RSS ${tree_kb}KB exceeded ${limit_kb}KB cap — sending SIGTERM (grace ${GRACE}s)" >&2
120
265
  kill -TERM "$pid" 2>/dev/null || true
121
266
  local waited=0
122
267
  while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
@@ -138,8 +283,16 @@ run_watchdog() {
138
283
  exit "$exit_code"
139
284
  }
140
285
 
141
- if docker_available; then
142
- run_docker "$@"
286
+ if [[ "$USE_DOCKER" -eq 1 ]]; then
287
+ # Explicit opt-in must fail loudly rather than silently downgrade —
288
+ # the caller asked for a hard cap, a silent watchdog switch would
289
+ # quietly change the guarantee they asked for.
290
+ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
291
+ run_docker "$@"
292
+ else
293
+ echo "caproom: --docker requested but the docker daemon is not reachable" >&2
294
+ exit 1
295
+ fi
143
296
  else
144
297
  run_watchdog "$@"
145
298
  fi
package/bin/caproom.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // Platform dispatch: bash script on POSIX, PowerShell script on Windows.
3
+ const { spawnSync } = require('child_process');
4
+ const { join } = require('path');
5
+
6
+ const args = process.argv.slice(2);
7
+ const isWin = process.platform === 'win32';
8
+
9
+ const result = isWin
10
+ ? spawnSync(
11
+ 'powershell.exe',
12
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(__dirname, 'caproom.ps1'), ...args],
13
+ { stdio: 'inherit' }
14
+ )
15
+ : spawnSync('bash', [join(__dirname, 'caproom'), ...args], { stdio: 'inherit' });
16
+
17
+ if (result.error) {
18
+ console.error(`caproom: failed to launch backend — ${result.error.message}`);
19
+ process.exit(1);
20
+ }
21
+ process.exit(result.status === null ? 1 : result.status);
@@ -0,0 +1,471 @@
1
+ #Requires -Version 5.1
2
+ # caproom -- Windows backend.
3
+ #
4
+ # Enforcement uses a Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY, which is a
5
+ # real kernel-enforced cap (no polling race) and automatically covers child
6
+ # processes. Note this limits *committed virtual memory*, not resident set -- the
7
+ # POSIX backend caps RSS, so the same --limit value can bite at a different point.
8
+ #
9
+ # park uses EmptyWorkingSet, which trims a process's working set to the pagefile
10
+ # on demand without suspending it -- unlike the POSIX backend's SIGSTOP, the
11
+ # process keeps running, so wake is a no-op here.
12
+
13
+ $ErrorActionPreference = 'Stop'
14
+
15
+ function Show-Usage {
16
+ param([switch]$AsHelp)
17
+ $text = @'
18
+ usage: caproom [--limit <mb>] [--interval <sec>] -- <command> [args...]
19
+ caproom park <pid>
20
+ caproom wake <pid>
21
+ caproom status <pid>
22
+ caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
23
+ caproom init <command> [--limit <mb>]
24
+
25
+ --limit <mb> memory cap in MB (default: 4096). On Windows this caps
26
+ committed virtual memory (Job Object ProcessMemoryLimit);
27
+ on macOS/Linux it caps RSS. Same flag, different quantity.
28
+ --interval <sec> poll interval for the fallback watchdog (default: 0.2)
29
+ --force-watchdog use the polling watchdog instead of the Job Object backend
30
+
31
+ Windows differences from macOS/Linux:
32
+ * No SIGTERM grace period. Windows console apps have no signal equivalent,
33
+ so a watchdog breach is a hard kill. The Job Object backend does not kill
34
+ at all -- the allocation simply fails inside the process.
35
+ * park <pid> uses EmptyWorkingSet: memory is trimmed to the pagefile
36
+ immediately, on demand, and the process KEEPS RUNNING. There is no
37
+ suspension, so it cannot hang a process that something is waiting on.
38
+ * wake <pid> is a no-op -- nothing was suspended. Trimmed pages fault back
39
+ in by themselves on next access.
40
+
41
+ guard watches SYSTEM-WIDE free memory (not any single process) and auto-parks
42
+ tracked pids (EmptyWorkingSet) once free mem drops below --threshold percent,
43
+ before the OS has to fail an allocation itself. Use it when unrelated heavy
44
+ processes (e.g. a GPU inference job and a TTS job in separate terminals)
45
+ share a box and neither individually breaches any --limit cap. Foreground,
46
+ blocking; exits once all watched pids have exited. There is no unpark step --
47
+ park just trims the working set, pages fault back in on next access.
48
+
49
+ env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_INTERVAL
50
+
51
+ examples:
52
+ caproom --limit 2048 -- npm run build
53
+ caproom park 12345
54
+ caproom guard --threshold 10 --interval 5 -- 12345 12346
55
+ caproom init claude --limit 6144
56
+ '@
57
+ if ($AsHelp) { Write-Output $text; exit 0 }
58
+ [Console]::Error.WriteLine($text)
59
+ exit 1
60
+ }
61
+
62
+ $NativeMethods = @'
63
+ using System;
64
+ using System.Runtime.InteropServices;
65
+
66
+ public static class Caproom {
67
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
68
+ public static extern IntPtr CreateJobObject(IntPtr a, string lpName);
69
+
70
+ [DllImport("kernel32.dll", SetLastError = true)]
71
+ public static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfo);
72
+
73
+ [DllImport("kernel32.dll", SetLastError = true)]
74
+ public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
75
+
76
+ [DllImport("psapi.dll", SetLastError = true)]
77
+ public static extern bool EmptyWorkingSet(IntPtr hProcess);
78
+
79
+ [StructLayout(LayoutKind.Sequential)]
80
+ public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
81
+ public Int64 PerProcessUserTimeLimit;
82
+ public Int64 PerJobUserTimeLimit;
83
+ public UInt32 LimitFlags;
84
+ public UIntPtr MinimumWorkingSetSize;
85
+ public UIntPtr MaximumWorkingSetSize;
86
+ public UInt32 ActiveProcessLimit;
87
+ public UIntPtr Affinity;
88
+ public UInt32 PriorityClass;
89
+ public UInt32 SchedulingClass;
90
+ }
91
+
92
+ [StructLayout(LayoutKind.Sequential)]
93
+ public struct IO_COUNTERS {
94
+ public UInt64 ReadOperationCount;
95
+ public UInt64 WriteOperationCount;
96
+ public UInt64 OtherOperationCount;
97
+ public UInt64 ReadTransferCount;
98
+ public UInt64 WriteTransferCount;
99
+ public UInt64 OtherTransferCount;
100
+ }
101
+
102
+ [StructLayout(LayoutKind.Sequential)]
103
+ public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
104
+ public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
105
+ public IO_COUNTERS IoInfo;
106
+ public UIntPtr ProcessMemoryLimit;
107
+ public UIntPtr JobMemoryLimit;
108
+ public UIntPtr PeakProcessMemoryUsed;
109
+ public UIntPtr PeakJobMemoryUsed;
110
+ }
111
+
112
+ public const int ExtendedLimitInformation = 9;
113
+ public const uint LIMIT_PROCESS_MEMORY = 0x00000100;
114
+ public const uint LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
115
+ }
116
+ '@
117
+
118
+ function Import-Native {
119
+ if (-not ('Caproom' -as [type])) { Add-Type -TypeDefinition $script:NativeMethods }
120
+ }
121
+
122
+ function Invoke-Park {
123
+ param([int]$TargetPid)
124
+ Import-Native
125
+ $proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
126
+ if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
127
+ $before = $proc.WorkingSet64
128
+ if (-not [Caproom]::EmptyWorkingSet($proc.Handle)) {
129
+ [Console]::Error.WriteLine("caproom: EmptyWorkingSet failed for pid $TargetPid (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))")
130
+ exit 1
131
+ }
132
+ $after = (Get-Process -Id $TargetPid).WorkingSet64
133
+ [Console]::Error.WriteLine("caproom: pid $TargetPid parked -- working set trimmed $([math]::Round($before/1MB))MB -> $([math]::Round($after/1MB))MB. Process is STILL RUNNING (no suspension); pages fault back in on access.")
134
+ }
135
+
136
+ function Invoke-Wake {
137
+ param([int]$TargetPid)
138
+ if (-not (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)) {
139
+ [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1
140
+ }
141
+ [Console]::Error.WriteLine("caproom: pid $TargetPid -- nothing to wake. On Windows park trims the working set without suspending, so the process never stopped running.")
142
+ }
143
+
144
+ function Invoke-Status {
145
+ param([int]$TargetPid)
146
+ $proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
147
+ if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
148
+ [PSCustomObject]@{
149
+ Pid = $proc.Id
150
+ WorkingSetMB = [math]::Round($proc.WorkingSet64 / 1MB)
151
+ CommittedMB = [math]::Round($proc.PagedMemorySize64 / 1MB)
152
+ Elapsed = (Get-Date) - $proc.StartTime
153
+ Command = $proc.ProcessName
154
+ } | Format-List
155
+ }
156
+
157
+ function Get-FreeMemPercent {
158
+ $os = Get-CimInstance Win32_OperatingSystem
159
+ return [math]::Floor(($os.FreePhysicalMemory * 100) / $os.TotalVisibleMemorySize)
160
+ }
161
+
162
+ function Invoke-Guard {
163
+ param([int]$Threshold, [double]$Interval, [int[]]$TargetPids)
164
+ Import-Native
165
+ [Console]::Error.WriteLine("caproom: guarding $($TargetPids.Count) pid(s), park when system free mem < ${Threshold}% (poll ${Interval}s)")
166
+ $parked = @{}
167
+ while ($true) {
168
+ $alive = @($TargetPids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
169
+ if ($alive.Count -eq 0) {
170
+ [Console]::Error.WriteLine("caproom: guard: all watched pids exited")
171
+ exit 0
172
+ }
173
+ $TargetPids = $alive
174
+ $pct = Get-FreeMemPercent
175
+ if ($pct -lt $Threshold) {
176
+ foreach ($p in $TargetPids) {
177
+ if (-not $parked.ContainsKey($p)) {
178
+ $proc = Get-Process -Id $p -ErrorAction SilentlyContinue
179
+ if ($proc) {
180
+ [Console]::Error.WriteLine("caproom: system free mem ${pct}% < ${Threshold}% threshold -- about to blow, parking pid $p (EmptyWorkingSet)")
181
+ [void][Caproom]::EmptyWorkingSet($proc.Handle)
182
+ $parked[$p] = $true
183
+ }
184
+ }
185
+ }
186
+ }
187
+ Start-Sleep -Seconds $Interval
188
+ }
189
+ }
190
+
191
+ function Invoke-Init {
192
+ param([string]$Target, [int]$LimitMb)
193
+ @"
194
+ # caproom: auto-cap '$Target' -- added by 'caproom init $Target'
195
+ # override per-shell: `$env:CAPROOM_LIMIT_MB = 8192
196
+ function ${Target}_capped {
197
+ `$limit = if (`$env:CAPROOM_LIMIT_MB) { `$env:CAPROOM_LIMIT_MB } else { $LimitMb }
198
+ caproom --force-watchdog --limit `$limit -- $Target @args
199
+ }
200
+ Set-Alias -Name $Target -Value ${Target}_capped -Force
201
+ "@
202
+ }
203
+
204
+ # Start-Process -ArgumentList joins an array with spaces and does no quoting,
205
+ # so an argument containing whitespace gets re-split into several arguments by
206
+ # the callee. Build one command line with CommandLineToArgvW quoting instead.
207
+ function ConvertTo-ArgString {
208
+ param([string[]]$Arguments)
209
+ $quoted = foreach ($a in $Arguments) {
210
+ if ($a -eq '') { '""' }
211
+ elseif ($a -notmatch '[\s"]') { $a }
212
+ else {
213
+ # Double any backslashes preceding a quote (and at end of string),
214
+ # then escape the quotes themselves.
215
+ $s = $a -replace '(\\*)"', '$1$1\"'
216
+ $s = $s -replace '(\\+)$', '$1$1'
217
+ '"' + $s + '"'
218
+ }
219
+ }
220
+ $quoted -join ' '
221
+ }
222
+
223
+ function New-CappedProcess {
224
+ # Every pipe-based capture (Process class + ReadToEndAsync, Process class
225
+ # + raw BaseStream, with and without stripping std-handle inheritance)
226
+ # returned zero bytes in CI despite a clean exit 0 -- caproom is invoked
227
+ # as powershell.exe -File caproom.ps1 from the Node shim, itself invoked
228
+ # from a pwsh.EXE step that captures via a pipe (`| Out-String`), and
229
+ # something in that nesting swallows anonymous-pipe output every time.
230
+ # File-based redirection (Start-Process -RedirectStandardOutput <file>)
231
+ # was the one capture method that survived an isolated repro under the
232
+ # exact same nesting in the same CI job, so route through temp files
233
+ # instead of pipes entirely.
234
+ param([string]$Exe, [string]$ArgLine)
235
+ $resolvedExe = $Exe
236
+ $cmd = Get-Command $Exe -ErrorAction SilentlyContinue
237
+ if ($cmd) { $resolvedExe = $cmd.Source }
238
+
239
+ $outFile = [IO.Path]::GetTempFileName()
240
+ $errFile = [IO.Path]::GetTempFileName()
241
+ $proc = Start-Process -FilePath $resolvedExe -ArgumentList $ArgLine -NoNewWindow `
242
+ -RedirectStandardOutput $outFile -RedirectStandardError $errFile -PassThru
243
+
244
+ # Start-Process's PassThru object opens a limited-rights handle lazily --
245
+ # if .Handle is never touched while the process is still alive, .ExitCode
246
+ # silently reads back 0 for an already-exited process instead of the real
247
+ # code. Force the full-access handle open now, before it can exit.
248
+ $null = $proc.Handle
249
+
250
+ $proc | Add-Member -NotePropertyName StdoutFile -NotePropertyValue $outFile
251
+ $proc | Add-Member -NotePropertyName StderrFile -NotePropertyValue $errFile
252
+ return $proc
253
+ }
254
+
255
+ function Read-NewOutput {
256
+ # Tail-follow one capture file from its recorded byte offset, writing new
257
+ # bytes to the given console stream as they land so output streams live.
258
+ # Byte-level writes pass the child's bytes through un-re-encoded.
259
+ param([string]$Path, $Offsets, $Stream)
260
+ if (-not (Test-Path -LiteralPath $Path)) { return }
261
+ $fs = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
262
+ try {
263
+ if ($fs.Length -lt $Offsets[$Path]) { $Offsets[$Path] = 0 } # file truncated/recreated under us
264
+ if ($fs.Length -gt $Offsets[$Path]) {
265
+ $fs.Position = $Offsets[$Path]
266
+ $len = [int]($fs.Length - $fs.Position)
267
+ $buf = New-Object byte[] $len
268
+ $read = 0
269
+ while ($read -lt $len) {
270
+ $n = $fs.Read($buf, $read, $len - $read)
271
+ if ($n -le 0) { break }
272
+ $read += $n
273
+ }
274
+ if ($read -gt 0) {
275
+ $Offsets[$Path] += $read
276
+ $Stream.Write($buf, 0, $read)
277
+ $Stream.Flush()
278
+ }
279
+ }
280
+ } finally { $fs.Close() }
281
+ }
282
+
283
+ function Wait-CappedProcess {
284
+ # Drains remaining output, cleans up the temp capture files, returns the
285
+ # exit code. If the caller streamed while polling (watchdog path), pass
286
+ # the SAME offsets table so only the unread tail is relayed here; the
287
+ # job-object path streams internally on a 50ms cadence.
288
+ param($Proc, $Offsets = @{ ($Proc.StdoutFile) = 0; ($Proc.StderrFile) = 0 })
289
+ try {
290
+ while (-not $Proc.HasExited) {
291
+ Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
292
+ Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
293
+ Start-Sleep -Milliseconds 50
294
+ }
295
+ Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
296
+ Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
297
+ return $Proc.ExitCode
298
+ } finally {
299
+ Remove-Item -LiteralPath $Proc.StdoutFile, $Proc.StderrFile -ErrorAction SilentlyContinue
300
+ }
301
+ }
302
+
303
+ # The watchdog must see the WHOLE tree, not just the top pid: coding agents
304
+ # keep their memory in children (MCP servers, bundler daemons, headless
305
+ # browsers) while the parent's own working set stays flat. Walk the
306
+ # parent->child edges of one Win32_Process snapshot and sum working sets.
307
+ function Get-TreeWorkingSetBytes {
308
+ param([int]$RootPid)
309
+ $ws = @{}
310
+ $kids = @{}
311
+ foreach ($p in Get-CimInstance -ClassName Win32_Process -Property ProcessId, ParentProcessId, WorkingSetSize) {
312
+ $pidInt = [int]$p.ProcessId
313
+ $ppidInt = [int]$p.ParentProcessId
314
+ $ws[$pidInt] = [uint64]$p.WorkingSetSize
315
+ if (-not $kids.ContainsKey($ppidInt)) { $kids[$ppidInt] = @() }
316
+ $kids[$ppidInt] += $pidInt
317
+ }
318
+ if (-not $ws.ContainsKey($RootPid)) { return [uint64]0 }
319
+ $total = [uint64]0
320
+ $queue = New-Object System.Collections.Queue
321
+ $visited = @{}
322
+ $queue.Enqueue($RootPid)
323
+ while ($queue.Count -gt 0) {
324
+ $cur = [int]$queue.Dequeue()
325
+ if ($visited.ContainsKey($cur)) { continue } # pid-reuse / cycle guard
326
+ $visited[$cur] = $true
327
+ $total += $ws[$cur]
328
+ if ($kids.ContainsKey($cur)) { foreach ($c in $kids[$cur]) { [void]$queue.Enqueue($c) } }
329
+ }
330
+ return $total
331
+ }
332
+
333
+ function Invoke-Capped {
334
+ param([int]$LimitMb, [double]$Interval, [bool]$ForceWatchdog, [string[]]$Command)
335
+
336
+ $exe = $Command[0]
337
+ $rest = if ($Command.Length -gt 1) { ConvertTo-ArgString $Command[1..($Command.Length - 1)] } else { '' }
338
+
339
+ if (-not $ForceWatchdog) {
340
+ try {
341
+ Import-Native
342
+ $job = [Caproom]::CreateJobObject([IntPtr]::Zero, $null)
343
+ if ($job -eq [IntPtr]::Zero) { throw 'CreateJobObject returned NULL' }
344
+
345
+ $info = New-Object Caproom+JOBOBJECT_EXTENDED_LIMIT_INFORMATION
346
+ $info.BasicLimitInformation.LimitFlags = [Caproom]::LIMIT_PROCESS_MEMORY -bor [Caproom]::LIMIT_KILL_ON_JOB_CLOSE
347
+ $info.ProcessMemoryLimit = [UIntPtr]::new([uint64]$LimitMb * 1MB)
348
+
349
+ $size = [Runtime.InteropServices.Marshal]::SizeOf($info)
350
+ $ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal($size)
351
+ try {
352
+ [Runtime.InteropServices.Marshal]::StructureToPtr($info, $ptr, $false)
353
+ if (-not [Caproom]::SetInformationJobObject($job, [Caproom]::ExtendedLimitInformation, $ptr, $size)) {
354
+ throw "SetInformationJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
355
+ }
356
+ } finally {
357
+ [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
358
+ }
359
+
360
+ # Assign ONLY the child to the job, immediately after spawn --
361
+ # never caproom's own process. Putting the PowerShell runtime
362
+ # inside the job made its ~100MB+ commit eat the user's budget,
363
+ # and a PS spike could fail allocations inside THEIR command.
364
+ # Policy: prefer under-counting over impeding. Cost is a
365
+ # millisecond-scale window before assignment lands; the child's
366
+ # own descendants are still covered automatically (they inherit
367
+ # the association at CreateProcess).
368
+ [Console]::Error.WriteLine("caproom: job object backend, limit=${LimitMb}m (committed memory, kernel-enforced, covers the command and its descendants)")
369
+ $proc = New-CappedProcess -Exe $exe -ArgLine $rest
370
+ if (-not [Caproom]::AssignProcessToJobObject($job, $proc.Handle)) {
371
+ # Child is already running -- kill it before falling back,
372
+ # or the watchdog path below would launch a second instance.
373
+ & taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
374
+ throw "AssignProcessToJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
375
+ }
376
+ exit (Wait-CappedProcess $proc)
377
+ } catch {
378
+ [Console]::Error.WriteLine("caproom: job object backend unavailable ($($_.Exception.Message)) -- falling back to watchdog")
379
+ }
380
+ }
381
+
382
+ [Console]::Error.WriteLine("caproom: watchdog backend, limit=${LimitMb}m poll=${Interval}s (process-tree working set, hard kill on breach -- Windows has no SIGTERM equivalent)")
383
+ $limitBytes = [uint64]$LimitMb * 1MB
384
+ $proc = New-CappedProcess -Exe $exe -ArgLine $rest
385
+ # Stream output WHILE the breach-poll loop runs -- polling must not sit
386
+ # on the whole runtime and leave the tail-follow to drain everything at
387
+ # exit. Same offsets table flows into Wait-CappedProcess for the final
388
+ # drain so nothing is relayed twice.
389
+ $offsets = @{ ($proc.StdoutFile) = 0; ($proc.StderrFile) = 0 }
390
+ while (-not $proc.HasExited) {
391
+ Read-NewOutput -Path $proc.StdoutFile -Offsets $offsets -Stream ([Console]::Out)
392
+ Read-NewOutput -Path $proc.StderrFile -Offsets $offsets -Stream ([Console]::Error)
393
+ Start-Sleep -Seconds $Interval
394
+ if ($proc.HasExited) { break }
395
+ $treeBytes = Get-TreeWorkingSetBytes -RootPid $proc.Id
396
+ if ($treeBytes -gt $limitBytes) {
397
+ [Console]::Error.WriteLine("caproom: process tree of pid $($proc.Id) using $([math]::Round($treeBytes/1MB))MB exceeded ${LimitMb}MB cap -- killing tree")
398
+ & taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
399
+ exit 137
400
+ }
401
+ }
402
+ exit (Wait-CappedProcess $proc -Offsets $offsets)
403
+ }
404
+
405
+ # ---- argument parsing ----
406
+
407
+ if ($args.Count -eq 0) { Show-Usage }
408
+
409
+ switch ($args[0]) {
410
+ 'help' { Show-Usage -AsHelp }
411
+ '-h' { Show-Usage -AsHelp }
412
+ '--help' { Show-Usage -AsHelp }
413
+ 'park' {
414
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
415
+ Invoke-Park -TargetPid ([int]$args[1]); exit 0
416
+ }
417
+ 'wake' {
418
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom wake <pid>'); exit 1 }
419
+ Invoke-Wake -TargetPid ([int]$args[1]); exit 0
420
+ }
421
+ 'status' {
422
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom status <pid>'); exit 1 }
423
+ Invoke-Status -TargetPid ([int]$args[1]); exit 0
424
+ }
425
+ 'guard' {
426
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
427
+ $threshold = 10
428
+ $gInterval = 5
429
+ $gPids = @()
430
+ for ($i = 1; $i -lt $args.Count; $i++) {
431
+ if ($args[$i] -eq '--threshold') { $threshold = [int]$args[$i + 1]; $i++ }
432
+ elseif ($args[$i] -eq '--interval') { $gInterval = [double]$args[$i + 1]; $i++ }
433
+ elseif ($args[$i] -eq '--') { continue }
434
+ else { $gPids += [int]$args[$i] }
435
+ }
436
+ if ($gPids.Count -eq 0) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
437
+ Invoke-Guard -Threshold $threshold -Interval $gInterval -TargetPids $gPids
438
+ exit 0
439
+ }
440
+ 'init' {
441
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom init <command> [--limit <mb>]'); exit 1 }
442
+ $target = $args[1]
443
+ $limit = 4096
444
+ for ($i = 2; $i -lt $args.Count; $i++) {
445
+ if ($args[$i] -eq '--limit') { $limit = [int]$args[$i + 1]; $i++ }
446
+ else { [Console]::Error.WriteLine("caproom: unknown init flag $($args[$i])"); exit 1 }
447
+ }
448
+ Invoke-Init -Target $target -LimitMb $limit
449
+ exit 0
450
+ }
451
+ }
452
+
453
+ $limitMb = if ($env:CAPROOM_LIMIT_MB) { [int]$env:CAPROOM_LIMIT_MB } else { 4096 }
454
+ $interval = if ($env:CAPROOM_INTERVAL) { [double]$env:CAPROOM_INTERVAL } else { 0.2 }
455
+ $forceWatchdog = $false
456
+ $i = 0
457
+ $parsing = $true
458
+ while ($parsing -and $i -lt $args.Count) {
459
+ $a = $args[$i]
460
+ if ($a -eq '--limit') { $limitMb = [int]$args[$i + 1]; $i += 2 }
461
+ elseif ($a -eq '--interval') { $interval = [double]$args[$i + 1]; $i += 2 }
462
+ elseif ($a -eq '--force-watchdog') { $forceWatchdog = $true; $i++ }
463
+ elseif ($a -eq '-h' -or $a -eq '--help') { Show-Usage -AsHelp }
464
+ elseif ($a -eq '--') { $i++; $parsing = $false }
465
+ else { $parsing = $false }
466
+ }
467
+
468
+ if ($i -ge $args.Count) { Show-Usage }
469
+ $command = @($args[$i..($args.Count - 1)])
470
+
471
+ Invoke-Capped -LimitMb $limitMb -Interval $interval -ForceWatchdog $forceWatchdog -Command $command
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "caproom",
3
- "version": "0.2.0",
4
- "description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS/Linux — real enforcement via Docker cgroups or a polling watchdog, plus park/wake to reclaim idle process memory without killing.",
3
+ "version": "0.4.0",
4
+ "description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS, Linux, and Windows — real enforcement via Docker cgroups, Windows Job Objects, or a polling watchdog, plus park/wake to reclaim idle process memory without killing.",
5
5
  "bin": {
6
- "caproom": "bin/caproom"
6
+ "caproom": "bin/caproom.js"
7
7
  },
8
8
  "files": [
9
- "bin/caproom"
9
+ "bin/caproom",
10
+ "bin/caproom.js",
11
+ "bin/caproom.ps1"
10
12
  ],
11
13
  "keywords": [
12
14
  "memory",
@@ -21,7 +23,8 @@
21
23
  ],
22
24
  "os": [
23
25
  "darwin",
24
- "linux"
26
+ "linux",
27
+ "win32"
25
28
  ],
26
29
  "license": "MIT",
27
30
  "repository": {