caproom 0.5.0 → 0.7.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
@@ -21,6 +21,9 @@ usage: caproom [--limit <mb>] [--interval <sec>] -- <command> [args...]
21
21
  caproom status <pid>
22
22
  caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
23
23
  caproom init <command> [--limit <mb>]
24
+ caproom top --json [--pid <pid>] [--park-min-mb <mb>]
25
+ caproom watch [--threshold-mb <mb>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>
26
+ caproom setup / freemem
24
27
 
25
28
  --limit <mb> memory cap in MB (default: 4096). On Windows this caps
26
29
  committed virtual memory (Job Object ProcessMemoryLimit);
@@ -406,10 +409,283 @@ function Invoke-Capped {
406
409
 
407
410
  if ($args.Count -eq 0) { Show-Usage }
408
411
 
412
+ $script:CaproomNtLoaded = $false
413
+ function Ensure-NtSuspend {
414
+ # Whole-tree park needs NtSuspendProcess/NtResumeProcess (ntdll) —
415
+ # the Windows analogue of kill -STOP/-CONT. Loaded lazily, once.
416
+ if ($script:CaproomNtLoaded) { return }
417
+ try {
418
+ Add-Type -Namespace Caproom -Name Nt -MemberDefinition @'
419
+ [DllImport("ntdll.dll")] public static extern int NtSuspendProcess(IntPtr processHandle);
420
+ [DllImport("ntdll.dll")] public static extern int NtResumeProcess(IntPtr processHandle);
421
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
422
+ [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
423
+ '@
424
+ $script:CaproomNtLoaded = $true
425
+ } catch {
426
+ [Console]::Error.WriteLine('caproom watch: cannot load ntdll suspend/resume — --auto-park unavailable')
427
+ throw
428
+ }
429
+ }
430
+
431
+ function Get-CaproomSnapshot {
432
+ # One CIM query -> ByPid map, Children map (only live parents), Roots
433
+ # (pids whose parent is not in the snapshot). Mirrors posix read_snapshot.
434
+ $procs = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CommandLine,WorkingSetSize)
435
+ $byPid = @{}
436
+ foreach ($p in $procs) { $byPid[[int]$p.ProcessId] = $p }
437
+ $children = @{}
438
+ foreach ($p in $procs) {
439
+ $ppid = [int]$p.ParentProcessId
440
+ if ($byPid.ContainsKey($ppid)) {
441
+ if (-not $children.ContainsKey($ppid)) { $children[$ppid] = New-Object System.Collections.Generic.List[int] }
442
+ $children[$ppid].Add([int]$p.ProcessId)
443
+ }
444
+ }
445
+ $roots = @($procs | Where-Object { -not $byPid.ContainsKey([int]$_.ParentProcessId) } | ForEach-Object { [int]$_.ProcessId })
446
+ return @{ ByPid=$byPid; Children=$children; Roots=$roots }
447
+ }
448
+
449
+ function Get-TreeStats {
450
+ param([hashtable]$Snap, [int]$RootPid)
451
+ $rss = [long]0
452
+ $pids = New-Object System.Collections.Generic.List[int]
453
+ $stack = New-Object System.Collections.Generic.Stack[int]
454
+ $seen = @{}
455
+ $stack.Push($RootPid)
456
+ while ($stack.Count -gt 0) {
457
+ $cur = $stack.Pop()
458
+ if ($seen.ContainsKey($cur)) { continue }
459
+ $seen[$cur] = $true
460
+ if (-not $Snap.ByPid.ContainsKey($cur)) { continue }
461
+ $proc = $Snap.ByPid[$cur]
462
+ if ($proc.WorkingSetSize) { $rss += [long]$proc.WorkingSetSize }
463
+ $pids.Add($cur)
464
+ if ($Snap.Children.ContainsKey($cur)) { foreach ($c in $Snap.Children[$cur]) { $stack.Push($c) } }
465
+ }
466
+ return @{ RssKb = [long]($rss / 1KB); Pids = $pids }
467
+ }
468
+
469
+ function Invoke-Top {
470
+ # schema:1 rows identical in shape to the POSIX build. One honest
471
+ # divergence: Windows exposes no cheap sleep-state, so state is always
472
+ # 'running' and park_candidate keys off tree size alone — the reason
473
+ # string says so instead of pretending a sleep check happened.
474
+ param([int]$FilterPid = 0, [int]$ParkMinMb = 512)
475
+ $snap = Get-CaproomSnapshot
476
+ $ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
477
+ $limitMb = 4096; if ($env:CAPROOM_LIMIT_MB) { $limitMb = [int]$env:CAPROOM_LIMIT_MB }
478
+ $parkMinKb = [long]$ParkMinMb * 1024
479
+ $rows = New-Object System.Collections.Generic.List[object]
480
+ foreach ($root in $snap.Roots) {
481
+ if ($FilterPid -ne 0 -and $root -ne $FilterPid) { continue }
482
+ $st = Get-TreeStats -Snap $snap -RootPid $root
483
+ $cmd = ''
484
+ if ($snap.ByPid.ContainsKey($root)) {
485
+ $procRow = $snap.ByPid[$root]
486
+ if ($procRow.CommandLine) { $cmd = [string]$procRow.CommandLine } else { $cmd = [string]$procRow.Name }
487
+ }
488
+ $cand = $false; $reason = ''
489
+ if ([long]$st.RssKb -ge $parkMinKb) {
490
+ $cand = $true
491
+ $reason = "tree_rss $($st.RssKb)KB >= ${parkMinKb}KB park threshold (win32: no sleep-state check)"
492
+ }
493
+ $rows.Add([pscustomobject]@{
494
+ pid = $root; cmd = $cmd; tree_rss_kb = $st.RssKb
495
+ tree_pids = @($st.Pids.ToArray()); state = 'running'
496
+ park_candidate = $cand; reason = $reason
497
+ })
498
+ }
499
+ $envelope = [pscustomobject]@{ schema = 1; ts = $ts; limit_mb_default = $limitMb; processes = @($rows.ToArray()) }
500
+ ConvertTo-Json -Compress -Depth 6 -InputObject $envelope
501
+ }
502
+
503
+ function Invoke-Watch {
504
+ # Same NDJSON contract as the POSIX watcher (schema:1 events on stdout
505
+ # under --json). Explicit pids only; naming the pid IS the opt-in for
506
+ # --auto-park, same rule as POSIX.
507
+ $thresholdMb = 2048; $intervalSec = 5.0; $auto = $false; $wake = -1.0; $json = $false
508
+ $targets = New-Object System.Collections.Generic.List[int]
509
+ for ($i = 0; $i -lt $args.Count; $i++) {
510
+ switch ($args[$i]) {
511
+ '--threshold-mb' { $thresholdMb = [int]$args[$i + 1]; $i++ }
512
+ '--interval' { $intervalSec = [double]$args[$i + 1]; $i++ }
513
+ '--auto-park' { $auto = $true }
514
+ '--auto-wake-free-pct' { $wake = [double]$args[$i + 1]; $i++ }
515
+ '--json' { $json = $true }
516
+ default {
517
+ try { $targets.Add([int]$args[$i]) }
518
+ catch { [Console]::Error.WriteLine("caproom: unknown watch arg $($args[$i])"); exit 1 }
519
+ }
520
+ }
521
+ }
522
+ if ($targets.Count -eq 0) {
523
+ [Console]::Error.WriteLine('usage: caproom watch [--threshold-mb <mb>] [--interval <sec>] [--auto-park] [--auto-wake-free-pct <pct>] [--json] <pid...>')
524
+ exit 1
525
+ }
526
+
527
+ function Emit([object]$Ev) {
528
+ [Console]::Out.WriteLine((ConvertTo-Json -Compress -Depth 6 -InputObject $Ev))
529
+ }
530
+
531
+ $mode = 'watch'; if ($auto) { $mode = 'auto-park' }
532
+ $ts0 = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
533
+ if ($json) {
534
+ Emit ([pscustomobject]@{ schema = 1; event = 'started'; ts = $ts0; mode = $mode; threshold_kb = ($thresholdMb * 1024); pids = @($targets.ToArray()) })
535
+ } else {
536
+ $armed = ''; if ($auto) { $armed = ', AUTO-PARK ARMED' }
537
+ [Console]::Error.WriteLine("caproom: watching $($targets.Count) pid(s), tree threshold ${thresholdMb}MB, poll ${intervalSec}s$armed")
538
+ }
539
+
540
+ $parkedByUs = New-Object System.Collections.Generic.List[int]
541
+ $breaching = @{}
542
+ while ($true) {
543
+ $liveSet = @{}
544
+ foreach ($q in @(Get-CimInstance Win32_Process -Property ProcessId)) { $liveSet[[int]$q.ProcessId] = $true }
545
+ $alive = New-Object System.Collections.Generic.List[int]
546
+ foreach ($tpid in $targets) { if ($liveSet.ContainsKey($tpid)) { $alive.Add($tpid) } }
547
+ if ($alive.Count -eq 0) {
548
+ if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'all-exited'; ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() }) }
549
+ [Console]::Error.WriteLine('caproom: watch: all watched pids exited')
550
+ exit 0
551
+ }
552
+ $targets = $alive
553
+
554
+ if ($wake -ge 0 -and $parkedByUs.Count -gt 0) {
555
+ $os = Get-CimInstance Win32_OperatingSystem
556
+ $pct = [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
557
+ if ($pct -ge $wake) {
558
+ Ensure-NtSuspend
559
+ foreach ($wpid in @($parkedByUs.ToArray())) {
560
+ if (-not $liveSet.ContainsKey($wpid)) { continue }
561
+ $h = [Caproom.Nt]::OpenProcess(0x0800, $false, $wpid)
562
+ if ($h -ne [IntPtr]::Zero) {
563
+ [void][Caproom.Nt]::NtResumeProcess($h); [void][Caproom.Nt]::CloseHandle($h)
564
+ if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'woke'; ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds(); pid = $wpid; free_pct = $pct }) }
565
+ else { [Console]::Error.WriteLine("caproom: watch: free mem ${pct}% >= ${wake}% — resuming pid $wpid") }
566
+ }
567
+ }
568
+ $parkedByUs.Clear()
569
+ }
570
+ }
571
+
572
+ $snap = Get-CaproomSnapshot
573
+ $threshKb = [long]$thresholdMb * 1024
574
+ foreach ($tpid in $targets) {
575
+ if (-not $snap.ByPid.ContainsKey($tpid)) { continue }
576
+ $st = Get-TreeStats -Snap $snap -RootPid $tpid
577
+ if ([long]$st.RssKb -ge $threshKb) {
578
+ if ($breaching.ContainsKey($tpid)) { continue }
579
+ $breaching[$tpid] = $true
580
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
581
+ if ($auto) {
582
+ Ensure-NtSuspend
583
+ $stopped = 0
584
+ foreach ($cp in $st.Pids) {
585
+ $h = [Caproom.Nt]::OpenProcess(0x0800, $false, $cp)
586
+ if ($h -ne [IntPtr]::Zero) {
587
+ [void][Caproom.Nt]::NtSuspendProcess($h); [void][Caproom.Nt]::CloseHandle($h)
588
+ $parkedByUs.Add($cp); $stopped++
589
+ }
590
+ }
591
+ if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'parked'; ts = $now; pid = $tpid; tree_rss_kb = $st.RssKb; tree_pids = @($st.Pids.ToArray()); stopped = $stopped }) }
592
+ else { [Console]::Error.WriteLine("caproom: watch: tree of pid $tpid hit $($st.RssKb)KB (>= $([int]$threshKb)KB) — PARKED tree ($stopped pids)") }
593
+ } else {
594
+ if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'breach'; ts = $now; pid = $tpid; tree_rss_kb = $st.RssKb }) }
595
+ else { [Console]::Error.WriteLine("caproom: watch: tree of pid $tpid hit $($st.RssKb)KB (>= $([int]$threshKb)KB) — no --auto-park, reporting only") }
596
+ }
597
+ } else {
598
+ if ($breaching.ContainsKey($tpid)) {
599
+ $breaching.Remove($tpid)
600
+ $now2 = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
601
+ if ($json) { Emit ([pscustomobject]@{ schema = 1; event = 'recovered'; ts = $now2; pid = $tpid; tree_rss_kb = $st.RssKb }) }
602
+ else { [Console]::Error.WriteLine("caproom: watch: pid $tpid back under threshold ($($st.RssKb)KB)") }
603
+ }
604
+ }
605
+ }
606
+ Start-Sleep -Seconds $intervalSec
607
+ }
608
+ }
609
+
610
+ function Invoke-Setup {
611
+ # Bind headroom management to PowerShell sessions in ANY terminal:
612
+ # writes ~/.caproom/shell.ps1 (single source) and marker-patches
613
+ # $PROFILE. Idempotent, backs up the profile, reversible via
614
+ # `caproom setup --uninstall`. Never runs on npm install.
615
+ $dir = Join-Path $HOME '.caproom'
616
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
617
+
618
+ @'
619
+ # caproom PowerShell integration — regenerated by `caproom setup`.
620
+ function global:caproom_freemem_pct {
621
+ $os = Get-CimInstance Win32_OperatingSystem
622
+ [int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
623
+ }
624
+ $global:__caproomLastWarn = 0
625
+ function global:prompt {
626
+ try {
627
+ $pct = caproom_freemem_pct
628
+ $now = [DateTimeOffset]::Now.ToUnixTimeSeconds()
629
+ $warn = if ($env:CAPROOM_HEADROOM_WARN) { [int]$env:CAPROOM_HEADROOM_WARN } else { 20 }
630
+ if ($pct -lt $warn -and ($now - $script:__caproomLastWarn) -ge 60) {
631
+ $script:__caproomLastWarn = $now
632
+ Write-Host "caproom: headroom low ($pct% free) - check 'caproom top' before launching heavy work" -ForegroundColor Yellow
633
+ }
634
+ } catch {}
635
+ "PS $($executionContext.SessionState.Path.CurrentLocation)> "
636
+ }
637
+ '@ | Set-Content -Encoding UTF8 (Join-Path $dir 'shell.ps1')
638
+
639
+ $profilePath = $PROFILE.CurrentUserAllHosts
640
+ if (-not (Test-Path $profilePath)) { New-Item -ItemType File -Force -Path $profilePath | Out-Null }
641
+ $content = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
642
+ if ($content -notmatch '# >>> caproom >>>') {
643
+ Copy-Item $profilePath "$profilePath.caproom.bak.$(Get-Date -Format yyyyMMddHHmmss)"
644
+ Add-Content $profilePath @'
645
+
646
+ # >>> caproom >>>
647
+ . "$HOME\.caproom\shell.ps1"
648
+ # <<< caproom <<<
649
+ '@
650
+ [Console]::Error.WriteLine("caproom setup: patched $profilePath (backup alongside)")
651
+ } else {
652
+ [Console]::Error.WriteLine('caproom setup: profile already bound')
653
+ }
654
+ [Console]::Error.WriteLine('caproom setup: shell.ps1 written to ' + $dir + ' — new terminals pick it up automatically')
655
+ }
656
+
409
657
  switch ($args[0]) {
410
658
  'help' { Show-Usage -AsHelp }
411
659
  '-h' { Show-Usage -AsHelp }
412
660
  '--help' { Show-Usage -AsHelp }
661
+ 'setup' {
662
+ Invoke-Setup; exit 0
663
+ }
664
+ 'bind' {
665
+ Invoke-Setup; exit 0
666
+ }
667
+ 'freemem' {
668
+ $os = Get-CimInstance Win32_OperatingSystem
669
+ Write-Output ([int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize))
670
+ exit 0
671
+ }
672
+ 'top' {
673
+ $fpid = 0; $parkMin = 512
674
+ for ($i = 1; $i -lt $args.Count; $i++) {
675
+ switch ($args[$i]) {
676
+ '--json' { }
677
+ '--pid' { $fpid = [int]$args[$i + 1]; $i++ }
678
+ '--park-min-mb' { $parkMin = [int]$args[$i + 1]; $i++ }
679
+ default { [Console]::Error.WriteLine("caproom: unknown top flag $($args[$i])"); exit 1 }
680
+ }
681
+ }
682
+ Invoke-Top -FilterPid $fpid -ParkMinMb $parkMin
683
+ exit 0
684
+ }
685
+ 'watch' {
686
+ Invoke-Watch @($args | Select-Object -Skip 1)
687
+ exit 0
688
+ }
413
689
  'park' {
414
690
  if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
415
691
  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.7.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",