商业产业 Hacker News Claude 2026-07-17T14:26 正文 ok 6510 words

🔶 Claude Code:剖析一项有问题的特性

英文标题:🔶 Claude Code: Anatomy of a Misfeature

2026年7月1日,Anthropic在Claude Code 2.1.198中悄悄加入了一个60秒无应答自动继续的AFK超时特性,未在更新日志中说明,可能导致代理在无人确认的情况下自主决策,引发384个👍和143条评论的社区强烈不满。两天后2.1.200版本将该行为改为默认关闭,并提供了配置项,但暴露出AI开发工具在变更透明度、默认安全性和人类控制方面的信任危机。本文深入分析了该特性的实现细节、修复过程及留给开发者的教训。
打开原文相关 3 · 机会 0
media5.4 KB media417.0 KB media1.2 KB media1.4 KB media1.4 KB media9.9 KB

中文

特色图

"机械式煮蛋计时器" 由 Hustvedt 创作,采用 CC BY-SA 3.0 许可。为适配更宽画面进行了填充;此改编同样遵循 CC BY-SA 3.0。

在2026年加拿大国庆日(7月1日),Anthropic 为 Claude Code 的用户推送了一个令人意外的“彩蛋”:2.1.198 包含了一个效率绕过机制,允许代理(agent)在无需等待人类指示的情况下继续执行。基本上,当 Claude Code 请求输入时,你会得到一个 60 秒的计时器。如果错过这个窗口,Claude Code 会自行判断最佳行动并继续执行。它看起来像这样:

● Claude asked:
  ⎿ …

  ● No response after 60s — continued without an answer

● The user stepped away. I'll proceed with best judgment. My plan:

注意:以上内容逐字取自我自己的一个 claude 会话,其中的问题已经被删减。

如果你觉得这种行为令人诧异,你并不孤单。让我们来考虑一下可能的后果:

  • 你做三明治时,非得把笔记本电脑带进厨房吗?如果你在此期间离开键盘,会发生什么?
  • 你同时运行多少个代理?你能同时观察它们全部吗?如果两个或更多代理在同个 60 秒窗口内请求你的输入,怎么办?
  • 如果代理做出了错误选择呢?期间又会烧掉多少 token?
  • 如果你正使用代理进行部署呢?(我知道这样用有问题,但 万一 呢?)

这些是在发布此类功能时你会合理考虑的事项,或许还会在更新日志中说明理由。但如果你根本没有在更新日志中提到新默认行为呢?那岂不是更令人惊讶?(剧透:事实上正是如此!)

这个故事有一个(勉强算)愉快的结局。“快速行动,打破常规”并不排除“快速行动,修复问题”的可能。几天内就发布了一个修复,但这对于用户对该产品的信任来说又意味着什么呢?

我们学到了一些事:

  • Claude Code 中令人意外的特性在理论上(以及实践中)可能由 Anthropic 按天迭代发布
  • 并非每个特性都一定会出现在更新日志中
  • 那些不应该成为默认值的选项,可能没有文档化的关闭开关
  • Claude Code 的自动更新功能,感觉更像“YOLO 模式”,而非我们早期所设想的那样

还有一些事,我不确定我们是否学到了:

  • 人类如何融入这个等式?
  • 是这个特性由人类构想出来的?
  • 是一个人类编写(或让代理编写)了这个特性?
  • 是一个人类审查了这个特性?
  • 是一个人类批准了这个特性?
  • 是一个人类合并了这个特性?
  • 是一个人类选择不记录这个特性或将其加入更新日志?
  • 是一个人类发布经理在发布前对版本差异进行了审查并签字放行?

就我个人而言,很难相信在所有这些环节中都有人类把关,却没有问过“这是个好主意吗?” 如果你告诉我,是 Claude Code 自己构建了这个特性,把它发布出去,自己签字批准,然后决定它不值得写进文档——这个说法我倒更倾向于相信,但我真的不知道。也许是两者的某种结合。可能很多事情出了差错,但我认为很明显,这种情况本不应该发生。而说这话的我,曾在至少一次绩效评估中被经理说:“嗯,你确实把一个严重的 bug 放进了生产环境。”

我一直在思考这件事是怎么发生的,以及公共记录中有哪些可供参考的事后分析。于是,我让 Claude Code 自己做了调查。值得称赞的是,Claude 似乎没有阻止对自身代码进行反思的过滤器。所以,需要完全公开的是,下面主要是 Claude 的工作成果,你只能姑且信之。如果你依赖于任何关键假设,最好独立复现一下。


Claude 的调研从此开始。

#时间线

  • 2026-06-29 — 2.1.196 发布;报告者所说的“最后一次可用的版本(我猜)”

  • 2026-06-30 — 2.1.197 发布;更新日志仅一行,关于 Sonnet 5 发布

  • 2026-07-01 — 2.1.198 发布——报告者断定引入该回归的版本。 没有公开提交显示该变更;该版本仅有的公开痕迹是机器人提交的发布笔记(75709ea),它只修改了 CHANGELOG.md 和 feed.xml

  • 2026-07-02 02:54 UTC — issue #73125 由 Aleksey Nogin 提交

  • 2026-07-02 03:45 UTC — 一位评论者 给出了逃生出口:CLAUDE_AFK_TIMEOUT_MS。在线程内以 peer-to-peer 方式分享,未在任何发布说明中指出

  • 2026-07-02 — 2.1.199 发布,包含 24 条记录,而该 issue 仍未关闭。依然未提及

  • 2026-07-03 — 2.1.200 逆转了该行为;又一次,唯一的公开痕迹是笔记提交(1322e9b)

  • 2026-07-04 18:04 UTC — issue 关闭

  • 该 issue 的反应量级:384 👍、143 条评论——并非小众抱怨

  • 报告者环境:2.1.198,“最后正常版本 2.1.196(猜测)”,Opus,AWS Bedrock,VS Code 终端

    git clone https://github.com/anthropics/claude-code.git cd claude-code

    修复在何时落地,属于哪个版本?

    git log -1 --format='%h %ai' -S'no longer auto-continue by default' -- CHANGELOG.md

    1322e9ba 2026-07-03 16:52:26 +0000

    推送该错误的版本在何时落地?

    git log -1 --format='%h %ai' -S'## 2.1.198' -- CHANGELOG.md

    75709eac 2026-07-01 20:45:29 +0000

#有问题的特性

  • AskUserQuestion 是 Claude Code 用来在任务中途停下并向人类提问的工具

  • 新行为:60 秒无任何操作后,该工具会自动返回一个“继续进行”的结果,而非保持阻塞

  • 返回给模型的消息——这是其模板,按 60 秒默认值渲染如下:

    // 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.}

  • “稍后再问”这一逃生路径是循环的:重新提问会遇到同样的超时。Aleksey Nogin 在提交 issue 后几分钟内就在线程中指出了这一点

  • 脚本输出行有 两种变体 —— 二进制文件根据你是否开始回答来选择其中之一:

    // 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 中缺失,却在 2.1.198 中出现

  • 实事求是地说:屏幕并非没有提示。 该对话框会渲染一个实时倒计时,按下任意键会重新计时。这是在运行时组合的,而非存储为一个字符串,因此这是渲染后的形式,而非字面上的 grep 命中:

    // 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

  • 但这倒计时的作用比看上去要有限。倒计时只对那些盯着屏幕的人有效,而该特性的前提恰好就是你没在盯着:

    • 内部名称是 AFK;消息中写着“用户可能离开了键盘”
    • 同时运行多个代理时,“盯着屏幕”不是单一位置——你需要的倒计时可能在另一个标签页上
  • 而且倒计时出现得比较晚。阈值默认为 20 秒 (CLAUDE_AFK_COUNTDOWN_MS),且它依剩余时间而非已过时间触发——因此在前 40 秒 对话框看起来就像普通的阻塞式提问。它确实在屏幕上,但上面没有任何东西显示计时器正在运行:

    // 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 的 工具参考 提到 “权限提示,包括计划审批,绝不会因闲置而自动解决” —— 与本文后面部分引用的文档声明不同,这一点可以针对已发布的 2.1.198 代码进行核实,而非修复后才撰写的页面:

    • 倒计时组件 (q0m) 在整个 bundle 中 只有一个调用点,其定时器 hook (_Rc) 也只有一个调用者——就是 q0m 自身。定时器只存在于一个组件内
    • 该组件的 props 可辨识:jsx(dRc,{question:V, questions:s, currentQuestionIndex:$, answers:R, questionStates:O, onAnswer:be, onSubmit:N, …})。其超时处理函数会触发 tengu_ask_user_question_afk_auto_advance
    • 权限提示(“是否继续”、“是否允许…”)是另一个独立的组件,未附加任何定时器
  • 但该豁免只有当权限提示确实出现时才保护你。 2.1.198 提供了 bypassPermissions、acceptEdits、allowedTools、--dangerously-skip-permissions 以及 PreToolUse 钩子。任何运行部署代理的人,很有可能已将部署命令加入允许列表或关闭了提示——这正是自动化 的含义。对他们而言,权限层本就不会触发,所以它对于该定时器的免疫力也毫无用处

  • 更狭窄但更致命的一点:定时器不能授予权限,但它能做出选择。 AskUserQuestion 不是在征求许可,它是在让你做决定——“预发布环境还是生产环境?”、“用哪个配置?”。超时后,模型被告知 使用你的最佳判断继续,并且部分回答的路径会以“沿用目前选定的答案”继续。如果权限已通过允许列表或绕过方式授予,那么选择就成了最后的关口

  • 工具 schema 中不存在 timeout 参数——模型既不能设置也无法控制它。这一点在二进制文件中已验证 (U_f=…H.strictObject({questions:…})),并非取自 issue 线程。输入参数仅包括:

    questions, answers, annotations, metadata

  • 因此,当模型声称自己没有跳过任何东西时是诚实的——是 执行环境 发回了答案,而非模型

#修复

  • 2.1.200 使自动继续 默认关闭

  • 空闲超时现在为 通过 /config 手动选择加入,而非强制所有用户启用

  • 请注意更新日志的用词:“不再默认自动继续” ——什么也没有移除,只是翻转了默认值。

  • 更新日志提示定位到的 /config 设置,在修复之前 并不存在。grep 2.1.198 搜索 askUserQuestionTimeout 结果为零:当它发布时,唯一的逃生方式是一个环境变量——而发布说明从未提及它。

  • 你可以在当前二进制文件(2.1.211,十一个版本之后)中确认其余部分。整个机制完好无损:

    • 设置项名称为 askUserQuestionTimeout,在 /config 中显示为 “问题自动继续超时”
    • 接受的值:60s、5m、10m、never——未设置时解析为 never,这正是它现在成为可选加入的原因
    • hook 的默认超时常量仍然是 60000;倒计时阈值是 20000
    • 两个 环境变量仍然会覆盖该设置—— CLAUDE_AFK_TIMEOUT_MS 和 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

  • 客观而言:这是 正确的 修复。选择加入本应是第一天就有的状态,而且该能力对某些用户确实有用。

  • 不太让人舒服的一面:同样那段曾替你自动回答的代码仍存在于发行版中,仅一个配置值之隔,并由当初悄悄开启它的同一流程治理。

  • 修复前的权宜之计(来自 issue 线程),供被钉在受影响版本的用户使用:

    // settings.json — 通过设定一个极大的 AFK 窗口来禁用自动继续 "env": { "CLAUDE_AFK_TIMEOUT_MS": "" }

  • 响应非常迅速——从报告到逆转约两天时间(值得赞扬)

#哪个版本发布了它——以及更新说明说了什么

  • 两处公开的位置

English / 原文

featured

"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 ownclaude sessions, with the questions having been trimmed.

If you find this behaviour surprising, you’re not alone. Let’s consider the possible consequences:

  • Do you have to take your laptop to the kitchen with you when you’re making a sandwich? What happens if you are afk during this window?
  • How many agents are you running at once? Can you possibly observe them all at the same time? What if two or more agents ask for your input during the same 60 second window?
  • What if the agent makes the wrong choice? How many tokens have been burned in the meantime?
  • What if you are using agents for deployments? (Yes, I know, but what if)

These are reasonable things you might consider when shipping this feature and maybe you’d document your reasoning in the changelog. But what if you never mentioned the new defaults in the changelog at all? Wouldn’t that be even more surprising? (Spoiler: it was!)

The story has a (sort of) happy ending. Move fast and break things does not necessarily preclude move fast and fix things. Within a couple of days a fix was shipped, but where does that leave user trust in this product?

We’ve learned a few things:

  • Surprising features in Claude Code can in theory (and in practice) be shipped by Anthropic on a daily cadence
  • Not every feature will necessarily appear in the changelog
  • Things that should not be defaults may not have a documented off switch
  • Claude Code’s auto update feature feels more like YOLO mode than we might have suspected early on

There are a few things I don’t know if we have learned:

  • How do humans fit into this equation?
  • Did a human dream up the feature?
  • Did a human write (or have an agent write) this feature?
  • Did a human review the feature?
  • Did a human sign off on the feature?
  • Did a human merge the feature?
  • Did a human opt out of documenting the feature or adding it to the changelog?
  • Did a human release manager diff the release with the previous release and give it their seal of approval before it went out the door?

Personally I would find it hard to believe that a human was gating all of these steps without asking “is this a good idea?”. If you told me that Claude Code actually built the feature, shipped it, signed off on it and then deemed it unworthy of documentation, that’s something I’m more inclined to believe, but I just don’t know. Maybe it’s some combination of those two things. Maybe a number of things went wrong, but I think it’s clear that this never should have happened. And I say this as someone who has had at least one performance review where my manager said “well, you did put a serious bug into production”.

I’ve wondered a bit about how this happened and what kind of post-mortem is available in the public record. So, I’ve asked Claude Code to investigate itself. To Claude’s credit, it seems to have no filter that prevents self-reflection regarding this code. So, full disclosure, what follows is mostly Claude’s work, so take that for what it’s worth and if you rely on any key assumptions, it’s worth reproducing them in isolation.


Claude’s research begins here.

#Timeline

  • 2026-06-29 — 2.1.196 released; the reporter’s “last working version (I am guessing)”

  • 2026-06-30 — 2.1.197 released; one changelog line, the Sonnet 5 launch

  • 2026-07-01 — 2.1.198 released — the version the reporter pins the regression to. No public commit shows the change; the only public trace of this release is the bot commit publishing its notes (75709ea), which touches nothing but CHANGELOG.md and feed.xml

  • 2026-07-02 02:54 UTC — issue #73125 filed by Aleksey Nogin

  • 2026-07-02 03:45 UTC — a commenter surfaces the escape hatch: CLAUDE_AFK_TIMEOUT_MS. Traded peer-to-peer in the thread, not pointed to from any release note

  • 2026-07-02 — 2.1.199 ships 24 entries while the issue is open. Still no mention.

  • 2026-07-03 — 2.1.200 reverses the behaviour; again the only public trace is the notes commit (1322e9b)

  • 2026-07-04 18:04 UTC — issue closed

  • Reaction/scale on the issue: 384 👍 , 143 comments — not a niche complaint

  • Reporter’s environment: 2.1.198, “last working 2.1.196 (guessing)”, Opus, AWS Bedrock, VS Code terminal

    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

  • AskUserQuestion is the tool Claude Code uses to stop and ask the human a question mid-task

  • New behaviour: after 60 seconds of inactivity , the tool auto-returns a “proceed anyway” result instead of blocking

  • The message handed back to the model — this is the template, rendered here at the 60-second default:

    // 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.197 and present in 2.1.198

  • In fairness: it was not silent on screen. The dialog rendered a live countdown, and a keypress restarted the timer. Assembled at runtime rather than stored as one string, so this is the rendered form, not a literal grep hit:

    // 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 shipped 2.1.198 code rather than a page written after the fix:

    • the countdown component (q0m) has exactly one call site in the entire bundle, and its timer hook (_Rc) has exactly one caller — q0m itself. The timer exists in one component
    • that component’s props identify it past argument: jsx(dRc,{question:V, questions:s, currentQuestionIndex:$, answers:R, questionStates:O, onAnswer:be, onSubmit:N, …}). Its timeout handler is what fires tengu_ask_user_question_afk_auto_advance
    • the permission prompt (Do you want to proceed, Do you want to allow …) is a separate component with no timer attached
  • But the exemption only protects you if a permission prompt appears at all. 2.1.198 ships bypassPermissions, acceptEdits, allowedTools, --dangerously-skip-permissions and PreToolUse hooks. Anyone running agents against deployments has plausibly allowlisted the deploy command or turned prompts off — that is what automating it means. For them the permission layer was never going to fire, so its immunity to the timer buys nothing

  • The narrower claim, and the one that bites: the timer could not grant permission, but it could make the choice. AskUserQuestion doesn’t ask permission, it asks you to decide — “staging or production?”, “which config?” On timeout the model is told to proceed using your best judgment, and the partial-answers path continues “with the answers selected so far”. If permission was already granted by allowlist or bypass, the choice was the only gate left

  • No timeout parameter exists in the tool schema — the model can neither set nor control it. Verified in the binary (U_f=…H.strictObject({questions:…})), not taken from the issue thread. The input params are only:

    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.200 made auto-continue off by default

  • Idle timeout is now opt-in via/config, not forced on everyone

  • Note the changelog’s verb: “no longer auto-continueby default ”. Nothing was removed — the default flipped.

  • The /config setting the note points you to did not exist before the fix. Grep 2.1.198 for askUserQuestionTimeout and you get zero hits: when this shipped, the only way to escape it was an env var — and the release notes never named it.

  • You can confirm the rest in the current binary (2.1.211, eleven releases later). The machinery is entirely intact:

    • the setting is askUserQuestionTimeout, surfaced in /config as “Question auto-continue timeout”
    • accepted values are 60s, 5m, 10m, never — and unset resolves to never, which is what makes it opt-in
    • the hook’s default timeout constant is still 60000; the countdown threshold is 20000
    • two env vars still override the setting — CLAUDE_AFK_TIMEOUT_MS and CLAUDE_AFK_COUNTDOWN_MS
  • So the fix is a one-line change in a gate, not a deletion:

    // 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": "" }

  • Turnaround was fast — roughly two days from report to reversal (credit where due)

#Which release shipped it — and what the notes said

  • Two places publish release notes: the official changelog and CHANGELOG.md in the repo (same content)

  • Links below are pinned to commit 1322e9b so they show what the notes said at the time, not as edited later

  • 2.1.197 changelog: one line , Claude Sonnet 5 launch. Nothing about question timeouts.

  • 2.1.198 changelog: ~30 entries. Nothing about AskUserQuestion auto-continuing.

  • 2.1.199 changelog: 24 entries , published while the issue was already open. Still nothing.

  • The 60s auto-continue was never announced in any release note when it was added

  • AskUserQuestion is not a stranger to the changelog — it appears 15 times across 13 versions, going back to 2.0.55. It is a tool Anthropic documents changes to, routinely:

    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.181 and 2.1.200 — the window containing the change — it appears nowhere. The behaviour changed twice, on and then off, and the notes record only the second

  • The only changelog line that ever mentions the auto-continue is the one that removes it, in 2.1.200:

    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_MS appears 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 askUserQuestionTimeout as the opt-in, and that setting has zero occurrences in the 2.1.198 binary. It also narrates 2.1.198 and 2.1.199 in the past tense, as a closed range

  • And the Wayback Machine settles it outright. The docs have no public repo, so I assumed their past was unknowable. It isn’t — the page is archived, repeatedly, straight through the window:

    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 grep
  • So the two questions collapse into one answer. On the day it shipped, the feature was in neither the release notes nor the docs. There was no channel through which a user could have been told
  • The documentation appears between 1 July 21:35 and 5 July 13:58 — a window containing the 2.1.200 reversal (2026-07-03T04:33:49Z). And the entry that shows up describes auto-continue as “off by default” , which was only true after the fix. The docs never described the world as it actually was on 1 and 2 July. They arrived with the reversal and documented the reversed behaviour
  • The tidy phrase for this is that the docs caught up. What they actually did was skip the part where it was on

#So where’s the commit?

  • The obvious next move: open the commit that introduced it and read the reasoning

  • 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:

    • 75709ea — publishes the 2.1.198 notes (the release that shipped it, unmentioned)
    • 1322e9b — publishes the 2.1.200 notes (the reversal)
  • Fine — then diff the source between releases. There is no source to diff

  • 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

  • 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.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:

    • 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.197 and present in 2.1.198, and you can check that yourself in about five minutes.

#Why was it added? (evidence, not confirmation)

  • No official rationale was ever published (no design doc, no changelog line, no PR — see below)

  • Circumstantial evidence of intent, all from naming and the message text:

    • Internal name is AFK — “away from keyboard” (CLAUDE_AFK_TIMEOUT_MS)
    • The message assumes an absent human: “the user may be away from keyboard”
    • Plausible target: unattended / many-parallel-agent runs that would otherwise block forever on an absent human
  • Users in the thread describe exactly the workflow it breaks: dozens of agents, some parked for days, waiting on a human by design (earlye)

  • Tension: the same design that unblocks an absent human also lets an agent pick a fork in the road you explicitly reserved for yourself

  • The strongest evidence is inside the binary, and it’s present in 2.1.198 itself. Two separate things, worth keeping apart:

  • 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_hash is a hash, and the rest is counters

  • Absent from 2.1.197. It arrived with the feature, in the same release

  • Read the payload for what it is: an instrument for counting how often the dialog resolved without a human — how many questions were pending, whether it happened mid-plan, and whether you had partially answered

  • That last one is hadPartialAnswers. The half-answered case wasn’t an oversight that nobody saw coming — it has a code path, and that path is counted separately from the rest

  • So: this was built , not stumbled into. The behaviour, the countdown, the schema field and the analytics all landed in the same release — that’s a feature with a measurement rig attached, not a stray default

  • Note what that does not establish. It says nothing about who, or whether a who was involved at all. The questions at the top of this post stay open; all the binary proves is that the work was coherent and deliberate, not who or what did it

  • Same release, the countdown it printed at you: auto-continue in {n}s · any key to stay

  • So the capability was designed, instrumented, and given UI — and still never made the notes. That’s the gap the post is about: this wasn’t too small to mention. Someone built a measurement rig for it.

  • What none of it tells us is who signed it off, or whether anyone weighed the failure mode against the upside. Still inference; still no published rationale.

#Can you even diff it?

  • Everything above says no. Then you look at what’s actually installed and the answer changes.

  • It’s a ~250MB native executable — and, crucially, not stripped :

    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. strings reads 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/never values, the analytics event, the countdown env var

  • The installer keeps recent versions on disk (ls ~/.local/share/claude/versions/), but only the last few — not enough to reach back to July

  • Any version you like is on npm, though. The catch: @anthropic-ai/claude-code is a ~152KB installer stub (7 files, dist.unpackedSize 155,204) — no product in it. The binary lives in per-platform packages:

    npm view @anthropic-ai/claude-code-linux-x64@2.1.198 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.197 and 2.1.198 off 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

  • 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

  • 21,903 changed strings for one release. The minifier renames every identifier on every build, so nearly all of it is churn, not change. CLAUDE_AFK_TIMEOUT_MS is in there — buried in 21,902 lines of noise

  • One caveat on that number, since it looks more solid than it is: it’s a property of the invocation , not of the release. strings -n 3 gives 81,289; the default -n 4 gives 29,910; -n 8 gives 21,903. The shape of the argument survives any of them — tens of thousands of lines, overwhelmingly noise — but don’t read 21,903 as a constant

  • Now filter to things that look like English sentences. Keep only added lines, made of letters and ordinary punctuation, starting with a capital, at least five words long. That drops every mangled identifier, path, and code fragment:

    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-*.txt was sort -u’d, so the list is alphabetical. Ten lines starting with “A” precede it, and it starts with “B”. Position tells you nothing here — the point is that 156 lines is a five-minute read, not that the alphabet was kind

#What it costs to find that

  • So the diff works. A person doing this ritual on 1 July, with no privileged information, would have caught it on the day it shipped — before the issue was filed.
  • Tempting conclusion, then: “so just diff every release.”
  • Which answers one of the questions from the top of this post — did a human release manager diff the release against the previous one before it went out the door?
    • Whoever shipped 2.1.198 had every advantage I didn’t: the source, the build, the diff, the review, the authors to ask
    • I had a curl and a grep, and it took about five minutes
    • So: either nobody looked, or someone looked and shipped it anyway. Both are answers. Neither is good.
    • The rest of those questions — who wrote it, who reviewed it, who signed it off — stay unanswerable, because the public record where that would live does not exist. That’s the same absence as the missing commit, wearing a different hat.
  • Which is the uncomfortable finding, not a reassuring one:
    • Can you? Yes. Demonstrably. It took a curl, a strings, and a diff.
    • Should you have to? 156 lines per release, forever, across every tool that auto-updates, to learn what a one-line release note would have told you.
    • Nobody is going to do this. The people most exposed — the ones running unattended agents at scale — are the least likely to be grepping binaries at 2am.
  • The capability is a workaround, not a remedy. That it works is what makes its necessity hard to accept.

#Why this is more than burned tokens

  • Cost angle: an unattended agent auto-answering its own questions can burn tokens on the wrong path
  • Safety angle is worse than cost:
    • AskUserQuestion is used as an explicit safety gate — hooks/rules built on it assume it 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 manual claude update
    • DISABLE_AUTOUPDATER=1 — stops the background check only; claude update still works. Takes precedence over the autoUpdates config setting
    • CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 — the big hammer: equivalent to DISABLE_AUTOUPDATER + DISABLE_FEEDBACK_COMMAND + DISABLE_ERROR_REPORTING + DISABLE_TELEMETRY (DISABLE_BUG_COMMAND is the older name, still accepted)
  • All of them freeze your plugins too. That’s documented for exactly one of the three — see below

  • The “set it everywhere” problem has a fix : don’t export the var from a shell profile — put it in the env block of settings.json, documented as “environment variables that will be applied to every session”. One file, every invocation — CI, cron, systemd, IDE-spawned terminals included.

    // ~/.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.json in a repo → team-wide, checked into source control
    • managed-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 update is manual, /doctor reports the update channel and install type

#The catch: this also stops updating your plugins

  • Turn off the auto-updater by any of the means above and plugin auto-updates stop as well

  • This one is documented — but on the plugin discovery page, under “Configure auto-updates”, which is not where you are standing when you disable auto-updates:

    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 nor FORCE_AUTOUPDATE_PLUGINS. Nor does the settings env var table. Follow the docs for the task you’re actually doing and you never learn about it

  • And the docs only ever say DISABLE_AUTOUPDATER. The binary shows four paths freeze plugins — DISABLE_UPDATES, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC and autoUpdates: false do it too, and that part is undocumented. It’s a single gate on “is the updater off for any reason”:

    // 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_AUTOUPDATER and DISABLE_UPDATES are parsed properly: the parser accepts 1, true, yes, or on (case-insensitive), so =1 does what you’d hope and =0 correctly reads as false

  • CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is not. It’s presence-detected, so any non-empty value switches it on — =0 included, which means CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=0 disables your auto-updater and freezes your plugins:

    // 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, plus autoUpdates: false in config. All four also stop plugin updates.

  • CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is the surprising one: reach for it as a privacy/egress control and you have silently pinned your CLI and frozen your plugins

  • autoUpdates: false can be ignored outright. The config path is gated on an extra condition:

    // 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 from autoUpdatesChannel, appears nowhere in the docs — and conditionally overridden. Prefer the env vars: they’re checked before it, and unconditionally.

  • 2.1.98 fixed DISABLE_AUTOUPDATER not fully suppressing the npm registry version check and symlink modification on npm-based installs — for a long stretch, “disabled” wasn’t entirely disabled

  • The auto-updater has overwritten a custom launcher/symlink at ~/.local/bin/claude on every release (since fixed; /doctor now flags externally managed launchers)

  • Which install methods self-update matters: native and npm auto-update by default ; Homebrew, WinGet, apt, dnf and apk do not (Homebrew and WinGet can opt in via CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1). If you’re on native or npm, you are on the daily-cadence treadmill unless you say otherwise


Claude’s research ends here.

#Wrap-up

Even though the source code is not properly public, I was surprised at how much we could learn from it. Having some clues as to what to look for certainly helped, but it’s still not a replacement for a well edited and accurate changelog.

This wasn’t a stray line of code that slipped through. It looks like an actual feature. Why it got shipped completely under the radar remains baffling.

I’m not assuming bad faith on the part of Anthropic. Things happen. Whether or not things like this continue to happen will help us to understand what Anthropic did or did not learn from this misfeature.

#Related posts