diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78387f0..03b512c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,65 @@ jobs: chmod +x tools/linux/*.sh install.sh bats --print-output-on-failure tests/linux/ + # ----- macOS: bats suite for tools/macos/ + install.sh (MEDIUM tier) ---- + # arm64 only (macos-14). Hosted Intel macOS (macos-13) was dropped in + # v1.2.0: GitHub is retiring hosted Intel macOS, so the macos-13 leg + # never got a runner -- every run sat in "awaiting a runner" until + # GitHub's hard 24h account ceiling and was auto-cancelled. timeout-minutes + # bounds execution time AFTER a runner is assigned, never queue time, so + # no workflow knob can rescue a starved label. The runtime scripts are + # architecture-neutral (process groups / signals / ps, no arch-specific + # paths) and bash-3.2-safe by construction; the "Static bash-3.2 safety + # check" step below is the standing proof of the 3.2 constraint, so only + # x86_64 *execution* is uncovered, not syntax. Restoring an Intel + # execution leg is tracked as an explicit open gap: issue #3. Matrix kept + # single-entry (not collapsed) so re-adding Intel is a one-line change; + # fail-fast stays off for when it returns. + macos: + name: bats (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14] + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install bats-core + # bats-core is the canonical Homebrew formula (it installs the + # `bats` binary); the bare `bats` formula is the dead pre-1.0 one. + run: brew install bats-core + + - name: Probe environment (record which bash actually runs the suite) + # macOS ships Apple's frozen bash 3.2.57 at /bin/bash, but GitHub + # runners also put a modern Homebrew bash earlier on PATH. The + # wrapper is written 3.2-safe by construction; this records in the + # log which bash the functional suite exercises so the gap is + # documented, not hidden. + run: | + sw_vers + echo "arch: $(uname -m)" + echo "bats: $(bats --version)" + echo "PATH bash: $(bash --version | head -1)" + echo "/bin/bash: $(/bin/bash --version | head -1) <- Apple stock 3.2.57" + + - name: Static bash-3.2 safety check (parse under Apple stock /bin/bash) + # bats may run the suite under Homebrew bash, so separately prove + # the runtime scripts at least PARSE under the frozen 3.2.57 every + # real macOS user has. Catches syntax-level 3.2 breakage directly + # instead of trusting the runner's newer bash to stand in for it. + run: | + /bin/bash -n tools/macos/claude-jobbed.sh + /bin/bash -n tools/macos/find-claude.sh + /bin/bash -n install.sh + echo "tools/macos/*.sh + install.sh parse clean under bash 3.2.57" + + - name: Run bats suite + run: | + chmod +x tools/macos/*.sh install.sh + bats --print-output-on-failure tests/macos/ + # ----- Windows: existing PowerShell suite (Wave A retroactive coverage) -- windows: name: pwsh (windows-latest) diff --git a/DESIGN.md b/DESIGN.md index 6a9f5d2..f27a50d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,9 +1,10 @@ # claude-code-structured-concurrency — Design Specification -> Version: 1.0.0 -> Status: shipped — 22+1 tests passing, 9ms reap latency verified on Windows 11 build 26200 +> Version: 1.2.0 +> Status: shipped — Windows STRONG (v1.0.0), Linux STRONG+MEDIUM (v1.1.0), macOS MEDIUM + cmd.exe shim (v1.2.0). 36+1 pwsh + 40 bats tests passing; 9ms reap latency verified on Windows 11 build 26200. > Author: Ronil Basu ([@ron2k1](https://github.com/ron2k1)) > Created: 2026-05-07 +> Revised: 2026-05-16 — v1.2.0 macOS MEDIUM tier + cmd.exe shim ## Naming note @@ -11,7 +12,7 @@ The skill is named after the OS-level concept (**structured concurrency**) -- th ## Problem statement -Claude Code (CC) on Windows spawns N stdio MCP child processes per session, where N grows with active plugins. Each stdio MCP is a 2-3 process chain: `cmd.exe → npx.cmd → node.exe` (or `cmd.exe → uvx → python.exe`). When CC exits ungracefully — terminal X-button close, parent crash, OS task-end — those chains are not signaled. They stay alive until the OS reboots. +Claude Code (CC) spawns N stdio MCP child processes per session, where N grows with active plugins. Each stdio MCP is a 2-3 process chain — on Windows `cmd.exe → npx.cmd → node.exe` (or `cmd.exe → uvx → python.exe`); on Linux/macOS the analogous `sh → npx → node` / `sh → uvx → python`. When CC exits ungracefully — terminal X-button close, parent crash, OS task-end — those chains are not signaled. They stay alive until the OS reboots. Cumulative effect, observed 2026-05-07: - 14 user-global MCPs + ~30 plugin MCPs ≈ 40-60 node.exe per active session @@ -23,7 +24,32 @@ Cumulative effect, observed 2026-05-07: - Killing all `node.exe` by name alone — would terminate active CC itself. The decision flow always checks `spare_classifications` first, so `claude.exe` (classified as `claude`) cannot be killed even if `node.exe` is in `kill_names`. This invariant is exercised explicitly in `tests/test-config-loader.ps1`. - Replacing CC's own subprocess discipline. Anthropic's harness can and should ship Job Objects natively; this skill is the user-side workaround until then. -- Running on macOS or Linux. Those platforms already have OS-level reapers (`prctl(PR_SET_PDEATHSIG)` + cgroups on Linux, equivalent semantics on macOS). +- A STRONG (kernel-enforced, SIGKILL-proof) guarantee on macOS. macOS has no Job Object, no `cgroup.kill`, and no `prctl(PR_SET_PDEATHSIG)` — there is no kernel primitive that atomically reaps a process subtree on ancestor death. macOS is MEDIUM by construction (process group + `trap` + a disowned out-of-process watchdog); the honest ceiling — a simultaneous `kill -9` of both wrapper and watchdog — is stated in the guarantee matrix below and pinned by `tests/macos/test-honesty.bats`. Closing that gap would need a Swift `kqueue`/`launchd` helper and is conditional on telemetry. + +> Note: an earlier draft listed "Running on macOS or Linux" as a non-goal, asserting those platforms "already have OS-level reapers ... equivalent semantics on macOS." That was wrong on both counts: Linux shipped STRONG+MEDIUM in v1.1.0 and macOS shipped MEDIUM in v1.2.0, and macOS specifically has *no* such reaper — that absence is the entire reason it tops out at MEDIUM. The only genuine remaining non-goal is the macOS STRONG gap above. + +## Cross-platform guarantee matrix + +The design goal is identical on every platform — *the OS, not application code, enforces parent-death cleanup* — but the available kernel primitive differs, so the strength of the guarantee differs. That difference is stated honestly rather than papered over; the install banner and the test suite both encode it. + +| Platform | Mechanism | Survives SIGKILL of wrapper? | Tier | +|---|---|---|---| +| Windows 10+ | Win32 Job Object + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` | Yes — kernel reaps on job-handle close, including Task Manager End-Task | **STRONG** (v1.0.0) | +| Linux ≥5.14 | `systemd-run --user --scope` + `cgroup.kill` | Yes — cgroup-level kill, kernel-enforced; an out-of-process watchdog supervises the scope so even SIGKILL of the wrapper still triggers `cgroup.kill` | **STRONG** (v1.1.0) | +| Linux <5.14 / no systemd / WSL1 | `set -m` + `trap` on EXIT/INT/TERM/HUP + `killpg` | No — a bash trap cannot fire on `kill -9` | **MEDIUM** (v1.1.0) | +| macOS | `set -m` + `trap` + disowned out-of-process watchdog | Partial — survives Force-Quit/SIGKILL of the wrapper *alone* (the watchdog outlives it and reaps the tree); does **not** survive a simultaneous SIGKILL of wrapper *and* watchdog | **MEDIUM** (v1.2.0) | + +### Why macOS is MEDIUM, not STRONG + +Windows has the Job Object; Linux ≥5.14 has `cgroup.kill`. macOS has neither, and `prctl(PR_SET_PDEATHSIG)` is Linux-only. There is no macOS syscall that says "kill this whole subtree when the ancestor dies." The MEDIUM design extracts the maximum the OS allows: + +1. `set -m` puts the spawned `claude` in its own process group, so it can be `killpg`'d without signaling the wrapper. +2. `trap 'cleanup' EXIT INT TERM HUP` handles every *catchable* exit of the wrapper. +3. A disowned watchdog subshell — in its *own* process group, so step 2's `killpg` cannot take it down — records the wrapper's PID and start time (`ps -p $pid -o lstart=`; macOS has no `/proc`) and polls. When the wrapper vanishes for *any* reason, including the un-catchable `kill -9` that defeats step 2, the watchdog runs the same `cleanup()` and reaps the tree. On graceful exit the wrapper `kill -KILL`s the watchdog (the watchdog traps catchable signals by design, so SIGTERM would not stop it). + +The watchdog is what lifts macOS from WEAK (`setpgid`+`trap` only, which dies with the wrapper on `kill -9`) to MEDIUM. The residual gap — a *simultaneous* `kill -9` of wrapper and watchdog — is unrecoverable because nothing is left alive and macOS has no kernel fallback. That exact scenario is asserted, and proven still-failing-by-design, in `tests/macos/test-honesty.bats`, so the ceiling is documented as an executable test, not just prose. + +This reuses the Linux v1.1.0 watchdog architecture: the out-of-process supervisor pattern was first built so the Linux STRONG path could survive a wrapper SIGKILL (the watchdog re-triggers `cgroup.kill`). macOS borrows the same supervisor idea but, lacking `cgroup.kill`, tops out at MEDIUM instead of STRONG. ## Architecture @@ -126,21 +152,26 @@ Tests in `tests/`: 1. `test-job-object.ps1` -- functional proof that `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` works on this Windows build. Spawns a sleeping `node`-like child, closes the job handle, asserts the child died within 2 seconds. **Verified 9ms reap latency on Windows 11 build 26200.** 2. `test-orphan-detect.ps1` -- 9 unit tests on synthetic process snapshots: orphan detection (with PID-reuse guard via `StartTime` comparison), classification, descendant tree walk. 3. `test-config-loader.ps1` -- 9 unit tests on the config schema: defaults, malformed-JSON fallback, partial-config merge, and the spare-wins-over-kill safety invariant. +4. `test-spawn-plan.ps1` -- 14 assertions on wrapper host-routing for `.cmd`/`.bat`/`.ps1` Claude shims (npm-installed Claude ships a shim, not an `.exe`) and extension-priority resolution. +5. `tests/linux/*.bats` (18) + `tests/macos/*.bats` (22) -- find-claude probe priority, pgid/watchdog cleanup, installer idempotency, and the two load-bearing *negative* tests: `tests/linux/test-cgroup-kill.bats` (Linux STRONG must survive wrapper SIGKILL via `cgroup.kill`) and `tests/macos/test-honesty.bats` (the macOS MEDIUM ceiling — simultaneous wrapper+watchdog SIGKILL leaks by design, and is asserted to still leak so it cannot silently regress). -All three suites must pass before any release tag. **22+1 tests passing as of v1.0.0.** CI on Windows runners is a v1.1 follow-up. +All suites must pass before any release tag. **36+1 PowerShell + 40 bats (18 Linux + 22 macOS) passing as of v1.2.0.** CI runs `ubuntu-latest`, `macos-14`, and `windows-latest` on every push; see `.github/workflows/test.yml`. Hosted Intel `macos-13` was dropped in v1.2.0 — GitHub is retiring hosted Intel macOS, so that leg never received a runner and ran to GitHub's hard 24h "awaiting a runner" ceiling (`timeout-minutes` bounds execution after assignment, never queue time); the scripts are architecture-neutral and bash-3.2-safe by construction, so only x86_64 *execution* is uncovered, tracked as an explicit open gap (#3). (CI on Windows runners was the v1.1 follow-up promised in the original spec; it shipped in v1.1.0 alongside the Linux port and was extended to macOS in v1.2.0.) ## Open questions (deferred) -- WSL interaction: if user runs `claude` from WSL, does the wrapper need a Linux equivalent? (Likely no -- WSL already reaps via cgroups, but verify.) +- ~~WSL interaction: if user runs `claude` from WSL, does the wrapper need a Linux equivalent?~~ Resolved in v1.1.0. WSL2 (real Linux kernel ≥5.14) takes the Linux STRONG `cgroup.kill` path; WSL1 (no real cgroup v2 / systemd) falls to the MEDIUM `set -m`+`trap` path. `CLAUDE_JOBBED_FORCE_FALLBACK=1` exercises that fallback in CI on a systemd-equipped runner. +- Intel (x86_64) macOS execution coverage: dropped from CI in v1.2.0 — GitHub is retiring hosted Intel macOS, so the `macos-13` leg never received a runner (it ran to GitHub's hard 24h "awaiting a runner" ceiling on every push, since `timeout-minutes` bounds execution after assignment, never queue time). The runtime scripts are architecture-neutral and bash-3.2-safe by construction, and the `/bin/bash -n` static-parse step still proves the 3.2 syntax constraint, so only x86_64 *execution* is uncovered. Restoring an Intel execution leg (self-hosted or paid runner) is tracked in #3. - Multi-session telemetry: should we track per-session spawn counts and report at end? (v1.1 feature.) - Plugin authors writing their own predicates: pluggable filter chain at the Layer-2 level? (v1.2 if asked.) - Windows Server / older Windows 10 builds: Job Object behavior was unreliable pre-build 17134 (January 2018). Currently documented as a hard floor; could be loosened with a runtime probe. ## Versioning -- v1.0.0 -- this release. Three tools, three test suites, config-driven predicate, four starter profiles, SessionStart hook, install script. -- v1.1.0 -- CI on Windows runners; multi-session telemetry; configurable log retention. -- v1.2.0 -- pluggable filter chain; companion macOS/Linux verifier (so cross-platform users can lint-check their config). +- v1.0.0 -- Windows. Three tools, three test suites, config-driven predicate, four starter profiles, SessionStart hook, install script. +- v1.0.2 / v1.0.3 -- installer `-ShadowClaude` (plain `claude` routes through the wrapper as a function, not a `Set-Alias`); wrapper host-routes npm-installed `.cmd`/`.ps1` Claude shims through `cmd.exe /c` / `powershell.exe -File`. +- v1.1.0 -- Linux. `tools/linux/` find-claude (9-probe) + two-tier wrapper: STRONG via `systemd-run --user --scope` + `cgroup.kill` (kernel ≥5.14) with an out-of-process watchdog supervising the scope, MEDIUM `set -m`+`trap` fallback for older kernels / no-systemd / WSL1. GitHub Actions matrix added (`ubuntu-latest` + `windows-latest`). bats suite. +- v1.2.0 -- macOS + cmd.exe (this release). `tools/macos/` find-claude (bash-3.2-safe; fnm probe also checks `~/Library/Application Support/fnm`) + MEDIUM wrapper (`set -m` + `trap` + disowned out-of-process watchdog; honest simultaneous-SIGKILL ceiling pinned by `tests/macos/test-honesty.bats`). `tools/claude-jobbed.cmd` shim so cmd.exe inherits the Windows STRONG Job Object. CI extended with the `macos-14` (Apple Silicon) leg plus a `/bin/bash -n` static-parse step against Apple's stock 3.2.57; hosted Intel `macos-13` execution was dropped (GitHub is retiring hosted Intel macOS; the leg never got a runner) and is tracked as an explicit open gap (#3). +- Deferred -- pluggable Layer-2 filter chain; multi-session spawn telemetry; configurable log retention; a Swift `kqueue`/`launchd` helper to lift macOS toward STRONG (conditional on telemetry showing real demand). ## Related work diff --git a/README.md b/README.md index 8f2ad13..b72c8de 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # claude-code-structured-concurrency -> Kernel-enforced cleanup of orphaned Claude Code subprocesses. Win32 Job Object on Windows, `cgroup.kill` (Linux 5.14+) with a process-group fallback for older kernels. macOS support tracked for v1.2.0. +> Kernel-enforced cleanup of orphaned Claude Code subprocesses. Win32 Job Object on Windows, `cgroup.kill` (Linux 5.14+) with a process-group fallback for older kernels, and `setpgid` + a disowned out-of-process watchdog on macOS. cmd.exe delegates to the PowerShell wrapper. [](LICENSE) -[](#requirements) +[](#requirements) [](#requirements) -[](#tests) +[](#tests) Claude Code spawns 40-60 child processes per session (MCP servers, plugins, LSPs, hooks). They often outlive their parent. After a few days, Task Manager (Windows) or `ps -ef` (Linux) fills with `node` entries from sessions that closed hours ago, and reboot becomes the cleanup primitive. This skill wires up the same kernel mechanisms Chrome, Edge, VS Code, and `systemd-run --scope` already use to bound helper-process lifetime, so the OS reaps the tree instead. -Verified 9 ms reap latency on Windows 11 build 26200. 36 PowerShell unit assertions plus 1 functional test (Windows side) and 18 bats tests (Linux side), all passing in CI. +Verified 9 ms reap latency on Windows 11 build 26200. 36 PowerShell unit assertions plus 1 functional test (Windows side) and 40 bats tests (Linux + macOS), all passing in CI. ## Guarantee matrix @@ -18,9 +18,9 @@ Verified 9 ms reap latency on Windows 11 build 26200. 36 PowerShell unit asserti | Windows 10+ | Win32 Job Object + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` | Yes (kernel reaps on handle close, including Task Manager End-Task) | **STRONG** — shipped v1.0.0 | | Linux 5.14+ | `systemd-run --user --scope` + `cgroup.kill` | Yes (cgroup-level kill, kernel-enforced) | **STRONG** — shipped v1.1.0 | | Linux <5.14 / containers / WSL1 | bash `set -m` + `trap` on EXIT/INT/TERM/HUP + `killpg` | No (trap doesn't fire on `kill -9`) | **MEDIUM** — fallback, shipped v1.1.0 | -| macOS | `setpgid` + `trap` (planned) | No (Force-Quit of wrapper escapes cleanup) | **WEAK** — v1.2.0 milestone, with explicit honesty in install banner | +| macOS | `setpgid` + `trap` + disowned out-of-process watchdog | Yes if only the wrapper is killed (the watchdog outlives it and reaps the tree); No if wrapper and watchdog are SIGKILLed simultaneously | **MEDIUM** — shipped v1.2.0; honest ceiling stated in the install banner and pinned by `tests/macos/test-honesty.bats` | -Running on Linux <5.14? The installer prints which tier you're getting at install time, so there's no surprise. +Running on Linux <5.14, or on macOS? The installer prints which tier you're getting at install time, so there's no surprise.
@@ -44,10 +44,14 @@ Running on Linux <5.14? The installer prints which tier you're getting at instal
- bash 4+. The fallback path uses `set -m` job control and trap-on-signal cleanup.
- Optional but recommended: kernel 5.14+ (Aug 2021, in every supported distro) and `systemd-run` available, for the STRONG `cgroup.kill` path. Without these the installer drops to the MEDIUM trap-based fallback and tells you so.
-Zero external dependencies on either platform. No PowerShell modules, no Node, no Python, no `sudo`.
+**macOS side:**
+- bash — Apple's stock `/bin/bash` 3.2.57 is sufficient; the wrapper, finder, and installer are written 3.2-safe by construction. zsh and fish are also wired.
+- macOS has no `cgroup.kill` / Job-Object analog, so it is MEDIUM tier by construction (`setpgid` + `trap` + disowned out-of-process watchdog). The installer states this ceiling explicitly at install time — there is no STRONG path to opt into.
+
+Zero external dependencies on any platform. No PowerShell modules, no Node, no Python, no `sudo`.
> [!IMPORTANT]
-> **Windows: PowerShell only. cmd.exe is not supported.** The wrapper alias relies on `$PROFILE`, which is a PowerShell concept. cmd.exe has no equivalent profile mechanism, so plain `claude` typed into a cmd.exe window bypasses the wrapper and runs unprotected. Use PowerShell or Git Bash. Per-launcher details and remedies live in [`docs/FAQ.md`](docs/FAQ.md).
+> **Windows: automatic shadowing is PowerShell-only — but cmd.exe is supported.** `-ShadowClaude` redefines `claude` via `$PROFILE`, a PowerShell concept; cmd.exe has no equivalent AutoRun profile, so plain `claude` typed into a bare cmd.exe window runs unprotected. cmd.exe users are not stuck: [`tools\claude-jobbed.cmd`](tools/claude-jobbed.cmd) delegates to the PowerShell wrapper and inherits the exact same **STRONG** Job Object guarantee. Invoke it directly, or add a per-session macro with `doskey claude=C:\path\to\tools\claude-jobbed.cmd $*`. PowerShell and Git Bash get automatic shadowing. Per-launcher details and remedies live in [`docs/FAQ.md`](docs/FAQ.md).
## Install
@@ -70,7 +74,9 @@ Get-Command claude
# CommandType=Application -> NOT wrapped
```
-### Linux
+**cmd.exe:** there is no AutoRun auto-shadow, but [`tools\claude-jobbed.cmd`](tools/claude-jobbed.cmd) delegates to `claude-jobbed.ps1` and inherits the same STRONG Job Object guarantee. Run it directly, or add a per-session macro: `doskey claude=C:\path\to\tools\claude-jobbed.cmd $*`.
+
+### Linux / macOS
```bash
git clone https://github.com/ron2k1/claude-code-structured-concurrency \
@@ -80,7 +86,7 @@ cd "$HOME/.claude/skills/structured-concurrency"
./install.sh
```
-The installer detects your kernel version, prints which guarantee tier you're getting (STRONG on 5.14+, MEDIUM below), and asks for `[y/N]` confirmation. It injects an idempotent shell function block into `~/.bashrc`, `~/.zshrc`, and `~/.config/fish/config.fish` (each only if the rc file already exists), so plain `claude` routes through the wrapper.
+The same `install.sh` covers Linux and macOS. On Linux it detects your kernel version and prints which guarantee tier you're getting (STRONG on 5.14+, MEDIUM below). On macOS it prints the MEDIUM tier and the honest ceiling: the disowned watchdog reaps the tree if the wrapper alone is Force-Quit, but a *simultaneous* SIGKILL of both wrapper and watchdog is unrecoverable because macOS has no kernel job-object/cgroup primitive. Either way it asks for `[y/N]` confirmation. It injects an idempotent shell function block into `~/.bashrc`, `~/.zshrc`, and `~/.config/fish/config.fish` (each only if the rc file already exists); on macOS it also writes `~/.bash_profile`, because macOS Terminal.app runs bash as a login shell and login shells source `~/.bash_profile`, not `~/.bashrc`. Plain `claude` then routes through the wrapper.
```bash
# CI / unattended:
@@ -113,6 +119,7 @@ type claude
| [`tools/cc-procs.ps1`](tools/cc-procs.ps1) | Visibility | Read-only inventory: PID, parent, age, memory, classification, orphan flag. No kill capability. |
| [`tools/cleanup-orphans.ps1`](tools/cleanup-orphans.ps1) | Cleanup | Terminates strict-orphan subtrees per `~/.reap/config.json`. Dry-run by default. |
| [`tools/claude-jobbed.ps1`](tools/claude-jobbed.ps1) | Prevention | Win32 Job Object wrapper. Kernel terminates the entire CC tree on wrapper exit. |
+| [`tools/claude-jobbed.cmd`](tools/claude-jobbed.cmd) | Prevention | cmd.exe shim. Re-execs `claude-jobbed.ps1` via `powershell.exe -NoProfile -File` and propagates its exit code, so cmd.exe users inherit the same STRONG Job Object guarantee. |
**Linux (`tools/linux/`):**
@@ -121,6 +128,13 @@ type claude
| [`tools/linux/find-claude.sh`](tools/linux/find-claude.sh) | Discovery | 9-probe path resolver: `command -v` → npm prefix → `/opt/homebrew/bin` → `/usr/local/bin` → nvm (highest version) → fnm → asdf → volta → yarn global. Returns 127 if nothing matches. |
| [`tools/linux/claude-jobbed.sh`](tools/linux/claude-jobbed.sh) | Prevention | Two-tier wrapper. STRONG: spawns `claude` inside `systemd-run --user --scope`, so `cgroup.kill` reaps the tree even on `kill -9` of the wrapper. FALLBACK: bash `set -m` + trap-on-EXIT/INT/TERM/HUP that issues `killpg -TERM` then `-KILL`. `CLAUDE_JOBBED_FORCE_FALLBACK=1` exercises the fallback path on systemd-equipped boxes (used in CI). |
+**macOS (`tools/macos/`):**
+
+| Tool | Layer | What it does |
+|------|-------|--------------|
+| [`tools/macos/find-claude.sh`](tools/macos/find-claude.sh) | Discovery | Same 9-probe order as Linux, written bash-3.2-safe (Apple ships frozen bash 3.2.57 at `/bin/bash`). The fnm probe additionally checks `~/Library/Application Support/fnm`, fnm's default `FNM_DIR` on macOS. |
+| [`tools/macos/claude-jobbed.sh`](tools/macos/claude-jobbed.sh) | Prevention | MEDIUM-tier wrapper. `set -m` gives the child its own process group; a `trap` reaps it on graceful exit or catchable signal; a disowned out-of-process watchdog (its own pgid, parent-identity check via `ps -p $pid -o lstart=` since macOS has no `/proc`) reaps the tree even when the wrapper alone is Force-Quit. Simultaneous SIGKILL of wrapper and watchdog is the honest ceiling — macOS has no kernel job-object/cgroup primitive. |
+
```powershell
# Windows
.\tools\cc-procs.ps1 # see what's running
@@ -136,7 +150,7 @@ type claude # confirm: should print "claude is a function"
Inside a Claude Code session on Windows, the same flow is `/structured-concurrency [kill|install|verify]`.
-A SessionStart hook (`hooks/reap-on-start.ps1`) runs the cleanup in strict-orphan-only mode on every CC start (Windows), so leftovers from un-wrapped or crashed sessions are reaped automatically. The Linux wrapper does not need a periodic reaper — `cgroup.kill` runs at wrapper exit, not on a schedule.
+A SessionStart hook (`hooks/reap-on-start.ps1`) runs the cleanup in strict-orphan-only mode on every CC start (Windows), so leftovers from un-wrapped or crashed sessions are reaped automatically. The Linux and macOS wrappers do not need a periodic reaper — cleanup runs at wrapper exit (Linux: `cgroup.kill`; macOS: the watchdog), not on a schedule.
## Auditable
@@ -233,11 +247,21 @@ Fallback path (kernels <5.14, containers without systemd, WSL1):
There is no application code path that can leak on the strong paths. This is structured concurrency enforced by the operating system, the way Nathaniel J. Smith [originally framed](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/) the problem class. Application discipline is what produced the leaks in the first place.
+### macOS
+
+macOS has no `cgroup.kill` and no Win32 Job Object. `prctl(PR_SET_PDEATHSIG)` is Linux-only; there is no kernel primitive that atomically reaps a process subtree when an ancestor dies. The MEDIUM tier closes as much of that gap as the OS permits:
+
+1. `set -m` so the spawned `claude` gets its own process group.
+2. `trap 'cleanup' EXIT INT TERM HUP` — graceful exit or any catchable signal `killpg`s the child group.
+3. A disowned watchdog subshell with its *own* process group records the wrapper's PID and start time (`ps -p "$pid" -o lstart=` — there is no `/proc` on macOS to read), then polls. When the wrapper disappears — including a Force-Quit / `kill -9` that the wrapper's own trap can never catch — the watchdog runs the same `cleanup()` and reaps the tree. On graceful exit the wrapper `kill -KILL`s the watchdog, since the watchdog traps catchable signals and would otherwise outlive its purpose.
+
+The honest ceiling: a *simultaneous* `kill -9` of both the wrapper and the watchdog leaves the child group unreaped, because nothing is left alive to do it and macOS offers no kernel fallback. That exact scenario is asserted — and proven still-failing-by-design — in `tests/macos/test-honesty.bats`, so the limit is documented in executable form, not just prose.
+
Full architecture: [`DESIGN.md`](DESIGN.md).
## Tests
-36 PowerShell unit assertions plus 1 functional test (Windows side) and 18 bats tests (Linux side), all passing in the GitHub Actions matrix.
+36 PowerShell unit assertions plus 1 functional test (Windows side), 18 bats tests (Linux), and 22 bats tests (macOS), all passing in the GitHub Actions matrix.
**Windows (`tests/test-*.ps1`):**
@@ -252,11 +276,20 @@ Full architecture: [`DESIGN.md`](DESIGN.md).
| Suite | Coverage |
|-------|----------|
-| `tests/linux/test-find-claude.bats` | Probe priority: PATH > npm prefix > nvm (highest version) > fnm > yarn global. Sandboxed PATH+HOME so probes only hit fixtures. The 127-when-not-found contract and source-mode contract. 8 tests. (Probes 3-4 — Homebrew paths — honestly skip on Linux runners; they land in v1.2.0 macOS CI.) |
+| `tests/linux/test-find-claude.bats` | Probe priority: PATH > npm prefix > nvm (highest version) > fnm > yarn global. Sandboxed PATH+HOME so probes only hit fixtures. The 127-when-not-found contract and source-mode contract. 8 tests. (Probes 3-4 — Homebrew paths — honestly skip on Linux runners; they are exercised by the macOS suite on the macos-14 leg.) |
| `tests/linux/test-pgid-cleanup.bats` | Forces fallback path via `CLAUDE_JOBBED_FORCE_FALLBACK=1`. Spawns a fake claude that backgrounds a grandchild, kills the wrapper with SIGTERM, polls (3s budget) for grandchild death. Plus exit-code propagation and verbatim arg forwarding. 3 tests. |
| `tests/linux/test-cgroup-kill.bats` | Load-bearing parity test against Win32 `KILL_ON_JOB_CLOSE`. Lets the wrapper take the strong (`systemd-run --scope`) path, then SIGKILLs the wrapper. Bash traps don't fire on `-9`, so only kernel-enforced cleanup via `cgroup.kill` can satisfy this. Skips with a printed reason if `systemd-run` is missing, `--user` systemd is inactive, or kernel < 5.14. 1 test. |
| `tests/linux/test-installer.bats` | Sandboxes HOME; covers `--yes` inject, idempotent re-run preserving marker count, `--force` overwrite (count stays at 2 not 4), `--uninstall` clean removal, `--uninstall` no-op, and unknown-flag exit code 2. 6 tests. |
+**macOS (`tests/macos/test-*.bats`):**
+
+| Suite | Coverage |
+|-------|----------|
+| `tests/macos/test-find-claude.bats` | Same probe-priority contract as Linux, plus the macOS-specific fnm `~/Library/Application Support/fnm` probe. Sandboxed PATH+HOME. 10 tests. |
+| `tests/macos/test-pgid-cleanup.bats` | Spawns a fake claude that backgrounds a grandchild, kills the wrapper with SIGTERM, polls for grandchild death. Plus exit-code propagation and verbatim arg forwarding. Single macOS path (no FORCE_FALLBACK split). 3 tests. |
+| `tests/macos/test-honesty.bats` | The load-bearing negative test that pins the honest MEDIUM ceiling. CASE 1: SIGKILL the wrapper alone — the disowned watchdog must outlive it and reap the grandchild (proves MEDIUM). CASE 2: SIGKILL wrapper and watchdog simultaneously — the grandchild survives, the documented un-closeable ceiling on a kernel with no job-object primitive. 2 tests. |
+| `tests/macos/test-installer.bats` | Sandboxes HOME; covers `--yes` inject into `~/.zshrc` + `~/.bashrc` + `~/.bash_profile`, the honest-ceiling install banner text, idempotent re-run, `--force` no-dup, `--uninstall` clean removal across all three rc files, `--uninstall` no-op, and unknown-flag exit code 2. 7 tests. |
+
```powershell
# Windows
.\tests\test-job-object.ps1
@@ -268,9 +301,12 @@ Full architecture: [`DESIGN.md`](DESIGN.md).
```bash
# Linux (requires bats-core: apt install bats)
bats --print-output-on-failure tests/linux/
+
+# macOS (requires bats-core: brew install bats-core)
+bats --print-output-on-failure tests/macos/
```
-CI runs both halves on every push (`.github/workflows/test.yml`): `ubuntu-latest` for the bats suite (with `loginctl enable-linger` so `systemctl --user` is active and the cgroup-kill test exercises the strong path instead of skipping), and `windows-latest` for the PowerShell suite. If a suite fails on your Windows build, file an issue with the output of `winver`. If it fails on a Linux distro, include `uname -r` and `systemctl --user is-active default.target`.
+CI runs all three platforms on every push (`.github/workflows/test.yml`): `ubuntu-latest` for the Linux bats suite (with `loginctl enable-linger` so `systemctl --user` is active and the cgroup-kill test exercises the strong path instead of skipping), `macos-14` (Apple Silicon) for the macOS bats suite, and `windows-latest` for the PowerShell suite. Hosted Intel macOS (`macos-13`) is intentionally not a CI leg — GitHub is retiring hosted Intel macOS, so that leg never received a runner and ran to GitHub's hard 24h "awaiting a runner" ceiling on every push; the runtime scripts are architecture-neutral and bash-3.2-safe by construction, so only x86_64 *execution* is uncovered, tracked as an explicit open gap in [#3](https://github.com/ron2k1/claude-code-structured-concurrency/issues/3). The macOS leg includes a probe step that records `sw_vers` / `uname -m` and which bash actually runs the suite: GitHub's macOS runners put a modern Homebrew bash ahead of Apple's frozen `/bin/bash` 3.2.57 on PATH, so a dedicated `/bin/bash -n` static-parse step proves the runtime scripts parse under real Apple stock bash even though bats itself runs under the newer bash — the gap is documented, not hidden. If a suite fails on your Windows build, file an issue with the output of `winver`. If it fails on a Linux distro, include `uname -r` and `systemctl --user is-active default.target`; on macOS include `sw_vers` and `uname -m`.
## Safety guarantees
@@ -282,9 +318,9 @@ CI runs both halves on every push (`.github/workflows/test.yml`): `ubuntu-latest
## What this does not do
- Replace Claude Code's own subprocess discipline. Anthropic can ship Job Objects + cgroups natively. This is the user-side workaround until they do.
-- Help on macOS yet. macOS has neither `cgroup.kill` nor a Job-Object equivalent — `prctl(PR_SET_PDEATHSIG)` is Linux-only, and `setpgid` + `atexit` cleanup does *not* survive `kill -9` of the wrapper (Activity Monitor "Force Quit"). The v1.2.0 milestone ships a process-group wrapper with explicit honesty about this gap in the install banner; the v1.3.0+ Swift `kqueue` helper is conditional on telemetry.
+- Fully match the Windows/Linux STRONG tier on macOS. macOS has neither `cgroup.kill` nor a Job-Object equivalent — `prctl(PR_SET_PDEATHSIG)` is Linux-only. v1.2.0 ships the MEDIUM tier: `setpgid` + `trap` + a disowned out-of-process watchdog, which *does* survive Force-Quit of the wrapper alone (the watchdog outlives it and reaps the tree). The honest ceiling — a *simultaneous* `kill -9` of both wrapper and watchdog — is unrecoverable, and that exact ceiling is pinned by `tests/macos/test-honesty.bats` so it cannot silently regress. A future Swift `kqueue`/`launchd` helper that could close the gap is conditional on telemetry.
- Wrap a `claude` that's already running. Restart your shell after install (Windows or Linux).
-- Cover launchers that bypass shell rc files: on Windows that's `cmd.exe`, `Win+R`, desktop shortcuts to `claude.exe`, Task Scheduler entries, VS Code's terminal until reloaded; on Linux that's anything launched with `env -i` or by a service manager that strips `~/.bashrc`. See [`docs/FAQ.md`](docs/FAQ.md) for per-path remedies (Windows side; Linux equivalents land with the next docs pass).
+- Auto-shadow launchers that bypass shell rc files: `Win+R`, desktop shortcuts to `claude.exe`, Task Scheduler entries, VS Code's terminal until reloaded; on Linux/macOS that's anything launched with `env -i` or by a service manager that strips the rc files. Bare cmd.exe has no AutoRun auto-shadow either — though [`tools\claude-jobbed.cmd`](tools/claude-jobbed.cmd) gives cmd.exe users the full STRONG guarantee when invoked or aliased explicitly. See [`docs/FAQ.md`](docs/FAQ.md) for per-path remedies.
## License
diff --git a/SKILL.md b/SKILL.md
index fbbeeae..218b8d3 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,30 +1,36 @@
---
name: structured-concurrency
-description: Use when the user types `/structured-concurrency`, mentions orphan or zombie processes, MCP process leaks, node.exe accumulation, Claude Code subprocess bloat, "task manager full of node processes", subprocess leak, structured concurrency on Windows, kill-on-close, Win32 Job Objects, or wants to reap leftover children from prior Claude Code sessions. Also triggers on questions about why memory fills up after multiple CC sessions, before planning heavy multi-session work that needs subprocess hygiene, or when troubleshooting "claude code spawned too many processes".
+description: Use when the user types `/structured-concurrency`, mentions orphan or zombie processes, MCP process leaks, node.exe (or `node`) accumulation, Claude Code subprocess bloat, "task manager full of node processes", "activity monitor full of node", subprocess leak, structured concurrency, kill-on-close, Win32 Job Objects, `cgroup.kill`, `setpgid`/`PR_SET_PDEATHSIG`, or wants to reap leftover children from prior Claude Code sessions. Cross-platform -- this skill covers Windows (PowerShell AND cmd.exe), macOS, and Linux, so make sure to use it for subprocess-hygiene questions on ANY of those platforms, including a Mac user asking how to stop Claude Code leaking processes, "orphaned node processes on my mac", launching `claude` so its children die with it, or wrapping CC in a job / cgroup / process group. Also triggers on questions about why memory fills up after multiple CC sessions, before planning heavy multi-session work that needs subprocess hygiene, or when troubleshooting "claude code spawned too many processes".
---
-# /structured-concurrency -- Claude Code Subprocess Lifetime Manager (Windows)
+# /structured-concurrency -- Claude Code Subprocess Lifetime Manager (Windows, macOS, Linux)
## Overview
-Claude Code spawns dozens of child processes per session (MCP servers, plugin runtimes, LSPs, hook scripts). On graceful exit they should die. They often don't -- Windows has no `init` reaper, and stdio MCPs leak as `cmd.exe -> npx.cmd -> node.exe` chains. Across sessions this compounds into multi-gigabyte zombies that only a reboot clears.
+Claude Code spawns dozens of child processes per session (MCP servers, plugin runtimes, LSPs, hook scripts). On graceful exit they should die. They often don't -- neither Windows nor macOS reaps a process subtree when the ancestor dies, and stdio MCPs leak as `cmd.exe -> npx.cmd -> node.exe` on Windows or `sh -> npx -> node` on macOS/Linux. Across sessions this compounds into multi-gigabyte zombies that only a reboot clears.
-This is the same problem Nathaniel J. Smith framed as "structured concurrency" in 2018: child task lifetimes should be bounded by their parent, enforced by the runtime, not by application discipline. Trio, Kotlin coroutines, and Swift Concurrency solved it at the language level. Linux has the OS primitive (`prctl(PR_SET_PDEATHSIG)` + cgroups). Windows has the OS primitive too -- Win32 Job Objects with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` -- but no language runtime wires it up for Node.js child processes. This skill does.
+This is the same problem Nathaniel J. Smith framed as "structured concurrency" in 2018: child task lifetimes should be bounded by their parent, enforced by the OS, not by application discipline. Trio, Kotlin coroutines, and Swift Concurrency solved it at the language level. The OS-level primitive differs by platform, so the strength of the guarantee differs too -- and that difference is stated honestly rather than papered over:
+
+- **Windows** -- Win32 Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Kernel-enforced, survives SIGKILL / Task-Manager End-Task: **STRONG**.
+- **Linux >= 5.14** -- `cgroup.kill` via `systemd-run --user --scope`, with an out-of-process watchdog supervising the scope: **STRONG**. Older kernels / no-systemd / WSL1 fall back to `setpgid` + `trap`: **MEDIUM** (a bash trap cannot fire on `kill -9`).
+- **macOS** -- has NONE of those primitives (no Job Object, no `cgroup.kill`, no `prctl(PR_SET_PDEATHSIG)`), so it uses `setpgid` + `trap` + a disowned out-of-process watchdog: **MEDIUM**. It survives Force-Quit / SIGKILL of the wrapper *alone* (the watchdog outlives it and reaps the tree) but NOT a simultaneous SIGKILL of wrapper *and* watchdog. That honest ceiling is pinned by `tests/macos/test-honesty.bats` so it cannot silently regress.
+
+No language runtime wires any of this up for Node.js child processes. This skill does, on all three platforms.
Three layers of fix, each composable:
-1. **Diagnostic** (`cc-procs.ps1`) -- read-only inventory of every CC-related process, parent chain, age, classification, orphan status
-2. **Cleanup** (`cleanup-orphans.ps1`) -- terminate strict orphans (and their descendants) per `~/.reap/config.json`. Dry-run default; safe-no-op when no config exists. See `docs/CONFIGURATION.md`.
-3. **Prevention** (`claude-jobbed.ps1`) -- Win32 Job Object wrapper. The kernel terminates the entire CC process tree when the wrapper exits, even on crash, BSOD, or X-button close.
+1. **Diagnostic** (`cc-procs.ps1`, Windows) -- read-only inventory of every CC-related process, parent chain, age, classification, orphan status
+2. **Cleanup** (`cleanup-orphans.ps1`, Windows) -- terminate strict orphans (and their descendants) per `~/.reap/config.json`. Dry-run default; safe-no-op when no config exists. See `docs/CONFIGURATION.md`.
+3. **Prevention** -- the OS reaps the entire CC tree when the wrapper exits, even on crash, BSOD, Force-Quit, or X-button close. This is the cross-platform layer: `tools/claude-jobbed.ps1` (Windows Job Object), `tools/claude-jobbed.cmd` (cmd.exe shim that re-execs the PowerShell wrapper, inheriting the same STRONG Job Object), `tools/linux/claude-jobbed.sh` (cgroup.kill scope, or setpgid+trap fallback), `tools/macos/claude-jobbed.sh` (setpgid+trap+disowned watchdog).
## When to Use
-Trigger on:
-- Task Manager shows many `Node.js JavaScript Runtime` or `Windows Command Processor` entries
+Trigger on (any platform):
+- Windows Task Manager shows many `Node.js JavaScript Runtime` / `Windows Command Processor` entries; OR macOS Activity Monitor / `ps aux | grep node` shows piled-up `node`; OR Linux `ps`/`htop` shows orphaned `node` reparented to PID 1
- Memory pressure after several CC sessions
-- "Why are there 80 node.exe processes?"
+- "Why are there 80 node.exe processes?" / "why is my Mac full of `node` processes?"
- Before any heavy multi-session work where leak compounding would hurt
-- After a CC crash, hard close, or terminal X-button kill
+- After a CC crash, hard close, Force-Quit, or terminal X-button kill
Do **not** trigger on:
- High CPU from a *single* legit MCP (that's a different problem -- kill that MCP, not orphans)
@@ -40,6 +46,12 @@ Do **not** trigger on:
| `/structured-concurrency verify` | Run `tests/` -- proves Job Object kill-on-close works on this machine |
| `/structured-concurrency wrap