caproom 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,8 +52,10 @@ caproom --limit 4096 --docker --image python:3.12-slim -- python train.py
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
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) |
55
57
 
56
- Env var overrides: `CAPROOM_LIMIT_MB`, `CAPROOM_IMAGE`, `CAPROOM_INTERVAL`, `CAPROOM_GRACE`, `CAPROOM_BYPASS_TTY=1` (force bypass, same as `--no-intercept-tty`).
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`).
57
59
 
58
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).
59
61
 
@@ -95,7 +97,7 @@ caproom init claude --limit 6144 --grace 10 >> ~/.zshrc && source ~/.zshrc
95
97
 
96
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.
97
99
 
98
- **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). Piped/batch `caproom -- opencode run "task"` stays fully capped via the watchdog. Override: `CAPROOM_BYPASS_TTY=1` / `--no-intercept-tty` forces bypass for any command; `CAPROOM_BYPASS_TTY=0` forces legacy pipe mode. `caproom init <TUI>` prints a warning and emits the tty-aware wrapper.
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.
99
101
 
100
102
  ## setup / bind — shell integration for every terminal
101
103
 
package/bin/caproom CHANGED
@@ -37,6 +37,11 @@ usage: caproom [--limit <mb>] [--image <docker-image>] [--interval <sec>] -- <co
37
37
  --no-intercept-tty bypass stdio interposition for TUI/pty apps (OSC 10/11,
38
38
  DSR CPR, mouse DEC 1003) — exec directly and monitor via
39
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)
40
45
 
41
46
  park / wake — freeze an idle process so the kernel CAN reclaim/compress its
42
47
  memory without killing it. Honest semantics: SIGSTOP only makes the pages
@@ -64,12 +69,14 @@ output to your shell rc (~/.zshrc, ~/.bashrc):
64
69
 
65
70
  caproom init claude >> ~/.zshrc && source ~/.zshrc
66
71
 
67
- env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_IMAGE, CAPROOM_INTERVAL, CAPROOM_GRACE, CAPROOM_BYPASS_TTY=1
72
+ env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_IMAGE, CAPROOM_INTERVAL, CAPROOM_GRACE, CAPROOM_BYPASS_TTY=1, CAPROOM_PTY=1
68
73
 
69
74
  TUI note: 'caproom -- <TUI>' (opencode, vim, htop, ...) bypasses stdio when [ -t 0 ] && [ -t 1 ]
70
75
  so terminal queries (OSC, mouse) stay on the pty. The cap is then advisory via a detached
71
- 'caproom watch --auto-park' monitor. Piped/batch 'caproom -- opencode run "task"' stays fully capped.
72
- Use --no-intercept-tty / CAPROOM_BYPASS_TTY=1 to force bypass for any command.
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.
73
80
 
74
81
  examples:
75
82
  caproom --limit 2048 -- npm run build
@@ -749,7 +756,9 @@ INTERVAL="${CAPROOM_INTERVAL:-0.2}"
749
756
  GRACE="${CAPROOM_GRACE:-5}"
750
757
  USE_DOCKER=0
751
758
  BYPASS_TTY=0
759
+ USE_PTY=0
752
760
  [[ "${CAPROOM_BYPASS_TTY:-0}" == "1" ]] && BYPASS_TTY=1
761
+ [[ "${CAPROOM_PTY:-0}" == "1" ]] && USE_PTY=1
753
762
 
754
763
  while [[ $# -gt 0 ]]; do
755
764
  case "$1" in
@@ -762,6 +771,8 @@ while [[ $# -gt 0 ]]; do
762
771
  # scripts and init snippets keep working
763
772
  --force-watchdog) shift ;;
764
773
  --no-intercept-tty) BYPASS_TTY=1; shift ;;
774
+ --pty) USE_PTY=1; shift ;;
775
+ --no-pty) USE_PTY=0; shift ;;
765
776
  --) shift; break ;;
766
777
  -h|--help) usage help ;;
767
778
  *) break ;;
@@ -792,7 +803,107 @@ is_known_tui() {
792
803
  esac
793
804
  }
794
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
+
795
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
796
907
  # TUI bypass: if stdio is a tty and target is a known TUI (or bypass flag),
797
908
  # don't sit on stdio — exec directly and monitor via detached watch.
798
909
  # This preserves pty semantics (OSC 10/11, DSR CPR, DEC 1003 mouse) that
@@ -805,6 +916,7 @@ run_watchdog() {
805
916
  fi
806
917
  # Clean stale tty modes before handing off, same snapshot hygiene as
807
918
  # the normal watchdog path but without taking foreground away.
919
+ stty -tostop 2>/dev/null || true
808
920
  printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
809
921
  # Detached watchdog monitors the TUI's pid tree without owning stdio.
810
922
  # Resolve our own path (works via shim and direct invocation).
@@ -832,8 +944,9 @@ run_watchdog() {
832
944
  # Terminal hygiene: a TUI child (opencode, claude, ...) puts the tty in
833
945
  # raw + mouse-tracking mode. If WE kill it, it never restores, and the
834
946
  # user's shell then prints mouse reports like [[<35;25;15M as garbage.
835
- # Clear any stale mode from a previous kill BEFORE snapshot, then snapshot
836
- # the clean state so restore goes to sane, not back to garbage.
947
+ # Clear stale state before snapshot disable tostop to avoid SIGTTOU suspend
948
+ # when caproom backgrounds the TUI (ghostty reports [[<... as suspended tty output).
949
+ stty -tostop 2>/dev/null || true
837
950
  printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[?25h' >/dev/tty 2>/dev/null || true
838
951
  local saved_stty=""
839
952
  if [[ -t 0 ]]; then
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "caproom",
3
- "version": "0.7.3",
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.",
3
+ "version": "0.7.5",
4
+ "description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS, Linux, and Windows \u2014 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",
7
7
  "caproom-mcp": "bin/caproom-mcp.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)