Cross-platform
Platform differences are subclasses, not branches. A factory picks once, and no call site asks process.platform again. This page explains what each subclass is for.
| Concern | win32 | macOS / Linux |
|---|---|---|
| Resolving a command | WindowsLauncher — where plus PATHEXT |
PosixLauncher (which) |
| Killing a tree | WindowsProcessTree (taskkill /F /T) |
PosixProcessTree (group signal) |
| Containing detached descendants | JobSpawner (Job Object) |
Not needed — detached plus a group signal covers it |
| Running a short command | cmd.exe as the executed file, argv as elements | execFile directly |
| Reading a command line | Get-CimInstance Win32_Process |
ps -o args= |
| Detecting a dead parent | Signal-0 probe (ppid is frozen) | ppid comparison (reparenting) |
The terminal a CLI thinks it has
Section titled “The terminal a CLI thinks it has”Applies to: all platforms.
This one is not a platform difference, but it belongs beside them because it has the same shape: an environment we did not choose corrupting output we have to parse.
A CLI that believes it is attached to a capable terminal decorates its output with ANSI escape sequences. Those land on the same stream as the protocol, so a JSON line arrives as \x1b[1 q{"type":"system"… and fails to parse — the exact breakage other tools have hit when launching agents through a login shell.
ClaudeAdapter sets a floor under every spawn:
| Variable | Value | Why |
|---|---|---|
TERM |
dumb |
The load-bearing one. Tells the CLI there is no capable terminal to decorate for. |
CI |
true |
Asks for non-interactive behaviour — nothing here is going to answer a prompt drawn on a terminal. |
CLAUDECODE |
undefined |
Cleared, not set. It marks “you are running inside Claude Code”; inheriting it from a parent that was would make the CLI behave as a nested run when it is not one. |
The caller’s own env is merged over this floor, because a caller may know something we do not — and the credential strip is applied last, over both.
The PATH a GUI process should have had
Section titled “The PATH a GUI process should have had”Applies to: all platforms.
A backend spawned by an IDE inherits a minimal PATH — the one the desktop session had, not the one the user’s shell builds. nvm, volta, homebrew and friends all live in directories added by shell init, so a CLI the user has clearly installed is simply invisible to a bare which.
The symptom is a spawn failing with ENOENT for a CLI the user can run perfectly well in their terminal.
AugmentedPath prepends the well-known locations, filtered to those that exist:
import { AugmentedPath } from 'activecli';
const env = AugmentedPath.shared.toEnv();// env.PATH now leads with ~/.claude/local, ~/.local/bin, /opt/homebrew/bin, …Every spawn in this library goes through it — sessions, CommandRunner, and the sign-in flow alike — so a CLI found by one is found by all of them.
The single most important entry is ~/.claude/local. That is where claude migrate-installer puts the binary, and the official installer points the user’s shell alias there — which a GUI-spawned backend never reads. Without this entry, a very common install spawns ENOENT.
nvm cannot be found by looking
Section titled “nvm cannot be found by looking”nvm does not install to a fixed location. It switches versions by rewriting PATH in the shell, which a GUI-spawned process never sees. The only reliable way to learn where the current version lives is to ask nvm itself:
bash -c "source '$NVM_DIR/nvm.sh' --no-use 2>/dev/null && nvm which current"NvmProbe does exactly that, with a 3 second timeout, and returns the directory of the node binary it names. Because it costs a subshell, AugmentedPath caches the result and AugmentedPath.shared exists so the cost is paid once per process rather than once per adapter.
The NVM_DIR value comes from the environment and is interpolated into a bash command, so it is treated as untrusted input: the path is resolved, the script’s existence is checked, and single quotes are escaped.
Windows is not one environment
Section titled “Windows is not one environment”The rest of this page concerns win32. Each section is a distinct failure, and they compound.
PATHEXT: where names a file that never runs
Section titled “PATHEXT: where names a file that never runs”Symptom: the resolved path points at a file that cmd.exe would never execute, so anything derived from it — install-method detection, update targets — drifts from the binary actually spawned.
where claude lists every match in PATH order, including the extension-less shell script npm ships beside claude.cmd:
C:\Users\me\AppData\Roaming\npm\claude <- MSYS/bash wrapper. cmd.exe never runs this.C:\Users\me\AppData\Roaming\npm\claude.cmd <- what actually runscmd.exe resolves a bare claude through PATHEXT (.COM;.EXE;.BAT;.CMD;…), so the first line is the wrong answer. WindowsLauncher.pick() takes the first line whose extension is in PATHEXT. where already prints matches in PATH-directory order, so that line belongs to the directory cmd.exe reaches first.
new WindowsLauncher().pick(whereOutput);// 'C:\Users\me\AppData\Roaming\npm\claude.cmd'When PATHEXT is unset, the cmd.exe default .COM;.EXE;.BAT;.CMD is used. When no line carries a PATHEXT extension, the first line is returned — a bare extension-less executable is still better than nothing.
shell: true is not a fix, it is an injection surface
Section titled “shell: true is not a fix, it is an injection surface”Symptom: a launcher under C:\Program Files\ tears in half, and arguments containing &, |, < or > change meaning.
The CLIs we drive ship as launcher wrappers (.cmd, .ps1), and execFile cannot run a .cmd without a shell — it fails with ENOENT. The obvious fix is shell: true, and it is wrong: that hands cmd.exe the whole command line as a string, which it then splits on spaces and expands metacharacters in.
The correct construction spawns cmd.exe as the executed file — a .exe, which also keeps Node’s batch-file caret hardening from firing — with the real launcher and its arguments as separate argv elements:
new WindowsCommandLine().toArgv('C:\\Program Files\\nodejs\\npm.cmd', ['view', 'pkg']);// ['/d', '/s', '/c', 'C:\Program Files\nodejs\npm.cmd', 'view', 'pkg']Node then double-quotes each element, which fixes the spaces. CommandRunner also sets shell: false (Node spawns the file directly rather than nesting another shell) and windowsVerbatimArguments: false (verbatim mode would pass arguments raw).
| Flag | Purpose |
|---|---|
/d |
Skips AutoRun |
/s |
Keeps quoting predictable |
/c |
Runs and exits |
shellPath() reads ComSpec so a host that relocated cmd.exe is honoured.
Quoting does not protect &, and CI proved it
Section titled “Quoting does not protect &, and CI proved it”The intuitive belief — that Node’s CommandLineToArgvW quoting makes & | < > literal — is wrong, and it was written into this library’s comments before a Windows runner tested it. Measured:
| How the argument travelled | What the child received |
|---|---|
| Directly, no cmd.exe | a&b|c<d>e — intact |
| Through cmd.exe, standard quoting | a, plus 'b' is not recognized as an internal or external command |
| Through cmd.exe, caret-escaped | a&b|c<d>e — intact |
cmd.exe scans for its command separators before argument parsing begins, and does so inside quotes too — it ends the command at the & and tries to run the rest. So toArgv() caret-escapes them:
new WindowsCommandLine().escape('a&b|c<d>e'); // 'a^&b^|c^<d^>e'The payload this protects is mcp add-json, whose JSON is full of these characters. Without the escaping, a Windows user’s MCP configuration would have been silently truncated on write — the kind of defect a green macOS suite implies does not exist.
%VAR% expands inside quotes
Section titled “%VAR% expands inside quotes”Symptom: an argument reaches the launcher altered — or emptied, when the named variable does not exist — with nothing anywhere reporting it.
This is the one thing Node’s quoting cannot protect. cmd.exe expands %VAR% even inside the double quotes Node wraps each argument in.
There is no safe escaping, so CommandRunner refuses:
await new CommandRunner().run('npm', ['view', '%API_KEY%']);// throws: "Cannot run this command on Windows: argument #3 contains a '%' character,// which cmd.exe expands as an environment variable (even inside quotes)// and would corrupt the value. Remove the '%' from that value and try again."Failing loudly is the only honest option: running a command whose arguments were silently rewritten is worse than not running it. The error names the position but never echoes the value, because an argument may carry a token or a key.
Carets do not help here either: cmd.exe expands %VAR% regardless of them.
Job Objects: the orphans taskkill /T cannot see
Section titled “Job Objects: the orphans taskkill /T cannot see”Symptom: after stopping a session, worker processes keep running — and keep billing — invisible to any later host.
win32 has no process groups. taskkill /F /T walks the live parent-child tree, but git-bash (MSYS) launches its workers under a fork helper that exits immediately, detaching the workers from the cmd → cli → bash tree and often reparenting them to ppid 1. Windows never reparents to a reaper, so those workers survive every tree-kill as headless orphans.
A Job Object is the kernel’s equivalent of a process group: descendants stay in the job no matter how PPIDs are rewritten.
graph TD Host["Host process"] --> PS["powershell wrapper<br>holds the job handle"] subgraph Job["Windows Job Object with KILL_ON_JOB_CLOSE"] C["cmd.exe"] --> CLI["claude"] CLI --> Bash["git-bash"] Bash -.->|"fork helper exits, worker detaches"| W["worker with ppid 1"] end PS --> CThe wrapper (win-job-wrapper.ps1) creates the job with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, launches the CLI inside it, then holds the job handle for the CLI’s lifetime. When the host kills the wrapper — or dies itself — the last handle closes and the kernel tears down the entire tree, the detached workers included.
Three contract details matter:
| Detail | Why |
|---|---|
The command line travels in ACTIVECLI_JOB_CMDLINE |
Keeps it out of the wrapper’s own argv, so nothing re-quotes it |
argv[0] is an opaque tag the wrapper ignores |
Carried only so a host’s process registry can find and kill this wrapper by that tag |
| The wrapper must be silent on stdout | The host reads the child’s stdout as the CLI’s stream protocol; only genuine errors go to stderr |
Every failure inside the wrapper is non-fatal — if job creation or assignment fails, the CLI is still launched, degrading to the pre-job behaviour. A missing job only reopens the orphan gap; it never blocks the user.
Process trees, side by side
Section titled “Process trees, side by side”| Aspect | POSIX | win32 |
|---|---|---|
| Mechanism | process.kill(-pid, signal) |
taskkill /F /T /PID <pid> |
| Requires | The CLI spawned detached, so it leads a group |
Nothing; walks the live tree |
| Reaches | Every descendant, regardless of reparenting | Only what is still attached to the tree |
| Fallback | proc.kill() on ESRCH — a non-detached child is the whole tree we know of |
proc.kill() when taskkill is missing |
| Signals honoured | Yes | No — signals are a POSIX notion |
Both are guarded by AbstractProcessTree.kill(), which refuses a process that has already been reaped. Neither mechanism is liveness-aware, and a reused pid would mean tearing down an unrelated tree.
WSL: the path that breaks spawning from both sides
Section titled “WSL: the path that breaks spawning from both sides”Symptom: either UNC paths are not supported, or an ENOENT that looks like a missing binary but is really a missing directory.
An IDE running on Windows hands a project root as a WSL UNC path — \\wsl.localhost\Ubuntu\home\user\proj, or the legacy \\wsl$\NixOS\.... That path fails as a spawn cwd in both directions:
| Where the host runs | What happens |
|---|---|
| Windows-native | cmd.exe refuses a UNC cwd outright (UNC paths are not supported), and the agent then picks the PowerShell tool instead of bash |
| Inside the distro — a Linux host, which is where an IDE-launched backend runs | The IDE still hands the project root in Windows form. That location does not exist inside the distro, so the spawn fails with ENOENT — reporting a missing cwd, though it reads like a missing binary |
Both are fixed by handing the child toWslPath() instead, and isSpawnableAsCwd() returning false is what makes a caller reach for it.
const path = new PathParser().parse('\\\\wsl.localhost\\Ubuntu\\home\\user\\proj');
path.isSpawnableAsCwd(); // falsepath.toWslPath(); // '/home/user/proj'ClaudeAdapter acts on that answer by leaving the cwd off the spawn entirely rather than passing something that would fail.
Two parsing rules that are easy to get wrong
Section titled “Two parsing rules that are easy to get wrong”Not every UNC path is a WSL path: \\server\share\file parses to null.
Windows drive paths inside a distro
Section titled “Windows drive paths inside a distro”A Linux child cannot use C:\Users\foo. Inside a WSL distro the Windows drives are mounted under /mnt/<letter>:
parser.parse('C:\\Users\\foo').toWslPath(); // '/mnt/c/Users/foo'parser.parse('D:\\Projects').toWslPath(); // '/mnt/d/Projects'parser.parse('C:\\').toWslPath(); // '/mnt/c'parser.parse('C:Users\\foo').toWslPath(); // '/mnt/c/Users/foo' — separator-less formThe drive letter is lower-cased because the mount point is /mnt/c, not /mnt/C. The trailing slash is stripped because /mnt/c/ reads as a different location than the mount point itself.
Where to go next
Section titled “Where to go next”- Orphan recovery — what survives a hard kill, and what does not.
- Reference: paths — the path classes in full.