Correct what a switch actually does to a running session

Every one of these said a switch leaves running sessions alone - that they keep
talking to the old provider until restarted. That is wrong, and reassuring in
exactly the wrong direction: a switch breaks them.

The static half of the config does behave as described. Base URL, model ids and
the env block are read once at startup and a running session keeps what it
started with.

The credential is not. It comes from running apiKeyHelper, which Claude Code
re-invokes on a timer - CLAUDE_CODE_API_KEY_HELPER_TTL_MS is present in the
2.1.251 binary - and claude-key-helper.sh answers for whatever state.json says at
that moment. So the switch reaches into a live session through the one part that
was never cached. Into anthropic, the helper returns nothing by design and the
next refresh comes back with no credential; into another provider, it hands over
the new key while the session is still pointed at the old base URL, which
rejects it. Either way the session starts failing calls whenever the TTL happens
to expire, mid-turn as easily as between turns.

One case does survive, and is now stated rather than glossed: switching between
two presets of the same provider that share a keyRef keeps the same key and the
same endpoint, so the session carries on with the model ids it started with.

The claim in "Restarting sessions" predates this work - it came in with the
Windows build - but it is wrong for the same reason and is corrected too.
This commit is contained in:
smoido
2026-08-30 21:41:09 +03:00
parent 22cadaebc5
commit 9c301e10a1
3 changed files with 70 additions and 27 deletions
+26 -9
View File
@@ -300,9 +300,27 @@ If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigne
## Restarting sessions ## Restarting sessions
Claude Code reads all of this **once at startup**. A switch does not affect a A switch **breaks** running sessions. This originally said it did not affect
running session — that's how the process loads config, not something scripting them; that was wrong, and the difference matters, because the two halves of the
can change. config behave differently.
The static half — base URL, model ids, the env block — genuinely is read once at
startup, and a running session keeps what it started with.
The credential is not. It comes from running `apiKeyHelper`, which Claude Code
re-invokes on a timer (`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`, present in 2.1.251),
and the helper answers for whatever `state.json` says *at that moment*. So a
switch reaches into a live session through the one thing that was never cached:
| switching to | what the running session gets |
|---|---|
| `anthropic` | the helper returns nothing, by design — no credential at all |
| another provider | the new provider's key, sent to the old base URL, which rejects it |
| another preset of the *same* provider, same `keyRef` | same key, same endpoint — this one survives, on the model ids it started with |
Either of the first two starts failing calls whenever the TTL happens to expire:
mid-turn as easily as between turns. So restart afterwards — and on Linux,
`claude-mode sessions --restart` will do it for you:
- **CLI** — exit and relaunch `claude` - **CLI** — exit and relaunch `claude`
- **VS Code** — `Ctrl+Shift+P`*Developer: Reload Window* - **VS Code** — `Ctrl+Shift+P`*Developer: Reload Window*
@@ -446,12 +464,11 @@ stays exactly as it was.
## Sessions still on the old provider ## Sessions still on the old provider
Claude Code reads all of this once, at startup. A switch therefore does nothing A switch does not leave running sessions on the old provider — it breaks them.
to a session already running: it keeps talking to the old provider on the old See [Restarting sessions](#restarting-sessions) for the mechanism: their
key until it is restarted, which is the confusing part — the bar says one thing endpoint is fixed at startup but their credential is re-fetched on a timer, so
and the session in front of you is doing another. A session mid-request is worse it switches under them and the endpoint they are still pointed at refuses it.
off, because anthropic mode *deletes* the helper, so an in-flight turn can fail Which is why this is a decision rather than a notification.
on its next tool call rather than stopping cleanly.
``` ```
claude-mode sessions claude-mode sessions
+29 -10
View File
@@ -539,14 +539,31 @@ cmd_preflight() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Running sessions # Running sessions
# #
# Claude Code reads settings.json once, at startup. A switch therefore does # A switch breaks running sessions. Not "leaves them on the old provider" -
# nothing to a session already running - it keeps talking to the old provider on # breaks them, and it is worth being exact about why, because the two halves of
# the old key until it is restarted, which is the confusing part: the bar says # the config behave differently.
# one thing and the session in front of you is doing another.
# #
# Worse for a session mid-request. The key it is using can be pulled out from # The static half - base URL, model ids, the env block - really is read once at
# under it (anthropic mode deletes the helper outright), so an in-flight turn # startup, and a running session keeps the values it started with.
# can fail on the next tool call rather than at a clean boundary. #
# The credential is not. It is fetched by running apiKeyHelper, which Claude
# Code re-invokes on a timer (CLAUDE_CODE_API_KEY_HELPER_TTL_MS, present in
# 2.1.251), and the helper answers for whatever state.json says *now*. So a
# switch reaches into a live session through the one thing that was never
# cached:
#
# -> anthropic the helper returns nothing at all, by design, and the
# session's next refresh comes back with no credential
# -> another provider
# the helper hands over the new provider's key while the
# session is still pointed at the old base URL, which rejects
# it
#
# Either way the session starts failing its calls, at whatever moment the TTL
# happens to expire - mid-turn as easily as between turns. The one case that
# does survive is a switch between two presets of the same provider sharing a
# keyRef: same key, same endpoint, and the session simply carries on with the
# model ids it started with.
# #
# Sessions are found through /proc/<pid>/exe rather than by matching process # Sessions are found through /proc/<pid>/exe rather than by matching process
# names. `claude` is a real ELF binary here, so the symlink resolves to it # names. `claude` is a real ELF binary here, so the symlink resolves to it
@@ -690,7 +707,7 @@ $rows
EOF_ROWS EOF_ROWS
if [ "$action" = "list" ]; then if [ "$action" = "list" ]; then
printf '\n %sthese keep the provider they started with until restarted%s\n' "$C_DIM" "$C_RESET" printf '\n %stheir credential is re-fetched on a timer, so a switch breaks them%s\n' "$C_DIM" "$C_RESET"
printf ' %sclaude-mode sessions --stop close them%s\n' "$C_DIM" "$C_RESET" printf ' %sclaude-mode sessions --stop close them%s\n' "$C_DIM" "$C_RESET"
printf ' %sclaude-mode sessions --restart close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET" printf ' %sclaude-mode sessions --restart close and reopen each in its own directory%s\n' "$C_DIM" "$C_RESET"
printf ' %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET" printf ' %s--dry-run shows what either would do, and does nothing%s\n' "$C_DIM" "$C_RESET"
@@ -803,9 +820,11 @@ cm_report_live_sessions() {
busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)" busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)"
printf '\n' printf '\n'
warn "$n Claude Code session(s) still running on the previous provider" warn "$n Claude Code session(s) are running and will start failing their calls"
say 'their key is re-fetched on a timer and now resolves to the new mode, which'
say 'the endpoint they are still pointed at will not accept. Restart them.'
if [ "${busy:-0}" -gt 0 ]; then if [ "${busy:-0}" -gt 0 ]; then
warn "$busy of them is mid-request - it may fail on its next call rather than at a clean stop" warn "$busy of them is mid-request and will break wherever it happens to be"
fi fi
printf ' %sclaude-mode sessions what is running%s\n' "$C_DIM" "$C_RESET" printf ' %sclaude-mode sessions what is running%s\n' "$C_DIM" "$C_RESET"
printf ' %sclaude-mode sessions --restart close and reopen them on the new provider%s\n' "$C_DIM" "$C_RESET" printf ' %sclaude-mode sessions --restart close and reopen them on the new provider%s\n' "$C_DIM" "$C_RESET"
+15 -8
View File
@@ -48,10 +48,12 @@ Panel {
// ---- Switch flow // ---- Switch flow
// //
// A switch is not one action. It rewrites settings.json, which every future // A switch is not one action. It rewrites settings.json, and two things have
// session reads and no running session re-reads, so two things have to be // to be settled before the write: whether the target can actually serve
// settled before the write: whether the target can actually serve requests, // requests, and what should happen to the sessions already running - which a
// and what should happen to the sessions still talking to the old one. // switch breaks rather than leaves alone, because their credential is
// re-fetched on a timer and resolves to the new mode while their endpoint
// stays the old one.
// //
// list -> preflight -> blocked // list -> preflight -> blocked
// -> sessions -> confirm -> switch -> list // -> sessions -> confirm -> switch -> list
@@ -848,10 +850,15 @@ Panel {
font.bold: true font.bold: true
} }
// Not "they will keep using the old provider". Their endpoint and model
// ids are fixed at startup, but the key is re-fetched on a timer and
// will resolve to the new mode, which the old endpoint refuses - so
// they fail rather than carry on.
Text { Text {
width: parent.width width: parent.width
text: "Claude Code reads its settings once, at startup, so these keep " text: "They keep pointing at " + Modes.title(root.mode) + ", but their key is "
+ Modes.title(root.mode) + " until they are restarted." + "re-fetched on a timer and will switch under them. Their calls start "
+ "failing from that moment, not at a clean stop."
color: Color.muted color: Color.muted
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
lineHeight: 1.2 lineHeight: 1.2
@@ -863,8 +870,8 @@ Panel {
width: parent.width width: parent.width
visible: parent.busyCount > 0 visible: parent.busyCount > 0
text: parent.busyCount === 1 text: parent.busyCount === 1
? "One is working right now and will lose that turn." ? "One is working right now and will break mid-turn."
: parent.busyCount + " are working right now and will lose those turns." : parent.busyCount + " are working right now and will break mid-turn."
color: root.urgentColor color: root.urgentColor
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family font.family: root.bar ? root.bar.fontFamily : Style.font.family