"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:
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:
There are a few things I don’t know if we have learned:
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.
2.1.196 released; the reporter’s “last working version (I am guessing)”2.1.197 released; one changelog line, the Sonnet 5 launch2.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.xmlCLAUDE_AFK_TIMEOUT_MS. Traded peer-to-peer in the thread, not pointed to from any release note2.1.199 ships 24 entries while the issue is open. Still no mention.2.1.200 reverses the behaviour; again the only public trace is the notes commit (1322e9b)2.1.198, “last working 2.1.196 (guessing)”, Opus, AWS Bedrock, VS Code terminalgit 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
AskUserQuestion is the tool Claude Code uses to stop and ask the human a question mid-task// 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.`}
// 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"
2.1.197 and present in 2.1.198// 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
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
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: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 componentjsx(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_advanceDo you want to proceed, Do you want to allow …) is a separate component with no timer attached2.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 nothingAskUserQuestion 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 lefttimeout 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:questions, answers, annotations, metadata
2.1.200 made auto-continue off by default/config, not forced on everyone/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.2.1.211, eleven releases later). The machinery is entirely intact:askUserQuestionTimeout, surfaced in /config as “Question auto-continue timeout”60s, 5m, 10m, never — and unset resolves to never, which is what makes it opt-in60000; the countdown threshold is 20000CLAUDE_AFK_TIMEOUT_MS and CLAUDE_AFK_COUNTDOWN_MS// 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
// settings.json — disable the auto-continue by setting a huge AFK window
"env": {
"CLAUDE_AFK_TIMEOUT_MS": "<massive number>"
}
CHANGELOG.md in the repo (same content)1322e9b so they show what the notes said at the time, not as edited later2.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.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: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
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 second2.1.200:## 2.1.200
- Changed `AskUserQuestion` dialogs to no longer auto-continue by default;
opt into an idle timeout via `/config`
CLAUDE_AFK_TIMEOUT_MS appears nowhere in the changelog or READMECLAUDE_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.
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# 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.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 grep2.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 behaviourThe 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.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:
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:
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
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 yougit diff --stat v2.1.197..v2.1.198
# CHANGELOG.md | 35 +++++++++++++++++++++++++++
# feed.xml | 77 +++++++++++++++++---------------------------------
# 2 files changed, 74 insertions(+), 38 deletions(-)
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
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:
CLAUDE_AFK_TIMEOUT_MS) that users found by asking each other, no release note having named itThe 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:
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.
CLAUDE_AFK_TIMEOUT_MS)earlye)2.1.198 itself. Two separate things, worth keeping apart:// 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.")
// 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_hash is a hash, and the rest is counters2.1.197. It arrived with the feature, in the same releasehadPartialAnswers. 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 restauto-continue in {n}s · any key to stayfile ~/.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
grep -abo -- '---- Bun! ----' ~/.local/share/claude/versions/2.1.211 | tail -1
# 261973153 <-- ~50KB from the end of a 262MB file
strings reads it:strings -n 3 ~/.local/share/claude/versions/2.1.211 | grep -c 'CLAUDE_AFK_TIMEOUT_MS'
# 4
60s/5m/10m/never values, the analytics event, the countdown env varls ~/.local/share/claude/versions/), but only the last few — not enough to reach back to July@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:npm view @anthropic-ai/claude-code-linux-x64@2.1.198 dist.unpackedSize
# 248900994 <-- ~249MB, the actual thing
2.1.197 and 2.1.198 off npm and diff them.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
// 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
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
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
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
CLAUDE_AFK_TIMEOUT_MS is in there — buried in 21,902 lines of noisestrings -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# 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
Before going idle the user had selected:
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 kind2.1.198 had every advantage I didn’t: the source, the build, the diff, the review, the authors to askcurl and a grep, and it took about five minutescurl, a strings, and a diff.AskUserQuestion is used as an explicit safety gate — hooks/rules built on it assume it blocksDISABLE_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)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.// ~/.claude/settings.json — user-wide, survives every shell/IDE/CI invocation
{
"env": {
"DISABLE_AUTOUPDATER": "1"
}
}
~/.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: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.)
claude update is manual, /doctor reports the update channel and install typeTo 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.
/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 itDISABLE_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”:// 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}
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 }
// ~/.claude/settings.json — pin the CLI, keep plugins current
{
"env": {
"DISABLE_AUTOUPDATER": "1",
"FORCE_AUTOUPDATE_PLUGINS": "1"
}
}
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 falseCLAUDE_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:// 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}
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.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 pluginsautoUpdates: false can 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
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~/.local/bin/claude on every release (since fixed; /doctor now flags externally managed launchers)CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1). If you’re on native or npm, you are on the daily-cadence treadmill unless you say otherwiseClaude’s research ends here.
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.