{"slug": "claude-code-anatomy-of-a-misfeature", "title": "Claude Code: Anatomy of a Misfeature", "summary": "Anthropic shipped an unannounced feature in Claude Code version 2.1.198 on July 1, 2026, that allows the AI agent to continue autonomously after 60 seconds if a user does not respond to a prompt. The change was not documented in the changelog, raising concerns about user trust and safety, especially when running multiple agents or using them for deployments. Anthropic released a fix within days, but the incident highlights risks of rapid, undocumented updates in AI coding tools.", "body_md": "# Claude Code: Anatomy of a Misfeature\n\n## contents\n\n\"[Mechanical egg timer](https://commons.wikimedia.org/wiki/File:Mechanical_egg_timer.jpg)\" by [Hustvedt](https://commons.wikimedia.org/wiki/User:Hustvedt) is licensed under [CC BY-SA 3.0 ](https://creativecommons.org/licenses/by-sa/3.0/). Padded to a wider frame; this adaptation is likewise licensed CC BY-SA 3.0.\n\nOn Canada Day (July 1), 2026, Anthropic shipped a surprising “easter egg” to\nusers of Claude Code: `2.1.198`\n\nincludes an efficiency bypass which allows\nagents to continue on without being blocked on direction from a human. You\nessentially get a 60 second timer after Claude Code asks for input. If you miss\nthe window, Claude Code helpfully does what it thinks is best and continues on\nits way. It looks like this:\n\n```\n● Claude asked:\n  ⎿ …\n\n  ● No response after 60s — continued without an answer\n\n● The user stepped away. I'll proceed with best judgment. My plan:\n```\n\n*Note: the above is taken verbatim from one of my own claude sessions, with the questions having been trimmed.*\n\nIf you find this behaviour surprising, you’re not alone. Let’s consider the possible consequences:\n\n- 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?\n- 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?\n- What if the agent makes the wrong choice? How many tokens have been burned in the meantime?\n- What if you are using agents for deployments? (Yes, I know, but\n*what if*)\n\nThese 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!)\n\nThe 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?\n\nWe’ve learned a few things:\n\n- Surprising features in Claude Code can in theory (and in practice) be shipped by Anthropic on a daily cadence\n- Not every feature will necessarily appear in the changelog\n- Things that should not be defaults may not have a documented off switch\n- Claude Code’s auto update feature feels more like YOLO mode than we might have suspected early on\n\nThere are a few things I don’t know if we have learned:\n\n- How do humans fit into this equation?\n- Did a human dream up the feature?\n- Did a human write (or have an agent write) this feature?\n- Did a human review the feature?\n- Did a human sign off on the feature?\n- Did a human merge the feature?\n- Did a human opt out of documenting the feature or adding it to the changelog?\n- 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?\n\nPersonally 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”.\n\nI’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.\n\n*Claude’s research begins here.*\n\n[#](#timeline)Timeline\n\n**2026-06-29**—`2.1.196`\n\nreleased; the reporter’s “last working version (I am guessing)”**2026-06-30**—`2.1.197`\n\nreleased; one changelog line, the Sonnet 5 launch**2026-07-01**—No public commit shows the change; the only public trace of this release is the bot commit publishing its notes (`2.1.198`\n\nreleased — the version the reporter pins the regression to.), which touches nothing but`75709ea`\n\n`CHANGELOG.md`\n\nand`feed.xml`\n\n**2026-07-02 02:54 UTC**— issue[#73125](https://github.com/anthropics/claude-code/issues/73125)filed by[Aleksey Nogin](https://github.com/ANogin)**2026-07-02 03:45 UTC**—[a commenter](https://github.com/noxasaxon)[surfaces the escape hatch](https://github.com/anthropics/claude-code/issues/73125#issuecomment-4861995993):`CLAUDE_AFK_TIMEOUT_MS`\n\n. Traded peer-to-peer in the thread, not pointed to from any release note**2026-07-02**—`2.1.199`\n\nships 24 entries while the issue is open. Still no mention.**2026-07-03**—`2.1.200`\n\nreverses the behaviour; again the only public trace is the notes commit ()`1322e9b`\n\n**2026-07-04 18:04 UTC**— issue closed- Reaction/scale on the issue:\n**384 👍**,** 143 comments**— not a niche complaint - Reporter’s environment:\n`2.1.198`\n\n, “last working`2.1.196`\n\n(guessing)”, Opus, AWS Bedrock, VS Code terminal\n\n```\ngit clone https://github.com/anthropics/claude-code.git\ncd claude-code\n\n# When did the fix land, and in which version?\ngit log -1 --format='%h %ai' -S'no longer auto-continue by default' -- CHANGELOG.md\n# 1322e9ba 2026-07-03 16:52:26 +0000\n\n# When did the version that shipped the bug land?\ngit log -1 --format='%h %ai' -S'## 2.1.198' -- CHANGELOG.md\n# 75709eac 2026-07-01 20:45:29 +0000\n```\n\n[#](#the-misfeature)The misfeature\n\n`AskUserQuestion`\n\nis the tool Claude Code uses to stop and ask the human a question mid-task- New behaviour: after\n**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:\n\n```\n// v2.1.198, verbatim. `Thl` is the minifier's name; the prose is the binary's own.\n// Note \"60s\" is interpolated, not a literal in the file:\nfunction Thl(e){return `No response after ${Math.round(e/1000)}s — the user may be\naway from keyboard. Proceed using your best judgment based on the context so far;\nyou can re-ask this question later if it's still relevant.`}\n```\n\n- 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\n- The transcript line has\n**two variants**— the binary picks between them on whether you’d started answering:\n\n```\n// v2.1.198, verbatim. `a` is the minifier's name for \"some answers exist\"\n// (`s=Object.entries(r)` over the answers, `a=s.length>0`); both string values\n// are the binary's own.\nlet d=a?\"continued with the answers selected so far\":\"continued without an answer\"\n```\n\n- So a half-answered dialog does not discard the partial input — it\n**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\n`2.1.197`\n\nand present in`2.1.198`\n\n**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:\n\n```\n// v2.1.198, verbatim — the pieces. `s` is the remaining-seconds value.\nchildren:[\"auto-continue in \",s,\"s \\xB7 any key to stay\"]\n// renders as:  auto-continue in 12s · any key to stay\n```\n\n- 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:\n- the internal name is\n**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\n\n- the internal name is\n- And the countdown is late. The threshold defaults to\n**20 seconds**(`CLAUDE_AFK_COUNTDOWN_MS`\n\n), 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:\n\n```\n// v2.1.198 ships this minified — the locals are mangled, but the property\n// names survive, so `showCountdown` and `remainingSeconds` are its own words:\nlet u=i*1000<=n;return{remainingSeconds:i,showCountdown:u,timeoutMs:t}\n\n// ...which reads, with n at its default of 20000:\nshowCountdown = remainingSeconds * 1000 <= 20000\n```\n\n- The warning arrives in the last third\n**What it does not touch:** the timeout applies only to`AskUserQuestion`\n\n. Anthropic’s[tools reference](https://code.claude.com/docs/en/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`\n\ncode rather than a page written after the fix:- the countdown component (\n`q0m`\n\n) has**exactly one call site** in the entire bundle, and its timer hook (`_Rc`\n\n) has exactly one caller —`q0m`\n\nitself. The timer exists in one component - that component’s props identify it past argument:\n`jsx(dRc,{question:V, questions:s, currentQuestionIndex:$, answers:R, questionStates:O, onAnswer:be, onSubmit:N, …})`\n\n. Its timeout handler is what fires`tengu_ask_user_question_afk_auto_advance`\n\n- the permission prompt (\n`Do you want to proceed`\n\n,`Do you want to allow …`\n\n) is a separate component with no timer attached\n\n- the countdown component (\n**But the exemption only protects you if a permission prompt appears at all.**`2.1.198`\n\nships`bypassPermissions`\n\n,`acceptEdits`\n\n,`allowedTools`\n\n,`--dangerously-skip-permissions`\n\nand`PreToolUse`\n\nhooks. 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:\n**the timer could not grant permission, but it could make the choice.**`AskUserQuestion`\n\ndoesn’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`\n\n, 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\n`timeout`\n\nparameter exists in the tool schema — the model can neither set nor control it. Verified in the binary (`U_f=…H.strictObject({questions:…})`\n\n), not taken from the issue thread. The input params are only:\n\n```\nquestions, answers, annotations, metadata\n```\n\n- So the model is truthful when it says it didn’t skip anything — the\n*harness*returned the answer, not the model\n\n[#](#the-fix)The fix\n\n`2.1.200`\n\nmade auto-continue**off by default**- Idle timeout is now\n**opt-in via**, not forced on everyone`/config`\n\n- Note the changelog’s verb:\n*“no longer auto-continue*. Nothing was removed — the default flipped.**by default**” - The\n`/config`\n\nsetting the note points you to**did not exist before the fix**. Grep`2.1.198`\n\nfor`askUserQuestionTimeout`\n\nand 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 (\n`2.1.211`\n\n, eleven releases later). The machinery is entirely intact:- the setting is\n`askUserQuestionTimeout`\n\n, surfaced in`/config`\n\nas**“Question auto-continue timeout”** - accepted values are\n`60s`\n\n,`5m`\n\n,`10m`\n\n,`never`\n\n— and unset resolves to`never`\n\n, which is what makes it opt-in - the hook’s default timeout constant is still\n`60000`\n\n; the countdown threshold is`20000`\n\n**two** env vars still override the setting —`CLAUDE_AFK_TIMEOUT_MS`\n\nand`CLAUDE_AFK_COUNTDOWN_MS`\n\n- the setting is\n- So the fix is a one-line change in a gate, not a deletion:\n\n```\n// v2.1.211, verbatim — only whitespace added. `Upf` is the minifier's name for it;\n// everything else here is the binary's own, string values included.\nfunction Upf(e){ switch(e){\n  case \"60s\": return 60000; case \"5m\": return 300000;\n  case \"10m\": return 600000; case \"never\": case void 0: return null; } }\n//                                        ^^^^^^^^^^^^ unset => null => disabled\n```\n\n- Fair reading: this is the\n*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.\n- Pre-fix stopgap (from the thread), for anyone pinned to an affected version:\n\n```\n// settings.json — disable the auto-continue by setting a huge AFK window\n\"env\": {\n  \"CLAUDE_AFK_TIMEOUT_MS\": \"<massive number>\"\n}\n```\n\n- Turnaround was fast — roughly two days from report to reversal (credit where due)\n\n[#](#which-release-shipped-it--and-what-the-notes-said)Which release shipped it — and what the notes said\n\n- Two places publish release notes: the\n[official changelog](https://code.claude.com/docs/en/changelog)andin the repo (same content)`CHANGELOG.md`\n\n- Links below are pinned to commit\n`1322e9b`\n\nso they show what the notes said at the time, not as edited later changelog:`2.1.197`\n\n**one line**, Claude Sonnet 5 launch. Nothing about question timeouts.changelog:`2.1.198`\n\n**~30 entries**. Nothing about`AskUserQuestion`\n\nauto-continuing.changelog:`2.1.199`\n\n**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\n*added* `AskUserQuestion`\n\nis not a stranger to the changelog — it appears 15 times across 13 versions, going back to`2.0.55`\n\n. It is a tool Anthropic documents changes to, routinely:\n\n```\ngit show 1322e9b:CHANGELOG.md | grep -c 'AskUserQuestion'\n# 15\ngit show 1322e9b:CHANGELOG.md | awk '/^## /{v=$2} /AskUserQuestion/{print v}' | sort -u | tr '\\n' ' '\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\n```\n\n- Which makes the gap the story. Between\nand`2.1.181`\n\n— the window containing the change — it appears`2.1.200`\n\n**nowhere**. The behaviour changed twice, on and then off, and the notes record only the second - The\n**only** changelog line that ever mentions the auto-continue is the one that*removes*it, in:`2.1.200`\n\n```\n## 2.1.200\n- Changed `AskUserQuestion` dialogs to no longer auto-continue by default;\n  opt into an idle timeout via `/config`\n```\n\n- The controlling env var\n`CLAUDE_AFK_TIMEOUT_MS`\n\nappears**nowhere** in the changelog or README\n\n[#](#the-docs-caught-up-afterwards)The docs caught up. Afterwards.\n\n- Today, both env vars\n**are** documented, in the[environment variables reference](https://code.claude.com/docs/en/env-vars). The entry is candid about the episode:\n\n```\nCLAUDE_AFK_TIMEOUT_MS — How many milliseconds of idle time before an unanswered\nAskUserQuestion dialog auto-continues without you. Auto-continue is off by\ndefault; opt in with the askUserQuestionTimeout setting. [...] In v2.1.198 and\nv2.1.199, auto-continue was on by default with a 60000 (60 seconds) timeout.\n```\n\n**But that text cannot be what was there on 1 July**, and the binary says so on its own: it points you to`askUserQuestionTimeout`\n\nas the opt-in, and that setting has**zero** occurrences in the`2.1.198`\n\nbinary. It also narrates`2.1.198`\n\nand`2.1.199`\n\nin 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:\n\n``` js\n# every archived capture of the env var reference, 23 Jun - 11 Jul 2026\ncurl -s \"https://web.archive.org/cdx/search/cdx?url=code.claude.com/docs/en/env-vars\\\n&output=json&filter=statuscode:200&fl=timestamp&from=20260601&to=20260718\"\n\n# fetch each one and count mentions of the feature\n#   (DISABLE_AUTOUPDATER is the control: it should hit on every capture)\nfor ts in 20260623083334 20260701121132 20260701213540 20260705135805; do\n  curl -s \"https://web.archive.org/web/${ts}id_/https://code.claude.com/docs/en/env-vars\" \\\n    | gunzip -c | grep -c -i 'CLAUDE_AFK'\ndone\n```\n\n| capture (UTC) | `CLAUDE_AFK` | `AskUserQuestion` | `DISABLE_AUTOUPDATER` (control) |\n|---|---|---|---|\n| 23 Jun | 0 | 0 | 2 |\n1 Jul 12:11 | 0 | 0 | 2 |\n1 Jul 21:35 | 0 | 0 | 2 |\n| 5 Jul | 2 | 2 | 2 |\n\nThe 21:35 capture is`2.1.198`\n\nwas published to npm at`2026-07-01T16:50:16Z`\n\n.**four hours and forty-five minutes later**— and the page does not mention the feature under any name: not`afk`\n\n, not`auto-continue`\n\n, not`AskUserQuestion`\n\n, not`COUNTDOWN`\n\n. (`idle`\n\nappears twice, both unrelated:`API_FORCE_IDLE_TIMEOUT`\n\nand 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\n**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\n`2.1.200`\n\nreversal (`2026-07-03T04:33:49Z`\n\n). 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\n\n[#](#so-wheres-the-commit)So where’s the commit?\n\nThe obvious next move: open the commit that introduced it and read the reasoning\n\n**There isn’t one.** Not “hard to find” — it does not exist publiclyNeither does a revert commit. The reversal in\n\n`2.1.200`\n\nhas no public commit either.The only git artifacts either release left behind are two automated changelog commits, both titled\n\n`chore: Update CHANGELOG.md and feed.xml`\n\n— notes*about*a release, never the release:Fine — then diff the source between releases. There is no source to diff\n\n`anthropics/claude-code`\n\nis 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:\n\n```\ngit ls-files | wc -l          # 216 tracked files\ngit ls-files '*.md' | wc -l   # 104 of them markdown\n\ngit ls-files | cut -d/ -f1 | sort -u | grep -v '^\\.'\n# CHANGELOG.md  demo.gif   examples   feed.xml   LICENSE.md\n# plugins       README.md  Script     scripts    SECURITY.md\n```\n\n- Every executable file in there is an example or a maintenance script.\n`plugins/`\n\nholds sample plugins,`examples/`\n\nholds a GCP gateway Terraform config and an MDM profile,`scripts/`\n\nis eight files of issue-tracker automation (`auto-close-duplicates.ts`\n\n,`sweep.ts`\n\n,`gh.sh`\n\n). Nothing in it ships to you - The repo does tag releases, so the tags at least\n*look*diffable. They aren’t, in the way that matters:\n\n```\ngit diff --stat v2.1.197..v2.1.198\n#  CHANGELOG.md | 35 +++++++++++++++++++++++++++\n#  feed.xml     | 77 +++++++++++++++++---------------------------------\n#  2 files changed, 74 insertions(+), 38 deletions(-)\n```\n\n`feed.xml`\n\nis the changelog restated as RSS, so that diff is the changelog twice. Across ten consecutive releases (`2.1.196`\n\n→`2.1.206`\n\n), every tag-to-tag diff touches those two files and nothing else**The punchline: a release tag diff** These are release-note tags, not source tags — there is no version of the code to check out*is*the release notes.So “read the release notes” fails (silent change), “diff the repo” fails (no source), and “diff the tags” fails (the tags\n\n*are*the notes). Three dead ends, one cause: nothing Anthropic publishes to git is the thing they ship youThe authored source is published nowhere; the behaviour ships only inside the compiled binary\n\nSo the sum of public evidence that this feature ever existed is:\n\n- the message it printed into people’s terminals\n- an env var (\n`CLAUDE_AFK_TIMEOUT_MS`\n\n) that users found by asking each other, no release note having named it - one changelog line, three days later, announcing its removal\n\nThe feature’s\n\n*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:\n\n- there is no public\n**source repository**— true, and that’s the governance problem - there is no way to\n**see what shipped**— false, and that turns out to matter enormously (below)\n\n- there is no public\nSkip ahead if you like: the shipped binary settles the question that git can’t. The feature is provably absent from\n\n`2.1.197`\n\nand present in`2.1.198`\n\n, and you can check that yourself in about five minutes.\n\n[#](#why-was-it-added-evidence-not-confirmation)Why was it added? (evidence, not confirmation)\n\n- No official rationale was ever published (no design doc, no changelog line, no PR — see below)\n- Circumstantial evidence of intent, all from naming and the message text:\n- Internal name is\n**AFK**— “away from keyboard” (`CLAUDE_AFK_TIMEOUT_MS`\n\n) - The message assumes an\n*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\n\n- Internal name is\n- Users in the thread describe exactly the workflow it breaks: dozens of agents, some parked for days, waiting on a human by design (\n`earlye`\n\n) - Tension: the same design that unblocks an absent human also lets an agent pick a fork in the road you explicitly reserved for yourself\n- The strongest evidence is inside the binary, and it’s present in\n`2.1.198`\n\nitself. 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:\n\n```\n// v2.1.198, verbatim. `H` is the minifier's name for the schema library\n// (Zod); the field name and the description text are the binary's own.\nafkTimeoutMs: H.number().int().positive().optional().describe(\"Set when the dialog\nauto-resolved after this many milliseconds of idle (user away from keyboard).\nAbsent on every human-resolved path.\")\n```\n\n**2. An analytics event — this one leaves your machine.** Fired at the moment the dialog auto-advances:\n\n```\n// v2.1.198, verbatim. The mangled names (q, ld, It, R, I, s) are the\n// minifier's; the event name and every property key are the binary's own.\nq(\"tengu_ask_user_question_afk_auto_advance\",{...i&&{source_hash:ld(i)},\n  timeoutMs:It, questionCount:s.length,\n  hadPartialAnswers:Object.keys(R).length>0, isInPlanMode:I})\n```\n\n`tengu_*`\n\nis the naming convention for Claude Code’s analytics events throughout the binary. The question text isn’t sent;`source_hash`\n\nis a hash, and the rest is counters- Absent from\n`2.1.197`\n\n. 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\n*without*a human — how many questions were pending, whether it happened mid-plan, and**whether you had partially answered** - That last one is\n`hadPartialAnswers`\n\n. 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\n**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\n*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:\n`auto-continue in {n}s · any key to stay`\n\n- So the capability was designed, instrumented, and given UI — and\n*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.\n\n[#](#can-you-even-diff-it)Can you even diff it?\n\n- Everything above says\n*no*. Then you look at what’s actually installed and the answer changes. - It’s a ~250MB native executable — and, crucially,\n**not stripped**:\n\n```\nfile ~/.local/share/claude/versions/2.1.211\n# ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,\n# interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., not stripped\n```\n\n- It’s a\n**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:\n\n```\ngrep -abo -- '---- Bun! ----' ~/.local/share/claude/versions/2.1.211 | tail -1\n# 261973153      <-- ~50KB from the end of a 262MB file\n```\n\n- Bun compiles by\n**embedding the JS bundle inside the executable**, so the shipped JavaScript is sitting in the file.`strings`\n\nreads it:\n\n```\nstrings -n 3 ~/.local/share/claude/versions/2.1.211 | grep -c 'CLAUDE_AFK_TIMEOUT_MS'\n# 4\n```\n\n- Every claim in “The fix” above was read straight out of that binary — the setting name, the\n`60s/5m/10m/never`\n\nvalues, the analytics event, the countdown env var - The installer keeps recent versions on disk (\n`ls ~/.local/share/claude/versions/`\n\n), but only the last few — not enough to reach back to July - Any version you like is on npm, though. The catch:\n`@anthropic-ai/claude-code`\n\nis a**~152KB installer stub**(7 files,`dist.unpackedSize`\n\n155,204) — no product in it. The binary lives in per-platform packages:\n\n```\nnpm view @anthropic-ai/claude-code-linux-x64@2.1.198 dist.unpackedSize\n# 248900994      <-- ~249MB, the actual thing\n```\n\n- So: closed source, but not a black box. Every release is fetchable and every release is legible. The distinction matters.\n\n[#](#so-lets-actually-diff-it)So let’s actually diff it\n\n- So let’s stop theorising and actually do it: pull\n`2.1.197`\n\nand`2.1.198`\n\noff npm and diff them. - The feature has a distinctive internal name —\n**AFK**. Grep both binaries for it:\n\n``` php\nfor v in 2.1.197 2.1.198; do\n  echo \"=== $v ===\"\n  for s in \"away from keyboard\" \"CLAUDE_AFK_TIMEOUT_MS\" \"CLAUDE_AFK_COUNTDOWN_MS\"; do\n    printf '  %-26s ' \"$s\"; strings -n 3 \"b-$v/package/claude\" | grep -c -- \"$s\"\n  done\ndone\n\n# === 2.1.197 ===\n#   away from keyboard         0\n#   CLAUDE_AFK_TIMEOUT_MS      0\n#   CLAUDE_AFK_COUNTDOWN_MS    0\n# === 2.1.198 ===\n#   away from keyboard         2\n#   CLAUDE_AFK_TIMEOUT_MS      3\n#   CLAUDE_AFK_COUNTDOWN_MS    3\n```\n\n**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.\n- 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:\n\n```\n// Both verbatim, only whitespace added. The mangled names (ke, Ie, li, OOb, Rpf,\n// Di, Jyn) are the minifier's; `hasExternalRacer` and the env var are the binary's.\n// v2.1.198\nke  = !Ie  && !n.hasExternalRacer   && !li()\n// v2.1.211 — same three conditions, plus one\nOOb = !Rpf && !Xde.hasExternalRacer && !Di() && (Jyn !== null || ye.CLAUDE_AFK_TIMEOUT_MS !== void 0)\n//                                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the entire fix\n```\n\n`Jyn`\n\nthere is the`afkTimeoutMs`\n\nprop — the`/config`\n\nsetting, resolved through the switch above. That’s the whole difference: in`2.1.198`\n\nthe gate existed, but nothing you could set was allowed to close it. In`2.1.211`\n\nit asks your permission firstWorth being precise, because it’s the sort of thing that gets overstated: it isn’t that\n\n`2.1.198`\n\nhad*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\n\n`&&`\n\nclause. Which is also the point: it was one`&&`\n\nclause 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\n\nA blind string-level diff of\n\n`2.1.197`\n\n→`2.1.198`\n\n:\n\n```\nfor v in 2.1.197 2.1.198; do strings -n 8 \"b-$v/package/claude\" | sort -u > \"s-$v.txt\"; done\ndiff s-2.1.197.txt s-2.1.198.txt | grep -c '^[<>]'\n# 21903\n```\n\n**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`\n\nis 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\n*invocation*, not of the release.`strings -n 3`\n\ngives 81,289; the default`-n 4`\n\ngives 29,910;`-n 8`\n\ngives 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:\n\n```\n# added strings only: present in 198, absent from 197\ndiff s-2.1.197.txt s-2.1.198.txt | grep '^>' | sed 's/^> //' > added.txt\nwc -l < added.txt\n# 16255\n\n# ...that read like English sentences\ngrep -E '^[A-Z][a-zA-Z0-9 ,.:;'\"'\"'-]+$' added.txt | awk 'NF>=5' > prose.txt\nwc -l < prose.txt\n# 156\n```\n\n**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:\n\n```\nBefore going idle the user had selected:\n```\n\n- 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\n- It lands at line 11 of the 156, which sounds like a lucky break and isn’t:\n`s-*.txt`\n\nwas`sort -u`\n\n’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\n\n[#](#what-it-costs-to-find-that)What it costs to find that\n\n**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.”\n- Which answers one of the questions from the top of this post —\n*did a human release manager diff the release against the previous one before it went out the door?*- Whoever shipped\n`2.1.198`\n\nhad every advantage I didn’t: the source, the build, the diff, the review, the authors to ask - I had a\n`curl`\n\nand a`grep`\n\n, and it took about five minutes - So: either nobody looked, or someone looked and shipped it anyway. Both are answers. Neither is good.\n- 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.\n\n- Whoever shipped\n- Which is the uncomfortable finding, not a reassuring one:\n**Can you?** Yes. Demonstrably. It took a`curl`\n\n, a`strings`\n\n, and a`diff`\n\n.**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.\n\n- The capability is a workaround, not a remedy. That it\n*works*is what makes its necessity hard to accept.\n\n[#](#why-this-is-more-than-burned-tokens)Why this is more than burned tokens\n\n- Cost angle: an unattended agent auto-answering its own questions can burn tokens on the wrong path\n- Safety angle is worse than cost:\n`AskUserQuestion`\n\nis 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\n- People run Claude Code in genuinely risky contexts — deployments, infra, prod-adjacent scripts\n\n- Compounding factor: Claude Code\n**auto-updates by default**- A silent behaviour change + auto-update = the gate can change under you without a single action on your part\n- Ties back to\n[On Cooldowns and Dependabot Tuning](/2026/05/19/On-Cooldowns-and-Dependabot-Tuning)— why “newest immediately” is a risk posture\n\n[#](#turning-auto-update-off)Turning auto-update off\n\n- Three env vars. The resolver checks them in this order, first match wins — note it is\n*not*the order you’d guess:`DISABLE_UPDATES=1`\n\n— checked first. The strictest: blocks*all*update paths, including a manual`claude update`\n\n`DISABLE_AUTOUPDATER=1`\n\n— stops the background check only;`claude update`\n\nstill works.**Takes precedence over** the`autoUpdates`\n\nconfig setting`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`\n\n— the big hammer: equivalent to`DISABLE_AUTOUPDATER`\n\n+`DISABLE_FEEDBACK_COMMAND`\n\n+`DISABLE_ERROR_REPORTING`\n\n+`DISABLE_TELEMETRY`\n\n(`DISABLE_BUG_COMMAND`\n\nis the older name, still accepted)\n\n- All of them freeze your plugins too. That’s documented for exactly one of the three — see below\n**The “set it everywhere” problem has a fix**: don’t export the var from a shell profile — put it in the`env`\n\nblock of`settings.json`\n\n, documented as*“environment variables that will be applied to every session”*. One file, every invocation — CI, cron, systemd, IDE-spawned terminals included.\n\n```\n// ~/.claude/settings.json — user-wide, survives every shell/IDE/CI invocation\n{\n  \"env\": {\n    \"DISABLE_AUTOUPDATER\": \"1\"\n  }\n}\n```\n\n- Scopes, least to most durable:\n- shell profile export → per-shell, easy to miss an environment (the trap)\n`~/.claude/settings.json`\n\n→ user-wide, one place`.claude/settings.json`\n\nin a repo → team-wide, checked into source control`managed-settings.json`\n\n→ enterprise policy,**highest precedence**, centrally enforced:\n\n```\nmacOS:        /Library/Application Support/ClaudeCode/managed-settings.json\nLinux/WSL:    /etc/claude-code/managed-settings.json\nWindows:      C:\\Program Files\\ClaudeCode\\managed-settings.json\n\n(C:\\ProgramData\\ClaudeCode is the legacy path and is no longer read.)\n```\n\n- No CLI flag exists to disable auto-update;\n`claude update`\n\nis manual,`/doctor`\n\nreports the update channel and install type\n\n[#](#the-catch-this-also-stops-updating-your-plugins)The catch: this also stops updating your plugins\n\n- Turn off the auto-updater by\n*any*of the means above and**plugin auto-updates stop as well** **This one is documented**— but on the[plugin discovery page](https://code.claude.com/docs/en/discover-plugins#configure-auto-updates), under “Configure auto-updates”, which is not where you are standing when you disable auto-updates:\n\n```\nTo disable all automatic updates entirely for both Claude Code and all plugins,\nset the DISABLE_AUTOUPDATER environment variable.\n\nTo keep plugin auto-updates enabled while disabling Claude Code auto-updates,\nset FORCE_AUTOUPDATE_PLUGINS=1 along with DISABLE_AUTOUPDATER.\n```\n\n- Where it\n*isn’t*:, the page that tells you how to do this, mentions neither plugins nor`/setup`\n\n→ Disable auto-updates`FORCE_AUTOUPDATE_PLUGINS`\n\n. Nor does the[settings](https://code.claude.com/docs/en/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\n`DISABLE_AUTOUPDATER`\n\n. The binary shows**four** paths freeze plugins —`DISABLE_UPDATES`\n\n,`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`\n\nand`autoUpdates: false`\n\ndo it too, and*that*part is undocumented. It’s a single gate on “is the updater off for any reason”:\n\n```\n// v2.1.211, verbatim. The names are meaningless because the minifier ate them —\n// the env var and the log string survive, being strings rather than identifiers.\nfunction u_e(){return h$e()!==null}\nfunction blt(){return u_e()&&!ut(process.env.FORCE_AUTOUPDATE_PLUGINS)}\n// ...and at the plugin auto-update entry point:\nif(blt()){w(\"Plugin autoupdate: skipped (auto-updater disabled)\");return}\n```\n\n- Unreadable, so here it is again with the five functions renamed\n**by me**—`h$e`\n\n→`disabledReason`\n\n,`u_e`\n\n→`updatesAreDisabled`\n\n,`blt`\n\n→`pluginUpdatesBlocked`\n\n,`ut`\n\n→`truthy`\n\n,`w`\n\n→`debugLog`\n\n. The logic is untouched; the names are my guesses at intent, not the authors':\n\n```\n// NOT the shipped code — function names below are mine, invented for legibility.\n// The control flow, the env var and the log string are verbatim from v2.1.211.\nfunction updatesAreDisabled(){ return disabledReason() !== null }\nfunction pluginUpdatesBlocked(){\n  return updatesAreDisabled() && !truthy(process.env.FORCE_AUTOUPDATE_PLUGINS)\n}\nif (pluginUpdatesBlocked()) { debugLog(\"Plugin autoupdate: skipped (auto-updater disabled)\"); return }\n```\n\n- 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.\n**The fix is one more env var**— the documented, supported one. It restores plugin updates while leaving the CLI pinned:\n\n```\n// ~/.claude/settings.json — pin the CLI, keep plugins current\n{\n  \"env\": {\n    \"DISABLE_AUTOUPDATER\": \"1\",\n    \"FORCE_AUTOUPDATE_PLUGINS\": \"1\"\n  }\n}\n```\n\n`FORCE_AUTOUPDATE_PLUGINS`\n\n,`DISABLE_AUTOUPDATER`\n\nand`DISABLE_UPDATES`\n\nare parsed properly: the parser accepts`1`\n\n,`true`\n\n,`yes`\n\n, or`on`\n\n(case-insensitive), so`=1`\n\ndoes what you’d hope and`=0`\n\ncorrectly reads as falseIt’s presence-detected, so`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`\n\nis not.*any*non-empty value switches it on —`=0`\n\nincluded, which means`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0`\n\ndisables your auto-updater and freezes your plugins:\n\n``` js\n// v2.1.211, verbatim. `Z3t` is the minifier's name; the env var is the binary's.\n// Note the bare `process.env.X` — the value is never read, only its presence.\nfunction Z3t(){if(process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)return\"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC\";return null}\n```\n\n- Note the shape of this: a var whose name begins with\n`FORCE_`\n\nis the supported way to get the*ordinary*behaviour of keeping plugins current. It reads like an escape hatch someone added on the way past.\n\n[#](#sharp-edges)Sharp edges\n\n- Reading the resolver in\n`2.1.211`\n\n,**four** things disable updates — the three env vars above, plus`autoUpdates: false`\n\nin config. All four also stop plugin updates. `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`\n\nis the surprising one: reach for it as a*privacy/egress*control and you have silently pinned your CLI and frozen your pluginsThe config path is gated on an extra condition:`autoUpdates: false`\n\ncan be ignored outright.\n\n```\n// v2.1.211, verbatim — `t` is the minified name for the settings object; every\n// key on it is real, because property names survive. `!1`/`!0` are false/true.\nif(t.autoUpdates===!1&&(t.installMethod!==\"native\"||t.autoUpdatesProtectedForNative!==!0))return{type:\"config\"}\n//                      ^ on a native install with autoUpdatesProtectedForNative,\n//                        your `autoUpdates: false` does nothing at all\n```\n\n- So the config key is both\n**undocumented**—`autoUpdates`\n\n, as distinct from`autoUpdatesChannel`\n\n, appears nowhere in the docs — and**conditionally overridden**. Prefer the env vars: they’re checked before it, and unconditionally. fixed`2.1.98`\n\n`DISABLE_AUTOUPDATER`\n\n**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\n`~/.local/bin/claude`\n\non every release (since fixed;`/doctor`\n\nnow flags externally managed launchers) - Which install methods self-update matters:\n**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`\n\n). If you’re on native or npm, you are on the daily-cadence treadmill unless you say otherwise\n\n*Claude’s research ends here.*\n\n[#](#wrap-up)Wrap-up\n\nEven 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.\n\nThis 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.\n\nI’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.", "url": "https://wpnews.pro/news/claude-code-anatomy-of-a-misfeature", "canonical_source": "https://www.olafalders.com/2026/07/17/claude-code-anatomy-of-a-misfeature/", "published_at": "2026-07-17 00:00:00+00:00", "updated_at": "2026-07-17 14:25:12.290700+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-ethics", "ai-products", "ai-tools"], "entities": ["Anthropic", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/claude-code-anatomy-of-a-misfeature", "markdown": "https://wpnews.pro/news/claude-code-anatomy-of-a-misfeature.md", "text": "https://wpnews.pro/news/claude-code-anatomy-of-a-misfeature.txt", "jsonld": "https://wpnews.pro/news/claude-code-anatomy-of-a-misfeature.jsonld"}}