From 9c301e10a153f974945d32fe051d445d9f5e52af Mon Sep 17 00:00:00 2001 From: smoido Date: Sun, 30 Aug 2026 21:41:09 +0300 Subject: [PATCH] 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. --- README.md | 35 ++++++++++++++++++------- linux/claude-mode | 39 +++++++++++++++++++++------- omarchy/smoido.claude-mode/Panel.qml | 23 ++++++++++------ 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 70f8aab..defd865 100644 --- a/README.md +++ b/README.md @@ -300,9 +300,27 @@ If the profile doesn't load: `Set-ExecutionPolicy -Scope CurrentUser RemoteSigne ## Restarting sessions -Claude Code reads all of this **once at startup**. A switch does not affect a -running session — that's how the process loads config, not something scripting -can change. +A switch **breaks** running sessions. This originally said it did not affect +them; that was wrong, and the difference matters, because the two halves of the +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` - **VS Code** — `Ctrl+Shift+P` → *Developer: Reload Window* @@ -446,12 +464,11 @@ stays exactly as it was. ## Sessions still on the old provider -Claude Code reads all of this once, at startup. A switch therefore does nothing -to a session already running: it keeps talking to the old provider on the old -key until it is restarted, which is the confusing part — the bar says one thing -and the session in front of you is doing another. A session mid-request is worse -off, because anthropic mode *deletes* the helper, so an in-flight turn can fail -on its next tool call rather than stopping cleanly. +A switch does not leave running sessions on the old provider — it breaks them. +See [Restarting sessions](#restarting-sessions) for the mechanism: their +endpoint is fixed at startup but their credential is re-fetched on a timer, so +it switches under them and the endpoint they are still pointed at refuses it. +Which is why this is a decision rather than a notification. ``` claude-mode sessions diff --git a/linux/claude-mode b/linux/claude-mode index 7fc77ea..fc3d917 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -539,14 +539,31 @@ cmd_preflight() { # --------------------------------------------------------------------------- # Running sessions # -# Claude Code reads settings.json once, at startup. A switch therefore does -# nothing to a session already running - it keeps talking to the old provider on -# the old key until it is restarted, which is the confusing part: the bar says -# one thing and the session in front of you is doing another. +# A switch breaks running sessions. Not "leaves them on the old provider" - +# breaks them, and it is worth being exact about why, because the two halves of +# the config behave differently. # -# Worse for a session mid-request. The key it is using can be pulled out from -# under it (anthropic mode deletes the helper outright), so an in-flight turn -# can fail on the next tool call rather than at a clean boundary. +# The static half - base URL, model ids, the env block - really is read once at +# startup, and a running session keeps the values it started with. +# +# 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//exe rather than by matching process # names. `claude` is a real ELF binary here, so the symlink resolves to it @@ -690,7 +707,7 @@ $rows EOF_ROWS 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 --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" @@ -803,9 +820,11 @@ cm_report_live_sessions() { busy="$(printf '%s' "$rows" | cut -f4 | grep -c '^yes$' || true)" 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 - 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 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" diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index 3dc32ea..7f26cc8 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -48,10 +48,12 @@ Panel { // ---- Switch flow // - // A switch is not one action. It rewrites settings.json, which every future - // session reads and no running session re-reads, so two things have to be - // settled before the write: whether the target can actually serve requests, - // and what should happen to the sessions still talking to the old one. + // A switch is not one action. It rewrites settings.json, and two things have + // to be settled before the write: whether the target can actually serve + // requests, and what should happen to the sessions already running - which a + // 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 // -> sessions -> confirm -> switch -> list @@ -848,10 +850,15 @@ Panel { 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 { width: parent.width - text: "Claude Code reads its settings once, at startup, so these keep " - + Modes.title(root.mode) + " until they are restarted." + text: "They keep pointing at " + Modes.title(root.mode) + ", but their key is " + + "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 wrapMode: Text.WordWrap lineHeight: 1.2 @@ -863,8 +870,8 @@ Panel { width: parent.width visible: parent.busyCount > 0 text: parent.busyCount === 1 - ? "One is working right now and will lose that turn." - : parent.busyCount + " are working right now and will lose those turns." + ? "One is working right now and will break mid-turn." + : parent.busyCount + " are working right now and will break mid-turn." color: root.urgentColor wrapMode: Text.WordWrap font.family: root.bar ? root.bar.fontFamily : Style.font.family