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 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 regenerated by `caproom setup`.
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 + ' new terminals pick it up automatically')
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.6.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 -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
+ "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 */ }