caproom 0.3.1 → 0.5.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.
@@ -0,0 +1,471 @@
1
+ #Requires -Version 5.1
2
+ # caproom -- Windows backend.
3
+ #
4
+ # Enforcement uses a Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY, which is a
5
+ # real kernel-enforced cap (no polling race) and automatically covers child
6
+ # processes. Note this limits *committed virtual memory*, not resident set -- the
7
+ # POSIX backend caps RSS, so the same --limit value can bite at a different point.
8
+ #
9
+ # park uses EmptyWorkingSet, which trims a process's working set to the pagefile
10
+ # on demand without suspending it -- unlike the POSIX backend's SIGSTOP, the
11
+ # process keeps running, so wake is a no-op here.
12
+
13
+ $ErrorActionPreference = 'Stop'
14
+
15
+ function Show-Usage {
16
+ param([switch]$AsHelp)
17
+ $text = @'
18
+ usage: caproom [--limit <mb>] [--interval <sec>] -- <command> [args...]
19
+ caproom park <pid>
20
+ caproom wake <pid>
21
+ caproom status <pid>
22
+ caproom guard [--threshold <pct>] [--interval <sec>] <pid...>
23
+ caproom init <command> [--limit <mb>]
24
+
25
+ --limit <mb> memory cap in MB (default: 4096). On Windows this caps
26
+ committed virtual memory (Job Object ProcessMemoryLimit);
27
+ on macOS/Linux it caps RSS. Same flag, different quantity.
28
+ --interval <sec> poll interval for the fallback watchdog (default: 0.2)
29
+ --force-watchdog use the polling watchdog instead of the Job Object backend
30
+
31
+ Windows differences from macOS/Linux:
32
+ * No SIGTERM grace period. Windows console apps have no signal equivalent,
33
+ so a watchdog breach is a hard kill. The Job Object backend does not kill
34
+ at all -- the allocation simply fails inside the process.
35
+ * park <pid> uses EmptyWorkingSet: memory is trimmed to the pagefile
36
+ immediately, on demand, and the process KEEPS RUNNING. There is no
37
+ suspension, so it cannot hang a process that something is waiting on.
38
+ * wake <pid> is a no-op -- nothing was suspended. Trimmed pages fault back
39
+ in by themselves on next access.
40
+
41
+ guard watches SYSTEM-WIDE free memory (not any single process) and auto-parks
42
+ tracked pids (EmptyWorkingSet) once free mem drops below --threshold percent,
43
+ before the OS has to fail an allocation itself. Use it when unrelated heavy
44
+ processes (e.g. a GPU inference job and a TTS job in separate terminals)
45
+ share a box and neither individually breaches any --limit cap. Foreground,
46
+ blocking; exits once all watched pids have exited. There is no unpark step --
47
+ park just trims the working set, pages fault back in on next access.
48
+
49
+ env vars (override flags): CAPROOM_LIMIT_MB, CAPROOM_INTERVAL
50
+
51
+ examples:
52
+ caproom --limit 2048 -- npm run build
53
+ caproom park 12345
54
+ caproom guard --threshold 10 --interval 5 -- 12345 12346
55
+ caproom init claude --limit 6144
56
+ '@
57
+ if ($AsHelp) { Write-Output $text; exit 0 }
58
+ [Console]::Error.WriteLine($text)
59
+ exit 1
60
+ }
61
+
62
+ $NativeMethods = @'
63
+ using System;
64
+ using System.Runtime.InteropServices;
65
+
66
+ public static class Caproom {
67
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
68
+ public static extern IntPtr CreateJobObject(IntPtr a, string lpName);
69
+
70
+ [DllImport("kernel32.dll", SetLastError = true)]
71
+ public static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfo);
72
+
73
+ [DllImport("kernel32.dll", SetLastError = true)]
74
+ public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
75
+
76
+ [DllImport("psapi.dll", SetLastError = true)]
77
+ public static extern bool EmptyWorkingSet(IntPtr hProcess);
78
+
79
+ [StructLayout(LayoutKind.Sequential)]
80
+ public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
81
+ public Int64 PerProcessUserTimeLimit;
82
+ public Int64 PerJobUserTimeLimit;
83
+ public UInt32 LimitFlags;
84
+ public UIntPtr MinimumWorkingSetSize;
85
+ public UIntPtr MaximumWorkingSetSize;
86
+ public UInt32 ActiveProcessLimit;
87
+ public UIntPtr Affinity;
88
+ public UInt32 PriorityClass;
89
+ public UInt32 SchedulingClass;
90
+ }
91
+
92
+ [StructLayout(LayoutKind.Sequential)]
93
+ public struct IO_COUNTERS {
94
+ public UInt64 ReadOperationCount;
95
+ public UInt64 WriteOperationCount;
96
+ public UInt64 OtherOperationCount;
97
+ public UInt64 ReadTransferCount;
98
+ public UInt64 WriteTransferCount;
99
+ public UInt64 OtherTransferCount;
100
+ }
101
+
102
+ [StructLayout(LayoutKind.Sequential)]
103
+ public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
104
+ public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
105
+ public IO_COUNTERS IoInfo;
106
+ public UIntPtr ProcessMemoryLimit;
107
+ public UIntPtr JobMemoryLimit;
108
+ public UIntPtr PeakProcessMemoryUsed;
109
+ public UIntPtr PeakJobMemoryUsed;
110
+ }
111
+
112
+ public const int ExtendedLimitInformation = 9;
113
+ public const uint LIMIT_PROCESS_MEMORY = 0x00000100;
114
+ public const uint LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
115
+ }
116
+ '@
117
+
118
+ function Import-Native {
119
+ if (-not ('Caproom' -as [type])) { Add-Type -TypeDefinition $script:NativeMethods }
120
+ }
121
+
122
+ function Invoke-Park {
123
+ param([int]$TargetPid)
124
+ Import-Native
125
+ $proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
126
+ if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
127
+ $before = $proc.WorkingSet64
128
+ if (-not [Caproom]::EmptyWorkingSet($proc.Handle)) {
129
+ [Console]::Error.WriteLine("caproom: EmptyWorkingSet failed for pid $TargetPid (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))")
130
+ exit 1
131
+ }
132
+ $after = (Get-Process -Id $TargetPid).WorkingSet64
133
+ [Console]::Error.WriteLine("caproom: pid $TargetPid parked -- working set trimmed $([math]::Round($before/1MB))MB -> $([math]::Round($after/1MB))MB. Process is STILL RUNNING (no suspension); pages fault back in on access.")
134
+ }
135
+
136
+ function Invoke-Wake {
137
+ param([int]$TargetPid)
138
+ if (-not (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)) {
139
+ [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1
140
+ }
141
+ [Console]::Error.WriteLine("caproom: pid $TargetPid -- nothing to wake. On Windows park trims the working set without suspending, so the process never stopped running.")
142
+ }
143
+
144
+ function Invoke-Status {
145
+ param([int]$TargetPid)
146
+ $proc = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
147
+ if (-not $proc) { [Console]::Error.WriteLine("caproom: no such pid $TargetPid"); exit 1 }
148
+ [PSCustomObject]@{
149
+ Pid = $proc.Id
150
+ WorkingSetMB = [math]::Round($proc.WorkingSet64 / 1MB)
151
+ CommittedMB = [math]::Round($proc.PagedMemorySize64 / 1MB)
152
+ Elapsed = (Get-Date) - $proc.StartTime
153
+ Command = $proc.ProcessName
154
+ } | Format-List
155
+ }
156
+
157
+ function Get-FreeMemPercent {
158
+ $os = Get-CimInstance Win32_OperatingSystem
159
+ return [math]::Floor(($os.FreePhysicalMemory * 100) / $os.TotalVisibleMemorySize)
160
+ }
161
+
162
+ function Invoke-Guard {
163
+ param([int]$Threshold, [double]$Interval, [int[]]$TargetPids)
164
+ Import-Native
165
+ [Console]::Error.WriteLine("caproom: guarding $($TargetPids.Count) pid(s), park when system free mem < ${Threshold}% (poll ${Interval}s)")
166
+ $parked = @{}
167
+ while ($true) {
168
+ $alive = @($TargetPids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
169
+ if ($alive.Count -eq 0) {
170
+ [Console]::Error.WriteLine("caproom: guard: all watched pids exited")
171
+ exit 0
172
+ }
173
+ $TargetPids = $alive
174
+ $pct = Get-FreeMemPercent
175
+ if ($pct -lt $Threshold) {
176
+ foreach ($p in $TargetPids) {
177
+ if (-not $parked.ContainsKey($p)) {
178
+ $proc = Get-Process -Id $p -ErrorAction SilentlyContinue
179
+ if ($proc) {
180
+ [Console]::Error.WriteLine("caproom: system free mem ${pct}% < ${Threshold}% threshold -- about to blow, parking pid $p (EmptyWorkingSet)")
181
+ [void][Caproom]::EmptyWorkingSet($proc.Handle)
182
+ $parked[$p] = $true
183
+ }
184
+ }
185
+ }
186
+ }
187
+ Start-Sleep -Seconds $Interval
188
+ }
189
+ }
190
+
191
+ function Invoke-Init {
192
+ param([string]$Target, [int]$LimitMb)
193
+ @"
194
+ # caproom: auto-cap '$Target' -- added by 'caproom init $Target'
195
+ # override per-shell: `$env:CAPROOM_LIMIT_MB = 8192
196
+ function ${Target}_capped {
197
+ `$limit = if (`$env:CAPROOM_LIMIT_MB) { `$env:CAPROOM_LIMIT_MB } else { $LimitMb }
198
+ caproom --force-watchdog --limit `$limit -- $Target @args
199
+ }
200
+ Set-Alias -Name $Target -Value ${Target}_capped -Force
201
+ "@
202
+ }
203
+
204
+ # Start-Process -ArgumentList joins an array with spaces and does no quoting,
205
+ # so an argument containing whitespace gets re-split into several arguments by
206
+ # the callee. Build one command line with CommandLineToArgvW quoting instead.
207
+ function ConvertTo-ArgString {
208
+ param([string[]]$Arguments)
209
+ $quoted = foreach ($a in $Arguments) {
210
+ if ($a -eq '') { '""' }
211
+ elseif ($a -notmatch '[\s"]') { $a }
212
+ else {
213
+ # Double any backslashes preceding a quote (and at end of string),
214
+ # then escape the quotes themselves.
215
+ $s = $a -replace '(\\*)"', '$1$1\"'
216
+ $s = $s -replace '(\\+)$', '$1$1'
217
+ '"' + $s + '"'
218
+ }
219
+ }
220
+ $quoted -join ' '
221
+ }
222
+
223
+ function New-CappedProcess {
224
+ # Every pipe-based capture (Process class + ReadToEndAsync, Process class
225
+ # + raw BaseStream, with and without stripping std-handle inheritance)
226
+ # returned zero bytes in CI despite a clean exit 0 -- caproom is invoked
227
+ # as powershell.exe -File caproom.ps1 from the Node shim, itself invoked
228
+ # from a pwsh.EXE step that captures via a pipe (`| Out-String`), and
229
+ # something in that nesting swallows anonymous-pipe output every time.
230
+ # File-based redirection (Start-Process -RedirectStandardOutput <file>)
231
+ # was the one capture method that survived an isolated repro under the
232
+ # exact same nesting in the same CI job, so route through temp files
233
+ # instead of pipes entirely.
234
+ param([string]$Exe, [string]$ArgLine)
235
+ $resolvedExe = $Exe
236
+ $cmd = Get-Command $Exe -ErrorAction SilentlyContinue
237
+ if ($cmd) { $resolvedExe = $cmd.Source }
238
+
239
+ $outFile = [IO.Path]::GetTempFileName()
240
+ $errFile = [IO.Path]::GetTempFileName()
241
+ $proc = Start-Process -FilePath $resolvedExe -ArgumentList $ArgLine -NoNewWindow `
242
+ -RedirectStandardOutput $outFile -RedirectStandardError $errFile -PassThru
243
+
244
+ # Start-Process's PassThru object opens a limited-rights handle lazily --
245
+ # if .Handle is never touched while the process is still alive, .ExitCode
246
+ # silently reads back 0 for an already-exited process instead of the real
247
+ # code. Force the full-access handle open now, before it can exit.
248
+ $null = $proc.Handle
249
+
250
+ $proc | Add-Member -NotePropertyName StdoutFile -NotePropertyValue $outFile
251
+ $proc | Add-Member -NotePropertyName StderrFile -NotePropertyValue $errFile
252
+ return $proc
253
+ }
254
+
255
+ function Read-NewOutput {
256
+ # Tail-follow one capture file from its recorded byte offset, writing new
257
+ # bytes to the given console stream as they land so output streams live.
258
+ # Byte-level writes pass the child's bytes through un-re-encoded.
259
+ param([string]$Path, $Offsets, $Stream)
260
+ if (-not (Test-Path -LiteralPath $Path)) { return }
261
+ $fs = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
262
+ try {
263
+ if ($fs.Length -lt $Offsets[$Path]) { $Offsets[$Path] = 0 } # file truncated/recreated under us
264
+ if ($fs.Length -gt $Offsets[$Path]) {
265
+ $fs.Position = $Offsets[$Path]
266
+ $len = [int]($fs.Length - $fs.Position)
267
+ $buf = New-Object byte[] $len
268
+ $read = 0
269
+ while ($read -lt $len) {
270
+ $n = $fs.Read($buf, $read, $len - $read)
271
+ if ($n -le 0) { break }
272
+ $read += $n
273
+ }
274
+ if ($read -gt 0) {
275
+ $Offsets[$Path] += $read
276
+ $Stream.Write($buf, 0, $read)
277
+ $Stream.Flush()
278
+ }
279
+ }
280
+ } finally { $fs.Close() }
281
+ }
282
+
283
+ function Wait-CappedProcess {
284
+ # Drains remaining output, cleans up the temp capture files, returns the
285
+ # exit code. If the caller streamed while polling (watchdog path), pass
286
+ # the SAME offsets table so only the unread tail is relayed here; the
287
+ # job-object path streams internally on a 50ms cadence.
288
+ param($Proc, $Offsets = @{ ($Proc.StdoutFile) = 0; ($Proc.StderrFile) = 0 })
289
+ try {
290
+ while (-not $Proc.HasExited) {
291
+ Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
292
+ Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
293
+ Start-Sleep -Milliseconds 50
294
+ }
295
+ Read-NewOutput -Path $Proc.StdoutFile -Offsets $Offsets -Stream ([Console]::Out)
296
+ Read-NewOutput -Path $Proc.StderrFile -Offsets $Offsets -Stream ([Console]::Error)
297
+ return $Proc.ExitCode
298
+ } finally {
299
+ Remove-Item -LiteralPath $Proc.StdoutFile, $Proc.StderrFile -ErrorAction SilentlyContinue
300
+ }
301
+ }
302
+
303
+ # The watchdog must see the WHOLE tree, not just the top pid: coding agents
304
+ # keep their memory in children (MCP servers, bundler daemons, headless
305
+ # browsers) while the parent's own working set stays flat. Walk the
306
+ # parent->child edges of one Win32_Process snapshot and sum working sets.
307
+ function Get-TreeWorkingSetBytes {
308
+ param([int]$RootPid)
309
+ $ws = @{}
310
+ $kids = @{}
311
+ foreach ($p in Get-CimInstance -ClassName Win32_Process -Property ProcessId, ParentProcessId, WorkingSetSize) {
312
+ $pidInt = [int]$p.ProcessId
313
+ $ppidInt = [int]$p.ParentProcessId
314
+ $ws[$pidInt] = [uint64]$p.WorkingSetSize
315
+ if (-not $kids.ContainsKey($ppidInt)) { $kids[$ppidInt] = @() }
316
+ $kids[$ppidInt] += $pidInt
317
+ }
318
+ if (-not $ws.ContainsKey($RootPid)) { return [uint64]0 }
319
+ $total = [uint64]0
320
+ $queue = New-Object System.Collections.Queue
321
+ $visited = @{}
322
+ $queue.Enqueue($RootPid)
323
+ while ($queue.Count -gt 0) {
324
+ $cur = [int]$queue.Dequeue()
325
+ if ($visited.ContainsKey($cur)) { continue } # pid-reuse / cycle guard
326
+ $visited[$cur] = $true
327
+ $total += $ws[$cur]
328
+ if ($kids.ContainsKey($cur)) { foreach ($c in $kids[$cur]) { [void]$queue.Enqueue($c) } }
329
+ }
330
+ return $total
331
+ }
332
+
333
+ function Invoke-Capped {
334
+ param([int]$LimitMb, [double]$Interval, [bool]$ForceWatchdog, [string[]]$Command)
335
+
336
+ $exe = $Command[0]
337
+ $rest = if ($Command.Length -gt 1) { ConvertTo-ArgString $Command[1..($Command.Length - 1)] } else { '' }
338
+
339
+ if (-not $ForceWatchdog) {
340
+ try {
341
+ Import-Native
342
+ $job = [Caproom]::CreateJobObject([IntPtr]::Zero, $null)
343
+ if ($job -eq [IntPtr]::Zero) { throw 'CreateJobObject returned NULL' }
344
+
345
+ $info = New-Object Caproom+JOBOBJECT_EXTENDED_LIMIT_INFORMATION
346
+ $info.BasicLimitInformation.LimitFlags = [Caproom]::LIMIT_PROCESS_MEMORY -bor [Caproom]::LIMIT_KILL_ON_JOB_CLOSE
347
+ $info.ProcessMemoryLimit = [UIntPtr]::new([uint64]$LimitMb * 1MB)
348
+
349
+ $size = [Runtime.InteropServices.Marshal]::SizeOf($info)
350
+ $ptr = [Runtime.InteropServices.Marshal]::AllocHGlobal($size)
351
+ try {
352
+ [Runtime.InteropServices.Marshal]::StructureToPtr($info, $ptr, $false)
353
+ if (-not [Caproom]::SetInformationJobObject($job, [Caproom]::ExtendedLimitInformation, $ptr, $size)) {
354
+ throw "SetInformationJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
355
+ }
356
+ } finally {
357
+ [Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
358
+ }
359
+
360
+ # Assign ONLY the child to the job, immediately after spawn --
361
+ # never caproom's own process. Putting the PowerShell runtime
362
+ # inside the job made its ~100MB+ commit eat the user's budget,
363
+ # and a PS spike could fail allocations inside THEIR command.
364
+ # Policy: prefer under-counting over impeding. Cost is a
365
+ # millisecond-scale window before assignment lands; the child's
366
+ # own descendants are still covered automatically (they inherit
367
+ # the association at CreateProcess).
368
+ [Console]::Error.WriteLine("caproom: job object backend, limit=${LimitMb}m (committed memory, kernel-enforced, covers the command and its descendants)")
369
+ $proc = New-CappedProcess -Exe $exe -ArgLine $rest
370
+ if (-not [Caproom]::AssignProcessToJobObject($job, $proc.Handle)) {
371
+ # Child is already running -- kill it before falling back,
372
+ # or the watchdog path below would launch a second instance.
373
+ & taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
374
+ throw "AssignProcessToJobObject failed (error $([Runtime.InteropServices.Marshal]::GetLastWin32Error()))"
375
+ }
376
+ exit (Wait-CappedProcess $proc)
377
+ } catch {
378
+ [Console]::Error.WriteLine("caproom: job object backend unavailable ($($_.Exception.Message)) -- falling back to watchdog")
379
+ }
380
+ }
381
+
382
+ [Console]::Error.WriteLine("caproom: watchdog backend, limit=${LimitMb}m poll=${Interval}s (process-tree working set, hard kill on breach -- Windows has no SIGTERM equivalent)")
383
+ $limitBytes = [uint64]$LimitMb * 1MB
384
+ $proc = New-CappedProcess -Exe $exe -ArgLine $rest
385
+ # Stream output WHILE the breach-poll loop runs -- polling must not sit
386
+ # on the whole runtime and leave the tail-follow to drain everything at
387
+ # exit. Same offsets table flows into Wait-CappedProcess for the final
388
+ # drain so nothing is relayed twice.
389
+ $offsets = @{ ($proc.StdoutFile) = 0; ($proc.StderrFile) = 0 }
390
+ while (-not $proc.HasExited) {
391
+ Read-NewOutput -Path $proc.StdoutFile -Offsets $offsets -Stream ([Console]::Out)
392
+ Read-NewOutput -Path $proc.StderrFile -Offsets $offsets -Stream ([Console]::Error)
393
+ Start-Sleep -Seconds $Interval
394
+ if ($proc.HasExited) { break }
395
+ $treeBytes = Get-TreeWorkingSetBytes -RootPid $proc.Id
396
+ if ($treeBytes -gt $limitBytes) {
397
+ [Console]::Error.WriteLine("caproom: process tree of pid $($proc.Id) using $([math]::Round($treeBytes/1MB))MB exceeded ${LimitMb}MB cap -- killing tree")
398
+ & taskkill.exe /PID $proc.Id /T /F 2>$null | Out-Null
399
+ exit 137
400
+ }
401
+ }
402
+ exit (Wait-CappedProcess $proc -Offsets $offsets)
403
+ }
404
+
405
+ # ---- argument parsing ----
406
+
407
+ if ($args.Count -eq 0) { Show-Usage }
408
+
409
+ switch ($args[0]) {
410
+ 'help' { Show-Usage -AsHelp }
411
+ '-h' { Show-Usage -AsHelp }
412
+ '--help' { Show-Usage -AsHelp }
413
+ 'park' {
414
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom park <pid>'); exit 1 }
415
+ Invoke-Park -TargetPid ([int]$args[1]); exit 0
416
+ }
417
+ 'wake' {
418
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom wake <pid>'); exit 1 }
419
+ Invoke-Wake -TargetPid ([int]$args[1]); exit 0
420
+ }
421
+ 'status' {
422
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom status <pid>'); exit 1 }
423
+ Invoke-Status -TargetPid ([int]$args[1]); exit 0
424
+ }
425
+ 'guard' {
426
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
427
+ $threshold = 10
428
+ $gInterval = 5
429
+ $gPids = @()
430
+ for ($i = 1; $i -lt $args.Count; $i++) {
431
+ if ($args[$i] -eq '--threshold') { $threshold = [int]$args[$i + 1]; $i++ }
432
+ elseif ($args[$i] -eq '--interval') { $gInterval = [double]$args[$i + 1]; $i++ }
433
+ elseif ($args[$i] -eq '--') { continue }
434
+ else { $gPids += [int]$args[$i] }
435
+ }
436
+ if ($gPids.Count -eq 0) { [Console]::Error.WriteLine('usage: caproom guard [--threshold <pct>] [--interval <sec>] <pid...>'); exit 1 }
437
+ Invoke-Guard -Threshold $threshold -Interval $gInterval -TargetPids $gPids
438
+ exit 0
439
+ }
440
+ 'init' {
441
+ if ($args.Count -lt 2) { [Console]::Error.WriteLine('usage: caproom init <command> [--limit <mb>]'); exit 1 }
442
+ $target = $args[1]
443
+ $limit = 4096
444
+ for ($i = 2; $i -lt $args.Count; $i++) {
445
+ if ($args[$i] -eq '--limit') { $limit = [int]$args[$i + 1]; $i++ }
446
+ else { [Console]::Error.WriteLine("caproom: unknown init flag $($args[$i])"); exit 1 }
447
+ }
448
+ Invoke-Init -Target $target -LimitMb $limit
449
+ exit 0
450
+ }
451
+ }
452
+
453
+ $limitMb = if ($env:CAPROOM_LIMIT_MB) { [int]$env:CAPROOM_LIMIT_MB } else { 4096 }
454
+ $interval = if ($env:CAPROOM_INTERVAL) { [double]$env:CAPROOM_INTERVAL } else { 0.2 }
455
+ $forceWatchdog = $false
456
+ $i = 0
457
+ $parsing = $true
458
+ while ($parsing -and $i -lt $args.Count) {
459
+ $a = $args[$i]
460
+ if ($a -eq '--limit') { $limitMb = [int]$args[$i + 1]; $i += 2 }
461
+ elseif ($a -eq '--interval') { $interval = [double]$args[$i + 1]; $i += 2 }
462
+ elseif ($a -eq '--force-watchdog') { $forceWatchdog = $true; $i++ }
463
+ elseif ($a -eq '-h' -or $a -eq '--help') { Show-Usage -AsHelp }
464
+ elseif ($a -eq '--') { $i++; $parsing = $false }
465
+ else { $parsing = $false }
466
+ }
467
+
468
+ if ($i -ge $args.Count) { Show-Usage }
469
+ $command = @($args[$i..($args.Count - 1)])
470
+
471
+ Invoke-Capped -LimitMb $limitMb -Interval $interval -ForceWatchdog $forceWatchdog -Command $command
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "caproom",
3
- "version": "0.3.1",
4
- "description": "Memory-cap any command (AI coding agents, builds, background jobs) on macOS/Linux — real enforcement via Docker cgroups or a polling watchdog, plus park/wake to reclaim idle process memory without killing.",
3
+ "version": "0.5.0",
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
- "caproom": "bin/caproom"
6
+ "caproom": "bin/caproom.js",
7
+ "caproom-mcp": "bin/caproom-mcp.js"
7
8
  },
8
9
  "files": [
9
- "bin/caproom"
10
+ "bin/caproom",
11
+ "bin/caproom.js",
12
+ "bin/caproom.ps1",
13
+ "bin/caproom-mcp.js"
10
14
  ],
11
15
  "keywords": [
12
16
  "memory",
@@ -21,7 +25,8 @@
21
25
  ],
22
26
  "os": [
23
27
  "darwin",
24
- "linux"
28
+ "linux",
29
+ "win32"
25
30
  ],
26
31
  "license": "MIT",
27
32
  "repository": {