caproom 0.7.2 → 0.7.4
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 +6 -1
- package/bin/caproom +201 -3
- package/package.json +3 -2
- package/scripts/pty_wrapper.py +104 -0
package/README.md
CHANGED
|
@@ -51,8 +51,11 @@ caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
|
|
|
51
51
|
| `--docker` | off | opt in to the Docker cgroup backend instead of the default host-native watchdog |
|
|
52
52
|
| `--image <name>` | `node:22-slim` | docker image used by the `--docker` backend |
|
|
53
53
|
| `--force-watchdog` | — | legacy no-op; the watchdog IS the default. Accepted so existing scripts and `init` snippets keep working |
|
|
54
|
+
| `--no-intercept-tty` | off | bypass stdio interposition for TUI/pty apps — `exec` directly and monitor via detached `watch --auto-park` (preserves `OSC 10/11`, `DSR CPR`, mouse `DEC 1003`) |
|
|
55
|
+
| `--pty` | off | allocate a real pty via `forkpty` (`scripts/pty_wrapper.py` python or `script`) and forward bytes verbatim — full terminal fidelity, watchdog enforces cap on pty tree |
|
|
56
|
+
| `--no-pty` | — | disable pty allocation (fallback to bypass/watchdog) |
|
|
54
57
|
|
|
55
|
-
Env var overrides: `CAPROOM_LIMIT_MB`, `CAPROOM_IMAGE`, `CAPROOM_INTERVAL`, `CAPROOM_GRACE
|
|
58
|
+
Env var overrides: `CAPROOM_LIMIT_MB`, `CAPROOM_IMAGE`, `CAPROOM_INTERVAL`, `CAPROOM_GRACE`, `CAPROOM_BYPASS_TTY=1` (force bypass), `CAPROOM_PTY=1` (force pty, same as `--pty`).
|
|
56
59
|
|
|
57
60
|
On cap breach, the watchdog backend sends `SIGTERM` first and waits `--grace` seconds before `SIGKILL`. If the process exits cleanly during the grace window, `caproom` propagates its real exit code; only a hard `SIGKILL` (process ignored `SIGTERM`, or grace ran out) reports `137` (same convention as Docker's own OOM-kill exit code, which the docker backend always uses on breach since Docker itself sends the kill).
|
|
58
61
|
|
|
@@ -94,6 +97,8 @@ caproom init claude --limit 6144 --grace 10 >> ~/.zshrc && source ~/.zshrc
|
|
|
94
97
|
|
|
95
98
|
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.
|
|
96
99
|
|
|
100
|
+
**TUI / pty note:** `caproom -- <TUI>` (opencode, claude, vim, htop, …) would break the pty — `OSC 10/11` (`^[]10;rgb:...`), `DSR CPR` (`^[[5;1R`), and mouse `^[[<35;` reports leak as text. Since 0.7.3, `caproom` detects `[ -t 0 ] && [ -t 1 ]` for known TUIs and **bypasses stdio interposition**: the TUI `exec`s directly and a detached `caproom watch --auto-park` monitors its pid tree (same cap, no pty corruption). For full pty fidelity, use `--pty` / `CAPROOM_PTY=1` which allocates a real pty via `forkpty` (`python3` `scripts/pty_wrapper.py` or `script`) and forwards bytes verbatim while the watchdog still enforces the cap on the pty tree. Piped/batch `caproom -- opencode run "task"` stays fully capped via the watchdog. Override: `CAPROOM_BYPASS_TTY=1` / `--no-intercept-tty` forces bypass, `CAPROOM_PTY=1` / `--pty` forces pty for any command. `caproom init <TUI>` prints a warning and emits the tty-aware wrapper.
|
|
101
|
+
|
|
97
102
|
## setup / bind — shell integration for every terminal
|
|
98
103
|
|
|
99
104
|
`caproom setup` binds headroom management to your shells at the **shell layer**, so it works in any terminal (Terminal.app, iTerm2, Ghostty, Windows Terminal) without touching terminal-specific config:
|
package/bin/caproom
CHANGED
|
@@ -34,6 +34,14 @@ usage: caproom [--limit <mb>] [--image <docker-image>] [--interval <sec>] -- <co
|
|
|
34
34
|
(default: node:22-slim)
|
|
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
|
+
--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)
|
|
37
45
|
|
|
38
46
|
park / wake — freeze an idle process so the kernel CAN reclaim/compress its
|
|
39
47
|
memory without killing it. Honest semantics: SIGSTOP only makes the pages
|
|
@@ -61,7 +69,14 @@ output to your shell rc (~/.zshrc, ~/.bashrc):
|
|
|
61
69
|
|
|
62
70
|
caproom init claude >> ~/.zshrc && source ~/.zshrc
|
|
63
71
|
|
|
64
|
-
env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_IMAGE, CAPROOM_INTERVAL, CAPROOM_GRACE
|
|
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.
|
|
65
80
|
|
|
66
81
|
examples:
|
|
67
82
|
caproom --limit 2048 -- npm run build
|
|
@@ -92,14 +107,48 @@ cmd_init() {
|
|
|
92
107
|
esac
|
|
93
108
|
done
|
|
94
109
|
local fn="${target}_capped"
|
|
95
|
-
|
|
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
|
|
96
117
|
# caproom: auto-cap '$target' — added by 'caproom init $target'
|
|
97
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.
|
|
98
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
|
|
99
134
|
command caproom --limit "\${CAPROOM_LIMIT_MB:-$limit}" --force-watchdog --grace "\${CAPROOM_GRACE:-$grace}" -- command $target "\$@"
|
|
100
135
|
}
|
|
101
136
|
alias $target=$fn
|
|
102
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
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
cmd_park() {
|
|
@@ -706,6 +755,10 @@ IMAGE="${CAPROOM_IMAGE:-node:22-slim}"
|
|
|
706
755
|
INTERVAL="${CAPROOM_INTERVAL:-0.2}"
|
|
707
756
|
GRACE="${CAPROOM_GRACE:-5}"
|
|
708
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
|
|
709
762
|
|
|
710
763
|
while [[ $# -gt 0 ]]; do
|
|
711
764
|
case "$1" in
|
|
@@ -717,6 +770,9 @@ while [[ $# -gt 0 ]]; do
|
|
|
717
770
|
# legacy no-op: the watchdog IS the default now; accepted so old
|
|
718
771
|
# scripts and init snippets keep working
|
|
719
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 ;;
|
|
720
776
|
--) shift; break ;;
|
|
721
777
|
-h|--help) usage help ;;
|
|
722
778
|
*) break ;;
|
|
@@ -740,13 +796,155 @@ collect_tree() {
|
|
|
740
796
|
walk_tree "$1"
|
|
741
797
|
}
|
|
742
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
|
+
|
|
743
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
|
+
printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
|
|
851
|
+
local saved_stty=""
|
|
852
|
+
if [[ -t 0 ]]; then
|
|
853
|
+
saved_stty=$(stty -g </dev/tty 2>/dev/null || true)
|
|
854
|
+
fi
|
|
855
|
+
restore_pty() {
|
|
856
|
+
[[ -n "$saved_stty" ]] && stty "$saved_stty" </dev/tty 2>/dev/null \
|
|
857
|
+
|| { [[ -t 0 ]] && stty sane </dev/tty 2>/dev/null || true; }
|
|
858
|
+
printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
|
|
859
|
+
}
|
|
860
|
+
local pty_pid
|
|
861
|
+
if spawn_with_pty "$@"; then
|
|
862
|
+
pty_pid=$SPAWN_PID
|
|
863
|
+
echo "caproom: pty allocated, pid $pty_pid — monitoring tree RSS" >&2
|
|
864
|
+
local limit_kb=$(( LIMIT_MB * 1024 ))
|
|
865
|
+
local exit_code=0
|
|
866
|
+
while kill -0 "$pty_pid" 2>/dev/null; do
|
|
867
|
+
collect_tree "$pty_pid"
|
|
868
|
+
if [[ "${#TREE_PIDS[@]}" -gt 0 && "$TREE_RSS_KB" -gt "$limit_kb" ]]; then
|
|
869
|
+
local overshoot=$(( TREE_RSS_KB * 100 / limit_kb ))
|
|
870
|
+
echo "caproom: pid $pty_pid tree RSS ${TREE_RSS_KB}KB exceeded ${limit_kb}KB cap (+${overshoot}%) — killing pty tree (grace ${GRACE}s)" >&2
|
|
871
|
+
kill -TERM "${TREE_PIDS[@]}" 2>/dev/null || true
|
|
872
|
+
local -a breach_pids=("${TREE_PIDS[@]}")
|
|
873
|
+
local waited=0
|
|
874
|
+
while kill -0 "$pty_pid" 2>/dev/null && [[ "$waited" -lt "$GRACE" ]]; do
|
|
875
|
+
sleep 1
|
|
876
|
+
waited=$(( waited + 1 ))
|
|
877
|
+
done
|
|
878
|
+
local sp sweep=0
|
|
879
|
+
for sp in "${breach_pids[@]}"; do
|
|
880
|
+
if kill -0 "$sp" 2>/dev/null; then
|
|
881
|
+
kill -9 "$sp" 2>/dev/null || true
|
|
882
|
+
sweep=$(( sweep + 1 ))
|
|
883
|
+
fi
|
|
884
|
+
done
|
|
885
|
+
if [[ "$sweep" -gt 0 ]]; then
|
|
886
|
+
echo "caproom: SIGKILLed ${sweep} survivor(s) after grace — exit 137" >&2
|
|
887
|
+
wait "$pty_pid" 2>/dev/null || true
|
|
888
|
+
restore_pty
|
|
889
|
+
exit 137
|
|
890
|
+
fi
|
|
891
|
+
wait "$pty_pid" 2>/dev/null || exit_code=$?
|
|
892
|
+
echo "caproom: pid $pty_pid exited cleanly (code $exit_code) during grace period" >&2
|
|
893
|
+
restore_pty
|
|
894
|
+
exit "$exit_code"
|
|
895
|
+
fi
|
|
896
|
+
sleep "$INTERVAL"
|
|
897
|
+
done
|
|
898
|
+
wait "$pty_pid" 2>/dev/null || exit_code=$?
|
|
899
|
+
restore_pty 2>/dev/null || true
|
|
900
|
+
exit "$exit_code"
|
|
901
|
+
else
|
|
902
|
+
echo "caproom: pty alloc failed — falling back to bypass/watchdog" >&2
|
|
903
|
+
restore_pty 2>/dev/null || true
|
|
904
|
+
fi
|
|
905
|
+
fi
|
|
906
|
+
# TUI bypass: if stdio is a tty and target is a known TUI (or bypass flag),
|
|
907
|
+
# don't sit on stdio — exec directly and monitor via detached watch.
|
|
908
|
+
# This preserves pty semantics (OSC 10/11, DSR CPR, DEC 1003 mouse) that
|
|
909
|
+
# break when a wrapper backgrounds the TUI and steals foreground pgrp.
|
|
910
|
+
if [[ "$BYPASS_TTY" -eq 1 ]] || { [[ -t 0 && -t 1 ]] && is_known_tui "${1:-}"; }; then
|
|
911
|
+
if [[ "$BYPASS_TTY" -eq 1 ]]; then
|
|
912
|
+
echo "caproom: bypass-tty active — exec directly, no stdio interposition (limit ${LIMIT_MB}m advisory via detached watch)" >&2
|
|
913
|
+
else
|
|
914
|
+
echo "caproom: tty TUI detected ('${1##*/}') — bypassing stdio interposition, monitoring via detached watch (limit ${LIMIT_MB}m)" >&2
|
|
915
|
+
fi
|
|
916
|
+
# Clean stale tty modes before handing off, same snapshot hygiene as
|
|
917
|
+
# the normal watchdog path but without taking foreground away.
|
|
918
|
+
printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
|
|
919
|
+
# Detached watchdog monitors the TUI's pid tree without owning stdio.
|
|
920
|
+
# Resolve our own path (works via shim and direct invocation).
|
|
921
|
+
local _cap_bin="${BASH_SOURCE[0]:-$0}"
|
|
922
|
+
[[ "$_cap_bin" != /* ]] && _cap_bin="$PWD/$_cap_bin"
|
|
923
|
+
if [[ ! -f "$_cap_bin" ]]; then
|
|
924
|
+
_cap_bin="$(command -v caproom 2>/dev/null || echo "$_cap_bin")"
|
|
925
|
+
fi
|
|
926
|
+
"$@" &
|
|
927
|
+
local _tui_pid=$!
|
|
928
|
+
# Fire-and-forget watch; it exits when _tui_pid exits. Suppressed so it
|
|
929
|
+
# never leaks into the TUI's stdout. Threshold is the cap itself.
|
|
930
|
+
bash "$_cap_bin" watch --threshold-mb "$LIMIT_MB" --interval "$INTERVAL" --auto-park --json "$_tui_pid" >/dev/null 2>&1 &
|
|
931
|
+
local _watch_pid=$!
|
|
932
|
+
wait "$_tui_pid"
|
|
933
|
+
local _rc=$?
|
|
934
|
+
# Best-effort cleanup of the detached watcher (it should already exit
|
|
935
|
+
# via all-exited, but kill is cheap if TUI exited fast).
|
|
936
|
+
kill "$_watch_pid" 2>/dev/null || true
|
|
937
|
+
wait "$_watch_pid" 2>/dev/null || true
|
|
938
|
+
exit "$_rc"
|
|
939
|
+
fi
|
|
744
940
|
echo "caproom: watchdog backend (host-native), limit=${LIMIT_MB}m poll=${INTERVAL}s (process-tree RSS)" >&2
|
|
745
941
|
local limit_kb=$(( LIMIT_MB * 1024 ))
|
|
746
942
|
# Terminal hygiene: a TUI child (opencode, claude, ...) puts the tty in
|
|
747
943
|
# raw + mouse-tracking mode. If WE kill it, it never restores, and the
|
|
748
944
|
# user's shell then prints mouse reports like [[<35;25;15M as garbage.
|
|
749
|
-
#
|
|
945
|
+
# Clear any stale mode from a previous kill BEFORE snapshot, then snapshot
|
|
946
|
+
# the clean state so restore goes to sane, not back to garbage.
|
|
947
|
+
printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
|
|
750
948
|
local saved_stty=""
|
|
751
949
|
if [[ -t 0 ]]; then
|
|
752
950
|
saved_stty=$(stty -g </dev/tty 2>/dev/null || true)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "caproom",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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
6
|
"caproom": "bin/caproom.js",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"bin/caproom.js",
|
|
12
12
|
"bin/caproom.ps1",
|
|
13
13
|
"bin/caproom-mcp.js",
|
|
14
|
-
"scripts/postinstall.js"
|
|
14
|
+
"scripts/postinstall.js",
|
|
15
|
+
"scripts/pty_wrapper.py"
|
|
15
16
|
],
|
|
16
17
|
"keywords": [
|
|
17
18
|
"memory",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pty_wrapper — allocate a pty and run command with verbatim forwarding, propagating exit code.
|
|
3
|
+
|
|
4
|
+
Usage: pty_wrapper.py <command> [args...]
|
|
5
|
+
"""
|
|
6
|
+
import os, sys, pty, select, signal, struct, fcntl, termios
|
|
7
|
+
|
|
8
|
+
def copy_winsize():
|
|
9
|
+
try:
|
|
10
|
+
s = struct.pack("HHHH", 0, 0, 0, 0)
|
|
11
|
+
a = struct.unpack('hhhh', fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, s))
|
|
12
|
+
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", a[0], a[1], a[2], a[3]))
|
|
13
|
+
except Exception:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
if len(sys.argv) < 2:
|
|
17
|
+
print("usage: pty_wrapper.py <command> [args...]", file=sys.stderr)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
pid, master_fd = pty.fork()
|
|
21
|
+
if pid == 0:
|
|
22
|
+
# child
|
|
23
|
+
try:
|
|
24
|
+
os.execvp(sys.argv[1], sys.argv[1:])
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f"pty_wrapper: exec failed: {e}", file=sys.stderr)
|
|
27
|
+
os._exit(127)
|
|
28
|
+
|
|
29
|
+
# parent: forward
|
|
30
|
+
# handle window resize
|
|
31
|
+
def handle_winch(sig, frame):
|
|
32
|
+
copy_winsize()
|
|
33
|
+
try:
|
|
34
|
+
signal.signal(signal.SIGWINCH, handle_winch)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
copy_winsize()
|
|
38
|
+
|
|
39
|
+
# make stdin non-blocking if it's a tty
|
|
40
|
+
stdin_fd = sys.stdin.fileno()
|
|
41
|
+
stdout_fd = sys.stdout.fileno()
|
|
42
|
+
is_tty = os.isatty(stdin_fd)
|
|
43
|
+
|
|
44
|
+
# set master to non-blocking? select handles
|
|
45
|
+
import errno
|
|
46
|
+
|
|
47
|
+
exit_code = 127
|
|
48
|
+
try:
|
|
49
|
+
while True:
|
|
50
|
+
r, _, _ = select.select([master_fd] + ([stdin_fd] if is_tty else []), [], [], 0.1)
|
|
51
|
+
if master_fd in r:
|
|
52
|
+
try:
|
|
53
|
+
data = os.read(master_fd, 1024)
|
|
54
|
+
except OSError as e:
|
|
55
|
+
if e.errno == errno.EIO:
|
|
56
|
+
data = b''
|
|
57
|
+
else:
|
|
58
|
+
raise
|
|
59
|
+
if not data:
|
|
60
|
+
# EOF — child may have exited
|
|
61
|
+
pass
|
|
62
|
+
else:
|
|
63
|
+
os.write(stdout_fd, data)
|
|
64
|
+
if is_tty and stdin_fd in r:
|
|
65
|
+
try:
|
|
66
|
+
data = os.read(stdin_fd, 1024)
|
|
67
|
+
except OSError:
|
|
68
|
+
data = b''
|
|
69
|
+
if data:
|
|
70
|
+
os.write(master_fd, data)
|
|
71
|
+
else:
|
|
72
|
+
# stdin EOF
|
|
73
|
+
pass
|
|
74
|
+
# check if child exited
|
|
75
|
+
try:
|
|
76
|
+
pid2, status = os.waitpid(pid, os.WNOHANG)
|
|
77
|
+
if pid2 != 0:
|
|
78
|
+
if os.WIFEXITED(status):
|
|
79
|
+
exit_code = os.WEXITSTATUS(status)
|
|
80
|
+
elif os.WIFSIGNALED(status):
|
|
81
|
+
exit_code = 128 + os.WTERMSIG(status)
|
|
82
|
+
else:
|
|
83
|
+
exit_code = 1
|
|
84
|
+
# drain remaining master data
|
|
85
|
+
try:
|
|
86
|
+
while True:
|
|
87
|
+
r2, _, _ = select.select([master_fd], [], [], 0.1)
|
|
88
|
+
if not r2:
|
|
89
|
+
break
|
|
90
|
+
d = os.read(master_fd, 1024)
|
|
91
|
+
if not d:
|
|
92
|
+
break
|
|
93
|
+
os.write(stdout_fd, d)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
break
|
|
97
|
+
except ChildProcessError:
|
|
98
|
+
break
|
|
99
|
+
finally:
|
|
100
|
+
try:
|
|
101
|
+
os.close(master_fd)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
sys.exit(exit_code)
|