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 +58 -2
- package/bin/caproom +307 -49
- package/bin/caproom-mcp.js +264 -0
- package/package.json +5 -3
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
|
|
39
|
-
memory without killing it.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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) —
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
echo "caproom: pid $pid tree RSS ${
|
|
265
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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.
|
|
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",
|