"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:
● Claude asked:
⎿ …
● No response after 60s — continued without an answer
● The user stepped away. I'll proceed with best judgment. My plan:
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.196released; the reporter’s “last working version (I am guessing)” - 2026-06-30 —
2.1.197released; one changelog line, the Sonnet 5 launch - 2026-07-01 —
2.1.198released — 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 butCHANGELOG.mdandfeed.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.199ships 24 entries while the issue is open. Still no mention. - 2026-07-03 —
2.1.200reverses 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 working2.1.196(guessing)”, Opus, AWS Bedrock, VS Code terminal
git clone https://github.com/anthropics/claude-code.git
cd claude-code
# When did the fix land, and in which version?
git log -1 --format='%h %ai' -S'no longer auto-continue by default' -- CHANGELOG.md
# 1322e9ba 2026-07-03 16:52:26 +0000
# When did the version that shipped the bug land?
git log -1 --format='%h %ai' -S'## 2.1.198' -- CHANGELOG.md
# 75709eac 2026-07-01 20:45:29 +0000
#The misfeature
AskUserQuestionis 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:
// v2.1.198, verbatim. `Thl` is the minifier's name; the prose is the binary's own.
// Note "60s" is interpolated, not a literal in the file:
function Thl(e){return `No response after ${Math.round(e/1000)}s — the user may be
away from keyboard. Proceed using your best judgment based on the context so far;
you can re-ask this question later if it's still relevant.`}
- 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:
// v2.1.198, verbatim. `a` is the minifier's name for "some answers exist"
// (`s=Object.entries(r)` over the answers, `a=s.length>0`); both string values
// are the binary's own.
let d=a?"continued with the answers selected so far":"continued without an answer"
- 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.197and present in2.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:
// v2.1.198, verbatim — the pieces. `s` is the remaining-seconds value.
children:["auto-continue in ",s,"s \xB7 any key to stay"]
// renders as: auto-continue in 12s · any key to stay
- 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
- 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:
// v2.1.198 ships this minified — the locals are mangled, but the property
// names survive, so `showCountdown` and `remainingSeconds` are its own words:
let u=i*1000<=n;return{remainingSeconds:i,showCountdown:u,timeoutMs:t}
// ...which reads, with n at its default of 20000:
showCountdown = remainingSeconds * 1000 <= 20000
- 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 shipped2.1.198code 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 —q0mitself. 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 firestengu_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
- the countdown component (
- But the exemption only protects you if a permission prompt appears at all.
2.1.198shipsbypassPermissions,acceptEdits,allowedTools,--dangerously-skip-permissionsandPreToolUsehooks. 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.
AskUserQuestiondoesn’t ask permission, it asks you to decide — “staging or production?”, “which config?” On timeout the model is told toproceed 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
timeoutparameter 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:
questions, answers, annotations, metadata
- 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.200made 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
/configsetting the note points you to did not exist before the fix. Grep2.1.198foraskUserQuestionTimeoutand 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/configas “Question auto-continue timeout” - accepted values are
60s,5m,10m,never— and unset resolves tonever, which is what makes it opt-in - the hook’s default timeout constant is still
60000; the countdown threshold is20000 - two env vars still override the setting —
CLAUDE_AFK_TIMEOUT_MSandCLAUDE_AFK_COUNTDOWN_MS
- the setting is
- So the fix is a one-line change in a gate, not a deletion:
// v2.1.211, verbatim — only whitespace added. `Upf` is the minifier's name for it;
// everything else here is the binary's own, string values included.
function Upf(e){ switch(e){
case "60s": return 60000; case "5m": return 300000;
case "10m": return 600000; case "never": case void 0: return null; } }
// ^^^^^^^^^^^^ unset => null => disabled
- 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:
// settings.json — disable the auto-continue by setting a huge AFK window
"env": {
"CLAUDE_AFK_TIMEOUT_MS": "<massive number>"
}
- 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.mdin the repo (same content) - Links below are pinned to commit
1322e9bso they show what the notes said at the time, not as edited later 2.1.197changelog: one line, Claude Sonnet 5 launch. Nothing about question timeouts.2.1.198changelog: ~30 entries. Nothing aboutAskUserQuestionauto-continuing.2.1.199changelog: 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
AskUserQuestionis not a stranger to the changelog — it appears 15 times across 13 versions, going back to2.0.55. It is a tool Anthropic documents changes to, routinely:
git show 1322e9b:CHANGELOG.md | grep -c 'AskUserQuestion'
# 15
git show 1322e9b:CHANGELOG.md | awk '/^## /{v=$2} /AskUserQuestion/{print v}' | sort -u | tr '\n' ' '
# 2.0.55 2.1.136 2.1.141 2.1.144 2.1.147 2.1.181 2.1.200 2.1.47 2.1.69 2.1.70 2.1.83 2.1.85 2.1.9
- Which makes the gap the story. Between
2.1.181and2.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:
## 2.1.200
- Changed `AskUserQuestion` dialogs to no longer auto-continue by default;
opt into an idle timeout via `/config`
- The controlling env var
CLAUDE_AFK_TIMEOUT_MSappears nowhere in the changelog or README
#The docs caught up. Afterwards.
CLAUDE_AFK_TIMEOUT_MS — How many milliseconds of idle time before an unanswered
AskUserQuestion dialog auto-continues without you. Auto-continue is off by
default; opt in with the askUserQuestionTimeout setting. [...] In v2.1.198 and
v2.1.199, auto-continue was on by default with a 60000 (60 seconds) timeout.
- But that text cannot be what was there on 1 July, and the binary says so on its own: it points you to
askUserQuestionTimeoutas the opt-in, and that setting has zero occurrences in the2.1.198binary. It also narrates2.1.198and2.1.199in 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:
# every archived capture of the env var reference, 23 Jun - 11 Jul 2026
curl -s "https://web.archive.org/cdx/search/cdx?url=code.claude.com/docs/en/env-vars\
&output=json&filter=statuscode:200&fl=timestamp&from=20260601&to=20260718"
# fetch each one and count mentions of the feature
# (DISABLE_AUTOUPDATER is the control: it should hit on every capture)
for ts in 20260623083334 20260701121132 20260701213540 20260705135805; do
curl -s "https://web.archive.org/web/${ts}id_/https://code.claude.com/docs/en/env-vars" \
| gunzip -c | grep -c -i 'CLAUDE_AFK'
done
| capture (UTC) | CLAUDE_AFK | AskUserQuestion | DISABLE_AUTOUPDATER (control) |
|---|---|---|---|
| 23 Jun | 0 | 0 | 2 |
| 1 Jul 12:11 | 0 | 0 | 2 |
| 1 Jul 21:35 | 0 | 0 | 2 |
| 5 Jul | 2 | 2 | 2 |
2.1.198was published to npm at2026-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: notafk, notauto-continue, notAskUserQuestion, notCOUNTDOWN. (idleappears twice, both unrelated:API_FORCE_IDLE_TIMEOUTand 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.200reversal (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
There isn’t one. Not “hard to find” — it does not exist publicly
Neither does a revert commit. The reversal in
2.1.200has 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:Fine — then diff the source between releases. There is no source to diff
anthropics/claude-codeis 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:
git ls-files | wc -l # 216 tracked files
git ls-files '*.md' | wc -l # 104 of them markdown
git ls-files | cut -d/ -f1 | sort -u | grep -v '^\.'
# CHANGELOG.md demo.gif examples feed.xml LICENSE.md
# plugins README.md Script scripts SECURITY.md
- 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:
git diff --stat v2.1.197..v2.1.198
# CHANGELOG.md | 35 +++++++++++++++++++++++++++
# feed.xml | 77 +++++++++++++++++---------------------------------
# 2 files changed, 74 insertions(+), 38 deletions(-)
feed.xmlis 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 elseThe 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
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 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.
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.197and present in2.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” (
- 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.198itself. Two separate things, worth keeping apart: - 1. 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:
// v2.1.198, verbatim. `H` is the minifier's name for the schema library
// (Zod); the field name and the description text are the binary's own.
afkTimeoutMs: H.number().int().positive().optional().describe("Set when the dialog
auto-resolved after this many milliseconds of idle (user away from keyboard).
Absent on every human-resolved path.")
- 2. An analytics event — this one leaves your machine. Fired at the moment the dialog auto-advances:
// v2.1.198, verbatim. The mangled names (q, ld, It, R, I, s) are the
// minifier's; the event name and every property key are the binary's own.
q("tengu_ask_user_question_afk_auto_advance",{...i&&{source_hash:ld(i)},
timeoutMs:It, questionCount:s.length,
hadPartialAnswers:Object.keys(R).length>0, isInPlanMode:I})
tengu_*is the naming convention for Claude Code’s analytics events throughout the binary. The question text isn’t sent;source_hashis 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:
file ~/.local/share/claude/versions/2.1.211
# ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
# interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., 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:
grep -abo -- '---- Bun! ----' ~/.local/share/claude/versions/2.1.211 | tail -1
# 261973153 <-- ~50KB from the end of a 262MB file
- Bun compiles by embedding the JS bundle inside the executable, so the shipped JavaScript is sitting in the file.
stringsreads it:
strings -n 3 ~/.local/share/claude/versions/2.1.211 | grep -c 'CLAUDE_AFK_TIMEOUT_MS'
# 4
- Every claim in “The fix” above was read straight out of that binary — the setting name, the
60s/5m/10m/nevervalues, 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-codeis a ~152KB installer stub (7 files,dist.unpackedSize155,204) — no product in it. The binary lives in per-platform packages:
npm view @anthropic-ai/[email protected] dist.unpackedSize
# 248900994 <-- ~249MB, the actual thing
- 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.197and2.1.198off npm and diff them. - The feature has a distinctive internal name — AFK. Grep both binaries for it:
for v in 2.1.197 2.1.198; do
echo "=== $v ==="
for s in "away from keyboard" "CLAUDE_AFK_TIMEOUT_MS" "CLAUDE_AFK_COUNTDOWN_MS"; do
printf ' %-26s ' "$s"; strings -n 3 "b-$v/package/claude" | grep -c -- "$s"
done
done
# === 2.1.197 ===
# away from keyboard 0
# CLAUDE_AFK_TIMEOUT_MS 0
# CLAUDE_AFK_COUNTDOWN_MS 0
# === 2.1.198 ===
# away from keyboard 2
# CLAUDE_AFK_TIMEOUT_MS 3
# CLAUDE_AFK_COUNTDOWN_MS 3
- 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:
// Both verbatim, only whitespace added. The mangled names (ke, Ie, li, OOb, Rpf,
// Di, Jyn) are the minifier's; `hasExternalRacer` and the env var are the binary's.
// v2.1.198
ke = !Ie && !n.hasExternalRacer && !li()
// v2.1.211 — same three conditions, plus one
OOb = !Rpf && !Xde.hasExternalRacer && !Di() && (Jyn !== null || ye.CLAUDE_AFK_TIMEOUT_MS !== void 0)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the entire fix
Jynthere is theafkTimeoutMsprop — the/configsetting, resolved through the switch above. That’s the whole difference: in2.1.198the gate existed, but nothing you could set was allowed to close it. In2.1.211it asks your permission firstWorth being precise, because it’s the sort of thing that gets overstated: it isn’t that
2.1.198had 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 influenceTwo 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
A blind string-level diff of
2.1.197→2.1.198:
for v in 2.1.197 2.1.198; do strings -n 8 "b-$v/package/claude" | sort -u > "s-$v.txt"; done
diff s-2.1.197.txt s-2.1.198.txt | grep -c '^[<>]'
# 21903
- 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_MSis 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 3gives 81,289; the default-n 4gives 29,910;-n 8gives 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:
# added strings only: present in 198, absent from 197
diff s-2.1.197.txt s-2.1.198.txt | grep '^>' | sed 's/^> //' > added.txt
wc -l < added.txt
# 16255
# ...that read like English sentences
grep -E '^[A-Z][a-zA-Z0-9 ,.:;'"'"'-]+$' added.txt | awk 'NF>=5' > prose.txt
wc -l < prose.txt
# 156
- 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:
Before going idle the user had selected:
- 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-*.txtwassort -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.198had every advantage I didn’t: the source, the build, the diff, the review, the authors to ask - I had a
curland agrep, 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.
- Whoever shipped
- Which is the uncomfortable finding, not a reassuring one:
- Can you? Yes. Demonstrably. It took a
curl, astrings, and adiff. - 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
- 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:
AskUserQuestionis 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 default
- 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
#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 manualclaude updateDISABLE_AUTOUPDATER=1— stops the background check only;claude updatestill works. Takes precedence over theautoUpdatesconfig settingCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1— the big hammer: equivalent toDISABLE_AUTOUPDATER+DISABLE_FEEDBACK_COMMAND+DISABLE_ERROR_REPORTING+DISABLE_TELEMETRY(DISABLE_BUG_COMMANDis 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
envblock ofsettings.json, documented as “environment variables that will be applied to every session”. One file, every invocation — CI, cron, systemd, IDE-spawned terminals included.
// ~/.claude/settings.json — user-wide, survives every shell/IDE/CI invocation
{
"env": {
"DISABLE_AUTOUPDATER": "1"
}
}
- 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.jsonin a repo → team-wide, checked into source controlmanaged-settings.json→ enterprise policy, highest precedence, centrally enforced:
macOS: /Library/Application Support/ClaudeCode/managed-settings.json
Linux/WSL: /etc/claude-code/managed-settings.json
Windows: C:\Program Files\ClaudeCode\managed-settings.json
(C:\ProgramData\ClaudeCode is the legacy path and is no longer read.)
- No CLI flag exists to disable auto-update;
claude updateis manual,/doctorreports 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:
To disable all automatic updates entirely for both Claude Code and all plugins,
set the DISABLE_AUTOUPDATER environment variable.
To keep plugin auto-updates enabled while disabling Claude Code auto-updates,
set FORCE_AUTOUPDATE_PLUGINS=1 along with DISABLE_AUTOUPDATER.
- Where it isn’t:
/setup→ Disable auto-updates, the page that tells you how to do this, mentions neither plugins norFORCE_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_TRAFFICandautoUpdates: falsedo it too, and that part is undocumented. It’s a single gate on “is the updater off for any reason”:
// v2.1.211, verbatim. The names are meaningless because the minifier ate them —
// the env var and the log string survive, being strings rather than identifiers.
function u_e(){return h$e()!==null}
function blt(){return u_e()&&!ut(process.env.FORCE_AUTOUPDATE_PLUGINS)}
// ...and at the plugin auto-update entry point:
if(blt()){w("Plugin autoupdate: skipped (auto-updater disabled)");return}
- 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':
// NOT the shipped code — function names below are mine, invented for legibility.
// The control flow, the env var and the log string are verbatim from v2.1.211.
function updatesAreDisabled(){ return disabledReason() !== null }
function pluginUpdatesBlocked(){
return updatesAreDisabled() && !truthy(process.env.FORCE_AUTOUPDATE_PLUGINS)
}
if (pluginUpdatesBlocked()) { debugLog("Plugin autoupdate: skipped (auto-updater disabled)"); return }
- 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:
// ~/.claude/settings.json — pin the CLI, keep plugins current
{
"env": {
"DISABLE_AUTOUPDATER": "1",
"FORCE_AUTOUPDATE_PLUGINS": "1"
}
}
FORCE_AUTOUPDATE_PLUGINS,DISABLE_AUTOUPDATERandDISABLE_UPDATESare parsed properly: the parser accepts1,true,yes, oron(case-insensitive), so=1does what you’d hope and=0correctly reads as falseCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICis not. It’s presence-detected, so any non-empty value switches it on —=0included, which meansCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0disables your auto-updater and freezes your plugins:
// v2.1.211, verbatim. `Z3t` is the minifier's name; the env var is the binary's.
// Note the bare `process.env.X` — the value is never read, only its presence.
function Z3t(){if(process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)return"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC";return null}
- 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, plusautoUpdates: falsein config. All four also stop plugin updates. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICis the surprising one: reach for it as a privacy/egress control and you have silently pinned your CLI and frozen your pluginsautoUpdates: falsecan be ignored outright. The config path is gated on an extra condition:
// v2.1.211, verbatim — `t` is the minified name for the settings object; every
// key on it is real, because property names survive. `!1`/`!0` are false/true.
if(t.autoUpdates===!1&&(t.installMethod!=="native"||t.autoUpdatesProtectedForNative!==!0))return{type:"config"}
// ^ on a native install with autoUpdatesProtectedForNative,
// your `autoUpdates: false` does nothing at all
- So the config key is both undocumented —
autoUpdates, as distinct fromautoUpdatesChannel, appears nowhere in the docs — and conditionally overridden. Prefer the env vars: they’re checked before it, and unconditionally. 2.1.98fixedDISABLE_AUTOUPDATERnot 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/claudeon every release (since fixed;/doctornow 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.
