Claude Code: Anatomy of a Misfeature

"Mechanical egg timer" by Hustvedt is licensed under CC BY-SA 3.0 . Padded to a wider frame; this adaptation is likewise licensed CC BY-SA 3.0.
On Canada Day (July 1), 2026, Anthropic shipped a surprising “easter egg” to
users of Claude Code: 2.1.198 includes an efficiency bypass which allows
agents to continue on without being blocked on direction from a human. You
essentially get a 60 second timer after Claude Code asks for input. If you miss
the window, Claude Code helpfully does what it thinks is best and continues on
its way. It looks like this:
Note: the above is taken verbatim from one of my own claude sessions, with the questions having been trimmed.
If you find this behaviour surprising, you’re not alone. Let’s consider the possible consequences:
Do you have to take your laptop to the kitchen with you when you’re making a sandwich? What happens if you are afk during this window?
How many agents are you running at once? Can you possibly observe them all at the same time? What if two or more agents ask for your input during the same 60 second window?
What if the agent makes the wrong choice? How many tokens have been burned in the meantime?
What if you are using agents for deployments? (Yes, I know, but what if)
These are reasonable things you might consider when shipping this feature and maybe you’d document your reasoning in the changelog. But what if you never mentioned the new defaults in the changelog at all? Wouldn’t that be even more surprising? (Spoiler: it was!)
The story has a (sort of) happy ending. Move fast and break things does not necessarily preclude move fast and fix things. Within a couple of days a fix was shipped, but where does that leave user trust in this product?
We’ve learned a few things:
Surprising features in Claude Code can in theory (and in practice) be shipped by Anthropic on a daily cadence
Not every feature will necessarily appear in the changelog
Things that should not be defaults may not have a documented off switch
Claude Code’s auto update feature feels more like YOLO mode than we might have suspected early on
There are a few things I don’t know if we have learned:
How do humans fit into this equation?
Did a human dream up the feature?
Did a human write (or have an agent write) this feature?
Did a human review the feature?
Did a human sign off on the feature?
Did a human merge the feature?
Did a human opt out of documenting the feature or adding it to the changelog?
Did a human release manager diff the release with the previous release and give it their seal of approval before it went out the door?
Personally I would find it hard to believe that a human was gating all of these
steps without asking “is this a good idea?”. If you told me that Claude Code
actually built the feature, shipped it, signed off on it and then deemed it
unworthy of documentation, that’s something I’m more inclined to believe, but I
just don’t know. Maybe it’s some combination of those two things. Maybe a
number of things went wrong, but I think it’s clear that this never should have
happened. And I say this as someone who has had at least one performance review
where my manager said “well, you did put a serious bug into production”.
I’ve wondered a bit about how this happened and what kind of post-mortem is
available in the public record. So, I’ve asked Claude Code to investigate
itself. To Claude’s credit, it seems to have no filter that prevents
self-reflection regarding this code. So, full disclosure, what follows is
mostly Claude’s work, so take that for what it’s worth and if you rely on any
key assumptions, it’s worth reproducing them in isolation.
Claude’s research begins here.
#Timeline
2026-06-29 — 2.1.196 released; the reporter’s “last working version (I am guessing)”
2026-06-30 — 2.1.197 released; one changelog line, the Sonnet 5 launch
2026-07-01 — 2.1.198 released — the version the reporter pins the regression to. No public commit shows the change; the only public trace of this release is the bot commit publishing its notes (75709ea), which touches nothing but CHANGELOG.md and feed.xml
2026-07-02 02:54 UTC — issue #73125 filed by Aleksey Nogin
2026-07-02 03:45 UTC — a commenter surfaces the escape hatch: CLAUDE_AFK_TIMEOUT_MS. Traded peer-to-peer in the thread, not pointed to from any release note
2026-07-02 — 2.1.199 ships 24 entries while the issue is open. Still no mention.
2026-07-03 — 2.1.200 reverses the behaviour; again the only public trace is the notes commit (1322e9b)
2026-07-04 18:04 UTC — issue closed
Reaction/scale on the issue: 384 👍, 143 comments — not a niche complaint
Reporter’s environment: 2.1.198, “last working 2.1.196 (guessing)”, Opus, AWS Bedrock, VS Code terminal
#The misfeature
AskUserQuestion is the tool Claude Code uses to stop and ask the human a question mid-task
New behaviour: after 60 seconds of inactivity, the tool auto-returns a “proceed anyway” result instead of blocking
The message handed back to the model — this is the template, rendered here at the 60-second default:
The “re-ask later” escape hatch is circular: the re-asked question hits the same timeout. Aleksey Nogin made this point in the issue thread within minutes of filing it
The transcript line has two variants — the binary picks between them on whether you’d started answering:
So a half-answered dialog does not discard the partial input — it submits it. Answer question one of three, step away, and the timeout commits your one answer plus whatever the model picks for the other two
Both strings are absent from 2.1.197 and present in 2.1.198
In fairness: it was not silent on screen. The dialog rendered a live countdown, and a keypress restarted the timer. Assembled at runtime rather than stored as one string, so this is the rendered form, not a literal grep hit:
Which cuts less far than it looks. The countdown only reaches someone watching the screen, and the premise of the feature is that you aren’t:the internal name is AFK; the message says “the user may be away from keyboard”running several agents at once, “watching the screen” is not one place — the countdown you needed was on another tab
the internal name is AFK; the message says “the user may be away from keyboard”
running several agents at once, “watching the screen” is not one place — the countdown you needed was on another tab
And the countdown is late. The threshold defaults to 20 seconds (CLAUDE_AFK_COUNTDOWN_MS), and it gates on remaining time, not elapsed — so for the first 40 seconds the dialog looks like an ordinary blocking question. It is on screen, but nothing on it says a timer is running:
The warning arrives in the last third
What it does not touch: the timeout applies only to AskUserQuestion. Anthropic’s tools reference says “permission prompts, including plan approval, never auto-resolve on idle” — and unlike the docs claims elsewhere in this post, this one is checkable against the shipped 2.1.198 code rather than a page written after the fix:the countdown component (q0m) has exactly one call site in the entire bundle, and its timer hook (_Rc) has exactly one caller — q0m itself. The timer exists in one componentthat component’s props identify it past argument: jsx(dRc,{question:V, questions:s, currentQuestionIndex:$, answers:R, questionStates:O, onAnswer:be, onSubmit:N, …}). Its timeout handler is what fires tengu_ask_user_question_afk_auto_advancethe permission prompt (Do you want to proceed, Do you want to allow …) is a separate component with no timer attached
the countdown component (q0m) has exactly one call site in the entire bundle, and its timer hook (_Rc) has exactly one caller — q0m itself. The timer exists in one component
that component’s props identify it past argument: jsx(dRc,{question:V, questions:s, currentQuestionIndex:$, answers:R, questionStates:O, onAnswer:be, onSubmit:N, …}). Its timeout handler is what fires tengu_ask_user_question_afk_auto_advance
the permission prompt (Do you want to proceed, Do you want to allow …) is a separate component with no timer attached
But the exemption only protects you if a permission prompt appears at all. 2.1.198 ships bypassPermissions, acceptEdits, allowedTools, --dangerously-skip-permissions and PreToolUse hooks. Anyone running agents against deployments has plausibly allowlisted the deploy command or turned prompts off — that is what automating it means. For them the permission layer was never going to fire, so its immunity to the timer buys nothing
The narrower claim, and the one that bites: the timer could not grant permission, but it could make the choice. AskUserQuestion doesn’t ask permission, it asks you to decide — “staging or production?”, “which config?” On timeout the model is told to proceed using your best judgment, and the partial-answers path continues “with the answers selected so far”. If permission was already granted by allowlist or bypass, the choice was the only gate left
No timeout parameter exists in the tool schema — the model can neither set nor control it. Verified in the binary (U_f=…H.strictObject({questions:…})), not taken from the issue thread. The input params are only:
So the model is truthful when it says it didn’t skip anything — the harness returned the answer, not the model
#The fix
2.1.200 made auto-continue off by default
Idle timeout is now opt-in via /config, not forced on everyone
Note the changelog’s verb: “no longer auto-continue by default”. Nothing was removed — the default flipped.
The /config setting the note points you to did not exist before the fix. Grep 2.1.198 for askUserQuestionTimeout and you get zero hits: when this shipped, the only way to escape it was an env var — and the release notes never named it.
You can confirm the rest in the current binary (2.1.211, eleven releases later). The machinery is entirely intact:the setting is askUserQuestionTimeout, surfaced in /config as “Question auto-continue timeout”accepted values are 60s, 5m, 10m, never — and unset resolves to never, which is what makes it opt-inthe hook’s default timeout constant is still 60000; the countdown threshold is 20000two env vars still override the setting — CLAUDE_AFK_TIMEOUT_MS and CLAUDE_AFK_COUNTDOWN_MS
the setting is askUserQuestionTimeout, surfaced in /config as “Question auto-continue timeout”
accepted values are 60s, 5m, 10m, never — and unset resolves to never, which is what makes it opt-in
the hook’s default timeout constant is still 60000; the countdown threshold is 20000
two env vars still override the setting — CLAUDE_AFK_TIMEOUT_MS and CLAUDE_AFK_COUNTDOWN_MS
So the fix is a one-line change in a gate, not a deletion:
Fair reading: this is the right fix. Opt-in is what it should have been on day one, and the capability is legitimately useful to someone.
Less comfortable reading: the same code that auto-answered for you is still shipping, one config value away, governed by the same process that turned it on silently the first time.
Pre-fix stopgap (from the thread), for anyone pinned to an affected version:
Turnaround was fast — roughly two days from report to reversal (credit where due)
#Which release shipped it — and what the notes said
Two places publish release notes: the official changelog and CHANGELOG.md in the repo (same content)
Links below are pinned to commit 1322e9b so they show what the notes said at the time, not as edited later
2.1.197 changelog: one line, Claude Sonnet 5 launch. Nothing about question timeouts.
2.1.198 changelog: ~30 entries. Nothing about AskUserQuestion auto-continuing.
2.1.199 changelog: 24 entries, published while the issue was already open. Still nothing.
The 60s auto-continue was never announced in any release note when it was added
AskUserQuestion is not a stranger to the changelog — it appears 15 times across 13 versions, going back to 2.0.55. It is a tool Anthropic documents changes to, routinely:
Which makes the gap the story. Between 2.1.181 and 2.1.200 — the window containing the change — it appears nowhere. The behaviour changed twice, on and then off, and the notes record only the second
The only changelog line that ever mentions the auto-continue is the one that removes it, in 2.1.200:
The controlling env var CLAUDE_AFK_TIMEOUT_MS appears nowhere in the changelog or README
#The docs caught up. Afterwards.
Today, both env vars are documented, in the environment variables reference. The entry is candid about the episode:
environment variables reference
But that text cannot be what was there on 1 July, and the binary says so on its own: it points you to askUserQuestionTimeout as the opt-in, and that setting has zero occurrences in the 2.1.198 binary. It also narrates 2.1.198 and 2.1.199 in the past tense, as a closed range
And the Wayback Machine settles it outright. The docs have no public repo, so I assumed their past was unknowable. It isn’t — the page is archived, repeatedly, straight through the window:
2.1.198 was published to npm at 2026-07-01T16:50:16Z. The 21:35 capture is four hours and forty-five minutes later — and the page does not mention the feature under any name: not afk, not auto-continue, not AskUserQuestion, not COUNTDOWN. (idle appears twice, both unrelated: API_FORCE_IDLE_TIMEOUT and the MCP tool timeout.) The control hits on every capture, so this is absence, not a broken grep
So the two questions collapse into one answer. On the day it shipped, the feature was in neither the release notes nor the docs. There was no channel through which a user could have been told
The documentation appears between 1 July 21:35 and 5 July 13:58 — a window containing the 2.1.200 reversal (2026-07-03T04:33:49Z). And the entry that shows up describes auto-continue as “off by default”, which was only true after the fix. The docs never described the world as it actually was on 1 and 2 July. They arrived with the reversal and documented the reversed behaviour
The tidy phrase for this is that the docs caught up. What they actually did was skip the part where it was on
#So where’s the commit?
The obvious next move: open the commit that introduced it and read the reasoning
The obvious next move: open the commit that introduced it and read the reasoning
There isn’t one. Not “hard to find” — it does not exist publicly
There isn’t one. Not “hard to find” — it does not exist publicly
Neither does a revert commit. The reversal in 2.1.200 has no public commit either.
Neither does a revert commit. The reversal in 2.1.200 has no public commit either.
The only git artifacts either release left behind are two automated changelog commits, both titled chore: Update CHANGELOG.md and feed.xml — notes about a release, never the release:75709ea — publishes the 2.1.198 notes (the release that shipped it, unmentioned)1322e9b — publishes the 2.1.200 notes (the reversal)
The only git artifacts either release left behind are two automated changelog commits, both titled chore: Update CHANGELOG.md and feed.xml — notes about a release, never the release:
75709ea — publishes the 2.1.198 notes (the release that shipped it, unmentioned)
1322e9b — publishes the 2.1.200 notes (the reversal)
Fine — then diff the source between releases. There is no source to diff
Fine — then diff the source between releases. There is no source to diff
anthropics/claude-code is not the product. It is the changelog, the docs, plugin examples, a couple of example infra configs, and the bots that triage the issue tracker:
anthropics/claude-code is not the product. It is the changelog, the docs, plugin examples, a couple of example infra configs, and the bots that triage the issue tracker:
Every executable file in there is an example or a maintenance script. plugins/ holds sample plugins, examples/ holds a GCP gateway Terraform config and an MDM profile, scripts/ is eight files of issue-tracker automation (auto-close-duplicates.ts, sweep.ts, gh.sh). Nothing in it ships to you
The repo does tag releases, so the tags at least look diffable. They aren’t, in the way that matters:
feed.xml is the changelog restated as RSS, so that diff is the changelog twice. Across ten consecutive releases (2.1.196 → 2.1.206), every tag-to-tag diff touches those two files and nothing else
feed.xml is the changelog restated as RSS, so that diff is the changelog twice. Across ten consecutive releases (2.1.196 → 2.1.206), every tag-to-tag diff touches those two files and nothing else
The punchline: a release tag diff is the release notes. These are release-note tags, not source tags — there is no version of the code to check out
The punchline: a release tag diff is the release notes. These are release-note tags, not source tags — there is no version of the code to check out
So “read the release notes” fails (silent change), “diff the repo” fails (no source), and “diff the tags” fails (the tags are the notes). Three dead ends, one cause: nothing Anthropic publishes to git is the thing they ship you
So “read the release notes” fails (silent change), “diff the repo” fails (no source), and “diff the tags” fails (the tags are the notes). Three dead ends, one cause: nothing Anthropic publishes to git is the thing they ship you
The authored source is published nowhere; the behaviour ships only inside the compiled binary
The authored source is published nowhere; the behaviour ships only inside the compiled binary
So the sum of public evidence that this feature ever existed is:the message it printed into people’s terminalsan env var (CLAUDE_AFK_TIMEOUT_MS) that users found by asking each other, no release note having named itone changelog line, three days later, announcing its removal
So the sum of public evidence that this feature ever existed is:
the message it printed into people’s terminals
an env var (CLAUDE_AFK_TIMEOUT_MS) that users found by asking each other, no release note having named it
one changelog line, three days later, announcing its removal
The feature’s introduction left no trace in the release notes or in git. Its deletion is the first time the notes ever mentioned it.
The feature’s introduction left no trace in the release notes or in git. Its deletion is the first time the notes ever mentioned it.
Worth separating two claims that are easy to conflate:there is no public source repository — true, and that’s the governance problemthere is no way to see what shipped — false, and that turns out to matter enormously (below)
Worth separating two claims that are easy to conflate:
there is no public source repository — true, and that’s the governance problem
there is no way to see what shipped — false, and that turns out to matter enormously (below)
Skip ahead if you like: the shipped binary settles the question that git can’t. The feature is provably absent from 2.1.197 and present in 2.1.198, and you can check that yourself in about five minutes.
Skip ahead if you like: the shipped binary settles the question that git can’t. The feature is provably absent from 2.1.197 and present in 2.1.198, and you can check that yourself in about five minutes.
#Why was it added? (evidence, not confirmation)
No official rationale was ever published (no design doc, no changelog line, no PR — see below)
Circumstantial evidence of intent, all from naming and the message text:Internal name is AFK — “away from keyboard” (CLAUDE_AFK_TIMEOUT_MS)The message assumes an absent human: “the user may be away from keyboard”Plausible target: unattended / many-parallel-agent runs that would otherwise block forever on an absent human
Internal name is AFK — “away from keyboard” (CLAUDE_AFK_TIMEOUT_MS)
The message assumes an absent human: “the user may be away from keyboard”
Plausible target: unattended / many-parallel-agent runs that would otherwise block forever on an absent human
Users in the thread describe exactly the workflow it breaks: dozens of agents, some parked for days, waiting on a human by design (earlye)
Tension: the same design that unblocks an absent human also lets an agent pick a fork in the road you explicitly reserved for yourself
The strongest evidence is inside the binary, and it’s present in 2.1.198 itself. Two separate things, worth keeping apart:
- A field on the tool’s own schema — this one stays local. It rides along in the tool result, tells the model the answer was auto-resolved rather than chosen, and picks which component renders the transcript line:
- An analytics event — this one leaves your machine. Fired at the moment the dialog auto-advances:
tengu_* is the naming convention for Claude Code’s analytics events throughout the binary. The question text isn’t sent; source_hash is a hash, and the rest is counters
Absent from 2.1.197. It arrived with the feature, in the same release
Read the payload for what it is: an instrument for counting how often the dialog resolved without a human — how many questions were pending, whether it happened mid-plan, and whether you had partially answered
That last one is hadPartialAnswers. The half-answered case wasn’t an oversight that nobody saw coming — it has a code path, and that path is counted separately from the rest
So: this was built, not stumbled into. The behaviour, the countdown, the schema field and the analytics all landed in the same release — that’s a feature with a measurement rig attached, not a stray default
Note what that does not establish. It says nothing about who, or whether a who was involved at all. The questions at the top of this post stay open; all the binary proves is that the work was coherent and deliberate, not who or what did it
Same release, the countdown it printed at you: auto-continue in {n}s · any key to stay
So the capability was designed, instrumented, and given UI — and still never made the notes. That’s the gap the post is about: this wasn’t too small to mention. Someone built a measurement rig for it.
What none of it tells us is who signed it off, or whether anyone weighed the failure mode against the upside. Still inference; still no published rationale.
#Can you even diff it?
Everything above says no. Then you look at what’s actually installed and the answer changes.
It’s a ~250MB native executable — and, crucially, not stripped:
It’s a Bun-compiled binary. Bun’s single-file executables append a module graph to the end of the runtime, behind a magic marker — and it’s right there:
Bun compiles by embedding the JS bundle inside the executable, so the shipped JavaScript is sitting in the file. strings reads it:
Every claim in “The fix” above was read straight out of that binary — the setting name, the 60s/5m/10m/never values, the analytics event, the countdown env var
The installer keeps recent versions on disk (ls ~/.local/share/claude/versions/), but only the last few — not enough to reach back to July
Any version you like is on npm, though. The catch: @anthropic-ai/claude-code is a ~152KB installer stub (7 files, dist.unpackedSize 155,204) — no product in it. The binary lives in per-platform packages:
So: closed source, but not a black box. Every release is fetchable and every release is legible. The distinction matters.
#So let’s actually diff it
So let’s stop theorising and actually do it: pull 2.1.197 and 2.1.198 off npm and diff them.
The feature has a distinctive internal name — AFK. Grep both binaries for it:
Zero to non-zero, across exactly the boundary the reporter named. No commit, no changelog line, no PR — but the artifact is unambiguous about when it landed.
This is the record that doesn’t exist in git. It was always in the thing they shipped you.
And it settles what the release actually did. The gate that decides whether the dialog gets a timer at all, in both versions, side by side:
Jyn there is the afkTimeoutMs prop — the /config setting, resolved through the switch above. That’s the whole difference: in 2.1.198 the gate existed, but nothing you could set was allowed to close it. In 2.1.211 it asks your permission first
Jyn there is the afkTimeoutMs prop — the /config setting, resolved through the switch above. That’s the whole difference: in 2.1.198 the gate existed, but nothing you could set was allowed to close it. In 2.1.211 it asks your permission first
Worth being precise, because it’s the sort of thing that gets overstated: it isn’t that 2.1.198 had no gate. It had this one, and it turned on whenever the dialog had no external racer. What it lacked was any condition a user could influence
Worth being precise, because it’s the sort of thing that gets overstated: it isn’t that 2.1.198 had no gate. It had this one, and it turned on whenever the dialog had no external racer. What it lacked was any condition a user could influence
Two days of outcry, and the remedy is one && clause. Which is also the point: it was one && clause away from never having happened.
Two days of outcry, and the remedy is one && clause. Which is also the point: it was one && clause away from never having happened.
But notice what that grep needed: the reporter handed us the version, and the feature has a name to grep for. Neither is true on an ordinary Wednesday. So the real question is whether you’d have found this cold, knowing neither
But notice what that grep needed: the reporter handed us the version, and the feature has a name to grep for. Neither is true on an ordinary Wednesday. So the real question is whether you’d have found this cold, knowing neither
A blind string-level diff of 2.1.197 → 2.1.198:
A blind string-level diff of 2.1.197 → 2.1.198:
21,903 changed strings for one release. The minifier renames every identifier on every build, so nearly all of it is churn, not change. CLAUDE_AFK_TIMEOUT_MS is in there — buried in 21,902 lines of noise
One caveat on that number, since it looks more solid than it is: it’s a property of the invocation, not of the release. strings -n 3 gives 81,289; the default -n 4 gives 29,910; -n 8 gives 21,903. The shape of the argument survives any of them — tens of thousands of lines, overwhelmingly noise — but don’t read 21,903 as a constant
Now filter to things that look like English sentences. Keep only added lines, made of letters and ordinary punctuation, starting with a capital, at least five words long. That drops every mangled identifier, path, and code fragment:
156 lines. That’s the whole surface of new human-readable text in the release — small enough to read over coffee. And the feature is in there, in plain language:
That’s the string injected back into the conversation when the dialog answers for you. Read that line cold, knowing nothing, and you’d stop
It lands at line 11 of the 156, which sounds like a lucky break and isn’t: s-*.txt was sort -u’d, so the list is alphabetical. Ten lines starting with “A” precede it, and it starts with “B”. Position tells you nothing here — the point is that 156 lines is a five-minute read, not that the alphabet was kind
#What it costs to find that
So the diff works. A person doing this ritual on 1 July, with no privileged information, would have caught it on the day it shipped — before the issue was filed.
Tempting conclusion, then: “so just diff every release.”
Which answers one of the questions from the top of this post — did a human release manager diff the release against the previous one before it went out the door?Whoever shipped 2.1.198 had every advantage I didn’t: the source, the build, the diff, the review, the authors to askI had a curl and a grep, and it took about five minutesSo: either nobody looked, or someone looked and shipped it anyway. Both are answers. Neither is good.The rest of those questions — who wrote it, who reviewed it, who signed it off — stay unanswerable, because the public record where that would live does not exist. That’s the same absence as the missing commit, wearing a different hat.
Whoever shipped 2.1.198 had every advantage I didn’t: the source, the build, the diff, the review, the authors to ask
I had a curl and a grep, and it took about five minutes
So: either nobody looked, or someone looked and shipped it anyway. Both are answers. Neither is good.
The rest of those questions — who wrote it, who reviewed it, who signed it off — stay unanswerable, because the public record where that would live does not exist. That’s the same absence as the missing commit, wearing a different hat.
Which is the uncomfortable finding, not a reassuring one:Can you? Yes. Demonstrably. It took a curl, a strings, and a diff.Should you have to? 156 lines per release, forever, across every tool that auto-updates, to learn what a one-line release note would have told you.Nobody is going to do this. The people most exposed — the ones running unattended agents at scale — are the least likely to be grepping binaries at 2am.
Can you? Yes. Demonstrably. It took a curl, a strings, and a diff.
Should you have to? 156 lines per release, forever, across every tool that auto-updates, to learn what a one-line release note would have told you.
Nobody is going to do this. The people most exposed — the ones running unattended agents at scale — are the least likely to be grepping binaries at 2am.
The capability is a workaround, not a remedy. That it works is what makes its necessity hard to accept.
#Why this is more than burned tokens
Cost angle: an unattended agent auto-answering its own questions can burn tokens on the wrong path
Safety angle is worse than cost:AskUserQuestion is used as an explicit safety gate — hooks/rules built on it assume it blocksTurning a blocking gate into a 60s countdown silently voids that assumptionPeople run Claude Code in genuinely risky contexts — deployments, infra, prod-adjacent scripts
AskUserQuestion is used as an explicit safety gate — hooks/rules built on it assume it blocks
Turning a blocking gate into a 60s countdown silently voids that assumption
People run Claude Code in genuinely risky contexts — deployments, infra, prod-adjacent scripts
Compounding factor: Claude Code auto-updates by defaultA silent behaviour change + auto-update = the gate can change under you without a single action on your partTies back to On Cooldowns and Dependabot Tuning — why “newest immediately” is a risk posture
A silent behaviour change + auto-update = the gate can change under you without a single action on your part
Ties back to On Cooldowns and Dependabot Tuning — why “newest immediately” is a risk posture
On Cooldowns and Dependabot Tuning
#Turning auto-update off
Three env vars. The resolver checks them in this order, first match wins — note it is not the order you’d guess:DISABLE_UPDATES=1 — checked first. The strictest: blocks all update paths, including a manual claude updateDISABLE_AUTOUPDATER=1 — stops the background check only; claude update still works. Takes precedence over the autoUpdates config settingCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 — the big hammer: equivalent to DISABLE_AUTOUPDATER + DISABLE_FEEDBACK_COMMAND + DISABLE_ERROR_REPORTING + DISABLE_TELEMETRY (DISABLE_BUG_COMMAND is the older name, still accepted)
DISABLE_UPDATES=1 — checked first. The strictest: blocks all update paths, including a manual claude update
DISABLE_AUTOUPDATER=1 — stops the background check only; claude update still works. Takes precedence over the autoUpdates config setting
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 — the big hammer: equivalent to DISABLE_AUTOUPDATER + DISABLE_FEEDBACK_COMMAND + DISABLE_ERROR_REPORTING + DISABLE_TELEMETRY (DISABLE_BUG_COMMAND is the older name, still accepted)
All of them freeze your plugins too. That’s documented for exactly one of the three — see below
The “set it everywhere” problem has a fix: don’t export the var from a shell profile — put it in the env block of settings.json, documented as “environment variables that will be applied to every session”. One file, every invocation — CI, cron, systemd, IDE-spawned terminals included.
Scopes, least to most durable:shell profile export → per-shell, easy to miss an environment (the trap)~/.claude/settings.json → user-wide, one place.claude/settings.json in a repo → team-wide, checked into source controlmanaged-settings.json → enterprise policy, highest precedence, centrally enforced:
shell profile export → per-shell, easy to miss an environment (the trap)
~/.claude/settings.json → user-wide, one place
.claude/settings.json in a repo → team-wide, checked into source control
managed-settings.json → enterprise policy, highest precedence, centrally enforced:
No CLI flag exists to disable auto-update; claude update is manual, /doctor reports the update channel and install type
#The catch: this also stops updating your plugins
Turn off the auto-updater by any of the means above and plugin auto-updates stop as well
This one is documented — but on the plugin discovery page, under “Configure auto-updates”, which is not where you are standing when you disable auto-updates:
Where it isn’t: /setup → Disable auto-updates, the page that tells you how to do this, mentions neither plugins nor FORCE_AUTOUPDATE_PLUGINS. Nor does the settings env var table. Follow the docs for the task you’re actually doing and you never learn about it
And the docs only ever say DISABLE_AUTOUPDATER. The binary shows four paths freeze plugins — DISABLE_UPDATES, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC and autoUpdates: false do it too, and that part is undocumented. It’s a single gate on “is the updater off for any reason”:
Unreadable, so here it is again with the five functions renamed by me — h$e → disabledReason, u_e → updatesAreDisabled, blt → pluginUpdatesBlocked, ut → truthy, w → debugLog. The logic is untouched; the names are my guesses at intent, not the authors':
At runtime the skip is nearly invisible — it’s a debug log line, not a warning. Documented or not, nothing in the session tells you your plugins have frozen.
The fix is one more env var — the documented, supported one. It restores plugin updates while leaving the CLI pinned:
FORCE_AUTOUPDATE_PLUGINS, DISABLE_AUTOUPDATER and DISABLE_UPDATES are parsed properly: the parser accepts 1, true, yes, or on (case-insensitive), so =1 does what you’d hope and =0 correctly reads as false
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is not. It’s presence-detected, so any non-empty value switches it on — =0 included, which means CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0 disables your auto-updater and freezes your plugins:
Note the shape of this: a var whose name begins with FORCE_ is the supported way to get the ordinary behaviour of keeping plugins current. It reads like an escape hatch someone added on the way past.
#Sharp edges
Reading the resolver in 2.1.211, four things disable updates — the three env vars above, plus autoUpdates: false in config. All four also stop plugin updates.
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is the surprising one: reach for it as a privacy/egress control and you have silently pinned your CLI and frozen your plugins
autoUpdates: false can be ignored outright. The config path is gated on an extra condition:
So the config key is both undocumented — autoUpdates, as distinct from autoUpdatesChannel, appears nowhere in the docs — and conditionally overridden. Prefer the env vars: they’re checked before it, and unconditionally.
2.1.98 fixed DISABLE_AUTOUPDATER not fully suppressing the npm registry version check and symlink modification on npm-based installs — for a long stretch, “disabled” wasn’t entirely disabled
The auto-updater has overwritten a custom launcher/symlink at ~/.local/bin/claude on every release (since fixed; /doctor now flags externally managed launchers)
Which install methods self-update matters: native and npm auto-update by default; Homebrew, WinGet, apt, dnf and apk do not (Homebrew and WinGet can opt in via CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1). If you’re on native or npm, you are on the daily-cadence treadmill unless you say otherwise
Claude’s research ends here.
#Wrap-up
Even though the source code is not properly public, I was surprised at how much we could learn from it. Having some clues as to what to look for certainly helped, but it’s still not a replacement for a well edited and accurate changelog.
This wasn’t a stray line of code that slipped through. It looks like an actual feature. Why it got shipped completely under the radar remains baffling.
I’m not assuming bad faith on the part of Anthropic. Things happen. Whether or not things like this continue to happen will help us to understand what Anthropic did or did not learn from this misfeature.
#Related posts
On Cooldowns and Dependabot Tuning
On Cooldowns and Dependabot Tuning
The dot claude Attack Surface
Claude Will Find a Way