Skip to content

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 WindowsLauncherwhere 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)

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.


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 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:

Terminal window
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.


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 runs

cmd.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.

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 --> C

The 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.

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(); // false
path.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.

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 form

The 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.