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

HN Code LLM Research 2026-07-17T15:18:37.748079

Claude Code:剖析一个反功能(Misfeature)

featured

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.

2026年加拿大国庆日(7月1日),Anthropic 向 Claude Code 用户推送了一个令人意外的“彩蛋”:2.1.198 版本包含一个效率绕过机制,允许 agent 在未获得人工指令时自行继续执行。本质上,Claude Code 提问后会给你 60 秒的计时窗口。如果你错过了这个窗口,Claude Code 会“贴心地”自行判断最佳做法,然后继续运行。效果如下:

● Claude 提问:
  ⎿ …

  ● 60 秒后无回应——未收到回答,继续执行

● 用户离开了。我将按最佳判断继续。我的计划:

注:以上内容直接引自我的一次 Claude 会话记录,问题部分已做删减。

如果你觉得这个行为很意外,你不是一个人。我们来看看可能引发的问题:

哪个版本发布了它——以及发布说明里写了什么

原文:
- 如果代理做出了错误的选择呢?期间又烧掉了多少 Token?
- 如果你用代理来做部署呢?(我知道,但万一呢)

你在发布这个功能时,可能会考虑这些合理的问题,也许还会在变更日志里记下你的理由。但如果变更日志里压根没提这些新默认值呢?那是不是更让人意外?(剧透:事实就是如此!)

这个故事(勉强)有个好结局。“快速行动,打破常规”并不意味着不能“快速行动,修复问题”。几天之内修复版本就发布了,但用户对这个产品的信任还剩多少呢?

我们学到了一些东西:

还有几件事我不确定我们是否学到了:

就我个人而言,我很难相信在所有这些步骤中,会没有一个人类问一句“这是个好主意吗?”。如果你告诉我,其实是 Claude Code 自己构建了功能、发布了它、给它签了字,然后觉得不值得写文档,那反而更让我相信。但我就是不知道。也许是这两者的某种组合。也许很多地方都出了问题,但我觉得很清楚:这种事情本不该发生。说这话的人,自己就经历过至少一次绩效考核,经理说:“嗯,你确实把一个严重的 Bug 放到了生产环境里。”

哪个版本引入了这个问题——以及版本说明写了什么

我有点好奇这事是怎么发生的,以及公开记录里有没有什么复盘分析。于是,我让 Claude Code 自己调查自己。值得肯定的是,Claude 似乎没有设置什么过滤器来阻止它对这段代码进行自我反思。所以,本着完全公开的原则,下面内容主要是 Claude 自己写的,你就姑且一看吧——如果你要依赖其中的关键假设,最好还是单独复现验证一下。


Claude 的调查从这里开始。

时间线

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

# 携带 bug 的那个版本是什么时候发布的?
git log -1 --format='%h %ai' -S'## 2.1.198' -- CHANGELOG.md
# 75709eac 2026-07-01 20:45:29 +0000

这个不好用的功能

哪个版本发布了它——以及发布说明怎么说

// v2.1.198,原样。`Thl` 是压缩器的命名;文字部分来自二进制文件本身。
// 注意 "60s" 是动态插入的,并非文件中的字面量:
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,原样。`a` 是压缩器对"存在一些回答"的命名
// (`s=Object.entries(r)` 遍历回答,`a=s.length>0`);两个字符串值
// 都来自二进制文件本身。
let d=a?"continued with the answers selected so far":"continued without an answer"

// v2.1.198,原样——各组成部分。`s` 是剩余秒数。
children:["auto-continue in ",s,"s \xB7 any key to stay"]
// 渲染后为:auto-continue in 12s · any key to stay

// v2.1.198 版本中这段代码被压缩了——本地变量名被打乱,
// 但属性名保留了原样,所以 `showCountdown` 和 `remainingSeconds` 这些名字还能认出来:
let u=i*1000<=n;return{remainingSeconds:i,showCountdown:u,timeoutMs:t}

// 当 n 取默认值 20000 时,等价于:
showCountdown = remainingSeconds * 1000 <= 20000

#哪个版本发布的功能——以及说明文档中写了什么

questions, answers, annotations, metadata

#修复

#哪个版本引入了该功能——发行说明说了什么

// v2.1.211,逐字复制——仅添加了空格。`Upf`是压缩工具为其起的名字;
// 此处的其他内容均来自二进制文件本身,包括字符串值。
function Upf(e){ switch(e){
  case "60s": return 60000; case "5m": return 300000;
  case "10m": return 600000; case "never": case void 0: return null; } }
//                                        ^^^^^^^^^^^^ 未设置 => null => 禁用
// settings.json — 通过设置一个极大的AFK窗口来禁用自动继续
"env": {
  "CLAUDE_AFK_TIMEOUT_MS": "<一个巨大的数值>"
}

##哪个版本引入了该功能——发行说明说了什么

哪个版本发布的——以及发布说明写了什么

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.200
- 修改了 `AskUserQuestion` 对话框的默认行为:不再自动继续;
  可通过 `/config` 选择进入空闲超时模式。

#文档后来补上了

CLAUDE_AFK_TIMEOUT_MS — 在无人回答的 AskUserQuestion 对话框自动继续之前,
允许的空闲时间(毫秒)。默认关闭自动继续;通过 askUserQuestionTimeout 设置
选择启用。[…] 在 2.1.198 和 2.1.199 版本中,自动继续默认开启,超时时间为
60000 毫秒(60 秒)。

#哪个版本发布了它——发布说明又说了什么

# 环境变量参考页面的每一次存档抓取,时间范围 2026年6月23日 - 7月11日
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"

# 依次获取每个存档,统计功能提及次数
# (DISABLE_AUTOUPDATER 作为对照:每次存档都应该匹配到)
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

#那么提交在哪里?

哪个版本发布了它——以及发布说明写了什么

两个发布在 Git 里留下的唯一痕迹是两条自动生成的变更日志提交,标题都是 chore: Update CHANGELOG.md and feed.xml——这些是关于发布的说明,而不是发布本身:
- 75709ea——发布 2.1.198 的说明(那个功能随此版本发布,但说明里只字未提)
- 1322e9b——发布 2.1.200 的说明(回滚)

那好,那就比对这两个版本之间的源码差异吧。但是——根本没有源码可对比。

anthropics/claude-code 仓库不是产品本身。它只是变更日志、文档、插件示例、几个示例基础设施配置,以及负责处理 Issue 跟踪器的机器人:

git ls-files | wc -l          # 216 个跟踪文件
git ls-files '*.md' | wc -l   # 其中 104 个是 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/ 存放的是插件示例,examples/ 里有一个 GCP 网关 Terraform 配置和一个 MDM 配置文件,scripts/ 是八个 Issue 跟踪器自动化文件(auto-close-duplicates.ts, sweep.ts, gh.sh)。没有任何东西真正推送给你。

不过这个仓库确实打了发布标签,所以这些标签至少看上去能拿来对比。但实际上,它们并不可比——至少在关键意义上不行:

git diff --stat v2.1.197..v2.1.198
#  CHANGELOG.md | 35 +++++++++++++++++++++++++++
#  feed.xml     | 77 +++++++++++++++++---------------------------------
#  2 files changed, 74 insertions(+), 38 deletions(-)

feed.xml 是把更新日志转成了 RSS 格式,所以上面的 diff 其实展示了同一份更新日志两次。在连续十个版本(2.1.196→2.1.206)中,每个标签之间的 diff 只动了这两个文件,其他文件一概没碰。

关键在于:发布标签之间的差异(diff)本身就是发布说明。这些是“发布说明标签”,而不是“源码标签”——你没法 checkout 出任何一份代码版本。

哪个版本带了它——发布说明又说了什么

所以,“去看发布说明”这条路走不通(静默变更);“diff 仓库”这条路也走不通(没有源码);“diff 标签”同样走不通(标签本身就是发布说明)。三条死胡同,同一个原因:Anthropic 放到 git 上的东西,跟它实际发给你的是两码事。

作者写的源码从未公开过;行为只存在于编译后的二进制文件里。

因此,这个功能曾经存在过的公开证据,全部加起来就是:

这个功能的引入,在发布说明和 git 里都没留下痕迹。而它的删除,反而是发布说明第一次提到它。

这里有必要把两件容易混为一谈的事拆开:

想跳过也行:发布的二进制文件能回答 git 回答不了的问题。这个功能可以证实——在 2.1.197 里没有,在 2.1.198 里就有,而且你自己花五分钟就能验证。

为什么加入这个功能?(证据而非确认)

哪个版本发布的功能——以及发布说明写了什么

  1. 工具自身 schema 上的一个字段——它只留在本地。这个字段会跟随工具结果一起返回,告诉模型答案是自动解析出来的,而不是手动选择的,并决定由哪个组件渲染对话记录行:

  2. 内部名称为 AFK——"away from keyboard"(离开键盘)的缩写(CLAUDE_AFK_TIMEOUT_MS

  3. 消息假设用户已不在:"the user may be away from keyboard"(用户可能已离开键盘)
  4. 合理的使用场景:无人值守 / 大量并行 agent 运行——否则这些运行会因用户不在而永远阻塞住

javascript // 来自 v2.1.198,逐字复制。`H` 是压缩器给 schema 库(Zod)起的名称; // 字段名和描述文本都是二进制文件自带的。 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.")

  1. 一个分析事件——它会离开你的机器。在对话框自动推进的那一刻触发:

javascript // 来自 v2.1.198,逐字复制。压缩后的名称(q, ld, It, R, I, s)是压缩器起的; // 事件名和所有属性键都是二进制文件自带的。 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})

因此:这个功能是有意构建的,而不是偶然撞上的。行为、倒计时、schema 字段和分析事件全都在同一个版本中出现——这是一个附带测量机制的功能,而不是一个随手设置的默认值。

#你能对它做二进制差异分析吗?

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
grep -abo -- '---- Bun! ----' ~/.local/share/claude/versions/2.1.211 | tail -1
# 261973153      <-- 距离262MB文件末尾约50KB的位置
strings -n 3 ~/.local/share/claude/versions/2.1.211 | grep -c 'CLAUDE_AFK_TIMEOUT_MS'
# 4

哪个版本包含了它——以及发布说明怎么说

npm view @anthropic-ai/claude-code-linux-x64@2.1.198 dist.unpackedSize
# 248900994      <-- ~249MB,实际文件大小

#那就实际 diff 一下看看

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

// 两者逐字相同,仅增加了空白。混淆后的名称 (ke, Ie, li, OOb, Rpf,
// Di, Jyn) 是压缩器生成的;`hasExternalRacer` 和环境变量是二进制自带的。
// v2.1.198
ke  = !Ie  && !n.hasExternalRacer   && !li()
// v2.1.211 — 三个条件相同,多了一个
OOb = !Rpf && !Xde.hasExternalRacer && !Di() && (Jyn !== null || ye.CLAUDE_AFK_TIMEOUT_MS !== void 0)
//                                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 整个修复所在

哪个版本引入了它——以及更新说明说了什么

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

得说清楚,因为这种事情容易被夸大:并不是说 2.1.198 版本根本就没有任何限制开关。它是有这个开关的,而且只要对话框里没有外部玩家,它就会自动开启。问题在于,它缺少任何用户能够影响的判断条件。

两天的抗议之后,修复方案就是一个 && 子句。这也正是关键所在:它距离不发生问题,就差那么一个 && 子句。

但注意那个 grep 需要什么:反馈者给了我们版本号,而且这个功能有名字可以 grep。普通周三的日常工作中,这两个条件都不成立。所以真正的问题是:在既不知道版本号、也不知道功能名的情况下,你是否能找到这个冷门问题。

对 2.1.197 → 2.1.198 做一次盲字符串级别 diff:

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

这里就是 afkTimeoutMs 属性——也就是 /config 设置项,通过上面的 switch 解析得到。这就是全部差异:在 2.1.198 版本中,这个开关存在,但没有任何你可以设置的选项能够把它关掉。在 2.1.211 版本中,它会先征得你的许可。

得说清楚,因为这种事情容易被夸大:并不是说 2.1.198 版本根本就没有任何限制开关。它是有这个开关的,而且只要对话框里没有外部玩家,它就会自动开启。问题在于,它缺少任何用户能够影响的判断条件。

两天的抗议之后,修复方案就是一个 && 子句。这也正是关键所在:它距离不发生问题,就差那么一个 && 子句。

但注意那个 grep 需要什么:反馈者给了我们版本号,而且这个功能有名字可以 grep。普通周三的日常工作中,这两个条件都不成立。所以真正的问题是:在既不知道版本号、也不知道功能名的情况下,你是否能找到这个冷门问题。

哪个版本发布的——以及发布说明写了什么

# 仅新增字符串:出现在198版本,不在197版本中
diff s-2.1.197.txt s-2.1.198.txt | grep '^>' | sed 's/^> //' > added.txt
wc -l < added.txt
# 16255

# ...那些像英文句子的行
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:

找到它的代价

哪个版本发布的——以及发布说明写了什么

#为什么这不仅仅是白白消耗token

#关闭自动更新

哪个版本引入的——以及更新日志怎么说?

// ~/.claude/settings.json — 用户级别,对每一个 shell / IDE / CI 调用都生效
{
  "env": {
    "DISABLE_AUTOUPDATER": "1"
  }
}
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 是旧路径,已不再读取)

注意:这也会阻止插件更新

要彻底禁用 Claude Code 和所有插件的自动更新,
设置 DISABLE_AUTOUPDATER 环境变量。

要保持插件自动更新启用而仅禁用 Claude Code 自动更新,
则设置 FORCE_AUTOUPDATE_PLUGINS=1 并同时设置 DISABLE_AUTOUPDATER。

哪个版本发布了它——以及发行说明说了什么

// 来自 v2.1.211,原文复制。函数名已无意义,因为压缩器把它们吞掉了——
// 环境变量名和日志字符串是字符串而非标识符,所以保留了下来。
function u_e(){return h$e()!==null}
function blt(){return u_e()&&!ut(process.env.FORCE_AUTOUPDATE_PLUGINS)}
// ...然后在插件自动更新入口点:
if(blt()){w("Plugin autoupdate: skipped (auto-updater disabled)");return}
// 不是发布的代码——下面的函数名是我为了可读性自己起的。
// 控制流、环境变量名和日志字符串与 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 — 固定 CLI 版本,保持插件更新
{
  "env": {
    "DISABLE_AUTOUPDATER": "1",
    "FORCE_AUTOUPDATE_PLUGINS": "1"
  }
}
// v2.1.211,原文如此。`Z3t` 是压缩器的名称;环境变量是二进制文件中的那个。
// 注意这里的 `process.env.X` —— 值从未被读取,只检查其是否存在。
function Z3t(){if(process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)return"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC";return null}

小心边缘情况

// v2.1.211,原文如此 —— `t` 是设置对象压缩后的名称;
// 上面的每个键都是真实的,因为属性名不会被压缩。`!1`/`!0` 对应 false/true。
if(t.autoUpdates===!1&&(t.installMethod!=="native"||t.autoUpdatesProtectedForNative!==!0))return{type:"config"}
//                      ^ 在原生安装且 autoUpdatesProtectedForNative 为 true 的情况下,
//                        你的 `autoUpdates: false` 根本不会生效

Claude 的研究到此结束。

#总结

虽然源代码并未完全公开,但我惊讶地发现,我们仍能从中学到这么多。知道要关注哪些线索当然有帮助,但这依然无法替代一份编写得当、准确的变更日志。

这不是一行不小心漏进来的代码。它看起来像是一个有意的功能。为什么它会在完全不被察觉的情况下发布,至今令人费解。

我并不认为 Anthropic 是恶意的。事情总会发生。但这类事情是否还会继续发生,将帮助我们判断 Anthropic 是否从这次错误功能中吸取了教训。

查看原文