caproom 0.7.6 → 0.9.2

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/bin/caproom DELETED
@@ -1,1025 +0,0 @@
1
- #!/usr/bin/env bash
2
- # caproom — memory-cap any command (AI coding agents, builds, background jobs)
3
- # on macOS/Linux. macOS has no working RLIMIT_AS/DATA/RSS or launchd RSS
4
- # enforcement (verified empirically — both are no-ops on modern macOS), so
5
- # this uses whichever real enforcement mechanism is available:
6
- # 1. Host-native polling watchdog (process-tree RSS + SIGKILL) — the
7
- # DEFAULT. Runs in your real environment: same PATH, auth, native
8
- # binaries, tty. Small race window bounded by --interval.
9
- # 2. Docker cgroup (--memory) — opt-in via --docker. Hard cap, zero race
10
- # window, but runs the command inside a Linux container (native-module
11
- # and toolchain drift; see README). Fails loudly if the daemon is down.
12
- set -euo pipefail
13
-
14
- usage() {
15
- local stream=/dev/stderr
16
- local code=1
17
- if [[ "${1:-}" == "help" ]]; then stream=/dev/stdout; code=0; fi
18
- cat >"$stream" << 'EOF'
19
- usage: caproom [--limit <mb>] [--image <docker-image>] [--interval <sec>] -- <command> [args...]
20
- caproom park <pid>
21
- caproom wake <pid>
22
- caproom status <pid>
23
- caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
24
- caproom init <command> [--limit <mb>] [--grace <sec>]
25
-
26
- --limit <mb> memory cap in MB (default: 4096)
27
- --interval <sec> watchdog poll interval in seconds (default: 0.2)
28
- --grace <sec> seconds to wait after SIGTERM before SIGKILL, watchdog
29
- backend only (default: 5) — gives the process a chance
30
- to flush/save state before a hard kill
31
- --docker opt in to the Docker cgroup backend instead of the default
32
- host-native watchdog (needs the daemon running)
33
- --image <name> docker image used by the --docker backend
34
- (default: node:22-slim)
35
- --force-watchdog no-op; the host-native watchdog IS the default — kept so
36
- existing scripts and 'init' snippets keep working
37
- --no-intercept-tty bypass stdio interposition for TUI/pty apps (OSC 10/11,
38
- DSR CPR, mouse DEC 1003) — exec directly and monitor via
39
- detached 'caproom watch --auto-park' instead
40
- --pty allocate a pty (forkpty) and forward bytes verbatim — full
41
- terminal fidelity for TUI (requires python3 or script)
42
- implies --no-intercept-tty + pty watchdog on the pty
43
- tree; fallback to bypass if pty alloc fails
44
- --no-pty disable pty allocation (use bypass or watchdog)
45
-
46
- park / wake — freeze an idle process so the kernel CAN reclaim/compress its
47
- memory without killing it. Honest semantics: SIGSTOP only makes the pages
48
- eligible — the kernel reclaims them lazily, when real memory pressure hits.
49
- Park a 2GB agent on a quiet machine and it may stay ~2GB resident for hours.
50
- Park is insurance against OOM, not immediate RAM return; use it for processes
51
- too expensive to restart. `caproom park <pid>` (SIGSTOP), `caproom wake
52
- <pid>` (SIGCONT) brings it back instantly, same state, no restart needed.
53
- Any agent can call these directly — they're just SIGSTOP/SIGCONT, no daemon,
54
- no tracking file required.
55
-
56
- guard — watch SYSTEM-WIDE free memory (not any single process) and auto-park
57
- tracked pids (SIGSTOP) when free mem drops below --threshold percent, before
58
- the kernel OOM-killer has to pick a victim. Use it when unrelated heavy
59
- processes (e.g. a GPU inference job in one terminal, a TTS job in another)
60
- share a box and neither is individually over any --limit cap. Foreground,
61
- blocking; exits once all watched pids have exited. Does not auto-wake —
62
- `caproom wake <pid>` when memory pressure clears:
63
-
64
- caproom guard --threshold 10 --interval 5 -- 12345 12346
65
-
66
- init <command> — print a shell snippet that auto-caps <command> on every
67
- invocation, so a new terminal tab is capped with no extra typing. Append the
68
- output to your shell rc (~/.zshrc, ~/.bashrc):
69
-
70
- caproom init claude >> ~/.zshrc && source ~/.zshrc
71
-
72
- env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_IMAGE, CAPROOM_INTERVAL, CAPROOM_GRACE, CAPROOM_BYPASS_TTY=1, CAPROOM_PTY=1
73
-
74
- TUI note: 'caproom -- <TUI>' (opencode, vim, htop, ...) bypasses stdio when [ -t 0 ] && [ -t 1 ]
75
- so terminal queries (OSC, mouse) stay on the pty. The cap is then advisory via a detached
76
- 'caproom watch --auto-park' monitor, or via '--pty' which allocates a real pty (forkpty)
77
- and forwards bytes verbatim (python3 or script). Piped/batch 'caproom -- opencode run "task"'
78
- stays fully capped. Use --no-intercept-tty / CAPROOM_BYPASS_TTY=1 to force bypass, --pty /
79
- CAPROOM_PTY=1 to force pty.
80
-
81
- examples:
82
- caproom --limit 2048 -- npm run build
83
- caproom --limit 512 -- claude --dangerously-skip-permissions -p "task"
84
- caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
85
- caproom park 12345
86
- caproom wake 12345
87
- caproom top --json [--pid <pid>] [--park-min-mb <mb>]
88
- caproom watch [--threshold-mb <mb>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>
89
- caproom setup [--guard] [--threshold <pct>] [--uninstall]
90
- caproom freemem
91
- caproom init claude --limit 6144 --grace 10
92
- EOF
93
- exit "$code"
94
- }
95
-
96
- cmd_init() {
97
- local target="${1:-}"
98
- [[ -z "$target" ]] && { echo "usage: caproom init <command> [--limit <mb>] [--grace <sec>]" >&2; exit 1; }
99
- shift
100
- local limit=4096
101
- local grace=5
102
- while [[ $# -gt 0 ]]; do
103
- case "$1" in
104
- --limit) limit="$2"; shift 2 ;;
105
- --grace) grace="$2"; shift 2 ;;
106
- *) echo "caproom: unknown init flag $1" >&2; exit 1 ;;
107
- esac
108
- done
109
- local fn="${target}_capped"
110
- local is_tui=0
111
- case "$target" in
112
- opencode|claude|codex|vim|nvim|htop|less|fzf|nano|emacs) is_tui=1 ;;
113
- esac
114
- if [[ $is_tui -eq 1 ]]; then
115
- echo "caproom: warning: '$target' is a TUI — interactive tty runs will bypass stdio and use a detached 'caproom watch --auto-park' monitor to preserve pty (OSC 10/11, DSR CPR, mouse DEC 1003). Batch 'caproom -- $target run' stays fully capped. Set CAPROOM_BYPASS_TTY=1 to always bypass, or CAPROOM_BYPASS_TTY=0 + re-init to force legacy pipe mode." >&2
116
- cat << EOF
117
- # caproom: auto-cap '$target' — added by 'caproom init $target'
118
- # override per-shell: CAPROOM_LIMIT_MB=8192 $target ...
119
- # TUI note: when [ -t 0 ] && [ -t 1 ], '$target' runs directly and a detached 'caproom watch --auto-park' monitors it
120
- # (preserves OSC 10/11, DSR CPR, mouse DEC 1003). Batch/headless (piped) still goes through the watchdog.
121
- # Set CAPROOM_BYPASS_TTY=1 to always bypass, 0 to force legacy mode.
122
- $fn() {
123
- if [ "\${CAPROOM_BYPASS_TTY:-0}" = "1" ]; then
124
- command caproom --no-intercept-tty --limit "\${CAPROOM_LIMIT_MB:-$limit}" --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
125
- return \$?
126
- fi
127
- if [ -t 0 ] && [ -t 1 ]; then
128
- command $target "\$@" &
129
- local _cap_pid=\$!
130
- command caproom watch --threshold-mb "\${CAPROOM_LIMIT_MB:-$limit}" --interval "\${CAPROOM_INTERVAL:-0.2}" --auto-park --json "\$_cap_pid" >/dev/null 2>&1 &
131
- wait "\$_cap_pid"
132
- return \$?
133
- fi
134
- command caproom --limit "\${CAPROOM_LIMIT_MB:-$limit}" --force-watchdog --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
135
- }
136
- alias $target=$fn
137
- EOF
138
- else
139
- cat << EOF
140
- # caproom: auto-cap '$target' — added by 'caproom init $target'
141
- # override per-shell: CAPROOM_LIMIT_MB=8192 $target ...
142
- $fn() {
143
- if [ "\${CAPROOM_BYPASS_TTY:-0}" = "1" ]; then
144
- command caproom --no-intercept-tty --limit "\${CAPROOM_LIMIT_MB:-$limit}" --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
145
- return \$?
146
- fi
147
- command caproom --limit "\${CAPROOM_LIMIT_MB:-$limit}" --force-watchdog --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
148
- }
149
- alias $target=$fn
150
- EOF
151
- fi
152
- }
153
-
154
- cmd_park() {
155
- local pid="${1:-}"
156
- [[ -z "$pid" ]] && { echo "usage: caproom park <pid>" >&2; exit 1; }
157
- kill -0 "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
158
- kill -STOP "$pid"
159
- 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
160
- }
161
-
162
- cmd_wake() {
163
- local pid="${1:-}"
164
- [[ -z "$pid" ]] && { echo "usage: caproom wake <pid>" >&2; exit 1; }
165
- kill -0 "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
166
- kill -CONT "$pid"
167
- echo "caproom: pid $pid woken (SIGCONT)" >&2
168
- }
169
-
170
- cmd_status() {
171
- local pid="${1:-}"
172
- [[ -z "$pid" ]] && { echo "usage: caproom status <pid>" >&2; exit 1; }
173
- ps -o pid,stat,rss,etime,command -p "$pid" 2>/dev/null || { echo "caproom: no such pid $pid" >&2; exit 1; }
174
- }
175
-
176
- # ---- process-tree inventory (top / watch foundation) ----
177
-
178
- # One ps pass filling the SNAP_* global arrays for the current user.
179
- read_snapshot() {
180
- SNAP_PID=(); SNAP_PPID=(); SNAP_RSS=(); SNAP_ST=(); SNAP_ET=(); SNAP_CMD=()
181
- local myuid uid pid ppid rss st et cmd
182
- myuid="$(id -u)"
183
- while read -r uid pid ppid rss st et cmd; do
184
- [[ "$uid" != "$myuid" ]] && continue
185
- SNAP_PID+=("$pid"); SNAP_PPID+=("$ppid"); SNAP_RSS+=("${rss:-0}")
186
- SNAP_ST+=("${st:-?}"); SNAP_ET+=("${et:-0}"); SNAP_CMD+=("${cmd:-}")
187
- done < <(ps -eo uid=,pid=,ppid=,rss=,state=,etime=,command=)
188
- }
189
-
190
- # Walk the subtree of $1 over the existing SNAP_* arrays, filling
191
- # TREE_PIDS / TREE_RSS_KB. Does NOT re-read ps — cheap enough to call
192
- # once per tree root from a single snapshot.
193
- walk_tree() {
194
- local root="$1" cur i j
195
- local -a lpids=("${SNAP_PPID[@]}") lq=()
196
- TREE_PIDS=(); TREE_RSS_KB=0
197
- lq=("$root")
198
- while [[ ${#lq[@]} -gt 0 ]]; do
199
- cur="${lq[0]}"
200
- if [[ ${#lq[@]} -gt 1 ]]; then lq=("${lq[@]:1}"); else lq=(); fi
201
- for i in "${!SNAP_PID[@]}"; do
202
- if [[ "${SNAP_PID[$i]}" == "$cur" ]]; then
203
- TREE_PIDS+=("$cur")
204
- TREE_RSS_KB=$(( TREE_RSS_KB + SNAP_RSS[$i] ))
205
- for j in "${!lpids[@]}"; do
206
- if [[ "${lpids[$j]}" == "$cur" ]]; then
207
- lq+=("${SNAP_PID[$j]}")
208
- lpids[$j]=""
209
- fi
210
- done
211
- break
212
- fi
213
- done
214
- done
215
- }
216
-
217
- json_escape() {
218
- local s="$1"
219
- s="${s//\\/\\\\}"
220
- s="${s//\"/\\\"}"
221
- s="${s//$'\n'/ }"
222
- s="${s//$'\r'/ }"
223
- s="${s//$'\t'/ }"
224
- printf '%s' "$s"
225
- }
226
-
227
- cmd_top() {
228
- local json=0 park_min_kb=$(( 512 * 1024 )) filter_pid=""
229
- while [[ $# -gt 0 ]]; do
230
- case "$1" in
231
- --json) json=1; shift ;;
232
- --park-min-mb) park_min_kb=$(( $2 * 1024 )); shift 2 ;;
233
- --pid) filter_pid="$2"; shift 2 ;;
234
- *) echo "caproom top: unknown option $1" >&2; exit 1 ;;
235
- esac
236
- done
237
-
238
- read_snapshot
239
- [[ ${#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; }
240
-
241
- # Tree roots: parents outside the visible set (or init-reparented).
242
- local -a roots=()
243
- local i j p found
244
- if [[ -n "$filter_pid" ]]; then
245
- found=""
246
- for i in "${!SNAP_PID[@]}"; do
247
- [[ "${SNAP_PID[$i]}" == "$filter_pid" ]] && { roots+=("$filter_pid"); found=1; break; }
248
- done
249
- if [[ -z "$found" ]]; then
250
- echo "caproom: no such pid $filter_pid (or not owned by you)" >&2
251
- exit 1
252
- fi
253
- else
254
- for i in "${!SNAP_PID[@]}"; do
255
- [[ "${SNAP_PID[$i]}" == "$$" ]] && continue # never report ourselves
256
- p="${SNAP_PPID[$i]}"
257
- if [[ "$p" == "1" ]]; then roots+=("${SNAP_PID[$i]}"); continue; fi
258
- found=""
259
- for j in "${!SNAP_PID[@]}"; do
260
- if [[ "${SNAP_PID[$j]}" == "$p" ]]; then found=1; break; fi
261
- done
262
- [[ -z "$found" ]] && roots+=("${SNAP_PID[$i]}")
263
- done
264
- fi
265
-
266
- # Walk each root once; keep results in parallel arrays, then sort by
267
- # tree RSS descending via a sortable temp stream.
268
- local -a r_pid=() r_trss=() r_tpids=() r_st=() r_et=() r_cmd=() order=()
269
- for p in "${roots[@]}"; do
270
- walk_tree "$p"
271
- local tjoin=""
272
- [[ ${#TREE_PIDS[@]} -gt 0 ]] && tjoin="$(printf '%s,' "${TREE_PIDS[@]}")" && tjoin="${tjoin%,}"
273
- for i in "${!SNAP_PID[@]}"; do
274
- if [[ "${SNAP_PID[$i]}" == "$p" ]]; then
275
- r_pid+=("$p"); r_trss+=("$TREE_RSS_KB")
276
- r_tpids+=("$tjoin")
277
- r_st+=("${SNAP_ST[$i]}"); r_et+=("${SNAP_ET[$i]}")
278
- r_cmd+=("${SNAP_CMD[$i]}")
279
- order+=("$(printf '%010d %d\n' "$TREE_RSS_KB" $(( ${#r_pid[@]} - 1 )))")
280
- break
281
- fi
282
- done
283
- done
284
-
285
- if [[ $json -eq 1 ]]; then
286
- local ts out='[' first=1 idx state cand reason kb
287
- ts="$(date +%s)"
288
- local -a sorted
289
- sorted=($(printf '%s\n' "${order[@]:-}" | sort -rn | awk '{print $2}'))
290
- for idx in "${sorted[@]:-}"; do
291
- [[ -z "$idx" ]] && continue
292
- kb="${r_trss[$idx]}"
293
- local st0="${r_st[$idx]:0:1}"
294
- case "$st0" in
295
- T) state="parked" ;;
296
- Z) state="zombie" ;;
297
- *) state="running" ;;
298
- esac
299
- cand=false; reason=""
300
- if [[ "$state" == "running" && ( "$st0" == "S" || "$st0" == "I" ) ]] && [[ "$kb" -ge "$park_min_kb" ]]; then
301
- cand=true
302
- reason="root sleeping + tree_rss ${kb}KB >= ${park_min_kb}KB park threshold"
303
- fi
304
- [[ $first -eq 1 ]] || out+=','
305
- first=0
306
- 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")\"}"
307
- done
308
- out+=']'
309
- printf '{"schema":1,"ts":%s,"limit_mb_default":%s,"processes":%s}\n' "$ts" "${CAPROOM_LIMIT_MB:-4096}" "$out"
310
- else
311
- local idx mb
312
- printf '%-8s %10s %-8s %-9s %s\n' PID TREE_MB STATE ETIME COMMAND
313
- local -a sorted
314
- sorted=($(printf '%s\n' "${order[@]:-}" | sort -rn | awk '{print $2}'))
315
- for idx in "${sorted[@]:-}"; do
316
- [[ -z "$idx" ]] && continue
317
- mb=$(( r_trss[idx] / 1024 ))
318
- 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
319
- done
320
- fi
321
- }
322
-
323
- cmd_watch() {
324
- # Daemon: watch explicit pids, emit events when their TREE crosses a
325
- # RSS threshold. --auto-park freezes breaching trees (SIGSTOP every pid
326
- # in the snapshot) — only for pids passed explicitly, since stopping a
327
- # mid-write process risks corruption; naming the pid IS the opt-in.
328
- # --auto-wake-free-pct N undoes its own parks when free memory recovers.
329
- local threshold_kb=$(( 2048 * 1024 )) interval=5 json=0 auto=0 wake_pct=""
330
- local -a pids=()
331
- while [[ $# -gt 0 ]]; do
332
- case "$1" in
333
- --threshold-mb) threshold_kb=$(( $2 * 1024 )); shift 2 ;;
334
- --interval) interval="$2"; shift 2 ;;
335
- --auto-park) auto=1; shift ;;
336
- --auto-wake-free-pct) wake_pct="$2"; shift 2 ;;
337
- --json) json=1; shift ;;
338
- *) pids+=("$1"); shift ;;
339
- esac
340
- done
341
- [[ ${#pids[@]} -eq 0 ]] && { echo "usage: caproom watch [--threshold-mb <mb>] [--interval <sec>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>" >&2; exit 1; }
342
-
343
- local -a parked_by_us=() breaching=()
344
- local mode
345
- mode="watch"
346
- [[ $auto -eq 1 ]] && mode="auto-park"
347
- if [[ $json -eq 1 ]]; then
348
- 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/,$//')"
349
- else
350
- 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
351
- fi
352
-
353
- while :; do
354
- local -a alive=()
355
- local pid i st0
356
- for pid in "${pids[@]}"; do
357
- kill -0 "$pid" 2>/dev/null && alive+=("$pid")
358
- done
359
- if [[ ${#alive[@]} -eq 0 ]]; then
360
- [[ $json -eq 1 ]] && printf '{"schema":1,"event":"all-exited","ts":%s}\n' "$(date +%s)"
361
- echo "caproom: watch: all watched pids exited" >&2
362
- exit 0
363
- fi
364
- pids=("${alive[@]}")
365
-
366
- # Auto-wake first: restore what WE parked once pressure clears.
367
- if [[ -n "$wake_pct" && ${#parked_by_us[@]} -gt 0 ]]; then
368
- local pct
369
- pct=$(mem_free_pct)
370
- if [[ "$pct" -ge "$wake_pct" ]]; then
371
- local -a woke=()
372
- for pid in "${parked_by_us[@]}"; do
373
- if kill -0 "$pid" 2>/dev/null && kill -CONT "$pid" 2>/dev/null; then
374
- woke+=("$pid")
375
- if [[ $json -eq 1 ]]; then
376
- printf '{"schema":1,"event":"woke","ts":%s,"pid":%s,"free_pct":%s}\n' "$(date +%s)" "$pid" "$pct"
377
- else
378
- echo "caproom: watch: free mem ${pct}% >= ${wake_pct}% — waking pid $pid" >&2
379
- fi
380
- fi
381
- done
382
- parked_by_us=()
383
- fi
384
- fi
385
-
386
- read_snapshot
387
- for pid in "${pids[@]}"; do
388
- local found=""
389
- for i in "${!SNAP_PID[@]}"; do
390
- [[ "${SNAP_PID[$i]}" == "$pid" ]] && { found="$i"; break; }
391
- done
392
- [[ -z "$found" ]] && continue
393
- st0="${SNAP_ST[$found]:0:1}"
394
- [[ "$st0" == "T" || "$st0" == "Z" ]] && continue # already parked/dead
395
- walk_tree "$pid"
396
- if [[ "$TREE_RSS_KB" -ge "$threshold_kb" ]]; then
397
- local is_breaching=""
398
- local ev1
399
- for ev1 in ${breaching[@]+"${breaching[@]}"}; do [[ "$ev1" == "$pid" ]] && is_breaching=1 && break; done
400
- if [[ -n "$is_breaching" ]]; then continue; fi
401
- breaching+=("$pid")
402
- if [[ $auto -eq 1 ]]; then
403
- local tp stopped=0
404
- for tp in "${TREE_PIDS[@]}"; do
405
- kill -STOP "$tp" 2>/dev/null && { parked_by_us+=("$tp"); stopped=$(( stopped + 1 )); }
406
- done
407
- if [[ $json -eq 1 ]]; then
408
- 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"
409
- else
410
- 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
411
- fi
412
- else
413
- if [[ $json -eq 1 ]]; then
414
- printf '{"schema":1,"event":"breach","ts":%s,"pid":%s,"tree_rss_kb":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB"
415
- else
416
- echo "caproom: watch: tree of pid $pid hit $(( TREE_RSS_KB / 1024 ))MB (>= $(( threshold_kb / 1024 ))MB) — no --auto-park, reporting only" >&2
417
- fi
418
- fi
419
- else
420
- local -a keep=()
421
- local was_breaching=0 ev2
422
- for ev2 in ${breaching[@]+"${breaching[@]}"}; do
423
- if [[ "$ev2" == "$pid" ]]; then was_breaching=1; else keep+=("$ev2"); fi
424
- done
425
- if [[ $was_breaching -eq 1 ]]; then
426
- breaching=()
427
- local k2
428
- for k2 in ${keep[@]+"${keep[@]}"}; do breaching+=("$k2"); done
429
- if [[ $json -eq 1 ]]; then
430
- printf '{"schema":1,"event":"recovered","ts":%s,"pid":%s,"tree_rss_kb":%s}\n' "$(date +%s)" "$pid" "$TREE_RSS_KB"
431
- else
432
- echo "caproom: watch: pid $pid back under threshold ($(( TREE_RSS_KB / 1024 ))MB)" >&2
433
- fi
434
- fi
435
- fi
436
- done
437
- sleep "$interval"
438
- done
439
- }
440
-
441
- mem_free_pct() { if [[ "$(uname)" == "Darwin" ]]; then
442
- local page_size free inactive total_bytes avail_bytes
443
- page_size=$(vm_stat | awk '/page size of/ {print $8}')
444
- free=$(vm_stat | awk '/Pages free/ {gsub("\\.","",$3); print $3}')
445
- inactive=$(vm_stat | awk '/Pages inactive/ {gsub("\\.","",$3); print $3}')
446
- total_bytes=$(sysctl -n hw.memsize)
447
- avail_bytes=$(( (free + inactive) * page_size ))
448
- echo $(( avail_bytes * 100 / total_bytes ))
449
- else
450
- local avail_kb total_kb
451
- avail_kb=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
452
- total_kb=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
453
- echo $(( avail_kb * 100 / total_kb ))
454
- fi
455
- }
456
-
457
- cmd_guard() {
458
- local threshold=10
459
- local interval=5
460
- local pids=()
461
- while [[ $# -gt 0 ]]; do
462
- case "$1" in
463
- --threshold) threshold="$2"; shift 2 ;;
464
- --interval) interval="$2"; shift 2 ;;
465
- --) shift ;;
466
- *) pids+=("$1"); shift ;;
467
- esac
468
- done
469
- [[ ${#pids[@]} -eq 0 ]] && { echo "usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>" >&2; exit 1; }
470
- echo "caproom: guarding ${#pids[@]} pid(s), park when system free mem < ${threshold}% (poll ${interval}s)" >&2
471
- local parked=()
472
- while :; do
473
- local alive=()
474
- local pid
475
- for pid in "${pids[@]}"; do
476
- kill -0 "$pid" 2>/dev/null && alive+=("$pid")
477
- done
478
- if [[ ${#alive[@]} -eq 0 ]]; then
479
- echo "caproom: guard: all watched pids exited" >&2
480
- exit 0
481
- fi
482
- pids=("${alive[@]}")
483
- local pct
484
- pct=$(mem_free_pct)
485
- if [[ "$pct" -lt "$threshold" ]]; then
486
- for pid in "${pids[@]}"; do
487
- if [[ ! " ${parked[*]:-} " == *" $pid "* ]]; then
488
- echo "caproom: system free mem ${pct}% < ${threshold}% threshold — about to blow, parking pid $pid (SIGSTOP)" >&2
489
- kill -STOP "$pid" 2>/dev/null && parked+=("$pid")
490
- fi
491
- done
492
- fi
493
- sleep "$interval"
494
- done
495
- }
496
-
497
- # ---- terminal bind: setup / unbind ------------------------------------
498
- # Binds headroom management to every interactive shell in ANY terminal
499
- # (Terminal.app, iTerm2, Ghostty, ...) by writing ONE integration file
500
- # per shell under ~/.caproom/ and marker-patching the rc files. Idempotent,
501
- # backed up, reversible with `caproom unbind`. Never runs automatically:
502
- # npm postinstall only prints a hint.
503
-
504
- CAPROOM_DIR="${CAPROOM_DIR:-$HOME/.caproom}"
505
-
506
- setup_shell_sh() {
507
- cat > "$CAPROOM_DIR/shell.sh" << 'EOF'
508
- # caproom shell integration — regenerated by `caproom setup`; edits here
509
- # are overwritten. Source of truth: bin/caproom (setup_shell_sh).
510
-
511
- caproom_freemem_pct() { command caproom freemem 2>/dev/null; }
512
-
513
- caproom_headroom_check() {
514
- local pct last now
515
- pct=$(caproom_freemem_pct) || return 0
516
- [ -n "$pct" ] || return 0
517
- [ "$pct" -lt "${CAPROOM_HEADROOM_WARN:-20}" ] || return 0
518
- last=$(cat "${TMPDIR:-/tmp}/caproom-headroom-last" 2>/dev/null || echo 0)
519
- now=$(date +%s)
520
- [ $(( now - ${last:-0} )) -ge 60 ] || return 0
521
- echo "$now" > "${TMPDIR:-/tmp}/caproom-headroom-last" 2>/dev/null
522
- echo "caproom: headroom low (${pct}% free) — check 'caproom top' before launching heavy work"
523
- }
524
-
525
- if [ -n "$ZSH_VERSION" ]; then
526
- autoload -Uz add-zsh-hook
527
- add-zsh-hook precmd caproom_headroom_check
528
- elif [ -n "$BASH_VERSION" ]; then
529
- case ";$PROMPT_COMMAND;" in
530
- *caproom_headroom_check*) ;;
531
- *) PROMPT_COMMAND="caproom_headroom_check${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
532
- esac
533
- fi
534
-
535
- # Opt-in auto-wrap: CAPROOM_AUTO_WRAP="claude,codex,opencode" gives every
536
- # listed command a <name>_capped twin running under $CAPROOM_LIMIT_MB.
537
- # The bare name is aliased ONLY with CAPROOM_AUTO_ALIAS=1 — never hijack a
538
- # command the user did not consent to wrap.
539
- if [ -n "${CAPROOM_AUTO_WRAP:-}" ]; then
540
- for _cr_cmd in $(echo "${CAPROOM_AUTO_WRAP}" | tr ',' ' '); do
541
- _cr_fn="$(printf '%s' "$_cr_cmd" | sed 's/[-.]/_/g')_capped"
542
- eval "$(printf "%s() { command caproom --limit \"\${CAPROOM_LIMIT_MB:-4096}\" --grace \"\${CAPROOM_GRACE:-5}\" -- '%s' \"\$@\"; }" "$_cr_fn" "$(printf '%s' "$_cr_cmd" | sed "s/'/'\\\\''/g")")"
543
- if [ "${CAPROOM_AUTO_ALIAS:-0}" = "1" ]; then
544
- alias "$_cr_cmd=$_cr_fn"
545
- fi
546
- done
547
- unset _cr_cmd _cr_fn
548
- fi
549
- EOF
550
- }
551
-
552
- setup_shell_fish() {
553
- cat > "$CAPROOM_DIR/shell.fish" << 'EOF'
554
- # caproom fish integration — regenerated by `caproom setup`.
555
- function __caproom_freemem
556
- command caproom freemem 2>/dev/null
557
- end
558
-
559
- function __caproom_headroom_check --on-event fish_prompt
560
- set -l pct (__caproom_freemem)
561
- or return
562
- test -n "$pct"; or return
563
- set -l warn 20
564
- if set -q CAPROOM_HEADROOM_WARN
565
- set warn $CAPROOM_HEADROOM_WARN
566
- end
567
- if test "$pct" -lt "$warn"
568
- set -l stamp /tmp/caproom-headroom-last
569
- set -l now (date +%s)
570
- set -l last 0
571
- if test -f $stamp
572
- set last (cat $stamp)
573
- end
574
- if test (math "$now - $last") -ge 60
575
- echo $now > $stamp
576
- echo "caproom: headroom low ($pct% free) — check 'caproom top' before launching heavy work"
577
- end
578
- end
579
- end
580
- EOF
581
- }
582
-
583
- setup_shell_ps1() {
584
- cat > "$CAPROOM_DIR/shell.ps1" << 'EOF'
585
- # caproom PowerShell integration — regenerated by `caproom setup` (Windows).
586
- function global:caproom_freemem_pct {
587
- $os = Get-CimInstance Win32_OperatingSystem
588
- [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
589
- }
590
- $global:__caproomLastWarn = 0
591
- function global:prompt {
592
- $pct = caproom_freemem_pct
593
- $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
594
- if ($pct -lt (${CAPROOM_HEADROOM_WARN:-20}) -and ($now - $script:__caproomLastWarn) -ge 60) {
595
- $script:__caproomLastWarn = $now
596
- Write-Host "caproom: headroom low ($pct% free) — check 'caproom top' before launching heavy work" -ForegroundColor Yellow
597
- }
598
- "PS $($executionContext.SessionState.Path.CurrentLocation)> "
599
- }
600
- EOF
601
- }
602
-
603
- rc_targets() {
604
- # Prints "path<TAB>required" pairs for every rc we manage. Only rcs that
605
- # already exist are patched, EXCEPT the login shell's own rc which is
606
- # created if missing — never invent configs for shells you don't use.
607
- local zshrc="${ZDOTDIR:-$HOME/.zshrc}"
608
- printf '%s\t%s\n' "$zshrc" "shell"
609
- [[ -f "$HOME/.bashrc" ]] && printf '%s\t%s\n' "$HOME/.bashrc" "optional"
610
- }
611
-
612
- patch_rc_file() {
613
- local rc="$1"
614
- [[ -f "$rc" ]] || touch "$rc"
615
- grep -q "# >>> caproom >>>" "$rc" && return 0
616
- cp "$rc" "$rc.caproom.bak.$(date +%Y%m%d%H%M%S)"
617
- {
618
- echo ""
619
- echo "# >>> caproom >>>"
620
- echo '[ -f ~/.caproom/shell.sh ] && source ~/.caproom/shell.sh'
621
- echo "# <<< caproom <<<"
622
- } >> "$rc"
623
- }
624
-
625
- patch_rc_file_fish() {
626
- local rc="$HOME/.config/fish/config.fish"
627
- mkdir -p "$(dirname "$rc")" 2>/dev/null
628
- [[ -f "$rc" ]] || return 0 # don't invent fish config unless it exists
629
- grep -q "# caproom (fish)" "$rc" && return 0
630
- cp "$rc" "$rc.caproom.bak.$(date +%Y%m%d%H%M%S)"
631
- {
632
- echo ""
633
- echo "# caproom (fish)"
634
- echo '[ -f ~/.caproom/shell.fish ] && source ~/.caproom/shell.fish'
635
- } >> "$rc"
636
- }
637
-
638
- install_guard_daemon() {
639
- local threshold="$1"
640
- local bin_path
641
- bin_path=$(command -v caproom || true)
642
- [[ -n "$bin_path" ]] || { echo "caproom setup: cannot resolve caproom binary for daemon" >&2; return 1; }
643
- if [[ "$(uname)" == "Darwin" ]]; then
644
- local plist="$HOME/Library/LaunchAgents/com.caproom.guard.plist"
645
- mkdir -p "$HOME/Library/LaunchAgents"
646
- cat > "$plist" << EOF
647
- <?xml version="1.0" encoding="UTF-8"?>
648
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
649
- <plist version="1.0"><dict>
650
- <key>Label</key><string>com.caproom.guard</string>
651
- <key>ProgramArguments</key><array>
652
- <string>/usr/bin/env</string><string>bash</string><string>$bin_path</string>
653
- <string>guard</string><string>--threshold</string><string>$threshold</string><string>--interval</string><string>15</string>
654
- </array>
655
- <key>RunAtLoad</key><true/>
656
- <key>KeepAlive</key><true/>
657
- </dict></plist>
658
- EOF
659
- echo "caproom setup: guard daemon installed -> $plist"
660
- echo " load now: launchctl load $plist"
661
- echo " unload: launchctl unload $plist"
662
- else
663
- local unit="$HOME/.config/systemd/user/caproom-guard.service"
664
- mkdir -p "$HOME/.config/systemd/user"
665
- cat > "$unit" << EOF
666
- [Unit]
667
- Description=caproom memory guard
668
-
669
- [Service]
670
- ExecStart=/usr/bin/env bash $bin_path guard --threshold $threshold --interval 15
671
- Restart=on-failure
672
-
673
- [Install]
674
- WantedBy=default.target
675
- EOF
676
- echo "caproom setup: guard service installed -> $unit"
677
- echo " start now: systemctl --user enable --now caproom-guard.service"
678
- echo " stop: systemctl --user disable --now caproom-guard.service"
679
- fi
680
- }
681
-
682
- cmd_setup() {
683
- local guard="" threshold=10 do_unbind=0
684
- while [[ $# -gt 0 ]]; do
685
- case "$1" in
686
- --guard) guard="$threshold"; shift ;;
687
- --threshold) threshold="$2"; shift 2 ;;
688
- --uninstall|--unbind) do_unbind=1; shift ;;
689
- *) echo "caproom setup: unknown option $1" >&2; exit 1 ;;
690
- esac
691
- done
692
-
693
- if [[ "$do_unbind" -eq 1 ]]; then
694
- local rc removed=0
695
- while IFS=$'\t' read -r rc _req; do
696
- [[ -f "$rc" ]] || continue
697
- if grep -q "# >>> caproom >>>" "$rc"; then
698
- awk '/^# >>> caproom >>>$/{skip=1;next} /^# <<< caproom <<<$/{skip=0;next} !skip' "$rc" > "$rc.cr.tmp" && mv "$rc.cr.tmp" "$rc"
699
- removed=$(( removed + 1 ))
700
- fi
701
- done < <(rc_targets)
702
- if grep -q "# caproom (fish)" "$HOME/.config/fish/config.fish" 2>/dev/null; then
703
- awk '/^# caproom \(fish\)$/{getline; skip=1; next} !skip' "$HOME/.config/fish/config.fish" > /tmp/cr-fish.tmp 2>/dev/null \
704
- && mv /tmp/cr-fish.tmp "$HOME/.config/fish/config.fish"
705
- removed=$(( removed + 1 ))
706
- fi
707
- echo "caproom unbind: markers removed from $removed file(s); backups kept as *.caproom.bak.*"
708
- echo " integration files left in $CAPROOM_DIR (rm -rf to purge)"
709
- return 0
710
- fi
711
-
712
- mkdir -p "$CAPROOM_DIR"
713
- setup_shell_sh
714
- setup_shell_fish
715
- [[ "$(uname)" != "Darwin" ]] || setup_shell_ps1
716
-
717
- local rc req patched=0
718
- while IFS=$'\t' read -r rc req; do
719
- if patch_rc_file "$rc"; then patched=$(( patched + 1 )); fi
720
- done < <(rc_targets)
721
- patch_rc_file_fish
722
-
723
- echo "caproom setup: bound to your shells via $CAPROOM_DIR/"
724
- echo " shell.sh zsh + bash (headroom warning on every prompt, opt-in auto-wrap)"
725
- echo " shell.fish fish equivalent"
726
- echo " patched rc files: $patched (backups alongside as *.caproom.bak.*)"
727
- echo ""
728
- echo "auto-wrap usage:"
729
- echo ' export CAPROOM_AUTO_WRAP="claude,codex,opencode" # creates <cmd>_capped twins'
730
- echo ' export CAPROOM_AUTO_ALIAS=1 # ALSO shadow bare names (explicit consent)'
731
- echo " export CAPROOM_LIMIT_MB=8192 # per-shell budget"
732
- echo ""
733
- echo "new terminals pick this up immediately; current ones: source ~/.caproom/shell.sh"
734
-
735
- if [[ -n "$guard" ]]; then
736
- echo ""
737
- install_guard_daemon "$threshold"
738
- fi
739
- }
740
- case "${1:-}" in
741
- park) shift; cmd_park "$@"; exit 0 ;;
742
- wake) shift; cmd_wake "$@"; exit 0 ;;
743
- freemem) mem_free_pct; exit 0 ;;
744
- setup|bind|unbind) shift; cmd_setup "$@"; exit 0 ;;
745
- status) shift; cmd_status "$@"; exit 0 ;;
746
- top) shift; cmd_top "$@"; exit 0 ;;
747
- watch) shift; cmd_watch "$@"; exit 0 ;;
748
- guard) shift; cmd_guard "$@"; exit 0 ;;
749
- init) shift; cmd_init "$@"; exit 0 ;;
750
- help|-h|--help) usage help ;;
751
- esac
752
-
753
- LIMIT_MB="${CAPROOM_LIMIT_MB:-4096}"
754
- IMAGE="${CAPROOM_IMAGE:-node:22-slim}"
755
- INTERVAL="${CAPROOM_INTERVAL:-0.2}"
756
- GRACE="${CAPROOM_GRACE:-5}"
757
- USE_DOCKER=0
758
- BYPASS_TTY=0
759
- USE_PTY=0
760
- [[ "${CAPROOM_BYPASS_TTY:-0}" == "1" ]] && BYPASS_TTY=1
761
- [[ "${CAPROOM_PTY:-0}" == "1" ]] && USE_PTY=1
762
-
763
- while [[ $# -gt 0 ]]; do
764
- case "$1" in
765
- --limit) LIMIT_MB="$2"; shift 2 ;;
766
- --image) IMAGE="$2"; shift 2 ;;
767
- --interval) INTERVAL="$2"; shift 2 ;;
768
- --grace) GRACE="$2"; shift 2 ;;
769
- --docker) USE_DOCKER=1; shift ;;
770
- # legacy no-op: the watchdog IS the default now; accepted so old
771
- # scripts and init snippets keep working
772
- --force-watchdog) shift ;;
773
- --no-intercept-tty) BYPASS_TTY=1; shift ;;
774
- --pty) USE_PTY=1; shift ;;
775
- --no-pty) USE_PTY=0; shift ;;
776
- --) shift; break ;;
777
- -h|--help) usage help ;;
778
- *) break ;;
779
- esac
780
- done
781
-
782
- [[ $# -eq 0 ]] && usage
783
-
784
- run_docker() {
785
- echo "caproom: docker cgroup backend, limit=${LIMIT_MB}m image=${IMAGE}" >&2
786
- exec docker run --rm -i \
787
- --memory="${LIMIT_MB}m" --memory-swap="${LIMIT_MB}m" \
788
- -v "$PWD:/work" -w /work "$IMAGE" "$@"
789
- }
790
-
791
- collect_tree() {
792
- # Snapshot ps once and walk the descendant tree of $1 into TREE_PIDS /
793
- # TREE_RSS_KB (see read_snapshot / walk_tree). Plain indexed arrays only
794
- # so macOS's stock bash 3.2 works.
795
- read_snapshot
796
- walk_tree "$1"
797
- }
798
-
799
- is_known_tui() {
800
- case "${1##*/}" in
801
- opencode|claude|codex|vim|nvim|htop|less|fzf|nano|emacs) return 0 ;;
802
- *) return 1 ;;
803
- esac
804
- }
805
-
806
- SPAWN_PID=""
807
- spawn_with_pty() {
808
- # Try python3 pty_wrapper.py, then script fallback. Sets SPAWN_PID on success.
809
- local wrapper
810
- wrapper="$(dirname "${BASH_SOURCE[0]:-$0}")/../scripts/pty_wrapper.py"
811
- if [[ ! -f "$wrapper" ]]; then
812
- local cap_path
813
- cap_path="$(command -v caproom 2>/dev/null || echo "")"
814
- if [[ -n "$cap_path" ]]; then
815
- wrapper="$(dirname "$cap_path")/../scripts/pty_wrapper.py"
816
- [[ ! -f "$wrapper" ]] && wrapper="$(dirname "$(dirname "$cap_path")")/scripts/pty_wrapper.py"
817
- fi
818
- fi
819
- [[ ! -f "$wrapper" ]] && wrapper="$HOME/Developer/caproom/scripts/pty_wrapper.py"
820
- if [[ -f "$wrapper" ]] && command -v python3 >/dev/null 2>&1 && python3 -c "import pty" 2>/dev/null; then
821
- # Avoid stdout capture issue: run in a way that doesn't hold the caller's command-substitution pipe.
822
- # Use a temp file to pass pid back if needed, but we set global.
823
- python3 "$wrapper" "$@" &
824
- SPAWN_PID=$!
825
- return 0
826
- fi
827
- if command -v script >/dev/null 2>&1; then
828
- if [[ "$(uname)" == "Darwin" ]]; then
829
- script -q /dev/null "$@" &
830
- SPAWN_PID=$!
831
- return 0
832
- else
833
- local cmd
834
- cmd=$(printf '%q ' "$@")
835
- script -q -c "$cmd" /dev/null &
836
- SPAWN_PID=$!
837
- return 0
838
- fi
839
- fi
840
- return 1
841
- }
842
-
843
- run_watchdog() {
844
- # PTY mode: allocate a real pty (forkpty) and forward bytes verbatim.
845
- # Full terminal fidelity — OSC 10/11, DSR CPR, mouse DEC 1003 all work
846
- # because the TUI talks to a real pty, not a pipe. Watchdog still enforces
847
- # the cap on the pty tree (same kill logic as normal).
848
- if [[ "$USE_PTY" -eq 1 ]]; then
849
- echo "caproom: pty mode — allocating pty via forkpty for '${1##*/}' (limit ${LIMIT_MB}m, verbatim forwarding)" >&2
850
- stty -tostop 2>/dev/null || true
851
- printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
852
- local saved_stty=""
853
- if [[ -t 0 ]]; then
854
- saved_stty=$(stty -g </dev/tty 2>/dev/null || true)
855
- fi
856
- restore_pty() {
857
- [[ -n "$saved_stty" ]] && stty "$saved_stty" </dev/tty 2>/dev/null \
858
- || { [[ -t 0 ]] && stty sane </dev/tty 2>/dev/null || true; }
859
- printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
860
- }
861
- local pty_pid
862
- if spawn_with_pty "$@"; then
863
- pty_pid=$SPAWN_PID
864
- echo "caproom: pty allocated, pid $pty_pid — monitoring tree RSS" >&2
865
- local limit_kb=$(( LIMIT_MB * 1024 ))
866
- local exit_code=0
867
- while kill -0 "$pty_pid" 2>/dev/null; do
868
- collect_tree "$pty_pid"
869
- if [[ "${#TREE_PIDS[@]}" -gt 0 && "$TREE_RSS_KB" -gt "$limit_kb" ]]; then
870
- local overshoot=$(( TREE_RSS_KB * 100 / limit_kb ))
871
- echo "caproom: pid $pty_pid tree RSS ${TREE_RSS_KB}KB exceeded ${limit_kb}KB cap (+${overshoot}%) — killing pty tree (grace ${GRACE}s)" >&2
872
- kill -TERM "${TREE_PIDS[@]}" 2>/dev/null || true
873
- local -a breach_pids=("${TREE_PIDS[@]}")
874
- local waited=0
875
- while kill -0 "$pty_pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
876
- sleep 1
877
- waited=$(( waited + 1 ))
878
- done
879
- local sp sweep=0
880
- for sp in "${breach_pids[@]}"; do
881
- if kill -0 "$sp" 2>/dev/null; then
882
- kill -9 "$sp" 2>/dev/null || true
883
- sweep=$(( sweep + 1 ))
884
- fi
885
- done
886
- if [[ "$sweep" -gt 0 ]]; then
887
- echo "caproom: SIGKILLed ${sweep} survivor(s) after grace — exit 137" >&2
888
- wait "$pty_pid" 2>/dev/null || true
889
- restore_pty
890
- exit 137
891
- fi
892
- wait "$pty_pid" 2>/dev/null || exit_code=$?
893
- echo "caproom: pid $pty_pid exited cleanly (code $exit_code) during grace period" >&2
894
- restore_pty
895
- exit "$exit_code"
896
- fi
897
- sleep "$INTERVAL"
898
- done
899
- wait "$pty_pid" 2>/dev/null || exit_code=$?
900
- restore_pty 2>/dev/null || true
901
- exit "$exit_code"
902
- else
903
- echo "caproom: pty alloc failed — falling back to bypass/watchdog" >&2
904
- restore_pty 2>/dev/null || true
905
- fi
906
- fi
907
- # TUI bypass: if stdio is a tty and target is a known TUI (or bypass flag),
908
- # don't sit on stdio — exec directly and monitor via detached watch.
909
- # This preserves pty semantics (OSC 10/11, DSR CPR, DEC 1003 mouse) that
910
- # break when a wrapper backgrounds the TUI and steals foreground pgrp.
911
- if [[ "$BYPASS_TTY" -eq 1 ]] || { [[ -t 0 && -t 1 ]] && is_known_tui "${1:-}"; }; then
912
- if [[ "$BYPASS_TTY" -eq 1 ]]; then
913
- echo "caproom: bypass-tty active — exec directly, no stdio interposition (limit ${LIMIT_MB}m advisory via detached watch)" >&2
914
- else
915
- echo "caproom: tty TUI detected ('${1##*/}') — bypassing stdio interposition, monitoring via detached watch (limit ${LIMIT_MB}m)" >&2
916
- fi
917
- # Clean stale tty modes before handing off, same snapshot hygiene as
918
- # the normal watchdog path but without taking foreground away.
919
- stty -tostop 2>/dev/null || true
920
- printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
921
- # Detached watchdog monitors the TUI's pid tree without owning stdio.
922
- # Resolve our own path (works via shim and direct invocation).
923
- local _cap_bin="${BASH_SOURCE[0]:-$0}"
924
- [[ "$_cap_bin" != /* ]] && _cap_bin="$PWD/$_cap_bin"
925
- if [[ ! -f "$_cap_bin" ]]; then
926
- _cap_bin="$(command -v caproom 2>/dev/null || echo "$_cap_bin")"
927
- fi
928
- set +m 2>/dev/null || true
929
- "$@" &
930
- local _tui_pid=$!
931
- # Fire-and-forget watch; it exits when _tui_pid exits. Suppressed so it
932
- # never leaks into the TUI's stdout. Threshold is the cap itself.
933
- bash "$_cap_bin" watch --threshold-mb "$LIMIT_MB" --interval "$INTERVAL" --auto-park --json "$_tui_pid" >/dev/null 2>&1 &
934
- local _watch_pid=$!
935
- wait "$_tui_pid"
936
- set -m 2>/dev/null || true
937
- local _rc=$?
938
- # Best-effort cleanup of the detached watcher (it should already exit
939
- # via all-exited, but kill is cheap if TUI exited fast).
940
- kill "$_watch_pid" 2>/dev/null || true
941
- wait "$_watch_pid" 2>/dev/null || true
942
- exit "$_rc"
943
- fi
944
- echo "caproom: watchdog backend (host-native), limit=${LIMIT_MB}m poll=${INTERVAL}s (process-tree RSS)" >&2
945
- local limit_kb=$(( LIMIT_MB * 1024 ))
946
- # Terminal hygiene: a TUI child (opencode, claude, ...) puts the tty in
947
- # raw + mouse-tracking mode. If WE kill it, it never restores, and the
948
- # user's shell then prints mouse reports like [[<35;25;15M as garbage.
949
- # Clear stale state before snapshot — disable tostop to avoid SIGTTOU suspend
950
- # when caproom backgrounds the TUI (ghostty reports [[<... as suspended tty output).
951
- stty -tostop 2>/dev/null || true
952
- printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
953
- local saved_stty=""
954
- if [[ -t 0 ]]; then
955
- saved_stty=$(stty -g </dev/tty 2>/dev/null || true)
956
- fi
957
- restore_tty() {
958
- # Only on OUR kill — a clean exit already restored its own state, and
959
- # re-emitting resets there could clobber whatever the NEXT program drew.
960
- [[ -n "$saved_stty" ]] && stty "$saved_stty" </dev/tty 2>/dev/null \
961
- || { [[ -t 0 ]] && stty sane </dev/tty 2>/dev/null || true; }
962
- printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
963
- }
964
- set +m 2>/dev/null || true
965
- "$@" &
966
- local pid=$!
967
- local exit_code=0
968
- while kill -0 "$pid" 2>/dev/null; do
969
- collect_tree "$pid"
970
- if [[ "${#TREE_PIDS[@]}" -gt 0 && "$TREE_RSS_KB" -gt "$limit_kb" ]]; then
971
- local overshoot=$(( TREE_RSS_KB * 100 / limit_kb ))
972
- echo "caproom: pid $pid tree RSS ${TREE_RSS_KB}KB exceeded ${limit_kb}KB cap (+${overshoot}%) — killing tree (grace ${GRACE}s)" >&2
973
- # Signal EVERY pid in the tree, not just the root: children that
974
- # survive a root-only TERM get orphaned and keep allocating past
975
- # the cap after caproom exits.
976
- kill -TERM "${TREE_PIDS[@]}" 2>/dev/null || true
977
- local -a breach_pids=("${TREE_PIDS[@]}")
978
- local waited=0
979
- while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
980
- sleep 1
981
- waited=$(( waited + 1 ))
982
- done
983
- # Escalate against anything that ignored TERM — root OR child.
984
- # Scanning the breach-time snapshot (not re-walking from the root)
985
- # also catches the case where the root died but a stubborn child
986
- # survived it. Children spawned DURING the grace window are not in
987
- # the snapshot; same accepted gap as detached daemons generally.
988
- local sp sweep=0
989
- for sp in "${breach_pids[@]}"; do
990
- if kill -0 "$sp" 2>/dev/null; then
991
- kill -9 "$sp" 2>/dev/null || true
992
- sweep=$(( sweep + 1 ))
993
- fi
994
- done
995
- if [[ "$sweep" -gt 0 ]]; then
996
- echo "caproom: SIGKILLed ${sweep} survivor(s) after grace — exit 137" >&2
997
- wait "$pid" 2>/dev/null || true
998
- restore_tty
999
- exit 137
1000
- fi
1001
- wait "$pid" 2>/dev/null || exit_code=$?
1002
- echo "caproom: pid $pid exited cleanly (code $exit_code) during grace period" >&2
1003
- restore_tty
1004
- exit "$exit_code"
1005
- fi
1006
- sleep "$INTERVAL"
1007
- done
1008
- wait "$pid" || exit_code=$?
1009
- set -m 2>/dev/null || true
1010
- exit "$exit_code"
1011
- }
1012
-
1013
- if [[ "$USE_DOCKER" -eq 1 ]]; then
1014
- # Explicit opt-in must fail loudly rather than silently downgrade —
1015
- # the caller asked for a hard cap, a silent watchdog switch would
1016
- # quietly change the guarantee they asked for.
1017
- if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
1018
- run_docker "$@"
1019
- else
1020
- echo "caproom: --docker requested but the docker daemon is not reachable" >&2
1021
- exit 1
1022
- fi
1023
- else
1024
- run_watchdog "$@"
1025
- fi