win-nice 0.1.0 → 0.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +309 -41
  3. package/bin/abovenormal +11 -11
  4. package/bin/abovenormal.bat +2 -2
  5. package/bin/abovenormal.ps1 +38 -10
  6. package/bin/admin +11 -11
  7. package/bin/admin.bat +2 -2
  8. package/bin/admin.ps1 +52 -13
  9. package/bin/belownormal +11 -11
  10. package/bin/belownormal.bat +2 -2
  11. package/bin/belownormal.ps1 +38 -10
  12. package/bin/{cap → capc} +11 -11
  13. package/bin/{cap.bat → capc.bat} +3 -3
  14. package/bin/capc.ps1 +432 -0
  15. package/bin/{pint → capm} +11 -11
  16. package/bin/capm.bat +12 -0
  17. package/bin/capm.ps1 +506 -0
  18. package/bin/capn +11 -0
  19. package/bin/capn.bat +8 -0
  20. package/bin/capn.ps1 +426 -0
  21. package/bin/caps +11 -0
  22. package/bin/caps.bat +10 -0
  23. package/bin/caps.ps1 +589 -0
  24. package/bin/capt +11 -0
  25. package/bin/capt.bat +8 -0
  26. package/bin/capt.ps1 +449 -0
  27. package/bin/cx +11 -11
  28. package/bin/cx.bat +1 -1
  29. package/bin/cx.ps1 +38 -10
  30. package/bin/cy +11 -11
  31. package/bin/cy.bat +1 -1
  32. package/bin/cy.ps1 +38 -10
  33. package/bin/high +11 -11
  34. package/bin/high.bat +2 -2
  35. package/bin/high.ps1 +38 -10
  36. package/bin/idle +11 -11
  37. package/bin/idle.bat +2 -2
  38. package/bin/idle.ps1 +38 -10
  39. package/bin/realtime +11 -11
  40. package/bin/realtime.bat +2 -2
  41. package/bin/realtime.ps1 +38 -10
  42. package/bin/uiup +11 -11
  43. package/bin/uiup.bat +1 -1
  44. package/bin/uiup.ps1 +2 -1
  45. package/install/install.js +30 -15
  46. package/install/paths.js +28 -2
  47. package/install/skill.js +25 -1
  48. package/install/uninstall.js +11 -0
  49. package/package.json +7 -3
  50. package/skills/win-nice/SKILL.md +107 -12
  51. package/bin/cap.ps1 +0 -269
  52. package/bin/pint.bat +0 -8
  53. package/bin/pint.ps1 +0 -270
package/CHANGELOG.md ADDED
@@ -0,0 +1,141 @@
1
+ # Changelog
2
+
3
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.2.0] - 2026-09-04
8
+
9
+ The first release since `0.1.0` - `0.1.1` was never tagged or published;
10
+ everything below shipped together as `0.2.0`.
11
+
12
+ ### Added
13
+
14
+ - `capm` - hard memory ceiling via Job Objects; `<size>` accepts a bare
15
+ integer `1`-`100` (percent of total physical RAM, same convention as
16
+ `capc`'s own `<percent 1-100>`), or `m`/`M` (MB), or `g`/`G` (GB).
17
+ - `caps` - wall-clock timeout wrapper: runs `<command>` for up to `<seconds>`
18
+ and if it's still running, force-terminates it and its whole process tree in
19
+ one kernel call (`TerminateJobObject` on the tool's Job Object), then exits
20
+ `124` (the unix `timeout` convention) with a message on stderr. Finishes
21
+ inside the deadline: the wrapped exit code propagates like every other
22
+ launcher. `<seconds>` accepts a positive whole or decimal number (e.g. `2`
23
+ or `2.5`), converted to whole milliseconds, minimum 1 ms, maximum
24
+ `4294967294` ms (~49.7 days) - a deliberate usage ceiling, not an API limit
25
+ (the deadline is an absolute 64-bit FILETIME, so no uint32 boundary applies)
26
+ - anything larger is a usage error, not a
27
+ silently truncated deadline. The deadline is absolute, not a relative wait:
28
+ it is computed from `DateTime.UtcNow`, armed as the absolute due time of a
29
+ one-shot waitable timer, and waited on together with the process handle via
30
+ `WaitForMultipleObjects` (a `GetProcessTimes` check rejects an exit that
31
+ only won the simultaneous-signal race after the deadline), so time spent in
32
+ sleep/suspend counts against it and the timeout fires on wake if the
33
+ deadline passed during sleep (a relative wait doesn't count sleep time on
34
+ Windows 8+). The due time is a system-clock value, so a manual or
35
+ service-driven system-clock adjustment during the wait can shorten or
36
+ lengthen the actual wait relative to `<seconds>`. Unlike
37
+ `capc`/`capt`/`capm` it sets no resource
38
+ limit - the Job Object (with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so a
39
+ non-cooperatively killed wrapper still takes the tree down) exists purely to
40
+ make the timeout kill cover the whole spawned tree.
41
+ - `capn` - process-count ceiling wrapper: caps the number of simultaneously
42
+ active processes in the wrapped command's whole tree via
43
+ `JOB_OBJECT_LIMIT_ACTIVE_PROCESS`. The count includes the wrapped process
44
+ itself (it is assigned to the still-empty job before it can spawn
45
+ anything), so `capn 1 <command>` lets the command run but fails its first
46
+ child-spawn attempt. Exceeding the limit refuses only the offending spawn -
47
+ nothing already running is killed or throttled, and a command within budget
48
+ is unaffected (all confirmed empirically). `<count>` accepts a positive
49
+ whole number, minimum 1, maximum 4294967295 (`ActiveProcessLimit` is a
50
+ uint32 field). Like the other Job Object launchers it carries
51
+ `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so a non-cooperatively killed `capn`
52
+ wrapper still takes the whole tree down.
53
+ - These tools can be chained by name (e.g. `capm 50 capc 50 idle <command>`)
54
+ - each wrapper's Job Object nests inside the outer one (Windows 8+). Nested
55
+ limits do *not* uniformly take the smaller value: CPU rate (`capc`)
56
+ multiplies relative to its parent (`capc 50 capc 50` ≈ 25%, not 50%);
57
+ memory (`capm`) ceilings apply independently to accounting scopes that
58
+ aren't the same size (a parent job's accounting includes every child job's
59
+ committed memory plus its own process, so nested `capm` ceilings don't
60
+ reduce to a simple minimum). Process count (`capn`) limits are enforced
61
+ independently per job, and an outer job's count already includes the inner
62
+ wrapper process itself, so nested `capn` ceilings aren't a simple minimum
63
+ either - leave the outer value headroom for the chain itself. See README's
64
+ "Chaining these tools together" section for the full picture.
65
+
66
+ ### Changed
67
+
68
+ - `cap` renamed to `capc`; `pint` renamed to `capt` (breaking - the old names
69
+ no longer exist).
70
+ - Every launcher's embedded native-process primitive now checks
71
+ `ResumeThread`/`WaitForSingleObject`/`GetExitCodeProcess`/`TerminateProcess`
72
+ return values instead of assuming success, and `AllocHGlobal`/`FreeHGlobal`
73
+ around each Job Object limit struct is wrapped in `try`/`finally`. On a
74
+ `ResumeThread`, `AssignProcessToJobObject`, or `WaitForSingleObject`
75
+ failure, the launcher now attempts to terminate the child instead of
76
+ either waiting on a still-suspended process forever or reporting failure
77
+ while it may still be running unmanaged in the background - and if that
78
+ best-effort kill itself also fails, the thrown error says so explicitly.
79
+ Hardening for a class of rare Win32 failures - not a fix for an observed
80
+ regression.
81
+ - A plain `install`/upgrade (including `postinstall`) now also refreshes an
82
+ already-installed, still-marked `win-nice skill install` copy
83
+ (`~/.claude/skills/win-nice/SKILL.md`, `~/.agents/skills/win-nice/SKILL.md`)
84
+ to the new version's content. Initial skill installation is still opt-in -
85
+ this only ever touches a copy that's already there.
86
+
87
+ ### Fixed
88
+
89
+ - Fixed executable search-order hardening for Windows system helpers: all
90
+ PowerShell-delegating `.bat` wrappers, the installer, and the `admin`/`uiup`
91
+ UAC paths now launch Windows PowerShell or `cmd.exe` by an absolute system
92
+ path. `admin` also carries the absolute path returned by `Get-Command` into
93
+ its direct elevated launch instead of resolving the original bare name a
94
+ second time through ShellExecute. This prevents a same-named executable in
95
+ the caller's current directory from replacing the intended process,
96
+ including at the elevation boundary.
97
+ - `capt` under 32-bit Windows PowerShell now rejects thread counts above the
98
+ 32-bit affinity-mask width with an actionable usage error instead of
99
+ reaching a `UIntPtr` conversion and reporting a raw overflow exception.
100
+ - `uninstall` and `reinstall` run from a source checkout of this repository
101
+ without an explicit `WIN_NICE_HOME` set now refuse to run, matching what
102
+ `install` already did - previously they deleted a real
103
+ `%LOCALAPPDATA%\win-nice` installation and its PATH entry, then silently
104
+ failed to restore it.
105
+ - `capc`/`capt`/`capm`/`caps`/`capn` now set `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`
106
+ on their Job Object as a backstop for non-cooperative termination: a wrapper
107
+ killed from outside (`taskkill` without `/T`, a crash), or a `caps` deadline
108
+ expiring, no longer leaves orphaned grandchild processes (e.g. a linker
109
+ spawned by a build) running unbounded outside the resource limit - Windows
110
+ itself terminates everything still in the job at that moment. A clean,
111
+ successful exit releases this guard first, so a daemon/compiler-server/
112
+ watcher the wrapped command legitimately left running still survives -
113
+ matching this same section's existing "a limit sticks to any daemon ... for
114
+ that daemon's whole lifetime" guarantee, which an earlier draft of this
115
+ change would otherwise have silently broken.
116
+
117
+ ## [0.1.0] - 2026-09-02
118
+
119
+ ### Added
120
+
121
+ - `idle`, `belownormal`, `abovenormal`, `high`, `realtime` - Windows priority
122
+ class launchers.
123
+ - `cap` - hard CPU quota via Job Objects.
124
+ - `pint` - CPU affinity ("pin threads") via Job Objects.
125
+ - `admin` - run a command elevated, with UAC prompt when needed.
126
+ - `uiup` - one-shot desktop-responsiveness priority boost.
127
+ - `cy` / `cx` - `claude`/`codex` launchers with permission/approval bypass
128
+ flags, for use in already-sandboxed/disposable environments.
129
+ - Each tool ships a `.bat`, `.ps1`, and extensionless (Git Bash) entry point.
130
+ - npm-based installer (`postinstall`, `win-nice status|reinstall|uninstall`),
131
+ PATH management via the Windows registry. Uninstall is an explicit `win-nice
132
+ uninstall` command, not an npm `preuninstall` lifecycle hook - npm >= 7
133
+ doesn't invoke `preuninstall` on a global `npm uninstall -g`.
134
+ - Optional `win-nice skill install|uninstall` - installs a reference skill
135
+ for Claude Code and Codex CLI.
136
+ - Node test suite (installer logic) and Pester integration suite (real
137
+ Windows process/priority/Job-Object behavior).
138
+
139
+ [Unreleased]: https://github.com/PHPCraftdream/win-nice/compare/v0.2.0...HEAD
140
+ [0.2.0]: https://github.com/PHPCraftdream/win-nice/compare/v0.1.0...v0.2.0
141
+ [0.1.0]: https://github.com/PHPCraftdream/win-nice/releases/tag/v0.1.0
package/README.md CHANGED
@@ -53,6 +53,16 @@ invocation resolves to depends on the calling shell:
53
53
  | cmd.exe, or PATHEXT-based resolution (e.g. Node's `child_process`, which doesn't include `.PS1` in `PATHEXT` by default) | `name.bat` | corrupted before `.ps1` ever runs (see below) |
54
54
  | POSIX shell (Git Bash only — ignores `PATHEXT`/bare-name extension resolution entirely) | `name` (no extension) | full argument safety — the shim `exec`s straight into `name.ps1` via `powershell -File`, the same direct-to-`.ps1` path PowerShell itself uses, with MSYS argument conversion disabled so slash-style switches (`/c`, `/d`) and Windows paths arrive untouched; no `.bat`/cmd.exe hop involved |
55
55
 
56
+ Known limitation of the extensionless shims: the `MSYS2_ARG_CONV_EXCL='*'`
57
+ they set to keep their own arguments intact is inherited by the wrapped
58
+ command and its whole process tree, so an MSYS program spawned *inside* the
59
+ wrapped command (an inner `bash`/`sh`, a `#!/bin/sh` git hook — not a native
60
+ program like `node.exe` or `cmd.exe`) inherits it too and stops converting
61
+ POSIX-style paths in its own children's arguments — e.g.
62
+ `caps 600 bash -c 'node /c/proj/run.js'` fails with `Cannot find module`
63
+ where the same command without the shim works. The shim's own documented
64
+ argument guarantee (its arguments arrive untouched) is unaffected.
65
+
56
66
  The extensionless shims are Git Bash-specific; WSL is not supported — WSL has
57
67
  no bare `powershell` (only `powershell.exe`), Windows PowerShell can't resolve
58
68
  the `/mnt/...` script path such a shim would pass to `-File`, and a WSL-side
@@ -104,7 +114,7 @@ have it by default); without it, Windows doesn't error out, it silently downgrad
104
114
  the request to `HIGH_PRIORITY_CLASS` instead — confirmed empirically. Same
105
115
  single-process-only caveat as `abovenormal`/`high` applies on top of all that.
106
116
 
107
- ### `cap <percent> <command> [args...]`
117
+ ### `capc <percent> <command> [args...]`
108
118
  Hard CPU quota (1-100) for the whole process tree, enforced by a Windows Job
109
119
  Object (`JOBOBJECT_CPU_RATE_CONTROL_INFORMATION`, hard cap). Unlike `idle`/
110
120
  `belownormal`, this is a real ceiling on total CPU%, not just a scheduling
@@ -112,49 +122,98 @@ priority — it holds even when nothing else on the machine is contending for CP
112
122
 
113
123
  The cap covers the whole subtree from its very first instruction: the wrapped
114
124
  command is created suspended, assigned to the Job Object, and only then resumed
115
- — there's no window where it runs uncapped. Every process it spawns (and their
116
- children, recursively) automatically joins the same job; this is standard Job
117
- Object behavior on any supported Windows version, not something specific to
118
- newer ones. The only way out is a descendant explicitly requesting
119
- `CREATE_BREAKAWAY_FROM_JOB`, and since the job here never sets a
120
- breakaway-allowed flag, that fails closed — the child just fails to launch
121
- rather than silently escaping the cap.
125
+ — there's no window where it runs uncapped. Every ordinary descendant created
126
+ via `CreateProcess` (and their children, recursively) automatically joins the
127
+ same job; this is standard Job Object behavior on any supported Windows
128
+ version, not something specific to newer ones. The known ways out: a
129
+ descendant explicitly requesting `CREATE_BREAKAWAY_FROM_JOB` (and since the job
130
+ here never sets a breakaway-allowed flag, that fails closed — the child just
131
+ fails to launch rather than silently escaping the cap), or a process brought
132
+ up through an external broker/service that never goes through the wrapped
133
+ tree's own `CreateProcess` calls (Microsoft documents, for example, that a
134
+ process started via WMI's `Win32_Process.Create` doesn't join the caller's
135
+ job).
122
136
 
123
137
  Windows 8+ specifically matters if something inside the wrapped command creates
124
- *its own* Job Object (some tools do, e.g. Chromium-based ones): before Windows 8
125
- a process could belong to only one job at a time, so that inner
126
- `AssignProcessToJobObject` call would fail. Windows 8+ allows nested jobs, so it
127
- succeeds instead, and both jobs' limits apply (whichever is more restrictive
128
- wins).
138
+ *its own* Job Object (some tools do, e.g. Chromium-based ones or these tools
139
+ themselves, when chained together, see "Chaining these tools together" below):
140
+ before Windows 8 a process could belong to only one job at a time, so that
141
+ inner `AssignProcessToJobObject` call would fail. Windows 8+ allows nested
142
+ jobs, so it succeeds instead, and both jobs' limits apply — but *how* they
143
+ combine depends on the limit type, not one universal "smaller wins" rule. For
144
+ CPU rate control specifically, a nested job's rate is relative to what its
145
+ parent already lets through, so equal caps **multiply**: `capc 50 capc 50 ...`
146
+ yields roughly 25% of total system CPU, not 50% (see "Chaining these tools
147
+ together" below for the full picture across limit types).
129
148
 
130
149
  Blocks until the command exits, propagates its exit code.
131
150
 
132
151
  ```
133
- cap 50 npm run build
152
+ capc 50 npm run build
134
153
  ```
135
154
 
136
- ### `pint <thread-count> <command> [args...]`
137
- Short for **pin threads**. Restricts the whole process tree to the first
155
+ ### `capt <thread-count> <command> [args...]`
156
+ Short for **cap threads**. Restricts the whole process tree to the first
138
157
  `<thread-count>` logical processors via Windows process affinity
139
158
  (`JOBOBJECT_BASIC_LIMIT_INFORMATION`, `JOB_OBJECT_LIMIT_AFFINITY`) — same
140
- suspend-then-assign-then-resume Job Object mechanism as `cap`, so the same
159
+ suspend-then-assign-then-resume Job Object mechanism as `capc`, so the same
141
160
  "covers the whole subtree from the first instruction" and "breakaway fails
142
161
  closed" guarantees apply.
143
162
 
144
163
  Deliberately *threads*, not *cores*, in both the name and the semantics:
145
164
  Windows affinity masks address logical processors (hardware threads), not
146
- physical cores. On a machine with Hyper-Threading/SMT, `pint 4` pins to 4
165
+ physical cores. On a machine with Hyper-Threading/SMT, `capt 4` pins to 4
147
166
  *logical processors* — depending on which ones, that could be 2 fully-used
148
167
  physical cores or 4 half-used ones; the affinity API has no concept of "whole
149
168
  core" grouping on its own. `<thread-count>` must be between 1 and the number
150
169
  of logical processors on the machine (`[Environment]::ProcessorCount`, capped
151
- at 63 — a single affinity mask can't address more).
170
+ at 63 — a single affinity mask can't address more). Under 32-bit Windows
171
+ PowerShell (the `SysWOW64` host) the effective cap is additionally 32: the
172
+ affinity mask is a pointer-sized `UIntPtr`, so a 32-bit process can only
173
+ address 32 logical processors — counts of 33-63 are rejected up front with a
174
+ usage error naming 64-bit PowerShell (the same process-width limit `capm`
175
+ enforces on its memory cap).
176
+
177
+ ```
178
+ capt 4 npm run build
179
+ ```
180
+
181
+ ### `capm <size> <command> [args...]`
182
+ Hard memory ceiling for the whole process tree, enforced by a Windows Job
183
+ Object (`JOBOBJECT_EXTENDED_LIMIT_INFORMATION`, `JOB_OBJECT_LIMIT_JOB_MEMORY`)
184
+ — same suspend-then-assign-then-resume mechanism as `capc`/`capt`, so the same
185
+ "covers the whole subtree from the first instruction" and "breakaway fails
186
+ closed" guarantees apply. Caps the whole job's *aggregate* committed memory,
187
+ not any single process — like `capc`'s CPU% and `capt`'s affinity, it's one
188
+ ceiling for the whole tree, not a per-process limit.
189
+
190
+ `<size>` accepts three forms:
191
+
192
+ | Form | Meaning |
193
+ | --- | --- |
194
+ | `50` (bare integer, `1`-`100`) | percent of total physical RAM (`GlobalMemoryStatusEx`), not of whatever's currently free — the cap means the same thing regardless of what else is running on the machine at invocation time. Same convention as `capc`'s own `<percent 1-100>` — deliberately no `%` character; see "Chaining these tools together" below for why |
195
+ | `512m` / `512M` | megabytes |
196
+ | `2g` / `2G` | gigabytes |
152
197
 
153
198
  ```
154
- pint 4 npm run build
199
+ capm 50 npm run build
200
+ capm 512m npm run build
201
+ capm 2g npm run build
155
202
  ```
156
203
 
157
- **A `cap`/`pint` limit sticks to any daemon the wrapped command leaves
204
+ **Unlike `capc`, exceeding the limit doesn't throttle it fails the
205
+ allocation.** A CPU cap just makes things slower; a memory cap that's
206
+ exceeded causes the *allocation call itself* to fail (`VirtualAlloc`-family
207
+ APIs return an error, .NET throws `OutOfMemoryException`) rather than the OS
208
+ gracefully degrading anything. Most programs don't handle allocation failure
209
+ cleanly, so in practice this usually looks like a crash. Set it too low and
210
+ even the wrapped program's own runtime can fail to start (confirmed: capping
211
+ Windows PowerShell 5.1 itself at 30 MB crashes it with
212
+ `StackOverflowException` before it can run anything) — leave headroom above
213
+ whatever interpreter/runtime the wrapped command needs just to start, on top
214
+ of what your actual workload needs.
215
+
216
+ **A `capc`/`capt`/`capm`/`capn` limit sticks to any daemon the wrapped command leaves
158
217
  running**, for that daemon's entire lifetime — not just for the wrapped
159
218
  command's own run. Job Object membership is permanent for a process once
160
219
  assigned (short of an explicit, disallowed breakaway); a background process
@@ -163,19 +222,196 @@ for as long as it stays alive. This bites build tools that reuse a persistent
163
222
  process across invocations to skip cold-start cost: `dotnet build`'s
164
223
  `VBCSCompiler`/MSBuild node reuse, a Gradle daemon, file-watcher processes
165
224
  left running by `npm run watch`-style scripts. A follow-up **uncapped**
166
- `dotnet build` (or `gradle`) can end up running inside the *previous* `cap`
167
- call's Job Object without a new `cap`/`pint` invocation of its own, capped
168
- because a stale daemon from an earlier call is doing the work. Either don't
169
- leave the daemon running across a `cap`/`pint` call whose limit shouldn't
170
- persist (`dotnet build -p:UseSharedCompilation=false`, `gradle --no-daemon`),
171
- or accept that the limit is now effectively attached to the daemon until it's
225
+ `dotnet build` (or `gradle`) can end up running inside the *previous* `capc`
226
+ call's Job Object without a new `capc`/`capt`/`capm` invocation of its own,
227
+ capped because a stale daemon from an earlier call is doing the work. Either
228
+ don't leave the daemon running across a call whose limit shouldn't persist
229
+ (`dotnet build -p:UseSharedCompilation=false`, `gradle --no-daemon`), or
230
+ accept that the limit is now effectively attached to the daemon until it's
172
231
  killed.
173
232
 
233
+ ### `caps <seconds> <command> [args...]`
234
+ Runs the command with a wall-clock deadline: if it's still running when
235
+ `<seconds>` have passed, it is force-killed and `caps` exits with code **124** (the unix
236
+ `timeout(1)` convention), printing
237
+ `caps: timed out after <seconds>s - job and every process in it were force-killed`
238
+ to stderr. If the command finishes in time, `caps` propagates its exit code
239
+ exactly like every other launcher in this family.
240
+
241
+ The deadline is genuinely wall-clock: `caps` computes it once as an absolute
242
+ UTC timestamp and arms a one-shot waitable timer with that absolute due time
243
+ (`SetWaitableTimer`), then waits on the timer and the wrapped process together
244
+ (`WaitForMultipleObjects`). An absolute timer due time is a property of the
245
+ wall clock, not of an in-progress wait, so time the machine spends
246
+ asleep/suspended counts against it: if the deadline passes during sleep, the
247
+ timer is already signaled when the machine wakes, and `caps` fires immediately
248
+ instead of waiting out any leftover countdown (a relative `WaitForSingleObject`
249
+ wait does not count sleep time on Windows 8+ and keeps counting its pre-sleep
250
+ remainder after the wake). If the two signals race - a child exiting right
251
+ around the deadline - `caps` asks the kernel for the process's real exit time
252
+ (`GetProcessTimes`) and only accepts it as on-time if it actually finished at
253
+ or before the deadline. Because that due time is a value on the system clock,
254
+ a manual or service-driven system-clock adjustment during the wait moves it
255
+ with the clock: the actual wait can come out shorter or longer than the
256
+ requested `<seconds>` (sleep/suspend still counts correctly - only clock
257
+ adjustments shift the deadline).
258
+
259
+ The same "covers the whole subtree from the first instruction" guarantee
260
+ applies: same suspend-then-assign-then-resume Job Object mechanism as
261
+ `capc`/`capt`/`capm`, so at expiry a single `TerminateJobObject` kernel call
262
+ kills the direct child *and every descendant it spawned* — no process-tree
263
+ walking, no window where a grandchild outlives the child. The job carries only
264
+ `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, which doubles as the backstop if the
265
+ `caps` wrapper itself is killed non-cooperatively (`taskkill` without `/T`, a
266
+ crash): Windows itself then terminates the whole job at that moment. That
267
+ backstop is released before a normal, inside-the-deadline exit, so a
268
+ background daemon the wrapped command legitimately left running survives -
269
+ `caps` only forces a whole-tree kill on its own timeout or on the wrapper's
270
+ own non-cooperative death, never on an ordinary successful exit.
271
+
272
+ `<seconds>` accepts a positive whole or decimal number (`2`, `2.5`), converted
273
+ to whole milliseconds (floored; minimum 1 ms). The maximum is 4294967294 ms
274
+ (~49.7 days) — a deliberate usage ceiling, not a Win32 API limit: the deadline
275
+ is an absolute 64-bit `FILETIME` armed into a waitable timer, and the
276
+ `WaitForMultipleObjects` wait itself always passes `INFINITE` (the timer is
277
+ what bounds it). Anything larger is rejected as a usage error (exit 1), never
278
+ silently truncated into a
279
+ different deadline.
280
+
281
+ **`caps` sets no resource limit of any kind** — no CPU, memory, priority, or
282
+ affinity limit. It is not a quota tool: `capc`/`capt`/`capm` deliberately
283
+ impose no timeout (a quota tool shouldn't unilaterally decide a legitimate long
284
+ build is stuck — same precedent as `nice`/`cpulimit`); `caps` is the
285
+ complement, for when *you* have already decided that N seconds is the limit
286
+ (see the system-clock caveat above).
287
+
288
+ ```
289
+ caps 300 npm test
290
+ ```
291
+
292
+ ### `capn <count> <command> [args...]`
293
+ Runs the command under a hard ceiling on the number of **simultaneously active
294
+ processes** in its whole process tree (`JOBOBJECT_BASIC_LIMIT_INFORMATION`,
295
+ `JOB_OBJECT_LIMIT_ACTIVE_PROCESS`) — same suspend-then-assign-then-resume Job
296
+ Object mechanism as `capc`/`capt`/`capm`, so the same "covers the whole subtree
297
+ from the first instruction" and "breakaway fails closed" guarantees apply.
298
+
299
+ The count **includes the directly wrapped process itself**: it is assigned to
300
+ the still-empty job before it can spawn anything, so `capn 1 <command>` runs
301
+ the command but the instant it tries to spawn any child — including
302
+ infrastructure children like PowerShell's own `Add-Type` compiler (`csc.exe`,
303
+ plus the `CVTRES.EXE` that compiler runs) — that spawn attempt fails.
304
+ Confirmed empirically, not just from docs: under `capn 1`, a wrapped
305
+ PowerShell's `Start-Process` fails with "Not enough quota is available to
306
+ process this command." and the parent keeps running and exits 0.
307
+
308
+ Unlike a `capc` throttle, exceeding the limit doesn't degrade anything — the
309
+ offending spawn attempt itself is what fails (closer to `capm`'s failed
310
+ allocation): nothing is killed, nothing already running is affected, and the
311
+ wrapped process sees an ordinary process-creation failure from its own spawn
312
+ call. A command that stays within the budget is completely unaffected —
313
+ `capn 3` wrapping a parent that spawns 2 children runs exactly like an
314
+ uncapped one (also confirmed empirically).
315
+
316
+ `<count>` is a positive whole number, minimum 1, maximum 4294967295 — the
317
+ `ActiveProcessLimit` struct field is a uint32, so anything larger (or
318
+ negative, or non-numeric) is a usage error (exit 1), never a silently
319
+ truncated limit.
320
+
321
+ ```
322
+ capn 10 npm run build
323
+ ```
324
+
325
+ ## Chaining these tools together
326
+
327
+ These tools can be stacked by passing one as another's `<command>`:
328
+
329
+ ```
330
+ capm 50 capc 50 idle npm run build
331
+ ```
332
+
333
+ Each wrapper wraps everything after its own arguments, so the outermost
334
+ wrapper is whichever one you type first. A few things to know before relying
335
+ on a combination:
336
+
337
+ **Bare tool names resolve through the same `cmd.exe`/`PATHEXT` fallback
338
+ documented above.** None of these ship a `.exe`, so e.g. `capm`'s attempt to
339
+ launch `capc` directly always fails and falls back to `cmd.exe`, which finds
340
+ `capc.bat` via `PATHEXT` — meaning chaining only works when the tools'
341
+ install directory is actually on `PATH`, and any `%` elsewhere on that
342
+ command line trips the same fail-closed check described above. `capm`'s own
343
+ `<size>` deliberately has no `%` form for exactly this reason: an earlier
344
+ version accepted `25%`, and `capc 50 capm 25% ...` failed the fail-closed
345
+ check while `capm 25% capc 50 ...` worked fine — an order-dependent foot-gun.
346
+ A bare percent (`capm 50 capc 50 ...`) sidesteps it entirely, in any order.
347
+
348
+ **Nested Job Object limits do not follow one universal "smaller wins" rule —
349
+ each limit type combines differently:**
350
+
351
+ - **CPU (`capc`)**: a nested job's CPU rate is relative to what its parent
352
+ already lets through, so equal caps *multiply* rather than take the
353
+ minimum — `capc 50 capc 50 ...` yields roughly 25% of total system CPU, not
354
+ 50%. This is documented Windows behavior for
355
+ [`JOBOBJECT_CPU_RATE_CONTROL_INFORMATION`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information),
356
+ not a bug here.
357
+ - **Memory (`capm`)**: each ceiling applies independently to its own
358
+ accounting scope, and those scopes are *not* the same size — a job's
359
+ committed-memory accounting includes its own processes **plus every child
360
+ job's committed memory**, while a child job's own accounting doesn't see
361
+ the outer wrapper's process at all
362
+ ([Nested Jobs — Resource Accounting](https://learn.microsoft.com/en-us/windows/win32/procthread/nested-jobs)).
363
+ So `capm 500m capm 400m ...` is *not* guaranteed to behave like a plain
364
+ `min(500m, 400m) = 400m` ceiling on the innermost workload — the outer
365
+ 500m job can run out of budget first purely from its own wrapper
366
+ process's memory use, on top of whatever the inner 400m job is using.
367
+ Don't rely on nested `capm` ceilings combining to an exact number; treat
368
+ the outer value as an upper bound that can bind earlier than expected.
369
+ - **Priority (`idle`/`belownormal`/`abovenormal`/`high`/`realtime`)**: not a
370
+ Job Object limit — the *last* one applied to a given process simply wins,
371
+ same as invoking any one of them alone.
372
+ - **Affinity (`capt`)**: nested affinity is an *effective-limits* chain — a
373
+ child job's mask can be as tight as it wants but is clamped to whatever
374
+ the parent already allows, never wider
375
+ ([Nested Jobs — Job Limits](https://learn.microsoft.com/en-us/windows/win32/procthread/nested-jobs)).
376
+ Confirmed empirically: `capt 2 capt 3 ...` (inner asking for *more*
377
+ processors than the outer allows) still comes back pinned to the outer's
378
+ 2, not the inner's 3 — no error, just silently clamped to the tighter mask.
379
+ - **Process count (`capn`)**: each job enforces its own `ActiveProcessLimit`
380
+ independently against its own simultaneously-active count — a spawn has to
381
+ fit under *every* job in the chain at once, and an outer job's count
382
+ includes the inner wrapper process itself plus everything beneath it. Not a
383
+ "silently clamped to the tighter limit" rule like affinity, and not a plain
384
+ `min()`: confirmed empirically, `capn 1 capn 5 ...` fails outright (the
385
+ outer job is already full with the inner wrapper alone, so the inner
386
+ wrapper can't even start its target — for a PowerShell-based inner tool
387
+ this surfaces as its own `Add-Type`/`csc.exe` child spawn being refused,
388
+ exit 1); `capn 2 capn 5 ...` still fails one step later (the compiler's own
389
+ `CVTRES.EXE` child doesn't fit); `capn 3 capn 5 ...` works. In the other
390
+ direction, `capn 5 capn 1 <cmd-that-spawns>` runs the command but its child
391
+ spawn is refused by the *inner* limit while the outer still has room. Leave
392
+ real headroom in an outer `capn` for the chain itself — roughly 3 slots
393
+ before a PowerShell-based inner tool's actual workload even starts.
394
+ - **Timeout (`caps`)**: not a Job Object limit being combined at all — each
395
+ `caps` enforces its own deadline on its direct child. The innermost `caps`
396
+ wrapping the eventual work fires at its own deadline and the outer one
397
+ propagates the inner's 124 exit code like any other tool's. If the *outer*
398
+ deadline fires first, its `TerminateJobObject` kills the whole subtree
399
+ including the inner `caps` wrapper — and everything the inner wrapper had
400
+ assigned to its own job dies with it via that inner job's own
401
+ `KILL_ON_JOB_CLOSE` flag (the same cascade documented above). Either way the
402
+ caller sees 124.
403
+
404
+ Test any combination you actually plan to depend on; don't assume "more
405
+ wrappers, more restrictive" holds uniformly across limit types.
406
+
174
407
  ### `uiup`
175
- One-shot priority boost (`HIGH`) for the live shell/UI/audio processes so the
176
- desktop stays responsive while heavy background work runs underneath:
177
- `explorer`, `dwm`, `sihost`, `ShellExperienceHost`, `StartMenuExperienceHost`,
178
- `StartMenu`, `SearchApp`, `audiodg`.
408
+ One-shot priority boost (`HIGH`) for the live shell/UI/audio processes,
409
+ intended to improve desktop responsiveness while heavy background work runs
410
+ underneath: `explorer`, `dwm`, `sihost`, `ShellExperienceHost`,
411
+ `StartMenuExperienceHost`, `StartMenu`, `SearchApp`, `audiodg`. This is a
412
+ best-effort one-shot tweak, not a guarantee — memory pressure, I/O
413
+ saturation, driver/GPU stalls, or a realtime-priority workload elsewhere can
414
+ still make the desktop stutter regardless.
179
415
 
180
416
  Self-elevates via UAC — `dwm`/`sihost` run under a separate account
181
417
  (`Window Manager\DWM-1`), so raising their priority needs admin rights.
@@ -186,10 +422,11 @@ The boost does **not** propagate to apps you launch from Explorer afterwards:
186
422
  see the project history for the test.
187
423
 
188
424
  ### `admin <command> [args...]`
189
- Runs `command` elevated (as Administrator), waits for it to exit, propagates its
190
- exit code — the elevated equivalent of `idle`. If it's already elevated, runs
191
- inline sharing the current console, with the full direct-launch argument safety
192
- described above.
425
+ Runs `command` elevated (as Administrator), waits for it to exit, propagates
426
+ its exit code — a blocking elevation wrapper with the same wait-and-propagate
427
+ semantics as the other wrappers here, but it does not set a priority class
428
+ (unlike `idle`). If it's already elevated, runs inline sharing the current
429
+ console, with the full direct-launch argument safety described above.
193
430
 
194
431
  If the calling shell isn't already elevated, triggers the standard UAC consent
195
432
  prompt (via `ShellExecute`, always opening its own console window, incompatible
@@ -240,7 +477,7 @@ either order — `npx` re-fetches the package to run it, so it still works even
240
477
  after the global package itself is gone.
241
478
 
242
479
  The commands themselves are never registered through npm's own global `bin`
243
- shimming — `idle`/`cap`/etc. are too generic a name to risk colliding with
480
+ shimming — `idle`/`capc`/etc. are too generic a name to risk colliding with
244
481
  someone else's global npm package. npm here is only the delivery mechanism for
245
482
  a dedicated, PATH-managed install directory.
246
483
 
@@ -269,7 +506,12 @@ npx win-nice skill install # add the win-nice reference skill
269
506
  npx win-nice skill uninstall # remove it
270
507
  ```
271
508
 
272
- Separate, opt-in install step — not run automatically by `postinstall`. Copies
509
+ Initial installation is a separate, opt-in step — `postinstall` never creates a
510
+ skill copy for a user who hasn't run `skill install`. Once a copy exists,
511
+ though, every subsequent ordinary package install/upgrade (including
512
+ `postinstall`) automatically refreshes that *existing* marked copy to the new
513
+ version's content, so it never goes stale after an upgrade. Explicit `skill
514
+ install` copies
273
515
  [`skills/win-nice/SKILL.md`](skills/win-nice/SKILL.md) (documents every tool
274
516
  above except `cy`/`cx`) to `~/.claude/skills/win-nice/SKILL.md` and
275
517
  `~/.agents/skills/win-nice/SKILL.md` (Codex CLI's personal-skill location) —
@@ -315,16 +557,22 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
315
557
  - `WIN_NICE_HOME` — overrides the install root (default
316
558
  `%LOCALAPPDATA%\win-nice`). Used by the test suite; also useful for a
317
559
  non-default install location.
318
- - `WIN_NICE_SKILL_HOME` — overrides the home directory `skill install`/
319
- `skill uninstall` resolve `~/.claude/skills/...` and `~/.agents/skills/...`
320
- against (default: the real user home). Mirrors `WIN_NICE_HOME`, for the
321
- skill files instead of `bin/`.
560
+ - `WIN_NICE_SKILL_HOME` — overrides the home directory `~/.claude/skills/...`
561
+ and `~/.agents/skills/...` are resolved against (default: the real user
562
+ home). Mirrors `WIN_NICE_HOME`, for the skill files instead of `bin/`.
563
+ Affects both the explicit `skill install`/`skill uninstall` commands and the
564
+ automatic postinstall/upgrade refresh of an already-installed copy described
565
+ above.
322
566
  - `WIN_NICE_NO_PATH` — if set (to anything), `install`/`uninstall`/
323
567
  `reinstall` skip the user `PATH` update/removal entirely, only managing
324
568
  files under the install directory.
325
569
 
326
570
  ## Testing
327
571
 
572
+ From a source checkout (`test/` and `scripts/` aren't part of the published
573
+ npm package - the `test`/`test:elevated`/`release-check` scripts below only
574
+ work when run from a git clone, not against an installed package):
575
+
328
576
  ```
329
577
  npm test # installer logic (fast; isolated scratch registry key, cleaned up automatically)
330
578
  powershell -Command "Invoke-Pester -Path test\win-nice.Tests.ps1" # real tool behavior
@@ -350,6 +598,26 @@ CPU-cap test retries a few times if the machine is too busy to get a clean
350
598
  baseline). It never touches the real system `PATH`/registry; the installer
351
599
  tests use `WIN_NICE_HOME` to redirect installs into a temp directory instead.
352
600
 
601
+ 3 of its cases only exercise `admin.ps1`'s already-elevated branch, which
602
+ needs the whole test-runner process to already be elevated (not just
603
+ `admin.ps1` itself) - a normal, unelevated run reports them `Skipped`, which
604
+ is expected, not a failure. A separate, disjoint set of 4 cases only makes
605
+ sense when NOT elevated, and `Skip`s under elevation instead. `npm run
606
+ test:elevated` (`test/run-elevated.ps1`) is a single entry point for the
607
+ first group: one UAC prompt elevates the runner once, then the suite runs
608
+ inside that elevated session, activating those 3 cases (and skipping the
609
+ other 4). Neither a normal run nor an elevated run alone exercises every
610
+ case - run both for full coverage.
611
+
612
+ `npm run release-check` (`scripts/release-check.js`) is a maintainer-only,
613
+ source-checkout-only command that packs the actual npm tarball, installs it
614
+ into an isolated temp directory, and verifies the real installed artifact
615
+ (launcher file set, manifest version, `capc`/`capt`/`capm`/`caps`/`capn` exit-code smoke
616
+ tests, and CHANGELOG/tag consistency) - this is what `publish.yml` runs right
617
+ before `npm publish`. It needs `scripts/` and git tag history, neither of
618
+ which is part of the published package, so it can't run against an installed
619
+ copy.
620
+
353
621
  ## License
354
622
 
355
623
  Dual-licensed under [MIT](LICENSE-MIT) or [Apache License, Version 2.0](LICENSE-APACHE),
package/bin/abovenormal CHANGED
@@ -1,11 +1,11 @@
1
- #!/bin/sh
2
- # SPDX-License-Identifier: MIT OR Apache-2.0
3
- # win-nice: managed-file
4
- # Git Bash ignores PATHEXT for bare-name resolution; this shim covers that shell.
5
- # MSYS2_ARG_CONV_EXCL='*' stops Git Bash/MSYS from rewriting user arguments
6
- # (e.g. /c, /d, C:\...) into Windows paths before the exec; the shim's own
7
- # .ps1 path is converted explicitly with cygpath -w so -File gets a Windows path.
8
- MSYS2_ARG_CONV_EXCL='*'
9
- export MSYS2_ARG_CONV_EXCL
10
- script=$(cygpath -w "$(dirname "$0")/abovenormal.ps1" 2>/dev/null) || script="$(dirname "$0")/abovenormal.ps1"
11
- exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
1
+ #!/bin/sh
2
+ # SPDX-License-Identifier: MIT OR Apache-2.0
3
+ # win-nice: managed-file
4
+ # Git Bash ignores PATHEXT for bare-name resolution; this shim covers that shell.
5
+ # MSYS2_ARG_CONV_EXCL='*' stops Git Bash/MSYS from rewriting user arguments
6
+ # (e.g. /c, /d, C:\...) into Windows paths before the exec; the shim's own
7
+ # .ps1 path is converted explicitly with cygpath -w so -File gets a Windows path.
8
+ MSYS2_ARG_CONV_EXCL='*'
9
+ export MSYS2_ARG_CONV_EXCL
10
+ script=$(cygpath -w "$(dirname "$0")/abovenormal.ps1" 2>/dev/null) || script="$(dirname "$0")/abovenormal.ps1"
11
+ exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
@@ -5,9 +5,9 @@ if "%~1"=="" (
5
5
  echo usage: abovenormal ^<command^> [args...] 1>&2
6
6
  exit /b 1
7
7
  )
8
- :: A literal "%" in any argument gets corrupted here - see cap.bat for why (a
8
+ :: A literal "%" in any argument gets corrupted here - see capc.bat for why (a
9
9
  :: cmd.exe batch-parameter quirk, not fixable from inside a .bat). Every other
10
10
  :: cmd.exe metacharacter (&|<>^) survives this hop untouched. Invoking
11
11
  :: "abovenormal" bare from an actual PowerShell session skips this file
12
12
  :: (abovenormal.ps1 preferred).
13
- powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0abovenormal.ps1" %*
13
+ "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%~dp0abovenormal.ps1" %*