Skip to content

fix(adapter): claude-code 补忙碌标志防止静默误判空闲 - #1317

Merged
deepcoldy merged 1 commit into
deepcoldy:masterfrom
Justin1989:fix/claude-code-busy-pattern
Sep 8, 2026
Merged

fix(adapter): claude-code 补忙碌标志防止静默误判空闲#1317
deepcoldy merged 1 commit into
deepcoldy:masterfrom
Justin1989:fix/claude-code-busy-pattern

Conversation

@Justin1989

Copy link
Copy Markdown
Contributor

改了什么

claude-code 适配器此前只有 readyPattern: /❯/,而 Claude Code 工作时输入框 ❯ 常驻——idle 判定只剩「2s 静默」这一条负证据。长思考或三方网关延迟造成的一次 ≥2s PTY 停顿就会把工作中的会话错翻成 idle,且没有任何拉回手段(无 busyPatterndeferPromptReadyWhileBusy() 直接 return false;无 idleToBusyPatternonBusy 永不触发),卡片钉死在绿色。

本 PR 给 claude-code 补上忙碌正证据双保险:

  • busyPattern: /(?<!press )esc to interrupt/:工作态 footer 比空闲态多出「· esc to interrupt ·」一段,翻绿前先查屏幕,忙碌标志否决错翻
  • idleToBusyPattern(同源):已错翻的绿卡在下一帧被拉回工作中

为什么

  • Claude Code 工作时 ❯ 常驻,正证据缺失导致只靠负证据判 idle
  • 实测健康 turn 很难撞上(本机 60s 采样 435 次重绘最大间隔 0.43s),但走三方网关的长思考会话(秒级空窗)正好踩中
  • 判别力实测:本机 242 个 tmux pane 扫末 3 行,命中 1 个(确实在工作),0 误报;空闲 footer 是「⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents」,不含该段

影响面

  • def 由 claude-code / seed / relay 三个同源 fork 共享(渲染同一套 Claude Code TUI footer),对三者效果一致
  • 不影响其它 CLI 适配器;未动 adapters/cli/ 共用工具
  • 负向后行断言排除「press esc to interrupt」散文形态(codex/traex 既有教训),防止正文引用提示语把闲卡翻忙
  • ⚠️ COMPLETION_RE 的字形问题(✳U+2733 vs 屏幕 ✻U+273B)刻意未动:该分支无 spinner 守卫,单独修会新造提前翻绿 bug

测试验证

  • bunx vitest run test/cli-adapters.test.ts — 441 passed(含新增回归用例:工作态 footer 命中、空闲态不命中、散文不命中、seed/relay 共享 def)
  • bun run build — 通过,audit 无异常
  • bun run daemon:restart — 已在 live daemon 验证加载

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR,问题定位得很准:claude-code 工作时 常驻 ⇒ readyPattern 在忙时也命中 ⇒ idle 判定只剩「2s 静默」这一条负证据,而且既没有 busyPattern 也没有 idleToBusyPattern,错翻之后没有任何拉回手段。这个缺口是真实存在的,我在本机 243 个 pane 上复核了你描述的判别力(9 个命中、全部确实在工作、0 误报),空闲态 footer 也确实不含 esc to interrupt 一段。方向我完全赞同。

不过实测下来,(?<!press )esc to interrupt 这个锚点偏宽了一档,会引入一个新的反向缺陷,建议合入前先收紧。

主要问题:CLI 自己把这句话打进正文,就会被判成「忙」

(?<!press ) 只排除了 press esc to interrupt 这一种散文形态,但裸短语本身不受任何保护。而 Claude Code 的 transcript 就在同一块屏幕上——只要本轮的用户输入或助手回答里出现这个短语(讨论中断快捷键、贴 diff、grep 源码、复述文档……),它就和真 footer 一样命中。

我用真实 claude(v2.1.263,独立 tmux socket,没碰线上会话)复现了一次:

❯ Reply with exactly this one line and nothing else: docs say esc to interrupt works
● docs say esc to interrupt works
✻ Cogitated for 19s · done 10:36 PM
────────────────────────────────────────
❯
────────────────────────────────────────
  ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents      ← footer 无中断段,会话确实空闲

会话已经真正结束(footer 没有中断段、· done),但把 worker 里逐字复制的 busyProbeRegion()(末 max(12, ⌈行数/3⌉) 行)套上去:

判据 结果
busyProbeRegion 命中 truedeferPromptReadyWhileBusy 拒绝翻绿
整屏重绘命中 trueidleToBusyPattern 把已翻绿的卡拉回「工作中」

短 transcript 时命中行可能落在探测窗之外,但只要 transcript 长到把最后一条消息顶到输入框上方(日常几乎必然),命中行就落在窗内——上面这份就是(命中行在 L41/L43,窗口从 L35 起)。

用真 IdleDetector + 真 adapter + 真实抓下来的 PTY 字节做了端到端验证(空闲态 tmux resize 触发整屏重绘,重放历史正文):

PR 分支:      FAIL — busyFired=1(一次纯重绘把正确的空闲态翻回「工作中」)
origin/master: PASS — busyFired=0

同一个探针、同一份输入,master 绿 / PR 红 ⇒ 这条是本 PR 引入的,不是既有行为。

后果比误翻绿更硬一些:

  • probeBusyPatternIdle 的重试循环(worker.ts scheduleBusyPatternIdleProbe没有 deadline、也没有次数上限tick 会无限自我重排;而那行正文会一直留在屏幕上直到被顶走 ⇒ 卡片可能长期钉死在「工作中」,本轮输出压住不发
  • claude-code 的 idle 边是 'screen'busyGuardedIdle'screen' 恒为 true),所以这条 defer 路径确实生效,不像 pi/omp/ebsd 那样只对特定 CLI 生效

建议改法:锚 footer 的结构,而不是锚裸短语

同仓 codex/traex 的既有做法就是「忙碌标签 + 上下文」联合锚定(/Working[^\r\n]{0,160}esc to interrupt/i),claude-code 这边同样可以,只是别去枚举 mode 名——我实测 footer 有 5 种模式串,且 manual mode on / bypass permissions on 在二进制里 count=0(运行时拼的),枚举容易漏:

⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents
⏸  manual mode on · esc to interrupt · ← for agents
⏵⏵ accept edits on (shift+tab to cycle) · esc to interrupt · ← for agents
⏸  plan mode on (shift+tab to cycle) · esc to interrupt · ← for agents
⏵⏵ auto mode on (shift+tab to cycle) · esc to interrupt · ← for agents
 · next try in 3s · attempt 2 · esc to interrupt        ← 重试 footer(二进制里逐字可见)

我验过的一个候选(行首模式字形 + · 分隔,不枚举 mode 名):

const CLAUDE_BUSY_FOOTER_RE =
  /^\s*(?:[]+\s.*\bon\b|.*next try in).*·\s*esc to interrupt\b/m;

实测:真 footer 7/7 全中(含上面 5 种模式 + 重试 footer + 带 ctrl+t to hide tasks 的变体),散文/自引用 8/8 全不中(含正文带 ·、带 on、grep 到的源码行、press 形态);上面那个端到端复现探针在这个改法下转绿,而真实工作态屏幕仍判 BUSY。

⚠️ 换成结构锚点后,你测试里 expect(busy!.test('· esc to interrupt ·')).toBe(true) 这条会失败——这个裸片段正是散文也能命中的形状,建议改成断言真 footer 整行(另外顺手补一条「正文引用该短语时不命中」的负例)。其余 3 条断言不受影响。

其它

  • 反变异我跑了 6 枪全红(删 busyPattern / 删 idleToBusyPattern / 去掉 press 后行断言 / 让两者不同源 / 只对 claude-code 生效 / 拼错锚点),说明你新增的用例是承重的,不是凑数 👍
  • bun run build 通过、test/cli-adapters.test.ts 在最新主干上 444/444 通过、CI 9/9 绿
  • 主干在我 review 期间前进到 61dadb04cfeat(trigger-user-auth): 按触发人身份调用 CLI(默认关闭) #1266 合入),我在本地基于最新 master rebase 过,无冲突,diff 仍是这 2 文件 +32 行
  • 你在描述里刻意没动 COMPLETION_RE 的字形问题,这个判断我认同;顺带确认一下现状:实测屏幕上是 (U+273B),而 COMPLETION_RE 写的是 (U+2733),所以那条 completion 路径目前对真实屏幕不命中——也就是说本 PR 的 busy 正证据实际承担的责任比预期更重,更值得把锚点收紧一档

以上是自动评审的初步意见,可能有我没覆盖到的场景;最终以维护者审阅为准。方向和问题定位都很好,收紧锚点之后我认为这就是一个干净的修复 🙏

@deepcoldy

Copy link
Copy Markdown
Owner

补充一条实测(复审阶段追加,加强而非推翻上面的结论):窄视口下 footer 会被截断,这让两条 pattern 出现一个不对称——对 (?<!press )esc to interrupt严格更糟,对结构锚点则是安全降级

在 61 宽(本机 fleet 里真实存在的宽度)实测同一个会话、同一份 transcript:

状态 屏幕实况 (?<!press ) 结构锚点
真正 footer 截断成 … · esc to i…,中断段不可见 MISS(拿不到忙证据) MISS(同样拿不到)
真正空闲(正文含裸短语) footer … · ← for ag… 无中断段,正文 L30/31/33 命中 BUSY ❌ 死锁 idle ✓

也就是说窄视口下 (?<!press )双向都错:该判忙的判不出(正证据丢失,退回原来的 2s 静默裸奔),不该判忙的反而判忙(正文命中 ⟹ probe 无上限重试 ⟹ 钉死)。而结构锚点只是退回改动前的行为(拿不到正证据、不误报),属于 footer 方案的固有局限,不是新增缺陷。

补充确认的触发面(比我原文说的更广):

  • tmux resize-pane -Z——src/adapters/backend/tmux-backend.ts:580 / :624 两处真实调用(adopt 收尾 unzoom、web 终端 attach 前 zoom)
  • tmux resize-window——src/adapters/backend/tmux-pipe-backend.ts:650,web 终端查看者视口不匹配时按查看者尺寸改真实 pane
  • 本机 244 个 pane 里 173 个是 160 宽、61 个是 270 宽 ⟹ 查看者打开 web 终端时尺寸不一致是常态,整屏重绘是日常路径而非边角

截断阈值实测:80 宽及以上中断段完整可见(80/93/98/160 全部 OK),61 宽开始截断 ⟹ 主流宽度不受影响,这一条只影响 ~80 宽以下的边缘会话,不改变上面的主结论和建议改法

claude-code 适配器此前只有 readyPattern(/❯/),而 Claude 工作时 ❯ 常驻,
idle 判定只剩 2s 静默这一条负证据:长思考或网关延迟造成的一次 ≥2s
PTY 停顿就会把工作中的会话错翻成 idle,且没有任何拉回手段,卡片钉死
在绿色。

补上忙碌正证据双保险(review 后收紧为结构锚点):
- busyPattern:deferPromptReadyWhileBusy 翻绿前先查屏幕,工作态
  footer 的「· esc to interrupt ·」段否决错翻
- idleToBusyPattern:已错翻的绿卡在下一帧拉回工作中

锚点锚 footer 结构而非裸短语:transcript 与 footer 同屏,probe 区扫
末 max(12, ⌈行数/3⌉) 行,正文出现裸短语(讨论快捷键、贴 diff、grep
源码)会把已空闲的会话钉死在「工作中」(probe 重试无上限)。改为
行首模式字形(⏵⏵/⏸,不枚举 mode 名——manual/bypass 是运行时拼的)
或重试段「next try」+ `·` 分隔联合锚定:真 footer 7/7 命中,散文
8/8 不误报(双向实测);窄视口(<80 列)footer 截断时安全降级回原
行为,不误报。COMPLETION_RE 字形问题(✳ vs ✻)刻意未动:该分支无
spinner 守卫,单独修会新造提前翻绿 bug。

影响面:def 由 claude-code/seed/relay 共享(同源 TUI footer),
不影响其它 CLI 适配器。

验证:bunx vitest run test/cli-adapters.test.ts 444 passed(含新增
回归用例:7 种真 footer 命中、6 种散文负例、多行 probe 区正反例);
bun run build 通过;已 rebase 到 upstream/master 61dadb0
@Justin1989
Justin1989 force-pushed the fix/claude-code-busy-pattern branch from f6abeec to 7f3dfc8 Compare September 8, 2026 08:10
@Justin1989

Copy link
Copy Markdown
Contributor Author

按 review 意见收紧了锚点,感谢非常扎实的实测(尤其是 prose 自引用钉死空闲态那条,和窄视口截断的不对称分析,都是我没覆盖到的场景)。

改动(?<!press )esc to interrupt → 结构锚点,与你建议的候选一致:

const CLAUDE_BUSY_FOOTER_RE = /^\s*(?:[]+\s.*\bon\b|.*next try).*·\s*esc to interrupt\b/m;
  • 落在 claude-code.ts 里做具名常量 + 注释,busyPattern / idleToBusyPattern 同源引用
  • 按你的清单在本地复验:真 footer 7/7 命中(5 种模式 + ctrl+t 变体 + 重试 footer),散文 8/8 不误报(含 prompt 回显、助手回答、裸片段、on + 短语形状、next try 散文形态)
  • 测试更新:删掉你点名的那条 '· esc to interrupt ·' 裸片段断言(现在断言为 false——它正是散文能命中的形状),换成 7 条真 footer 整行 + 6 条散文负例,另加了多行 probe 区的正反例(idle 区域不带中断段不命中 / busy 区域带中断段命中),模拟 busyProbeRegion 的真实输入形状
  • 已 rebase 到 upstream/master 61dadb0(无冲突),CI 触发中

窄视口截断的结论也认同:结构锚点是「拿不到正证据、退回原行为」的安全降级,不是新增缺陷,这条留给 footer 方案的固有局限。

COMPLETION_RE 的字形问题维持不动,理由同你确认的现状。

@deepcoldy

Copy link
Copy Markdown
Owner

重新 review 了新 head 7f3dfc84d(force-push,旧 head f6abeeccd 不是它的祖先,所以前几轮结论对新树不再自动成立,我从基线重跑了一遍)。

✅ 阻断已解除

原阻断(裸短语被 CLI 自己的 transcript 命中 ⟹ probe 无上限重试 ⟹ 钉死「工作中」)已修好,而且是用结构锚点修的。我重新造了一次复现场景验证(真 claude v2.1.263 + 独立 tmux socket,未碰线上会话):把 transcript 填长,让含裸短语的正文落到输入框上方(busyProbeRegion 扫的末 max(12,⌈行数/3⌉) 行之内),会话真正结束后(footer 无中断段 + · done)抓真实 resize PTY 字节,喂给真 IdleDetector + 真 adapter:

分支 结果
新 head PASSbusyFired=0(重绘不再翻回工作中)
把 pattern 换回旧的裸短语版(阴性对照) FAILbusyFired=1

阴性对照转红说明这个探针不是哑弹,确实打在这条改动上。真实屏幕双向也都对:抓到的真忙屏(⏵⏵ bypass permissions on … · esc to interrupt · ← for agents)判 BUSY,含裸短语的真空闲屏判 idle。

测试也补得很到位——5 种权限模式 + ctrl+t 变体 + 重试 footer 的正例,加上多行 probe region 的双向对照(正文不救、footer 要中),比我建议的更全。

反变异 9 枪 8 红:删 busyPattern / 删 idleToBusyPattern / 退回裸短语 / 两者不同源 / 只对 claude-code 生效 / 删 mode-on 支路 / 删 next-try 支路 / 去掉 · 分隔要求,全部转红 ⟹ 新增用例是承重的。

bun run build 绿、test/cli-adapters.test.ts 444/444、CI 9/9 绿。我也在最新 origin/master12e6e1d83,期间 #1308 合入)上本地 rebase 过,无冲突,diff 仍是这 2 文件 +83 行。

两条非阻断(都不影响合入,作者可自行取舍)

N1(建议改,一行):next try 比真实字符串松了一档。 二进制里逐字是 next try in (带尾空格,· next try in ${ze} · attempt ${nt.attempt} · esc to interrupt),而 next try 这条支路没有行首字形要求,所以任何含 next try + · + 该短语的正文行都会命中:

next try · esc to interrupt                          -> BUSY ❌
on the next try you can use · esc to interrupt        -> BUSY ❌
next try in the docs · esc to interrupt is the hint   -> BUSY ❌

改回 next try in 这三条全部变 idle,且真实重试 footer 仍然命中(我实测过)。注意这不是原阻断的重现——严重度低得多:mode on 那条支路有 ^\s*[⏵⏸]+ 把关,正文引用命中不了;只有 next try 这条是裸的。而且本机 182 个 composer pane 扫下来,next try 版与 next try in判定完全一致(0 处分歧),说明现实中还没撞上。建议顺手收紧,属于「只收紧不改现状」的零风险改动。

顺带说明:你测试里那条 expect(busy!.test('next try: please esc to interrupt yourself')).toBe(false) 之所以是绿的,是因为它缺 · 分隔,被 · 那一环挡住了,不是被 next try 挡住的——所以它没有覆盖到上面这三种形态。

N2(可选,测试覆盖):^ 行首锚没有用例守着。 我把 /^\s*…/m^ 去掉后全套测试仍然全绿(9 枪里唯一的一枪绿)。我插桩确认这不是惰性编辑——^ 有真实作用:

● 空闲态是 ⏵⏵ bypass permissions on (shift+tab) · esc to interrupt · 这样   有^=idle / 无^=BUSY
     | ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt …   有^=idle / 无^=BUSY

即「正文里带前缀引用一整行 footer」(贴日志、贴 review 引用、grep 输出带 file:line: 前缀)只被 ^ 挡住。这是覆盖缺口而非冗余代码,建议补一条负例把它钉住,否则后人「顺手简化」删掉 ^ 时没有任何测试会报警。

小结

阻断解除,我这边认为可以合入;N1/N2 是加固建议,不阻断。方向和落地都很干净,感谢按建议收紧锚点 🙏

以上仍是自动评审意见,最终以维护者审阅为准

@deepcoldy

Copy link
Copy Markdown
Owner

更正我上一条评论里的 N1 建议——我给的收紧方案不完整,复审时被指出,我实测确认了(结论不变:N1 仍是非阻断的加固项,但改法要换)。

我原话说「改回 next try in 这三条全部变 idle」,这句是错的。实测 next try in 只修掉 2/3:

next try · esc to interrupt                            next try→BUSY   next try in→idle ✓
on the next try you can use · esc to interrupt          next try→BUSY   next try in→idle ✓
next try in the docs · esc to interrupt is the hint     next try→BUSY   next try in→BUSY ✗ 仍误判

第三条本身包含 next try in,所以只补 in 挡不住。根因是这条支路没有任何结构约束mode on 支路有 ^\s*[⏵⏸]+ 字形把关,正文进不来;retry 支路是裸的)。

如果要收紧,建议按 retry footer 的真实结构来锚。二进制里那段是逐字拼的:

· next try in ${delay} · attempt ${n} · esc to interrupt

也就是「行首 · + next try in + · attempt + · esc to interrupt」四段俱全,正文很难凑齐:

const CLAUDE_BUSY_FOOTER_RE =
  /^\s*(?:[]+\s.*\bon\b|·\s*next try in\b.*·\s*attempt\b).*·\s*esc to interrupt\b/m;

实测 8/8 全对:真 retry footer、真 footer(bypass/manual 等 5 模式)全部命中;上面三条散文 + 我另造的 the next try in attempt 2 · esc to interrupt maybe(带 attempt 但非行首 ·)+ 空闲 footer 全部不误报。你现有的整套测试在这个改法下仍然 444/444 全绿(含你那条 · next try in 3s · attempt 2 · esc to interrupt 正例),所以是纯收紧、不动现有行为。

重申严重度没变,这条不阻断合入:主路径(mode on)受字形保护、只有罕见的 retry 路径裸着、本机 182 个 composer pane 扫下来两版判定零分歧(现实尚未撞上)。你可以顺手改,也可以留着后续处理。

抱歉上一条给了个只修 2/3 的方案,浪费你时间 🙏 其余结论(阻断已解除、可以合入、N2 补 ^ 负例)均不变。

@deepcoldy

Copy link
Copy Markdown
Owner

再更正一次我自己的 N1 改法——我上一条给的锚点会漏掉真实的 retry footer,这次是往「不安全」的方向错了,必须说清。结论仍不变:N1 是非阻断加固项。

我上一条建议锚「行首 · + next try in + · attempt」。我去读了 Dit 组件 low_priority_waiting 分支的完整渲染树,发现那一行不是以 · 开头的

// vt = <Box aria-hidden width={2}><Text color="warning">{PH}</Text></Box>   ← 2 列 glyph
// Ot = truncate(waitBanner, Do)                                            ← "Waiting for API response"
// An = ` · next try in ${ze} · attempt ${nt.attempt} · esc to interrupt`    ← 拼在后面

An 前面还有一个 width:2aria-hidden glyph 盒 + waitBanner 文本,所以实际渲染成:

✻ Waiting for API response · next try in 30s · attempt 2 · esc to interrupt

我那个「行首 ·」的锚点对这一行 MISS(只有 waitBanner 为 null、只剩 An 时才是 · next try in … 开头)。也就是说我上条建议会把真实重试态判成不忙——比你现在的 next try 更糟,因为它丢正证据。抱歉。

真正兼顾两者的写法是只要求 next try inattempt 各自被 · 分隔(不管前面有没有 banner/glyph):

const CLAUDE_BUSY_FOOTER_RE =
  /^\s*(?:[]+\s.*\bon\b.*|.*·\s*next try in\b[^\r\n]*·\s*attempt\b[^\r\n]*)·\s*esc to interrupt\b/m;

实测 10/10 全对

形态 期望 结果
✻ Waiting for API response · next try in 30s · attempt 2 · esc to interrupt BUSY
· next try in 30s · attempt 2 · esc to interrupt(banner=null) BUSY
真 footer(bypass / manual 等 5 模式) BUSY
next try · esc to interrupt idle
on the next try you can use · esc to interrupt idle
next try in the docs · esc to interrupt is the hint idle
the next try in attempt 2 · esc to interrupt maybe(无 · 分隔) idle
空闲 footer / 带前缀引用整行 footer idle

你现有的整套测试在这个改法下仍然 444/444 全绿(含你那条 · next try in 3s · attempt 2 · esc to interrupt 正例),所以依然是纯收紧。

再次重申严重度:N1 不阻断合入。主路径(mode on)有 ^\s*[⏵⏸]+ 字形把关,正文引用进不来;只有罕见的 retry 路径裸着;本机 182 个 composer pane 扫下来,你的版本与收紧版判定零分歧。你可以顺手改,也可以留着后续处理——只是别用我上一条那个「行首 ·」的写法。

两次给了不完整的方案,浪费时间,抱歉 🙏 其余结论(阻断已解除、可以合入、N2 补 ^ 负例)均不变。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

首审 + 复审双人独立验证:阻断已解除。

结构锚点修好了「裸短语被 CLI 自己的 transcript 命中 ⟹ idle probe 无上限重试 ⟹ 卡片钉死工作中且输出压住不发」这条回归。重造复现场景验证:本 head PASS,换回旧裸短语版阴性对照 FAIL(证探针非哑弹)。

反变异 9 枪 8 红(唯一绿枪已插桩归类为覆盖缺口而非死代码),合并态 bun run build 绿、444/444、CI 9/9 绿,merge-tree 预演 rc=0 且实际产出树 == 预测树。

N1(retry 支路可再收紧一档,两人各自实测且作者原测试仍全绿 ⟹ 纯收紧)与 N2(补 ^ 负例)是加固建议,不阻断,已在评论里留档。

@deepcoldy
deepcoldy merged commit 8880fce into deepcoldy:master Sep 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants