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 +247 -0
- package/bin/caproom-mcp.js +5 -2
- package/bin/caproom.ps1 +276 -0
- package/package.json +4 -1
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 ;;
|
package/bin/caproom-mcp.js
CHANGED
|
@@ -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
|
-
|
|
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' + (
|
|
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.
|
|
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",
|