Rolling Claude Code Upgrades from a Running Session
The pkg upgrade doesn't kill your live session: Unix file semantics and a bectl snapshot are the whole story
Table of Contents
1. Summary
I upgraded claude-code on nexus from 2.1.89 (April 2026) to
2.1.261 (155 patch versions later, first version that resolves the
fable model alias to Fable 5.1 rather than 5.0) while three
claude sessions, including the one issuing the upgrade command,
were live and mid-conversation. Zero interruption to the running
sessions; the upgrade took ~15 seconds; rollback would have been one
bectl activate away.
The mechanic is Unix file semantics. pkg writes new files to disk;
running processes already have theirs open. The old binary keeps
resolving through the open file descriptors and the loaded memory
pages until the process exits. Only the next invocation of the CLI
picks up the new binary.
This is worth calling out because the folk advice ("close every session before upgrading") leaks in from the Windows install-time world where files can't be replaced while open. It does not apply on Unix, and specifically doesn't apply to a Node-based CLI whose code is loaded once at process start.
2. The command sequence
Six shell lines. Comments explain why.
# 1. Snapshot the entire boot environment as a rollback point.
# Instant, ~8 KB, no downtime. Undo is `bectl activate` + reboot.
sudo bectl create pre-claude-upgrade-20260909
# 2. Refresh catalogues.
sudo pkg update
# 3. DRY RUN — see the diff before touching anything on disk.
# Note bundled deps (linux_base-rl9, bash) that come along.
sudo pkg upgrade -n -r FreeBSD-latest claude-code
# 4. Real upgrade. This runs while the previous claude session is still up.
sudo pkg upgrade -r FreeBSD-latest -y claude-code
# 5. Confirm on disk. Note: the running session STILL reports the old version
# because it never re-reads its binary; only new invocations get the new one.
pkg info claude-code
claude --version
# 6. Optional but recommended — reap the broken npm shadow if you had one.
# (See "Why npm can't do this on FreeBSD" in the sister post.)
npm uninstall -g @anthropic-ai/claude-code
which -a claude
On my box the whole thing was ~15 s wall time, 133 MB downloaded, 165 MB installed, and produced the on-disk transition:
Installed packages to be UPGRADED:
bash: 5.3.9 -> 5.3.15
claude-code: 2.1.89 -> 2.1.261
gettext-runtime: 1.0 -> 1.0_1
linux_base-rl9: 9.7 -> 9.8
The process will require 165 MiB more space.
133 MiB to be downloaded.
claude --version from a fresh shell after the upgrade returns
2.1.261 (Claude Code). My running session, still executing from the
pre-upgrade pages, continued to report 2.1.112 (the file's declared
version at the point that session started) until I exited and
re-launched. The new session came up on 2.1.261 and
Fable 5.1, which is how this post is being finished.
3. Why it's safe (the interesting part)
3.1. Unix keeps the old file alive under running processes
When pkg upgrade replaces /usr/local/libexec/claude (or its
cli.js equivalent), it doesn't overwrite the bytes of the currently
open file. It writes a new file to a temp path and rename(2)'s it
into place. The old inode is still referenced by every process that
opened the file (and by the file descriptors those processes still
hold), so its contents remain readable until the last reference goes
away. The old inode is only reclaimed when both the directory link
count and the open-fd count reach zero.
That's why lsof will show (deleted) for the file path of a
running process whose binary was replaced. Nothing about that state
harms the running process; the kernel keeps serving
the old inode.
Concretely, on FreeBSD you can watch it:
# Before upgrade
ps auxww | grep -m1 "node.*claude"
# Note the PID.
sudo pkg upgrade -y claude-code
# The upgrade completes; the PID above is still running.
procstat -f <pid> | grep -i claude
# The file entry now says (deleted) but the fd is live.
3.2. Node CLIs load their code once at process start
Some binaries lazy-load parts of themselves: a shared library
loaded on demand, a data file mmap'd late. If pkg swaps that
file while the running process is between loads, the process
crashes.
Claude Code is a Node app whose JavaScript is bundled and loaded at startup;
by the time the claude command returns to its REPL loop it has
already resolved everything it needs from disk into V8's heap. There
is no lazy re-read of the binary. Same story for pretty much any
Electron/Node CLI: single-load, then all runtime access is
in-memory.
One thing that does get re-read per invocation: vendored helper
binaries. On the pkg-installed FreeBSD build, Grep and Glob tool
calls spawn a vendored ripgrep from
vendor/ripgrep/x64-freebsd/rg, a path that doesn't exist in the
port's layout. Those tools fail with ENOENT both before and after
the upgrade; it's a packaging gap, not an upgrade regression, and the
Bash tool with grep=/=find covers the same ground.
3.3. The bundled deps behave the same way
The upgrade also bumped linux_base-rl9, bash, and
gettext-runtime. Same guarantee:
linux_base-rl99.7 → 9.8: the Rocky userland files under/compat/linux/are only read by Linux processes atexec(3)time. Any Linux process already running under the linuxulator holds its own loaded libc/loader pages. New Linux processes started after the upgrade see the new files. My host had no active linuxulator processes at upgrade time; the two Bastille jails on this box run their ownlinux_base-rl9from their own pkg installs, so the host swap didn't touch them.bash5.3.9 → 5.3.15: the shell binary I was using as my login shell continued to work. New shells picked up the new one.gettext-runtime: a shared library. Any process that already linked it holds its own resolved copy; new invocations link against the new.so. If you had a very long-lived C daemon that dlopens gettext on demand, this is the one to be cautious about. For interactive CLIs you're fine.
3.4. What actually would require restart
The pattern breaks for:
- Anything that
dlopens modules on demand from the upgraded path - Long-running C daemons that reopen files at runtime and expect version invariants
- Kernel modules – those really do need a reboot to swap
- SUID/SGID binaries where the setuid bit re-check on exec matters
- Bundled resources loaded via
readdir=/=fs.readFileat each tool invocation instead of at process start
None of these describe claude. The Node process is essentially a
single read(2) of the bundled JavaScript at startup, and everything after
is in-memory.
4. When to prefer kill-and-relaunch anyway
Live-upgrade-then-drift is fine, but three cases where I close sessions first:
- I want to use the new version now. Obvious: the running session stays on the old code until it exits. If the upgrade contains a bug fix I need right now, exit and re-launch.
- The upgrade brings a settings/schema change that the old
binary would mishandle if it wrote state during the swap window.
Rare for
claude-code; common for database CLIs. - I'm about to reboot for some other reason anyway (kernel patch, hardware maintenance). Cheaper to defer.
Otherwise, I upgrade in place and let the old session continue.
4.1. A concrete case of #1 from this very upgrade
Auto mode in Claude Code routes every non-read-only tool call
through a safety classifier before executing it. The classifier is
itself a model call. Midway through drafting this post, the
classifier's backend became temporarily unavailable, and every
Write and Bash call in the old session was refused with:
claude-fable-5-1[1m] is temporarily unavailable, so auto mode cannot
determine the safety of Write right now.
Read-only tools kept working. The old (pre-upgrade) session had no way to recover from that except to wait. Killing it and relaunching on the freshly-installed 2.1.261 (the "I want to use the new version now" case) gave a session whose classifier path was healthy, and the write went through on the first try.
5. Rollback
Boot environments make this cheap. The bectl create step above
took an atomic ZFS snapshot of the root dataset; if the new
claude-code regresses, one activate + reboot restores everything
below that dataset: the old pkg, the old /usr/local/lib/, the old
/boot/loader.conf, the old config.
sudo bectl list
# → default, pre-claude-upgrade-20260909
# If something breaks:
sudo bectl activate pre-claude-upgrade-20260909
sudo reboot
# Boots into the pre-upgrade filesystem view.
There are surgical alternatives: reinstall the previous version's
pkg from /var/cache/pkg/ if it's still there, or keep a
claude-code-legacy-<v> subport installed. But BE-level rollback
covers every file the upgrade touched, not just the one package,
and it costs nothing to keep around.
6. Sanity checks I ran after the upgrade
| Check | Command | Result |
|---|---|---|
| pkg reflects new version | pkg info claude-code |
2.1.261 ✅ |
| Binary on disk works | Fresh shell, claude --version |
2.1.261 ✅ |
| Running session unaffected | claude --version from the still-live tmux pane |
2.1.112 (old, expected) |
| Broken npm shadow removed | which -a claude |
/usr/local/bin/claude only ✅ |
| linux_base bump didn't break the jails | Each jail's own claude --version |
Unchanged (each jail has its own linux_base install) ✅ |
| BE snapshot exists | bectl list |
pre-claude-upgrade-20260909 present ✅ |
| Relaunched session is on the new build | /status in the fresh session |
2.1.261 / Fable 5.1 ✅ |
7. Related posts on this box
- Upgrading Claude Code in a Bastille Jail on FreeBSD 15.1 – how I validated the packaging path in a jail before applying it to the host, plus the pf NAT gap Bastille leaves in the default install.
- FreeBSD Agent Sandboxing: Bastille, Capsicum, and Deno – where the keyless-proxy / LiteLLM chokepoint pattern that this harness eventually plugs into is developed.
8. Takeaways
pkg upgradeof an interactive CLI is a live-safe operation on Unix. Running sessions keep their old inode; new sessions get the new one. Node CLIs are especially safe because they load once and run from memory.bectl createbefore every non-trivial upgrade. It's atomic, free, and it's the only rollback that covers every file the upgrade touched.pkg upgrade -nbeforepkg upgrade -y. Bundled deps sometimes surprise you; the dry-run tells you what's coming.- The "kill everything first" habit is folklore from other platforms. Skip it here; it costs you context and doesn't buy anything. Relaunch when you want the new build, not because the upgrade demands it.