caproom 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/caproom CHANGED
@@ -71,6 +71,8 @@ examples:
71
71
  caproom wake 12345
72
72
  caproom top --json [--pid <pid>] [--park-min-mb <mb>]
73
73
  caproom watch [--threshold-mb <mb>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>
74
+ caproom setup [--guard] [--threshold <pct>] [--uninstall]
75
+ caproom freemem
74
76
  caproom init claude --limit 6144 --grace 10
75
77
  EOF
76
78
  exit "$code"
@@ -443,9 +445,254 @@ cmd_guard() {
443
445
  done
444
446
  }
445
447
 
448
+ # ---- terminal bind: setup / unbind ------------------------------------
449
+ # Binds headroom management to every interactive shell in ANY terminal
450
+ # (Terminal.app, iTerm2, Ghostty, ...) by writing ONE integration file
451
+ # per shell under ~/.caproom/ and marker-patching the rc files. Idempotent,
452
+ # backed up, reversible with `caproom unbind`. Never runs automatically:
453
+ # npm postinstall only prints a hint.
454
+
455
+ CAPROOM_DIR="${CAPROOM_DIR:-$HOME/.caproom}"
456
+
457
+ setup_shell_sh() {
458
+ cat > "$CAPROOM_DIR/shell.sh" << 'EOF'
459
+ # caproom shell integration — regenerated by `caproom setup`; edits here
460
+ # are overwritten. Source of truth: bin/caproom (setup_shell_sh).
461
+
462
+ caproom_freemem_pct() { command caproom freemem 2>/dev/null; }
463
+
464
+ caproom_headroom_check() {
465
+ local pct last now
466
+ pct=$(caproom_freemem_pct) || return 0
467
+ [ -n "$pct" ] || return 0
468
+ [ "$pct" -lt "${CAPROOM_HEADROOM_WARN:-20}" ] || return 0
469
+ last=$(cat "${TMPDIR:-/tmp}/caproom-headroom-last" 2>/dev/null || echo 0)
470
+ now=$(date +%s)
471
+ [ $(( now - ${last:-0} )) -ge 60 ] || return 0
472
+ echo "$now" > "${TMPDIR:-/tmp}/caproom-headroom-last" 2>/dev/null
473
+ echo "caproom: headroom low (${pct}% free) — check 'caproom top' before launching heavy work"
474
+ }
475
+
476
+ if [ -n "$ZSH_VERSION" ]; then
477
+ autoload -Uz add-zsh-hook
478
+ add-zsh-hook precmd caproom_headroom_check
479
+ elif [ -n "$BASH_VERSION" ]; then
480
+ case ";$PROMPT_COMMAND;" in
481
+ *caproom_headroom_check*) ;;
482
+ *) PROMPT_COMMAND="caproom_headroom_check${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
483
+ esac
484
+ fi
485
+
486
+ # Opt-in auto-wrap: CAPROOM_AUTO_WRAP="claude,codex,opencode" gives every
487
+ # listed command a <name>_capped twin running under $CAPROOM_LIMIT_MB.
488
+ # The bare name is aliased ONLY with CAPROOM_AUTO_ALIAS=1 — never hijack a
489
+ # command the user did not consent to wrap.
490
+ if [ -n "${CAPROOM_AUTO_WRAP:-}" ]; then
491
+ for _cr_cmd in $(echo "${CAPROOM_AUTO_WRAP}" | tr ',' ' '); do
492
+ _cr_fn="$(printf '%s' "$_cr_cmd" | sed 's/[-.]/_/g')_capped"
493
+ eval "$(printf "%s() { command caproom --limit \"\${CAPROOM_LIMIT_MB:-4096}\" --grace \"\${CAPROOM_GRACE:-5}\" -- '%s' \"\$@\"; }" "$_cr_fn" "$(printf '%s' "$_cr_cmd" | sed "s/'/'\\\\''/g")")"
494
+ if [ "${CAPROOM_AUTO_ALIAS:-0}" = "1" ]; then
495
+ alias "$_cr_cmd=$_cr_fn"
496
+ fi
497
+ done
498
+ unset _cr_cmd _cr_fn
499
+ fi
500
+ EOF
501
+ }
502
+
503
+ setup_shell_fish() {
504
+ cat > "$CAPROOM_DIR/shell.fish" << 'EOF'
505
+ # caproom fish integration — regenerated by `caproom setup`.
506
+ function __caproom_freemem
507
+ command caproom freemem 2>/dev/null
508
+ end
509
+
510
+ function __caproom_headroom_check --on-event fish_prompt
511
+ set -l pct (__caproom_freemem)
512
+ or return
513
+ test -n "$pct"; or return
514
+ set -l warn 20
515
+ if set -q CAPROOM_HEADROOM_WARN
516
+ set warn $CAPROOM_HEADROOM_WARN
517
+ end
518
+ if test "$pct" -lt "$warn"
519
+ set -l stamp /tmp/caproom-headroom-last
520
+ set -l now (date +%s)
521
+ set -l last 0
522
+ if test -f $stamp
523
+ set last (cat $stamp)
524
+ end
525
+ if test (math "$now - $last") -ge 60
526
+ echo $now > $stamp
527
+ echo "caproom: headroom low ($pct% free) — check 'caproom top' before launching heavy work"
528
+ end
529
+ end
530
+ end
531
+ EOF
532
+ }
533
+
534
+ setup_shell_ps1() {
535
+ cat > "$CAPROOM_DIR/shell.ps1" << 'EOF'
536
+ # caproom PowerShell integration — regenerated by `caproom setup` (Windows).
537
+ function global:caproom_freemem_pct {
538
+ $os = Get-CimInstance Win32_OperatingSystem
539
+ [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
540
+ }
541
+ $global:__caproomLastWarn = 0
542
+ function global:prompt {
543
+ $pct = caproom_freemem_pct
544
+ $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
545
+ if ($pct -lt (${CAPROOM_HEADROOM_WARN:-20}) -and ($now - $script:__caproomLastWarn) -ge 60) {
546
+ $script:__caproomLastWarn = $now
547
+ Write-Host "caproom: headroom low ($pct% free) — check 'caproom top' before launching heavy work" -ForegroundColor Yellow
548
+ }
549
+ "PS $($executionContext.SessionState.Path.CurrentLocation)> "
550
+ }
551
+ EOF
552
+ }
553
+
554
+ rc_targets() {
555
+ # Prints "path<TAB>required" pairs for every rc we manage. Only rcs that
556
+ # already exist are patched, EXCEPT the login shell's own rc which is
557
+ # created if missing — never invent configs for shells you don't use.
558
+ local zshrc="${ZDOTDIR:-$HOME/.zshrc}"
559
+ printf '%s\t%s\n' "$zshrc" "shell"
560
+ [[ -f "$HOME/.bashrc" ]] && printf '%s\t%s\n' "$HOME/.bashrc" "optional"
561
+ }
562
+
563
+ patch_rc_file() {
564
+ local rc="$1"
565
+ [[ -f "$rc" ]] || touch "$rc"
566
+ grep -q "# >>> caproom >>>" "$rc" && return 0
567
+ cp "$rc" "$rc.caproom.bak.$(date +%Y%m%d%H%M%S)"
568
+ {
569
+ echo ""
570
+ echo "# >>> caproom >>>"
571
+ echo '[ -f ~/.caproom/shell.sh ] && source ~/.caproom/shell.sh'
572
+ echo "# <<< caproom <<<"
573
+ } >> "$rc"
574
+ }
575
+
576
+ patch_rc_file_fish() {
577
+ local rc="$HOME/.config/fish/config.fish"
578
+ mkdir -p "$(dirname "$rc")" 2>/dev/null
579
+ [[ -f "$rc" ]] || return 0 # don't invent fish config unless it exists
580
+ grep -q "# caproom (fish)" "$rc" && return 0
581
+ cp "$rc" "$rc.caproom.bak.$(date +%Y%m%d%H%M%S)"
582
+ {
583
+ echo ""
584
+ echo "# caproom (fish)"
585
+ echo '[ -f ~/.caproom/shell.fish ] && source ~/.caproom/shell.fish'
586
+ } >> "$rc"
587
+ }
588
+
589
+ install_guard_daemon() {
590
+ local threshold="$1"
591
+ local bin_path
592
+ bin_path=$(command -v caproom || true)
593
+ [[ -n "$bin_path" ]] || { echo "caproom setup: cannot resolve caproom binary for daemon" >&2; return 1; }
594
+ if [[ "$(uname)" == "Darwin" ]]; then
595
+ local plist="$HOME/Library/LaunchAgents/com.caproom.guard.plist"
596
+ mkdir -p "$HOME/Library/LaunchAgents"
597
+ cat > "$plist" << EOF
598
+ <?xml version="1.0" encoding="UTF-8"?>
599
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
600
+ <plist version="1.0"><dict>
601
+ <key>Label</key><string>com.caproom.guard</string>
602
+ <key>ProgramArguments</key><array>
603
+ <string>/usr/bin/env</string><string>bash</string><string>$bin_path</string>
604
+ <string>guard</string><string>--threshold</string><string>$threshold</string><string>--interval</string><string>15</string>
605
+ </array>
606
+ <key>RunAtLoad</key><true/>
607
+ <key>KeepAlive</key><true/>
608
+ </dict></plist>
609
+ EOF
610
+ echo "caproom setup: guard daemon installed -> $plist"
611
+ echo " load now: launchctl load $plist"
612
+ echo " unload: launchctl unload $plist"
613
+ else
614
+ local unit="$HOME/.config/systemd/user/caproom-guard.service"
615
+ mkdir -p "$HOME/.config/systemd/user"
616
+ cat > "$unit" << EOF
617
+ [Unit]
618
+ Description=caproom memory guard
619
+
620
+ [Service]
621
+ ExecStart=/usr/bin/env bash $bin_path guard --threshold $threshold --interval 15
622
+ Restart=on-failure
623
+
624
+ [Install]
625
+ WantedBy=default.target
626
+ EOF
627
+ echo "caproom setup: guard service installed -> $unit"
628
+ echo " start now: systemctl --user enable --now caproom-guard.service"
629
+ echo " stop: systemctl --user disable --now caproom-guard.service"
630
+ fi
631
+ }
632
+
633
+ cmd_setup() {
634
+ local guard="" threshold=10 do_unbind=0
635
+ while [[ $# -gt 0 ]]; do
636
+ case "$1" in
637
+ --guard) guard="$threshold"; shift ;;
638
+ --threshold) threshold="$2"; shift 2 ;;
639
+ --uninstall|--unbind) do_unbind=1; shift ;;
640
+ *) echo "caproom setup: unknown option $1" >&2; exit 1 ;;
641
+ esac
642
+ done
643
+
644
+ if [[ "$do_unbind" -eq 1 ]]; then
645
+ local rc removed=0
646
+ while IFS=$'\t' read -r rc _req; do
647
+ [[ -f "$rc" ]] || continue
648
+ if grep -q "# >>> caproom >>>" "$rc"; then
649
+ awk '/^# >>> caproom >>>$/{skip=1;next} /^# <<< caproom <<<$/{skip=0;next} !skip' "$rc" > "$rc.cr.tmp" && mv "$rc.cr.tmp" "$rc"
650
+ removed=$(( removed + 1 ))
651
+ fi
652
+ done < <(rc_targets)
653
+ if grep -q "# caproom (fish)" "$HOME/.config/fish/config.fish" 2>/dev/null; then
654
+ awk '/^# caproom \(fish\)$/{getline; skip=1; next} !skip' "$HOME/.config/fish/config.fish" > /tmp/cr-fish.tmp 2>/dev/null \
655
+ && mv /tmp/cr-fish.tmp "$HOME/.config/fish/config.fish"
656
+ removed=$(( removed + 1 ))
657
+ fi
658
+ echo "caproom unbind: markers removed from $removed file(s); backups kept as *.caproom.bak.*"
659
+ echo " integration files left in $CAPROOM_DIR (rm -rf to purge)"
660
+ return 0
661
+ fi
662
+
663
+ mkdir -p "$CAPROOM_DIR"
664
+ setup_shell_sh
665
+ setup_shell_fish
666
+ [[ "$(uname)" != "Darwin" ]] || setup_shell_ps1
667
+
668
+ local rc req patched=0
669
+ while IFS=$'\t' read -r rc req; do
670
+ if patch_rc_file "$rc"; then patched=$(( patched + 1 )); fi
671
+ done < <(rc_targets)
672
+ patch_rc_file_fish
673
+
674
+ echo "caproom setup: bound to your shells via $CAPROOM_DIR/"
675
+ echo " shell.sh zsh + bash (headroom warning on every prompt, opt-in auto-wrap)"
676
+ echo " shell.fish fish equivalent"
677
+ echo " patched rc files: $patched (backups alongside as *.caproom.bak.*)"
678
+ echo ""
679
+ echo "auto-wrap usage:"
680
+ echo ' export CAPROOM_AUTO_WRAP="claude,codex,opencode" # creates <cmd>_capped twins'
681
+ echo ' export CAPROOM_AUTO_ALIAS=1 # ALSO shadow bare names (explicit consent)'
682
+ echo " export CAPROOM_LIMIT_MB=8192 # per-shell budget"
683
+ echo ""
684
+ echo "new terminals pick this up immediately; current ones: source ~/.caproom/shell.sh"
685
+
686
+ if [[ -n "$guard" ]]; then
687
+ echo ""
688
+ install_guard_daemon "$threshold"
689
+ fi
690
+ }
446
691
  case "${1:-}" in
447
692
  park) shift; cmd_park "$@"; exit 0 ;;
448
693
  wake) shift; cmd_wake "$@"; exit 0 ;;
694
+ freemem) mem_free_pct; exit 0 ;;
695
+ setup|bind|unbind) shift; cmd_setup "$@"; exit 0 ;;
449
696
  status) shift; cmd_status "$@"; exit 0 ;;
450
697
  top) shift; cmd_top "$@"; exit 0 ;;
451
698
  watch) shift; cmd_watch "$@"; exit 0 ;;
@@ -131,11 +131,14 @@ function callTool(name, args) {
131
131
  if (args.docker) { a.push('--docker'); if (args.image) a.push('--image', String(args.image)); }
132
132
  a.push('--');
133
133
  const r = caproom(a.concat(cmd), { timeout: Math.max(60000, (args.timeout_ms || 300000)) });
134
- let verdict = '';
134
+ const errText = (r.stderr || '');
135
+ const capped = /exceeded .* cap/.test(errText);
136
+ let verdict;
135
137
  if (r.status === 137 || r.signal === 'SIGKILL') verdict = 'RESULT: KILLED BY CAP (exit 137)';
138
+ else if (capped && (r.status === 143 || r.signal === 'SIGTERM')) verdict = 'RESULT: CAPPED — tree killed during grace (SIGTERM honored, exit 143)';
136
139
  else if (r.status === 143 || r.signal === 'SIGTERM') verdict = 'RESULT: terminated during grace (SIGTERM honored)';
137
140
  else verdict = 'RESULT: exit=' + r.status;
138
- return text(verdict + '\n--- stderr ---\n' + ((r.stderr || '').slice(-4000) || '(empty)'));
141
+ return text(verdict + '\n--- stderr ---\n' + (errText.slice(-4000) || '(empty)'));
139
142
  }
140
143
  default:
141
144
  throw new Error('unknown tool: ' + name);
package/bin/caproom.ps1 CHANGED
@@ -406,10 +406,68 @@ function Invoke-Capped {
406
406
 
407
407
  if ($args.Count -eq 0) { Show-Usage }
408
408
 
409
+ function Invoke-Setup {
410
+ # Bind headroom management to PowerShell sessions in ANY terminal:
411
+ # writes ~/.caproom/shell.ps1 (single source) and marker-patches
412
+ # $PROFILE. Idempotent, backs up the profile, reversible via
413
+ # `caproom setup --uninstall`. Never runs on npm install.
414
+ $dir = Join-Path $HOME '.caproom'
415
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
416
+
417
+ @'
418
+ # caproom PowerShell integration — regenerated by `caproom setup`.
419
+ function global:caproom_freemem_pct {
420
+ $os = Get-CimInstance Win32_OperatingSystem
421
+ [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
422
+ }
423
+ $global:__caproomLastWarn = 0
424
+ function global:prompt {
425
+ try {
426
+ $pct = caproom_freemem_pct
427
+ $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
428
+ $warn = if ($env:CAPROOM_HEADROOM_WARN) { [int]$env:CAPROOM_HEADROOM_WARN } else { 20 }
429
+ if ($pct -lt $warn -and ($now - $script:__caproomLastWarn) -ge 60) {
430
+ $script:__caproomLastWarn = $now
431
+ Write-Host "caproom: headroom low ($pct% free) - check 'caproom top' before launching heavy work" -ForegroundColor Yellow
432
+ }
433
+ } catch {}
434
+ "PS $($executionContext.SessionState.Path.CurrentLocation)> "
435
+ }
436
+ '@ | Set-Content -Encoding UTF8 (Join-Path $dir 'shell.ps1')
437
+
438
+ $profilePath = $PROFILE.CurrentUserAllHosts
439
+ if (-not (Test-Path $profilePath)) { New-Item -ItemType File -Force -Path $profilePath | Out-Null }
440
+ $content = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
441
+ if ($content -notmatch '# >>> caproom >>>') {
442
+ Copy-Item $profilePath "$profilePath.caproom.bak.$(Get-Date -Format yyyyMMddHHmmss)"
443
+ Add-Content $profilePath @'
444
+
445
+ # >>> caproom >>>
446
+ . "$HOME\.caproom\shell.ps1"
447
+ # <<< caproom <<<
448
+ '@
449
+ [Console]::Error.WriteLine("caproom setup: patched $profilePath (backup alongside)")
450
+ } else {
451
+ [Console]::Error.WriteLine('caproom setup: profile already bound')
452
+ }
453
+ [Console]::Error.WriteLine('caproom setup: shell.ps1 written to ' + $dir + ' — new terminals pick it up automatically')
454
+ }
455
+
409
456
  switch ($args[0]) {
410
457
  'help' { Show-Usage -AsHelp }
411
458
  '-h' { Show-Usage -AsHelp }
412
459
  '--help' { Show-Usage -AsHelp }
460
+ 'setup' {
461
+ Invoke-Setup; exit 0
462
+ }
463
+ 'bind' {
464
+ Invoke-Setup; exit 0
465
+ }
466
+ 'freemem' {
467
+ $os = Get-CimInstance Win32_OperatingSystem
468
+ Write-Output ([int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize))
469
+ exit 0
470
+ }
413
471
  'park' {
414
472
  if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
415
473
  Invoke-Park -TargetPid ([int]$args[1]); exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caproom",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS, Linux, and Windows — real enforcement via Docker cgroups, Windows Job Objects, or a polling watchdog, plus park/wake to reclaim idle process memory without killing.",
5
5
  "bin": {
6
6
  "caproom": "bin/caproom.js",
@@ -28,6 +28,9 @@
28
28
  "linux",
29
29
  "win32"
30
30
  ],
31
+ "scripts": {
32
+ "postinstall": "node -e \"process.stdout.write('caproom: optional next step — run `caproom setup` to bind headroom management to your shells (never modifies rc files on install)\\n')\""
33
+ },
31
34
  "license": "MIT",
32
35
  "repository": {
33
36
  "type": "git",