caproom 0.4.0 → 0.6.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,10 @@ 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...>
74
+ caproom setup [--guard] [--threshold <pct>] [--uninstall]
75
+ caproom freemem
71
76
  caproom init claude --limit 6144 --grace 10
72
77
  EOF
73
78
  exit "$code"
@@ -102,7 +107,7 @@ cmd_park() {
102
107
  [[ -z "$pid" ]] && { echo "usage: caproom park <pid>" >&2; exit 1; }
103
108
  kill -0 "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
104
109
  kill -STOP "$pid"
105
- echo "caproom: pid $pid parked (SIGSTOP) — memory now eligible for kernel reclaim under pressure. wake with: caproom wake $pid" >&2
110
+ 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
111
  }
107
112
 
108
113
  cmd_wake() {
@@ -119,8 +124,272 @@ cmd_status() {
119
124
  ps -o pid,stat,rss,etime,command -p "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
120
125
  }
121
126
 
122
- mem_free_pct() {
123
- if [[ "$(uname)" == "Darwin" ]]; then
127
+ # ---- process-tree inventory (top / watch foundation) ----
128
+
129
+ # One ps pass filling the SNAP_* global arrays for the current user.
130
+ read_snapshot() {
131
+ SNAP_PID=(); SNAP_PPID=(); SNAP_RSS=(); SNAP_ST=(); SNAP_ET=(); SNAP_CMD=()
132
+ local myuid uid pid ppid rss st et cmd
133
+ myuid="$(id -u)"
134
+ while read -r uid pid ppid rss st et cmd; do
135
+ [[ "$uid" != "$myuid" ]] && continue
136
+ SNAP_PID+=("$pid"); SNAP_PPID+=("$ppid"); SNAP_RSS+=("${rss:-0}")
137
+ SNAP_ST+=("${st:-?}"); SNAP_ET+=("${et:-0}"); SNAP_CMD+=("${cmd:-}")
138
+ done < <(ps -eo uid=,pid=,ppid=,rss=,state=,etime=,command=)
139
+ }
140
+
141
+ # Walk the subtree of $1 over the existing SNAP_* arrays, filling
142
+ # TREE_PIDS / TREE_RSS_KB. Does NOT re-read ps — cheap enough to call
143
+ # once per tree root from a single snapshot.
144
+ walk_tree() {
145
+ local root="$1" cur i j
146
+ local -a lpids=("${SNAP_PPID[@]}") lq=()
147
+ TREE_PIDS=(); TREE_RSS_KB=0
148
+ lq=("$root")
149
+ while [[ ${#lq[@]} -gt 0 ]]; do
150
+ cur="${lq[0]}"
151
+ if [[ ${#lq[@]} -gt 1 ]]; then lq=("${lq[@]:1}"); else lq=(); fi
152
+ for i in "${!SNAP_PID[@]}"; do
153
+ if [[ "${SNAP_PID[$i]}" == "$cur" ]]; then
154
+ TREE_PIDS+=("$cur")
155
+ TREE_RSS_KB=$(( TREE_RSS_KB + SNAP_RSS[$i] ))
156
+ for j in "${!lpids[@]}"; do
157
+ if [[ "${lpids[$j]}" == "$cur" ]]; then
158
+ lq+=("${SNAP_PID[$j]}")
159
+ lpids[$j]=""
160
+ fi
161
+ done
162
+ break
163
+ fi
164
+ done
165
+ done
166
+ }
167
+
168
+ json_escape() {
169
+ local s="$1"
170
+ s="${s//\\/\\\\}"
171
+ s="${s//\"/\\\"}"
172
+ s="${s//$'\n'/ }"
173
+ s="${s//$'\r'/ }"
174
+ s="${s//$'\t'/ }"
175
+ printf '%s' "$s"
176
+ }
177
+
178
+ cmd_top() {
179
+ local json=0 park_min_kb=$(( 512 * 1024 )) filter_pid=""
180
+ while [[ $# -gt 0 ]]; do
181
+ case "$1" in
182
+ --json) json=1; shift ;;
183
+ --park-min-mb) park_min_kb=$(( $2 * 1024 )); shift 2 ;;
184
+ --pid) filter_pid="$2"; shift 2 ;;
185
+ *) echo "caproom top: unknown option $1" >&2; exit 1 ;;
186
+ esac
187
+ done
188
+
189
+ read_snapshot
190
+ [[ ${#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; }
191
+
192
+ # Tree roots: parents outside the visible set (or init-reparented).
193
+ local -a roots=()
194
+ local i j p found
195
+ if [[ -n "$filter_pid" ]]; then
196
+ found=""
197
+ for i in "${!SNAP_PID[@]}"; do
198
+ [[ "${SNAP_PID[$i]}" == "$filter_pid" ]] && { roots+=("$filter_pid"); found=1; break; }
199
+ done
200
+ if [[ -z "$found" ]]; then
201
+ echo "caproom: no such pid $filter_pid (or not owned by you)" >&2
202
+ exit 1
203
+ fi
204
+ else
205
+ for i in "${!SNAP_PID[@]}"; do
206
+ [[ "${SNAP_PID[$i]}" == "$$" ]] && continue # never report ourselves
207
+ p="${SNAP_PPID[$i]}"
208
+ if [[ "$p" == "1" ]]; then roots+=("${SNAP_PID[$i]}"); continue; fi
209
+ found=""
210
+ for j in "${!SNAP_PID[@]}"; do
211
+ if [[ "${SNAP_PID[$j]}" == "$p" ]]; then found=1; break; fi
212
+ done
213
+ [[ -z "$found" ]] && roots+=("${SNAP_PID[$i]}")
214
+ done
215
+ fi
216
+
217
+ # Walk each root once; keep results in parallel arrays, then sort by
218
+ # tree RSS descending via a sortable temp stream.
219
+ local -a r_pid=() r_trss=() r_tpids=() r_st=() r_et=() r_cmd=() order=()
220
+ for p in "${roots[@]}"; do
221
+ walk_tree "$p"
222
+ local tjoin=""
223
+ [[ ${#TREE_PIDS[@]} -gt 0 ]] && tjoin="$(printf '%s,' "${TREE_PIDS[@]}")" && tjoin="${tjoin%,}"
224
+ for i in "${!SNAP_PID[@]}"; do
225
+ if [[ "${SNAP_PID[$i]}" == "$p" ]]; then
226
+ r_pid+=("$p"); r_trss+=("$TREE_RSS_KB")
227
+ r_tpids+=("$tjoin")
228
+ r_st+=("${SNAP_ST[$i]}"); r_et+=("${SNAP_ET[$i]}")
229
+ r_cmd+=("${SNAP_CMD[$i]}")
230
+ order+=("$(printf '%010d %d\n' "$TREE_RSS_KB" $(( ${#r_pid[@]} - 1 )))")
231
+ break
232
+ fi
233
+ done
234
+ done
235
+
236
+ if [[ $json -eq 1 ]]; then
237
+ local ts out='[' first=1 idx state cand reason kb
238
+ ts="$(date +%s)"
239
+ local -a sorted
240
+ sorted=($(printf '%s\n' "${order[@]:-}" | sort -rn | awk '{print $2}'))
241
+ for idx in "${sorted[@]:-}"; do
242
+ [[ -z "$idx" ]] && continue
243
+ kb="${r_trss[$idx]}"
244
+ local st0="${r_st[$idx]:0:1}"
245
+ case "$st0" in
246
+ T) state="parked" ;;
247
+ Z) state="zombie" ;;
248
+ *) state="running" ;;
249
+ esac
250
+ cand=false; reason=""
251
+ if [[ "$state" == "running" && ( "$st0" == "S" || "$st0" == "I" ) ]] && [[ "$kb" -ge "$park_min_kb" ]]; then
252
+ cand=true
253
+ reason="root sleeping + tree_rss ${kb}KB >= ${park_min_kb}KB park threshold"
254
+ fi
255
+ [[ $first -eq 1 ]] || out+=','
256
+ first=0
257
+ 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")\"}"
258
+ done
259
+ out+=']'
260
+ printf '{"schema":1,"ts":%s,"limit_mb_default":%s,"processes":%s}\n' "$ts" "${CAPROOM_LIMIT_MB:-4096}" "$out"
261
+ else
262
+ local idx mb
263
+ printf '%-8s %10s %-8s %-9s %s\n' PID TREE_MB STATE ETIME COMMAND
264
+ local -a sorted
265
+ sorted=($(printf '%s\n' "${order[@]:-}" | sort -rn | awk '{print $2}'))
266
+ for idx in "${sorted[@]:-}"; do
267
+ [[ -z "$idx" ]] && continue
268
+ mb=$(( r_trss[idx] / 1024 ))
269
+ 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
270
+ done
271
+ fi
272
+ }
273
+
274
+ cmd_watch() {
275
+ # Daemon: watch explicit pids, emit events when their TREE crosses a
276
+ # RSS threshold. --auto-park freezes breaching trees (SIGSTOP every pid
277
+ # in the snapshot) — only for pids passed explicitly, since stopping a
278
+ # mid-write process risks corruption; naming the pid IS the opt-in.
279
+ # --auto-wake-free-pct N undoes its own parks when free memory recovers.
280
+ local threshold_kb=$(( 2048 * 1024 )) interval=5 json=0 auto=0 wake_pct=""
281
+ local -a pids=()
282
+ while [[ $# -gt 0 ]]; do
283
+ case "$1" in
284
+ --threshold-mb) threshold_kb=$(( $2 * 1024 )); shift 2 ;;
285
+ --interval) interval="$2"; shift 2 ;;
286
+ --auto-park) auto=1; shift ;;
287
+ --auto-wake-free-pct) wake_pct="$2"; shift 2 ;;
288
+ --json) json=1; shift ;;
289
+ *) pids+=("$1"); shift ;;
290
+ esac
291
+ done
292
+ [[ ${#pids[@]} -eq 0 ]] && { echo "usage: caproom watch [--threshold-mb <mb>] [--interval <sec>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>" >&2; exit 1; }
293
+
294
+ local -a parked_by_us=() breaching=()
295
+ local mode
296
+ mode="watch"
297
+ [[ $auto -eq 1 ]] && mode="auto-park"
298
+ if [[ $json -eq 1 ]]; then
299
+ 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/,$//')"
300
+ else
301
+ 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
302
+ fi
303
+
304
+ while :; do
305
+ local -a alive=()
306
+ local pid i st0
307
+ for pid in "${pids[@]}"; do
308
+ kill -0 "$pid" 2>/dev/null && alive+=("$pid")
309
+ done
310
+ if [[ ${#alive[@]} -eq 0 ]]; then
311
+ [[ $json -eq 1 ]] && printf '{"schema":1,"event":"all-exited","ts":%s}\n' "$(date +%s)"
312
+ echo "caproom: watch: all watched pids exited" >&2
313
+ exit 0
314
+ fi
315
+ pids=("${alive[@]}")
316
+
317
+ # Auto-wake first: restore what WE parked once pressure clears.
318
+ if [[ -n "$wake_pct" && ${#parked_by_us[@]} -gt 0 ]]; then
319
+ local pct
320
+ pct=$(mem_free_pct)
321
+ if [[ "$pct" -ge "$wake_pct" ]]; then
322
+ local -a woke=()
323
+ for pid in "${parked_by_us[@]}"; do
324
+ if kill -0 "$pid" 2>/dev/null && kill -CONT "$pid" 2>/dev/null; then
325
+ woke+=("$pid")
326
+ if [[ $json -eq 1 ]]; then
327
+ printf '{"schema":1,"event":"woke","ts":%s,"pid":%s,"free_pct":%s}\n' "$(date +%s)" "$pid" "$pct"
328
+ else
329
+ echo "caproom: watch: free mem ${pct}% >= ${wake_pct}% — waking pid $pid" >&2
330
+ fi
331
+ fi
332
+ done
333
+ parked_by_us=()
334
+ fi
335
+ fi
336
+
337
+ read_snapshot
338
+ for pid in "${pids[@]}"; do
339
+ local found=""
340
+ for i in "${!SNAP_PID[@]}"; do
341
+ [[ "${SNAP_PID[$i]}" == "$pid" ]] && { found="$i"; break; }
342
+ done
343
+ [[ -z "$found" ]] && continue
344
+ st0="${SNAP_ST[$found]:0:1}"
345
+ [[ "$st0" == "T" || "$st0" == "Z" ]] && continue # already parked/dead
346
+ walk_tree "$pid"
347
+ if [[ "$TREE_RSS_KB" -ge "$threshold_kb" ]]; then
348
+ local is_breaching=""
349
+ local ev1
350
+ for ev1 in ${breaching[@]+"${breaching[@]}"}; do [[ "$ev1" == "$pid" ]] && is_breaching=1 && break; done
351
+ if [[ -n "$is_breaching" ]]; then continue; fi
352
+ breaching+=("$pid")
353
+ if [[ $auto -eq 1 ]]; then
354
+ local tp stopped=0
355
+ for tp in "${TREE_PIDS[@]}"; do
356
+ kill -STOP "$tp" 2>/dev/null && { parked_by_us+=("$tp"); stopped=$(( stopped + 1 )); }
357
+ done
358
+ if [[ $json -eq 1 ]]; then
359
+ 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"
360
+ else
361
+ 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
362
+ fi
363
+ else
364
+ if [[ $json -eq 1 ]]; then
365
+ printf '{"schema":1,"event":"breach","ts":%s,"pid":%s,"tree_rss_kb":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB"
366
+ else
367
+ echo "caproom: watch: tree of pid $pid hit $(( TREE_RSS_KB / 1024 ))MB (>= $(( threshold_kb / 1024 ))MB) — no --auto-park, reporting only" >&2
368
+ fi
369
+ fi
370
+ else
371
+ local -a keep=()
372
+ local was_breaching=0 ev2
373
+ for ev2 in ${breaching[@]+"${breaching[@]}"}; do
374
+ if [[ "$ev2" == "$pid" ]]; then was_breaching=1; else keep+=("$ev2"); fi
375
+ done
376
+ if [[ $was_breaching -eq 1 ]]; then
377
+ breaching=()
378
+ local k2
379
+ for k2 in ${keep[@]+"${keep[@]}"}; do breaching+=("$k2"); done
380
+ if [[ $json -eq 1 ]]; then
381
+ printf '{"schema":1,"event":"recovered","ts":%s,"pid":%s,"tree_rss_kb":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB"
382
+ else
383
+ echo "caproom: watch: pid $pid back under threshold ($(( TREE_RSS_KB / 1024 ))MB)" >&2
384
+ fi
385
+ fi
386
+ fi
387
+ done
388
+ sleep "$interval"
389
+ done
390
+ }
391
+
392
+ mem_free_pct() { if [[ "$(uname)" == "Darwin" ]]; then
124
393
  local page_size free inactive total_bytes avail_bytes
125
394
  page_size=$(vm_stat | awk '/page size of/ {print $8}')
126
395
  free=$(vm_stat | awk '/Pages free/ {gsub("\\.","",$3); print $3}')
@@ -176,10 +445,257 @@ cmd_guard() {
176
445
  done
177
446
  }
178
447
 
448
+ # ---- terminal bind: setup / unbind ------------------------------------
449
+ # Binds headroom management to every interactive shell in ANY terminal
450
+ # (Terminal.app, iTerm2, Ghostty, ...) by writing ONE integration file
451
+ # per shell under ~/.caproom/ and marker-patching the rc files. Idempotent,
452
+ # backed up, reversible with `caproom unbind`. Never runs automatically:
453
+ # npm postinstall only prints a hint.
454
+
455
+ CAPROOM_DIR="${CAPROOM_DIR:-$HOME/.caproom}"
456
+
457
+ setup_shell_sh() {
458
+ cat > "$CAPROOM_DIR/shell.sh" << 'EOF'
459
+ # caproom shell integration — regenerated by `caproom setup`; edits here
460
+ # are overwritten. Source of truth: bin/caproom (setup_shell_sh).
461
+
462
+ caproom_freemem_pct() { command caproom freemem 2>/dev/null; }
463
+
464
+ caproom_headroom_check() {
465
+ local pct last now
466
+ pct=$(caproom_freemem_pct) || return 0
467
+ [ -n "$pct" ] || return 0
468
+ [ "$pct" -lt "${CAPROOM_HEADROOM_WARN:-20}" ] || return 0
469
+ last=$(cat "${TMPDIR:-/tmp}/caproom-headroom-last" 2>/dev/null || echo 0)
470
+ now=$(date +%s)
471
+ [ $(( now - ${last:-0} )) -ge 60 ] || return 0
472
+ echo "$now" > "${TMPDIR:-/tmp}/caproom-headroom-last" 2>/dev/null
473
+ echo "caproom: headroom low (${pct}% free) — check 'caproom top' before launching heavy work"
474
+ }
475
+
476
+ if [ -n "$ZSH_VERSION" ]; then
477
+ autoload -Uz add-zsh-hook
478
+ add-zsh-hook precmd caproom_headroom_check
479
+ elif [ -n "$BASH_VERSION" ]; then
480
+ case ";$PROMPT_COMMAND;" in
481
+ *caproom_headroom_check*) ;;
482
+ *) PROMPT_COMMAND="caproom_headroom_check${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
483
+ esac
484
+ fi
485
+
486
+ # Opt-in auto-wrap: CAPROOM_AUTO_WRAP="claude,codex,opencode" gives every
487
+ # listed command a <name>_capped twin running under $CAPROOM_LIMIT_MB.
488
+ # The bare name is aliased ONLY with CAPROOM_AUTO_ALIAS=1 — never hijack a
489
+ # command the user did not consent to wrap.
490
+ if [ -n "${CAPROOM_AUTO_WRAP:-}" ]; then
491
+ for _cr_cmd in $(echo "${CAPROOM_AUTO_WRAP}" | tr ',' ' '); do
492
+ _cr_fn="$(printf '%s' "$_cr_cmd" | sed 's/[-.]/_/g')_capped"
493
+ eval "$(printf "%s() { command caproom --limit \"\${CAPROOM_LIMIT_MB:-4096}\" --grace \"\${CAPROOM_GRACE:-5}\" -- '%s' \"\$@\"; }" "$_cr_fn" "$(printf '%s' "$_cr_cmd" | sed "s/'/'\\\\''/g")")"
494
+ if [ "${CAPROOM_AUTO_ALIAS:-0}" = "1" ]; then
495
+ alias "$_cr_cmd=$_cr_fn"
496
+ fi
497
+ done
498
+ unset _cr_cmd _cr_fn
499
+ fi
500
+ EOF
501
+ }
502
+
503
+ setup_shell_fish() {
504
+ cat > "$CAPROOM_DIR/shell.fish" << 'EOF'
505
+ # caproom fish integration — regenerated by `caproom setup`.
506
+ function __caproom_freemem
507
+ command caproom freemem 2>/dev/null
508
+ end
509
+
510
+ function __caproom_headroom_check --on-event fish_prompt
511
+ set -l pct (__caproom_freemem)
512
+ or return
513
+ test -n "$pct"; or return
514
+ set -l warn 20
515
+ if set -q CAPROOM_HEADROOM_WARN
516
+ set warn $CAPROOM_HEADROOM_WARN
517
+ end
518
+ if test "$pct" -lt "$warn"
519
+ set -l stamp /tmp/caproom-headroom-last
520
+ set -l now (date +%s)
521
+ set -l last 0
522
+ if test -f $stamp
523
+ set last (cat $stamp)
524
+ end
525
+ if test (math "$now - $last") -ge 60
526
+ echo $now > $stamp
527
+ echo "caproom: headroom low ($pct% free) — check 'caproom top' before launching heavy work"
528
+ end
529
+ end
530
+ end
531
+ EOF
532
+ }
533
+
534
+ setup_shell_ps1() {
535
+ cat > "$CAPROOM_DIR/shell.ps1" << 'EOF'
536
+ # caproom PowerShell integration — regenerated by `caproom setup` (Windows).
537
+ function global:caproom_freemem_pct {
538
+ $os = Get-CimInstance Win32_OperatingSystem
539
+ [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
540
+ }
541
+ $global:__caproomLastWarn = 0
542
+ function global:prompt {
543
+ $pct = caproom_freemem_pct
544
+ $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
545
+ if ($pct -lt (${CAPROOM_HEADROOM_WARN:-20}) -and ($now - $script:__caproomLastWarn) -ge 60) {
546
+ $script:__caproomLastWarn = $now
547
+ Write-Host "caproom: headroom low ($pct% free) — check 'caproom top' before launching heavy work" -ForegroundColor Yellow
548
+ }
549
+ "PS $($executionContext.SessionState.Path.CurrentLocation)> "
550
+ }
551
+ EOF
552
+ }
553
+
554
+ rc_targets() {
555
+ # Prints "path<TAB>required" pairs for every rc we manage. Only rcs that
556
+ # already exist are patched, EXCEPT the login shell's own rc which is
557
+ # created if missing — never invent configs for shells you don't use.
558
+ local zshrc="${ZDOTDIR:-$HOME/.zshrc}"
559
+ printf '%s\t%s\n' "$zshrc" "shell"
560
+ [[ -f "$HOME/.bashrc" ]] && printf '%s\t%s\n' "$HOME/.bashrc" "optional"
561
+ }
562
+
563
+ patch_rc_file() {
564
+ local rc="$1"
565
+ [[ -f "$rc" ]] || touch "$rc"
566
+ grep -q "# >>> caproom >>>" "$rc" && return 0
567
+ cp "$rc" "$rc.caproom.bak.$(date +%Y%m%d%H%M%S)"
568
+ {
569
+ echo ""
570
+ echo "# >>> caproom >>>"
571
+ echo '[ -f ~/.caproom/shell.sh ] && source ~/.caproom/shell.sh'
572
+ echo "# <<< caproom <<<"
573
+ } >> "$rc"
574
+ }
575
+
576
+ patch_rc_file_fish() {
577
+ local rc="$HOME/.config/fish/config.fish"
578
+ mkdir -p "$(dirname "$rc")" 2>/dev/null
579
+ [[ -f "$rc" ]] || return 0 # don't invent fish config unless it exists
580
+ grep -q "# caproom (fish)" "$rc" && return 0
581
+ cp "$rc" "$rc.caproom.bak.$(date +%Y%m%d%H%M%S)"
582
+ {
583
+ echo ""
584
+ echo "# caproom (fish)"
585
+ echo '[ -f ~/.caproom/shell.fish ] && source ~/.caproom/shell.fish'
586
+ } >> "$rc"
587
+ }
588
+
589
+ install_guard_daemon() {
590
+ local threshold="$1"
591
+ local bin_path
592
+ bin_path=$(command -v caproom || true)
593
+ [[ -n "$bin_path" ]] || { echo "caproom setup: cannot resolve caproom binary for daemon" >&2; return 1; }
594
+ if [[ "$(uname)" == "Darwin" ]]; then
595
+ local plist="$HOME/Library/LaunchAgents/com.caproom.guard.plist"
596
+ mkdir -p "$HOME/Library/LaunchAgents"
597
+ cat > "$plist" << EOF
598
+ <?xml version="1.0" encoding="UTF-8"?>
599
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
600
+ <plist version="1.0"><dict>
601
+ <key>Label</key><string>com.caproom.guard</string>
602
+ <key>ProgramArguments</key><array>
603
+ <string>/usr/bin/env</string><string>bash</string><string>$bin_path</string>
604
+ <string>guard</string><string>--threshold</string><string>$threshold</string><string>--interval</string><string>15</string>
605
+ </array>
606
+ <key>RunAtLoad</key><true/>
607
+ <key>KeepAlive</key><true/>
608
+ </dict></plist>
609
+ EOF
610
+ echo "caproom setup: guard daemon installed -> $plist"
611
+ echo " load now: launchctl load $plist"
612
+ echo " unload: launchctl unload $plist"
613
+ else
614
+ local unit="$HOME/.config/systemd/user/caproom-guard.service"
615
+ mkdir -p "$HOME/.config/systemd/user"
616
+ cat > "$unit" << EOF
617
+ [Unit]
618
+ Description=caproom memory guard
619
+
620
+ [Service]
621
+ ExecStart=/usr/bin/env bash $bin_path guard --threshold $threshold --interval 15
622
+ Restart=on-failure
623
+
624
+ [Install]
625
+ WantedBy=default.target
626
+ EOF
627
+ echo "caproom setup: guard service installed -> $unit"
628
+ echo " start now: systemctl --user enable --now caproom-guard.service"
629
+ echo " stop: systemctl --user disable --now caproom-guard.service"
630
+ fi
631
+ }
632
+
633
+ cmd_setup() {
634
+ local guard="" threshold=10 do_unbind=0
635
+ while [[ $# -gt 0 ]]; do
636
+ case "$1" in
637
+ --guard) guard="$threshold"; shift ;;
638
+ --threshold) threshold="$2"; shift 2 ;;
639
+ --uninstall|--unbind) do_unbind=1; shift ;;
640
+ *) echo "caproom setup: unknown option $1" >&2; exit 1 ;;
641
+ esac
642
+ done
643
+
644
+ if [[ "$do_unbind" -eq 1 ]]; then
645
+ local rc removed=0
646
+ while IFS=$'\t' read -r rc _req; do
647
+ [[ -f "$rc" ]] || continue
648
+ if grep -q "# >>> caproom >>>" "$rc"; then
649
+ awk '/^# >>> caproom >>>$/{skip=1;next} /^# <<< caproom <<<$/{skip=0;next} !skip' "$rc" > "$rc.cr.tmp" && mv "$rc.cr.tmp" "$rc"
650
+ removed=$(( removed + 1 ))
651
+ fi
652
+ done < <(rc_targets)
653
+ if grep -q "# caproom (fish)" "$HOME/.config/fish/config.fish" 2>/dev/null; then
654
+ awk '/^# caproom \(fish\)$/{getline; skip=1; next} !skip' "$HOME/.config/fish/config.fish" > /tmp/cr-fish.tmp 2>/dev/null \
655
+ && mv /tmp/cr-fish.tmp "$HOME/.config/fish/config.fish"
656
+ removed=$(( removed + 1 ))
657
+ fi
658
+ echo "caproom unbind: markers removed from $removed file(s); backups kept as *.caproom.bak.*"
659
+ echo " integration files left in $CAPROOM_DIR (rm -rf to purge)"
660
+ return 0
661
+ fi
662
+
663
+ mkdir -p "$CAPROOM_DIR"
664
+ setup_shell_sh
665
+ setup_shell_fish
666
+ [[ "$(uname)" != "Darwin" ]] || setup_shell_ps1
667
+
668
+ local rc req patched=0
669
+ while IFS=$'\t' read -r rc req; do
670
+ if patch_rc_file "$rc"; then patched=$(( patched + 1 )); fi
671
+ done < <(rc_targets)
672
+ patch_rc_file_fish
673
+
674
+ echo "caproom setup: bound to your shells via $CAPROOM_DIR/"
675
+ echo " shell.sh zsh + bash (headroom warning on every prompt, opt-in auto-wrap)"
676
+ echo " shell.fish fish equivalent"
677
+ echo " patched rc files: $patched (backups alongside as *.caproom.bak.*)"
678
+ echo ""
679
+ echo "auto-wrap usage:"
680
+ echo ' export CAPROOM_AUTO_WRAP="claude,codex,opencode" # creates <cmd>_capped twins'
681
+ echo ' export CAPROOM_AUTO_ALIAS=1 # ALSO shadow bare names (explicit consent)'
682
+ echo " export CAPROOM_LIMIT_MB=8192 # per-shell budget"
683
+ echo ""
684
+ echo "new terminals pick this up immediately; current ones: source ~/.caproom/shell.sh"
685
+
686
+ if [[ -n "$guard" ]]; then
687
+ echo ""
688
+ install_guard_daemon "$threshold"
689
+ fi
690
+ }
179
691
  case "${1:-}" in
180
692
  park) shift; cmd_park "$@"; exit 0 ;;
181
693
  wake) shift; cmd_wake "$@"; exit 0 ;;
694
+ freemem) mem_free_pct; exit 0 ;;
695
+ setup|bind|unbind) shift; cmd_setup "$@"; exit 0 ;;
182
696
  status) shift; cmd_status "$@"; exit 0 ;;
697
+ top) shift; cmd_top "$@"; exit 0 ;;
698
+ watch) shift; cmd_watch "$@"; exit 0 ;;
183
699
  guard) shift; cmd_guard "$@"; exit 0 ;;
184
700
  init) shift; cmd_init "$@"; exit 0 ;;
185
701
  help|-h|--help) usage help ;;
@@ -216,39 +732,12 @@ run_docker() {
216
732
  -v "$PWD:/work" -w /work "$IMAGE" "$@"
217
733
  }
218
734
 
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"
735
+ collect_tree() {
736
+ # Snapshot ps once and walk the descendant tree of $1 into TREE_PIDS /
737
+ # TREE_RSS_KB (see read_snapshot / walk_tree). Plain indexed arrays only
738
+ # so macOS's stock bash 3.2 works.
739
+ read_snapshot
740
+ walk_tree "$1"
252
741
  }
253
742
 
254
743
  run_watchdog() {
@@ -258,19 +747,35 @@ run_watchdog() {
258
747
  local pid=$!
259
748
  local exit_code=0
260
749
  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
750
+ collect_tree "$pid"
751
+ if [[ "${#TREE_PIDS[@]}" -gt 0 && "$TREE_RSS_KB" -gt "$limit_kb" ]]; then
752
+ local overshoot=$(( TREE_RSS_KB * 100 / limit_kb ))
753
+ echo "caproom: pid $pid tree RSS ${TREE_RSS_KB}KB exceeded ${limit_kb}KB cap (+${overshoot}%) killing tree (grace ${GRACE}s)" >&2
754
+ # Signal EVERY pid in the tree, not just the root: children that
755
+ # survive a root-only TERM get orphaned and keep allocating past
756
+ # the cap after caproom exits.
757
+ kill -TERM "${TREE_PIDS[@]}" 2>/dev/null || true
758
+ local -a breach_pids=("${TREE_PIDS[@]}")
266
759
  local waited=0
267
760
  while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
268
761
  sleep 1
269
762
  waited=$(( waited + 1 ))
270
763
  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
764
+ # Escalate against anything that ignored TERM — root OR child.
765
+ # Scanning the breach-time snapshot (not re-walking from the root)
766
+ # also catches the case where the root died but a stubborn child
767
+ # survived it. Children spawned DURING the grace window are not in
768
+ # the snapshot; same accepted gap as detached daemons generally.
769
+ local sp sweep=0
770
+ for sp in "${breach_pids[@]}"; do
771
+ if kill -0 "$sp" 2>/dev/null; then
772
+ kill -9 "$sp" 2>/dev/null || true
773
+ sweep=$(( sweep + 1 ))
774
+ fi
775
+ done
776
+ if [[ "$sweep" -gt 0 ]]; then
777
+ echo "caproom: SIGKILLed ${sweep} survivor(s) after grace — exit 137" >&2
778
+ wait "$pid" 2>/dev/null || true
274
779
  exit 137
275
780
  fi
276
781
  wait "$pid" 2>/dev/null || exit_code=$?
@@ -0,0 +1,267 @@
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
+ const errText = (r.stderr || '');
135
+ const capped = /exceeded .* cap/.test(errText);
136
+ let verdict;
137
+ if (r.status === 137 || r.signal === 'SIGKILL') verdict = 'RESULT: KILLED BY CAP (exit 137)';
138
+ else if (capped && (r.status === 143 || r.signal === 'SIGTERM')) verdict = 'RESULT: CAPPED — tree killed during grace (SIGTERM honored, exit 143)';
139
+ else if (r.status === 143 || r.signal === 'SIGTERM') verdict = 'RESULT: terminated during grace (SIGTERM honored)';
140
+ else verdict = 'RESULT: exit=' + r.status;
141
+ return text(verdict + '\n--- stderr ---\n' + (errText.slice(-4000) || '(empty)'));
142
+ }
143
+ default:
144
+ throw new Error('unknown tool: ' + name);
145
+ }
146
+ }
147
+
148
+ const TOOLS = [
149
+ {
150
+ name: 'top',
151
+ 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.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ pid: { type: 'number', description: 'restrict to one subtree' },
156
+ park_min_mb: { type: 'number', description: 'park-candidate threshold in MB (default 512)' },
157
+ },
158
+ },
159
+ },
160
+ {
161
+ name: 'park',
162
+ 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.',
163
+ inputSchema: { type: 'object', properties: { pid: { type: 'number' } }, required: ['pid'] },
164
+ },
165
+ {
166
+ name: 'wake',
167
+ description: 'SIGCONT a parked process back to life, same state, same PID.',
168
+ inputSchema: { type: 'object', properties: { pid: { type: 'number' } }, required: ['pid'] },
169
+ },
170
+ {
171
+ name: 'watch_start',
172
+ 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.',
173
+ inputSchema: {
174
+ type: 'object',
175
+ properties: {
176
+ pids: { type: 'array', items: { type: 'number' }, description: 'explicit pids to watch' },
177
+ threshold_mb: { type: 'number', description: 'per-tree breach threshold (default 2048)' },
178
+ auto_park: { type: 'boolean' },
179
+ auto_wake_free_pct: { type: 'number' },
180
+ interval: { type: 'number', description: 'poll seconds (default 5)' },
181
+ },
182
+ required: ['pids'],
183
+ },
184
+ },
185
+ {
186
+ name: 'watch_events',
187
+ description: 'Drain accumulated NDJSON events from a watcher (clears them unless clear=false). Also reports whether the watcher exited.',
188
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, clear: { type: 'boolean' } }, required: ['id'] },
189
+ },
190
+ {
191
+ name: 'watch_stop',
192
+ description: 'Stop a watcher and return its final drained events.',
193
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
194
+ },
195
+ {
196
+ name: 'run',
197
+ 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.',
198
+ inputSchema: {
199
+ type: 'object',
200
+ properties: {
201
+ command: { type: 'array', items: { type: 'string' }, description: 'argv, e.g. ["npm","run","build"]' },
202
+ limit_mb: { type: 'number' },
203
+ grace: { type: 'number', description: 'seconds between SIGTERM and SIGKILL (default 5)' },
204
+ docker: { type: 'boolean' },
205
+ image: { type: 'string' },
206
+ timeout_ms: { type: 'number', description: 'default 300000' },
207
+ },
208
+ required: ['command'],
209
+ },
210
+ },
211
+ ];
212
+
213
+ process.stdin.setEncoding('utf8');
214
+ let inbuf = '';
215
+
216
+ function send(obj) {
217
+ process.stdout.write(JSON.stringify(obj) + '\n');
218
+ }
219
+
220
+ function handle(msg) {
221
+ if (msg.method === 'initialize') {
222
+ send({
223
+ jsonrpc: '2.0', id: msg.id,
224
+ result: {
225
+ protocolVersion: msg.params && msg.params.protocolVersion || '2024-11-05',
226
+ capabilities: { tools: {} },
227
+ serverInfo: { name: 'caproom-mcp', version: require('../package.json').version },
228
+ },
229
+ });
230
+ } else if (msg.method === 'tools/list') {
231
+ send({ jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
232
+ } else if (msg.method === 'tools/call') {
233
+ const { name, arguments: args } = msg.params || {};
234
+ try {
235
+ const res = callTool(name, args || {});
236
+ send({ jsonrpc: '2.0', id: msg.id, result: res });
237
+ } catch (e) {
238
+ send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: 'error: ' + e.message }], isError: true } });
239
+ }
240
+ } else if (msg.method === 'ping') {
241
+ send({ jsonrpc: '2.0', id: msg.id, result: {} });
242
+ } else if (msg.id !== undefined) {
243
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found: ' + msg.method } });
244
+ }
245
+ // notifications (initialized, etc.) get no reply
246
+ }
247
+
248
+ process.stdin.on('data', chunk => {
249
+ inbuf += chunk;
250
+ let i;
251
+ while ((i = inbuf.indexOf('\n')) >= 0) {
252
+ const line = inbuf.slice(0, i).trim();
253
+ inbuf = inbuf.slice(i + 1);
254
+ if (!line) continue;
255
+ let msg;
256
+ try { msg = JSON.parse(line); } catch (_) { continue; }
257
+ try { handle(msg); } catch (e) {
258
+ if (msg && msg.id !== undefined) {
259
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(e.message || e) } });
260
+ }
261
+ }
262
+ }
263
+ });
264
+
265
+ process.on('disconnect', () => {
266
+ for (const id of Array.from(watchers.keys())) stopWatcher(id);
267
+ });
package/bin/caproom.ps1 CHANGED
@@ -406,10 +406,68 @@ function Invoke-Capped {
406
406
 
407
407
  if ($args.Count -eq 0) { Show-Usage }
408
408
 
409
+ function Invoke-Setup {
410
+ # Bind headroom management to PowerShell sessions in ANY terminal:
411
+ # writes ~/.caproom/shell.ps1 (single source) and marker-patches
412
+ # $PROFILE. Idempotent, backs up the profile, reversible via
413
+ # `caproom setup --uninstall`. Never runs on npm install.
414
+ $dir = Join-Path $HOME '.caproom'
415
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
416
+
417
+ @'
418
+ # caproom PowerShell integration — regenerated by `caproom setup`.
419
+ function global:caproom_freemem_pct {
420
+ $os = Get-CimInstance Win32_OperatingSystem
421
+ [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
422
+ }
423
+ $global:__caproomLastWarn = 0
424
+ function global:prompt {
425
+ try {
426
+ $pct = caproom_freemem_pct
427
+ $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
428
+ $warn = if ($env:CAPROOM_HEADROOM_WARN) { [int]$env:CAPROOM_HEADROOM_WARN } else { 20 }
429
+ if ($pct -lt $warn -and ($now - $script:__caproomLastWarn) -ge 60) {
430
+ $script:__caproomLastWarn = $now
431
+ Write-Host "caproom: headroom low ($pct% free) - check 'caproom top' before launching heavy work" -ForegroundColor Yellow
432
+ }
433
+ } catch {}
434
+ "PS $($executionContext.SessionState.Path.CurrentLocation)> "
435
+ }
436
+ '@ | Set-Content -Encoding UTF8 (Join-Path $dir 'shell.ps1')
437
+
438
+ $profilePath = $PROFILE.CurrentUserAllHosts
439
+ if (-not (Test-Path $profilePath)) { New-Item -ItemType File -Force -Path $profilePath | Out-Null }
440
+ $content = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
441
+ if ($content -notmatch '# >>> caproom >>>') {
442
+ Copy-Item $profilePath "$profilePath.caproom.bak.$(Get-Date -Format yyyyMMddHHmmss)"
443
+ Add-Content $profilePath @'
444
+
445
+ # >>> caproom >>>
446
+ . "$HOME\.caproom\shell.ps1"
447
+ # <<< caproom <<<
448
+ '@
449
+ [Console]::Error.WriteLine("caproom setup: patched $profilePath (backup alongside)")
450
+ } else {
451
+ [Console]::Error.WriteLine('caproom setup: profile already bound')
452
+ }
453
+ [Console]::Error.WriteLine('caproom setup: shell.ps1 written to ' + $dir + ' — new terminals pick it up automatically')
454
+ }
455
+
409
456
  switch ($args[0]) {
410
457
  'help' { Show-Usage -AsHelp }
411
458
  '-h' { Show-Usage -AsHelp }
412
459
  '--help' { Show-Usage -AsHelp }
460
+ 'setup' {
461
+ Invoke-Setup; exit 0
462
+ }
463
+ 'bind' {
464
+ Invoke-Setup; exit 0
465
+ }
466
+ 'freemem' {
467
+ $os = Get-CimInstance Win32_OperatingSystem
468
+ Write-Output ([int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize))
469
+ exit 0
470
+ }
413
471
  'park' {
414
472
  if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
415
473
  Invoke-Park -TargetPid ([int]$args[1]); exit 0
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "caproom",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -26,6 +28,9 @@
26
28
  "linux",
27
29
  "win32"
28
30
  ],
31
+ "scripts": {
32
+ "postinstall": "node -e \"process.stdout.write('caproom: optional next step — run `caproom setup` to bind headroom management to your shells (never modifies rc files on install)\\n')\""
33
+ },
29
34
  "license": "MIT",
30
35
  "repository": {
31
36
  "type": "git",