Auto-updater installs into the PATH-resolved npm prefix, not its own — clobbers a different install
Version: 2.1.220 → 2.1.222 (mechanism is version-independent)
Platform: darwin-arm64, macOS 15 (Darwin 25.5.0)
Install method: npm global (two prefixes present: nvm-managed Node, and Homebrew-managed Node)
Summary
When two npm-global installs of @anthropic-ai/claude-code exist under different Node
prefixes, claude update (and the background updater) decides the install target fromnpm -g config get prefix — i.e. from whichever npm is first on PATH — rather than
from the location of the binary that is actually running. It then runsnpm install -g @anthropic-ai/claude-code@<ver> with no --prefix.
Result: running the claude binary in prefix A upgrades the install in prefix B.
Prefix A is never updated, so it believes it is outdated forever and re-triggers the same
cross-prefix install on every run.
Reproduction
Two npm-global installs:
- Prefix A (Homebrew Node): claude 2.1.220 — earlier on disk, later in PATH
- Prefix B (nvm Node): claude 2.1.222 — its
npmis first on PATH
Run the prefix-A binary:
$ /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe update
Current version: 2.1.220
Checking for updates to latest version...
New version available: 2.1.222 (current: 2.1.220)
Installing update...
Using global installation update method...
Successfully updated from 2.1.220 to version 2.1.222
Observed npm invocations (captured with an npm shim on PATH):
npm -g config get prefix # -> prefix B (PATH-resolved npm)
npm view @anthropic-ai/claude-code@latest version --prefer-online
npm install -g @anthropic-ai/claude-code@2.1.222 # <-- no --prefix; lands in prefix B
Expected: prefix A is upgraded to 2.1.222, or the mismatch is detected and reported.
Actual: prefix B is reinstalled; prefix A stays at 2.1.220 and reports success.
Control: with the shim's prefix set to A, the install correctly targets A — confirming the
target is taken from the PATH-resolved npm prefix, not the running binary's own path.
Secondary issue — misreported "currently running" install
While running the prefix-A binary, claude update printed:
Warning: Multiple installations found
- npm-global at <PREFIX-B>/bin/claude (currently running)
The running binary was prefix A. "currently running" appears to be attributed via the npm
prefix rather than the actual executable path. (claude doctor reports the path correctly,
so the two code paths disagree.)
Why this is worse than it looks: interaction with npm 12
npm 12 blocks package lifecycle scripts unless allowlisted (allowScripts). claude-code
relies on a postinstall (node install.cjs) to replace the ~500-byte bin/claude.exe
placeholder with the real native binary. Under npm 12 without an allowlist entry:
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts @anthropic-ai/claude-code@2.1.222 (postinstall: node install.cjs)
…so the install completes "successfully" (exit 0) but leaves the placeholder in place.
Every subsequent claude invocation then fails with:
Error: claude native binary not installed.
Combined with the cross-prefix bug, this produces a self-reinflicting loop: the stale
prefix-A binary keeps "updating", each time overwriting prefix B's working binary with a
placeholder. Repairing prefix B by hand is undone on the next prefix-A run.
The daemon then compounds it — it watches the binary's mtime and self-restarts into the
broken successor:
[supervisor] binary at <PREFIX-B>/.../bin/claude.exe changed (mtime changed) — self-restarting for upgrade
[supervisor] upgrade self-respawn spawned but never became reachable within 45s ...
successor stderr: Error: claude native binary not installed.
npm 11.17.0 warns about the same package but still runs the script ("not yet covered by
allowScripts"), so this only bites once npm reaches 12.
Suggested fixes
- Pass an explicit
--prefixderived from the running binary's own location, or refuse
to update when the PATH-resolved npm prefix does not contain the running binary.
- Verify after install that the binary at the target path is executable (not the
placeholder) and surface a clear error if the postinstall was blocked.
- Detect npm ≥12 and either include
--allow-scripts=@anthropic-ai/claude-codein the
install invocation or tell the user to allowlist it.
- Fix "currently running" attribution to use the executable path (as
doctordoes).
Environment notes
- Only the mechanism above was reproduced; the npm calls were intercepted by a shim so no
real installs occurred during testing.
DISABLE_AUTOUPDATER=1does not suppress an explicitclaude update(may be intended).
4 Comments
Second measured instance — with the loop caught running. Linux, Claude Code npm installs, npm 9.2.0 / node v22.22.1. Paths abbreviate
/home/<user>to~.Setup: two npm-layout global installs coexist:
/usr/local/lib/node_modules/@anthropic-ai/claude-code(2.1.237, root-owned) and~/.npm-global/lib/node_modules/@anthropic-ai/claude-code(2.1.245;~/.npmrcsets the npm prefix to~/.npm-global). A live session runs the /usr/local copy — itsCLAUDE_CODE_EXECPATHpoints at/usr/local/.../bin/claude.exe.autoUpdatesChannelislatest.Observed today: unattended, version-pinned installs keep landing in the config-resolved prefix, not the running install's own:
— at 11:12Z and again at 11:26Z, while
~/.npm-globalalready held 2.1.245. Each run does the full dance anyway: retires the existing 2.1.245 (silly reify mark retired→.claude-code-9bcaTW9F), unpacks 2.1.245 again (reify:unpack3.2 s), trashes the retired copy, exits 0. The stale 2.1.237 instance keeps running, so its update check presumably keeps coming up "outdated" and the reinstall repeats — a full-package churn per poll cycle that can never converge, because the decider's version and the target prefix's version belong to different installs. Relic debug logs on the same box show the poll step (npm view @anthropic-ai/claude-code@latest version --prefer-online) on a ~30-minute cadence.Attribution basis (stated explicitly, since I can't see inside the process): pinned-version argv — not a shape a human types; recurrence at machine cadence with no user command at either timestamp; auto-updates enabled. Which of the running claude processes issues the install I cannot pin from filesystem evidence alone.
Why it's worse than wasted work: every churn cycle re-enters the retire-rename window from #88091 / #12026. On this box an earlier interrupted update had already stranded a 649 MB orphan at the deterministic retire name (
.claude-code-9bcaTW9F), which bricked all manual updates with ENOTEMPTY until it was removed by hand. An unattended reinstall loop maximizes trips through exactly that window. (Practical note for anyone gathering evidence: the 11:12Z debug log had already disappeared ~30 minutes later on this churning box — npm caps debug logs at 10 — so capture logs promptly.)Resolution follow-up to my measurement comment above: the loop on this box is fixed locally, with before/after evidence. Paths abbreviate
/home/<user>to~.The local fix — converge the two installs into one, so the decider and the install target stop being different copies:
(Why launches kept landing on the stale copy at all: this box's session launcher resolves
claudewithcommand -vin a non-login SSH context whose PATH lacks the~/.npm-global/binprepend, so it found/usr/local/bin/claude= 2.1.237 even though~/.npm-globalheld 2.1.245.)Mechanism proof, measured immediately after the re-point (2026-08-25 ~12:11Z):
Every launch context now resolves the install the updater actually writes to.
The running decider churned right up to the end — expected, since the fix retargets future launches, not a live process: the stale 2.1.237 session logged a fourth same-version reinstall at 12:08:49Z (
verbose argv "install" "--global" "@anthropic-ai/claude-code@2.1.245", samereify movesto the same deterministic retire names as before, 3,756 ms of work, exit 0) and executed a fifth at 13:00:48–51Z, minutes before it was shut down — identified by its filesystem fingerprint (24 paths across the package rewritten in one pass; package.json→bin-link 3 s spread matching the logged ~3.7 s reify), because its npm debug log did not survive: the next npm invocation's log-rotation removed a file. Both landed while~/.npm-globalalready held 2.1.245.Loop-stopped proof, measured after the stale session exited:
/usr/local/bin/claudesymlink — i.e. the old muscle-memory path now lands on the converged install):/proc/<pid>/exeresolves to~/.npm-global/.../bin/claude.exe,CLAUDE_CODE_EXECPATHpoints into~/.npm-global, self-reported version 2.1.245.npm view @anthropic-ai/claude-code@latest version --prefer-online, GET 200 in 71 ms) followed by no install — exit 0 and stop (log2026-08-25T13_12_52). Same check, opposite outcome, because the decider's version and the target prefix's version finally belong to the same install./usr/local/lib/node_modules/@anthropic-aiis gone and its parent directory is empty; the/usr/local/bin/claudesymlink is intact and a fresh--versionthrough it prints 2.1.245. The box is down to one install — the config-resolved prefix the updater writes to is now the install every launch context runs.verbose argv "view" "@anthropic-ai/claude-code@latest" "version" "--prefer-online", GET 200 in 95 ms, cache revalidated,command:viewdone in 104 ms, exit 0) — and again installed nothing. A sweep over every surviving npm debug log finds zeroinstallargv lines (control: the same instrument sees that poll's ownargvline, so it demonstrably reads these files), and~/.npm-global/bin/claudemtime is still 13:00:51Z — churn #5 remains the last write to the install tree, a tamper line that also covers whatever npm's 10-file log rotation already dropped (measured three runs in a row here, each npm invocation's cleanup removed its predecessor's log — the quoted lines are what survives of the 13:12:52Z and 14:08:42Z logs; an install, unlike a log, cannot rotate away the mtime it leaves on the tree). Registry cross-check at 14:32:37Z: latest is still 2.1.245, so both no-install decisions were made against an unchanged latest — the "a version bump would legitimately install" caveat never triggered. For scale: the previous 79-minute span under the stale decider contained reinstall churns #4 and #5 (12:08:49Z, 13:00:48Z — 52 min apart); the same span under the converged install contains two polls and zero installs.Convergence also closes the exposure multiplier from #88091 / #12026: no unattended reinstall cycles means no repeated trips through the ~3.7 s retire-rename window that can strand the deterministic-name orphan and brick future updates.
Reproduced on the current release (2.1.233, Linux). With two npm-global installs under different prefixes and the second prefix's
npmfirst on PATH, runningclaude updatefrom the first prefix's binary queriesnpm -g config get prefix(which resolves to the other prefix) and then runsnpm install -g @anthropic-ai/claude-code@<version>with no prefix override — so the update lands in the other install while the one actually running stays at the old version and reports success. Re-running reproduces the same cross-prefix install every time, matching the "believes it is outdated forever" loop you describe.Thanks for the unusually thorough writeup — the shim-captured npm invocations matched exactly what we observed.
🤖 Generated with Claude Code
Thanks for the confirmation. One datapoint from the two field measurements above ("second measured instance" / resolution follow-up) worth folding into the fix design: the divergence doesn't require two npms.
This box had exactly one
npm(/usr/bin/npm, npm 9.2.0). The split came from config plus launch context alone: a user-level~/.npmrcpinningprefixto~/.npm-globalsteerednpm -g config get prefix— and therefore every update install — to~/.npm-global, while the stale copy kept running because the launching context resolved/usr/local/bin/claude. Same invariant violation — the outdated check reads the running package, the install writes the ambient npm-config prefix — with no second npm and no PATH-shadowed npm anywhere. So a fix keyed on detecting npm multiplicity or PATH shadowing would miss this shape; deriving the install target from the running entrypoint closes both routes.Concretely, three moves that end the class by construction:
npm install -g --prefix <resolved> <pkg>lands the package under<resolved>/lib/node_moduleswith bins in<resolved>/bin.npm -g config get prefix, print both paths. That one warning line replaces the /proc and debug-log forensics upthread.The divergent layout here is re-creatable on demand (it was dismantled when the symlink workaround landed — timeline in the resolution comment), and the npm debug-log timelines remain available — happy to validate a candidate build against the one-npm variant if useful.