caproom 0.6.0 → 0.7.1
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.ps1 +220 -2
- package/package.json +4 -3
- package/scripts/postinstall.js +7 -0
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,6 +409,204 @@ 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
|
+
|
|
409
610
|
function Invoke-Setup {
|
|
410
611
|
# Bind headroom management to PowerShell sessions in ANY terminal:
|
|
411
612
|
# writes ~/.caproom/shell.ps1 (single source) and marker-patches
|
|
@@ -415,7 +616,7 @@ function Invoke-Setup {
|
|
|
415
616
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
416
617
|
|
|
417
618
|
@'
|
|
418
|
-
# caproom PowerShell integration
|
|
619
|
+
# caproom PowerShell integration -- regenerated by `caproom setup`.
|
|
419
620
|
function global:caproom_freemem_pct {
|
|
420
621
|
$os = Get-CimInstance Win32_OperatingSystem
|
|
421
622
|
[int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize)
|
|
@@ -450,7 +651,7 @@ function global:prompt {
|
|
|
450
651
|
} else {
|
|
451
652
|
[Console]::Error.WriteLine('caproom setup: profile already bound')
|
|
452
653
|
}
|
|
453
|
-
[Console]::Error.WriteLine('caproom setup: shell.ps1 written to ' + $dir + '
|
|
654
|
+
[Console]::Error.WriteLine('caproom setup: shell.ps1 written to ' + $dir + ' -- new terminals pick it up automatically')
|
|
454
655
|
}
|
|
455
656
|
|
|
456
657
|
switch ($args[0]) {
|
|
@@ -468,6 +669,23 @@ switch ($args[0]) {
|
|
|
468
669
|
Write-Output ([int]($os.FreePhysicalMemory * 100 / $os.TotalVisibleMemorySize))
|
|
469
670
|
exit 0
|
|
470
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
|
+
}
|
|
471
689
|
'park' {
|
|
472
690
|
if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
|
|
473
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.1",
|
|
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",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"bin/caproom",
|
|
11
11
|
"bin/caproom.js",
|
|
12
12
|
"bin/caproom.ps1",
|
|
13
|
-
"bin/caproom-mcp.js"
|
|
13
|
+
"bin/caproom-mcp.js",
|
|
14
|
+
"scripts/postinstall.js"
|
|
14
15
|
],
|
|
15
16
|
"keywords": [
|
|
16
17
|
"memory",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"win32"
|
|
30
31
|
],
|
|
31
32
|
"scripts": {
|
|
32
|
-
"postinstall": "node
|
|
33
|
+
"postinstall": "node scripts/postinstall.js"
|
|
33
34
|
},
|
|
34
35
|
"license": "MIT",
|
|
35
36
|
"repository": {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Hint only — never touches rc files on install. Binding is explicit:
|
|
2
|
+
// caproom setup
|
|
3
|
+
try {
|
|
4
|
+
process.stdout.write(
|
|
5
|
+
'caproom: optional next step — run "caproom setup" to bind headroom warnings to your shells (never modifies rc files on install)\n'
|
|
6
|
+
);
|
|
7
|
+
} catch (_) { /* never block installs */ }
|