Auto-updater installs into the PATH-resolved npm prefix, not its own — clobbers a different install

Status Open
Reported on v2.1.220
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Aug 5, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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 from
npm -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 runs
npm 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 npm is 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

  1. Pass an explicit --prefix derived from the running binary's own location, or refuse

to update when the PATH-resolved npm prefix does not contain the running binary.

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

  1. Detect npm ≥12 and either include --allow-scripts=@anthropic-ai/claude-code in the

install invocation or tell the user to allowlist it.

  1. Fix "currently running" attribution to use the executable path (as doctor does).

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=1 does not suppress an explicit claude update (may be intended).

View original on GitHub ↗

4 Comments

konsta95 · 5 days ago

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; ~/.npmrc sets the npm prefix to ~/.npm-global). A live session runs the /usr/local copy — its CLAUDE_CODE_EXECPATH points at /usr/local/.../bin/claude.exe. autoUpdatesChannel is latest.

Observed today: unattended, version-pinned installs keep landing in the config-resolved prefix, not the running install's own:

verbose cli /usr/bin/node /usr/bin/npm
verbose argv "install" "--global" "@anthropic-ai/claude-code@2.1.245"

— at 11:12Z and again at 11:26Z, while ~/.npm-global already 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:unpack 3.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.)

konsta95 · 5 days ago

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:

sudo ln -sfn /home/<user>/.npm-global/bin/claude /usr/local/bin/claude   # re-point stale bin at the user install
sudo rm -rf /usr/local/lib/node_modules/@anthropic-ai                    # remove the stale tree (after stale sessions exit)

(Why launches kept landing on the stale copy at all: this box's session launcher resolves claude with command -v in a non-login SSH context whose PATH lacks the ~/.npm-global/bin prepend, so it found /usr/local/bin/claude = 2.1.237 even though ~/.npm-global held 2.1.245.)

Mechanism proof, measured immediately after the re-point (2026-08-25 ~12:11Z):

$ readlink /usr/local/bin/claude
/home/<user>/.npm-global/bin/claude      # before: ../lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe (the root-owned 2.1.237 tree)
$ /usr/local/bin/claude --version
2.1.245 (Claude Code)

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", same reify moves to 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-global already held 2.1.245.

Loop-stopped proof, measured after the stale session exited:

  • Stale decider exited: its last act was churn #5 at 13:00:48–51Z; the process was gone by the 13:15:00Z sweep. A full /proc sweep at 13:16:37Z found zero processes with exe, cwd, or memory maps inside the /usr/local tree — nothing lazily executes from it anymore.
  • Replacement session (started 13:12:40Z, invoked through the re-pointed /usr/local/bin/claude symlink — i.e. the old muscle-memory path now lands on the converged install): /proc/<pid>/exe resolves to ~/.npm-global/.../bin/claude.exe, CLAUDE_CODE_EXECPATH points into ~/.npm-global, self-reported version 2.1.245.
  • The convergence signature, captured live: the old decider's last act was same-version churn #5; the new decider's first act, 12 s after boot, was the same poll (npm view @anthropic-ai/claude-code@latest version --prefer-online, GET 200 in 71 ms) followed by no install — exit 0 and stop (log 2026-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.
  • Stale tree removed (13:26:37Z): /usr/local/lib/node_modules/@anthropic-ai is gone and its parent directory is empty; the /usr/local/bin/claude symlink is intact and a fresh --version through 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.
  • Quiet window (measured through 14:31:47Z, 79 min past the baseline poll): the updater polled again at 14:08:42Z — same fingerprint (verbose argv "view" "@anthropic-ai/claude-code@latest" "version" "--prefer-online", GET 200 in 95 ms, cache revalidated, command:view done in 104 ms, exit 0) — and again installed nothing. A sweep over every surviving npm debug log finds zero install argv lines (control: the same instrument sees that poll's own argv line, so it demonstrably reads these files), and ~/.npm-global/bin/claude mtime 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.

bcherny collaborator · 5 days ago

Reproduced on the current release (2.1.233, Linux). With two npm-global installs under different prefixes and the second prefix's npm first on PATH, running claude update from the first prefix's binary queries npm -g config get prefix (which resolves to the other prefix) and then runs npm 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

konsta95 · 4 days ago

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 ~/.npmrc pinning prefix to ~/.npm-global steered npm -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:

  1. Resolve the install target from the running entrypoint — realpath through the launching bin link to its prefix — and pass it explicitly. Verified on npm 9.2.0: npm install -g --prefix <resolved> <pkg> lands the package under <resolved>/lib/node_modules with bins in <resolved>/bin.
  2. Feed that same entrypoint-resolved version into both ends of the updater. The outdated pre-check is what stops the unattended churn — otherwise a stale running copy keeps re-triggering installs that land elsewhere (#89560 documents that side of the class: five identical same-version reinstalls in 109 minutes, each one a pass through the retire-rename window flagged in #88091 / #12026). The post-install check — report success only if the entrypoint's version actually changed — turns any remaining divergence into a visible failure instead of a green exit.
  3. When the entrypoint's prefix ≠ 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.