caproom 0.4.0 → 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 CHANGED
@@ -81,6 +81,8 @@ Opt in with `--docker`. The command then runs inside `node:22-slim` with `$PWD`
81
81
 
82
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
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
+
84
86
 
85
87
  ## init — auto-cap a command on every launch
86
88
 
@@ -92,6 +94,31 @@ caproom init claude --limit 6144 --grace 10 >> ~/.zshrc && source ~/.zshrc
92
94
 
93
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.
94
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
+
95
122
  ## park / wake — reclaim idle memory without killing
96
123
 
97
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:
@@ -109,7 +136,36 @@ Verified empirically on macOS: a parked process's RSS dropped ~90% (345MB → 37
109
136
 
110
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.
111
138
 
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.
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.
113
169
 
114
170
  ## What it never touches
115
171
 
@@ -141,7 +197,7 @@ Docker backend is not wired up on Windows — the Job Object path already gives
141
197
 
142
198
  - Docker backend mounts `$PWD` into the container at `/work` and runs there — paths outside `$PWD` aren't visible to the command.
143
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.
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.
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.
145
201
  - On Windows, `Get-CimInstance` per poll makes the watchdog heavier than a plain RSS read; keep `--interval` at 0.2s or above there.
146
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.)
147
203
 
package/bin/caproom CHANGED
@@ -35,11 +35,12 @@ usage: caproom [--limit <mb>] [--image <docker-image>] [--interval <sec>] -- <co
35
35
  --force-watchdog no-op; the host-native watchdog IS the default — kept so
36
36
  existing scripts and 'init' snippets keep working
37
37
 
38
- park / wake — freeze an idle process so the kernel can reclaim/compress its
39
- memory without killing it. For a long-running agent sitting on stale
40
- subprocesses: `caproom park <pid>` (SIGSTOP) instead of killing it. It stays
41
- alive, keeps its PID, keeps its state just isn't scheduled and its memory
42
- becomes eligible for compression under system memory pressure. `caproom wake
38
+ park / wake — freeze an idle process so the kernel CAN reclaim/compress its
39
+ memory without killing it. Honest semantics: SIGSTOP only makes the pages
40
+ eligible the kernel reclaims them lazily, when real memory pressure hits.
41
+ Park a 2GB agent on a quiet machine and it may stay ~2GB resident for hours.
42
+ Park is insurance against OOM, not immediate RAM return; use it for processes
43
+ too expensive to restart. `caproom park <pid>` (SIGSTOP), `caproom wake
43
44
  <pid>` (SIGCONT) brings it back instantly, same state, no restart needed.
44
45
  Any agent can call these directly — they're just SIGSTOP/SIGCONT, no daemon,
45
46
  no tracking file required.
@@ -68,6 +69,8 @@ examples:
68
69
  caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
69
70
  caproom park 12345
70
71
  caproom wake 12345
72
+ caproom top --json [--pid <pid>] [--park-min-mb <mb>]
73
+ caproom watch [--threshold-mb <mb>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>
71
74
  caproom init claude --limit 6144 --grace 10
72
75
  EOF
73
76
  exit "$code"
@@ -102,7 +105,7 @@ cmd_park() {
102
105
  [[ -z "$pid" ]] && { echo "usage: caproom park <pid>" >&2; exit 1; }
103
106
  kill -0 "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
104
107
  kill -STOP "$pid"
105
- echo "caproom: pid $pid parked (SIGSTOP) — memory now eligible for kernel reclaim under pressure. wake with: caproom wake $pid" >&2
108
+ echo "caproom: pid $pid parked (SIGSTOP) — pages now eligible for kernel reclaim, but the kernel acts only under real memory pressure; on a quiet machine RSS may not drop. wake with: caproom wake $pid" >&2
106
109
  }
107
110
 
108
111
  cmd_wake() {
@@ -119,8 +122,272 @@ cmd_status() {
119
122
  ps -o pid,stat,rss,etime,command -p "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
120
123
  }
121
124
 
122
- mem_free_pct() {
123
- if [[ "$(uname)" == "Darwin" ]]; then
125
+ # ---- process-tree inventory (top / watch foundation) ----
126
+
127
+ # One ps pass filling the SNAP_* global arrays for the current user.
128
+ read_snapshot() {
129
+ SNAP_PID=(); SNAP_PPID=(); SNAP_RSS=(); SNAP_ST=(); SNAP_ET=(); SNAP_CMD=()
130
+ local myuid uid pid ppid rss st et cmd
131
+ myuid="$(id -u)"
132
+ while read -r uid pid ppid rss st et cmd; do
133
+ [[ "$uid" != "$myuid" ]] && continue
134
+ SNAP_PID+=("$pid"); SNAP_PPID+=("$ppid"); SNAP_RSS+=("${rss:-0}")
135
+ SNAP_ST+=("${st:-?}"); SNAP_ET+=("${et:-0}"); SNAP_CMD+=("${cmd:-}")
136
+ done < <(ps -eo uid=,pid=,ppid=,rss=,state=,etime=,command=)
137
+ }
138
+
139
+ # Walk the subtree of $1 over the existing SNAP_* arrays, filling
140
+ # TREE_PIDS / TREE_RSS_KB. Does NOT re-read ps — cheap enough to call
141
+ # once per tree root from a single snapshot.
142
+ walk_tree() {
143
+ local root="$1" cur i j
144
+ local -a lpids=("${SNAP_PPID[@]}") lq=()
145
+ TREE_PIDS=(); TREE_RSS_KB=0
146
+ lq=("$root")
147
+ while [[ ${#lq[@]} -gt 0 ]]; do
148
+ cur="${lq[0]}"
149
+ if [[ ${#lq[@]} -gt 1 ]]; then lq=("${lq[@]:1}"); else lq=(); fi
150
+ for i in "${!SNAP_PID[@]}"; do
151
+ if [[ "${SNAP_PID[$i]}" == "$cur" ]]; then
152
+ TREE_PIDS+=("$cur")
153
+ TREE_RSS_KB=$(( TREE_RSS_KB + SNAP_RSS[$i] ))
154
+ for j in "${!lpids[@]}"; do
155
+ if [[ "${lpids[$j]}" == "$cur" ]]; then
156
+ lq+=("${SNAP_PID[$j]}")
157
+ lpids[$j]=""
158
+ fi
159
+ done
160
+ break
161
+ fi
162
+ done
163
+ done
164
+ }
165
+
166
+ json_escape() {
167
+ local s="$1"
168
+ s="${s//\\/\\\\}"
169
+ s="${s//\"/\\\"}"
170
+ s="${s//$'\n'/ }"
171
+ s="${s//$'\r'/ }"
172
+ s="${s//$'\t'/ }"
173
+ printf '%s' "$s"
174
+ }
175
+
176
+ cmd_top() {
177
+ local json=0 park_min_kb=$(( 512 * 1024 )) filter_pid=""
178
+ while [[ $# -gt 0 ]]; do
179
+ case "$1" in
180
+ --json) json=1; shift ;;
181
+ --park-min-mb) park_min_kb=$(( $2 * 1024 )); shift 2 ;;
182
+ --pid) filter_pid="$2"; shift 2 ;;
183
+ *) echo "caproom top: unknown option $1" >&2; exit 1 ;;
184
+ esac
185
+ done
186
+
187
+ read_snapshot
188
+ [[ ${#SNAP_PID[@]} -eq 0 ]] && { [[ $json -eq 1 ]] && printf '{"schema":1,"ts":%s,"limit_mb_default":%s,"processes":[]}\n' "$(date +%s)" "${CAPROOM_LIMIT_MB:-4096}"; return 0; }
189
+
190
+ # Tree roots: parents outside the visible set (or init-reparented).
191
+ local -a roots=()
192
+ local i j p found
193
+ if [[ -n "$filter_pid" ]]; then
194
+ found=""
195
+ for i in "${!SNAP_PID[@]}"; do
196
+ [[ "${SNAP_PID[$i]}" == "$filter_pid" ]] && { roots+=("$filter_pid"); found=1; break; }
197
+ done
198
+ if [[ -z "$found" ]]; then
199
+ echo "caproom: no such pid $filter_pid (or not owned by you)" >&2
200
+ exit 1
201
+ fi
202
+ else
203
+ for i in "${!SNAP_PID[@]}"; do
204
+ [[ "${SNAP_PID[$i]}" == "$$" ]] && continue # never report ourselves
205
+ p="${SNAP_PPID[$i]}"
206
+ if [[ "$p" == "1" ]]; then roots+=("${SNAP_PID[$i]}"); continue; fi
207
+ found=""
208
+ for j in "${!SNAP_PID[@]}"; do
209
+ if [[ "${SNAP_PID[$j]}" == "$p" ]]; then found=1; break; fi
210
+ done
211
+ [[ -z "$found" ]] && roots+=("${SNAP_PID[$i]}")
212
+ done
213
+ fi
214
+
215
+ # Walk each root once; keep results in parallel arrays, then sort by
216
+ # tree RSS descending via a sortable temp stream.
217
+ local -a r_pid=() r_trss=() r_tpids=() r_st=() r_et=() r_cmd=() order=()
218
+ for p in "${roots[@]}"; do
219
+ walk_tree "$p"
220
+ local tjoin=""
221
+ [[ ${#TREE_PIDS[@]} -gt 0 ]] && tjoin="$(printf '%s,' "${TREE_PIDS[@]}")" && tjoin="${tjoin%,}"
222
+ for i in "${!SNAP_PID[@]}"; do
223
+ if [[ "${SNAP_PID[$i]}" == "$p" ]]; then
224
+ r_pid+=("$p"); r_trss+=("$TREE_RSS_KB")
225
+ r_tpids+=("$tjoin")
226
+ r_st+=("${SNAP_ST[$i]}"); r_et+=("${SNAP_ET[$i]}")
227
+ r_cmd+=("${SNAP_CMD[$i]}")
228
+ order+=("$(printf '%010d %d\n' "$TREE_RSS_KB" $(( ${#r_pid[@]} - 1 )))")
229
+ break
230
+ fi
231
+ done
232
+ done
233
+
234
+ if [[ $json -eq 1 ]]; then
235
+ local ts out='[' first=1 idx state cand reason kb
236
+ ts="$(date +%s)"
237
+ local -a sorted
238
+ sorted=($(printf '%s\n' "${order[@]:-}" | sort -rn | awk '{print $2}'))
239
+ for idx in "${sorted[@]:-}"; do
240
+ [[ -z "$idx" ]] && continue
241
+ kb="${r_trss[$idx]}"
242
+ local st0="${r_st[$idx]:0:1}"
243
+ case "$st0" in
244
+ T) state="parked" ;;
245
+ Z) state="zombie" ;;
246
+ *) state="running" ;;
247
+ esac
248
+ cand=false; reason=""
249
+ if [[ "$state" == "running" && ( "$st0" == "S" || "$st0" == "I" ) ]] && [[ "$kb" -ge "$park_min_kb" ]]; then
250
+ cand=true
251
+ reason="root sleeping + tree_rss ${kb}KB >= ${park_min_kb}KB park threshold"
252
+ fi
253
+ [[ $first -eq 1 ]] || out+=','
254
+ first=0
255
+ out+="{\"pid\":${r_pid[$idx]},\"cmd\":\"$(json_escape "${r_cmd[$idx]}")\",\"tree_rss_kb\":${kb},\"tree_pids\":[${r_tpids[$idx]}],\"state\":\"$state\",\"park_candidate\":$cand,\"reason\":\"$(json_escape "$reason")\"}"
256
+ done
257
+ out+=']'
258
+ printf '{"schema":1,"ts":%s,"limit_mb_default":%s,"processes":%s}\n' "$ts" "${CAPROOM_LIMIT_MB:-4096}" "$out"
259
+ else
260
+ local idx mb
261
+ printf '%-8s %10s %-8s %-9s %s\n' PID TREE_MB STATE ETIME COMMAND
262
+ local -a sorted
263
+ sorted=($(printf '%s\n' "${order[@]:-}" | sort -rn | awk '{print $2}'))
264
+ for idx in "${sorted[@]:-}"; do
265
+ [[ -z "$idx" ]] && continue
266
+ mb=$(( r_trss[idx] / 1024 ))
267
+ case "${r_st[$idx]}" in T) printf '%-8s %10s %-8s %-9s %s\n' "${r_pid[$idx]}" "$mb" PARKED "${r_et[$idx]}" "${r_cmd[$idx]:0:60}" ;; *) printf '%-8s %10s %-8s %-9s %s\n' "${r_pid[$idx]}" "$mb" "-" "${r_et[$idx]}" "${r_cmd[$idx]:0:60}" ;; esac
268
+ done
269
+ fi
270
+ }
271
+
272
+ cmd_watch() {
273
+ # Daemon: watch explicit pids, emit events when their TREE crosses a
274
+ # RSS threshold. --auto-park freezes breaching trees (SIGSTOP every pid
275
+ # in the snapshot) — only for pids passed explicitly, since stopping a
276
+ # mid-write process risks corruption; naming the pid IS the opt-in.
277
+ # --auto-wake-free-pct N undoes its own parks when free memory recovers.
278
+ local threshold_kb=$(( 2048 * 1024 )) interval=5 json=0 auto=0 wake_pct=""
279
+ local -a pids=()
280
+ while [[ $# -gt 0 ]]; do
281
+ case "$1" in
282
+ --threshold-mb) threshold_kb=$(( $2 * 1024 )); shift 2 ;;
283
+ --interval) interval="$2"; shift 2 ;;
284
+ --auto-park) auto=1; shift ;;
285
+ --auto-wake-free-pct) wake_pct="$2"; shift 2 ;;
286
+ --json) json=1; shift ;;
287
+ *) pids+=("$1"); shift ;;
288
+ esac
289
+ done
290
+ [[ ${#pids[@]} -eq 0 ]] && { echo "usage: caproom watch [--threshold-mb <mb>] [--interval <sec>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>" >&2; exit 1; }
291
+
292
+ local -a parked_by_us=() breaching=()
293
+ local mode
294
+ mode="watch"
295
+ [[ $auto -eq 1 ]] && mode="auto-park"
296
+ if [[ $json -eq 1 ]]; then
297
+ printf '{"schema":1,"event":"started","ts":%s,"mode":"%s","threshold_kb":%s,"pids":[%s]}\n' "$(date +%s)" "$mode" "$threshold_kb" "$(printf '%s,' "${pids[@]}" | sed 's/,$//')"
298
+ else
299
+ echo "caproom: watching ${#pids[@]} pid(s), tree threshold $(( threshold_kb / 1024 ))MB, poll ${interval}s$([[ $auto -eq 1 ]] && echo ', AUTO-PARK ARMED')$([[ -n "$wake_pct" ]] && echo ", auto-wake at >=${wake_pct}% free")" >&2
300
+ fi
301
+
302
+ while :; do
303
+ local -a alive=()
304
+ local pid i st0
305
+ for pid in "${pids[@]}"; do
306
+ kill -0 "$pid" 2>/dev/null && alive+=("$pid")
307
+ done
308
+ if [[ ${#alive[@]} -eq 0 ]]; then
309
+ [[ $json -eq 1 ]] && printf '{"schema":1,"event":"all-exited","ts":%s}\n' "$(date +%s)"
310
+ echo "caproom: watch: all watched pids exited" >&2
311
+ exit 0
312
+ fi
313
+ pids=("${alive[@]}")
314
+
315
+ # Auto-wake first: restore what WE parked once pressure clears.
316
+ if [[ -n "$wake_pct" && ${#parked_by_us[@]} -gt 0 ]]; then
317
+ local pct
318
+ pct=$(mem_free_pct)
319
+ if [[ "$pct" -ge "$wake_pct" ]]; then
320
+ local -a woke=()
321
+ for pid in "${parked_by_us[@]}"; do
322
+ if kill -0 "$pid" 2>/dev/null && kill -CONT "$pid" 2>/dev/null; then
323
+ woke+=("$pid")
324
+ if [[ $json -eq 1 ]]; then
325
+ printf '{"schema":1,"event":"woke","ts":%s,"pid":%s,"free_pct":%s}\n' "$(date +%s)" "$pid" "$pct"
326
+ else
327
+ echo "caproom: watch: free mem ${pct}% >= ${wake_pct}% — waking pid $pid" >&2
328
+ fi
329
+ fi
330
+ done
331
+ parked_by_us=()
332
+ fi
333
+ fi
334
+
335
+ read_snapshot
336
+ for pid in "${pids[@]}"; do
337
+ local found=""
338
+ for i in "${!SNAP_PID[@]}"; do
339
+ [[ "${SNAP_PID[$i]}" == "$pid" ]] && { found="$i"; break; }
340
+ done
341
+ [[ -z "$found" ]] && continue
342
+ st0="${SNAP_ST[$found]:0:1}"
343
+ [[ "$st0" == "T" || "$st0" == "Z" ]] && continue # already parked/dead
344
+ walk_tree "$pid"
345
+ if [[ "$TREE_RSS_KB" -ge "$threshold_kb" ]]; then
346
+ local is_breaching=""
347
+ local ev1
348
+ for ev1 in ${breaching[@]+"${breaching[@]}"}; do [[ "$ev1" == "$pid" ]] && is_breaching=1 && break; done
349
+ if [[ -n "$is_breaching" ]]; then continue; fi
350
+ breaching+=("$pid")
351
+ if [[ $auto -eq 1 ]]; then
352
+ local tp stopped=0
353
+ for tp in "${TREE_PIDS[@]}"; do
354
+ kill -STOP "$tp" 2>/dev/null && { parked_by_us+=("$tp"); stopped=$(( stopped + 1 )); }
355
+ done
356
+ if [[ $json -eq 1 ]]; then
357
+ printf '{"schema":1,"event":"parked","ts":%s,"pid":%s,"tree_rss_kb":%s,"tree_pids":[%s],"stopped":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB" "$(printf '%s,' "${TREE_PIDS[@]}" | sed 's/,$//')" "$stopped"
358
+ else
359
+ echo "caproom: watch: tree of pid $pid hit $(( TREE_RSS_KB / 1024 ))MB (>= $(( threshold_kb / 1024 ))MB) — PARKED tree (${stopped} pids, wake: caproom wake $pid)" >&2
360
+ fi
361
+ else
362
+ if [[ $json -eq 1 ]]; then
363
+ printf '{"schema":1,"event":"breach","ts":%s,"pid":%s,"tree_rss_kb":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB"
364
+ else
365
+ echo "caproom: watch: tree of pid $pid hit $(( TREE_RSS_KB / 1024 ))MB (>= $(( threshold_kb / 1024 ))MB) — no --auto-park, reporting only" >&2
366
+ fi
367
+ fi
368
+ else
369
+ local -a keep=()
370
+ local was_breaching=0 ev2
371
+ for ev2 in ${breaching[@]+"${breaching[@]}"}; do
372
+ if [[ "$ev2" == "$pid" ]]; then was_breaching=1; else keep+=("$ev2"); fi
373
+ done
374
+ if [[ $was_breaching -eq 1 ]]; then
375
+ breaching=()
376
+ local k2
377
+ for k2 in ${keep[@]+"${keep[@]}"}; do breaching+=("$k2"); done
378
+ if [[ $json -eq 1 ]]; then
379
+ printf '{"schema":1,"event":"recovered","ts":%s,"pid":%s,"tree_rss_kb":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB"
380
+ else
381
+ echo "caproom: watch: pid $pid back under threshold ($(( TREE_RSS_KB / 1024 ))MB)" >&2
382
+ fi
383
+ fi
384
+ fi
385
+ done
386
+ sleep "$interval"
387
+ done
388
+ }
389
+
390
+ mem_free_pct() { if [[ "$(uname)" == "Darwin" ]]; then
124
391
  local page_size free inactive total_bytes avail_bytes
125
392
  page_size=$(vm_stat | awk '/page size of/ {print $8}')
126
393
  free=$(vm_stat | awk '/Pages free/ {gsub("\\.","",$3); print $3}')
@@ -180,6 +447,8 @@ case "${1:-}" in
180
447
  park) shift; cmd_park "$@"; exit 0 ;;
181
448
  wake) shift; cmd_wake "$@"; exit 0 ;;
182
449
  status) shift; cmd_status "$@"; exit 0 ;;
450
+ top) shift; cmd_top "$@"; exit 0 ;;
451
+ watch) shift; cmd_watch "$@"; exit 0 ;;
183
452
  guard) shift; cmd_guard "$@"; exit 0 ;;
184
453
  init) shift; cmd_init "$@"; exit 0 ;;
185
454
  help|-h|--help) usage help ;;
@@ -216,39 +485,12 @@ run_docker() {
216
485
  -v "$PWD:/work" -w /work "$IMAGE" "$@"
217
486
  }
218
487
 
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"
488
+ collect_tree() {
489
+ # Snapshot ps once and walk the descendant tree of $1 into TREE_PIDS /
490
+ # TREE_RSS_KB (see read_snapshot / walk_tree). Plain indexed arrays only
491
+ # so macOS's stock bash 3.2 works.
492
+ read_snapshot
493
+ walk_tree "$1"
252
494
  }
253
495
 
254
496
  run_watchdog() {
@@ -258,19 +500,35 @@ run_watchdog() {
258
500
  local pid=$!
259
501
  local exit_code=0
260
502
  while kill -0 "$pid" 2>/dev/null; do
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
265
- kill -TERM "$pid" 2>/dev/null || true
503
+ collect_tree "$pid"
504
+ if [[ "${#TREE_PIDS[@]}" -gt 0 && "$TREE_RSS_KB" -gt "$limit_kb" ]]; then
505
+ local overshoot=$(( TREE_RSS_KB * 100 / limit_kb ))
506
+ echo "caproom: pid $pid tree RSS ${TREE_RSS_KB}KB exceeded ${limit_kb}KB cap (+${overshoot}%) killing tree (grace ${GRACE}s)" >&2
507
+ # Signal EVERY pid in the tree, not just the root: children that
508
+ # survive a root-only TERM get orphaned and keep allocating past
509
+ # the cap after caproom exits.
510
+ kill -TERM "${TREE_PIDS[@]}" 2>/dev/null || true
511
+ local -a breach_pids=("${TREE_PIDS[@]}")
266
512
  local waited=0
267
513
  while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
268
514
  sleep 1
269
515
  waited=$(( waited + 1 ))
270
516
  done
271
- if kill -0 "$pid" 2>/dev/null; then
272
- echo "caproom: pid $pid still alive after ${GRACE}s grace — SIGKILL" >&2
273
- kill -9 "$pid" 2>/dev/null || true
517
+ # Escalate against anything that ignored TERM — root OR child.
518
+ # Scanning the breach-time snapshot (not re-walking from the root)
519
+ # also catches the case where the root died but a stubborn child
520
+ # survived it. Children spawned DURING the grace window are not in
521
+ # the snapshot; same accepted gap as detached daemons generally.
522
+ local sp sweep=0
523
+ for sp in "${breach_pids[@]}"; do
524
+ if kill -0 "$sp" 2>/dev/null; then
525
+ kill -9 "$sp" 2>/dev/null || true
526
+ sweep=$(( sweep + 1 ))
527
+ fi
528
+ done
529
+ if [[ "$sweep" -gt 0 ]]; then
530
+ echo "caproom: SIGKILLed ${sweep} survivor(s) after grace — exit 137" >&2
531
+ wait "$pid" 2>/dev/null || true
274
532
  exit 137
275
533
  fi
276
534
  wait "$pid" 2>/dev/null || exit_code=$?
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ // caproom-mcp — MCP server wrapping the caproom CLI so coding agents can
3
+ // discover, freeze, and restore memory-heavy process trees natively.
4
+ //
5
+ // Tools:
6
+ // top {pid?, park_min_mb?} read-only tree inventory (stable schema)
7
+ // park {pid} SIGSTOP an idle tree's root
8
+ // wake {pid} SIGCONT it back
9
+ // watch_start {pids[], threshold_mb?, auto_park?, auto_wake_free_pct?, interval?}
10
+ // watch_events {id} drain NDJSON events from a running watcher
11
+ // watch_stop {id}
12
+ // run {command[], limit_mb?, grace?, image?, docker?}
13
+ // cap a command end-to-end; returns exit code + stderr tail
14
+ //
15
+ // Hand-rolled MCP stdio transport (newline-delimited JSON-RPC 2.0): zero
16
+ // dependencies, same rule as the CLI itself.
17
+
18
+ 'use strict';
19
+
20
+ const { spawn, spawnSync } = require('child_process');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ const CAPROOM = path.join(__dirname, process.platform === 'win32' ? 'caproom.ps1' : 'caproom');
25
+ if (process.platform !== 'win32') {
26
+ try { require('fs').chmodSync(CAPROOM, 0o755); } catch (_) { /* already exec */ }
27
+ }
28
+
29
+ const watchers = new Map();
30
+ let watcherSeq = 0;
31
+
32
+ function caproom(args, opts = {}) {
33
+ const exe = process.platform === 'win32' ? 'powershell.exe' : 'bash';
34
+ const argv = process.platform === 'win32' ? ['-NoProfile', '-File', CAPROOM].concat(args) : [CAPROOM].concat(args);
35
+ return spawnSync(exe, argv, { encoding: 'utf8', timeout: opts.timeout || 30000 });
36
+ }
37
+
38
+ function text(s) {
39
+ return { content: [{ type: 'text', text: String(s) }] };
40
+ }
41
+
42
+ function startWatcher(p) {
43
+ const id = 'w' + (++watcherSeq);
44
+ const args = ['watch', '--json'];
45
+ if (p.threshold_mb != null) args.push('--threshold-mb', String(p.threshold_mb));
46
+ if (p.interval != null) args.push('--interval', String(p.interval));
47
+ if (p.auto_park) args.push('--auto-park');
48
+ if (p.auto_wake_free_pct != null) args.push('--auto-wake-free-pct', String(p.auto_wake_free_pct));
49
+ const pids = Array.isArray(p.pids) ? p.pids : [];
50
+ if (!pids.length) throw new Error('pids[] required');
51
+ for (const x of pids) args.push(String(x));
52
+
53
+ const exe = process.platform === 'win32' ? 'powershell.exe' : 'bash';
54
+ const argv = process.platform === 'win32' ? ['-NoProfile', '-File', CAPROOM].concat(args) : [CAPROOM].concat(args);
55
+ const child = spawn(exe, argv, { stdio: ['ignore', 'pipe', 'pipe'], detached: false });
56
+ const rec = {
57
+ id, child, pids,
58
+ events: [], buf: '', stderr: [],
59
+ };
60
+ child.stdout.on('data', d => {
61
+ rec.buf += d.toString();
62
+ let i;
63
+ while ((i = rec.buf.indexOf('\n')) >= 0) {
64
+ const line = rec.buf.slice(0, i).trim();
65
+ rec.buf = rec.buf.slice(i + 1);
66
+ if (!line) continue;
67
+ try { rec.events.push(JSON.parse(line)); } catch (_) { /* partial */ }
68
+ if (rec.events.length > 1000) rec.events.splice(0, rec.events.length - 1000);
69
+ }
70
+ });
71
+ child.stderr.on('data', d => {
72
+ rec.stderr.push(d.toString());
73
+ if (rec.stderr.length > 50) rec.stderr.splice(0, rec.stderr.length - 50);
74
+ });
75
+ child.on('exit', () => { rec.exited = true; });
76
+ watchers.set(id, rec);
77
+ return { id, pids };
78
+ }
79
+
80
+ function drainWatcher(id, clear) {
81
+ const rec = watchers.get(id);
82
+ if (!rec) throw new Error('unknown watcher id: ' + id);
83
+ const out = rec.events.slice();
84
+ if (clear) rec.events = [];
85
+ return { events: out, exited: !!rec.exited, stderr_tail: rec.stderr.join('').slice(-2000) };
86
+ }
87
+
88
+ function stopWatcher(id) {
89
+ const rec = watchers.get(id);
90
+ if (!rec) throw new Error('unknown watcher id: ' + id);
91
+ if (!rec.exited) {
92
+ try { rec.child.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); } catch (_) {}
93
+ }
94
+ const out = drainWatcher(id, true);
95
+ watchers.delete(id);
96
+ return out;
97
+ }
98
+
99
+ function callTool(name, args) {
100
+ switch (name) {
101
+ case 'top': {
102
+ const a = ['top', '--json'];
103
+ if (args.pid != null) a.push('--pid', String(args.pid));
104
+ if (args.park_min_mb != null) a.push('--park-min-mb', String(args.park_min_mb));
105
+ const r = caproom(a);
106
+ if (r.status !== 0) return text(r.stderr || 'top failed');
107
+ return text(r.stdout.trim());
108
+ }
109
+ case 'park':
110
+ case 'wake': {
111
+ if (args.pid == null) return text('pid required');
112
+ const r = caproom([name, String(args.pid)]);
113
+ return text((r.stderr || '').trim() + (r.status === 0 ? '' : `\nexit=${r.status}`));
114
+ }
115
+ case 'watch_start': {
116
+ const { id, pids } = startWatcher(args);
117
+ return text(JSON.stringify({ id, pids, note: 'poll watch_events{ id } to drain NDJSON events' }));
118
+ }
119
+ case 'watch_events': {
120
+ return text(JSON.stringify(drainWatcher(String(args.id), args.clear !== false)));
121
+ }
122
+ case 'watch_stop': {
123
+ return text(JSON.stringify(stopWatcher(String(args.id))));
124
+ }
125
+ case 'run': {
126
+ const cmd = Array.isArray(args.command) ? args.command : null;
127
+ if (!cmd || !cmd.length) return text('command[] required');
128
+ const a = [];
129
+ a.push('--limit', String(args.limit_mb != null ? args.limit_mb : 4096));
130
+ if (args.grace != null) a.push('--grace', String(args.grace));
131
+ if (args.docker) { a.push('--docker'); if (args.image) a.push('--image', String(args.image)); }
132
+ a.push('--');
133
+ const r = caproom(a.concat(cmd), { timeout: Math.max(60000, (args.timeout_ms || 300000)) });
134
+ let verdict = '';
135
+ if (r.status === 137 || r.signal === 'SIGKILL') verdict = 'RESULT: KILLED BY CAP (exit 137)';
136
+ else if (r.status === 143 || r.signal === 'SIGTERM') verdict = 'RESULT: terminated during grace (SIGTERM honored)';
137
+ else verdict = 'RESULT: exit=' + r.status;
138
+ return text(verdict + '\n--- stderr ---\n' + ((r.stderr || '').slice(-4000) || '(empty)'));
139
+ }
140
+ default:
141
+ throw new Error('unknown tool: ' + name);
142
+ }
143
+ }
144
+
145
+ const TOOLS = [
146
+ {
147
+ name: 'top',
148
+ description: 'Snapshot every process tree you own, sorted by tree RSS. Read-only. Returns caproom\'s stable schema-1 JSON: rows are tree roots with cmd, tree_rss_kb, tree_pids (blast radius), state (running|parked|zombie), park_candidate + reason heuristic.',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ pid: { type: 'number', description: 'restrict to one subtree' },
153
+ park_min_mb: { type: 'number', description: 'park-candidate threshold in MB (default 512)' },
154
+ },
155
+ },
156
+ },
157
+ {
158
+ name: 'park',
159
+ description: 'SIGSTOP an idle process (and only its root — use watch auto_park for whole trees). Pages become eligible for lazy kernel reclaim under pressure; not immediate RAM return. Only park genuinely idle processes — never one an agent awaits a reply from.',
160
+ inputSchema: { type: 'object', properties: { pid: { type: 'number' } }, required: ['pid'] },
161
+ },
162
+ {
163
+ name: 'wake',
164
+ description: 'SIGCONT a parked process back to life, same state, same PID.',
165
+ inputSchema: { type: 'object', properties: { pid: { type: 'number' } }, required: ['pid'] },
166
+ },
167
+ {
168
+ name: 'watch_start',
169
+ description: 'Start a caproom watch daemon on explicit pids. Naming pids is the opt-in; there is no system-wide mode. Emits NDJSON events (started/breach/parked/recovered/woke/all-exited). auto_park freezes whole breaching trees; auto_wake_free_pct restores its own parks when free memory recovers.',
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ pids: { type: 'array', items: { type: 'number' }, description: 'explicit pids to watch' },
174
+ threshold_mb: { type: 'number', description: 'per-tree breach threshold (default 2048)' },
175
+ auto_park: { type: 'boolean' },
176
+ auto_wake_free_pct: { type: 'number' },
177
+ interval: { type: 'number', description: 'poll seconds (default 5)' },
178
+ },
179
+ required: ['pids'],
180
+ },
181
+ },
182
+ {
183
+ name: 'watch_events',
184
+ description: 'Drain accumulated NDJSON events from a watcher (clears them unless clear=false). Also reports whether the watcher exited.',
185
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, clear: { type: 'boolean' } }, required: ['id'] },
186
+ },
187
+ {
188
+ name: 'watch_stop',
189
+ description: 'Stop a watcher and return its final drained events.',
190
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
191
+ },
192
+ {
193
+ name: 'run',
194
+ description: 'Run a command under a caproom memory cap (default watchdog backend; docker:true opts into the container cgroup backend). Kills the whole tree on breach. Returns verdict line (KILLED BY CAP at exit 137) plus captured stderr.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ command: { type: 'array', items: { type: 'string' }, description: 'argv, e.g. ["npm","run","build"]' },
199
+ limit_mb: { type: 'number' },
200
+ grace: { type: 'number', description: 'seconds between SIGTERM and SIGKILL (default 5)' },
201
+ docker: { type: 'boolean' },
202
+ image: { type: 'string' },
203
+ timeout_ms: { type: 'number', description: 'default 300000' },
204
+ },
205
+ required: ['command'],
206
+ },
207
+ },
208
+ ];
209
+
210
+ process.stdin.setEncoding('utf8');
211
+ let inbuf = '';
212
+
213
+ function send(obj) {
214
+ process.stdout.write(JSON.stringify(obj) + '\n');
215
+ }
216
+
217
+ function handle(msg) {
218
+ if (msg.method === 'initialize') {
219
+ send({
220
+ jsonrpc: '2.0', id: msg.id,
221
+ result: {
222
+ protocolVersion: msg.params && msg.params.protocolVersion || '2024-11-05',
223
+ capabilities: { tools: {} },
224
+ serverInfo: { name: 'caproom-mcp', version: require('../package.json').version },
225
+ },
226
+ });
227
+ } else if (msg.method === 'tools/list') {
228
+ send({ jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
229
+ } else if (msg.method === 'tools/call') {
230
+ const { name, arguments: args } = msg.params || {};
231
+ try {
232
+ const res = callTool(name, args || {});
233
+ send({ jsonrpc: '2.0', id: msg.id, result: res });
234
+ } catch (e) {
235
+ send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: 'error: ' + e.message }], isError: true } });
236
+ }
237
+ } else if (msg.method === 'ping') {
238
+ send({ jsonrpc: '2.0', id: msg.id, result: {} });
239
+ } else if (msg.id !== undefined) {
240
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found: ' + msg.method } });
241
+ }
242
+ // notifications (initialized, etc.) get no reply
243
+ }
244
+
245
+ process.stdin.on('data', chunk => {
246
+ inbuf += chunk;
247
+ let i;
248
+ while ((i = inbuf.indexOf('\n')) >= 0) {
249
+ const line = inbuf.slice(0, i).trim();
250
+ inbuf = inbuf.slice(i + 1);
251
+ if (!line) continue;
252
+ let msg;
253
+ try { msg = JSON.parse(line); } catch (_) { continue; }
254
+ try { handle(msg); } catch (e) {
255
+ if (msg && msg.id !== undefined) {
256
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(e.message || e) } });
257
+ }
258
+ }
259
+ }
260
+ });
261
+
262
+ process.on('disconnect', () => {
263
+ for (const id of Array.from(watchers.keys())) stopWatcher(id);
264
+ });
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "caproom",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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.js"
6
+ "caproom": "bin/caproom.js",
7
+ "caproom-mcp": "bin/caproom-mcp.js"
7
8
  },
8
9
  "files": [
9
10
  "bin/caproom",
10
11
  "bin/caproom.js",
11
- "bin/caproom.ps1"
12
+ "bin/caproom.ps1",
13
+ "bin/caproom-mcp.js"
12
14
  ],
13
15
  "keywords": [
14
16
  "memory",